Compare commits

...

2 Commits

Author SHA1 Message Date
Eve
a87b3f7b58 fix(daemon): wire feature flag service in daemon process, not API server (MUL-3560)
PR #4449 wired `execenv.SetFeatureFlags(flags)` in cmd/server/main.go,
but the API server process never calls `execenv.InjectRuntimeConfig` —
the brief is generated by the daemon process (`multica daemon` from
cmd/multica/cmd_daemon.go). Result: setting FF_RUNTIME_BRIEF_SLIM=true
on the API server pod would have had zero effect, and the staging
rollout would have silently kept rendering the legacy brief.

Yushen caught this asking how to configure the flag in K8s; mapping
the answer to the actual code surfaced the bug.

Fix:

- cmd/multica/cmd_daemon.go: construct the feature flag service from
  the same `MULTICA_FEATURE_FLAGS_FILE` / `FF_<KEY>` env conventions
  and pass it to `execenv.SetFeatureFlags` right before `daemon.New`.
  Malformed rule file fails startup loudly, matching the DATABASE_URL
  parse-error precedent.
- cmd/server/main.go: revert the misplaced SetFeatureFlags call.
  Drop the execenv import (no longer referenced from the API server).
  Restore the prior `_ = flags` comment shape so future call sites
  reading the API server's flag service stay grepable.

Now to enable slim brief in staging:

1. Set `FF_RUNTIME_BRIEF_SLIM=true` (or `MULTICA_FEATURE_FLAGS_FILE`
   YAML rule) on **the daemon pods** (where `multica daemon` runs).
2. Restart the daemon pods (`kubectl rollout restart`).
3. Verify via the log line from the first commit on this PR
   (`brief_mode=slim`) or by `cat`-ing AGENTS.md / CLAUDE.md inside
   any active task workdir.

The API server pods do NOT need the flag set — they don't render
briefs.

Verification:

- go vet ./cmd/... ./internal/daemon/...                         ok
- go build ./...                                                 ok
- go test ./internal/daemon/...                                  ok
- go test ./cmd/multica/...                                      ok

Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 14:39:28 +08:00
Eve
4600e725e3 feat(daemon): log runtime brief mode + path on every task start (MUL-3560)
Yushen asked how to confirm which prompt template a task is given —
slim or legacy — once staging starts opting into `runtime_brief_slim`.
Two verification paths now exist:

1. File inspection (works today): the rendered brief lives in
   {workDir}/CLAUDE.md (claude/codebuddy), AGENTS.md (codex /
   copilot / opencode / openclaw / hermes / pi / cursor / kimi /
   kiro / qoder / antigravity), or GEMINI.md (gemini). Operators
   can `cat` the file to see the exact bytes the agent will load.

2. Structured daemon log (this PR): the daemon now logs one
   `execenv: runtime brief written` line per task start, carrying
   provider, brief_path (so you don't have to guess the filename),
   brief_chars (rendered rune count), and brief_mode (`slim` or
   `legacy`). Dashboards / log queries can filter by brief_mode to
   confirm the staging rollout is actually hitting the slim path
   without grepping every workdir.

What's in this PR:

- `internal/daemon/execenv/observability.go` (new): public
  `RuntimeConfigPath(workDir, provider) string` (thin wrapper
  around the private mapping) and `BriefMode() string` ("slim" or
  "legacy", nil-safe via the feature flag service).
- `internal/daemon/daemon.go`: one `taskLog.Info(...)` after
  `InjectRuntimeConfig` returns successfully. Failure path
  unchanged (still warns).
- `internal/daemon/execenv/observability_test.go` (new):
  TestRuntimeConfigPath pins the full 15-provider mapping;
  TestBriefMode verifies the label flips with the flag and that
  a nil service returns "legacy" rather than panicking.

Sample log line (staging, flag on, codex provider):

    INFO execenv: runtime brief written task=abc12345
        provider=codex
        brief_path=/var/multica/workspaces/.../task-abc/workdir/AGENTS.md
        brief_chars=11868
        brief_mode=slim

Same line on prod (flag off):

    INFO execenv: runtime brief written task=abc12345
        provider=codex
        brief_path=/var/multica/workspaces/.../task-abc/workdir/AGENTS.md
        brief_chars=19567
        brief_mode=legacy

Verification:

- go vet ./internal/daemon/...                                   ok
- go test ./internal/daemon/...                                  ok
- TestRuntimeConfigPath pins all 15 providers (including the
  fall-through to "" for unknown).
- TestBriefMode verifies both flag states + nil-safety.

Risk: very low. Adds one INFO log per task start (already a low-
frequency event by daemon standards), no behaviour change anywhere
in the brief generation or task execution path.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 14:29:04 +08:00
5 changed files with 136 additions and 6 deletions

View File

@@ -19,8 +19,10 @@ import (
"github.com/multica-ai/multica/server/internal/cli"
"github.com/multica-ai/multica/server/internal/daemon"
"github.com/multica-ai/multica/server/internal/daemon/execenv"
logger_pkg "github.com/multica-ai/multica/server/internal/logger"
"github.com/multica-ai/multica/server/internal/util"
"github.com/multica-ai/multica/server/pkg/featureflag"
)
var daemonCmd = &cobra.Command{
@@ -389,6 +391,23 @@ func runDaemonForeground(cmd *cobra.Command) error {
defer stop()
logger := logger_pkg.NewLogger("daemon")
// MUL-3560: wire the daemon's process-level feature flag service into
// execenv. The daemon is the process that calls
// execenv.InjectRuntimeConfig (the API server never does), so this is
// the toggle point that actually decides whether a given task gets the
// slim or legacy runtime brief. Reads the same env conventions as the
// API server: MULTICA_FEATURE_FLAGS_FILE for the YAML rule set,
// FF_<KEY> for per-flag env overrides (e.g. FF_RUNTIME_BRIEF_SLIM=true).
// nil-safe — a misconfigured rule file fails startup loudly, matching
// the DATABASE_URL parse-error precedent.
flags, err := featureflag.NewServiceFromEnv(featureflag.WithLogger(logger))
if err != nil {
logger.Error("feature flag configuration failed to load", "error", err)
return err
}
execenv.SetFeatureFlags(flags)
d := daemon.New(cfg, logger)
// Write PID file so "daemon stop" can find us.

View File

@@ -13,7 +13,6 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/daemon/execenv"
"github.com/multica-ai/multica/server/internal/daemonws"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/handler"
@@ -158,11 +157,7 @@ func main() {
slog.Error("feature flag configuration failed to load", "error", err)
os.Exit(1)
}
// MUL-3560: execenv consults `runtime_brief_slim` to decide between
// the legacy and slim runtime brief. Default-off everywhere; staging
// YAML opts in, prod stays on legacy until staging burns in.
execenv.SetFeatureFlags(flags)
_ = flags // remaining call sites adopt flags as needed; see docs/feature-flags.md
_ = flags // wired into handlers/services as call sites adopt flags; see docs/feature-flags.md
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {

View File

@@ -3455,6 +3455,19 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
runtimeBrief, err := execenv.InjectRuntimeConfig(env.WorkDir, provider, taskCtx)
if err != nil {
d.logger.Warn("execenv: inject runtime config failed (non-fatal)", "error", err)
} else {
// MUL-3560: log which brief shipped (slim vs legacy) and where
// it landed, so operators verifying the staging rollout can
// confirm at a glance which template the task was given — and
// `cat` the exact file at brief_path to see the rendered bytes.
// brief_chars is the rendered rune count; the brief_path file
// also carries the canonical content inside the marker block.
taskLog.Info("execenv: runtime brief written",
"provider", provider,
"brief_path", execenv.RuntimeConfigPath(env.WorkDir, provider),
"brief_chars", len(runtimeBrief),
"brief_mode", execenv.BriefMode(),
)
}
// Workdir is preserved for reuse by future tasks on the same (agent,
// issue) pair in cloud mode; the work_dir path is stored in DB on task

View File

@@ -0,0 +1,33 @@
package execenv
// RuntimeConfigPath returns the absolute path to the runtime-brief file
// that InjectRuntimeConfig writes for the given provider, or "" when the
// provider has no file-based config target.
//
// Daemon code uses this to log "where did the brief land" alongside the
// rendered char count, so an operator can `cat` the exact file to confirm
// which template a given task was given. The private runtimeConfigPath is
// the implementation; this is a stable export so the daemon does not have
// to thread the provider→filename table in a second place.
func RuntimeConfigPath(workDir, provider string) string {
return runtimeConfigPath(workDir, provider)
}
// BriefMode returns the human-readable label of the brief path that
// InjectRuntimeConfig would render right now: "slim" when the
// `runtime_brief_slim` feature flag evaluates to on, "legacy" otherwise.
//
// This is intended for daemon observability only — the brief mode is
// always derivable from the flag service, but a structured label keeps log
// queries cheap and lets dashboards filter by mode without re-implementing
// the toggle logic.
//
// Nil-safe by way of useSlimBrief (which falls through Service.IsEnabled's
// nil-safe path), so a daemon that forgot to wire SetFeatureFlags still
// produces a meaningful label ("legacy") rather than panicking.
func BriefMode() string {
if useSlimBrief() {
return "slim"
}
return "legacy"
}

View File

@@ -0,0 +1,70 @@
package execenv
import (
"testing"
"github.com/multica-ai/multica/server/pkg/featureflag"
)
// TestRuntimeConfigPath pins the provider→filename mapping the daemon
// log line relies on. If the mapping ever changes, the test catches it
// — operators expect to know exactly which file to `cat`.
func TestRuntimeConfigPath(t *testing.T) {
t.Parallel()
cases := []struct {
provider string
want string
}{
{"claude", "/work/CLAUDE.md"},
{"codebuddy", "/work/CLAUDE.md"},
{"codex", "/work/AGENTS.md"},
{"copilot", "/work/AGENTS.md"},
{"opencode", "/work/AGENTS.md"},
{"openclaw", "/work/AGENTS.md"},
{"hermes", "/work/AGENTS.md"},
{"pi", "/work/AGENTS.md"},
{"cursor", "/work/AGENTS.md"},
{"kimi", "/work/AGENTS.md"},
{"kiro", "/work/AGENTS.md"},
{"qoder", "/work/AGENTS.md"},
{"antigravity", "/work/AGENTS.md"},
{"gemini", "/work/GEMINI.md"},
{"totally-unknown", ""},
}
for _, tc := range cases {
if got := RuntimeConfigPath("/work", tc.provider); got != tc.want {
t.Errorf("RuntimeConfigPath(/work, %q) = %q, want %q", tc.provider, got, tc.want)
}
}
}
// TestBriefMode verifies the label flips with the feature flag. Nil-safe
// path returns "legacy" so a daemon that forgot to wire SetFeatureFlags
// emits a meaningful label, not panics.
func TestBriefMode(t *testing.T) {
// Not t.Parallel-safe because we mutate the package-level flag pointer.
saved := runtimeFlags.Load()
t.Cleanup(func() { runtimeFlags.Store(saved) })
// Nil service → legacy.
runtimeFlags.Store(nil)
if got := BriefMode(); got != "legacy" {
t.Errorf("BriefMode with nil service = %q, want %q", got, "legacy")
}
// Flag off → legacy.
off := featureflag.NewStaticProvider()
off.Set(runtimeBriefSlimFlag, featureflag.Rule{Default: false})
runtimeFlags.Store(featureflag.NewService(off))
if got := BriefMode(); got != "legacy" {
t.Errorf("BriefMode with flag off = %q, want %q", got, "legacy")
}
// Flag on → slim.
on := featureflag.NewStaticProvider()
on.Set(runtimeBriefSlimFlag, featureflag.Rule{Default: true})
runtimeFlags.Store(featureflag.NewService(on))
if got := BriefMode(); got != "slim" {
t.Errorf("BriefMode with flag on = %q, want %q", got, "slim")
}
}