From 4b61e93bfd2505b945bb5e5187cdda22cd61643f Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 13:07:54 +0800 Subject: [PATCH 1/8] refactor(harness): isolate acceptance command surface Move acceptance scenarios out of the mnemon-harness product command into a test-only mnemon-acceptance binary, and add command-boundary guardrails so product code does not regain acceptance semantics. Validation: go test ./harness/cmd/mnemon-harness ./harness/cmd/mnemon-acceptance ./harness/internal/coreguard; go build -o /tmp/mnemon-harness ./harness/cmd/mnemon-harness && go build -o /tmp/mnemon-hub ./harness/cmd/mnemon-hub && go build -o /tmp/mnemond ./harness/cmd/mnemond && go build -o /tmp/mnemon-acceptance ./harness/cmd/mnemon-acceptance --- Makefile | 3 +- .../acceptance.go | 9 +- .../acceptance_cluster_single_entrypoint.go | 2 +- ...ceptance_cluster_single_entrypoint_test.go | 0 .../acceptance_github_mesh.go | 2 +- .../acceptance_github_mesh_test.go | 0 .../acceptance_observe.go | 2 +- .../acceptance_observe_test.go | 2 +- .../acceptance_prod_sim.go | 2 +- .../acceptance_prod_sim_test.go | 0 .../acceptance_task_sim.go | 2 +- .../acceptance_test.go | 0 harness/cmd/mnemon-acceptance/root.go | 26 ++++++ harness/cmd/mnemon-acceptance/root_test.go | 25 ++++++ harness/cmd/mnemon-acceptance/sync_remote.go | 90 +++++++++++++++++++ harness/cmd/mnemon-harness/root_test.go | 12 +++ .../coreguard/acceptance_boundary_test.go | 72 +++++++++++++++ 17 files changed, 234 insertions(+), 15 deletions(-) rename harness/cmd/{mnemon-harness => mnemon-acceptance}/acceptance.go (99%) rename harness/cmd/{mnemon-harness => mnemon-acceptance}/acceptance_cluster_single_entrypoint.go (99%) rename harness/cmd/{mnemon-harness => mnemon-acceptance}/acceptance_cluster_single_entrypoint_test.go (100%) rename harness/cmd/{mnemon-harness => mnemon-acceptance}/acceptance_github_mesh.go (99%) rename harness/cmd/{mnemon-harness => mnemon-acceptance}/acceptance_github_mesh_test.go (100%) rename harness/cmd/{mnemon-harness => mnemon-acceptance}/acceptance_observe.go (99%) rename harness/cmd/{mnemon-harness => mnemon-acceptance}/acceptance_observe_test.go (99%) rename harness/cmd/{mnemon-harness => mnemon-acceptance}/acceptance_prod_sim.go (99%) rename harness/cmd/{mnemon-harness => mnemon-acceptance}/acceptance_prod_sim_test.go (100%) rename harness/cmd/{mnemon-harness => mnemon-acceptance}/acceptance_task_sim.go (99%) rename harness/cmd/{mnemon-harness => mnemon-acceptance}/acceptance_test.go (100%) create mode 100644 harness/cmd/mnemon-acceptance/root.go create mode 100644 harness/cmd/mnemon-acceptance/root_test.go create mode 100644 harness/cmd/mnemon-acceptance/sync_remote.go create mode 100644 harness/internal/coreguard/acceptance_boundary_test.go diff --git a/Makefile b/Makefile index d8370bb3..fcbc8032 100644 --- a/Makefile +++ b/Makefile @@ -22,10 +22,11 @@ deps: ## Download Go dependencies build: ## Build the mnemon binary go build -ldflags "$(LDFLAGS)" -o $(BINARY) . -harness-build: ## Build the harness binaries (mnemon-harness local plane + mnemon-hub remote hub + mnemond local governance daemon) +harness-build: ## Build the harness binaries (mnemon-harness local plane + mnemon-hub remote hub + mnemond local governance daemon + test-only acceptance runner) go build -ldflags "$(LDFLAGS)" -o mnemon-harness ./harness/cmd/mnemon-harness go build -ldflags "$(LDFLAGS)" -o mnemon-hub ./harness/cmd/mnemon-hub go build -ldflags "$(LDFLAGS)" -o mnemond ./harness/cmd/mnemond + go build -ldflags "$(LDFLAGS)" -o mnemon-acceptance ./harness/cmd/mnemon-acceptance # ── Install / Uninstall ───────────────────────────────────────────── diff --git a/harness/cmd/mnemon-harness/acceptance.go b/harness/cmd/mnemon-acceptance/acceptance.go similarity index 99% rename from harness/cmd/mnemon-harness/acceptance.go rename to harness/cmd/mnemon-acceptance/acceptance.go index 6a373a74..1a97aaf6 100644 --- a/harness/cmd/mnemon-harness/acceptance.go +++ b/harness/cmd/mnemon-acceptance/acceptance.go @@ -39,12 +39,6 @@ var ( acceptanceTurnTimeout time.Duration ) -var acceptanceCmd = &cobra.Command{ - Use: "acceptance", - Short: "Run hidden acceptance gates", - Hidden: true, -} - var acceptanceR1CodexCmd = &cobra.Command{ Use: "r1-codex", Short: "Run the R1 real Codex appserver acceptance gate", @@ -81,8 +75,7 @@ func init() { acceptanceR1CodexCmd.Flags().BoolVar(&acceptanceAgentTurns, "agent-turns", false, "run real model turns that write governed R1 events") acceptanceR1CodexCmd.Flags().BoolVar(&acceptanceSyncArm, "sync-arm", false, "run the 6B real sync/import arm after the local arm") acceptanceR1CodexCmd.Flags().DurationVar(&acceptanceTurnTimeout, "turn-timeout", 5*time.Minute, "timeout per real agent turn") - acceptanceCmd.AddCommand(acceptanceR1CodexCmd) - rootCmd.AddCommand(acceptanceCmd) + rootCmd.AddCommand(acceptanceR1CodexCmd) } type r1CodexAcceptanceOptions struct { diff --git a/harness/cmd/mnemon-harness/acceptance_cluster_single_entrypoint.go b/harness/cmd/mnemon-acceptance/acceptance_cluster_single_entrypoint.go similarity index 99% rename from harness/cmd/mnemon-harness/acceptance_cluster_single_entrypoint.go rename to harness/cmd/mnemon-acceptance/acceptance_cluster_single_entrypoint.go index eab35e5f..4e0e22c3 100644 --- a/harness/cmd/mnemon-harness/acceptance_cluster_single_entrypoint.go +++ b/harness/cmd/mnemon-acceptance/acceptance_cluster_single_entrypoint.go @@ -74,7 +74,7 @@ func init() { acceptanceR1ClusterSingleEntrypointCmd.Flags().IntVar(&acceptanceClusterWakeCycles, "wake-cycles", 4, "generic worker wake cycles") acceptanceR1ClusterSingleEntrypointCmd.Flags().DurationVar(&acceptanceClusterWakeInterval, "wake-interval", 3*time.Second, "delay between worker wake cycles") acceptanceR1ClusterSingleEntrypointCmd.Flags().StringVar(&acceptanceClusterEntrypoint, "entrypoint", "", "explicit entrypoint principal; empty chooses by seed") - acceptanceCmd.AddCommand(acceptanceR1ClusterSingleEntrypointCmd) + rootCmd.AddCommand(acceptanceR1ClusterSingleEntrypointCmd) } type r1ClusterSingleEntrypointOptions struct { diff --git a/harness/cmd/mnemon-harness/acceptance_cluster_single_entrypoint_test.go b/harness/cmd/mnemon-acceptance/acceptance_cluster_single_entrypoint_test.go similarity index 100% rename from harness/cmd/mnemon-harness/acceptance_cluster_single_entrypoint_test.go rename to harness/cmd/mnemon-acceptance/acceptance_cluster_single_entrypoint_test.go diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh.go b/harness/cmd/mnemon-acceptance/acceptance_github_mesh.go similarity index 99% rename from harness/cmd/mnemon-harness/acceptance_github_mesh.go rename to harness/cmd/mnemon-acceptance/acceptance_github_mesh.go index 4166f0fb..b13e16fc 100644 --- a/harness/cmd/mnemon-harness/acceptance_github_mesh.go +++ b/harness/cmd/mnemon-acceptance/acceptance_github_mesh.go @@ -81,7 +81,7 @@ func init() { acceptanceR1GitHubMeshCmd.Flags().StringVar(&acceptanceGitHubBranchPrefix, "github-branch-prefix", "", "GitHub publication branch prefix; empty uses a run-scoped mnemond id prefix") acceptanceR1GitHubMeshCmd.Flags().StringArrayVar(&acceptanceGitHubScenarios, "scenario", nil, "natural scenario to run; repeatable") acceptanceR1GitHubMeshCmd.Flags().DurationVar(&acceptanceGitHubSyncInterval, "sync-interval", 30*time.Second, "GitHub sync interval per local mnemond") - acceptanceCmd.AddCommand(acceptanceR1GitHubMeshCmd) + rootCmd.AddCommand(acceptanceR1GitHubMeshCmd) } type r1GitHubMeshAcceptanceOptions struct { diff --git a/harness/cmd/mnemon-harness/acceptance_github_mesh_test.go b/harness/cmd/mnemon-acceptance/acceptance_github_mesh_test.go similarity index 100% rename from harness/cmd/mnemon-harness/acceptance_github_mesh_test.go rename to harness/cmd/mnemon-acceptance/acceptance_github_mesh_test.go diff --git a/harness/cmd/mnemon-harness/acceptance_observe.go b/harness/cmd/mnemon-acceptance/acceptance_observe.go similarity index 99% rename from harness/cmd/mnemon-harness/acceptance_observe.go rename to harness/cmd/mnemon-acceptance/acceptance_observe.go index 063c12a3..388bd8e1 100644 --- a/harness/cmd/mnemon-harness/acceptance_observe.go +++ b/harness/cmd/mnemon-acceptance/acceptance_observe.go @@ -75,7 +75,7 @@ func init() { acceptanceObserveCmd.Flags().BoolVar(&acceptanceObserveWatch, "watch", false, "continue refreshing observation snapshots") acceptanceObserveCmd.Flags().DurationVar(&acceptanceObserveInterval, "interval", 2*time.Second, "watch refresh interval") acceptanceObserveCmd.Flags().BoolVar(&acceptanceObserveOnce, "once", false, "render one watch snapshot and exit") - acceptanceCmd.AddCommand(acceptanceObserveCmd) + rootCmd.AddCommand(acceptanceObserveCmd) } type acceptanceObserveReport struct { diff --git a/harness/cmd/mnemon-harness/acceptance_observe_test.go b/harness/cmd/mnemon-acceptance/acceptance_observe_test.go similarity index 99% rename from harness/cmd/mnemon-harness/acceptance_observe_test.go rename to harness/cmd/mnemon-acceptance/acceptance_observe_test.go index ea47ff0e..f26eb8c1 100644 --- a/harness/cmd/mnemon-harness/acceptance_observe_test.go +++ b/harness/cmd/mnemon-acceptance/acceptance_observe_test.go @@ -57,7 +57,7 @@ func TestObserveAcceptanceRunReadsMnemondAndHubEvents(t *testing.T) { func TestAcceptanceObservationUsesSingleCommandSurface(t *testing.T) { commands := map[string]bool{} - for _, cmd := range acceptanceCmd.Commands() { + for _, cmd := range rootCmd.Commands() { commands[cmd.Name()] = true } if !commands["observe"] { diff --git a/harness/cmd/mnemon-harness/acceptance_prod_sim.go b/harness/cmd/mnemon-acceptance/acceptance_prod_sim.go similarity index 99% rename from harness/cmd/mnemon-harness/acceptance_prod_sim.go rename to harness/cmd/mnemon-acceptance/acceptance_prod_sim.go index c7f15407..5ae14315 100644 --- a/harness/cmd/mnemon-harness/acceptance_prod_sim.go +++ b/harness/cmd/mnemon-acceptance/acceptance_prod_sim.go @@ -51,7 +51,7 @@ func init() { acceptanceR1ProdSimCmd.Flags().IntVar(&acceptanceAgents, "agents", 5, "number of Codex appservers") acceptanceR1ProdSimCmd.Flags().BoolVar(&acceptanceAgentTurns, "agent-turns", false, "run real model turns that write governed R1 production-like events") acceptanceR1ProdSimCmd.Flags().DurationVar(&acceptanceTurnTimeout, "turn-timeout", 5*time.Minute, "timeout per real agent turn") - acceptanceCmd.AddCommand(acceptanceR1ProdSimCmd) + rootCmd.AddCommand(acceptanceR1ProdSimCmd) } type r1ProdSimAcceptanceOptions struct { diff --git a/harness/cmd/mnemon-harness/acceptance_prod_sim_test.go b/harness/cmd/mnemon-acceptance/acceptance_prod_sim_test.go similarity index 100% rename from harness/cmd/mnemon-harness/acceptance_prod_sim_test.go rename to harness/cmd/mnemon-acceptance/acceptance_prod_sim_test.go diff --git a/harness/cmd/mnemon-harness/acceptance_task_sim.go b/harness/cmd/mnemon-acceptance/acceptance_task_sim.go similarity index 99% rename from harness/cmd/mnemon-harness/acceptance_task_sim.go rename to harness/cmd/mnemon-acceptance/acceptance_task_sim.go index 6aea9b19..8e861ad1 100644 --- a/harness/cmd/mnemon-harness/acceptance_task_sim.go +++ b/harness/cmd/mnemon-acceptance/acceptance_task_sim.go @@ -57,7 +57,7 @@ func init() { acceptanceR1TaskSimCmd.Flags().BoolVar(&acceptanceSyncArm, "sync-arm", false, "run the cross-workspace sync/import scenario") acceptanceR1TaskSimCmd.Flags().DurationVar(&acceptanceTurnTimeout, "turn-timeout", 5*time.Minute, "timeout per real agent turn") acceptanceR1TaskSimCmd.Flags().StringArrayVar(&acceptanceTaskSimScenarios, "scenario", nil, "scenario to run; repeatable") - acceptanceCmd.AddCommand(acceptanceR1TaskSimCmd) + rootCmd.AddCommand(acceptanceR1TaskSimCmd) } type r1TaskSimAcceptanceOptions struct { diff --git a/harness/cmd/mnemon-harness/acceptance_test.go b/harness/cmd/mnemon-acceptance/acceptance_test.go similarity index 100% rename from harness/cmd/mnemon-harness/acceptance_test.go rename to harness/cmd/mnemon-acceptance/acceptance_test.go diff --git a/harness/cmd/mnemon-acceptance/root.go b/harness/cmd/mnemon-acceptance/root.go new file mode 100644 index 00000000..9728863b --- /dev/null +++ b/harness/cmd/mnemon-acceptance/root.go @@ -0,0 +1,26 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var version = "dev" + +var rootCmd = &cobra.Command{ + Use: "mnemon-acceptance", + Version: version, + Short: "Mnemon test-only acceptance runner", + CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true}, + Long: "Run controlled Mnemon acceptance scenarios. This command is test-only: " + + "it starts, seeds, observes, and reports on product surfaces without defining runtime semantics.", +} + +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/harness/cmd/mnemon-acceptance/root_test.go b/harness/cmd/mnemon-acceptance/root_test.go new file mode 100644 index 00000000..9d28d61d --- /dev/null +++ b/harness/cmd/mnemon-acceptance/root_test.go @@ -0,0 +1,25 @@ +package main + +import "testing" + +func TestRootExposesAcceptanceScenarioCommands(t *testing.T) { + commands := map[string]bool{} + for _, cmd := range rootCmd.Commands() { + commands[cmd.Name()] = true + } + for _, want := range []string{ + "observe", + "r1-codex", + "r1-prod-sim", + "r1-task-sim", + "r1-cluster-single-entrypoint", + "r1-github-mesh-task-suite", + } { + if !commands[want] { + t.Fatalf("mnemon-acceptance missing command %q; commands=%v", want, commands) + } + } + if commands["acceptance"] { + t.Fatalf("mnemon-acceptance should expose scenarios directly, not under an acceptance parent") + } +} diff --git a/harness/cmd/mnemon-acceptance/sync_remote.go b/harness/cmd/mnemon-acceptance/sync_remote.go new file mode 100644 index 00000000..4a37723f --- /dev/null +++ b/harness/cmd/mnemon-acceptance/sync_remote.go @@ -0,0 +1,90 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" +) + +func upsertSyncRemote(path, root, id, backend, direction, endpoint, repo, branch, token, tokenFile, caFile string) error { + doc := exchange.RemotesDoc{SchemaVersion: 1} + if raw, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(raw))) > 0 { + if err := json.Unmarshal(raw, &doc); err != nil { + return fmt.Errorf("parse Remote Workspace config: %w", err) + } + if doc.SchemaVersion != 1 { + return fmt.Errorf("Remote Workspace config schema_version %d unsupported (want 1)", doc.SchemaVersion) + } + } else if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("read Remote Workspace config: %w", err) + } + credentialRef, err := syncCredentialRef(root, id, token, tokenFile) + if err != nil { + return err + } + entry := exchange.RemoteEntry{ + Backend: backend, + Direction: direction, + ID: id, + Endpoint: endpoint, + Repo: repo, + Branch: branch, + CredentialRef: credentialRef, + CAFile: normalizeSyncFileRef(caFile), + } + replaced := false + for i := range doc.Remotes { + if doc.Remotes[i].ID == id { + doc.Remotes[i] = entry + replaced = true + break + } + } + if !replaced { + doc.Remotes = append(doc.Remotes, entry) + } + doc.Current = id + data, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o644) +} + +func normalizeSyncFileRef(ref string) string { + ref = strings.TrimSpace(ref) + if ref == "" || filepath.IsAbs(ref) { + return ref + } + return filepath.ToSlash(filepath.Clean(ref)) +} + +func syncCredentialRef(root, id, token, tokenFile string) (string, error) { + token = strings.TrimSpace(token) + tokenFile = strings.TrimSpace(tokenFile) + if token != "" { + credentialRef := filepath.ToSlash(filepath.Join(".mnemon", "harness", "sync", "credentials", id+".token")) + path := filepath.Join(root, filepath.FromSlash(credentialRef)) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return "", err + } + if err := os.WriteFile(path, []byte(token+"\n"), 0o600); err != nil { + return "", err + } + return credentialRef, nil + } + if tokenFile == "" { + return "", fmt.Errorf("--token or --token-file is required") + } + if filepath.IsAbs(tokenFile) { + return tokenFile, nil + } + return filepath.ToSlash(filepath.Clean(tokenFile)), nil +} diff --git a/harness/cmd/mnemon-harness/root_test.go b/harness/cmd/mnemon-harness/root_test.go index 595bc7a7..d9d41da5 100644 --- a/harness/cmd/mnemon-harness/root_test.go +++ b/harness/cmd/mnemon-harness/root_test.go @@ -34,6 +34,18 @@ func TestRootHelpUsesLocalFirstProductSurface(t *testing.T) { } } +func TestRootDoesNotExposeAcceptanceCommands(t *testing.T) { + commands := map[string]bool{} + for _, cmd := range rootCmd.Commands() { + commands[cmd.Name()] = true + } + for _, blocked := range []string{"acceptance", "r1-codex", "r1-prod-sim", "r1-task-sim", "r1-github-mesh-task-suite"} { + if commands[blocked] { + t.Fatalf("mnemon-harness must not expose test-only acceptance command %q", blocked) + } + } +} + func TestProductHelpDoesNotExposeInternalVocabulary(t *testing.T) { for _, args := range [][]string{ {"setup", "--help"}, diff --git a/harness/internal/coreguard/acceptance_boundary_test.go b/harness/internal/coreguard/acceptance_boundary_test.go new file mode 100644 index 00000000..d0c39c69 --- /dev/null +++ b/harness/internal/coreguard/acceptance_boundary_test.go @@ -0,0 +1,72 @@ +package coreguard + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAcceptanceCommandIsIsolatedFromProductCommands(t *testing.T) { + productCmdDir := filepath.Join("..", "..", "cmd", "mnemon-harness") + entries, err := os.ReadDir(productCmdDir) + if err != nil { + t.Fatalf("read product command dir: %v", err) + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasPrefix(name, "acceptance") && strings.HasSuffix(name, ".go") { + t.Fatalf("test-only acceptance source %q must not live under mnemon-harness", name) + } + } + if !hasNonTestGoFiles(filepath.Join("..", "..", "cmd", "mnemon-acceptance")) { + t.Fatalf("mnemon-acceptance command must own acceptance scenarios") + } +} + +func TestProductCodeDoesNotImportAcceptanceCommand(t *testing.T) { + root := filepath.Join("..", "..") + for _, dir := range []string{ + filepath.Join(root, "internal"), + filepath.Join(root, "cmd", "mnemon-harness"), + filepath.Join(root, "cmd", "mnemond"), + filepath.Join(root, "cmd", "mnemon-hub"), + } { + assertNoAcceptanceCommandImports(t, dir) + } +} + +func assertNoAcceptanceCommandImports(t *testing.T, dir string) { + t.Helper() + err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".go") { + return nil + } + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly) + if err != nil { + return err + } + for _, imp := range file.Imports { + importPath := strings.Trim(imp.Path.Value, `"`) + if strings.Contains(importPath, "harness/cmd/mnemon-acceptance") { + t.Errorf("%s imports test-only acceptance command %q", path, importPath) + } + } + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", dir, err) + } +} From 61d188706a63df4e5a9dc40b82948c8264397f05 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 13:10:41 +0800 Subject: [PATCH 2/8] refactor(harness): make profile cues lifecycle-aware Replace unconditional profile rendering with a rule-derived lifecycle policy and soften derived teamwork cue wording so presentation surfaces facts and affordances instead of forcing event writes. Validation: go test ./harness/internal/mnemond/presentation; go test ./harness/internal/hostagent ./harness/internal/coreguard --- .../internal/mnemond/presentation/items.go | 35 ++++++++ .../mnemond/presentation/render_test.go | 71 +++++++++++++++- .../presentation/teamwork_presenter.go | 81 +++++++++++++++++-- 3 files changed, 178 insertions(+), 9 deletions(-) diff --git a/harness/internal/mnemond/presentation/items.go b/harness/internal/mnemond/presentation/items.go index 81ec8cbb..7278dbfa 100644 --- a/harness/internal/mnemond/presentation/items.go +++ b/harness/internal/mnemond/presentation/items.go @@ -45,6 +45,41 @@ func itemString(item map[string]any, key string) string { return "" } +func itemStringList(item map[string]any, key string) []string { + if out := stringList(item[key]); len(out) > 0 { + return out + } + for _, section := range []string{eventmodel.PayloadRuleKey, eventmodel.PayloadNarrativeKey, eventmodel.PayloadRefsKey} { + if m, ok := item[section].(map[string]any); ok { + if out := stringList(m[key]); len(out) > 0 { + return out + } + } + } + return nil +} + +func stringList(raw any) []string { + var out []string + switch v := raw.(type) { + case []string: + for _, s := range v { + if s = strings.TrimSpace(s); s != "" { + out = append(out, s) + } + } + case []any: + for _, item := range v { + if s, ok := item.(string); ok { + if s = strings.TrimSpace(s); s != "" { + out = append(out, s) + } + } + } + } + return out +} + func firstNonEmpty(item map[string]any, keys ...string) string { for _, key := range keys { if s := itemString(item, key); s != "" { diff --git a/harness/internal/mnemond/presentation/render_test.go b/harness/internal/mnemond/presentation/render_test.go index 852d7383..84dca534 100644 --- a/harness/internal/mnemond/presentation/render_test.go +++ b/harness/internal/mnemond/presentation/render_test.go @@ -104,8 +104,8 @@ func TestDeriveEventEnvelopesSeparateEventModelFromPresentation(t *testing.T) { }} envelopes := DeriveEventEnvelopes(reqB, proj, now) - if len(envelopes) != 3 { - t.Fatalf("expected profile/work/feedback derived envelopes, got %+v", envelopes) + if len(envelopes) != 2 { + t.Fatalf("expected work/feedback derived envelopes, got %+v", envelopes) } got := map[string]eventmodel.EventEnvelope{} for _, env := range envelopes { @@ -147,6 +147,64 @@ func TestDeriveEventEnvelopesSeparateEventModelFromPresentation(t *testing.T) { } } +func TestProfileCuePolicyFollowsLifecycle(t *testing.T) { + now := mustTime(t, "2026-06-24T10:00:00Z") + proj := view.View{Ref: "proj_profile", Digest: "digest_profile", Content: []view.ResourceContent{ + content("teamwork_signal", "project", []any{map[string]any{"id": "sig1", "statement": "Need teammate context"}}), + }} + + prime := DeriveEventEnvelopes(Request{Principal: "codex-a@project", Lifecycle: "prime", RenderIntent: IntentTeamworkEvents}, proj, now) + if _, ok := eventByType(prime, DerivedEventProfileUpdateRequested); !ok { + t.Fatalf("prime with missing profile should render a bounded profile cue: %+v", prime) + } + + remind := DeriveEventEnvelopes(Request{Principal: "codex-a@project", Lifecycle: "remind", RenderIntent: IntentTeamworkEvents}, proj, now) + if _, ok := eventByType(remind, DerivedEventProfileUpdateRequested); !ok { + t.Fatalf("remind with open teamwork signal should render a contextual profile cue: %+v", remind) + } + + workOnly := view.View{Ref: "proj_work", Digest: "digest_work", Content: []view.ResourceContent{ + content("assignment", "project", []any{map[string]any{ + "id": "asg1", "actor": "codex-b@project", "assignee": "codex-a@project", + "scope": "review render presentation", "expected_work": "review render presentation", + "ttl": "30m", "created_at": "2026-06-24T09:45:00Z", + }}), + }} + nudge := DeriveEventEnvelopes(Request{Principal: "codex-a@project", Lifecycle: "nudge", RenderIntent: IntentTeamworkEvents}, workOnly, now) + if _, ok := eventByType(nudge, DerivedEventProfileUpdateRequested); ok { + t.Fatalf("nudge should not render profile cue merely because profile is missing: %+v", nudge) + } + + changed := view.View{Ref: "proj_changed", Digest: "digest_changed", Content: []view.ResourceContent{ + content("progress_digest", "project", []any{map[string]any{ + "id": "pg1", "actor": "codex-a@project", "feedback_kind": "progress", + "changed_context": []any{"learned managed wake constraint"}, + }}), + }} + nudgeChanged := DeriveEventEnvelopes(Request{Principal: "codex-a@project", Lifecycle: "nudge", RenderIntent: IntentTeamworkEvents}, changed, now) + if _, ok := eventByType(nudgeChanged, DerivedEventProfileUpdateRequested); !ok { + t.Fatalf("nudge with structured changed_context should render profile cue: %+v", nudgeChanged) + } +} + +func TestDerivedCueTextAvoidsForcedActionWording(t *testing.T) { + now := mustTime(t, "2026-06-24T10:00:00Z") + proj := view.View{Ref: "proj_cues", Digest: "digest_cues", Content: []view.ResourceContent{ + content("teamwork_signal", "project", []any{map[string]any{"id": "sig1", "statement": "Need teammate context"}}), + content("assignment", "project", []any{map[string]any{ + "id": "asg-exp", "actor": "codex-a@project", "assignee": "codex-b@project", + "scope": "review overdue work", "expected_work": "review overdue work", + "ttl": "30m", "created_at": "2026-06-24T09:00:00Z", + }}), + }} + body := PresentEventEnvelopes(DeriveEventEnvelopes(Request{Principal: "codex-a@project", Lifecycle: "remind", RenderIntent: IntentTeamworkEvents}, proj, now)) + for _, forced := range []string{"Update your agent_profile", "Decide whether", "Start a new act", "emit progress_digest"} { + if strings.Contains(body, forced) { + t.Fatalf("derived cue body should not force action with %q:\n%s", forced, body) + } + } +} + func TestRenderPresentationExpiredOnlyForOriginator(t *testing.T) { now := mustTime(t, "2026-06-24T10:00:00Z") proj := view.View{Ref: "proj_expired", Digest: "digest_expired", Content: []view.ResourceContent{ @@ -266,6 +324,15 @@ func bytesTrimSpace(in []byte) []byte { return []byte(strings.TrimSpace(string(in))) } +func eventByType(events []eventmodel.EventEnvelope, eventType string) (eventmodel.EventEnvelope, bool) { + for _, event := range events { + if event.Event.Type == eventType { + return event, true + } + } + return eventmodel.EventEnvelope{}, false +} + func content(kind, id string, items []any) view.ResourceContent { return contentWithFields(kind, id, map[string]any{"items": items}) } diff --git a/harness/internal/mnemond/presentation/teamwork_presenter.go b/harness/internal/mnemond/presentation/teamwork_presenter.go index 1d9f438a..b7b27dce 100644 --- a/harness/internal/mnemond/presentation/teamwork_presenter.go +++ b/harness/internal/mnemond/presentation/teamwork_presenter.go @@ -60,12 +60,12 @@ func DeriveEventEnvelopes(req Request, proj view.View, now time.Time) []eventmod events = append(events, eventmodel.DerivedEnvelope(model, derivedAt, expiresAt, presentationHintForDerivedEventType(eventType), suggested)) } - if profileStaleOrMissing(items["agent_profile"], principal) { + if shouldRenderProfileCue(req, items, principal, now) { appendDerived( DerivedEventProfileUpdateRequested, "agent_profile/project", nil, - "Update your agent_profile if your focus, availability, or context advantages changed.", + "Your agent_profile is missing or stale. If your focus, availability, context advantages, or active scopes changed, you may record an agent_profile update.", []string{"agent_profile.write_candidate.observed"}, ) } @@ -81,7 +81,7 @@ func DeriveEventEnvelopes(req Request, proj view.View, now time.Time) []eventmod DerivedEventTeamworkSignalOpen, subject, []string{subject}, - fmt.Sprintf("Teamwork signal is open: %s. Decide whether to self-assign or assign a suited teammate.", statement), + fmt.Sprintf("Teamwork signal is open: %s. Assignment or self-assignment may be useful when you choose to act.", statement), []string{"assignment.write_candidate.observed"}, ) } @@ -108,7 +108,7 @@ func DeriveEventEnvelopes(req Request, proj view.View, now time.Time) []eventmod DerivedEventAssignmentExpired, subject, []string{subject}, - fmt.Sprintf("Assignment %s expired without progress: %s. Start a new act: renew, reassign, split, close, or escalate.", id, scope), + fmt.Sprintf("Assignment %s expired without progress: %s. Available follow-up options include renew, reassign, split, close, or escalate.", id, scope), []string{"assignment.write_candidate.observed", "teamwork_signal.write_candidate.observed"}, ) case owner == principal && len(linked) > 0: @@ -131,7 +131,7 @@ func DeriveEventEnvelopes(req Request, proj view.View, now time.Time) []eventmod DerivedEventAssignmentFeedbackNeeded, subject, []string{subject}, - fmt.Sprintf("When you have progress or a blocker for assignment %s, emit progress_digest with assignment_ref=%s.", id, id), + fmt.Sprintf("Progress or blocker feedback for assignment %s can be recorded as progress_digest with assignment_ref=%s when useful.", id, id), []string{"progress_digest.write_candidate.observed"}, ) } @@ -185,16 +185,83 @@ func teamworkItems(proj view.View) map[string][]map[string]any { return out } -func profileStaleOrMissing(profiles []map[string]any, principal string) bool { +func shouldRenderProfileCue(req Request, items map[string][]map[string]any, principal string, now time.Time) bool { + if !profileStaleOrMissing(items["agent_profile"], principal, now) { + return false + } + switch req.RenderIntent { + case IntentProfileEvents: + return true + case IntentTeamworkEvents: + switch strings.TrimSpace(req.Lifecycle) { + case "prime", "compact": + return true + case "remind": + return profileRelevantForCollaboration(items, principal, now) + case "nudge": + return profileRelevantForRecentChange(items, principal) + default: + return false + } + default: + return false + } +} + +func profileStaleOrMissing(profiles []map[string]any, principal string, now time.Time) bool { for _, p := range profiles { if itemString(p, "actor") != principal { continue } - return itemString(p, "freshness") == "stale" + return itemString(p, "freshness") == "stale" || profileTTLExpired(p, now) } return true } +func profileTTLExpired(profile map[string]any, now time.Time) bool { + if now.IsZero() { + return false + } + created, err := time.Parse(time.RFC3339, itemString(profile, "created_at")) + if err != nil { + return false + } + ttl, err := time.ParseDuration(itemString(profile, "ttl")) + if err != nil || ttl <= 0 { + return false + } + return now.After(created.Add(ttl)) +} + +func profileRelevantForCollaboration(items map[string][]map[string]any, principal string, now time.Time) bool { + for _, signal := range items["teamwork_signal"] { + if itemString(signal, "statement") != "" { + return true + } + } + for _, assignment := range items["assignment"] { + if itemString(assignment, "actor") != principal { + continue + } + if assignmentExpired(assignment, now) { + return true + } + } + return false +} + +func profileRelevantForRecentChange(items map[string][]map[string]any, principal string) bool { + for _, progress := range items["progress_digest"] { + if itemString(progress, "actor") != principal { + continue + } + if itemString(progress, "blocker") != "" || len(itemStringList(progress, "changed_context")) > 0 { + return true + } + } + return false +} + func assignmentExpired(item map[string]any, now time.Time) bool { created, err := time.Parse(time.RFC3339, itemString(item, "created_at")) if err != nil { From 8c2ffa67006462edf3ed6887f0a8010f05cdb6a0 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 13:13:48 +0800 Subject: [PATCH 3/8] feat(harness): add managed wake contract Add a local managed-agent driver contract that sends only the [mnemon:wake] sentinel, records wake attempts, rejects non-local principals, and exposes a minimal mnemond agent run front door for contract validation. Validation: go test ./harness/internal/driver ./harness/cmd/mnemond ./harness/internal/coreguard --- harness/cmd/mnemond/agent.go | 78 +++++++ harness/cmd/mnemond/agent_test.go | 40 ++++ harness/cmd/mnemond/main.go | 2 + harness/internal/driver/managed_agent.go | 197 ++++++++++++++++++ harness/internal/driver/managed_agent_test.go | 118 +++++++++++ 5 files changed, 435 insertions(+) create mode 100644 harness/cmd/mnemond/agent.go create mode 100644 harness/cmd/mnemond/agent_test.go create mode 100644 harness/internal/driver/managed_agent.go create mode 100644 harness/internal/driver/managed_agent_test.go diff --git a/harness/cmd/mnemond/agent.go b/harness/cmd/mnemond/agent.go new file mode 100644 index 00000000..b828011b --- /dev/null +++ b/harness/cmd/mnemond/agent.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "strings" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/driver" +) + +func runAgent(ctx context.Context, args []string, out, errw io.Writer) error { + if len(args) == 0 { + return fmt.Errorf("agent requires a subcommand") + } + switch args[0] { + case "run": + return runAgentRun(ctx, args[1:], out, errw) + default: + return fmt.Errorf("unknown agent subcommand %q", args[0]) + } +} + +func runAgentRun(ctx context.Context, args []string, out, errw io.Writer) error { + fs := flag.NewFlagSet("mnemond agent run", flag.ContinueOnError) + fs.SetOutput(errw) + principal := fs.String("principal", "", "local managed agent principal") + runtimeName := fs.String("runtime", "noop", "managed runtime adapter (noop)") + dryRun := fs.Bool("dry-run", false, "print the managed wake query without starting a runtime turn") + cooldown := fs.Duration("cooldown", 0, "minimum delay between managed wakes") + if err := fs.Parse(args); err != nil { + return err + } + if strings.TrimSpace(*principal) == "" { + return fmt.Errorf("mnemond agent run requires --principal") + } + if *dryRun { + fmt.Fprintln(out, driver.ManagedWakeQuery) + return nil + } + if strings.TrimSpace(*runtimeName) != "noop" { + return fmt.Errorf("runtime %q is not supported yet; use --runtime noop for contract validation", *runtimeName) + } + managed := &driver.ManagedAgentDriver{ + Principal: strings.TrimSpace(*principal), + Client: noopManagedTurnClient{}, + Ledger: driver.NewMemoryManagedWakeLedger(), + Cooldown: *cooldown, + Now: func() time.Time { return time.Now().UTC() }, + } + record, err := managed.Wake(ctx, driver.ManagedWakeCandidate{ + Principal: strings.TrimSpace(*principal), + DerivedEventID: "manual:agent-run", + BodyDigest: "sha256:manual", + Reason: "manual", + }) + if err != nil { + return err + } + raw, err := json.MarshalIndent(record, "", " ") + if err != nil { + return err + } + fmt.Fprintln(out, string(raw)) + return nil +} + +type noopManagedTurnClient struct{} + +func (noopManagedTurnClient) StartTurn(_ context.Context, query string) (driver.ManagedTurnResult, error) { + if query != driver.ManagedWakeQuery { + return driver.ManagedTurnResult{}, fmt.Errorf("unexpected managed wake query %q", query) + } + return driver.ManagedTurnResult{TurnID: "noop-turn", Status: "completed"}, nil +} diff --git a/harness/cmd/mnemond/agent_test.go b/harness/cmd/mnemond/agent_test.go new file mode 100644 index 00000000..6bc97585 --- /dev/null +++ b/harness/cmd/mnemond/agent_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/driver" +) + +func TestAgentRunDryRunPrintsSentinelOnly(t *testing.T) { + var out, errw bytes.Buffer + err := run(context.Background(), []string{"agent", "run", "--principal", "codex-a@project", "--dry-run"}, &out, &errw) + if err != nil { + t.Fatalf("agent run dry-run: %v\nstderr=%s", err, errw.String()) + } + if got := strings.TrimSpace(out.String()); got != driver.ManagedWakeQuery { + t.Fatalf("dry-run output = %q, want %q", got, driver.ManagedWakeQuery) + } +} + +func TestAgentRunRequiresPrincipal(t *testing.T) { + var out, errw bytes.Buffer + err := run(context.Background(), []string{"agent", "run", "--dry-run"}, &out, &errw) + if err == nil || !strings.Contains(err.Error(), "--principal") { + t.Fatalf("missing principal should fail clearly, got err=%v out=%q stderr=%q", err, out.String(), errw.String()) + } +} + +func TestAgentRunNoopRecordsSentinelQuery(t *testing.T) { + var out, errw bytes.Buffer + err := run(context.Background(), []string{"agent", "run", "--principal", "codex-a@project", "--runtime", "noop"}, &out, &errw) + if err != nil { + t.Fatalf("agent run noop: %v\nstderr=%s", err, errw.String()) + } + if !strings.Contains(out.String(), `"query": "`+driver.ManagedWakeQuery+`"`) { + t.Fatalf("noop run should report sentinel query only:\n%s", out.String()) + } +} diff --git a/harness/cmd/mnemond/main.go b/harness/cmd/mnemond/main.go index 7e9e5da1..9a2f2c98 100644 --- a/harness/cmd/mnemond/main.go +++ b/harness/cmd/mnemond/main.go @@ -50,6 +50,8 @@ func run(ctx context.Context, args []string, out, errw io.Writer) error { return daemonStatus(args[1:], out, errw) case "logs": return daemonLogs(args[1:], out, errw) + case "agent": + return runAgent(ctx, args[1:], out, errw) case "serve": args = args[1:] } diff --git a/harness/internal/driver/managed_agent.go b/harness/internal/driver/managed_agent.go new file mode 100644 index 00000000..db8dcce2 --- /dev/null +++ b/harness/internal/driver/managed_agent.go @@ -0,0 +1,197 @@ +package driver + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "sync" + "time" + + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" +) + +const ManagedWakeQuery = "[mnemon:wake]" + +type ManagedTurnClient interface { + StartTurn(context.Context, string) (ManagedTurnResult, error) +} + +type ManagedTurnResult struct { + TurnID string + Status string +} + +type ManagedWakeCandidate struct { + Principal string + DerivedEventID string + BodyDigest string + Reason string +} + +type ManagedWakeRecord struct { + Principal string `json:"principal"` + DerivedEventID string `json:"derived_event_id"` + BodyDigest string `json:"body_digest"` + Reason string `json:"reason"` + Query string `json:"query"` + TurnID string `json:"turn_id,omitempty"` + Status string `json:"status"` + StartedAt time.Time `json:"started_at"` + Error string `json:"error,omitempty"` +} + +type ManagedWakeLedger interface { + Seen(ManagedWakeCandidate) bool + Record(ManagedWakeRecord) error +} + +type MemoryManagedWakeLedger struct { + mu sync.Mutex + seen map[string]ManagedWakeRecord +} + +func NewMemoryManagedWakeLedger() *MemoryManagedWakeLedger { + return &MemoryManagedWakeLedger{seen: map[string]ManagedWakeRecord{}} +} + +func (l *MemoryManagedWakeLedger) Seen(candidate ManagedWakeCandidate) bool { + l.mu.Lock() + defer l.mu.Unlock() + _, ok := l.seen[managedWakeKey(candidate)] + return ok +} + +func (l *MemoryManagedWakeLedger) Record(record ManagedWakeRecord) error { + l.mu.Lock() + defer l.mu.Unlock() + l.seen[managedWakeKey(ManagedWakeCandidate{ + Principal: record.Principal, + DerivedEventID: record.DerivedEventID, + BodyDigest: record.BodyDigest, + })] = record + return nil +} + +type ManagedAgentDriver struct { + Principal string + Client ManagedTurnClient + Ledger ManagedWakeLedger + Cooldown time.Duration + Now func() time.Time + + mu sync.Mutex + inFlight bool + lastWake time.Time +} + +func (d *ManagedAgentDriver) Wake(ctx context.Context, candidate ManagedWakeCandidate) (ManagedWakeRecord, error) { + if d.Principal == "" { + return ManagedWakeRecord{}, fmt.Errorf("managed agent driver requires a principal") + } + if candidate.Principal != d.Principal { + return ManagedWakeRecord{}, fmt.Errorf("wake candidate principal %q does not match local principal %q", candidate.Principal, d.Principal) + } + if candidate.DerivedEventID == "" || candidate.BodyDigest == "" { + return ManagedWakeRecord{}, fmt.Errorf("wake candidate requires derived event id and body digest") + } + if d.Client == nil { + return ManagedWakeRecord{}, fmt.Errorf("managed agent driver requires a turn client") + } + ledger := d.Ledger + if ledger == nil { + ledger = NewMemoryManagedWakeLedger() + d.Ledger = ledger + } + now := d.now() + d.mu.Lock() + if d.inFlight { + d.mu.Unlock() + return ManagedWakeRecord{}, fmt.Errorf("managed wake already in flight") + } + if d.Cooldown > 0 && !d.lastWake.IsZero() && now.Sub(d.lastWake) < d.Cooldown { + d.mu.Unlock() + return ManagedWakeRecord{}, fmt.Errorf("managed wake cooldown active") + } + if ledger.Seen(candidate) { + d.mu.Unlock() + return ManagedWakeRecord{}, fmt.Errorf("managed wake candidate already handled") + } + d.inFlight = true + d.mu.Unlock() + + record := ManagedWakeRecord{ + Principal: candidate.Principal, + DerivedEventID: candidate.DerivedEventID, + BodyDigest: candidate.BodyDigest, + Reason: candidate.Reason, + Query: ManagedWakeQuery, + StartedAt: now, + Status: "started", + } + result, err := d.Client.StartTurn(ctx, ManagedWakeQuery) + if err != nil { + record.Status = "failed" + record.Error = err.Error() + _ = ledger.Record(record) + d.clearInFlight() + return record, err + } + record.TurnID = result.TurnID + record.Status = result.Status + if record.Status == "" { + record.Status = "completed" + } + if err := ledger.Record(record); err != nil { + d.clearInFlight() + return record, err + } + d.mu.Lock() + d.lastWake = now + d.inFlight = false + d.mu.Unlock() + return record, nil +} + +func (d *ManagedAgentDriver) clearInFlight() { + d.mu.Lock() + d.inFlight = false + d.mu.Unlock() +} + +func (d *ManagedAgentDriver) now() time.Time { + if d.Now != nil { + return d.Now().UTC() + } + return time.Now().UTC() +} + +func ManagedWakeCandidatesFromEvents(principal string, events []eventmodel.EventEnvelope) []ManagedWakeCandidate { + var out []ManagedWakeCandidate + for _, env := range events { + if string(env.Event.Audience) != principal { + continue + } + reason, _ := env.Meta["presentation_hint"].(string) + if reason == "" { + continue + } + body, _ := eventmodel.PayloadNarrative(env.Event.Payload)["body"].(string) + out = append(out, ManagedWakeCandidate{ + Principal: principal, + DerivedEventID: env.Event.ID, + BodyDigest: managedBodyDigest(body), + Reason: reason, + }) + } + return out +} + +func managedBodyDigest(body string) string { + sum := sha256.Sum256([]byte(body)) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func managedWakeKey(candidate ManagedWakeCandidate) string { + return candidate.Principal + "|" + candidate.DerivedEventID + "|" + candidate.BodyDigest +} diff --git a/harness/internal/driver/managed_agent_test.go b/harness/internal/driver/managed_agent_test.go new file mode 100644 index 00000000..9ac75688 --- /dev/null +++ b/harness/internal/driver/managed_agent_test.go @@ -0,0 +1,118 @@ +package driver + +import ( + "context" + "errors" + "testing" + "time" + + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" +) + +type managedTurnClientFunc func(context.Context, string) (ManagedTurnResult, error) + +func (f managedTurnClientFunc) StartTurn(ctx context.Context, query string) (ManagedTurnResult, error) { + return f(ctx, query) +} + +func TestManagedAgentDriverUsesSentinelOnly(t *testing.T) { + var gotQuery string + driver := &ManagedAgentDriver{ + Principal: "codex-a@project", + Client: managedTurnClientFunc(func(_ context.Context, query string) (ManagedTurnResult, error) { + gotQuery = query + return ManagedTurnResult{TurnID: "turn-1", Status: "completed"}, nil + }), + Ledger: NewMemoryManagedWakeLedger(), + Now: func() time.Time { return time.Unix(100, 0).UTC() }, + } + record, err := driver.Wake(context.Background(), ManagedWakeCandidate{ + Principal: "codex-a@project", + DerivedEventID: "derived:assignment.work_available:assignment/asg1:codex-a@project", + BodyDigest: "sha256:body", + Reason: "work", + }) + if err != nil { + t.Fatal(err) + } + if gotQuery != ManagedWakeQuery || record.Query != ManagedWakeQuery { + t.Fatalf("managed wake query = %q / %q, want %q", gotQuery, record.Query, ManagedWakeQuery) + } +} + +func TestManagedAgentDriverRejectsRemotePrincipal(t *testing.T) { + driver := &ManagedAgentDriver{ + Principal: "codex-a@project", + Client: managedTurnClientFunc(func(context.Context, string) (ManagedTurnResult, error) { return ManagedTurnResult{}, nil }), + Ledger: NewMemoryManagedWakeLedger(), + } + if _, err := driver.Wake(context.Background(), ManagedWakeCandidate{Principal: "codex-b@project", DerivedEventID: "d1", BodyDigest: "sha256:x"}); err == nil { + t.Fatal("managed driver must reject candidates for non-local principals") + } +} + +func TestManagedAgentDriverDedupesAndCoolsDown(t *testing.T) { + now := time.Unix(100, 0).UTC() + driver := &ManagedAgentDriver{ + Principal: "codex-a@project", + Client: managedTurnClientFunc(func(context.Context, string) (ManagedTurnResult, error) { return ManagedTurnResult{}, nil }), + Ledger: NewMemoryManagedWakeLedger(), + Cooldown: time.Minute, + Now: func() time.Time { return now }, + } + candidate := ManagedWakeCandidate{Principal: "codex-a@project", DerivedEventID: "d1", BodyDigest: "sha256:x"} + if _, err := driver.Wake(context.Background(), candidate); err != nil { + t.Fatal(err) + } + if _, err := driver.Wake(context.Background(), candidate); err == nil { + t.Fatal("duplicate candidate should not wake twice") + } + now = now.Add(10 * time.Second) + if _, err := driver.Wake(context.Background(), ManagedWakeCandidate{Principal: "codex-a@project", DerivedEventID: "d2", BodyDigest: "sha256:y"}); err == nil { + t.Fatal("cooldown should block a different rapid wake") + } +} + +func TestManagedAgentDriverRecordsFailureWithoutChangingQuery(t *testing.T) { + ledger := NewMemoryManagedWakeLedger() + driver := &ManagedAgentDriver{ + Principal: "codex-a@project", + Client: managedTurnClientFunc(func(_ context.Context, query string) (ManagedTurnResult, error) { + if query != ManagedWakeQuery { + t.Fatalf("query = %q, want %q", query, ManagedWakeQuery) + } + return ManagedTurnResult{}, errors.New("runtime unavailable") + }), + Ledger: ledger, + } + candidate := ManagedWakeCandidate{Principal: "codex-a@project", DerivedEventID: "d1", BodyDigest: "sha256:x"} + record, err := driver.Wake(context.Background(), candidate) + if err == nil { + t.Fatal("wake should surface runtime failure") + } + if record.Status != "failed" || record.Query != ManagedWakeQuery { + t.Fatalf("failed record mismatch: %+v", record) + } + if !ledger.Seen(candidate) { + t.Fatal("failure should be recorded locally for audit/idempotence") + } +} + +func TestManagedWakeCandidatesFromDerivedEvents(t *testing.T) { + env := eventmodel.DerivedEnvelope(eventmodel.Event{ + SchemaVersion: eventmodel.SchemaVersion, + ID: "derived:work", + Type: "assignment.work_available", + Subject: "assignment/asg1", + Actor: "mnemond@local", + Audience: "codex-a@project", + Payload: eventmodel.BuildPayload(nil, map[string]any{"body": "Assignment asg1 is yours."}, nil), + }, "2026-06-24T10:00:00Z", "2026-06-24T10:05:00Z", "work", nil) + candidates := ManagedWakeCandidatesFromEvents("codex-a@project", []eventmodel.EventEnvelope{env}) + if len(candidates) != 1 { + t.Fatalf("candidates = %+v, want one", candidates) + } + if candidates[0].Reason != "work" || candidates[0].BodyDigest == "" { + t.Fatalf("candidate should carry reason and body digest: %+v", candidates[0]) + } +} From a0ee652eec82106610262a77edc88ba10847f605 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 13:20:32 +0800 Subject: [PATCH 4/8] test(harness): add managed runtime acceptance contract Add a mnemon-acceptance managed-runtime scenario shell that records seed-and-observe report metadata, asserts raw managed wakes stay at [mnemon:wake], and blocks real GitHub runs unless a token file is supplied. Validation: go test ./harness/cmd/mnemon-acceptance --- .../acceptance_managed_runtime.go | 267 ++++++++++++++++++ .../acceptance_managed_runtime_test.go | 84 ++++++ 2 files changed, 351 insertions(+) create mode 100644 harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go create mode 100644 harness/cmd/mnemon-acceptance/acceptance_managed_runtime_test.go diff --git a/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go new file mode 100644 index 00000000..33563565 --- /dev/null +++ b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go @@ -0,0 +1,267 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/driver" + "github.com/spf13/cobra" +) + +var ( + acceptanceManagedExchange string + acceptanceManagedMnemondBin string + acceptanceManagedGitHubRepo string + acceptanceManagedGitHubTokenFile string +) + +var acceptanceManagedRuntimeCmd = &cobra.Command{ + Use: "managed-runtime", + Short: "Run managed-runtime seed-and-observe acceptance", + RunE: func(cmd *cobra.Command, args []string) error { + report, err := runManagedRuntimeAcceptance(cmd.Context(), managedRuntimeAcceptanceOptions{ + RunRoot: acceptanceRunRoot, + Agents: acceptanceAgents, + Exchange: acceptanceManagedExchange, + MnemondBin: acceptanceManagedMnemondBin, + GitHubRepo: acceptanceManagedGitHubRepo, + GitHubTokenFile: acceptanceManagedGitHubTokenFile, + TurnTimeout: acceptanceTurnTimeout, + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), + Wake: managedRuntimeMnemondDryRunWake, + }) + if report.ReportPath != "" { + fmt.Fprintf(cmd.OutOrStdout(), "acceptance report: %s\n", report.ReportPath) + } + if err != nil { + return err + } + if report.Status != "ok" { + return fmt.Errorf("managed-runtime acceptance status: %s", report.Status) + } + return nil + }, +} + +func init() { + acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceRunRoot, "run-root", "", "acceptance run directory") + acceptanceManagedRuntimeCmd.Flags().IntVar(&acceptanceAgents, "agents", 5, "number of managed agent nodes") + acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedExchange, "exchange", "mnemonhub", "exchange mode: mnemonhub or github") + acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedMnemondBin, "mnemond-bin", "mnemond", "mnemond binary used for product-path wake checks") + acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedGitHubRepo, "github-repo", "mnemon-dev/mnemon-teamwork-example", "GitHub Remote Workspace repository (owner/name)") + acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedGitHubTokenFile, "github-token-file", "", "GitHub token file for real GitHub exchange validation") + acceptanceManagedRuntimeCmd.Flags().DurationVar(&acceptanceTurnTimeout, "turn-timeout", 5*time.Minute, "timeout per managed wake check") + rootCmd.AddCommand(acceptanceManagedRuntimeCmd) +} + +type managedRuntimeAcceptanceOptions struct { + RunRoot string + Agents int + Exchange string + MnemondBin string + GitHubRepo string + GitHubTokenFile string + TurnTimeout time.Duration + Stdout io.Writer + Stderr io.Writer + Wake managedRuntimeWakeFunc +} + +type managedRuntimeWakeFunc func(context.Context, managedRuntimeAcceptanceOptions, string) (string, error) + +type managedRuntimeAcceptanceReport struct { + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` + Layer string `json:"layer"` + RunnerRole string `json:"runner_role"` + Exchange string `json:"exchange"` + GitHubRepo string `json:"github_repo,omitempty"` + StartedAt string `json:"started_at"` + FinishedAt string `json:"finished_at"` + RunRoot string `json:"run_root"` + ReportPath string `json:"report_path"` + Agents []managedRuntimeAgentReport `json:"agents"` + Assertions []managedRuntimeAssertion `json:"assertions"` + PromptAudit []managedRuntimePromptAuditEntry `json:"prompt_audit"` + Errors []string `json:"errors,omitempty"` +} + +type managedRuntimeAgentReport struct { + Principal string `json:"principal"` + RawQuery string `json:"raw_query"` + Status string `json:"status"` +} + +type managedRuntimePromptAuditEntry struct { + Principal string `json:"principal"` + Kind string `json:"kind"` + Query string `json:"query"` +} + +type managedRuntimeAssertion struct { + Name string `json:"name"` + Passed bool `json:"passed"` + Detail string `json:"detail,omitempty"` +} + +func runManagedRuntimeAcceptance(ctx context.Context, opts managedRuntimeAcceptanceOptions) (managedRuntimeAcceptanceReport, error) { + started := time.Now().UTC() + if opts.Stdout == nil { + opts.Stdout = io.Discard + } + if opts.Stderr == nil { + opts.Stderr = io.Discard + } + if opts.Wake == nil { + opts.Wake = managedRuntimeMnemondDryRunWake + } + if opts.Agents <= 0 { + opts.Agents = 5 + } + exchangeMode := strings.TrimSpace(opts.Exchange) + if exchangeMode == "" { + exchangeMode = "mnemonhub" + } + runRoot := opts.RunRoot + if runRoot == "" { + runRoot = filepath.Join(".testdata", "managed-runtime", started.Format("20060102T150405Z")) + } + report := managedRuntimeAcceptanceReport{ + SchemaVersion: 1, + Status: "ok", + Layer: "managed_runtime_acceptance", + RunnerRole: "seed_and_observe", + Exchange: exchangeMode, + GitHubRepo: strings.TrimSpace(opts.GitHubRepo), + StartedAt: started.Format(time.RFC3339), + RunRoot: runRoot, + } + if err := os.MkdirAll(runRoot, 0o755); err != nil { + report.Status = "blocked" + report.Errors = append(report.Errors, err.Error()) + return finishManagedRuntimeReport(report), err + } + if exchangeMode != "mnemonhub" && exchangeMode != "github" { + err := fmt.Errorf("managed-runtime acceptance exchange must be mnemonhub or github, got %q", exchangeMode) + report.Status = "blocked" + report.Errors = append(report.Errors, err.Error()) + written, writeErr := finishAndWriteManagedRuntimeReport(report) + if writeErr != nil { + return written, writeErr + } + return written, err + } + if exchangeMode == "github" && strings.TrimSpace(opts.GitHubTokenFile) == "" { + err := fmt.Errorf("managed-runtime github acceptance requires --github-token-file for real GitHub access") + report.Status = "blocked" + report.Errors = append(report.Errors, err.Error()) + addManagedAssertion(&report, "github token file provided", false, err.Error()) + written, writeErr := finishAndWriteManagedRuntimeReport(report) + if writeErr != nil { + return written, writeErr + } + return written, err + } + allSentinel := true + for i := 1; i <= opts.Agents; i++ { + principal := fmt.Sprintf("codex-%02d@project", i) + query, err := opts.Wake(ctx, opts, principal) + status := "ok" + if err != nil { + status = "failed" + allSentinel = false + report.Errors = append(report.Errors, fmt.Sprintf("%s: %v", principal, err)) + } + if strings.TrimSpace(query) != driver.ManagedWakeQuery { + allSentinel = false + } + report.Agents = append(report.Agents, managedRuntimeAgentReport{Principal: principal, RawQuery: strings.TrimSpace(query), Status: status}) + report.PromptAudit = append(report.PromptAudit, managedRuntimePromptAuditEntry{Principal: principal, Kind: "raw_managed_wake", Query: strings.TrimSpace(query)}) + } + addManagedAssertion(&report, "raw managed queries are sentinel only", allSentinel, fmt.Sprintf("agents=%d", len(report.Agents))) + addManagedAssertion(&report, "runner role is seed-and-observe", report.RunnerRole == "seed_and_observe", report.RunnerRole) + addManagedAssertion(&report, "no direct worker business prompts", managedRuntimeDirectWorkerPromptCount(report) == 0, "prompt_audit contains raw_managed_wake only") + if len(report.Errors) > 0 || !managedRuntimeAssertionsPassed(report) { + report.Status = "failed" + } + return finishAndWriteManagedRuntimeReport(report) +} + +func managedRuntimeMnemondDryRunWake(ctx context.Context, opts managedRuntimeAcceptanceOptions, principal string) (string, error) { + timeout := opts.TurnTimeout + if timeout <= 0 { + timeout = 30 * time.Second + } + wakeCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + bin := strings.TrimSpace(opts.MnemondBin) + if bin == "" { + bin = "mnemond" + } + cmd := exec.CommandContext(wakeCtx, bin, "agent", "run", "--principal", principal, "--dry-run") + out, err := cmd.CombinedOutput() + if err != nil { + return strings.TrimSpace(string(out)), fmt.Errorf("mnemond managed wake dry-run: %w: %s", err, strings.TrimSpace(string(out))) + } + return strings.TrimSpace(string(out)), nil +} + +func managedRuntimeDirectWorkerPromptCount(report managedRuntimeAcceptanceReport) int { + count := 0 + for _, entry := range report.PromptAudit { + if entry.Kind == "worker_business" { + count++ + } + } + return count +} + +func addManagedAssertion(report *managedRuntimeAcceptanceReport, name string, passed bool, detail string) { + report.Assertions = append(report.Assertions, managedRuntimeAssertion{Name: name, Passed: passed, Detail: detail}) +} + +func managedRuntimeAssertionsPassed(report managedRuntimeAcceptanceReport) bool { + for _, assertion := range report.Assertions { + if !assertion.Passed { + return false + } + } + return true +} + +func finishAndWriteManagedRuntimeReport(report managedRuntimeAcceptanceReport) (managedRuntimeAcceptanceReport, error) { + report = finishManagedRuntimeReport(report) + path := filepath.Join(report.RunRoot, "acceptance-report.json") + raw, err := json.MarshalIndent(report, "", " ") + if err != nil { + return report, err + } + if err := os.WriteFile(path, append(raw, '\n'), 0o644); err != nil { + return report, err + } + report.ReportPath = path + raw, err = json.MarshalIndent(report, "", " ") + if err != nil { + return report, err + } + if err := os.WriteFile(path, append(raw, '\n'), 0o644); err != nil { + return report, err + } + return report, nil +} + +func finishManagedRuntimeReport(report managedRuntimeAcceptanceReport) managedRuntimeAcceptanceReport { + report.FinishedAt = time.Now().UTC().Format(time.RFC3339) + if report.Status == "" { + report.Status = "ok" + } + return report +} diff --git a/harness/cmd/mnemon-acceptance/acceptance_managed_runtime_test.go b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime_test.go new file mode 100644 index 00000000..6c455e1c --- /dev/null +++ b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "context" + "strings" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/driver" +) + +func TestManagedRuntimeAcceptanceCommandRegistered(t *testing.T) { + commands := map[string]bool{} + for _, cmd := range rootCmd.Commands() { + commands[cmd.Name()] = true + } + if !commands["managed-runtime"] { + t.Fatalf("mnemon-acceptance should expose managed-runtime command: %v", commands) + } +} + +func TestManagedRuntimeAcceptanceRequiresSentinelWake(t *testing.T) { + report, err := runManagedRuntimeAcceptance(context.Background(), managedRuntimeAcceptanceOptions{ + RunRoot: t.TempDir(), + Agents: 3, + Exchange: "mnemonhub", + Wake: func(_ context.Context, _ managedRuntimeAcceptanceOptions, _ string) (string, error) { + return driver.ManagedWakeQuery, nil + }, + }) + if err != nil { + t.Fatal(err) + } + if report.Status != "ok" || report.Layer != "managed_runtime_acceptance" || report.RunnerRole != "seed_and_observe" { + t.Fatalf("report contract mismatch: %+v", report) + } + if len(report.Agents) != 3 { + t.Fatalf("agents = %d, want 3", len(report.Agents)) + } + for _, agent := range report.Agents { + if agent.RawQuery != driver.ManagedWakeQuery { + t.Fatalf("raw query = %q, want %q", agent.RawQuery, driver.ManagedWakeQuery) + } + } + if managedRuntimeDirectWorkerPromptCount(report) != 0 { + t.Fatalf("managed acceptance must not record worker business prompts: %+v", report.PromptAudit) + } +} + +func TestManagedRuntimeAcceptanceRejectsNonSentinelWake(t *testing.T) { + report, err := runManagedRuntimeAcceptance(context.Background(), managedRuntimeAcceptanceOptions{ + RunRoot: t.TempDir(), + Agents: 1, + Exchange: "mnemonhub", + Wake: func(_ context.Context, _ managedRuntimeAcceptanceOptions, _ string) (string, error) { + return "inspect assignment asg1", nil + }, + }) + if err != nil { + t.Fatal(err) + } + if report.Status == "ok" { + t.Fatalf("non-sentinel wake should fail managed acceptance: %+v", report) + } + if !strings.Contains(report.Agents[0].RawQuery, "assignment") { + t.Fatalf("test setup did not preserve non-sentinel query: %+v", report.Agents) + } +} + +func TestManagedRuntimeGitHubRequiresTokenFile(t *testing.T) { + report, err := runManagedRuntimeAcceptance(context.Background(), managedRuntimeAcceptanceOptions{ + RunRoot: t.TempDir(), + Agents: 1, + Exchange: "github", + Wake: func(_ context.Context, _ managedRuntimeAcceptanceOptions, _ string) (string, error) { + return driver.ManagedWakeQuery, nil + }, + }) + if err == nil { + t.Fatal("github managed acceptance without token file should return an explicit blocker") + } + if report.Status != "blocked" || !strings.Contains(err.Error(), "--github-token-file") { + t.Fatalf("github blocker mismatch: status=%s err=%v report=%+v", report.Status, err, report) + } +} From 8243961e16b5e1dd1645a5954ef2becd7deef652 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 13:32:17 +0800 Subject: [PATCH 5/8] feat(harness): drive local managed agents from mnemond Extend the managed wake contract with render-derived candidates, persistent local wake ledgers, and a codex-appserver turn client. Wire mnemond agent run so it can read local render context by rule, wake only the local runtime with [mnemon:wake], and record local audit evidence. Validation: go test ./harness/internal/driver ./harness/cmd/mnemond --- harness/cmd/mnemond/agent.go | 134 ++++++++- harness/cmd/mnemond/agent_test.go | 63 +++- harness/internal/driver/managed_agent.go | 53 ++-- harness/internal/driver/managed_runtime.go | 269 ++++++++++++++++++ .../internal/driver/managed_runtime_test.go | 76 +++++ 5 files changed, 559 insertions(+), 36 deletions(-) create mode 100644 harness/internal/driver/managed_runtime.go create mode 100644 harness/internal/driver/managed_runtime_test.go diff --git a/harness/cmd/mnemond/agent.go b/harness/cmd/mnemond/agent.go index b828011b..65298bcf 100644 --- a/harness/cmd/mnemond/agent.go +++ b/harness/cmd/mnemond/agent.go @@ -6,10 +6,14 @@ import ( "flag" "fmt" "io" + "os" + "path/filepath" "strings" "time" + "github.com/mnemon-dev/mnemon/harness/internal/contract" "github.com/mnemon-dev/mnemon/harness/internal/driver" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation" ) func runAgent(ctx context.Context, args []string, out, errw io.Writer) error { @@ -28,38 +32,142 @@ func runAgentRun(ctx context.Context, args []string, out, errw io.Writer) error fs := flag.NewFlagSet("mnemond agent run", flag.ContinueOnError) fs.SetOutput(errw) principal := fs.String("principal", "", "local managed agent principal") - runtimeName := fs.String("runtime", "noop", "managed runtime adapter (noop)") + runtimeName := fs.String("runtime", "noop", "managed runtime adapter (noop or codex-appserver)") dryRun := fs.Bool("dry-run", false, "print the managed wake query without starting a runtime turn") cooldown := fs.Duration("cooldown", 0, "minimum delay between managed wakes") + addr := fs.String("addr", "", "local mnemond HTTP address used to render wake candidates") + tokenFile := fs.String("token-file", "", "file containing the local mnemond bearer token") + workspace := fs.String("workspace", "", "managed agent workspace") + command := fs.String("command", "codex", "Codex CLI command for --runtime codex-appserver") + codexHome := fs.String("codex-home", "", "CODEX_HOME for --runtime codex-appserver") + ledgerPath := fs.String("ledger", "", "managed wake ledger path") + lifecycle := fs.String("lifecycle", "remind", "render lifecycle used for wake candidate derivation") + renderIntent := fs.String("render-intent", presentation.IntentTeamworkEvents, "render intent used for wake candidate derivation") + surface := fs.String("surface", "hook", "render surface used for wake candidate derivation") + turnTimeout := fs.Duration("turn-timeout", 5*time.Minute, "timeout for one managed agent turn") if err := fs.Parse(args); err != nil { return err } - if strings.TrimSpace(*principal) == "" { + localPrincipal := strings.TrimSpace(*principal) + if localPrincipal == "" { return fmt.Errorf("mnemond agent run requires --principal") } if *dryRun { fmt.Fprintln(out, driver.ManagedWakeQuery) return nil } - if strings.TrimSpace(*runtimeName) != "noop" { - return fmt.Errorf("runtime %q is not supported yet; use --runtime noop for contract validation", *runtimeName) + candidate := driver.ManagedWakeCandidate{ + Principal: localPrincipal, + DerivedEventID: "manual:agent-run", + BodyDigest: "sha256:manual", + Reason: "manual", + } + if strings.TrimSpace(*addr) != "" { + token, err := readAgentTokenFile(*tokenFile) + if err != nil { + return err + } + resp, err := (driver.HTTPRenderClient{ + BaseURL: strings.TrimSpace(*addr), + Token: token, + Principal: contract.ActorID(localPrincipal), + }).Render(ctx, presentation.Request{ + SchemaVersion: 1, + Principal: contract.ActorID(localPrincipal), + RenderIntent: strings.TrimSpace(*renderIntent), + Lifecycle: strings.TrimSpace(*lifecycle), + Surface: strings.TrimSpace(*surface), + }) + if err != nil { + return err + } + candidates := driver.ManagedWakeCandidatesFromRender(localPrincipal, resp) + if len(candidates) == 0 { + return writeManagedAgentRecord(out, driver.ManagedWakeRecord{ + Principal: localPrincipal, + Status: "skipped", + Reason: "no_wake_candidate", + StartedAt: time.Now().UTC(), + RenderAuditID: resp.AuditID, + RenderBodyDigest: resp.BodyDigest, + }) + } + candidate = candidates[0] + } + client, err := managedTurnClient(*runtimeName, *command, *workspace, *codexHome, *turnTimeout) + if err != nil { + return err } + ledger := managedWakeLedger(*ledgerPath, *workspace) managed := &driver.ManagedAgentDriver{ - Principal: strings.TrimSpace(*principal), - Client: noopManagedTurnClient{}, - Ledger: driver.NewMemoryManagedWakeLedger(), + Principal: localPrincipal, + Client: client, + Ledger: ledger, Cooldown: *cooldown, Now: func() time.Time { return time.Now().UTC() }, } - record, err := managed.Wake(ctx, driver.ManagedWakeCandidate{ - Principal: strings.TrimSpace(*principal), - DerivedEventID: "manual:agent-run", - BodyDigest: "sha256:manual", - Reason: "manual", - }) + record, err := managed.Wake(ctx, candidate) if err != nil { return err } + return writeManagedAgentRecord(out, record) +} + +func managedTurnClient(runtimeName, command, workspace, codexHome string, turnTimeout time.Duration) (driver.ManagedTurnClient, error) { + switch strings.TrimSpace(runtimeName) { + case "", "noop": + return noopManagedTurnClient{}, nil + case "codex-appserver": + env := os.Environ() + if strings.TrimSpace(codexHome) != "" { + env = setAgentEnv(env, "CODEX_HOME", strings.TrimSpace(codexHome)) + } + return driver.CodexAppServerTurnClient{ + Command: strings.TrimSpace(command), + Workspace: strings.TrimSpace(workspace), + Env: env, + TurnTimeout: turnTimeout, + }, nil + default: + return nil, fmt.Errorf("runtime %q is not supported; use noop or codex-appserver", runtimeName) + } +} + +func managedWakeLedger(explicitPath, workspace string) driver.ManagedWakeLedger { + path := strings.TrimSpace(explicitPath) + if path == "" { + root := strings.TrimSpace(workspace) + if root == "" { + root = "." + } + path = filepath.Join(root, ".mnemon", "harness", "local", "managed-agent", "wake-ledger.jsonl") + } + return driver.NewFileManagedWakeLedger(path) +} + +func readAgentTokenFile(path string) (string, error) { + if strings.TrimSpace(path) == "" { + return "", nil + } + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return strings.TrimSpace(string(data)), nil +} + +func setAgentEnv(env []string, key, value string) []string { + prefix := key + "=" + out := env[:0] + for _, item := range env { + if !strings.HasPrefix(item, prefix) { + out = append(out, item) + } + } + return append(out, prefix+value) +} + +func writeManagedAgentRecord(out io.Writer, record driver.ManagedWakeRecord) error { raw, err := json.MarshalIndent(record, "", " ") if err != nil { return err diff --git a/harness/cmd/mnemond/agent_test.go b/harness/cmd/mnemond/agent_test.go index 6bc97585..c6674118 100644 --- a/harness/cmd/mnemond/agent_test.go +++ b/harness/cmd/mnemond/agent_test.go @@ -3,10 +3,17 @@ package main import ( "bytes" "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" "strings" "testing" "github.com/mnemon-dev/mnemon/harness/internal/driver" + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation" ) func TestAgentRunDryRunPrintsSentinelOnly(t *testing.T) { @@ -30,7 +37,7 @@ func TestAgentRunRequiresPrincipal(t *testing.T) { func TestAgentRunNoopRecordsSentinelQuery(t *testing.T) { var out, errw bytes.Buffer - err := run(context.Background(), []string{"agent", "run", "--principal", "codex-a@project", "--runtime", "noop"}, &out, &errw) + err := run(context.Background(), []string{"agent", "run", "--principal", "codex-a@project", "--runtime", "noop", "--workspace", t.TempDir()}, &out, &errw) if err != nil { t.Fatalf("agent run noop: %v\nstderr=%s", err, errw.String()) } @@ -38,3 +45,57 @@ func TestAgentRunNoopRecordsSentinelQuery(t *testing.T) { t.Fatalf("noop run should report sentinel query only:\n%s", out.String()) } } + +func TestAgentRunNoopUsesRenderedWakeCandidate(t *testing.T) { + rendered := presentation.Response{ + SchemaVersion: 1, + Status: presentation.StatusOK, + AuditID: "render-1", + BodyDigest: "sha256:render", + Events: []eventmodel.EventEnvelope{eventmodel.DerivedEnvelope(eventmodel.Event{ + SchemaVersion: eventmodel.SchemaVersion, + ID: "derived:assignment.work_available:assignment/asg1:codex-a@project", + Type: "assignment.work_available", + Subject: "assignment/asg1", + Actor: "mnemond@local", + Audience: "codex-a@project", + Payload: eventmodel.BuildPayload(nil, map[string]any{"body": "Assignment asg1 is yours."}, nil), + }, "2026-06-24T10:00:00Z", "2026-06-24T10:05:00Z", "work", nil)}, + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/render" { + t.Fatalf("path = %s, want /render", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer local-token" { + t.Fatalf("Authorization = %q, want bearer token", got) + } + _ = json.NewEncoder(w).Encode(rendered) + })) + defer server.Close() + root := t.TempDir() + tokenFile := filepath.Join(root, "token") + if err := os.WriteFile(tokenFile, []byte("local-token\n"), 0o600); err != nil { + t.Fatal(err) + } + var out, errw bytes.Buffer + err := run(context.Background(), []string{ + "agent", "run", + "--principal", "codex-a@project", + "--runtime", "noop", + "--addr", server.URL, + "--token-file", tokenFile, + "--workspace", root, + }, &out, &errw) + if err != nil { + t.Fatalf("agent run noop render: %v\nstderr=%s", err, errw.String()) + } + if !strings.Contains(out.String(), `"query": "`+driver.ManagedWakeQuery+`"`) { + t.Fatalf("render candidate run should report sentinel query only:\n%s", out.String()) + } + if !strings.Contains(out.String(), `"derived_event_id": "derived:assignment.work_available:assignment/asg1:codex-a@project"`) { + t.Fatalf("render candidate did not become wake record:\n%s", out.String()) + } + if !strings.Contains(out.String(), `"render_audit_id": "render-1"`) { + t.Fatalf("wake record should carry render audit id:\n%s", out.String()) + } +} diff --git a/harness/internal/driver/managed_agent.go b/harness/internal/driver/managed_agent.go index db8dcce2..9e09548f 100644 --- a/harness/internal/driver/managed_agent.go +++ b/harness/internal/driver/managed_agent.go @@ -18,27 +18,33 @@ type ManagedTurnClient interface { } type ManagedTurnResult struct { - TurnID string - Status string + TurnID string + Status string + FinalAnswer string } type ManagedWakeCandidate struct { - Principal string - DerivedEventID string - BodyDigest string - Reason string + Principal string + DerivedEventID string + BodyDigest string + Reason string + RenderAuditID string + RenderBodyDigest string } type ManagedWakeRecord struct { - Principal string `json:"principal"` - DerivedEventID string `json:"derived_event_id"` - BodyDigest string `json:"body_digest"` - Reason string `json:"reason"` - Query string `json:"query"` - TurnID string `json:"turn_id,omitempty"` - Status string `json:"status"` - StartedAt time.Time `json:"started_at"` - Error string `json:"error,omitempty"` + Principal string `json:"principal"` + DerivedEventID string `json:"derived_event_id"` + BodyDigest string `json:"body_digest"` + Reason string `json:"reason"` + Query string `json:"query"` + TurnID string `json:"turn_id,omitempty"` + Status string `json:"status"` + StartedAt time.Time `json:"started_at"` + RenderAuditID string `json:"render_audit_id,omitempty"` + RenderBodyDigest string `json:"render_body_digest,omitempty"` + FinalAnswer string `json:"final_answer,omitempty"` + Error string `json:"error,omitempty"` } type ManagedWakeLedger interface { @@ -121,13 +127,15 @@ func (d *ManagedAgentDriver) Wake(ctx context.Context, candidate ManagedWakeCand d.mu.Unlock() record := ManagedWakeRecord{ - Principal: candidate.Principal, - DerivedEventID: candidate.DerivedEventID, - BodyDigest: candidate.BodyDigest, - Reason: candidate.Reason, - Query: ManagedWakeQuery, - StartedAt: now, - Status: "started", + Principal: candidate.Principal, + DerivedEventID: candidate.DerivedEventID, + BodyDigest: candidate.BodyDigest, + Reason: candidate.Reason, + Query: ManagedWakeQuery, + StartedAt: now, + Status: "started", + RenderAuditID: candidate.RenderAuditID, + RenderBodyDigest: candidate.RenderBodyDigest, } result, err := d.Client.StartTurn(ctx, ManagedWakeQuery) if err != nil { @@ -138,6 +146,7 @@ func (d *ManagedAgentDriver) Wake(ctx context.Context, candidate ManagedWakeCand return record, err } record.TurnID = result.TurnID + record.FinalAnswer = result.FinalAnswer record.Status = result.Status if record.Status == "" { record.Status = "completed" diff --git a/harness/internal/driver/managed_runtime.go b/harness/internal/driver/managed_runtime.go new file mode 100644 index 00000000..238e9681 --- /dev/null +++ b/harness/internal/driver/managed_runtime.go @@ -0,0 +1,269 @@ +package driver + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/codexapp" + "github.com/mnemon-dev/mnemon/harness/internal/contract" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation" +) + +type HTTPRenderClient struct { + BaseURL string + Token string + Principal contract.ActorID + HTTP *http.Client +} + +func (c HTTPRenderClient) Render(ctx context.Context, req presentation.Request) (presentation.Response, error) { + base := strings.TrimRight(strings.TrimSpace(c.BaseURL), "/") + if base == "" { + return presentation.Response{}, fmt.Errorf("render client requires base URL") + } + req.Principal = c.Principal + body, err := json.Marshal(req) + if err != nil { + return presentation.Response{}, err + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/render", bytes.NewReader(body)) + if err != nil { + return presentation.Response{}, err + } + if strings.TrimSpace(c.Token) != "" { + httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(c.Token)) + } else { + httpReq.Header.Set(access.PrincipalHeader, string(c.Principal)) + } + httpReq.Header.Set("Content-Type", "application/json") + client := c.HTTP + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(httpReq) + if err != nil { + return presentation.Response{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + data, _ := io.ReadAll(resp.Body) + return presentation.Response{}, fmt.Errorf("render failed: %s: %s", resp.Status, strings.TrimSpace(string(data))) + } + var out presentation.Response + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return presentation.Response{}, err + } + return out, nil +} + +func ManagedWakeCandidatesFromRender(principal string, resp presentation.Response) []ManagedWakeCandidate { + candidates := ManagedWakeCandidatesFromEvents(principal, resp.Events) + for i := range candidates { + candidates[i].RenderAuditID = resp.AuditID + candidates[i].RenderBodyDigest = resp.BodyDigest + } + return candidates +} + +type FileManagedWakeLedger struct { + path string + mu sync.Mutex + loaded bool + loadErr error + seen map[string]ManagedWakeRecord +} + +func NewFileManagedWakeLedger(path string) *FileManagedWakeLedger { + return &FileManagedWakeLedger{path: path, seen: map[string]ManagedWakeRecord{}} +} + +func (l *FileManagedWakeLedger) Seen(candidate ManagedWakeCandidate) bool { + l.mu.Lock() + defer l.mu.Unlock() + l.loadLocked() + _, ok := l.seen[managedWakeKey(candidate)] + return ok +} + +func (l *FileManagedWakeLedger) Record(record ManagedWakeRecord) error { + l.mu.Lock() + defer l.mu.Unlock() + l.loadLocked() + if l.loadErr != nil { + return l.loadErr + } + if strings.TrimSpace(l.path) == "" { + return fmt.Errorf("managed wake ledger path is required") + } + if err := os.MkdirAll(filepath.Dir(l.path), 0o755); err != nil { + return err + } + f, err := os.OpenFile(l.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return err + } + enc := json.NewEncoder(f) + if err := enc.Encode(record); err != nil { + _ = f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + l.seen[managedWakeKey(ManagedWakeCandidate{ + Principal: record.Principal, + DerivedEventID: record.DerivedEventID, + BodyDigest: record.BodyDigest, + })] = record + return nil +} + +func (l *FileManagedWakeLedger) loadLocked() { + if l.loaded { + return + } + l.loaded = true + if strings.TrimSpace(l.path) == "" { + l.loadErr = fmt.Errorf("managed wake ledger path is required") + return + } + f, err := os.Open(l.path) + if errors.Is(err, os.ErrNotExist) { + return + } + if err != nil { + l.loadErr = err + return + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var record ManagedWakeRecord + if err := json.Unmarshal([]byte(line), &record); err != nil { + l.loadErr = err + return + } + l.seen[managedWakeKey(ManagedWakeCandidate{ + Principal: record.Principal, + DerivedEventID: record.DerivedEventID, + BodyDigest: record.BodyDigest, + })] = record + } + if err := scanner.Err(); err != nil { + l.loadErr = err + } +} + +type CodexAppServerTurnClient struct { + Command string + Workspace string + Env []string + DeveloperInstructions string + TurnTimeout time.Duration + RequestTimeout time.Duration + ClientName string + ClientVersion string +} + +func (c CodexAppServerTurnClient) StartTurn(_ context.Context, query string) (ManagedTurnResult, error) { + if strings.TrimSpace(query) != ManagedWakeQuery { + return ManagedTurnResult{}, fmt.Errorf("managed codex appserver client only accepts %q queries", ManagedWakeQuery) + } + command := strings.TrimSpace(c.Command) + if command == "" { + command = "codex" + } + workspace := strings.TrimSpace(c.Workspace) + if workspace == "" { + workspace = "." + } + requestTimeout := c.RequestTimeout + if requestTimeout <= 0 { + requestTimeout = 30 * time.Second + } + turnTimeout := c.TurnTimeout + if turnTimeout <= 0 { + turnTimeout = 5 * time.Minute + } + clientName := strings.TrimSpace(c.ClientName) + if clientName == "" { + clientName = "mnemond-managed-agent" + } + clientVersion := strings.TrimSpace(c.ClientVersion) + if clientVersion == "" { + clientVersion = "dev" + } + server := codexapp.New(command, workspace) + if len(c.Env) > 0 { + server.SetEnv(c.Env) + } + if err := server.Start(); err != nil { + return ManagedTurnResult{}, err + } + defer server.Close() + if _, err := server.Request("initialize", map[string]any{ + "clientInfo": map[string]any{"name": clientName, "version": clientVersion}, + }, requestTimeout); err != nil { + return ManagedTurnResult{}, fmt.Errorf("initialize: %w", err) + } + thread, err := server.Request("thread/start", map[string]any{ + "cwd": workspace, + "approvalPolicy": "never", + "sandbox": "danger-full-access", + "ephemeral": true, + "developerInstructions": c.developerInstructions(), + }, requestTimeout) + if err != nil { + return ManagedTurnResult{}, fmt.Errorf("thread/start: %w", err) + } + threadID := codexapp.ThreadID(thread) + if threadID == "" { + return ManagedTurnResult{}, fmt.Errorf("thread/start returned no thread id") + } + before := server.NotificationCount() + if _, err := server.Request("turn/start", map[string]any{ + "threadId": threadID, + "input": []map[string]any{{"type": "text", "text": ManagedWakeQuery}}, + "cwd": workspace, + "approvalPolicy": "never", + "sandboxPolicy": map[string]any{"type": "dangerFullAccess"}, + }, requestTimeout); err != nil { + return ManagedTurnResult{}, fmt.Errorf("turn/start: %w", err) + } + if _, err := server.WaitNotification("turn/completed", turnTimeout, before); err != nil { + text := codexapp.CombinedText(server.NotificationsSince(before)) + return ManagedTurnResult{TurnID: threadID, Status: "failed", FinalAnswer: text}, fmt.Errorf("wait turn/completed: %w", err) + } + notifications := server.NotificationsSince(before) + answer := codexapp.FinalAnswer(notifications) + if answer == "" { + answer = codexapp.CombinedText(notifications) + } + return ManagedTurnResult{TurnID: threadID, Status: "completed", FinalAnswer: answer}, nil +} + +func (c CodexAppServerTurnClient) developerInstructions() string { + if strings.TrimSpace(c.DeveloperInstructions) != "" { + return c.DeveloperInstructions + } + return `You are a mnemond-managed Mnemon agent. +When the user input is [mnemon:wake], treat it only as a local wake signal. +Use the normal Mnemon hooks/skills and Local Mnemon commands in this workspace to inspect governed context and decide whether to act. +Do not expect assignment details in the raw wake query. Keep any governed event drafts short and emit them through Local Mnemon.` +} diff --git a/harness/internal/driver/managed_runtime_test.go b/harness/internal/driver/managed_runtime_test.go new file mode 100644 index 00000000..0711a8c3 --- /dev/null +++ b/harness/internal/driver/managed_runtime_test.go @@ -0,0 +1,76 @@ +package driver + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation" +) + +func TestManagedWakeCandidatesFromRenderCarryAudit(t *testing.T) { + resp := presentation.Response{ + AuditID: "render_audit_1", + BodyDigest: "sha256:render", + Events: []eventmodel.EventEnvelope{eventmodel.DerivedEnvelope(eventmodel.Event{ + SchemaVersion: eventmodel.SchemaVersion, + ID: "derived:assignment.work_available:assignment/asg1:codex-a@project", + Type: "assignment.work_available", + Subject: "assignment/asg1", + Actor: "mnemond@local", + Audience: "codex-a@project", + Payload: eventmodel.BuildPayload(nil, map[string]any{"body": "Assignment asg1 is yours."}, nil), + }, "2026-06-24T10:00:00Z", "2026-06-24T10:05:00Z", "work", nil)}, + } + candidates := ManagedWakeCandidatesFromRender("codex-a@project", resp) + if len(candidates) != 1 { + t.Fatalf("candidates = %+v, want one", candidates) + } + if candidates[0].RenderAuditID != resp.AuditID || candidates[0].RenderBodyDigest != resp.BodyDigest { + t.Fatalf("candidate should carry render audit metadata: %+v", candidates[0]) + } +} + +func TestFileManagedWakeLedgerPersistsSeen(t *testing.T) { + path := filepath.Join(t.TempDir(), "wake-ledger.jsonl") + candidate := ManagedWakeCandidate{Principal: "codex-a@project", DerivedEventID: "d1", BodyDigest: "sha256:x"} + ledger := NewFileManagedWakeLedger(path) + if ledger.Seen(candidate) { + t.Fatal("fresh ledger should not have seen candidate") + } + if err := ledger.Record(ManagedWakeRecord{Principal: candidate.Principal, DerivedEventID: candidate.DerivedEventID, BodyDigest: candidate.BodyDigest, Status: "completed"}); err != nil { + t.Fatal(err) + } + reopened := NewFileManagedWakeLedger(path) + if !reopened.Seen(candidate) { + t.Fatal("reopened ledger should remember candidate") + } +} + +func TestHTTPRenderClientUsesBearerAndDecodesResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer token-1" { + t.Fatalf("Authorization = %q, want bearer token", got) + } + _ = json.NewEncoder(w).Encode(presentation.Response{SchemaVersion: 1, Status: presentation.StatusOK, AuditID: "audit-1"}) + })) + defer server.Close() + resp, err := (HTTPRenderClient{BaseURL: server.URL, Token: "token-1"}).Render(context.Background(), presentation.Request{}) + if err != nil { + t.Fatal(err) + } + if resp.AuditID != "audit-1" { + t.Fatalf("render response = %+v", resp) + } +} + +func TestCodexAppServerTurnClientRejectsContextQuery(t *testing.T) { + client := CodexAppServerTurnClient{Command: "definitely-not-run"} + if _, err := client.StartTurn(context.Background(), "assignment asg1"); err == nil { + t.Fatal("codex appserver client must reject non-sentinel queries before starting a process") + } +} From f2aac0f0a87b6d063fe9e78a6d05117e75e6bc1c Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 13:38:55 +0800 Subject: [PATCH 6/8] test(harness): exercise managed runtime seed and observe path Upgrade mnemon-acceptance managed-runtime from a dry-run shell to a real topology path for mnemonhub and GitHub mesh. The scenario seeds once through an entry agent, then records local driver wakes that use only [mnemon:wake] while hook-rendered context supplies work and integration cues. Validation: go test ./harness/cmd/mnemon-acceptance ./harness/cmd/mnemond ./harness/internal/driver --- harness/cmd/mnemon-acceptance/acceptance.go | 46 ++- .../acceptance_managed_runtime.go | 323 +++++++++++++++++- harness/cmd/mnemond/agent.go | 5 +- harness/internal/driver/managed_runtime.go | 9 +- 4 files changed, 371 insertions(+), 12 deletions(-) diff --git a/harness/cmd/mnemon-acceptance/acceptance.go b/harness/cmd/mnemon-acceptance/acceptance.go index 1a97aaf6..31ac680b 100644 --- a/harness/cmd/mnemon-acceptance/acceptance.go +++ b/harness/cmd/mnemon-acceptance/acceptance.go @@ -374,14 +374,31 @@ func runR1CodexAcceptance(ctx context.Context, opts r1CodexAcceptanceOptions) (r } func installAcceptanceHarnessBinary(runRoot string) (string, error) { - exe, err := os.Executable() - if err != nil { - return "", err - } binDir := filepath.Join(runRoot, "bin") if err := os.MkdirAll(binDir, 0o755); err != nil { return "", err } + if sourceRoot, ok := acceptanceSourceRoot(); ok { + targets := map[string]string{ + "mnemon-harness": "./harness/cmd/mnemon-harness", + "mnemond": "./harness/cmd/mnemond", + "mnemon-hub": "./harness/cmd/mnemon-hub", + } + for name, pkg := range targets { + target := filepath.Join(binDir, name) + cmd := exec.Command("go", "build", "-o", target, pkg) + cmd.Dir = sourceRoot + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("build acceptance product binary %s: %w: %s", name, err, strings.TrimSpace(string(out))) + } + } + return binDir, nil + } + exe, err := os.Executable() + if err != nil { + return "", err + } target := filepath.Join(binDir, "mnemon-harness") in, err := os.Open(exe) if err != nil { @@ -402,6 +419,27 @@ func installAcceptanceHarnessBinary(runRoot string) (string, error) { return binDir, nil } +func acceptanceSourceRoot() (string, bool) { + cwd, err := os.Getwd() + if err != nil { + return "", false + } + for dir := cwd; ; dir = filepath.Dir(dir) { + if fileExists(filepath.Join(dir, "go.mod")) && fileExists(filepath.Join(dir, "harness", "cmd", "mnemon-harness", "root.go")) { + return dir, true + } + parent := filepath.Dir(dir) + if parent == dir { + return "", false + } + } +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + func prepareR1AcceptanceRunRoot(runRoot string) error { testdataRoot, err := physicalAcceptancePath(".testdata") if err != nil { diff --git a/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go index 33563565..3a94fd13 100644 --- a/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go +++ b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go @@ -12,12 +12,15 @@ import ( "time" "github.com/mnemon-dev/mnemon/harness/internal/driver" + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation" "github.com/spf13/cobra" ) var ( acceptanceManagedExchange string acceptanceManagedMnemondBin string + acceptanceManagedRuntimeAdapter string acceptanceManagedGitHubRepo string acceptanceManagedGitHubTokenFile string ) @@ -28,15 +31,18 @@ var acceptanceManagedRuntimeCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { report, err := runManagedRuntimeAcceptance(cmd.Context(), managedRuntimeAcceptanceOptions{ RunRoot: acceptanceRunRoot, + Command: acceptanceCommand, + CodexHome: acceptanceCodexHome, Agents: acceptanceAgents, + AgentTurns: acceptanceAgentTurns, Exchange: acceptanceManagedExchange, MnemondBin: acceptanceManagedMnemondBin, + Runtime: acceptanceManagedRuntimeAdapter, GitHubRepo: acceptanceManagedGitHubRepo, GitHubTokenFile: acceptanceManagedGitHubTokenFile, TurnTimeout: acceptanceTurnTimeout, Stdout: cmd.OutOrStdout(), Stderr: cmd.ErrOrStderr(), - Wake: managedRuntimeMnemondDryRunWake, }) if report.ReportPath != "" { fmt.Fprintf(cmd.OutOrStdout(), "acceptance report: %s\n", report.ReportPath) @@ -53,9 +59,13 @@ var acceptanceManagedRuntimeCmd = &cobra.Command{ func init() { acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceRunRoot, "run-root", "", "acceptance run directory") + acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceCommand, "command", "codex --dangerously-bypass-hook-trust", "Codex CLI command") + acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceCodexHome, "codex-home-source", "", "source CODEX_HOME to copy auth/config from") acceptanceManagedRuntimeCmd.Flags().IntVar(&acceptanceAgents, "agents", 5, "number of managed agent nodes") + acceptanceManagedRuntimeCmd.Flags().BoolVar(&acceptanceAgentTurns, "agent-turns", false, "run real managed Codex turns after the seed") acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedExchange, "exchange", "mnemonhub", "exchange mode: mnemonhub or github") acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedMnemondBin, "mnemond-bin", "mnemond", "mnemond binary used for product-path wake checks") + acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedRuntimeAdapter, "runtime", "codex-appserver", "managed runtime adapter: codex-appserver or noop") acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedGitHubRepo, "github-repo", "mnemon-dev/mnemon-teamwork-example", "GitHub Remote Workspace repository (owner/name)") acceptanceManagedRuntimeCmd.Flags().StringVar(&acceptanceManagedGitHubTokenFile, "github-token-file", "", "GitHub token file for real GitHub exchange validation") acceptanceManagedRuntimeCmd.Flags().DurationVar(&acceptanceTurnTimeout, "turn-timeout", 5*time.Minute, "timeout per managed wake check") @@ -64,9 +74,13 @@ func init() { type managedRuntimeAcceptanceOptions struct { RunRoot string + Command string + CodexHome string Agents int + AgentTurns bool Exchange string MnemondBin string + Runtime string GitHubRepo string GitHubTokenFile string TurnTimeout time.Duration @@ -84,11 +98,17 @@ type managedRuntimeAcceptanceReport struct { RunnerRole string `json:"runner_role"` Exchange string `json:"exchange"` GitHubRepo string `json:"github_repo,omitempty"` + Runtime string `json:"runtime"` StartedAt string `json:"started_at"` FinishedAt string `json:"finished_at"` RunRoot string `json:"run_root"` ReportPath string `json:"report_path"` + Topology *r1AcceptanceTopologyReport `json:"topology,omitempty"` Agents []managedRuntimeAgentReport `json:"agents"` + DriverWakes []driver.ManagedWakeRecord `json:"driver_wakes,omitempty"` + Scenarios []r1TaskSimScenarioReport `json:"scenarios,omitempty"` + LedgerCounts map[string]int `json:"ledger_counts,omitempty"` + Artifacts map[string]string `json:"artifacts,omitempty"` Assertions []managedRuntimeAssertion `json:"assertions"` PromptAudit []managedRuntimePromptAuditEntry `json:"prompt_audit"` Errors []string `json:"errors,omitempty"` @@ -96,6 +116,8 @@ type managedRuntimeAcceptanceReport struct { type managedRuntimeAgentReport struct { Principal string `json:"principal"` + Workspace string `json:"workspace,omitempty"` + ThreadID string `json:"thread_id,omitempty"` RawQuery string `json:"raw_query"` Status string `json:"status"` } @@ -120,16 +142,26 @@ func runManagedRuntimeAcceptance(ctx context.Context, opts managedRuntimeAccepta if opts.Stderr == nil { opts.Stderr = io.Discard } - if opts.Wake == nil { - opts.Wake = managedRuntimeMnemondDryRunWake - } if opts.Agents <= 0 { opts.Agents = 5 } + if opts.Command == "" { + opts.Command = "codex --dangerously-bypass-hook-trust" + } + if opts.TurnTimeout <= 0 { + opts.TurnTimeout = 5 * time.Minute + } + if strings.TrimSpace(opts.GitHubRepo) == "" { + opts.GitHubRepo = "mnemon-dev/mnemon-teamwork-example" + } exchangeMode := strings.TrimSpace(opts.Exchange) if exchangeMode == "" { exchangeMode = "mnemonhub" } + runtimeName := strings.TrimSpace(opts.Runtime) + if runtimeName == "" { + runtimeName = "codex-appserver" + } runRoot := opts.RunRoot if runRoot == "" { runRoot = filepath.Join(".testdata", "managed-runtime", started.Format("20060102T150405Z")) @@ -141,8 +173,10 @@ func runManagedRuntimeAcceptance(ctx context.Context, opts managedRuntimeAccepta RunnerRole: "seed_and_observe", Exchange: exchangeMode, GitHubRepo: strings.TrimSpace(opts.GitHubRepo), + Runtime: runtimeName, StartedAt: started.Format(time.RFC3339), RunRoot: runRoot, + Artifacts: map[string]string{}, } if err := os.MkdirAll(runRoot, 0o755); err != nil { report.Status = "blocked" @@ -159,6 +193,11 @@ func runManagedRuntimeAcceptance(ctx context.Context, opts managedRuntimeAccepta } return written, err } + if err := prepareR1AcceptanceRunRoot(runRoot); err != nil { + report.Status = "blocked" + report.Errors = append(report.Errors, err.Error()) + return finishManagedRuntimeReport(report), err + } if exchangeMode == "github" && strings.TrimSpace(opts.GitHubTokenFile) == "" { err := fmt.Errorf("managed-runtime github acceptance requires --github-token-file for real GitHub access") report.Status = "blocked" @@ -170,6 +209,31 @@ func runManagedRuntimeAcceptance(ctx context.Context, opts managedRuntimeAccepta } return written, err } + if opts.Wake != nil { + return runManagedRuntimeInjectedWakeAcceptance(ctx, opts, report) + } + if !opts.AgentTurns { + err := fmt.Errorf("managed-runtime acceptance requires --agent-turns for real seed-and-observe validation") + report.Status = "failed" + report.Errors = append(report.Errors, err.Error()) + addManagedAssertion(&report, "managed-runtime real agent turns requested", false, "rerun with --agent-turns") + written, writeErr := finishAndWriteManagedRuntimeReport(report) + if writeErr != nil { + return written, writeErr + } + return written, err + } + switch exchangeMode { + case "mnemonhub": + return runManagedRuntimeMnemonhubAcceptance(ctx, opts, report) + case "github": + return runManagedRuntimeGitHubAcceptance(ctx, opts, report) + default: + panic("validated exchange mode escaped switch") + } +} + +func runManagedRuntimeInjectedWakeAcceptance(ctx context.Context, opts managedRuntimeAcceptanceOptions, report managedRuntimeAcceptanceReport) (managedRuntimeAcceptanceReport, error) { allSentinel := true for i := 1; i <= opts.Agents; i++ { principal := fmt.Sprintf("codex-%02d@project", i) @@ -195,6 +259,257 @@ func runManagedRuntimeAcceptance(ctx context.Context, opts managedRuntimeAccepta return finishAndWriteManagedRuntimeReport(report) } +func runManagedRuntimeMnemonhubAcceptance(ctx context.Context, opts managedRuntimeAcceptanceOptions, report managedRuntimeAcceptanceReport) (managedRuntimeAcceptanceReport, error) { + binDir, err := installAcceptanceHarnessBinary(report.RunRoot) + if err != nil { + return managedRuntimeBlocked(report, err) + } + hub, err := startR1SyncHub(report.RunRoot, opts.Agents) + if err != nil { + return managedRuntimeBlocked(report, err) + } + defer hub.close() + sourceCodexHome := resolveSourceCodexHome(opts.CodexHome) + report.Artifacts["codex_home_source"] = sourceCodexHome + report.Artifacts["hub_db"] = filepath.Join(report.RunRoot, "hub", "hub.db") + report.Artifacts["hub_audit"] = hub.AuditPath + agents, err := setupR1CodexSyncAgents(ctx, report.RunRoot, binDir, hub, opts.Agents, sourceCodexHome) + if err != nil { + return managedRuntimeBlocked(report, err) + } + defer stopR1CodexSyncAgents(agents) + report.Topology = buildR1ProdSimTopology(agents) + addManagedAssertion(&report, "managed-runtime strict per-hostagent mnemond topology", prodSimStrictTopology(report.Topology), fmt.Sprintf("%+v", report.Topology)) + for _, agent := range agents { + report.Artifacts["mnemond:"+agent.principal] = prodSimMnemondPath(agent) + report.Artifacts["render_audit:"+agent.principal] = agent.renderAuditPath + } + return runManagedRuntimeRealScenario(ctx, opts, report, agents) +} + +func runManagedRuntimeGitHubAcceptance(ctx context.Context, opts managedRuntimeAcceptanceOptions, report managedRuntimeAcceptanceReport) (managedRuntimeAcceptanceReport, error) { + tokenFile, err := filepath.Abs(opts.GitHubTokenFile) + if err != nil { + return managedRuntimeBlocked(report, err) + } + if _, err := os.Stat(tokenFile); err != nil { + return managedRuntimeBlocked(report, fmt.Errorf("github token file: %w", err)) + } + binDir, err := installAcceptanceHarnessBinary(report.RunRoot) + if err != nil { + return managedRuntimeBlocked(report, err) + } + started, _ := time.Parse(time.RFC3339, report.StartedAt) + branchPrefix := r1GitHubMeshBranchPrefix("", started) + branches := r1GitHubMeshBranches(branchPrefix, opts.Agents) + if err := ensureR1GitHubMeshBranches(ctx, report.GitHubRepo, tokenFile, branches); err != nil { + return managedRuntimeBlocked(report, err) + } + sourceCodexHome := resolveSourceCodexHome(opts.CodexHome) + report.Artifacts["codex_home_source"] = sourceCodexHome + report.Artifacts["github_repo"] = report.GitHubRepo + report.Artifacts["github_token_file"] = tokenFile + report.Artifacts["github_branch_prefix"] = branchPrefix + agents, err := setupR1CodexGitHubMeshAgents(ctx, report.RunRoot, binDir, report.GitHubRepo, tokenFile, branchPrefix, opts.Agents, sourceCodexHome, 30*time.Second, opts.Agents) + if err != nil { + return managedRuntimeBlocked(report, err) + } + defer stopR1CodexSyncAgents(agents) + report.Topology = buildR1ProdSimTopology(agents) + report.Topology.MnemonhubInstances = 0 + addManagedAssertion(&report, "managed-runtime github per-hostagent mnemond topology", r1GitHubMeshStrictTopology(report.Topology), fmt.Sprintf("%+v", report.Topology)) + addManagedAssertion(&report, "managed-runtime github has no central hub", report.Topology.MnemonhubInstances == 0, "backend=github") + for _, agent := range agents { + report.Artifacts["mnemond:"+agent.principal] = prodSimMnemondPath(agent) + report.Artifacts["render_audit:"+agent.principal] = agent.renderAuditPath + } + return runManagedRuntimeRealScenario(ctx, opts, report, agents) +} + +func runManagedRuntimeRealScenario(ctx context.Context, opts managedRuntimeAcceptanceOptions, report managedRuntimeAcceptanceReport, agents []r1CodexSyncAgent) (managedRuntimeAcceptanceReport, error) { + if len(agents) < 5 { + return managedRuntimeFailed(report, fmt.Errorf("managed-runtime requires at least 5 agents, got %d", len(agents))) + } + for i := range agents { + if err := startR1CodexAppserver(&agents[i].r1CodexAgent, opts.Command); err != nil { + return managedRuntimeBlocked(report, err) + } + agentReport, _, err := initializeR1CodexAgent(&agents[i].r1CodexAgent, opts.TurnTimeout) + if err != nil { + return managedRuntimeBlocked(report, err) + } + report.Agents = append(report.Agents, managedRuntimeAgentReport{ + Principal: agentReport.Principal, + Workspace: agentReport.Workspace, + ThreadID: agentReport.ThreadID, + Status: "initialized", + }) + } + addManagedAssertion(&report, "managed-runtime 5/5 appservers start/init", len(report.Agents) == opts.Agents, fmt.Sprintf("started=%d requested=%d", len(report.Agents), opts.Agents)) + + runID := strings.ToLower(time.Now().UTC().Format("150405")) + entry := &agents[0] + worker := &agents[1] + assignmentID := "managed-asg-" + runID + signalID := "managed-signal-" + runID + seedPrompt := fmt.Sprintf(`You are the entry agent for a managed-runtime Mnemon acceptance run. +Emit one teamwork_signal.write_candidate.observed event with external id seed-signal-%s and payload: +{"rule":{"signal_id":%q,"scope":"managed-runtime/seeded-teamwork","ttl":"30m"},"narrative":{"statement":"Validate that managed local mnemond drivers can continue teamwork after one seed prompt.","why_teamwork":"a worker should act from hook-rendered Mnemon context after receiving only the managed wake sentinel"},"refs":{"evidence_refs":["managed-runtime acceptance seed"]}} +Then emit one assignment.write_candidate.observed event with external id seed-assignment-%s and payload: +{"rule":{"assignment_id":%q,"signal_ref":%q,"assignee":%q,"scope":"managed-runtime/seeded-teamwork","ttl":"20m"},"narrative":{"expected_work":"Inspect the hook-rendered managed work brief and report whether the sentinel-driven turn can advance the teamwork task.","expected_feedback":"progress_digest with evidence from the managed wake turn"},"refs":{"evidence_refs":["seed signal %s"]}} +Do not contact the worker directly. After both commands succeed, answer "seed written".`, runID, signalID, runID, assignmentID, signalID, worker.principal, signalID) + report.PromptAudit = append(report.PromptAudit, managedRuntimePromptAuditEntry{Principal: entry.principal, Kind: "entry_seed", Query: seedPrompt}) + answer, err := runR1Turn(&entry.r1CodexAgent, seedPrompt, opts.TurnTimeout) + _ = answer + if err != nil { + addManagedAssertion(&report, "managed-runtime entry seed accepted", false, err.Error()) + return managedRuntimeFailed(report, err) + } + waitForLedgerCount(entry.localURL, entry.r1CodexAgent, "assignment", 1, 30*time.Second) + entryCounts := countR1Ledger(entry.localURL, entry.r1CodexAgent) + addManagedAssertion(&report, "managed-runtime entry seed accepted", entryCounts["teamwork_signal"] >= 1 && entryCounts["assignment"] >= 1, fmt.Sprintf("teamwork_signal=%d assignment=%d", entryCounts["teamwork_signal"], entryCounts["assignment"])) + + workerRecord, err := managedRuntimeWakeExistingAgent(ctx, opts, worker, []string{assignmentID}) + if err != nil { + addManagedAssertion(&report, "managed-runtime worker woke from local mnemond driver", false, err.Error()) + return managedRuntimeFailed(report, err) + } + report.DriverWakes = append(report.DriverWakes, workerRecord) + report.PromptAudit = append(report.PromptAudit, managedRuntimePromptAuditEntry{Principal: worker.principal, Kind: "raw_managed_wake", Query: workerRecord.Query}) + addManagedAssertion(&report, "managed-runtime worker woke from local mnemond driver", workerRecord.Query == driver.ManagedWakeQuery && workerRecord.RenderAuditID != "", fmt.Sprintf("query=%q render_audit=%s", workerRecord.Query, workerRecord.RenderAuditID)) + waitForLedgerCount(worker.localURL, worker.r1CodexAgent, "progress_digest", 1, 60*time.Second) + workerCounts := countR1Ledger(worker.localURL, worker.r1CodexAgent) + addManagedAssertion(&report, "managed-runtime worker emitted progress through normal events", workerCounts["progress_digest"] >= 1, fmt.Sprintf("progress_digest=%d", workerCounts["progress_digest"])) + + leadRecord, err := managedRuntimeWakeExistingAgent(ctx, opts, entry, []string{assignmentID}) + if err != nil { + addManagedAssertion(&report, "managed-runtime lead woke for integrate", false, err.Error()) + return managedRuntimeFailed(report, err) + } + report.DriverWakes = append(report.DriverWakes, leadRecord) + report.PromptAudit = append(report.PromptAudit, managedRuntimePromptAuditEntry{Principal: entry.principal, Kind: "raw_managed_wake", Query: leadRecord.Query}) + addManagedAssertion(&report, "managed-runtime lead woke for integrate", leadRecord.Query == driver.ManagedWakeQuery && leadRecord.RenderAuditID != "", fmt.Sprintf("query=%q render_audit=%s", leadRecord.Query, leadRecord.RenderAuditID)) + + report.LedgerCounts = countR1Ledger(entry.localURL, entry.r1CodexAgent) + report.Scenarios = append(report.Scenarios, r1TaskSimScenarioReport{ + Name: "seeded_teamwork_managed_wake", + Status: statusFromBool(managedRuntimeAssertionsPassed(report)), + Actors: []string{entry.principal, worker.principal}, + Evidence: map[string]any{ + "assignment": assignmentID, + "driver_wakes": len(report.DriverWakes), + "raw_query": driver.ManagedWakeQuery, + }, + }) + addManagedAssertion(&report, "raw managed queries are sentinel only", managedRuntimeRawQueriesSentinel(report), fmt.Sprintf("wakes=%d", len(report.DriverWakes))) + addManagedAssertion(&report, "runner role is seed-and-observe", report.RunnerRole == "seed_and_observe", report.RunnerRole) + addManagedAssertion(&report, "no direct worker business prompts", managedRuntimeDirectWorkerPromptCount(report) == 0, "only entry_seed and raw_managed_wake prompts recorded") + if len(report.Errors) == 0 && managedRuntimeAssertionsPassed(report) { + report.Status = "ok" + return finishAndWriteManagedRuntimeReport(report) + } + return managedRuntimeFailed(report, fmt.Errorf("managed-runtime acceptance failed")) +} + +type managedRuntimeExistingCodexClient struct { + agent *r1CodexAgent + timeout time.Duration +} + +func (c managedRuntimeExistingCodexClient) StartTurn(_ context.Context, query string) (driver.ManagedTurnResult, error) { + if strings.TrimSpace(query) != driver.ManagedWakeQuery { + return driver.ManagedTurnResult{}, fmt.Errorf("managed acceptance client only accepts %q queries", driver.ManagedWakeQuery) + } + answer, err := runR1Turn(c.agent, driver.ManagedWakeQuery, c.timeout) + status := "completed" + if err != nil { + status = "failed" + } + return driver.ManagedTurnResult{TurnID: c.agent.threadID, Status: status, FinalAnswer: answer}, err +} + +func managedRuntimeWakeExistingAgent(ctx context.Context, opts managedRuntimeAcceptanceOptions, agent *r1CodexSyncAgent, bodyWants []string) (driver.ManagedWakeRecord, error) { + resp, ok := waitForR1DerivedEventPresentation(agent.localURL, agent.token, bodyWants, 90*time.Second) + if !ok { + return driver.ManagedWakeRecord{}, fmt.Errorf("%s did not receive derived presentation containing %v", agent.principal, bodyWants) + } + candidate, ok := managedRuntimeCandidateFromRender(agent.principal, resp, bodyWants) + if !ok { + return driver.ManagedWakeRecord{}, fmt.Errorf("%s render had no wake candidate matching %v", agent.principal, bodyWants) + } + ledgerPath := filepath.Join(agent.workspace, ".mnemon", "harness", "local", "managed-agent", "wake-ledger.jsonl") + managed := &driver.ManagedAgentDriver{ + Principal: agent.principal, + Client: managedRuntimeExistingCodexClient{agent: &agent.r1CodexAgent, timeout: opts.TurnTimeout}, + Ledger: driver.NewFileManagedWakeLedger(ledgerPath), + Now: func() time.Time { return time.Now().UTC() }, + } + return managed.Wake(ctx, candidate) +} + +func managedRuntimeCandidateFromRender(principal string, resp presentation.Response, bodyWants []string) (driver.ManagedWakeCandidate, bool) { + for _, env := range resp.Events { + if string(env.Event.Audience) != principal { + continue + } + body, _ := eventmodel.PayloadNarrative(env.Event.Payload)["body"].(string) + if !managedRuntimeContainsAll(body, bodyWants) { + continue + } + candidates := driver.ManagedWakeCandidatesFromEvents(principal, []eventmodel.EventEnvelope{env}) + if len(candidates) == 0 { + continue + } + candidates[0].RenderAuditID = resp.AuditID + candidates[0].RenderBodyDigest = resp.BodyDigest + return candidates[0], true + } + return driver.ManagedWakeCandidate{}, false +} + +func managedRuntimeContainsAll(body string, wants []string) bool { + for _, want := range wants { + if strings.TrimSpace(want) != "" && !strings.Contains(body, want) { + return false + } + } + return true +} + +func managedRuntimeRawQueriesSentinel(report managedRuntimeAcceptanceReport) bool { + for _, entry := range report.PromptAudit { + if entry.Kind != "raw_managed_wake" { + continue + } + if strings.TrimSpace(entry.Query) != driver.ManagedWakeQuery { + return false + } + } + return true +} + +func managedRuntimeBlocked(report managedRuntimeAcceptanceReport, err error) (managedRuntimeAcceptanceReport, error) { + report.Status = "blocked" + report.Errors = append(report.Errors, err.Error()) + written, writeErr := finishAndWriteManagedRuntimeReport(report) + if writeErr != nil { + return written, writeErr + } + return written, err +} + +func managedRuntimeFailed(report managedRuntimeAcceptanceReport, err error) (managedRuntimeAcceptanceReport, error) { + report.Status = "failed" + if err != nil { + report.Errors = append(report.Errors, err.Error()) + } + written, writeErr := finishAndWriteManagedRuntimeReport(report) + if writeErr != nil { + return written, writeErr + } + return written, err +} + func managedRuntimeMnemondDryRunWake(ctx context.Context, opts managedRuntimeAcceptanceOptions, principal string) (string, error) { timeout := opts.TurnTimeout if timeout <= 0 { diff --git a/harness/cmd/mnemond/agent.go b/harness/cmd/mnemond/agent.go index 65298bcf..93375342 100644 --- a/harness/cmd/mnemond/agent.go +++ b/harness/cmd/mnemond/agent.go @@ -94,7 +94,7 @@ func runAgentRun(ctx context.Context, args []string, out, errw io.Writer) error } candidate = candidates[0] } - client, err := managedTurnClient(*runtimeName, *command, *workspace, *codexHome, *turnTimeout) + client, err := managedTurnClient(localPrincipal, *runtimeName, *command, *workspace, *codexHome, *turnTimeout) if err != nil { return err } @@ -113,7 +113,7 @@ func runAgentRun(ctx context.Context, args []string, out, errw io.Writer) error return writeManagedAgentRecord(out, record) } -func managedTurnClient(runtimeName, command, workspace, codexHome string, turnTimeout time.Duration) (driver.ManagedTurnClient, error) { +func managedTurnClient(principal, runtimeName, command, workspace, codexHome string, turnTimeout time.Duration) (driver.ManagedTurnClient, error) { switch strings.TrimSpace(runtimeName) { case "", "noop": return noopManagedTurnClient{}, nil @@ -123,6 +123,7 @@ func managedTurnClient(runtimeName, command, workspace, codexHome string, turnTi env = setAgentEnv(env, "CODEX_HOME", strings.TrimSpace(codexHome)) } return driver.CodexAppServerTurnClient{ + Principal: strings.TrimSpace(principal), Command: strings.TrimSpace(command), Workspace: strings.TrimSpace(workspace), Env: env, diff --git a/harness/internal/driver/managed_runtime.go b/harness/internal/driver/managed_runtime.go index 238e9681..e88b56ea 100644 --- a/harness/internal/driver/managed_runtime.go +++ b/harness/internal/driver/managed_runtime.go @@ -171,6 +171,7 @@ func (l *FileManagedWakeLedger) loadLocked() { } type CodexAppServerTurnClient struct { + Principal string Command string Workspace string Env []string @@ -262,8 +263,12 @@ func (c CodexAppServerTurnClient) developerInstructions() string { if strings.TrimSpace(c.DeveloperInstructions) != "" { return c.DeveloperInstructions } - return `You are a mnemond-managed Mnemon agent. + principal := strings.TrimSpace(c.Principal) + if principal == "" { + principal = "the local managed principal" + } + return fmt.Sprintf(`You are %s in a mnemond-managed Mnemon runtime. When the user input is [mnemon:wake], treat it only as a local wake signal. Use the normal Mnemon hooks/skills and Local Mnemon commands in this workspace to inspect governed context and decide whether to act. -Do not expect assignment details in the raw wake query. Keep any governed event drafts short and emit them through Local Mnemon.` +Do not expect assignment details in the raw wake query. Keep any governed event drafts short and emit them through Local Mnemon.`, principal) } From 7ad745f27f43341d5f7e8dc06f16d199f0ab09f4 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 14:03:12 +0800 Subject: [PATCH 7/8] test(harness): stabilize managed runtime live acceptance Use absolute run roots for managed acceptance and initialize managed-runtime appserver threads with generic wake instructions that render teamwork context and payload contracts before acting. This keeps [mnemon:wake] context-free while making accepted progress events reliable in both exchange modes. Validation: go test ./harness/cmd/mnemon-acceptance; go test ./harness/cmd/... ./harness/internal/driver ./harness/internal/coreguard ./harness/internal/mnemond/presentation ./harness/internal/hostagent; built mnemon-harness, mnemond, mnemon-hub, and mnemon-acceptance; live managed-runtime acceptance passed for mnemonhub and GitHub mesh. --- .../acceptance_managed_runtime.go | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go index 3a94fd13..1b523458 100644 --- a/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go +++ b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/mnemon-dev/mnemon/harness/internal/codexapp" "github.com/mnemon-dev/mnemon/harness/internal/driver" eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation" @@ -166,6 +167,11 @@ func runManagedRuntimeAcceptance(ctx context.Context, opts managedRuntimeAccepta if runRoot == "" { runRoot = filepath.Join(".testdata", "managed-runtime", started.Format("20060102T150405Z")) } + absRunRoot, err := filepath.Abs(runRoot) + if err != nil { + return managedRuntimeAcceptanceReport{}, err + } + runRoot = absRunRoot report := managedRuntimeAcceptanceReport{ SchemaVersion: 1, Status: "ok", @@ -334,7 +340,7 @@ func runManagedRuntimeRealScenario(ctx context.Context, opts managedRuntimeAccep if err := startR1CodexAppserver(&agents[i].r1CodexAgent, opts.Command); err != nil { return managedRuntimeBlocked(report, err) } - agentReport, _, err := initializeR1CodexAgent(&agents[i].r1CodexAgent, opts.TurnTimeout) + agentReport, _, err := initializeManagedRuntimeCodexAgent(&agents[i].r1CodexAgent) if err != nil { return managedRuntimeBlocked(report, err) } @@ -411,6 +417,63 @@ Do not contact the worker directly. After both commands succeed, answer "seed wr return managedRuntimeFailed(report, fmt.Errorf("managed-runtime acceptance failed")) } +func initializeManagedRuntimeCodexAgent(agent *r1CodexAgent) (r1CodexAgentReport, json.RawMessage, error) { + initResp, err := agent.server.Request("initialize", map[string]any{ + "clientInfo": map[string]any{"name": "mnemon-managed-runtime-acceptance", "version": version}, + }, 30*time.Second) + if err != nil { + return r1CodexAgentReport{}, nil, fmt.Errorf("%s: initialize: %w", agent.principal, err) + } + _ = initResp + hooksResp, err := agent.server.Request("hooks/list", map[string]any{"cwds": []string{agent.workspace}}, 30*time.Second) + if err != nil { + return r1CodexAgentReport{}, nil, fmt.Errorf("%s: hooks/list: %w", agent.principal, err) + } + hooksRaw, _ := json.Marshal(hooksResp) + hooks := collectHookMetadata(hooksResp) + thread, err := agent.server.Request("thread/start", map[string]any{ + "cwd": agent.workspace, + "approvalPolicy": "never", + "sandbox": "danger-full-access", + "ephemeral": true, + "developerInstructions": managedRuntimeDeveloperInstructions(agent.principal), + }, 30*time.Second) + if err != nil { + return r1CodexAgentReport{}, hooksRaw, fmt.Errorf("%s: thread/start: %w", agent.principal, err) + } + agent.threadID = codexapp.ThreadID(thread) + if agent.threadID == "" { + return r1CodexAgentReport{}, hooksRaw, fmt.Errorf("%s: thread/start returned no thread id", agent.principal) + } + rendered, err := runManualR1HookReminder(agent) + if err != nil { + return r1CodexAgentReport{}, hooksRaw, err + } + return r1CodexAgentReport{ + Principal: agent.principal, + Workspace: agent.workspace, + CodexHome: agent.codexHome, + ThreadID: agent.threadID, + HookCount: len(hooks), + HookTrustStatuses: hookTrustStatuses(hooks), + ManualHookReminded: strings.Contains(rendered, "governed context") || strings.Contains(rendered, "systemMessage"), + }, hooksRaw, nil +} + +func managedRuntimeDeveloperInstructions(principal string) string { + return fmt.Sprintf(`You are %s in a Mnemon managed-runtime acceptance run. +The user input [mnemon:wake] is only a local wake signal. It is not task context. +On a wake, inspect governed Mnemon context through the normal hook/skill surface and Local Mnemon commands from the workspace root: + . .mnemon/harness/local/env.sh + mnemon-harness control render --addr "$MNEMON_CONTROL_ADDR" --principal "$MNEMON_CONTROL_PRINCIPAL" --token-file "$MNEMON_CONTROL_TOKEN_FILE" --intent teamwork.events --lifecycle remind --surface agent + mnemon-harness control render --addr "$MNEMON_CONTROL_ADDR" --principal "$MNEMON_CONTROL_PRINCIPAL" --token-file "$MNEMON_CONTROL_TOKEN_FILE" --intent payload.contract --lifecycle remind --surface agent + mnemon-harness control teamwork progress --addr "$MNEMON_CONTROL_ADDR" --principal "$MNEMON_CONTROL_PRINCIPAL" --token-file "$MNEMON_CONTROL_TOKEN_FILE" --assignment-ref --summary "" --external-id + mnemon-harness control observe --addr "$MNEMON_CONTROL_ADDR" --principal "$MNEMON_CONTROL_PRINCIPAL" --token-file "$MNEMON_CONTROL_TOKEN_FILE" --type --external-id --payload '' +If the rendered brief contains [mnemon:work], you may complete the addressed work and emit a short progress_digest.write_candidate.observed event with assignment_ref from the brief. +If the rendered brief contains [mnemon:integrate], you may integrate, close, or assign follow-up work through normal governed events. +Do not say an event was written unless the Local Mnemon command succeeded. Keep final answers brief and name the governed event actually written.`, principal) +} + type managedRuntimeExistingCodexClient struct { agent *r1CodexAgent timeout time.Duration From 634ec155683df1f73e24e46d44297547787c81b2 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 15:03:01 +0800 Subject: [PATCH 8/8] fix(harness): route managed wake through standard hooks Remove managed-runtime acceptance developer instructions and pass standard Codex prime/remind hook output through app-server additionalContext while keeping the raw managed query as [mnemon:wake]. The local env, GUIDE, and control CLI now agree on the projected Local Mnemon endpoint, and bridge stamping narrows read sets to written refs so disjoint coordination writes from one turn do not reject each other. Validation: go test ./harness/...; make harness-validate; go build -o /tmp/mnemon .; real managed-runtime acceptance passed for mnemonhub and github exchanges. --- harness/cmd/mnemon-acceptance/acceptance.go | 24 +++- .../acceptance_github_mesh.go | 1 + .../acceptance_managed_runtime.go | 34 ++--- .../acceptance_managed_runtime_test.go | 11 ++ harness/cmd/mnemon-harness/control.go | 15 +- harness/internal/app/setup.go | 7 +- .../assets/guides/mnemon-harness-guide.md | 26 ++-- harness/internal/driver/managed_runtime.go | 135 +++++++++++++----- .../internal/driver/managed_runtime_test.go | 56 ++++++++ harness/internal/hostagent/thinshim.go | 17 ++- harness/internal/hostagent/thinshim_test.go | 4 +- harness/internal/runtime/bridge.go | 30 ++-- harness/internal/runtime/bridge_test.go | 12 +- 13 files changed, 281 insertions(+), 91 deletions(-) diff --git a/harness/cmd/mnemon-acceptance/acceptance.go b/harness/cmd/mnemon-acceptance/acceptance.go index 31ac680b..d3db9968 100644 --- a/harness/cmd/mnemon-acceptance/acceptance.go +++ b/harness/cmd/mnemon-acceptance/acceptance.go @@ -515,6 +515,7 @@ func setupR1CodexAgents(runRoot, binDir, controlURL string, count int, sourceCod Host: "codex", ControlURL: controlURL, Principal: principal, + HarnessBin: filepath.Join(binDir, "mnemon-harness"), ProjectRoot: workspace, UseToken: true, }); err != nil { @@ -684,7 +685,8 @@ func startR1CodexAppserver(agent *r1CodexAgent, command string) error { func initializeR1CodexAgent(agent *r1CodexAgent, turnTimeout time.Duration) (r1CodexAgentReport, json.RawMessage, error) { initResp, err := agent.server.Request("initialize", map[string]any{ - "clientInfo": map[string]any{"name": "mnemon-r1-codex-acceptance", "version": version}, + "clientInfo": map[string]any{"name": "mnemon-r1-codex-acceptance", "version": version}, + "capabilities": codexAppServerCapabilities(), }, 30*time.Second) if err != nil { return r1CodexAgentReport{}, nil, fmt.Errorf("%s: initialize: %w", agent.principal, err) @@ -727,6 +729,13 @@ func initializeR1CodexAgent(agent *r1CodexAgent, turnTimeout time.Duration) (r1C return report, hooksRaw, nil } +func codexAppServerCapabilities() map[string]any { + return map[string]any{ + "experimentalApi": true, + "requestAttestation": false, + } +} + func r1AcceptanceDeveloperInstructions(principal string) string { return fmt.Sprintf(`You are %s in a Mnemon R1 real Codex cluster acceptance run. Follow the managed Mnemon GUIDE and the mnemon-observe skill. Read governed context when it is relevant, then write governed events through Local Mnemon from the shell. @@ -1167,6 +1176,7 @@ func setupR1CodexSyncAgents(ctx context.Context, runRoot, binDir string, hub r1S Host: "codex", ControlURL: localURL, Principal: principal, + HarnessBin: filepath.Join(binDir, "mnemon-harness"), ProjectRoot: workspace, UseToken: true, }); err != nil { @@ -1273,14 +1283,22 @@ func waitForR1DerivedEventPresentation(controlURL, token string, wants []string, } func runR1Turn(agent *r1CodexAgent, prompt string, timeout time.Duration) (string, error) { + return runR1TurnWithAdditionalContext(agent, prompt, timeout, nil) +} + +func runR1TurnWithAdditionalContext(agent *r1CodexAgent, prompt string, timeout time.Duration, additionalContext map[string]any) (string, error) { before := agent.server.NotificationCount() - if _, err := agent.server.Request("turn/start", map[string]any{ + params := map[string]any{ "threadId": agent.threadID, "input": []map[string]any{{"type": "text", "text": prompt}}, "cwd": agent.workspace, "approvalPolicy": "never", "sandboxPolicy": map[string]any{"type": "dangerFullAccess"}, - }, 30*time.Second); err != nil { + } + if len(additionalContext) > 0 { + params["additionalContext"] = additionalContext + } + if _, err := agent.server.Request("turn/start", params, 30*time.Second); err != nil { return "", fmt.Errorf("%s: turn/start: %w", agent.principal, err) } if _, err := agent.server.WaitNotification("turn/completed", timeout, before); err != nil { diff --git a/harness/cmd/mnemon-acceptance/acceptance_github_mesh.go b/harness/cmd/mnemon-acceptance/acceptance_github_mesh.go index b13e16fc..16b54716 100644 --- a/harness/cmd/mnemon-acceptance/acceptance_github_mesh.go +++ b/harness/cmd/mnemon-acceptance/acceptance_github_mesh.go @@ -1162,6 +1162,7 @@ func setupR1CodexGitHubMeshAgents(ctx context.Context, runRoot, binDir, repo, to Host: "codex", ControlURL: localURL, Principal: principal, + HarnessBin: filepath.Join(binDir, "mnemon-harness"), ProjectRoot: workspace, UseToken: true, }); err != nil { diff --git a/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go index 1b523458..b584cda3 100644 --- a/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go +++ b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime.go @@ -419,7 +419,8 @@ Do not contact the worker directly. After both commands succeed, answer "seed wr func initializeManagedRuntimeCodexAgent(agent *r1CodexAgent) (r1CodexAgentReport, json.RawMessage, error) { initResp, err := agent.server.Request("initialize", map[string]any{ - "clientInfo": map[string]any{"name": "mnemon-managed-runtime-acceptance", "version": version}, + "clientInfo": map[string]any{"name": "mnemon-managed-runtime-acceptance", "version": version}, + "capabilities": codexAppServerCapabilities(), }, 30*time.Second) if err != nil { return r1CodexAgentReport{}, nil, fmt.Errorf("%s: initialize: %w", agent.principal, err) @@ -432,11 +433,10 @@ func initializeManagedRuntimeCodexAgent(agent *r1CodexAgent) (r1CodexAgentReport hooksRaw, _ := json.Marshal(hooksResp) hooks := collectHookMetadata(hooksResp) thread, err := agent.server.Request("thread/start", map[string]any{ - "cwd": agent.workspace, - "approvalPolicy": "never", - "sandbox": "danger-full-access", - "ephemeral": true, - "developerInstructions": managedRuntimeDeveloperInstructions(agent.principal), + "cwd": agent.workspace, + "approvalPolicy": "never", + "sandbox": "danger-full-access", + "ephemeral": true, }, 30*time.Second) if err != nil { return r1CodexAgentReport{}, hooksRaw, fmt.Errorf("%s: thread/start: %w", agent.principal, err) @@ -460,30 +460,20 @@ func initializeManagedRuntimeCodexAgent(agent *r1CodexAgent) (r1CodexAgentReport }, hooksRaw, nil } -func managedRuntimeDeveloperInstructions(principal string) string { - return fmt.Sprintf(`You are %s in a Mnemon managed-runtime acceptance run. -The user input [mnemon:wake] is only a local wake signal. It is not task context. -On a wake, inspect governed Mnemon context through the normal hook/skill surface and Local Mnemon commands from the workspace root: - . .mnemon/harness/local/env.sh - mnemon-harness control render --addr "$MNEMON_CONTROL_ADDR" --principal "$MNEMON_CONTROL_PRINCIPAL" --token-file "$MNEMON_CONTROL_TOKEN_FILE" --intent teamwork.events --lifecycle remind --surface agent - mnemon-harness control render --addr "$MNEMON_CONTROL_ADDR" --principal "$MNEMON_CONTROL_PRINCIPAL" --token-file "$MNEMON_CONTROL_TOKEN_FILE" --intent payload.contract --lifecycle remind --surface agent - mnemon-harness control teamwork progress --addr "$MNEMON_CONTROL_ADDR" --principal "$MNEMON_CONTROL_PRINCIPAL" --token-file "$MNEMON_CONTROL_TOKEN_FILE" --assignment-ref --summary "" --external-id - mnemon-harness control observe --addr "$MNEMON_CONTROL_ADDR" --principal "$MNEMON_CONTROL_PRINCIPAL" --token-file "$MNEMON_CONTROL_TOKEN_FILE" --type --external-id --payload '' -If the rendered brief contains [mnemon:work], you may complete the addressed work and emit a short progress_digest.write_candidate.observed event with assignment_ref from the brief. -If the rendered brief contains [mnemon:integrate], you may integrate, close, or assign follow-up work through normal governed events. -Do not say an event was written unless the Local Mnemon command succeeded. Keep final answers brief and name the governed event actually written.`, principal) -} - type managedRuntimeExistingCodexClient struct { agent *r1CodexAgent timeout time.Duration } -func (c managedRuntimeExistingCodexClient) StartTurn(_ context.Context, query string) (driver.ManagedTurnResult, error) { +func (c managedRuntimeExistingCodexClient) StartTurn(ctx context.Context, query string) (driver.ManagedTurnResult, error) { if strings.TrimSpace(query) != driver.ManagedWakeQuery { return driver.ManagedTurnResult{}, fmt.Errorf("managed acceptance client only accepts %q queries", driver.ManagedWakeQuery) } - answer, err := runR1Turn(c.agent, driver.ManagedWakeQuery, c.timeout) + additionalContext, err := driver.CodexAppServerAdditionalContext(ctx, c.agent.workspace, c.agent.env, driver.ManagedWakeQuery) + if err != nil { + return driver.ManagedTurnResult{}, err + } + answer, err := runR1TurnWithAdditionalContext(c.agent, driver.ManagedWakeQuery, c.timeout, additionalContext) status := "completed" if err != nil { status = "failed" diff --git a/harness/cmd/mnemon-acceptance/acceptance_managed_runtime_test.go b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime_test.go index 6c455e1c..d7688f45 100644 --- a/harness/cmd/mnemon-acceptance/acceptance_managed_runtime_test.go +++ b/harness/cmd/mnemon-acceptance/acceptance_managed_runtime_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "os" "strings" "testing" @@ -82,3 +83,13 @@ func TestManagedRuntimeGitHubRequiresTokenFile(t *testing.T) { t.Fatalf("github blocker mismatch: status=%s err=%v report=%+v", report.Status, err, report) } } + +func TestManagedRuntimeAcceptanceDoesNotInjectDeveloperInstructions(t *testing.T) { + raw, err := os.ReadFile("acceptance_managed_runtime.go") + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "developerInstructions") { + t.Fatal("managed-runtime acceptance must rely on standard GUIDE/hook/render flow, not acceptance-specific developer instructions") + } +} diff --git a/harness/cmd/mnemon-harness/control.go b/harness/cmd/mnemon-harness/control.go index 284c77b4..721992c4 100644 --- a/harness/cmd/mnemon-harness/control.go +++ b/harness/cmd/mnemon-harness/control.go @@ -266,10 +266,10 @@ func init() { controlLeafCommands := []*cobra.Command{controlObserveCmd, controlPullCmd, controlStatusCmd, controlRenderCmd} controlLeafCommands = append(controlLeafCommands, controlShortObserveCommands()...) for _, c := range controlLeafCommands { - c.Flags().StringVar(&controlAddr, "addr", "http://127.0.0.1:8787", "server base URL") - c.Flags().StringVar(&controlPrincipal, "principal", "", "authenticated principal (trusted-header transport)") - c.Flags().StringVar(&controlToken, "token", "", "bearer token (TokenAuthenticator transport)") - c.Flags().StringVar(&controlTokenFile, "token-file", "", "read the bearer token from a file (keeps tokens out of prompt-visible command lines)") + c.Flags().StringVar(&controlAddr, "addr", envDefault("MNEMON_CONTROL_ADDR", "http://127.0.0.1:8787"), "server base URL") + c.Flags().StringVar(&controlPrincipal, "principal", envDefault("MNEMON_CONTROL_PRINCIPAL", ""), "authenticated principal (trusted-header transport)") + c.Flags().StringVar(&controlToken, "token", envDefault("MNEMON_CONTROL_TOKEN", ""), "bearer token (TokenAuthenticator transport)") + c.Flags().StringVar(&controlTokenFile, "token-file", envDefault("MNEMON_CONTROL_TOKEN_FILE", ""), "read the bearer token from a file (keeps tokens out of prompt-visible command lines)") } controlObserveCmd.Flags().StringVar(&controlType, "type", "", "observed event type") controlObserveCmd.Flags().StringVar(&controlPayload, "payload", "", "observation payload as JSON") @@ -286,3 +286,10 @@ func init() { controlCmd.GroupID = groupSpine rootCmd.AddCommand(controlCmd) } + +func envDefault(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} diff --git a/harness/internal/app/setup.go b/harness/internal/app/setup.go index 50ff4206..0169b79d 100644 --- a/harness/internal/app/setup.go +++ b/harness/internal/app/setup.go @@ -30,6 +30,7 @@ type SetupOptions struct { ControlURL string // channel endpoint, e.g. "http://127.0.0.1:8787" Principal string // authenticated principal, e.g. "codex@project" ActorKind string // "host-agent" (default) or "control-agent" + HarnessBin string // command or absolute path agents should use for Local Mnemon CLI calls UseToken bool // generate + reference a bearer token file (vs trusted-header auth) TokenExplicit bool // true when the caller explicitly set UseToken ProjectRoot string // host projection working dir (defaults to the facade root) @@ -341,9 +342,13 @@ func writeHostObserveSkill(projectRoot, host string) error { } func writeLocalEnv(path string, opts SetupOptions, tokenRel string, loops []string) error { + harnessBin := strings.TrimSpace(opts.HarnessBin) + if harnessBin == "" { + harnessBin = "mnemon-harness" + } var b strings.Builder b.WriteString("# Managed by mnemon-harness setup - Local Mnemon environment.\n") - b.WriteString(exportLine("MNEMON_HARNESS_BIN", "mnemon-harness")) + b.WriteString(exportLine("MNEMON_HARNESS_BIN", harnessBin)) b.WriteString(exportLine("MNEMON_CONTROL_ADDR", opts.ControlURL)) b.WriteString(exportLine("MNEMON_CONTROL_PRINCIPAL", opts.Principal)) if tokenRel != "" { diff --git a/harness/internal/assets/guides/mnemon-harness-guide.md b/harness/internal/assets/guides/mnemon-harness-guide.md index 9e4a4141..7ef49459 100644 --- a/harness/internal/assets/guides/mnemon-harness-guide.md +++ b/harness/internal/assets/guides/mnemon-harness-guide.md @@ -7,6 +7,7 @@ Mnemon is the governed event layer for durable agent context. Use it when the cu - Before substantive work, decide whether governed context should be read. - After substantive work, decide whether durable state should be recorded. - Before context compaction, preserve important continuity through Mnemon when needed. +- `[mnemon:wake]` is only a local wake signal. ## Read @@ -15,11 +16,12 @@ Use the generic skill or CLI to inspect current state before acting when the pro Useful commands: ```bash -mnemon-harness control pull -mnemon-harness control render --intent context.packet -mnemon-harness control render --intent teamwork.events -mnemon-harness loop packages -mnemon-harness loop schema --type +. .mnemon/harness/local/env.sh +"${MNEMON_HARNESS_BIN:-mnemon-harness}" control pull +"${MNEMON_HARNESS_BIN:-mnemon-harness}" control render --intent context.packet +"${MNEMON_HARNESS_BIN:-mnemon-harness}" control render --intent teamwork.events +"${MNEMON_HARNESS_BIN:-mnemon-harness}" loop packages +"${MNEMON_HARNESS_BIN:-mnemon-harness}" loop schema --type ``` ## Record @@ -29,26 +31,27 @@ Emit governed events through Mnemon. Do not write `.mnemon` state files directly Prefer the short teamwork/profile commands when they fit the event you need: ```bash -mnemon-harness control teamwork signal \ +. .mnemon/harness/local/env.sh +"${MNEMON_HARNESS_BIN:-mnemon-harness}" control teamwork signal \ --scope \ --statement "" \ --why-teamwork "" \ --evidence "" \ --external-id -mnemon-harness control teamwork assign \ +"${MNEMON_HARNESS_BIN:-mnemon-harness}" control teamwork assign \ --assignee \ --scope \ --work "" \ --evidence "" \ --external-id -mnemon-harness control teamwork progress \ +"${MNEMON_HARNESS_BIN:-mnemon-harness}" control teamwork progress \ --assignment-ref \ --summary "" \ --external-id -mnemon-harness control profile update \ +"${MNEMON_HARNESS_BIN:-mnemon-harness}" control profile update \ --focus "" \ --advantage "" \ --summary "" \ @@ -58,13 +61,14 @@ mnemon-harness control profile update \ Use the low-level observe API only when you need fields the short commands do not expose: ```bash -mnemon-harness control observe \ +. .mnemon/harness/local/env.sh +"${MNEMON_HARNESS_BIN:-mnemon-harness}" control observe \ --type .write_candidate.observed \ --payload '{"rule":{...},"narrative":{...},"refs":{...}}' \ --external-id ``` -Check `mnemon-harness loop schema --type ` before guessing payload fields. +Check `"${MNEMON_HARNESS_BIN:-mnemon-harness}" loop schema --type ` before guessing payload fields. ## Teamwork Events diff --git a/harness/internal/driver/managed_runtime.go b/harness/internal/driver/managed_runtime.go index e88b56ea..41ce2710 100644 --- a/harness/internal/driver/managed_runtime.go +++ b/harness/internal/driver/managed_runtime.go @@ -10,6 +10,7 @@ import ( "io" "net/http" "os" + "os/exec" "path/filepath" "strings" "sync" @@ -171,18 +172,17 @@ func (l *FileManagedWakeLedger) loadLocked() { } type CodexAppServerTurnClient struct { - Principal string - Command string - Workspace string - Env []string - DeveloperInstructions string - TurnTimeout time.Duration - RequestTimeout time.Duration - ClientName string - ClientVersion string + Principal string + Command string + Workspace string + Env []string + TurnTimeout time.Duration + RequestTimeout time.Duration + ClientName string + ClientVersion string } -func (c CodexAppServerTurnClient) StartTurn(_ context.Context, query string) (ManagedTurnResult, error) { +func (c CodexAppServerTurnClient) StartTurn(ctx context.Context, query string) (ManagedTurnResult, error) { if strings.TrimSpace(query) != ManagedWakeQuery { return ManagedTurnResult{}, fmt.Errorf("managed codex appserver client only accepts %q queries", ManagedWakeQuery) } @@ -219,17 +219,18 @@ func (c CodexAppServerTurnClient) StartTurn(_ context.Context, query string) (Ma } defer server.Close() if _, err := server.Request("initialize", map[string]any{ - "clientInfo": map[string]any{"name": clientName, "version": clientVersion}, + "clientInfo": map[string]any{"name": clientName, "version": clientVersion}, + "capabilities": codexAppServerCapabilities(), }, requestTimeout); err != nil { return ManagedTurnResult{}, fmt.Errorf("initialize: %w", err) } - thread, err := server.Request("thread/start", map[string]any{ - "cwd": workspace, - "approvalPolicy": "never", - "sandbox": "danger-full-access", - "ephemeral": true, - "developerInstructions": c.developerInstructions(), - }, requestTimeout) + threadParams := map[string]any{ + "cwd": workspace, + "approvalPolicy": "never", + "sandbox": "danger-full-access", + "ephemeral": true, + } + thread, err := server.Request("thread/start", threadParams, requestTimeout) if err != nil { return ManagedTurnResult{}, fmt.Errorf("thread/start: %w", err) } @@ -237,14 +238,22 @@ func (c CodexAppServerTurnClient) StartTurn(_ context.Context, query string) (Ma if threadID == "" { return ManagedTurnResult{}, fmt.Errorf("thread/start returned no thread id") } - before := server.NotificationCount() - if _, err := server.Request("turn/start", map[string]any{ + additionalContext, err := CodexAppServerAdditionalContext(ctx, workspace, c.Env, ManagedWakeQuery) + if err != nil { + return ManagedTurnResult{}, fmt.Errorf("load hook context: %w", err) + } + turnParams := map[string]any{ "threadId": threadID, "input": []map[string]any{{"type": "text", "text": ManagedWakeQuery}}, "cwd": workspace, "approvalPolicy": "never", "sandboxPolicy": map[string]any{"type": "dangerFullAccess"}, - }, requestTimeout); err != nil { + } + if len(additionalContext) > 0 { + turnParams["additionalContext"] = additionalContext + } + before := server.NotificationCount() + if _, err := server.Request("turn/start", turnParams, requestTimeout); err != nil { return ManagedTurnResult{}, fmt.Errorf("turn/start: %w", err) } if _, err := server.WaitNotification("turn/completed", turnTimeout, before); err != nil { @@ -259,16 +268,76 @@ func (c CodexAppServerTurnClient) StartTurn(_ context.Context, query string) (Ma return ManagedTurnResult{TurnID: threadID, Status: "completed", FinalAnswer: answer}, nil } -func (c CodexAppServerTurnClient) developerInstructions() string { - if strings.TrimSpace(c.DeveloperInstructions) != "" { - return c.DeveloperInstructions - } - principal := strings.TrimSpace(c.Principal) - if principal == "" { - principal = "the local managed principal" - } - return fmt.Sprintf(`You are %s in a mnemond-managed Mnemon runtime. -When the user input is [mnemon:wake], treat it only as a local wake signal. -Use the normal Mnemon hooks/skills and Local Mnemon commands in this workspace to inspect governed context and decide whether to act. -Do not expect assignment details in the raw wake query. Keep any governed event drafts short and emit them through Local Mnemon.`, principal) +func CodexAppServerAdditionalContext(ctx context.Context, workspace string, env []string, query string) (map[string]any, error) { + out := map[string]any{} + for _, lifecycle := range []string{"prime", "remind"} { + message, err := runCodexLifecycleHook(ctx, workspace, env, lifecycle, query) + if err != nil { + return nil, err + } + if strings.TrimSpace(message) == "" { + continue + } + out["mnemon.hook."+lifecycle] = map[string]any{ + "kind": "application", + "value": message, + } + } + return out, nil +} + +func runCodexLifecycleHook(ctx context.Context, workspace string, env []string, lifecycle string, query string) (string, error) { + hook := filepath.Join(workspace, ".codex", "hooks", "mnemon-r1", lifecycle+".sh") + if _, err := os.Stat(hook); errors.Is(err, os.ErrNotExist) { + return "", nil + } else if err != nil { + return "", err + } + stdin := "{}" + if lifecycle == "remind" { + raw, err := json.Marshal(map[string]string{"prompt": query}) + if err != nil { + return "", err + } + stdin = string(raw) + } + cmd := exec.CommandContext(ctx, "bash", hook) + cmd.Dir = workspace + if len(env) > 0 { + cmd.Env = append([]string(nil), env...) + } + cmd.Stdin = strings.NewReader(stdin) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + detail := strings.TrimSpace(stderr.String()) + if detail != "" { + return "", fmt.Errorf("%s hook failed: %w: %s", lifecycle, err, detail) + } + return "", fmt.Errorf("%s hook failed: %w", lifecycle, err) + } + return codexHookSystemMessage(stdout.String()), nil +} + +func codexHookSystemMessage(raw string) string { + text := strings.TrimSpace(raw) + if text == "" { + return "" + } + var envelope map[string]any + if err := json.Unmarshal([]byte(text), &envelope); err == nil { + if message, ok := envelope["systemMessage"].(string); ok { + return strings.TrimSpace(message) + } + } + return text +} + +func codexAppServerCapabilities() map[string]any { + return map[string]any{ + "experimentalApi": true, + "requestAttestation": false, + } } diff --git a/harness/internal/driver/managed_runtime_test.go b/harness/internal/driver/managed_runtime_test.go index 0711a8c3..56fa1ee2 100644 --- a/harness/internal/driver/managed_runtime_test.go +++ b/harness/internal/driver/managed_runtime_test.go @@ -5,7 +5,9 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "path/filepath" + "strings" "testing" eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" @@ -74,3 +76,57 @@ func TestCodexAppServerTurnClientRejectsContextQuery(t *testing.T) { t.Fatal("codex appserver client must reject non-sentinel queries before starting a process") } } + +func TestCodexAppServerAdditionalContextRunsStandardHooks(t *testing.T) { + workspace := t.TempDir() + hookDir := filepath.Join(workspace, ".codex", "hooks", "mnemon-r1") + if err := os.MkdirAll(hookDir, 0o755); err != nil { + t.Fatal(err) + } + prime := filepath.Join(hookDir, "prime.sh") + if err := os.WriteFile(prime, []byte(`#!/usr/bin/env bash +printf '{"systemMessage":"prime guide loaded"}\n' +`), 0o755); err != nil { + t.Fatal(err) + } + remind := filepath.Join(hookDir, "remind.sh") + if err := os.WriteFile(remind, []byte(`#!/usr/bin/env bash +INPUT="$(cat || true)" +case "${INPUT}" in + *"[mnemon:wake]"*) printf '{"systemMessage":"remind rendered governed context"}\n' ;; + *) printf '{"systemMessage":"remind generic"}\n' ;; +esac +`), 0o755); err != nil { + t.Fatal(err) + } + + ctx, err := CodexAppServerAdditionalContext(context.Background(), workspace, os.Environ(), ManagedWakeQuery) + if err != nil { + t.Fatal(err) + } + if len(ctx) != 2 { + t.Fatalf("additional context keys = %+v, want prime and remind", ctx) + } + for key, want := range map[string]string{ + "mnemon.hook.prime": "prime guide loaded", + "mnemon.hook.remind": "remind rendered governed context", + } { + entry, ok := ctx[key].(map[string]any) + if !ok { + t.Fatalf("%s entry = %#v", key, ctx[key]) + } + if entry["kind"] != "application" || !strings.Contains(entry["value"].(string), want) { + t.Fatalf("%s entry = %#v, want application context containing %q", key, entry, want) + } + } +} + +func TestCodexAppServerAdditionalContextSkipsMissingHooks(t *testing.T) { + ctx, err := CodexAppServerAdditionalContext(context.Background(), t.TempDir(), nil, ManagedWakeQuery) + if err != nil { + t.Fatal(err) + } + if len(ctx) != 0 { + t.Fatalf("additional context = %+v, want empty when hooks are absent", ctx) + } +} diff --git a/harness/internal/hostagent/thinshim.go b/harness/internal/hostagent/thinshim.go index c833c850..ee3a39f8 100644 --- a/harness/internal/hostagent/thinshim.go +++ b/harness/internal/hostagent/thinshim.go @@ -118,7 +118,22 @@ func hookBodyBlock(timing string) string { `fi`, }, "\n") case "remind": - return `HOOK_BODY="[mnemon] Evaluate whether governed context should be read before responding."` + return strings.Join([]string{ + `HOOK_BODY="[mnemon] Evaluate whether governed context should be read before responding."`, + `if printf '%s' "${INPUT}" | grep -q '\[mnemon:wake\]'; then`, + ` if [[ -n "${MNEMON_CONTROL_ADDR:-}" && -n "${MNEMON_CONTROL_PRINCIPAL:-}" ]]; then`, + ` TOKEN_ARGS=()`, + ` if [[ -n "${MNEMON_CONTROL_TOKEN_FILE:-}" ]]; then`, + ` TOKEN_ARGS=(--token-file "${MNEMON_CONTROL_TOKEN_FILE}")`, + ` fi`, + ` if RENDERED="$(cd "${PROJECT_ROOT}" && "${MNEMON_HARNESS_BIN:-mnemon-harness}" control render --addr "${MNEMON_CONTROL_ADDR}" --principal "${MNEMON_CONTROL_PRINCIPAL}" "${TOKEN_ARGS[@]}" --intent teamwork.events --lifecycle remind --surface hook 2>/dev/null)"; then`, + ` if [[ -n "${RENDERED}" ]]; then`, + ` HOOK_BODY="$(printf '%s\n\n%s' "[mnemon] Wake signal received. Current governed context:" "${RENDERED}")"`, + ` fi`, + ` fi`, + ` fi`, + `fi`, + }, "\n") case "nudge": return `HOOK_BODY="[mnemon] Evaluate whether this turn changed durable state that should be recorded."` case "compact": diff --git a/harness/internal/hostagent/thinshim_test.go b/harness/internal/hostagent/thinshim_test.go index cca1f124..6cdd12c5 100644 --- a/harness/internal/hostagent/thinshim_test.go +++ b/harness/internal/hostagent/thinshim_test.go @@ -19,6 +19,8 @@ func TestRenderThinHookIsGenericLifecycleShim(t *testing.T) { `LOCAL_ENV="${PROJECT_ROOT}/.mnemon/harness/local/env.sh"`, `GUIDE_PATH="${PROJECT_ROOT}/.mnemon/harness/local/guide.md"`, "Evaluate whether governed context should be read before responding.", + `grep -q '\[mnemon:wake\]'`, + `--intent teamwork.events --lifecycle remind --surface hook`, `"systemMessage": "${SYSTEM_MESSAGE}"`, } { if !strings.Contains(body, want) { @@ -27,10 +29,8 @@ func TestRenderThinHookIsGenericLifecycleShim(t *testing.T) { } for _, blocked := range []string{ "MEMORY.md", - "control render", "control pull", "control observe", - "teamwork", "assignment", "progress_digest", "agent_profile", diff --git a/harness/internal/runtime/bridge.go b/harness/internal/runtime/bridge.go index 38dfd1a2..d6c5c43d 100644 --- a/harness/internal/runtime/bridge.go +++ b/harness/internal/runtime/bridge.go @@ -29,22 +29,30 @@ func NewBridge(newID, now func() string) *Bridge { return &Bridge{newID: newID, // Stamp turns intent into a trusted *.proposed event, OR returns an error if any proposed write targets a // ref outside the actor's DISPATCHED SCOPE (write-scope, R11 — the kernel's authz is actor/kind only). -// Trusted fields come from the binding (write identity), the dispatched presentation view (read-set + provenance), +// Trusted fields come from the binding (write identity), the dispatched presentation view (scope + provenance), // and the trigger (correlation + lineage) — NEVER from the intent payload, even if a hostile callback stuffs -// "actor"/"based_on" into it (R1/R2). Only Payload (the write set) rides through proposer-controlled; the -// kernel validates it. An empty/undecodable write set PASSES the bridge (the kernel rejects it as a -// malformed/empty op, preserving the audit trail); only a DECODED, out-of-scope write is blocked here. +// "actor"/"based_on" into it (R1/R2). The bridge uses the full dispatched scope for ref-level authorization, +// but stamps the proposal read-set from the written refs only; generic append-item rules read the target +// resource they update, not every resource visible to the actor. Only Payload (the write set) rides through +// proposer-controlled; the kernel validates it. An empty/undecodable write set PASSES the bridge (the kernel +// rejects it as a malformed/empty op, preserving the audit trail); only a DECODED, out-of-scope write is +// blocked here. func (br *Bridge) Stamp(b ResolvedBinding, dispatchedOn view.View, trigger contract.Event, intent contract.ProposedEvent) (contract.Event, error) { scope := make(map[contract.ResourceRef]bool, len(dispatchedOn.Resources)) + versions := make(map[contract.ResourceRef]contract.Version, len(dispatchedOn.Resources)) refs := make([]contract.ResourceRef, 0, len(dispatchedOn.Resources)) for _, rv := range dispatchedOn.Resources { scope[rv.Ref] = true + versions[rv.Ref] = rv.Version refs = append(refs, rv.Ref) } - for _, w := range decodeWrites(intent.Payload) { + writes := decodeWrites(intent.Payload) + readSet := make([]contract.ResourceVersion, 0, len(writes)) + for _, w := range writes { if !scope[w.Ref] { return contract.Event{}, fmt.Errorf("proposal writes %s/%s outside actor %q dispatched scope", w.Ref.Kind, w.Ref.ID, b.Actor) } + readSet = append(readSet, contract.ResourceVersion{Ref: w.Ref, Version: versions[w.Ref]}) } corr := trigger.CorrelationID if corr == "" { @@ -57,12 +65,12 @@ func (br *Bridge) Stamp(b ResolvedBinding, dispatchedOn view.View, trigger contr Type: b.Emits, // authorized type from the binding, not the intent's claim Actor: b.Actor, // TRUSTED write identity ResourceRefs: refs, - BasedOn: dispatchedOn.Resources, // TRUSTED read-set - PresentationViewRef: dispatchedOn.Ref, // provenance - ContextDigest: dispatchedOn.Digest, // provenance - CorrelationID: corr, // TRUSTED: inherited or minted - CausedBy: trigger.ID, // lineage - Payload: intent.Payload, // proposer-controlled write set (kernel-validated) + BasedOn: readSet, // TRUSTED rule read-set, narrowed to written refs + PresentationViewRef: dispatchedOn.Ref, // provenance + ContextDigest: dispatchedOn.Digest, // provenance + CorrelationID: corr, // TRUSTED: inherited or minted + CausedBy: trigger.ID, // lineage + Payload: intent.Payload, // proposer-controlled write set (kernel-validated) }, nil } diff --git a/harness/internal/runtime/bridge_test.go b/harness/internal/runtime/bridge_test.go index 7361d4e9..c1e4509a 100644 --- a/harness/internal/runtime/bridge_test.go +++ b/harness/internal/runtime/bridge_test.go @@ -19,7 +19,10 @@ func TestStampUsesTrustedSourcesNotPayload(t *testing.T) { br := newBridge() b := ResolvedBinding{Actor: "agent", Emits: "memory.write.proposed"} proj := view.View{Ref: "proj_abc", Digest: "abc", - Resources: []contract.ResourceVersion{{Ref: contract.ResourceRef{Kind: "memory", ID: "m1"}, Version: 3}}} + Resources: []contract.ResourceVersion{ + {Ref: contract.ResourceRef{Kind: "memory", ID: "m1"}, Version: 3}, + {Ref: contract.ResourceRef{Kind: "goal", ID: "g1"}, Version: 9}, + }} trigger := contract.Event{ID: "ev-trigger", Type: "memory.observed", CorrelationID: "corr-1"} // hostile intent tries to escalate identity / forge a read-set via payload; the write itself is in-scope: intent := contract.ProposedEvent{Type: "memory.write.proposed", Payload: map[string]any{ @@ -33,8 +36,11 @@ func TestStampUsesTrustedSourcesNotPayload(t *testing.T) { if ev.Actor != "agent" { t.Fatalf("Actor must come from binding, not payload; got %q", ev.Actor) } - if len(ev.BasedOn) != 1 || ev.BasedOn[0].Version != 3 { - t.Fatalf("BasedOn must be the dispatched presentation view's read-set; got %+v", ev.BasedOn) + if len(ev.ResourceRefs) != 2 { + t.Fatalf("ResourceRefs must retain the dispatched scope, got %+v", ev.ResourceRefs) + } + if len(ev.BasedOn) != 1 || ev.BasedOn[0].Ref != (contract.ResourceRef{Kind: "memory", ID: "m1"}) || ev.BasedOn[0].Version != 3 { + t.Fatalf("BasedOn must be narrowed to written refs from the dispatched view; got %+v", ev.BasedOn) } if ev.PresentationViewRef != "proj_abc" || ev.ContextDigest != "abc" { t.Fatalf("provenance must come from the presentation view; got ref=%q digest=%q", ev.PresentationViewRef, ev.ContextDigest)