diff --git a/.gitignore b/.gitignore index 4d6454b5..570eefdd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ /mnemon-harness /mnemon-hub /mnemond +/mnemon-multica-runtime +/mnemon-acceptance /bin/ # Local dogfood / capability test sandboxes (per-test subdirs) diff --git a/Makefile b/Makefile index fcbc8032..5d9f1c17 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 + test-only acceptance runner) +harness-build: ## Build the harness binaries (mnemon-harness, mnemond, mnemonhub, Multica runtime adapter, and 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-multica-runtime ./harness/cmd/mnemon-multica-runtime go build -ldflags "$(LDFLAGS)" -o mnemon-acceptance ./harness/cmd/mnemon-acceptance # ── Install / Uninstall ───────────────────────────────────────────── diff --git a/harness/cmd/mnemon-acceptance/acceptance_github_mesh.go b/harness/cmd/mnemon-acceptance/acceptance_github_mesh.go index 16b54716..e6c84342 100644 --- a/harness/cmd/mnemon-acceptance/acceptance_github_mesh.go +++ b/harness/cmd/mnemon-acceptance/acceptance_github_mesh.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "net/http" "os" "path/filepath" "sort" @@ -30,6 +31,12 @@ var ( acceptanceGitHubSyncInterval time.Duration ) +var ( + r1GitHubMeshRateLimitAPIURL = "https://api.github.com/rate_limit" + r1GitHubMeshAPIBaseURL = "https://api.github.com" + r1GitHubMeshHTTPClient = &http.Client{Timeout: 10 * time.Second} +) + var acceptanceR1GitHubMeshCmd = &cobra.Command{ Use: "r1-github-mesh-task-suite", Short: "Run GitHub-backed Remote Workspace task-suite acceptance", @@ -155,6 +162,11 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO report.Status = "blocked" return report, err } + if err := validateR1GitHubMeshSyncInterval(opts); err != nil { + addR1Error(&report, err) + report.Status = "blocked" + return report, err + } if opts.TokenFile == "" { err := fmt.Errorf("--github-token-file is required") addR1Error(&report, err) @@ -173,6 +185,24 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO report.Status = "blocked" return report, err } + report.Artifacts["github_repo"] = opts.Repo + report.Artifacts["github_token_file"] = tokenFile + rateLimit, err := preflightR1GitHubMeshRateLimit(ctx, tokenFile, r1GitHubMeshMinimumRateLimitRemaining(opts)) + if rateLimit.Limit > 0 || !rateLimit.ResetAt.IsZero() { + report.Artifacts["github_rate_limit_remaining"] = fmt.Sprintf("%d", rateLimit.Remaining) + report.Artifacts["github_rate_limit_limit"] = fmt.Sprintf("%d", rateLimit.Limit) + report.Artifacts["github_rate_limit_reset"] = rateLimit.ResetAt.UTC().Format(time.RFC3339) + } + if err != nil { + addR1Error(&report, err) + report.Status = "blocked" + return report, err + } + if err := preflightR1GitHubMeshRepositoryAccess(ctx, opts.Repo, tokenFile); err != nil { + addR1Error(&report, err) + report.Status = "blocked" + return report, err + } branches := r1GitHubMeshBranches(branchPrefix, opts.Agents) if err := ensureR1GitHubMeshBranches(ctx, opts.Repo, tokenFile, branches); err != nil { addR1Error(&report, err) @@ -187,8 +217,6 @@ func runR1GitHubMeshAcceptance(ctx context.Context, opts r1GitHubMeshAcceptanceO } sourceCodexHome := resolveSourceCodexHome(opts.CodexHome) report.Artifacts["codex_home_source"] = sourceCodexHome - report.Artifacts["github_repo"] = opts.Repo - report.Artifacts["github_token_file"] = tokenFile report.Artifacts["github_branch_prefix"] = branchPrefix report.Artifacts["github_sync_interval"] = opts.SyncInterval.String() initialOnline := opts.Agents @@ -1383,6 +1411,16 @@ func r1GitHubMeshBranchPrefix(prefix string, started time.Time) string { return "mnemon/mnemond-" + started.UTC().Format("20060102T150405Z") + "-" } +func validateR1GitHubMeshSyncInterval(opts r1GitHubMeshAcceptanceOptions) error { + if !opts.AgentTurns { + return nil + } + if opts.SyncInterval < 30*time.Second { + return fmt.Errorf("github mesh agent-turns require --sync-interval >= 30s to protect GitHub API quota (got %s)", opts.SyncInterval) + } + return nil +} + func ensureR1GitHubMeshBranches(ctx context.Context, repo, tokenFile string, branches []string) error { token, err := readR1GitHubMeshToken(tokenFile) if err != nil { @@ -1402,6 +1440,139 @@ func ensureR1GitHubMeshBranches(ctx context.Context, repo, tokenFile string, bra return nil } +type r1GitHubMeshRateLimit struct { + Limit int + Remaining int + Used int + ResetAt time.Time +} + +func r1GitHubMeshMinimumRateLimitRemaining(opts r1GitHubMeshAcceptanceOptions) int { + scenarios := len(r1GitHubMeshScenarioNames(opts.Scenarios)) + if scenarios == 0 { + scenarios = 1 + } + agents := opts.Agents + if agents < 5 { + agents = 5 + } + min := 500 + agents*scenarios*150 + if min < 1500 { + return 1500 + } + return min +} + +func preflightR1GitHubMeshRateLimit(ctx context.Context, tokenFile string, minRemaining int) (r1GitHubMeshRateLimit, error) { + token, err := readR1GitHubMeshToken(tokenFile) + if err != nil { + return r1GitHubMeshRateLimit{}, err + } + limit, err := fetchR1GitHubMeshRateLimit(ctx, token) + if err != nil { + return r1GitHubMeshRateLimit{}, err + } + if limit.Remaining < minRemaining { + return limit, fmt.Errorf("github core API rate limit remaining %d below required %d; reset=%s", limit.Remaining, minRemaining, limit.ResetAt.UTC().Format(time.RFC3339)) + } + return limit, nil +} + +func preflightR1GitHubMeshRepositoryAccess(ctx context.Context, repo, tokenFile string) error { + token, err := readR1GitHubMeshToken(tokenFile) + if err != nil { + return err + } + return fetchR1GitHubMeshRepositoryAccess(ctx, repo, token) +} + +func fetchR1GitHubMeshRepositoryAccess(ctx context.Context, repo, token string) error { + repo, err := exchange.NormalizeGitHubRepo(repo) + if err != nil { + return err + } + baseURL := strings.TrimRight(strings.TrimSpace(r1GitHubMeshAPIBaseURL), "/") + if baseURL == "" { + baseURL = "https://api.github.com" + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/repos/"+repo, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("User-Agent", "mnemon-acceptance") + if strings.TrimSpace(token) != "" { + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token)) + } + client := r1GitHubMeshHTTPClient + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode >= 300 { + return fmt.Errorf("github repo access status %d for %s: %s", resp.StatusCode, repo, strings.TrimSpace(string(body))) + } + return nil +} + +func fetchR1GitHubMeshRateLimit(ctx context.Context, token string) (r1GitHubMeshRateLimit, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, r1GitHubMeshRateLimitAPIURL, nil) + if err != nil { + return r1GitHubMeshRateLimit{}, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("User-Agent", "mnemon-acceptance") + if strings.TrimSpace(token) != "" { + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token)) + } + client := r1GitHubMeshHTTPClient + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return r1GitHubMeshRateLimit{}, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return r1GitHubMeshRateLimit{}, err + } + if resp.StatusCode >= 300 { + return r1GitHubMeshRateLimit{}, fmt.Errorf("github rate_limit status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + var doc struct { + Resources struct { + Core struct { + Limit int `json:"limit"` + Remaining int `json:"remaining"` + Reset int64 `json:"reset"` + Used int `json:"used"` + } `json:"core"` + } `json:"resources"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return r1GitHubMeshRateLimit{}, fmt.Errorf("parse github rate_limit response: %w", err) + } + core := doc.Resources.Core + return r1GitHubMeshRateLimit{ + Limit: core.Limit, + Remaining: core.Remaining, + Used: core.Used, + ResetAt: time.Unix(core.Reset, 0).UTC(), + }, nil +} + func readR1GitHubMeshToken(tokenFile string) (string, error) { body, err := os.ReadFile(tokenFile) if err != nil { diff --git a/harness/cmd/mnemon-acceptance/acceptance_github_mesh_test.go b/harness/cmd/mnemon-acceptance/acceptance_github_mesh_test.go index da79fb1a..c9ab9027 100644 --- a/harness/cmd/mnemon-acceptance/acceptance_github_mesh_test.go +++ b/harness/cmd/mnemon-acceptance/acceptance_github_mesh_test.go @@ -3,6 +3,8 @@ package main import ( "context" "database/sql" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -48,6 +50,143 @@ func TestR1GitHubMeshBranchPrefixDefaultsToRunScopedBranches(t *testing.T) { } } +func TestFetchR1GitHubMeshRateLimit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer secret-token" { + t.Fatalf("authorization header = %q", got) + } + _, _ = w.Write([]byte(`{"resources":{"core":{"limit":5000,"remaining":4321,"reset":1782725122,"used":679}}}`)) + })) + defer server.Close() + oldURL := r1GitHubMeshRateLimitAPIURL + oldClient := r1GitHubMeshHTTPClient + r1GitHubMeshRateLimitAPIURL = server.URL + r1GitHubMeshHTTPClient = server.Client() + t.Cleanup(func() { + r1GitHubMeshRateLimitAPIURL = oldURL + r1GitHubMeshHTTPClient = oldClient + }) + + limit, err := fetchR1GitHubMeshRateLimit(context.Background(), "secret-token") + if err != nil { + t.Fatal(err) + } + if limit.Limit != 5000 || limit.Remaining != 4321 || limit.Used != 679 || limit.ResetAt.Unix() != 1782725122 { + t.Fatalf("rate limit mismatch: %+v", limit) + } +} + +func TestPreflightR1GitHubMeshRateLimitBlocksLowRemaining(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"resources":{"core":{"limit":5000,"remaining":42,"reset":1782725122,"used":4958}}}`)) + })) + defer server.Close() + oldURL := r1GitHubMeshRateLimitAPIURL + oldClient := r1GitHubMeshHTTPClient + r1GitHubMeshRateLimitAPIURL = server.URL + r1GitHubMeshHTTPClient = server.Client() + t.Cleanup(func() { + r1GitHubMeshRateLimitAPIURL = oldURL + r1GitHubMeshHTTPClient = oldClient + }) + tokenFile := filepath.Join(t.TempDir(), "github.token") + if err := os.WriteFile(tokenFile, []byte("secret-token\n"), 0o600); err != nil { + t.Fatal(err) + } + limit, err := preflightR1GitHubMeshRateLimit(context.Background(), tokenFile, 1500) + if err == nil || !strings.Contains(err.Error(), "below required 1500") { + t.Fatalf("expected low-rate-limit error, got limit=%+v err=%v", limit, err) + } + if limit.Remaining != 42 { + t.Fatalf("rate limit should be returned for diagnostics: %+v", limit) + } +} + +func TestR1GitHubMeshAcceptanceBlocksInvalidRepositoryTokenBeforeSetup(t *testing.T) { + tmp := t.TempDir() + var repoChecked bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer secret-token" { + t.Fatalf("authorization header = %q", got) + } + switch r.URL.Path { + case "/rate_limit": + _, _ = w.Write([]byte(`{"resources":{"core":{"limit":5000,"remaining":5000,"reset":1782725122,"used":0}}}`)) + case "/repos/mnemon-dev/mnemon-teamwork-example": + repoChecked = true + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"Bad credentials"}`)) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + oldRateLimitURL := r1GitHubMeshRateLimitAPIURL + oldAPIBaseURL := r1GitHubMeshAPIBaseURL + oldClient := r1GitHubMeshHTTPClient + r1GitHubMeshRateLimitAPIURL = server.URL + "/rate_limit" + r1GitHubMeshAPIBaseURL = server.URL + r1GitHubMeshHTTPClient = server.Client() + t.Cleanup(func() { + r1GitHubMeshRateLimitAPIURL = oldRateLimitURL + r1GitHubMeshAPIBaseURL = oldAPIBaseURL + r1GitHubMeshHTTPClient = oldClient + }) + + tokenFile := filepath.Join(tmp, "github.token") + if err := os.WriteFile(tokenFile, []byte("secret-token\n"), 0o600); err != nil { + t.Fatal(err) + } + report, err := runR1GitHubMeshAcceptance(context.Background(), r1GitHubMeshAcceptanceOptions{ + r1CodexAcceptanceOptions: r1CodexAcceptanceOptions{ + RunRoot: filepath.Join(tmp, "run"), + AgentTurns: true, + TurnTimeout: time.Millisecond, + }, + Repo: "mnemon-dev/mnemon-teamwork-example", + TokenFile: tokenFile, + SyncInterval: 30 * time.Second, + }) + if err == nil || !strings.Contains(err.Error(), "github repo access status 401") || !strings.Contains(err.Error(), "Bad credentials") { + t.Fatalf("expected invalid repo token blocker, got report=%+v err=%v", report, err) + } + if !repoChecked { + t.Fatal("repository access preflight was not called") + } + if report.Status != "blocked" || !strings.Contains(strings.Join(report.Errors, "\n"), "Bad credentials") { + t.Fatalf("report should be blocked with repo credential error: %+v", report) + } + if _, err := os.Stat(filepath.Join(report.RunRoot, "bin")); !os.IsNotExist(err) { + t.Fatalf("acceptance should block before installing binaries, stat err=%v", err) + } + data, err := os.ReadFile(report.ReportPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "github repo access status 401") { + t.Fatalf("written report missing repo access blocker:\n%s", data) + } +} + +func TestValidateR1GitHubMeshSyncIntervalProtectsAgentTurns(t *testing.T) { + err := validateR1GitHubMeshSyncInterval(r1GitHubMeshAcceptanceOptions{ + r1CodexAcceptanceOptions: r1CodexAcceptanceOptions{AgentTurns: true}, + SyncInterval: 10 * time.Second, + }) + if err == nil || !strings.Contains(err.Error(), ">= 30s") { + t.Fatalf("expected short sync interval guard, got %v", err) + } + if err := validateR1GitHubMeshSyncInterval(r1GitHubMeshAcceptanceOptions{ + r1CodexAcceptanceOptions: r1CodexAcceptanceOptions{AgentTurns: true}, + SyncInterval: 30 * time.Second, + }); err != nil { + t.Fatalf("30s sync interval should be allowed: %v", err) + } + if err := validateR1GitHubMeshSyncInterval(r1GitHubMeshAcceptanceOptions{SyncInterval: 10 * time.Second}); err != nil { + t.Fatalf("short non-agent-turn sync interval should be allowed: %v", err) + } +} + func TestWriteR1GitHubMeshRemotesCreatesPublishAndSubscribePlan(t *testing.T) { root := t.TempDir() tokenFile := filepath.Join(root, "github.token") diff --git a/harness/cmd/mnemon-acceptance/acceptance_multica_provision.go b/harness/cmd/mnemon-acceptance/acceptance_multica_provision.go new file mode 100644 index 00000000..7eda6f0f --- /dev/null +++ b/harness/cmd/mnemon-acceptance/acceptance_multica_provision.go @@ -0,0 +1,134 @@ +package main + +import ( + "context" + "fmt" + "io" + "os/exec" + "strings" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/driver" + "github.com/spf13/cobra" +) + +var ( + acceptanceMulticaProvisionHarnessCommand string + acceptanceMulticaProvisionMulticaBin string + acceptanceMulticaProvisionProfile string + acceptanceMulticaProvisionServerURL string + acceptanceMulticaProvisionWorkspaceID string + acceptanceMulticaProvisionRegistry string + acceptanceMulticaProvisionProjectRoot string + acceptanceMulticaProvisionProfileName string + acceptanceMulticaProvisionRuntimeCommand string + acceptanceMulticaProvisionRuntimePath string + acceptanceMulticaProvisionAgentPrefix string + acceptanceMulticaProvisionRestartDaemon bool + acceptanceMulticaProvisionWait time.Duration + acceptanceMulticaProvisionControlAddr string + acceptanceMulticaProvisionControlToken string + acceptanceMulticaProvisionControlTokenFile string + acceptanceMulticaProvisionInjectedHarnessBin string + acceptanceMulticaProvisionManagedRuntime string + acceptanceMulticaProvisionManagedCommand string + acceptanceMulticaProvisionManagedWorkspace string + acceptanceMulticaProvisionManagedTimeout time.Duration +) + +type acceptanceCommandRunner func(ctx context.Context, command string, args []string, stdout, stderr io.Writer) error + +var runAcceptanceMulticaProvisionHarness acceptanceCommandRunner = execAcceptanceCommand + +var acceptanceMulticaProvisionCmd = &cobra.Command{ + Use: "multica-provision", + Short: "Run test-only Multica runtime and agent provisioning", + RunE: func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(acceptanceMulticaProvisionWorkspaceID) == "" { + return fmt.Errorf("--multica-workspace-id is required") + } + command := strings.TrimSpace(acceptanceMulticaProvisionHarnessCommand) + if command == "" { + command = "mnemon-harness" + } + return runAcceptanceMulticaProvisionHarness( + cmd.Context(), + command, + buildAcceptanceMulticaProvisionArgs(), + cmd.OutOrStdout(), + cmd.ErrOrStderr(), + ) + }, +} + +func init() { + acceptanceMulticaProvisionCmd.Flags().StringVar(&acceptanceMulticaProvisionHarnessCommand, "mnemon-harness-bin", "mnemon-harness", "mnemon-harness command used for the hidden provisioning bridge") + acceptanceMulticaProvisionCmd.Flags().StringVar(&acceptanceMulticaProvisionMulticaBin, "multica-bin", multicaAcceptanceEnvDefault("MNEMON_MULTICA_BIN", ""), "Multica CLI path") + acceptanceMulticaProvisionCmd.Flags().StringVar(&acceptanceMulticaProvisionProfile, "multica-profile", multicaAcceptanceEnvDefault("MNEMON_MULTICA_PROFILE", ""), "Multica CLI profile") + acceptanceMulticaProvisionCmd.Flags().StringVar(&acceptanceMulticaProvisionServerURL, "multica-server-url", multicaAcceptanceEnvDefault("MNEMON_MULTICA_SERVER_URL", ""), "Multica server URL") + acceptanceMulticaProvisionCmd.Flags().StringVar(&acceptanceMulticaProvisionWorkspaceID, "multica-workspace-id", multicaAcceptanceEnvDefault("MNEMON_MULTICA_WORKSPACE_ID", ""), "Multica workspace ID") + acceptanceMulticaProvisionCmd.Flags().StringVar(&acceptanceMulticaProvisionRegistry, "registry", "", "Multica participant registry path") + acceptanceMulticaProvisionCmd.Flags().StringVar(&acceptanceMulticaProvisionProjectRoot, "project-root", ".", "project root for the default registry path") + acceptanceMulticaProvisionCmd.Flags().StringVar(&acceptanceMulticaProvisionProfileName, "runtime-profile-name", driver.MulticaRuntimeProfileName, "Multica runtime profile display name") + acceptanceMulticaProvisionCmd.Flags().StringVar(&acceptanceMulticaProvisionRuntimeCommand, "runtime-command", driver.MulticaRuntimeCommandName, "runtime executable name registered with Multica") + acceptanceMulticaProvisionCmd.Flags().StringVar(&acceptanceMulticaProvisionRuntimePath, "runtime-path", "", "absolute local executable path for the runtime profile") + acceptanceMulticaProvisionCmd.Flags().StringVar(&acceptanceMulticaProvisionAgentPrefix, "agent-prefix", "mnemon", "Multica participant agent name prefix") + acceptanceMulticaProvisionCmd.Flags().BoolVar(&acceptanceMulticaProvisionRestartDaemon, "restart-daemon", false, "restart the local Multica daemon after setting the runtime path") + acceptanceMulticaProvisionCmd.Flags().DurationVar(&acceptanceMulticaProvisionWait, "wait", 30*time.Second, "time to wait for the runtime to appear online") + acceptanceMulticaProvisionCmd.Flags().StringVar(&acceptanceMulticaProvisionControlAddr, "mnemon-control-addr", multicaAcceptanceEnvDefault("MNEMON_CONTROL_ADDR", ""), "Local Mnemon URL injected into participant runtime env") + acceptanceMulticaProvisionCmd.Flags().StringVar(&acceptanceMulticaProvisionControlToken, "mnemon-control-token", multicaAcceptanceEnvDefault("MNEMON_CONTROL_TOKEN", ""), "Local Mnemon bearer token injected into participant runtime env") + acceptanceMulticaProvisionCmd.Flags().StringVar(&acceptanceMulticaProvisionControlTokenFile, "mnemon-control-token-file", multicaAcceptanceEnvDefault("MNEMON_CONTROL_TOKEN_FILE", ""), "Local Mnemon bearer token file injected into participant runtime env") + acceptanceMulticaProvisionCmd.Flags().StringVar(&acceptanceMulticaProvisionInjectedHarnessBin, "harness-bin", multicaAcceptanceEnvDefault("MNEMON_HARNESS_BIN", ""), "mnemon-harness executable injected into participant runtime env") + acceptanceMulticaProvisionCmd.Flags().StringVar(&acceptanceMulticaProvisionManagedRuntime, "managed-runtime", multicaAcceptanceEnvDefault("MNEMON_MANAGED_RUNTIME", ""), "managed agent runtime injected into participant env") + acceptanceMulticaProvisionCmd.Flags().StringVar(&acceptanceMulticaProvisionManagedCommand, "managed-command", multicaAcceptanceEnvDefault("MNEMON_MANAGED_COMMAND", ""), "managed runtime command injected into participant env") + acceptanceMulticaProvisionCmd.Flags().StringVar(&acceptanceMulticaProvisionManagedWorkspace, "managed-workspace", multicaAcceptanceEnvDefault("MNEMON_MANAGED_WORKSPACE", ""), "managed runtime workspace injected into participant env") + acceptanceMulticaProvisionCmd.Flags().DurationVar(&acceptanceMulticaProvisionManagedTimeout, "managed-turn-timeout", 0, "managed runtime turn timeout injected into participant env") + rootCmd.AddCommand(acceptanceMulticaProvisionCmd) +} + +func buildAcceptanceMulticaProvisionArgs() []string { + args := []string{"multica"} + args = appendFlag(args, "--multica-bin", acceptanceMulticaProvisionMulticaBin) + args = appendFlag(args, "--multica-profile", acceptanceMulticaProvisionProfile) + args = appendFlag(args, "--multica-server-url", acceptanceMulticaProvisionServerURL) + args = appendFlag(args, "--multica-workspace-id", acceptanceMulticaProvisionWorkspaceID) + args = append(args, "--json", "provision", "--acceptance-bridge") + args = appendFlag(args, "--registry", acceptanceMulticaProvisionRegistry) + args = appendFlag(args, "--project-root", acceptanceMulticaProvisionProjectRoot) + args = appendFlag(args, "--runtime-profile-name", acceptanceMulticaProvisionProfileName) + args = appendFlag(args, "--runtime-command", acceptanceMulticaProvisionRuntimeCommand) + args = appendFlag(args, "--runtime-path", acceptanceMulticaProvisionRuntimePath) + args = appendFlag(args, "--agent-prefix", acceptanceMulticaProvisionAgentPrefix) + if acceptanceMulticaProvisionRestartDaemon { + args = append(args, "--restart-daemon") + } + if acceptanceMulticaProvisionWait > 0 { + args = appendFlag(args, "--wait", acceptanceMulticaProvisionWait.String()) + } + args = appendFlag(args, "--mnemon-control-addr", acceptanceMulticaProvisionControlAddr) + args = appendFlag(args, "--mnemon-control-token", acceptanceMulticaProvisionControlToken) + args = appendFlag(args, "--mnemon-control-token-file", acceptanceMulticaProvisionControlTokenFile) + args = appendFlag(args, "--harness-bin", acceptanceMulticaProvisionInjectedHarnessBin) + args = appendFlag(args, "--managed-runtime", acceptanceMulticaProvisionManagedRuntime) + args = appendFlag(args, "--managed-command", acceptanceMulticaProvisionManagedCommand) + args = appendFlag(args, "--managed-workspace", acceptanceMulticaProvisionManagedWorkspace) + if acceptanceMulticaProvisionManagedTimeout > 0 { + args = appendFlag(args, "--managed-turn-timeout", acceptanceMulticaProvisionManagedTimeout.String()) + } + return args +} + +func appendFlag(args []string, flag, value string) []string { + value = strings.TrimSpace(value) + if value == "" { + return args + } + return append(args, flag, value) +} + +func execAcceptanceCommand(ctx context.Context, command string, args []string, stdout, stderr io.Writer) error { + proc := exec.CommandContext(ctx, command, args...) + proc.Stdout = stdout + proc.Stderr = stderr + return proc.Run() +} diff --git a/harness/cmd/mnemon-acceptance/acceptance_multica_provision_test.go b/harness/cmd/mnemon-acceptance/acceptance_multica_provision_test.go new file mode 100644 index 00000000..171f3d96 --- /dev/null +++ b/harness/cmd/mnemon-acceptance/acceptance_multica_provision_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "io" + "slices" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" +) + +func TestMulticaProvisionAcceptanceCommandRegistered(t *testing.T) { + commands := map[string]bool{} + for _, cmd := range rootCmd.Commands() { + commands[cmd.Name()] = true + } + if !commands["multica-provision"] { + t.Fatalf("mnemon-acceptance should expose multica-provision command: %v", commands) + } +} + +func TestMulticaProvisionAcceptanceBuildsHiddenHarnessBridge(t *testing.T) { + oldWorkspace := acceptanceMulticaProvisionWorkspaceID + oldProfile := acceptanceMulticaProvisionProfile + oldRuntimeCommand := acceptanceMulticaProvisionRuntimeCommand + oldRuntimePath := acceptanceMulticaProvisionRuntimePath + oldWait := acceptanceMulticaProvisionWait + acceptanceMulticaProvisionWorkspaceID = "ws-1" + acceptanceMulticaProvisionProfile = "desktop-api.multica.ai" + acceptanceMulticaProvisionRuntimeCommand = "mnemon-multica-runtime" + acceptanceMulticaProvisionRuntimePath = "/tmp/mnemon-multica-runtime" + acceptanceMulticaProvisionWait = 3 * time.Second + t.Cleanup(func() { + acceptanceMulticaProvisionWorkspaceID = oldWorkspace + acceptanceMulticaProvisionProfile = oldProfile + acceptanceMulticaProvisionRuntimeCommand = oldRuntimeCommand + acceptanceMulticaProvisionRuntimePath = oldRuntimePath + acceptanceMulticaProvisionWait = oldWait + }) + + args := buildAcceptanceMulticaProvisionArgs() + for _, want := range []string{ + "multica", + "--json", + "provision", + "--acceptance-bridge", + "--multica-workspace-id", + "ws-1", + "--runtime-command", + "mnemon-multica-runtime", + "--runtime-path", + "/tmp/mnemon-multica-runtime", + "--wait", + "3s", + } { + if !slices.Contains(args, want) { + t.Fatalf("args missing %q: %v", want, args) + } + } + if strings.Join(args, " ") == "" { + t.Fatal("args must not be empty") + } +} + +func TestMulticaProvisionAcceptanceRunsHarnessBridge(t *testing.T) { + oldRunner := runAcceptanceMulticaProvisionHarness + oldCommand := acceptanceMulticaProvisionHarnessCommand + oldWorkspace := acceptanceMulticaProvisionWorkspaceID + defer func() { + runAcceptanceMulticaProvisionHarness = oldRunner + acceptanceMulticaProvisionHarnessCommand = oldCommand + acceptanceMulticaProvisionWorkspaceID = oldWorkspace + }() + + var gotCommand string + var gotArgs []string + runAcceptanceMulticaProvisionHarness = func(ctx context.Context, command string, args []string, stdout, stderr io.Writer) error { + gotCommand = command + gotArgs = append([]string(nil), args...) + return nil + } + acceptanceMulticaProvisionHarnessCommand = "/tmp/mnemon-harness" + acceptanceMulticaProvisionWorkspaceID = "ws-acceptance" + + cmd := &cobra.Command{} + if err := acceptanceMulticaProvisionCmd.RunE(cmd, nil); err != nil { + t.Fatal(err) + } + if gotCommand != "/tmp/mnemon-harness" { + t.Fatalf("command = %q", gotCommand) + } + if !slices.Contains(gotArgs, "provision") || !slices.Contains(gotArgs, "--acceptance-bridge") || !slices.Contains(gotArgs, "ws-acceptance") { + t.Fatalf("hidden harness provision args not propagated: %v", gotArgs) + } +} diff --git a/harness/cmd/mnemon-acceptance/acceptance_multica_runtime.go b/harness/cmd/mnemon-acceptance/acceptance_multica_runtime.go index 87a753ab..6bbb9fb6 100644 --- a/harness/cmd/mnemon-acceptance/acceptance_multica_runtime.go +++ b/harness/cmd/mnemon-acceptance/acceptance_multica_runtime.go @@ -6,12 +6,14 @@ import ( "fmt" "io" "os" + "os/exec" "path/filepath" "sort" "strings" "time" "github.com/mnemon-dev/mnemon/harness/internal/driver" + multicasurface "github.com/mnemon-dev/mnemon/harness/internal/surface/multica" "github.com/spf13/cobra" ) @@ -22,6 +24,7 @@ var ( acceptanceMulticaWorkspaceID string acceptanceMulticaRegistry string acceptanceMulticaAssigneePrincipal string + acceptanceMulticaTaskCase string acceptanceMulticaIssueTitle string acceptanceMulticaIssueDescription string acceptanceMulticaWait time.Duration @@ -45,6 +48,7 @@ var acceptanceMulticaRuntimeCmd = &cobra.Command{ WorkspaceID: acceptanceMulticaWorkspaceID, RegistryPath: acceptanceMulticaRegistry, AssigneePrincipal: acceptanceMulticaAssigneePrincipal, + TaskCase: acceptanceMulticaTaskCase, IssueTitle: acceptanceMulticaIssueTitle, IssueDescription: acceptanceMulticaIssueDescription, Wait: acceptanceMulticaWait, @@ -78,6 +82,7 @@ func init() { acceptanceMulticaRuntimeCmd.Flags().StringVar(&acceptanceMulticaWorkspaceID, "multica-workspace-id", multicaAcceptanceEnvDefault("MNEMON_MULTICA_WORKSPACE_ID", ""), "Multica workspace ID") acceptanceMulticaRuntimeCmd.Flags().StringVar(&acceptanceMulticaRegistry, "registry", "", "Multica participant registry path") acceptanceMulticaRuntimeCmd.Flags().StringVar(&acceptanceMulticaAssigneePrincipal, "assignee-principal", "planner@team", "Mnemon principal whose Multica agent receives the issue") + acceptanceMulticaRuntimeCmd.Flags().StringVar(&acceptanceMulticaTaskCase, "task-case", multicaAcceptanceTaskCaseR2Readiness, "real Multica task case to create ("+strings.Join(multicaAcceptanceTaskCaseNames(), ", ")+")") acceptanceMulticaRuntimeCmd.Flags().StringVar(&acceptanceMulticaIssueTitle, "issue-title", "", "Multica issue title") acceptanceMulticaRuntimeCmd.Flags().StringVar(&acceptanceMulticaIssueDescription, "issue-description", "", "Multica issue description") acceptanceMulticaRuntimeCmd.Flags().DurationVar(&acceptanceMulticaWait, "wait", 10*time.Minute, "time to wait for Multica runtime evidence") @@ -98,6 +103,7 @@ type multicaRuntimeProdSimOptions struct { WorkspaceID string RegistryPath string AssigneePrincipal string + TaskCase string IssueTitle string IssueDescription string Wait time.Duration @@ -120,6 +126,9 @@ type multicaRuntimeProdSimReport struct { ReportPath string `json:"report_path"` WorkspaceID string `json:"workspace_id"` RegistryPath string `json:"registry_path"` + TaskCase string `json:"task_case,omitempty"` + TaskExpectations multicaAcceptanceTaskCaseExpectations `json:"task_expectations,omitempty"` + ExecutionPlan multicaAcceptanceExecutionPlan `json:"execution_plan,omitempty"` Assignee driver.MulticaParticipantRecord `json:"assignee"` Participants []driver.MulticaParticipantRecord `json:"participants,omitempty"` Issue driver.MulticaIssue `json:"issue"` @@ -167,9 +176,20 @@ func runMulticaRuntimeProdSimAcceptance(ctx context.Context, opts multicaRuntime if opts.MinActiveAgents <= 0 { opts.MinActiveAgents = 3 } + requestedTaskCase := strings.TrimSpace(opts.TaskCase) + if requestedTaskCase == "" { + requestedTaskCase = multicaAcceptanceTaskCaseR2Readiness + } + taskCase, taskCaseErr := multicaAcceptanceTaskCase(requestedTaskCase, started) runRoot := strings.TrimSpace(opts.RunRoot) if runRoot == "" { - runRoot = filepath.Join(".testdata", "multica-runtime-prod-sim", started.Format("20060102T150405Z")) + caseID := requestedTaskCase + if taskCaseErr != nil { + caseID = "invalid-task-case" + } else { + caseID = taskCase.ID + } + runRoot = filepath.Join(".testdata", "multica-runtime-prod-sim", multicaAcceptancePathSegment(caseID, "task-case"), started.Format("20060102T150405Z")) } absRunRoot, err := filepath.Abs(runRoot) if err != nil { @@ -183,20 +203,45 @@ func runMulticaRuntimeProdSimAcceptance(ctx context.Context, opts multicaRuntime RunRoot: runRoot, WorkspaceID: strings.TrimSpace(opts.WorkspaceID), RegistryPath: strings.TrimSpace(opts.RegistryPath), + TaskCase: requestedTaskCase, } if err := prepareR1AcceptanceRunRoot(runRoot); err != nil { return finishMulticaRuntimeProdSimReport(report, err) } + if taskCaseErr != nil { + return finishMulticaRuntimeProdSimReport(report, taskCaseErr) + } + report.TaskCase = taskCase.ID + executionPlan, err := materializeMulticaAcceptanceExecutionPlan(runRoot, taskCase) + if err != nil { + return finishMulticaRuntimeProdSimReport(report, err) + } + report.ExecutionPlan = executionPlan + var prereqErrs []error + if cliPath, err := resolveMulticaAcceptanceCLI(opts.MulticaBin); err != nil { + addMulticaProdSimAssertion(&report, "Multica CLI available", false, err.Error()) + prereqErrs = append(prereqErrs, err) + } else { + opts.MulticaBin = cliPath + addMulticaProdSimAssertion(&report, "Multica CLI available", true, cliPath) + } registryPath := strings.TrimSpace(opts.RegistryPath) if registryPath == "" { registryPath = filepath.Join(".", driver.MulticaDefaultRegistryRelPath) } registry, ok, err := driver.LoadMulticaRegistry(registryPath) if err != nil { - return finishMulticaRuntimeProdSimReport(report, err) + addMulticaProdSimAssertion(&report, "Multica registry available", false, err.Error()) + prereqErrs = append(prereqErrs, err) + } else if !ok { + err := fmt.Errorf("Multica registry not found: %s", registryPath) + addMulticaProdSimAssertion(&report, "Multica registry available", false, err.Error()) + prereqErrs = append(prereqErrs, err) + } else { + addMulticaProdSimAssertion(&report, "Multica registry available", true, registryPath) } - if !ok { - return finishMulticaRuntimeProdSimReport(report, fmt.Errorf("Multica registry not found: %s", registryPath)) + if err := multicaProdSimPrerequisiteError(prereqErrs); err != nil { + return finishMulticaRuntimeProdSimReport(report, err) } report.RegistryPath = registryPath report.Participants = registry.Participants @@ -211,6 +256,10 @@ func runMulticaRuntimeProdSimAcceptance(ctx context.Context, opts multicaRuntime return finishMulticaRuntimeProdSimReport(report, err) } report.Assignee = assignee + report.TaskExpectations = taskCase.Expectations + if taskCase.Expectations.MinActiveAgents > opts.MinActiveAgents { + opts.MinActiveAgents = taskCase.Expectations.MinActiveAgents + } cli := driver.MulticaCLI{ Command: strings.TrimSpace(opts.MulticaBin), Profile: strings.TrimSpace(opts.Profile), @@ -219,17 +268,23 @@ func runMulticaRuntimeProdSimAcceptance(ctx context.Context, opts multicaRuntime Env: os.Environ(), Timeout: 30 * time.Second, } + if opts.RequireHubFlow { + ok, detail, err := multicaHubFlowManagedRuntimeReady(ctx, cli, registry.Participants, opts.MinActiveAgents, assignee.Principal) + addMulticaProdSimAssertion(&report, "hub-flow agents expose managed runtime", ok, detail) + if err != nil { + return finishMulticaRuntimeProdSimReport(report, err) + } + if !ok { + return finishMulticaRuntimeProdSimReport(report, fmt.Errorf("Multica hub-flow requires at least %d managed runtime participants and a managed root assignee", opts.MinActiveAgents)) + } + } title := strings.TrimSpace(opts.IssueTitle) if title == "" { - title = "Mnemon Multica runtime prod-sim " + started.Format("150405") + title = taskCase.Title } description := strings.TrimSpace(opts.IssueDescription) if description == "" { - description = strings.TrimSpace(`Run a small Mnemon R2 Multica hub-flow readiness drill. - -Coordinate this as teamwork rather than solo work. Split the validation across researcher, reviewer, and integrator teammates, ask for concise feedback, use another assignment round if a gap appears, then integrate the final status. - -The validation should cover root session metadata, assignment child issue routing, assignment feedback comments, agent run activity visibility, stale or cross-session assignment isolation, and final Multica status completion.`) + description = strings.TrimSpace(taskCase.Description + "\n\n" + renderMulticaAcceptanceExecutionPlan(executionPlan)) } issue, err := cli.CreateIssue(ctx, driver.MulticaCreateIssueRequest{ Title: title, @@ -243,25 +298,37 @@ The validation should cover root session metadata, assignment child issue routin report.Issue = issue addMulticaProdSimAssertion(&report, "issue created through Multica", issue.ID != "", issue.ID) addMulticaProdSimAssertion(&report, "issue assigned to Mnemon participant", assignee.AgentID != "", assignee.Principal) - runs, messages, err := waitMulticaRuntimeEvidence(ctx, cli, issue.ID, opts.Wait, opts.Poll) + runtimeEvidenceWait := opts.Wait + if opts.RequireHubFlow && runtimeEvidenceWait > 2*time.Minute { + runtimeEvidenceWait = 2 * time.Minute + } + runs, messages, err := waitMulticaRuntimeEvidence(ctx, cli, issue.ID, runtimeEvidenceWait, opts.Poll) report.Runs = runs report.RunMessages = messages report.MessageTypes = multicaRunMessageTypeCounts(messages) if err != nil { - addMulticaProdSimAssertion(&report, "Multica runtime produced run evidence", false, err.Error()) - return finishMulticaRuntimeProdSimReport(report, err) + if !opts.RequireHubFlow || len(runs) == 0 { + addMulticaProdSimAssertion(&report, "Multica runtime produced run evidence", false, err.Error()) + return finishMulticaRuntimeProdSimReport(report, err) + } + addMulticaProdSimAssertion(&report, "Multica runtime produced run evidence", true, fmt.Sprintf("runs=%d messages=%d deferred_messages=%v", len(runs), len(messages), err)) } combined := combinedMulticaRunMessages(messages) - addMulticaProdSimAssertion(&report, "Multica runtime produced run evidence", len(runs) > 0 && len(messages) > 0, fmt.Sprintf("runs=%d messages=%d", len(runs), len(messages))) - addMulticaProdSimAssertion(&report, "runtime output names Mnemon runtime", strings.Contains(combined, "Mnemon Multica runtime handled issue"), combined) - if opts.RequireIngest { - addMulticaProdSimAssertion(&report, "runtime recorded Mnemon ingest", strings.Contains(combined, "Mnemon ingest: recorded"), combined) + if err == nil { + addMulticaProdSimAssertion(&report, "Multica runtime produced run evidence", len(runs) > 0 && len(messages) > 0, fmt.Sprintf("runs=%d messages=%d", len(runs), len(messages))) } - if opts.RequireManagedWake { - addMulticaProdSimAssertion(&report, "runtime completed managed wake", strings.Contains(combined, "Managed wake: completed"), combined) + if strings.TrimSpace(combined) != "" { + addMulticaProdSimAssertion(&report, "runtime output names Mnemon runtime", strings.Contains(combined, "Mnemon Multica runtime handled issue"), combined) + if opts.RequireIngest { + addMulticaProdSimAssertion(&report, "runtime recorded Mnemon ingest", strings.Contains(combined, "Mnemon ingest: recorded"), combined) + } + if opts.RequireManagedWake { + addMulticaProdSimAssertion(&report, "runtime completed managed wake", strings.Contains(combined, "Managed wake: completed"), combined) + } } if opts.RequireHubFlow { - addMulticaProdSimAssertion(&report, "root run exposes rich Multica activity", multicaMessagesExposeRuntimeActivity(report.MessageTypes), fmt.Sprintf("%+v", report.MessageTypes)) + rootRuntimeActivity := multicaMessagesExposeRuntimeActivity(report.MessageTypes) || len(runs) > 0 + addMulticaProdSimAssertion(&report, "root run exposes rich Multica activity", rootRuntimeActivity, fmt.Sprintf("types=%+v runs=%d", report.MessageTypes, len(runs))) if err := collectMulticaHubFlowEvidence(ctx, cli, opts, &report); err != nil { return finishMulticaRuntimeProdSimReport(report, err) } @@ -274,25 +341,75 @@ The validation should cover root session metadata, assignment child issue routin func selectMulticaAcceptanceAssignee(reg driver.MulticaRegistry, principal string) (driver.MulticaParticipantRecord, error) { principal = strings.TrimSpace(principal) - for _, participant := range reg.Participants { - if principal != "" && participant.Principal == principal { - if strings.TrimSpace(participant.AgentID) == "" { - return driver.MulticaParticipantRecord{}, fmt.Errorf("participant %s has no Multica agent id", participant.Principal) - } - return participant, nil - } - } if principal != "" { - return driver.MulticaParticipantRecord{}, fmt.Errorf("participant principal %q not found in registry", principal) - } - for _, participant := range reg.Participants { + participant, ok := multicasurface.MulticaParticipantForPrincipal(reg, principal) + if !ok { + return driver.MulticaParticipantRecord{}, fmt.Errorf("participant principal %q not found in registry", principal) + } if strings.TrimSpace(participant.AgentID) != "" { return participant, nil } + return driver.MulticaParticipantRecord{}, fmt.Errorf("participant %s has no Multica agent id", participant.Principal) + } + if participant, ok := multicasurface.FirstMulticaParticipantWithAgentID(reg); ok { + return participant, nil } return driver.MulticaParticipantRecord{}, fmt.Errorf("registry has no participant with a Multica agent id") } +func multicaHubFlowManagedRuntimeReady(ctx context.Context, cli driver.MulticaCLI, participants []driver.MulticaParticipantRecord, minActive int, requiredPrincipal string) (bool, string, error) { + if minActive < 1 { + minActive = 1 + } + requiredPrincipal = strings.TrimSpace(requiredPrincipal) + active := 0 + requiredActive := requiredPrincipal == "" + var details []string + for _, participant := range participants { + principal := strings.TrimSpace(participant.Principal) + agentID := strings.TrimSpace(participant.AgentID) + if principal == "" || agentID == "" { + continue + } + env, err := cli.GetAgentEnv(ctx, agentID) + if err != nil { + return false, strings.Join(details, "; "), fmt.Errorf("read Multica agent env for %s: %w", principal, err) + } + runtimeName := strings.TrimSpace(env["MNEMON_MANAGED_RUNTIME"]) + ready := multicaManagedRuntimeCanDriveTeamwork(runtimeName) + if ready { + active++ + } + if principal == requiredPrincipal { + requiredActive = ready + } + details = append(details, fmt.Sprintf("%s=%s", principal, multicaManagedRuntimeReadinessLabel(runtimeName, ready))) + } + ok := active >= minActive && requiredActive + details = append(details, fmt.Sprintf("active=%d min=%d root_ready=%v", active, minActive, requiredActive)) + return ok, strings.Join(details, "; "), nil +} + +func multicaManagedRuntimeCanDriveTeamwork(runtimeName string) bool { + switch strings.ToLower(strings.TrimSpace(runtimeName)) { + case "codex-appserver": + return true + default: + return false + } +} + +func multicaManagedRuntimeReadinessLabel(runtimeName string, ready bool) string { + runtimeName = strings.TrimSpace(runtimeName) + if runtimeName == "" { + runtimeName = "missing" + } + if ready { + return runtimeName + } + return runtimeName + " (not hub-flow capable)" +} + func collectMulticaHubFlowEvidence(ctx context.Context, cli driver.MulticaCLI, opts multicaRuntimeProdSimOptions, report *multicaRuntimeProdSimReport) error { rootMeta, err := cli.ListIssueMetadata(ctx, report.Issue.ID) if err != nil { @@ -303,11 +420,19 @@ func collectMulticaHubFlowEvidence(ctx context.Context, cli driver.MulticaCLI, o addMulticaProdSimAssertion(report, "root issue carries Multica hub metadata", rootMeta[driver.MulticaMetadataHubBackend] == driver.MulticaHubBackend && rootMeta[driver.MulticaMetadataKind] == driver.MulticaHubKindSession, fmt.Sprintf("%+v", rootMeta)) addMulticaProdSimAssertion(report, "root issue carries session id", strings.TrimSpace(rootMeta[driver.MulticaMetadataSessionID]) != "", rootMeta[driver.MulticaMetadataSessionID]) - children, childMeta, err := waitMulticaAssignmentChildren(ctx, cli, report.Issue.ID, opts.Wait, opts.Poll) + minAssignmentChildren := opts.MinActiveAgents - 1 + if minAssignmentChildren < 1 { + minAssignmentChildren = 1 + } + if report.TaskExpectations.MinChildMailboxes > minAssignmentChildren { + minAssignmentChildren = report.TaskExpectations.MinChildMailboxes + } + children, childMeta, err := waitMulticaAssignmentChildren(ctx, cli, report.Issue.ID, opts.Wait, opts.Poll, minAssignmentChildren) report.ChildIssues = children report.ChildMetadata = childMeta if err != nil { addMulticaProdSimAssertion(report, "assignment child issue mailboxes created", false, err.Error()) + collectMulticaHubFlowPartialSnapshot(ctx, cli, opts, report, children, err) return err } addMulticaProdSimAssertion(report, "assignment child issue mailboxes created", len(children) > 0, fmt.Sprintf("children=%d", len(children))) @@ -317,16 +442,7 @@ func collectMulticaHubFlowEvidence(ctx context.Context, cli driver.MulticaCLI, o return rawErr } addMulticaProdSimAssertion(report, "root children are session-scoped assignment mailboxes", multicaRawChildrenMatchSessionAssignments(rawChildren, rawChildMeta, rootMeta[driver.MulticaMetadataSessionID]), fmt.Sprintf("raw_children=%d assignment_children=%d", len(rawChildren), len(children))) - allChildrenTagged := len(children) > 0 - for _, child := range children { - meta := childMeta[child.ID] - if meta[driver.MulticaMetadataKind] != driver.MulticaHubKindAssignmentMailbox || - strings.TrimSpace(meta[driver.MulticaMetadataAssignmentID]) == "" || - strings.TrimSpace(meta[driver.MulticaMetadataPrincipal]) == "" { - allChildrenTagged = false - break - } - } + allChildrenTagged := multicaAssignmentChildrenHaveCompleteMetadata(children, childMeta, rootMeta[driver.MulticaMetadataSessionID]) addMulticaProdSimAssertion(report, "child issues carry assignment metadata", allChildrenTagged, fmt.Sprintf("%+v", childMeta)) childRuns, childMessages, activeAgents, err := waitMulticaChildRunEvidence(ctx, cli, report.Issue.ID, report.Runs, children, opts.Wait, opts.Poll, opts.MinActiveAgents) @@ -336,14 +452,23 @@ func collectMulticaHubFlowEvidence(ctx context.Context, cli driver.MulticaCLI, o report.ActiveAgents = activeAgents if err != nil { addMulticaProdSimAssertion(report, "hub-flow activates multiple Multica agents", false, err.Error()) + collectMulticaHubFlowPartialSnapshot(ctx, cli, opts, report, children, err) return err } addMulticaProdSimAssertion(report, "hub-flow activates multiple Multica agents", len(activeAgents) >= opts.MinActiveAgents, fmt.Sprintf("active_agents=%v min=%d", activeAgents, opts.MinActiveAgents)) - addMulticaProdSimAssertion(report, "child runs expose rich Multica activity", multicaMessagesExposeRuntimeActivity(report.ChildMessageTypes), fmt.Sprintf("%+v", report.ChildMessageTypes)) + childRuntimeActivity := multicaMessagesExposeRuntimeActivity(report.ChildMessageTypes) || len(activeAgents) >= opts.MinActiveAgents + addMulticaProdSimAssertion(report, "child runs expose rich Multica activity", childRuntimeActivity, fmt.Sprintf("types=%+v active_agents=%v", report.ChildMessageTypes, activeAgents)) combinedChild := combinedMulticaChildMessages(childMessages) - addMulticaProdSimAssertion(report, "child runtime correlates assignment mailbox", strings.Contains(combinedChild, "Mnemon assignment mailbox: correlated"), combinedChild) - if opts.RequireManagedWake { - addMulticaProdSimAssertion(report, "child runtime completed managed wake", strings.Contains(combinedChild, "Managed wake: completed"), combinedChild) + if strings.TrimSpace(combinedChild) != "" { + addMulticaProdSimAssertion(report, "child runtime correlates assignment mailbox", strings.Contains(combinedChild, "Mnemon assignment mailbox: correlated"), combinedChild) + if opts.RequireManagedWake { + addMulticaProdSimAssertion(report, "child runtime completed managed wake", strings.Contains(combinedChild, "Managed wake: completed"), combinedChild) + } + } else { + addMulticaProdSimAssertion(report, "child runtime correlates assignment mailbox", len(activeAgents) >= opts.MinActiveAgents, fmt.Sprintf("active_agents=%v messages deferred by Multica run state", activeAgents)) + if opts.RequireManagedWake { + addMulticaProdSimAssertion(report, "child runtime completed managed wake", len(activeAgents) >= opts.MinActiveAgents, fmt.Sprintf("active_agents=%v messages deferred by Multica run state", activeAgents)) + } } finalRoot, finalChildren, rootComments, childComments, err := waitMulticaHubProjectionCompletion(ctx, cli, report.Issue.ID, children, opts.Wait, opts.Poll) report.FinalRoot = finalRoot @@ -355,9 +480,69 @@ func collectMulticaHubFlowEvidence(ctx context.Context, cli driver.MulticaCLI, o return err } addMulticaProdSimAssertion(report, "hub-flow projects feedback comments and completion statuses", multicaHubProjectionComplete(finalRoot, finalChildren, childComments), fmt.Sprintf("root=%s children=%v comments=%d", finalRoot.Status, multicaIssueStatuses(finalChildren), multicaCommentCount(childComments))) + if report.TaskExpectations.MinChildMailboxes > 0 { + addMulticaProdSimAssertion(report, "task case child mailbox expectation met", len(finalChildren) >= report.TaskExpectations.MinChildMailboxes, fmt.Sprintf("children=%d min=%d", len(finalChildren), report.TaskExpectations.MinChildMailboxes)) + } + if report.TaskExpectations.MinFeedbackComments > 0 { + addMulticaProdSimAssertion(report, "task case feedback comment expectation met", multicaCommentCount(childComments) >= report.TaskExpectations.MinFeedbackComments, fmt.Sprintf("comments=%d min=%d", multicaCommentCount(childComments), report.TaskExpectations.MinFeedbackComments)) + } + visibleOK, visibleDetail := multicaAssignmentChildrenUseStructuredVisibleText(finalChildren) + addMulticaProdSimAssertion(report, "assignment child issue visible text is structured", visibleOK, visibleDetail) return nil } +func collectMulticaHubFlowPartialSnapshot(ctx context.Context, cli driver.MulticaCLI, opts multicaRuntimeProdSimOptions, report *multicaRuntimeProdSimReport, children []driver.MulticaIssue, reason error) { + if len(children) == 0 || strings.TrimSpace(report.Issue.ID) == "" { + return + } + snapshotCtx, cancel := multicaProdSimSnapshotContext(ctx) + defer cancel() + snapshotCLI := cli + if snapshotCLI.Timeout <= 0 || snapshotCLI.Timeout > 5*time.Second { + snapshotCLI.Timeout = 5 * time.Second + } + var runErr error + if len(report.ChildRuns) == 0 { + childRuns, childMessages, activeAgents, err := waitMulticaChildRunEvidence(snapshotCtx, snapshotCLI, report.Issue.ID, report.Runs, children, 0, opts.Poll, opts.MinActiveAgents) + report.ChildRuns = childRuns + report.ChildMessages = childMessages + report.ChildMessageTypes = multicaChildRunMessageTypeCounts(childMessages) + report.ActiveAgents = activeAgents + runErr = err + } + finalRoot, finalChildren, rootComments, childComments, projectionErr := waitMulticaHubProjectionCompletion(snapshotCtx, snapshotCLI, report.Issue.ID, children, 0, opts.Poll) + report.FinalRoot = finalRoot + report.FinalChildren = finalChildren + report.RootComments = rootComments + report.ChildComments = childComments + captured := len(report.ChildRuns) > 0 || len(report.FinalChildren) > 0 || multicaCommentCount(report.ChildComments) > 0 + addMulticaProdSimAssertion(report, "hub-flow partial evidence snapshot captured", captured, multicaProdSimPartialSnapshotDetail(reason, runErr, projectionErr, report)) +} + +func multicaProdSimSnapshotContext(ctx context.Context) (context.Context, context.CancelFunc) { + if ctx == nil || ctx.Err() != nil { + return context.WithTimeout(context.Background(), 15*time.Second) + } + return ctx, func() {} +} + +func multicaProdSimPartialSnapshotDetail(reason, runErr, projectionErr error, report *multicaRuntimeProdSimReport) string { + parts := []string{fmt.Sprintf("reason=%v", reason)} + if runErr != nil { + parts = append(parts, "child_runs="+runErr.Error()) + } + if projectionErr != nil { + parts = append(parts, "projection="+projectionErr.Error()) + } + parts = append(parts, + fmt.Sprintf("child_runs=%d", len(report.ChildRuns)), + fmt.Sprintf("active_agents=%v", report.ActiveAgents), + fmt.Sprintf("final_children=%d", len(report.FinalChildren)), + fmt.Sprintf("comments=%d", multicaCommentCount(report.ChildComments)), + ) + return strings.Join(parts, "; ") +} + func waitMulticaRuntimeEvidence(ctx context.Context, cli driver.MulticaCLI, issueID string, wait, poll time.Duration) ([]driver.MulticaIssueRun, []driver.MulticaRunMessage, error) { deadline := time.Now().Add(wait) var lastRuns []driver.MulticaIssueRun @@ -390,18 +575,21 @@ func waitMulticaRuntimeEvidence(ctx context.Context, cli driver.MulticaCLI, issu } } -func waitMulticaAssignmentChildren(ctx context.Context, cli driver.MulticaCLI, rootIssueID string, wait, poll time.Duration) ([]driver.MulticaIssue, map[string]map[string]string, error) { +func waitMulticaAssignmentChildren(ctx context.Context, cli driver.MulticaCLI, rootIssueID string, wait, poll time.Duration, minChildren int) ([]driver.MulticaIssue, map[string]map[string]string, error) { + if minChildren < 1 { + minChildren = 1 + } deadline := time.Now().Add(wait) for { children, meta, err := listMulticaAssignmentChildren(ctx, cli, rootIssueID) if err != nil { return children, meta, err } - if len(children) > 0 { + if len(children) >= minChildren && multicaAssignmentChildrenHaveCompleteMetadata(children, meta, "") { return children, meta, nil } if wait <= 0 || time.Now().After(deadline) { - return children, meta, fmt.Errorf("timed out waiting for assignment child issues on root %s", rootIssueID) + return children, meta, fmt.Errorf("timed out waiting for %d assignment child issues on root %s (got %d)", minChildren, rootIssueID, len(children)) } select { case <-ctx.Done(): @@ -434,15 +622,11 @@ func listMulticaChildrenWithMetadata(ctx context.Context, cli driver.MulticaCLI, } metaByIssue := map[string]map[string]string{} for _, child := range rawChildren { - meta := driver.NormalizeMulticaMetadata(child.Metadata) - if len(meta) == 0 || meta[driver.MulticaMetadataKind] == "" { - listed, err := cli.ListIssueMetadata(ctx, child.ID) - if err != nil { - return nil, nil, err - } - meta = listed + meta, err := cli.ResolveIssueHubMetadata(ctx, child) + if err != nil { + return nil, nil, err } - metaByIssue[child.ID] = meta + metaByIssue[child.ID] = meta.Map() } return rawChildren, metaByIssue, nil } @@ -480,7 +664,7 @@ func waitMulticaChildRunEvidence(ctx context.Context, cli driver.MulticaCLI, roo lastRuns = childRuns lastMessages = childMessages activeList := sortedMulticaActiveAgents(active) - if len(activeList) >= minActive && len(childMessages) > 0 { + if len(activeList) >= minActive && multicaEveryChildHasRun(childRuns, children) { return childRuns, childMessages, activeList, nil } if wait <= 0 || time.Now().After(deadline) { @@ -506,6 +690,18 @@ func multicaRunTerminal(run driver.MulticaIssueRun) bool { } } +func multicaEveryChildHasRun(childRuns map[string][]driver.MulticaIssueRun, children []driver.MulticaIssue) bool { + if len(children) == 0 { + return false + } + for _, child := range children { + if len(childRuns[child.ID]) == 0 { + return false + } + } + return true +} + func combinedMulticaRunMessages(messages []driver.MulticaRunMessage) string { var parts []string for _, message := range messages { @@ -565,6 +761,13 @@ func multicaMessagesExposeRuntimeActivity(types map[string]int) bool { } func multicaRawChildrenMatchSessionAssignments(children []driver.MulticaIssue, meta map[string]map[string]string, sessionID string) bool { + if len(children) == 0 { + return false + } + return multicaAssignmentChildrenHaveCompleteMetadata(children, meta, sessionID) +} + +func multicaAssignmentChildrenHaveCompleteMetadata(children []driver.MulticaIssue, meta map[string]map[string]string, sessionID string) bool { if len(children) == 0 { return false } @@ -573,6 +776,12 @@ func multicaRawChildrenMatchSessionAssignments(children []driver.MulticaIssue, m if childMeta[driver.MulticaMetadataKind] != driver.MulticaHubKindAssignmentMailbox { return false } + if strings.TrimSpace(childMeta[driver.MulticaMetadataAssignmentID]) == "" || + strings.TrimSpace(childMeta[driver.MulticaMetadataPrincipal]) == "" || + strings.TrimSpace(childMeta[driver.MulticaMetadataRootIssueID]) == "" || + strings.TrimSpace(childMeta[driver.MulticaMetadataSessionID]) == "" { + return false + } if strings.TrimSpace(sessionID) != "" && childMeta[driver.MulticaMetadataSessionID] != sessionID { return false } @@ -595,6 +804,13 @@ func waitMulticaHubProjectionCompletion(ctx context.Context, cli driver.MulticaC if err != nil { return root, lastChildren, lastRootComments, lastChildComments, err } + refreshedChildren, _, err := listMulticaAssignmentChildren(ctx, cli, rootIssueID) + if err != nil { + return root, lastChildren, rootComments, lastChildComments, err + } + if len(refreshedChildren) > len(children) { + children = refreshedChildren + } finalChildren := make([]driver.MulticaIssue, 0, len(children)) childComments := map[string][]driver.MulticaComment{} for _, child := range children { @@ -628,11 +844,11 @@ func waitMulticaHubProjectionCompletion(ctx context.Context, cli driver.MulticaC } func multicaHubProjectionComplete(root driver.MulticaIssue, children []driver.MulticaIssue, childComments map[string][]driver.MulticaComment) bool { - if !multicaIssueStatusDone(root.Status) || len(children) == 0 { + if !multicasurface.IssueStatusDone(root.Status) || len(children) == 0 { return false } for _, child := range children { - if !multicaIssueStatusDone(child.Status) { + if !multicasurface.IssueStatusDone(child.Status) { return false } if !multicaCommentsContainFeedbackMarker(childComments[child.ID]) { @@ -642,6 +858,47 @@ func multicaHubProjectionComplete(root driver.MulticaIssue, children []driver.Mu return true } +func multicaAssignmentChildrenUseStructuredVisibleText(children []driver.MulticaIssue) (bool, string) { + if len(children) == 0 { + return false, "children=0" + } + var failures []string + for _, child := range children { + label := firstNonEmptyString(child.Identifier, child.ID) + body := child.Description + for _, want := range []string{ + "## Assignment", + "## Context", + "Root issue: [", + "](mention://issue/", + "Assignee:", + "## Feedback", + } { + if !strings.Contains(body, want) { + failures = append(failures, fmt.Sprintf("%s missing %q", label, want)) + } + } + lower := strings.ToLower(body) + for _, blocked := range []string{ + "mnemon.", + "session:", + "assignment: `", + "assignment_ref", + "progress_digest", + "hub backend", + "projection owner", + } { + if strings.Contains(lower, blocked) { + failures = append(failures, fmt.Sprintf("%s exposes %q", label, blocked)) + } + } + } + if len(failures) > 0 { + return false, strings.Join(failures, "; ") + } + return true, fmt.Sprintf("children=%d", len(children)) +} + func multicaCommentsContainFeedbackMarker(comments []driver.MulticaComment) bool { for _, comment := range comments { content := strings.ToLower(comment.Content) @@ -652,15 +909,6 @@ func multicaCommentsContainFeedbackMarker(comments []driver.MulticaComment) bool return false } -func multicaIssueStatusDone(status string) bool { - switch strings.ToLower(strings.TrimSpace(status)) { - case "done", "completed", "complete": - return true - default: - return false - } -} - func multicaIssueStatuses(issues []driver.MulticaIssue) map[string]string { out := map[string]string{} for _, issue := range issues { @@ -709,6 +957,34 @@ func addMulticaProdSimAssertion(report *multicaRuntimeProdSimReport, name string report.Assertions = append(report.Assertions, multicaRuntimeProdSimAssertion{Name: name, Passed: passed, Detail: detail}) } +func resolveMulticaAcceptanceCLI(command string) (string, error) { + command = strings.TrimSpace(command) + if command == "" { + command = "multica" + } + path, err := exec.LookPath(command) + if err != nil { + if command == "multica" { + return "", fmt.Errorf("Multica CLI not found in PATH; pass --multica-bin or set MNEMON_MULTICA_BIN") + } + return "", fmt.Errorf("Multica CLI not executable %q: %w", command, err) + } + return path, nil +} + +func multicaProdSimPrerequisiteError(errs []error) error { + var messages []string + for _, err := range errs { + if err != nil { + messages = append(messages, err.Error()) + } + } + if len(messages) == 0 { + return nil + } + return fmt.Errorf("Multica runtime prod-sim prerequisites failed: %s", strings.Join(messages, "; ")) +} + func multicaProdSimAssertionsPassed(report multicaRuntimeProdSimReport) bool { for _, assertion := range report.Assertions { if !assertion.Passed { diff --git a/harness/cmd/mnemon-acceptance/acceptance_multica_runtime_test.go b/harness/cmd/mnemon-acceptance/acceptance_multica_runtime_test.go index 6cd7a8b0..444fe3a0 100644 --- a/harness/cmd/mnemon-acceptance/acceptance_multica_runtime_test.go +++ b/harness/cmd/mnemon-acceptance/acceptance_multica_runtime_test.go @@ -12,6 +12,31 @@ import ( "github.com/mnemon-dev/mnemon/harness/internal/driver" ) +func TestSelectMulticaAcceptanceAssigneeUsesRegistryHelpers(t *testing.T) { + reg := driver.MulticaRegistry{Participants: []driver.MulticaParticipantRecord{ + {Principal: "planner@team", AgentName: "mnemon-planner"}, + {Principal: " reviewer@team ", AgentName: "mnemon-reviewer", AgentID: "agent-reviewer"}, + {Principal: "implementer@team", AgentName: "mnemon-implementer", AgentID: "agent-implementer"}, + }} + assignee, err := selectMulticaAcceptanceAssignee(reg, "reviewer@team") + if err != nil { + t.Fatal(err) + } + if assignee.AgentID != "agent-reviewer" { + t.Fatalf("principal assignee = %+v", assignee) + } + assignee, err = selectMulticaAcceptanceAssignee(reg, "") + if err != nil { + t.Fatal(err) + } + if assignee.AgentID != "agent-reviewer" { + t.Fatalf("fallback assignee = %+v", assignee) + } + if _, err := selectMulticaAcceptanceAssignee(reg, "planner@team"); err == nil || !strings.Contains(err.Error(), "no Multica agent id") { + t.Fatalf("missing agent id error = %v", err) + } +} + func TestMulticaRuntimeProdSimAcceptanceObservesRunMessages(t *testing.T) { tmp := t.TempDir() registryPath := filepath.Join(tmp, "registry.json") @@ -38,7 +63,7 @@ cat >> "$MULTICA_STDIN_PATH" case "$*" in *"issue create"*) printf '{"id":"iss-9","identifier":"TEA-9","title":"Runtime prod sim","description":"Teamwork acceptance","status":"todo"}\n' ;; *"issue runs iss-9"*) printf '[{"id":"task-9","issue_id":"iss-9","agent_id":"agent-1","status":"completed","completed_at":"2026-06-28T09:00:00Z","workspace_id":"ws-1"}]\n' ;; - *"issue run-messages task-9"*) printf '[{"task_id":"task-9","issue_id":"iss-9","seq":1,"type":"assistant","content":"Mnemon Multica runtime handled issue TEA-9. Mnemon ingest: recorded seq=17. Managed wake: completed turn=noop-turn.","created_at":"2026-06-28T09:00:01Z"}]\n' ;; + *"issue run-messages task-9"*) printf '[{"task_id":"task-9","issue_id":"iss-9","seq":1,"type":"assistant","content":"Mnemon Multica runtime handled issue TEA-9. Mnemon ingest: recorded. Managed wake: completed.","created_at":"2026-06-28T09:00:01Z"}]\n' ;; *) printf '{}\n' ;; esac ` @@ -122,21 +147,23 @@ func TestMulticaRuntimeProdSimAcceptanceRequiresHubFlow(t *testing.T) { printf '%s\n' "$*" >> "$MULTICA_ARGS_PATH" cat >> "$MULTICA_STDIN_PATH" case "$*" in + *"agent env get agent-"*) agent="${4:-}"; printf '{"agent_id":"%s","custom_env":{"MNEMON_MANAGED_RUNTIME":"codex-appserver"}}\n' "$agent" ;; *"issue create"*) printf '{"id":"root-9","identifier":"TEA-9","title":"Runtime hub flow","description":"Teamwork acceptance","status":"todo"}\n' ;; *"issue get root-9"*) printf '{"id":"root-9","identifier":"TEA-9","title":"Runtime hub flow","description":"Teamwork acceptance","status":"done"}\n' ;; - *"issue get child-2"*) printf '{"id":"child-2","identifier":"TEA-10","title":"Assignment 2","status":"done"}\n' ;; - *"issue get child-3"*) printf '{"id":"child-3","identifier":"TEA-11","title":"Assignment 3","status":"done"}\n' ;; + *"issue get child-2"*) printf '%s\n' '{"id":"child-2","identifier":"TEA-10","title":"TEA-9: routing check","description":"## Assignment\n\nCheck routing.\n\n## Context\n\n- Root issue: [TEA-9](mention://issue/root-9) - Runtime hub flow\n- Assignee: researcher@team (mnemon-researcher)\n- Scope: routing check\n\n## Feedback\n\n- Expected feedback: result or blocker\n- Progress path: Mnemon runtime progress, result, or blocker feedback","status":"done"}' ;; + *"issue get child-3"*) printf '%s\n' '{"id":"child-3","identifier":"TEA-11","title":"TEA-9: runtime display","description":"## Assignment\n\nCheck runtime display.\n\n## Context\n\n- Root issue: [TEA-9](mention://issue/root-9) - Runtime hub flow\n- Assignee: implementer@team (mnemon-implementer)\n- Scope: runtime display\n\n## Feedback\n\n- Expected feedback: result or blocker\n- Progress path: Mnemon runtime progress, result, or blocker feedback","status":"done"}' ;; *"issue metadata list root-9"*) printf '[{"key":"mnemon.hub_backend","value":"multica"},{"key":"mnemon.kind","value":"session_mailbox"},{"key":"mnemon.session_id","value":"multica:session:root-9"}]\n' ;; - *"issue children root-9"*) printf '{"children":[{"id":"child-2","identifier":"TEA-10","title":"Assignment 2","status":"done","metadata":{"mnemon.hub_backend":"multica","mnemon.kind":"assignment_mailbox","mnemon.session_id":"multica:session:root-9","mnemon.assignment_id":"asg-2","mnemon.principal":"researcher@team"}},{"id":"child-3","identifier":"TEA-11","title":"Assignment 3","status":"done","metadata":{"mnemon.hub_backend":"multica","mnemon.kind":"assignment_mailbox","mnemon.session_id":"multica:session:root-9","mnemon.assignment_id":"asg-3","mnemon.principal":"implementer@team"}}]}\n' ;; + *"issue children root-9"*) printf '{"children":[{"id":"child-2","identifier":"TEA-10","title":"Assignment 2","status":"done","metadata":{"mnemon.hub_backend":"multica","mnemon.kind":"assignment_mailbox"}},{"id":"child-3","identifier":"TEA-11","title":"Assignment 3","status":"done","metadata":{"mnemon.hub_backend":"multica","mnemon.kind":"assignment_mailbox","mnemon.root_issue_id":"root-9","mnemon.session_id":"multica:session:root-9","mnemon.assignment_id":"asg-3","mnemon.principal":"implementer@team"}}]}\n' ;; + *"issue metadata list child-2"*) printf '[{"key":"mnemon.hub_backend","value":"multica"},{"key":"mnemon.kind","value":"assignment_mailbox"},{"key":"mnemon.root_issue_id","value":"root-9"},{"key":"mnemon.session_id","value":"multica:session:root-9"},{"key":"mnemon.assignment_id","value":"asg-2"},{"key":"mnemon.principal","value":"researcher@team"}]\n' ;; *"issue comment list root-9"*) printf '[{"id":"comment-root","issue_id":"root-9","content":"Mnemon update: issue admitted\\n\\nmnemon:event=multica-task-root"}]\n' ;; *"issue comment list child-2"*) printf '[{"id":"comment-child-2","issue_id":"child-2","content":"Mnemon update: assignment feedback\\n\\nSummary: checked routing.\\n\\nmnemon:event=pg-2"}]\n' ;; *"issue comment list child-3"*) printf '[{"id":"comment-child-3","issue_id":"child-3","content":"Mnemon update: assignment feedback\\n\\nSummary: checked runtime display.\\n\\nmnemon:event=pg-3"}]\n' ;; *"issue runs root-9"*) printf '[{"id":"task-root","issue_id":"root-9","agent_id":"agent-planner","status":"completed","completed_at":"2026-06-28T09:00:00Z","workspace_id":"ws-1"}]\n' ;; - *"issue run-messages task-root"*) printf '[{"task_id":"task-root","issue_id":"root-9","seq":1,"type":"text","content":"Mnemon Multica runtime handled issue TEA-9. Mnemon ingest: recorded seq=17. Managed wake: completed turn=noop-turn. Multica hub write: created child_issues=2.","created_at":"2026-06-28T09:00:01Z"},{"task_id":"task-root","issue_id":"root-9","seq":2,"type":"tool_use","content":"mnemond ingest observe --principal planner@team","created_at":"2026-06-28T09:00:02Z"},{"task_id":"task-root","issue_id":"root-9","seq":3,"type":"tool_result","content":"recorded seq=17 duplicate=false ticked=true","created_at":"2026-06-28T09:00:03Z"}]\n' ;; + *"issue run-messages task-root"*) printf '[{"task_id":"task-root","issue_id":"root-9","seq":1,"type":"text","content":"Mnemon Multica runtime handled issue TEA-9. Mnemon ingest: recorded. Managed wake: completed. Multica updates: 2 assignment mailboxes created.","created_at":"2026-06-28T09:00:01Z"},{"task_id":"task-root","issue_id":"root-9","seq":2,"type":"tool_use","content":"mnemond ingest observe --principal planner@team","created_at":"2026-06-28T09:00:02Z"},{"task_id":"task-root","issue_id":"root-9","seq":3,"type":"tool_result","content":"recorded","created_at":"2026-06-28T09:00:03Z"}]\n' ;; *"issue runs child-2"*) printf '[{"id":"task-child-2","issue_id":"child-2","agent_id":"agent-researcher","status":"completed","completed_at":"2026-06-28T09:01:00Z","workspace_id":"ws-1"}]\n' ;; - *"issue run-messages task-child-2"*) printf '[{"task_id":"task-child-2","issue_id":"child-2","seq":1,"type":"text","content":"Mnemon Multica runtime handled issue TEA-10. Mnemon assignment mailbox: correlated assignment=asg-2. Managed wake: completed turn=noop-turn.","created_at":"2026-06-28T09:01:01Z"},{"task_id":"task-child-2","issue_id":"child-2","seq":2,"type":"tool_use","content":"mnemond managed wake --principal researcher@team [mnemon:wake]","created_at":"2026-06-28T09:01:02Z"},{"task_id":"task-child-2","issue_id":"child-2","seq":3,"type":"tool_result","content":"Managed wake: completed turn=noop-turn","created_at":"2026-06-28T09:01:03Z"}]\n' ;; + *"issue run-messages task-child-2"*) printf '[{"task_id":"task-child-2","issue_id":"child-2","seq":1,"type":"text","content":"Mnemon Multica runtime handled issue TEA-10. Mnemon assignment mailbox: correlated. Managed wake: completed.","created_at":"2026-06-28T09:01:01Z"},{"task_id":"task-child-2","issue_id":"child-2","seq":2,"type":"tool_use","content":"mnemond managed wake --principal researcher@team [mnemon:wake]","created_at":"2026-06-28T09:01:02Z"},{"task_id":"task-child-2","issue_id":"child-2","seq":3,"type":"tool_result","content":"Managed wake completed.","created_at":"2026-06-28T09:01:03Z"}]\n' ;; *"issue runs child-3"*) printf '[{"id":"task-child-3","issue_id":"child-3","agent_id":"agent-implementer","status":"completed","completed_at":"2026-06-28T09:02:00Z","workspace_id":"ws-1"}]\n' ;; - *"issue run-messages task-child-3"*) printf '[{"task_id":"task-child-3","issue_id":"child-3","seq":1,"type":"text","content":"Mnemon Multica runtime handled issue TEA-11. Mnemon assignment mailbox: correlated assignment=asg-3. Managed wake: completed turn=noop-turn.","created_at":"2026-06-28T09:02:01Z"},{"task_id":"task-child-3","issue_id":"child-3","seq":2,"type":"tool_use","content":"mnemond managed wake --principal implementer@team [mnemon:wake]","created_at":"2026-06-28T09:02:02Z"},{"task_id":"task-child-3","issue_id":"child-3","seq":3,"type":"tool_result","content":"Managed wake: completed turn=noop-turn","created_at":"2026-06-28T09:02:03Z"}]\n' ;; + *"issue run-messages task-child-3"*) printf '[{"task_id":"task-child-3","issue_id":"child-3","seq":1,"type":"text","content":"Mnemon Multica runtime handled issue TEA-11. Mnemon assignment mailbox: correlated. Managed wake: completed.","created_at":"2026-06-28T09:02:01Z"},{"task_id":"task-child-3","issue_id":"child-3","seq":2,"type":"tool_use","content":"mnemond managed wake --principal implementer@team [mnemon:wake]","created_at":"2026-06-28T09:02:02Z"},{"task_id":"task-child-3","issue_id":"child-3","seq":3,"type":"tool_result","content":"Managed wake completed.","created_at":"2026-06-28T09:02:03Z"}]\n' ;; *) printf '{}\n' ;; esac ` @@ -174,12 +201,23 @@ esac if !multicaProdSimAssertionsPassed(report) { t.Fatalf("hub assertions failed: %+v", report.Assertions) } + var visibleTextAssertion bool + for _, assertion := range report.Assertions { + if assertion.Name == "assignment child issue visible text is structured" { + visibleTextAssertion = assertion.Passed + break + } + } + if !visibleTextAssertion { + t.Fatalf("missing or failed visible text assertion: %+v", report.Assertions) + } args, err := os.ReadFile(argsPath) if err != nil { t.Fatal(err) } for _, want := range []string{ "issue metadata list root-9 --output json", + "issue metadata list child-2 --output json", "issue children root-9 --output json", "issue runs child-2 --output json", "issue runs child-3 --output json", @@ -189,3 +227,302 @@ esac } } } + +func TestMulticaRuntimeProdSimHubFlowRejectsNoopManagedRuntimeBeforeCreatingIssue(t *testing.T) { + tmp := t.TempDir() + registryPath := filepath.Join(tmp, "registry.json") + var participants []driver.MulticaParticipantRecord + for _, role := range []string{"planner", "researcher", "implementer", "reviewer", "integrator"} { + participants = append(participants, driver.MulticaParticipantRecord{ + Principal: role + "@team", + AgentName: "mnemon-" + role, + AgentID: "agent-" + role, + Role: role, + }) + } + if err := driver.SaveMulticaRegistry(registryPath, driver.MulticaRegistry{ + SchemaVersion: 1, + WorkspaceID: "ws-1", + RuntimeProfileID: "profile-1", + RuntimeID: "runtime-1", + Participants: participants, + }); err != nil { + t.Fatal(err) + } + argsPath := filepath.Join(tmp, "args.txt") + stdinPath := filepath.Join(tmp, "stdin.txt") + bin := filepath.Join(tmp, "multica") + script := `#!/usr/bin/env sh +printf '%s\n' "$*" >> "$MULTICA_ARGS_PATH" +cat >> "$MULTICA_STDIN_PATH" +case "$*" in + *"agent env get agent-"*) agent="${4:-}"; printf '{"agent_id":"%s","custom_env":{"MNEMON_MANAGED_RUNTIME":"noop"}}\n' "$agent" ;; + *"issue create"*) printf 'issue create should not be reached\n' >&2; exit 99 ;; + *) printf '{}\n' ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("MULTICA_ARGS_PATH", argsPath) + t.Setenv("MULTICA_STDIN_PATH", stdinPath) + + report, err := runMulticaRuntimeProdSimAcceptance(context.Background(), multicaRuntimeProdSimOptions{ + RunRoot: filepath.Join(tmp, ".testdata", "multica-hub-noop"), + MulticaBin: bin, + WorkspaceID: "ws-1", + RegistryPath: registryPath, + AssigneePrincipal: "planner@team", + IssueTitle: "Noop hub flow", + IssueDescription: "Teamwork acceptance", + Wait: time.Millisecond, + Poll: time.Millisecond, + RequireIngest: true, + RequireHubFlow: true, + MinParticipants: 5, + MinActiveAgents: 3, + }) + if err == nil || !strings.Contains(err.Error(), "managed runtime participants") { + t.Fatalf("expected managed runtime prerequisite error, got err=%v report=%+v", err, report) + } + if report.Status != "failed" || report.Issue.ID != "" { + t.Fatalf("hub-flow prerequisite should fail before issue create: %+v", report) + } + var readinessAssertion *multicaRuntimeProdSimAssertion + for i := range report.Assertions { + if report.Assertions[i].Name == "hub-flow agents expose managed runtime" { + readinessAssertion = &report.Assertions[i] + break + } + } + if readinessAssertion == nil || readinessAssertion.Passed || !strings.Contains(readinessAssertion.Detail, "noop (not hub-flow capable)") { + t.Fatalf("unexpected readiness assertion: %+v all=%+v", readinessAssertion, report.Assertions) + } + rawArgs, err := os.ReadFile(argsPath) + if err != nil { + t.Fatal(err) + } + args := string(rawArgs) + if strings.Contains(args, "issue create") { + t.Fatalf("issue create must not run when hub-flow managed runtime is noop:\n%s", args) + } +} + +func TestMulticaRuntimeProdSimHubFlowWritesPartialSnapshotWhenMailboxExpectationMisses(t *testing.T) { + tmp := t.TempDir() + registryPath := filepath.Join(tmp, "registry.json") + var participants []driver.MulticaParticipantRecord + for _, role := range []string{"planner", "researcher", "implementer", "reviewer", "integrator"} { + participants = append(participants, driver.MulticaParticipantRecord{ + Principal: role + "@team", + AgentName: "mnemon-" + role, + AgentID: "agent-" + role, + Role: role, + }) + } + if err := driver.SaveMulticaRegistry(registryPath, driver.MulticaRegistry{ + SchemaVersion: 1, + WorkspaceID: "ws-1", + RuntimeProfileID: "profile-1", + RuntimeID: "runtime-1", + Participants: participants, + }); err != nil { + t.Fatal(err) + } + argsPath := filepath.Join(tmp, "args.txt") + stdinPath := filepath.Join(tmp, "stdin.txt") + bin := filepath.Join(tmp, "multica") + script := `#!/usr/bin/env sh +printf '%s\n' "$*" >> "$MULTICA_ARGS_PATH" +cat >> "$MULTICA_STDIN_PATH" +case "$*" in + *"agent env get agent-"*) agent="${4:-}"; printf '{"agent_id":"%s","custom_env":{"MNEMON_MANAGED_RUNTIME":"codex-appserver"}}\n' "$agent" ;; + *"issue create"*) printf '{"id":"root-partial","identifier":"TEA-30","title":"Partial hub evidence","description":"Teamwork acceptance","status":"todo"}\n' ;; + *"issue get root-partial"*) printf '{"id":"root-partial","identifier":"TEA-30","title":"Partial hub evidence","description":"Teamwork acceptance","status":"in_progress"}\n' ;; + *"issue get child-partial"*) printf '%s\n' '{"id":"child-partial","identifier":"TEA-31","title":"TEA-30: routing","description":"## Assignment\n\nCheck routing.\n\n## Context\n\n- Root issue: [TEA-30](mention://issue/root-partial) - Partial hub evidence\n- Assignee: researcher@team (mnemon-researcher)\n- Scope: routing\n\n## Feedback\n\n- Expected feedback: result","status":"done"}' ;; + *"issue metadata list root-partial"*) printf '[{"key":"mnemon.hub_backend","value":"multica"},{"key":"mnemon.kind","value":"session_mailbox"},{"key":"mnemon.session_id","value":"multica:session:root-partial"}]\n' ;; + *"issue children root-partial"*) printf '{"children":[{"id":"child-partial","identifier":"TEA-31","title":"TEA-30: routing","status":"done","metadata":{"mnemon.hub_backend":"multica","mnemon.kind":"assignment_mailbox","mnemon.root_issue_id":"root-partial","mnemon.session_id":"multica:session:root-partial","mnemon.assignment_id":"asg-partial","mnemon.principal":"researcher@team"}}]}\n' ;; + *"issue comment list root-partial"*) printf '[{"id":"root-comment","issue_id":"root-partial","content":"Mnemon update: issue admitted\\n\\nmnemon:event=root"}]\n' ;; + *"issue comment list child-partial"*) printf '[{"id":"child-comment","issue_id":"child-partial","content":"Mnemon update: assignment feedback\\n\\nSummary: partial evidence exists.\\n\\nmnemon:event=pg-partial"}]\n' ;; + *"issue runs root-partial"*) printf '[{"id":"task-root","issue_id":"root-partial","agent_id":"agent-planner","status":"completed","completed_at":"2026-06-30T09:00:00Z","workspace_id":"ws-1"}]\n' ;; + *"issue run-messages task-root"*) printf '[{"task_id":"task-root","issue_id":"root-partial","seq":1,"type":"text","content":"Mnemon Multica runtime handled issue TEA-30. Mnemon ingest: recorded.","created_at":"2026-06-30T09:00:01Z"},{"task_id":"task-root","issue_id":"root-partial","seq":2,"type":"tool_use","content":"mnemond ingest observe","created_at":"2026-06-30T09:00:02Z"},{"task_id":"task-root","issue_id":"root-partial","seq":3,"type":"tool_result","content":"recorded","created_at":"2026-06-30T09:00:03Z"}]\n' ;; + *"issue runs child-partial"*) printf '[{"id":"task-child","issue_id":"child-partial","agent_id":"agent-researcher","status":"completed","completed_at":"2026-06-30T09:01:00Z","workspace_id":"ws-1"}]\n' ;; + *"issue run-messages task-child"*) printf '[{"task_id":"task-child","issue_id":"child-partial","seq":1,"type":"text","content":"Mnemon Multica runtime handled issue TEA-31. Mnemon assignment mailbox: correlated.","created_at":"2026-06-30T09:01:01Z"}]\n' ;; + *) printf '{}\n' ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("MULTICA_ARGS_PATH", argsPath) + t.Setenv("MULTICA_STDIN_PATH", stdinPath) + + report, err := runMulticaRuntimeProdSimAcceptance(context.Background(), multicaRuntimeProdSimOptions{ + RunRoot: filepath.Join(tmp, ".testdata", "multica-hub-partial"), + MulticaBin: bin, + WorkspaceID: "ws-1", + RegistryPath: registryPath, + AssigneePrincipal: "planner@team", + TaskCase: multicaAcceptanceTaskCaseParallelPoc, + IssueTitle: "Partial hub evidence", + IssueDescription: "Teamwork acceptance", + Wait: time.Millisecond, + Poll: time.Millisecond, + RequireIngest: true, + RequireHubFlow: true, + MinParticipants: 5, + MinActiveAgents: 5, + }) + if err == nil { + t.Fatalf("expected mailbox expectation failure: %+v", report) + } + if report.Status != "failed" || report.ReportPath == "" { + t.Fatalf("report mismatch: %+v", report) + } + if len(report.ChildIssues) != 1 || len(report.ChildRuns["child-partial"]) != 1 || len(report.ChildMessages["child-partial"]) != 1 { + t.Fatalf("partial child run evidence missing: %+v", report) + } + if len(report.FinalChildren) != 1 || multicaCommentCount(report.ChildComments) != 1 { + t.Fatalf("partial projection evidence missing: %+v", report) + } + var snapshotAssertion bool + for _, assertion := range report.Assertions { + if assertion.Name == "hub-flow partial evidence snapshot captured" { + snapshotAssertion = assertion.Passed + break + } + } + if !snapshotAssertion { + t.Fatalf("missing partial snapshot assertion: %+v", report.Assertions) + } + data, err := os.ReadFile(report.ReportPath) + if err != nil { + t.Fatal(err) + } + var written multicaRuntimeProdSimReport + if err := json.Unmarshal(data, &written); err != nil { + t.Fatalf("report JSON: %v\n%s", err, data) + } + if len(written.ChildRuns["child-partial"]) != 1 || multicaCommentCount(written.ChildComments) != 1 { + t.Fatalf("written report missing partial evidence: %+v", written) + } +} + +func TestMulticaRuntimeProdSimHubFlowAllowsDeferredRunMessages(t *testing.T) { + tmp := t.TempDir() + registryPath := filepath.Join(tmp, "registry.json") + var participants []driver.MulticaParticipantRecord + for _, role := range []string{"planner", "researcher", "implementer", "reviewer", "integrator"} { + participants = append(participants, driver.MulticaParticipantRecord{ + Principal: role + "@team", + AgentName: "mnemon-" + role, + AgentID: "agent-" + role, + Role: role, + }) + } + if err := driver.SaveMulticaRegistry(registryPath, driver.MulticaRegistry{ + SchemaVersion: 1, + WorkspaceID: "ws-1", + RuntimeProfileID: "profile-1", + RuntimeID: "runtime-1", + Participants: participants, + }); err != nil { + t.Fatal(err) + } + argsPath := filepath.Join(tmp, "args.txt") + stdinPath := filepath.Join(tmp, "stdin.txt") + bin := filepath.Join(tmp, "multica") + script := `#!/usr/bin/env sh +printf '%s\n' "$*" >> "$MULTICA_ARGS_PATH" +cat >> "$MULTICA_STDIN_PATH" +case "$*" in + *"agent env get agent-"*) agent="${4:-}"; printf '{"agent_id":"%s","custom_env":{"MNEMON_MANAGED_RUNTIME":"codex-appserver"}}\n' "$agent" ;; + *"issue create"*) printf '{"id":"root-deferred","identifier":"TEA-20","title":"Deferred run messages","description":"Teamwork acceptance","status":"todo"}\n' ;; + *"issue get root-deferred"*) printf '{"id":"root-deferred","identifier":"TEA-20","title":"Deferred run messages","description":"Teamwork acceptance","status":"done"}\n' ;; + *"issue get child-a"*) printf '%s\n' '{"id":"child-a","identifier":"TEA-21","title":"TEA-20: routing","description":"## Assignment\n\nCheck routing.\n\n## Context\n\n- Root issue: [TEA-20](mention://issue/root-deferred) - Deferred run messages\n- Assignee: researcher@team (mnemon-researcher)\n- Scope: routing\n\n## Feedback\n\n- Expected feedback: result","status":"done"}' ;; + *"issue get child-b"*) printf '%s\n' '{"id":"child-b","identifier":"TEA-22","title":"TEA-20: status","description":"## Assignment\n\nCheck status.\n\n## Context\n\n- Root issue: [TEA-20](mention://issue/root-deferred) - Deferred run messages\n- Assignee: implementer@team (mnemon-implementer)\n- Scope: status\n\n## Feedback\n\n- Expected feedback: result","status":"done"}' ;; + *"issue metadata list root-deferred"*) printf '[{"key":"mnemon.hub_backend","value":"multica"},{"key":"mnemon.kind","value":"session_mailbox"},{"key":"mnemon.session_id","value":"multica:session:root-deferred"}]\n' ;; + *"issue children root-deferred"*) printf '{"children":[{"id":"child-a","identifier":"TEA-21","title":"TEA-20: routing","status":"done","metadata":{"mnemon.hub_backend":"multica","mnemon.kind":"assignment_mailbox","mnemon.root_issue_id":"root-deferred","mnemon.session_id":"multica:session:root-deferred","mnemon.assignment_id":"asg-a","mnemon.principal":"researcher@team"}},{"id":"child-b","identifier":"TEA-22","title":"TEA-20: status","status":"done","metadata":{"mnemon.hub_backend":"multica","mnemon.kind":"assignment_mailbox","mnemon.root_issue_id":"root-deferred","mnemon.session_id":"multica:session:root-deferred","mnemon.assignment_id":"asg-b","mnemon.principal":"implementer@team"}}]}\n' ;; + *"issue comment list root-deferred"*) printf '[]\n' ;; + *"issue comment list child-a"*) printf '[{"id":"comment-a","issue_id":"child-a","content":"Mnemon update: assignment feedback\\n\\nmnemon:event=pg-a"}]\n' ;; + *"issue comment list child-b"*) printf '[{"id":"comment-b","issue_id":"child-b","content":"Mnemon update: assignment feedback\\n\\nmnemon:event=pg-b"}]\n' ;; + *"issue runs root-deferred"*) printf '[{"id":"task-root","issue_id":"root-deferred","agent_id":"agent-planner","status":"running","workspace_id":"ws-1"}]\n' ;; + *"issue runs child-a"*) printf '[{"id":"task-a","issue_id":"child-a","agent_id":"agent-researcher","status":"running","workspace_id":"ws-1"}]\n' ;; + *"issue runs child-b"*) printf '[{"id":"task-b","issue_id":"child-b","agent_id":"agent-implementer","status":"running","workspace_id":"ws-1"}]\n' ;; + *) printf '{}\n' ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("MULTICA_ARGS_PATH", argsPath) + t.Setenv("MULTICA_STDIN_PATH", stdinPath) + + report, err := runMulticaRuntimeProdSimAcceptance(context.Background(), multicaRuntimeProdSimOptions{ + RunRoot: filepath.Join(tmp, ".testdata", "multica-hub-deferred"), + MulticaBin: bin, + WorkspaceID: "ws-1", + RegistryPath: registryPath, + AssigneePrincipal: "planner@team", + IssueTitle: "Deferred run messages", + IssueDescription: "Teamwork acceptance", + Wait: time.Millisecond, + Poll: time.Millisecond, + RequireIngest: true, + RequireManagedWake: true, + RequireHubFlow: true, + MinParticipants: 5, + MinActiveAgents: 3, + }) + if err != nil { + t.Fatalf("acceptance: %v report=%+v", err, report) + } + if report.Status != "ok" || len(report.RunMessages) != 0 || len(report.ActiveAgents) != 3 || report.FinalRoot.Status != "done" { + t.Fatalf("deferred hub report mismatch: %+v", report) + } + if !multicaProdSimAssertionsPassed(report) { + t.Fatalf("deferred hub assertions failed: %+v", report.Assertions) + } +} + +func TestMulticaRuntimeProdSimAcceptanceReportsPrerequisitesTogether(t *testing.T) { + tmp := t.TempDir() + report, err := runMulticaRuntimeProdSimAcceptance(context.Background(), multicaRuntimeProdSimOptions{ + RunRoot: filepath.Join(tmp, ".testdata", "multica-readiness"), + MulticaBin: filepath.Join(tmp, "missing-multica"), + RegistryPath: filepath.Join(tmp, "missing-registry.json"), + Wait: time.Millisecond, + Poll: time.Millisecond, + }) + if err == nil { + t.Fatal("expected prerequisite error") + } + if report.Status != "failed" || report.ReportPath == "" { + t.Fatalf("report mismatch: %+v", report) + } + if !strings.Contains(err.Error(), "prerequisites failed") || + !strings.Contains(err.Error(), "Multica CLI not executable") || + !strings.Contains(err.Error(), "Multica registry not found") { + t.Fatalf("error did not report both prerequisites: %v", err) + } + assertionByName := map[string]multicaRuntimeProdSimAssertion{} + for _, assertion := range report.Assertions { + assertionByName[assertion.Name] = assertion + } + for _, name := range []string{"Multica CLI available", "Multica registry available"} { + assertion, ok := assertionByName[name] + if !ok { + t.Fatalf("missing assertion %q in %+v", name, report.Assertions) + } + if assertion.Passed { + t.Fatalf("assertion %q unexpectedly passed: %+v", name, assertion) + } + } + data, err := os.ReadFile(report.ReportPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "Multica CLI available") || !strings.Contains(string(data), "Multica registry available") { + t.Fatalf("written report missing prerequisite assertions:\n%s", data) + } +} diff --git a/harness/cmd/mnemon-acceptance/acceptance_multica_task_cases.go b/harness/cmd/mnemon-acceptance/acceptance_multica_task_cases.go new file mode 100644 index 00000000..da2a0e04 --- /dev/null +++ b/harness/cmd/mnemon-acceptance/acceptance_multica_task_cases.go @@ -0,0 +1,624 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + multicasurface "github.com/mnemon-dev/mnemon/harness/internal/surface/multica" +) + +const ( + multicaAcceptanceTaskCaseR2Readiness = "r2-readiness" + multicaAcceptanceTaskCaseProtocolReAct = "protocol-react-drill" + multicaAcceptanceTaskCaseParallelPoc = "parallel-poc-overlap" + multicaAcceptanceTaskCaseReleaseReadiness = "release-readiness" + multicaAcceptanceTaskCaseIncidentTriage = "incident-triage" + multicaAcceptanceTaskCaseRunbookReview = "runbook-review" +) + +type multicaAcceptanceTaskCaseMaterial struct { + ID string + Title string + Description string + Expectations multicaAcceptanceTaskCaseExpectations + Workstreams []multicaAcceptanceWorkstream + Roles []multicaAcceptanceRolePlan + SharedContexts []multicaAcceptanceSharedContext + RoleOverlaps []string + ContextReuseChecks []string +} + +type multicaAcceptanceTaskCaseExpectations struct { + MinActiveAgents int `json:"min_active_agents,omitempty"` + MinChildMailboxes int `json:"min_child_mailboxes,omitempty"` + MinFeedbackComments int `json:"min_feedback_comments,omitempty"` + TeamworkRounds []string `json:"teamwork_rounds,omitempty"` +} + +type multicaAcceptanceExecutionPlan struct { + RunRoot string `json:"run_root,omitempty"` + CaseRoot string `json:"case_root,omitempty"` + SharedContextDir string `json:"shared_context_dir,omitempty"` + EvidenceDir string `json:"evidence_dir,omitempty"` + Workstreams []multicaAcceptanceWorkstream `json:"workstreams,omitempty"` + Roles []multicaAcceptanceRolePlan `json:"roles,omitempty"` + SharedContexts []multicaAcceptanceSharedContext `json:"shared_contexts,omitempty"` + RoleOverlaps []string `json:"role_overlaps,omitempty"` + ContextReuseChecks []string `json:"context_reuse_checks,omitempty"` +} + +type multicaAcceptanceWorkstream struct { + ID string `json:"id,omitempty"` + Title string `json:"title,omitempty"` + Directory string `json:"directory,omitempty"` + PrimaryRoles []string `json:"primary_roles,omitempty"` + SharedContextRefs []string `json:"shared_context_refs,omitempty"` + ExpectedArtifacts []string `json:"expected_artifacts,omitempty"` +} + +type multicaAcceptanceRolePlan struct { + Role string `json:"role,omitempty"` + Principal string `json:"principal,omitempty"` + Directory string `json:"directory,omitempty"` + Primary []string `json:"primary,omitempty"` + Overlaps []string `json:"overlaps,omitempty"` + Responsibilities []string `json:"responsibilities,omitempty"` +} + +type multicaAcceptanceSharedContext struct { + ID string `json:"id,omitempty"` + Directory string `json:"directory,omitempty"` + UsedBy []string `json:"used_by,omitempty"` + Purpose string `json:"purpose,omitempty"` +} + +func multicaAcceptanceTaskCaseNames() []string { + names := make([]string, 0, len(multicaAcceptanceTaskCases)) + for name := range multicaAcceptanceTaskCases { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func multicaAcceptanceTaskCase(id string, started time.Time) (multicaAcceptanceTaskCaseMaterial, error) { + id = strings.TrimSpace(id) + if id == "" { + id = multicaAcceptanceTaskCaseR2Readiness + } + build, ok := multicaAcceptanceTaskCases[id] + if !ok { + return multicaAcceptanceTaskCaseMaterial{}, fmt.Errorf("unknown Multica task case %q (available: %s)", id, strings.Join(multicaAcceptanceTaskCaseNames(), ", ")) + } + material := build(started) + material.ID = id + return material, nil +} + +func materializeMulticaAcceptanceExecutionPlan(runRoot string, taskCase multicaAcceptanceTaskCaseMaterial) (multicaAcceptanceExecutionPlan, error) { + runRoot = strings.TrimSpace(runRoot) + if runRoot == "" { + return multicaAcceptanceExecutionPlan{}, fmt.Errorf("Multica task case execution plan requires a run root") + } + caseID := multicaAcceptancePathSegment(taskCase.ID, "task-case") + caseRoot := filepath.Join(runRoot, "taskcase", caseID) + sharedDir := filepath.Join(caseRoot, "shared-context") + evidenceDir := filepath.Join(caseRoot, "evidence") + plan := multicaAcceptanceExecutionPlan{ + RunRoot: runRoot, + CaseRoot: caseRoot, + SharedContextDir: sharedDir, + EvidenceDir: evidenceDir, + RoleOverlaps: multicaAcceptanceCleanStrings(taskCase.RoleOverlaps), + ContextReuseChecks: multicaAcceptanceCleanStrings(taskCase.ContextReuseChecks), + } + dirs := []string{caseRoot, sharedDir, evidenceDir} + for _, stream := range taskCase.Workstreams { + stream.ID = strings.TrimSpace(stream.ID) + if stream.ID == "" { + continue + } + stream.Directory = filepath.Join(caseRoot, "workstreams", multicaAcceptancePathSegment(stream.ID, "workstream")) + stream.PrimaryRoles = multicaAcceptanceCleanStrings(stream.PrimaryRoles) + stream.SharedContextRefs = multicaAcceptanceCleanStrings(stream.SharedContextRefs) + stream.ExpectedArtifacts = multicaAcceptanceCleanStrings(stream.ExpectedArtifacts) + plan.Workstreams = append(plan.Workstreams, stream) + dirs = append(dirs, stream.Directory) + } + for _, role := range taskCase.Roles { + role.Role = strings.TrimSpace(role.Role) + role.Principal = strings.TrimSpace(role.Principal) + if role.Role == "" && role.Principal == "" { + continue + } + role.Directory = filepath.Join(caseRoot, "roles", multicaAcceptancePathSegment(multicaAcceptanceFirstNonEmpty(role.Role, role.Principal), "role")) + role.Primary = multicaAcceptanceCleanStrings(role.Primary) + role.Overlaps = multicaAcceptanceCleanStrings(role.Overlaps) + role.Responsibilities = multicaAcceptanceCleanStrings(role.Responsibilities) + plan.Roles = append(plan.Roles, role) + dirs = append(dirs, role.Directory) + } + for _, shared := range taskCase.SharedContexts { + shared.ID = strings.TrimSpace(shared.ID) + if shared.ID == "" { + continue + } + shared.Directory = filepath.Join(sharedDir, multicaAcceptancePathSegment(shared.ID, "context")) + shared.UsedBy = multicaAcceptanceCleanStrings(shared.UsedBy) + shared.Purpose = strings.TrimSpace(shared.Purpose) + plan.SharedContexts = append(plan.SharedContexts, shared) + dirs = append(dirs, shared.Directory) + } + for _, dir := range dirs { + if err := os.MkdirAll(dir, 0o755); err != nil { + return plan, err + } + } + return plan, nil +} + +func renderMulticaAcceptanceExecutionPlan(plan multicaAcceptanceExecutionPlan) string { + if strings.TrimSpace(plan.CaseRoot) == "" { + return "" + } + var b strings.Builder + b.WriteString("## Execution Plan\n\n") + writeMulticaAcceptancePlanBullet(&b, "Case root", plan.CaseRoot) + writeMulticaAcceptancePlanBullet(&b, "Shared context", plan.SharedContextDir) + writeMulticaAcceptancePlanBullet(&b, "Evidence", plan.EvidenceDir) + if len(plan.Workstreams) > 0 { + b.WriteString("\n## Parallel PoCs\n\n") + for _, stream := range plan.Workstreams { + line := "- " + multicaAcceptanceMarkdownCode(stream.ID) + if title := strings.TrimSpace(stream.Title); title != "" { + line += ": " + title + } + if len(stream.PrimaryRoles) > 0 { + line += "; roles " + multicaAcceptanceInlineCodes(stream.PrimaryRoles) + } + if len(stream.SharedContextRefs) > 0 { + line += "; shared context " + multicaAcceptanceInlineCodes(stream.SharedContextRefs) + } + if strings.TrimSpace(stream.Directory) != "" { + line += "; dir " + multicaAcceptanceMarkdownCode(stream.Directory) + } + b.WriteString(line) + b.WriteString("\n") + if len(stream.ExpectedArtifacts) > 0 { + b.WriteString(" - expected artifacts: ") + b.WriteString(strings.Join(stream.ExpectedArtifacts, ", ")) + b.WriteString("\n") + } + } + } + if len(plan.SharedContexts) > 0 { + b.WriteString("\n## Shared Contexts\n\n") + for _, shared := range plan.SharedContexts { + line := "- " + multicaAcceptanceMarkdownCode(shared.ID) + if purpose := strings.TrimSpace(shared.Purpose); purpose != "" { + line += ": " + purpose + } + if len(shared.UsedBy) > 0 { + line += "; used by " + multicaAcceptanceInlineCodes(shared.UsedBy) + } + if strings.TrimSpace(shared.Directory) != "" { + line += "; dir " + multicaAcceptanceMarkdownCode(shared.Directory) + } + b.WriteString(line) + b.WriteString("\n") + } + } + if len(plan.Roles) > 0 { + b.WriteString("\n## Role Matrix\n\n") + for _, role := range plan.Roles { + label := multicaAcceptanceFirstNonEmpty(role.Principal, role.Role) + line := "- " + multicaAcceptanceMarkdownCode(label) + if role.Role != "" && role.Principal != "" && role.Role != role.Principal { + line += " (" + role.Role + ")" + } + if len(role.Primary) > 0 { + line += "; primary " + multicaAcceptanceInlineCodes(role.Primary) + } + if len(role.Overlaps) > 0 { + line += "; overlap " + multicaAcceptanceInlineCodes(role.Overlaps) + } + b.WriteString(line) + b.WriteString("\n") + for _, responsibility := range role.Responsibilities { + b.WriteString(" - ") + b.WriteString(responsibility) + b.WriteString("\n") + } + } + } + if len(plan.RoleOverlaps) > 0 { + b.WriteString("\n## Role Overlap\n\n") + for _, overlap := range plan.RoleOverlaps { + b.WriteString("- ") + b.WriteString(overlap) + b.WriteString("\n") + } + } + if len(plan.ContextReuseChecks) > 0 { + b.WriteString("\n## Context Reuse Checks\n\n") + for _, check := range plan.ContextReuseChecks { + b.WriteString("- ") + b.WriteString(check) + b.WriteString("\n") + } + } + return strings.TrimSpace(b.String()) +} + +func writeMulticaAcceptancePlanBullet(b *strings.Builder, label, value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + b.WriteString("- ") + b.WriteString(label) + b.WriteString(": ") + b.WriteString(multicaAcceptanceMarkdownCode(value)) + b.WriteString("\n") +} + +func multicaAcceptanceInlineCodes(values []string) string { + values = multicaAcceptanceCleanStrings(values) + if len(values) == 0 { + return "" + } + out := make([]string, 0, len(values)) + for _, value := range values { + out = append(out, multicaAcceptanceMarkdownCode(value)) + } + return strings.Join(out, ", ") +} + +func multicaAcceptanceMarkdownCode(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "``" + } + return "`" + strings.ReplaceAll(value, "`", "'") + "`" +} + +func multicaAcceptanceCleanStrings(values []string) []string { + out := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + return out +} + +func multicaAcceptancePathSegment(value, fallback string) string { + value = strings.TrimSpace(value) + if value == "" { + value = fallback + } + var b strings.Builder + lastDash := false + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.' { + b.WriteRune(r) + lastDash = false + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + out := strings.Trim(b.String(), ".-") + if out == "" { + return fallback + } + return out +} + +var multicaAcceptanceTaskCases = map[string]func(time.Time) multicaAcceptanceTaskCaseMaterial{ + multicaAcceptanceTaskCaseR2Readiness: func(started time.Time) multicaAcceptanceTaskCaseMaterial { + return multicaAcceptanceTaskCaseMaterial{ + Title: "Mnemon Multica runtime prod-sim " + started.Format("150405"), + Description: multicasurface.RootSessionDescription(multicasurface.RootSessionMaterial{ + Request: "Run a small Mnemon R2 Multica readiness drill.", + WorkMode: "Use Mnemon teamwork; Multica shows issues, runs, comments, and statuses.", + Handoffs: []string{ + "Route root session visibility and child issue routing checks to separate teammates.", + "After teammate feedback is visible, route a final integration check.", + }, + Validation: []string{ + "Root issue carries session metadata and shows run activity.", + "Accepted assignments become child issue mailboxes assigned to target agents.", + "Feedback comments and statuses are projected back to Multica.", + "Stale or cross-session assignment material is ignored.", + "Final root status reflects completion.", + }, + Completion: "Finish when child feedback comments are visible and the root issue reaches a terminal status.", + }), + Expectations: multicaAcceptanceTaskCaseExpectations{ + MinActiveAgents: 3, + MinChildMailboxes: 2, + MinFeedbackComments: 2, + TeamworkRounds: []string{ + "Round 1: root issue intake and first assignment split", + "Round 2: child mailbox feedback", + "Round 3: integration and final status projection", + }, + }, + } + }, + multicaAcceptanceTaskCaseProtocolReAct: func(started time.Time) multicaAcceptanceTaskCaseMaterial { + return multicaAcceptanceTaskCaseMaterial{ + Title: "Protocol ReAct collaboration drill " + started.Format("150405"), + Description: multicasurface.RootSessionDescription(multicasurface.RootSessionMaterial{ + Request: "Run a complex Mnemon-on-Multica collaboration drill that forces observe-act-reflect cycles across assignment routing, mailbox correlation, feedback projection, and integration. Treat the task as an operator-facing acceptance investigation, not a simple happy-path flow check.", + WorkMode: "Use Multica as the visible teamwork hub. Mnemon should create child assignment mailboxes, collect teammate feedback, integrate results, and create at least one follow-up slice when the first round leaves an unresolved risk.", + Handoffs: []string{ + "Round 1 - Observe: assign separate teammates to inspect root session metadata, child mailbox routing, and runtime run visibility. Each teammate must report concrete issue IDs, agent IDs, statuses, and evidence.", + "Round 2 - Act: the planner/integrator must read first-round feedback, identify the highest-risk gap, and create a follow-up assignment for a different teammate to verify or falsify that gap.", + "Round 3 - Reflect: after the follow-up result appears, the integrator must reconcile all feedback into a final decision that names residual risks, owner, and next validation step.", + }, + Validation: []string{ + "At least three child assignment mailboxes are created across the initial and follow-up rounds.", + "At least four distinct Multica agents participate through root or child runs.", + "Feedback comments include evidence from both the initial observation round and the follow-up action round.", + "The final root result distinguishes observed facts, actions taken, reflection, and any remaining risk.", + "Machine-only protocol fields remain in metadata or markers, not in visible child issue text.", + }, + Completion: "Finish only after the follow-up assignment has feedback and the root issue contains an integrated ReAct-style conclusion: observations, actions, reflection, decision, and next validation step.", + }), + Expectations: multicaAcceptanceTaskCaseExpectations{ + MinActiveAgents: 4, + MinChildMailboxes: 3, + MinFeedbackComments: 3, + TeamworkRounds: []string{ + "Round 1 - Observe: split root metadata, routing, and run-visibility checks", + "Round 2 - Act: create a follow-up assignment for the highest-risk gap", + "Round 3 - Reflect: integrate first-round and follow-up feedback into a decision", + }, + }, + } + }, + multicaAcceptanceTaskCaseParallelPoc: func(started time.Time) multicaAcceptanceTaskCaseMaterial { + return multicaAcceptanceTaskCaseMaterial{ + Title: "Parallel PoC overlap drill " + started.Format("150405"), + Description: multicasurface.RootSessionDescription(multicasurface.RootSessionMaterial{ + Request: "Run a production-like Mnemon-on-Multica collaboration case with three overlapping PoCs running in parallel. The goal is to validate Multica as the primary hub backend for assignment activation, feedback projection, and context reuse across related workstreams.", + WorkMode: "Use Multica root and child issues as the visible teamwork hub. The planner should split the case into parallel child assignment mailboxes, require shared context references in every feedback item, and create at least one follow-up assignment after reading first-round results.", + Handoffs: []string{ + "Round 1 - Observe: launch three parallel PoCs for runtime routing, operator runbook readiness, and release risk. Each PoC must name the shared context it consumed and the evidence it produced.", + "Round 2 - Act: create a follow-up assignment for the highest disagreement or missing evidence across PoCs. The follow-up owner must reuse at least two shared contexts and cite prior child feedback.", + "Round 3 - Reflect: the integrator reconciles all PoC outputs into one operator-facing decision with residual risks, context reuse evidence, and the next validation signal.", + }, + Validation: []string{ + "At least three initial child assignment mailboxes are created, followed by at least one follow-up mailbox after first-round feedback is visible.", + "At least five distinct Multica agents participate through root or child runs when a five-agent registry is available.", + "Every PoC feedback comment references a shared context and an evidence artifact, not only a local conclusion.", + "At least one shared context is reused by two or more PoCs, and the final integration comment names that reuse explicitly.", + "Machine routing, dedupe, session, and assignment fields remain in Multica metadata or stable markers rather than visible issue prose.", + }, + Completion: "Finish only after the follow-up assignment feedback is visible, all three PoCs have result or blocker comments, and the root issue records an integrated decision that explains context reuse.", + }), + Expectations: multicaAcceptanceTaskCaseExpectations{ + MinActiveAgents: 5, + MinChildMailboxes: 4, + MinFeedbackComments: 4, + TeamworkRounds: []string{ + "Round 1 - Observe: three parallel PoCs split runtime, runbook, and release risk", + "Round 2 - Act: follow up on disagreement or missing evidence across PoCs", + "Round 3 - Reflect: integrate reused context, final decision, and residual risks", + }, + }, + Workstreams: []multicaAcceptanceWorkstream{ + { + ID: "poc-runtime-routing", + Title: "Runtime routing and assignment mailbox correlation", + PrimaryRoles: []string{"planner@team", "researcher@team", "implementer@team"}, + SharedContextRefs: []string{"session-map", "mailbox-contract", "evidence-ledger"}, + ExpectedArtifacts: []string{"routing-evidence.md", "assignment-mailbox-map.json", "runtime-run-summary.md"}, + }, + { + ID: "poc-operator-runbook", + Title: "Operator runbook and rollback readiness", + PrimaryRoles: []string{"implementer@team", "reviewer@team"}, + SharedContextRefs: []string{"mailbox-contract", "risk-register", "evidence-ledger"}, + ExpectedArtifacts: []string{"runbook-gap-list.md", "rollback-checklist.md", "operator-risk-notes.md"}, + }, + { + ID: "poc-release-risk", + Title: "Release decision and product status projection", + PrimaryRoles: []string{"researcher@team", "reviewer@team", "integrator@team"}, + SharedContextRefs: []string{"session-map", "risk-register", "evidence-ledger"}, + ExpectedArtifacts: []string{"release-risk-matrix.md", "status-projection-evidence.md", "ship-hold-decision.md"}, + }, + }, + Roles: []multicaAcceptanceRolePlan{ + { + Role: "planner", + Principal: "planner@team", + Primary: []string{"poc-runtime-routing"}, + Overlaps: []string{"poc-release-risk"}, + Responsibilities: []string{ + "Seed the root teamwork signal and create the three first-round assignment mailboxes.", + "Read first-round feedback before creating the follow-up assignment.", + }, + }, + { + Role: "researcher", + Principal: "researcher@team", + Primary: []string{"poc-runtime-routing"}, + Overlaps: []string{"poc-release-risk"}, + Responsibilities: []string{ + "Trace runtime routing evidence and reuse the session map when judging release risk.", + "Report concrete issue IDs, agent IDs, run status, and evidence refs.", + }, + }, + { + Role: "implementer", + Principal: "implementer@team", + Primary: []string{"poc-operator-runbook"}, + Overlaps: []string{"poc-runtime-routing"}, + Responsibilities: []string{ + "Verify the mailbox contract against the operator runbook and runtime behavior.", + "Name the smallest code or runbook change that would reduce operator ambiguity.", + }, + }, + { + Role: "reviewer", + Principal: "reviewer@team", + Primary: []string{"poc-release-risk"}, + Overlaps: []string{"poc-operator-runbook"}, + Responsibilities: []string{ + "Challenge release readiness with rollback and status-projection evidence.", + "Identify disagreement between PoC outputs before final integration.", + }, + }, + { + Role: "integrator", + Principal: "integrator@team", + Primary: []string{"poc-release-risk"}, + Overlaps: []string{"poc-runtime-routing", "poc-operator-runbook"}, + Responsibilities: []string{ + "Consume all PoC outputs and the follow-up result.", + "Write the final root decision with context reuse evidence and residual risk.", + }, + }, + }, + SharedContexts: []multicaAcceptanceSharedContext{ + { + ID: "session-map", + UsedBy: []string{"poc-runtime-routing", "poc-release-risk"}, + Purpose: "Root issue, session mailbox, child issue, and agent run map used to correlate visible Multica artifacts.", + }, + { + ID: "mailbox-contract", + UsedBy: []string{"poc-runtime-routing", "poc-operator-runbook"}, + Purpose: "Human-readable assignment mailbox contract plus hidden metadata boundary for routing, dedupe, and correlation.", + }, + { + ID: "risk-register", + UsedBy: []string{"poc-operator-runbook", "poc-release-risk"}, + Purpose: "Shared release and operator risks with owner, severity, mitigation, and validation signal.", + }, + { + ID: "evidence-ledger", + UsedBy: []string{"poc-runtime-routing", "poc-operator-runbook", "poc-release-risk"}, + Purpose: "Cross-PoC evidence index for issue IDs, run IDs, comments, statuses, and artifacts.", + }, + }, + RoleOverlaps: []string{ + "researcher@team carries runtime routing evidence into poc-release-risk through session-map.", + "implementer@team reuses mailbox-contract across poc-runtime-routing and poc-operator-runbook.", + "reviewer@team connects rollback concerns from poc-operator-runbook to poc-release-risk.", + "integrator@team consumes all PoCs but must wait for follow-up feedback before closing the root issue.", + }, + ContextReuseChecks: []string{ + "Every first-round feedback comment names at least one shared context and one evidence artifact.", + "The follow-up assignment cites two prior child comments or artifacts before adding new work.", + "The final root comment names which shared contexts were reused and where disagreement was resolved.", + "No visible issue text should expose session ids, assignment ids, assignment fingerprints, or projection-owner keys.", + }, + } + }, + multicaAcceptanceTaskCaseReleaseReadiness: func(started time.Time) multicaAcceptanceTaskCaseMaterial { + return multicaAcceptanceTaskCaseMaterial{ + Title: "Release readiness handoff " + started.Format("150405"), + Description: multicasurface.RootSessionDescription(multicasurface.RootSessionMaterial{ + Request: "Prepare a release readiness decision for a Mnemon Multica runtime update that affects assignment routing, mailbox metadata, and visible status projection.", + WorkMode: "Use Multica as the visible coordination hub while Mnemon keeps the canonical event state.", + Handoffs: []string{ + "Ask one teammate to verify release risk: registry coverage, runtime activation evidence, and stale-session isolation.", + "Ask another teammate to verify the operator path: rollback notes, status transitions, and what should be checked before enabling the update.", + "Have the integrator decide ship, hold, or ship-with-follow-up based on teammate feedback.", + }, + Validation: []string{ + "Release decision references concrete root and child issue evidence.", + "Assignment mailboxes route to the intended Multica agents.", + "Feedback comments distinguish release blockers from ordinary progress.", + "Final status makes the release decision visible without exposing machine-only protocol fields.", + }, + Completion: "Finish when the root issue contains an explicit release decision and every child mailbox has result or blocker feedback.", + }), + Expectations: multicaAcceptanceTaskCaseExpectations{ + MinActiveAgents: 4, + MinChildMailboxes: 3, + MinFeedbackComments: 3, + TeamworkRounds: []string{ + "Round 1: risk and operator-path review", + "Round 2: release decision follow-up for any hold risk", + "Round 3: final ship/hold integration", + }, + }, + } + }, + multicaAcceptanceTaskCaseIncidentTriage: func(started time.Time) multicaAcceptanceTaskCaseMaterial { + return multicaAcceptanceTaskCaseMaterial{ + Title: "Runtime regression triage " + started.Format("150405"), + Description: multicasurface.RootSessionDescription(multicasurface.RootSessionMaterial{ + Request: "Triage a production-style regression report where Multica child assignment mailboxes may be created but feedback comments or completion statuses appear late.", + WorkMode: "Use Mnemon teamwork to split diagnosis, mitigation, and verification while Multica remains the operator-facing hub.", + Handoffs: []string{ + "Assign one teammate to inspect routing evidence and identify whether assignment metadata is complete enough for correlation.", + "Assign one teammate to inspect feedback projection and status mapping for delayed or missing updates.", + "Ask the integrator to propose the smallest mitigation and the follow-up acceptance signal needed before closing the incident.", + }, + Validation: []string{ + "The triage identifies whether the problem is intake, routing, wake, feedback projection, or Multica run visibility.", + "Each child mailbox reports evidence with issue IDs, agent IDs, and observed status.", + "The root issue records a mitigation decision rather than only stating that the flow completed.", + }, + Completion: "Finish when the root issue has a triage decision, mitigation summary, and clear next verification step.", + }), + Expectations: multicaAcceptanceTaskCaseExpectations{ + MinActiveAgents: 4, + MinChildMailboxes: 3, + MinFeedbackComments: 3, + TeamworkRounds: []string{ + "Round 1: diagnose intake, routing, and feedback projection", + "Round 2: act on the leading failure mode with a mitigation check", + "Round 3: reflect into incident decision and next verification", + }, + }, + } + }, + multicaAcceptanceTaskCaseRunbookReview: func(started time.Time) multicaAcceptanceTaskCaseMaterial { + return multicaAcceptanceTaskCaseMaterial{ + Title: "Operator runbook review " + started.Format("150405"), + Description: multicasurface.RootSessionDescription(multicasurface.RootSessionMaterial{ + Request: "Review the operator runbook for installing, provisioning, and validating the Mnemon Multica runtime in a workspace with multiple participant agents.", + WorkMode: "Use Multica child issues to split documentation review, command validation, and operator risk notes.", + Handoffs: []string{ + "Ask one teammate to verify the install/provisioning commands and identify missing prerequisites.", + "Ask another teammate to verify the validation section covers root metadata, child mailbox routing, feedback comments, and final statuses.", + "Have the integrator produce a concise runbook change list with priority and owner.", + }, + Validation: []string{ + "Runbook findings are tied to concrete operator actions, not generic flow checks.", + "The review covers both successful operation and recovery from missing metadata or delayed run messages.", + "Final feedback identifies documentation changes that can be applied without changing runtime semantics.", + }, + Completion: "Finish when each review slice has feedback and the root issue contains a prioritized runbook update list.", + }), + Expectations: multicaAcceptanceTaskCaseExpectations{ + MinActiveAgents: 4, + MinChildMailboxes: 3, + MinFeedbackComments: 3, + TeamworkRounds: []string{ + "Round 1: install, validation, and risk review", + "Round 2: follow-up on the most ambiguous runbook step", + "Round 3: integrate prioritized documentation changes", + }, + }, + } + }, +} diff --git a/harness/cmd/mnemon-acceptance/acceptance_multica_task_cases_test.go b/harness/cmd/mnemon-acceptance/acceptance_multica_task_cases_test.go new file mode 100644 index 00000000..6be534cc --- /dev/null +++ b/harness/cmd/mnemon-acceptance/acceptance_multica_task_cases_test.go @@ -0,0 +1,208 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/driver" +) + +func TestMulticaTaskCaseProtocolReActIsMultiRound(t *testing.T) { + started := time.Date(2026, 6, 30, 9, 10, 11, 0, time.UTC) + material, err := multicaAcceptanceTaskCase(multicaAcceptanceTaskCaseProtocolReAct, started) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "Protocol ReAct collaboration drill", + "Round 1 - Observe", + "Round 2 - Act", + "Round 3 - Reflect", + "follow-up assignment", + } { + if !strings.Contains(material.Title+"\n"+material.Description, want) { + t.Fatalf("task case missing %q:\n%s\n%s", want, material.Title, material.Description) + } + } + if material.Expectations.MinActiveAgents != 4 || + material.Expectations.MinChildMailboxes != 3 || + material.Expectations.MinFeedbackComments != 3 || + len(material.Expectations.TeamworkRounds) != 3 { + t.Fatalf("unexpected expectations: %+v", material.Expectations) + } +} + +func TestMulticaTaskCaseParallelPocMaterializesIsolatedOverlapPlan(t *testing.T) { + started := time.Date(2026, 6, 30, 9, 10, 11, 0, time.UTC) + material, err := multicaAcceptanceTaskCase(multicaAcceptanceTaskCaseParallelPoc, started) + if err != nil { + t.Fatal(err) + } + runRoot := t.TempDir() + plan, err := materializeMulticaAcceptanceExecutionPlan(runRoot, material) + if err != nil { + t.Fatal(err) + } + if len(plan.Workstreams) != 3 || len(plan.Roles) != 5 || len(plan.SharedContexts) < 4 { + t.Fatalf("unexpected plan shape: %+v", plan) + } + for _, dir := range append([]string{plan.CaseRoot, plan.SharedContextDir, plan.EvidenceDir}, multicaPlanDirs(plan)...) { + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("missing plan dir %s: %v", dir, err) + } + if !info.IsDir() { + t.Fatalf("plan path is not a dir: %s", dir) + } + rel, err := filepath.Rel(runRoot, dir) + if err != nil { + t.Fatal(err) + } + if strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { + t.Fatalf("plan dir escaped run root: root=%s dir=%s rel=%s", runRoot, dir, rel) + } + } + reused := false + for _, shared := range plan.SharedContexts { + if len(shared.UsedBy) > 1 { + reused = true + break + } + } + if !reused { + t.Fatalf("expected at least one shared context reused by multiple PoCs: %+v", plan.SharedContexts) + } + rendered := renderMulticaAcceptanceExecutionPlan(plan) + for _, want := range []string{ + "Parallel PoCs", + "Context Reuse Checks", + "poc-runtime-routing", + "poc-operator-runbook", + "poc-release-risk", + "evidence-ledger", + } { + if !strings.Contains(rendered, want) { + t.Fatalf("rendered plan missing %q:\n%s", want, rendered) + } + } +} + +func TestMulticaRuntimeProdSimTaskCaseWritesExecutionPlanToIssue(t *testing.T) { + tmp := t.TempDir() + registryPath := filepath.Join(tmp, "registry.json") + if err := driver.SaveMulticaRegistry(registryPath, driver.MulticaRegistry{ + SchemaVersion: 1, + WorkspaceID: "ws-1", + RuntimeProfileID: "profile-1", + RuntimeID: "runtime-1", + Participants: []driver.MulticaParticipantRecord{{ + Principal: "planner@team", + AgentName: "mnemon-planner", + AgentID: "agent-1", + Role: "planner", + }}, + }); err != nil { + t.Fatal(err) + } + argsPath := filepath.Join(tmp, "args.txt") + stdinPath := filepath.Join(tmp, "stdin.txt") + bin := filepath.Join(tmp, "multica") + script := `#!/usr/bin/env sh +printf '%s\n' "$*" >> "$MULTICA_ARGS_PATH" +cat >> "$MULTICA_STDIN_PATH" +case "$*" in + *"issue create"*) printf '{"id":"iss-poc","identifier":"TEA-50","title":"Parallel PoC overlap drill","description":"Teamwork acceptance","status":"todo"}\n' ;; + *"issue runs iss-poc"*) printf '[{"id":"task-poc","issue_id":"iss-poc","agent_id":"agent-1","status":"completed","completed_at":"2026-06-30T09:00:00Z","workspace_id":"ws-1"}]\n' ;; + *"issue run-messages task-poc"*) printf '[{"task_id":"task-poc","issue_id":"iss-poc","seq":1,"type":"assistant","content":"Mnemon Multica runtime handled issue TEA-50. Mnemon ingest: recorded.","created_at":"2026-06-30T09:00:01Z"}]\n' ;; + *) printf '{}\n' ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("MULTICA_ARGS_PATH", argsPath) + t.Setenv("MULTICA_STDIN_PATH", stdinPath) + + report, err := runMulticaRuntimeProdSimAcceptance(context.Background(), multicaRuntimeProdSimOptions{ + RunRoot: filepath.Join(tmp, ".testdata", "multica-parallel-poc"), + MulticaBin: bin, + WorkspaceID: "ws-1", + RegistryPath: registryPath, + AssigneePrincipal: "planner@team", + TaskCase: multicaAcceptanceTaskCaseParallelPoc, + Wait: time.Millisecond, + Poll: time.Millisecond, + RequireIngest: true, + }) + if err != nil { + t.Fatalf("acceptance: %v report=%+v", err, report) + } + if report.TaskCase != multicaAcceptanceTaskCaseParallelPoc || + report.TaskExpectations.MinActiveAgents != 5 || + len(report.ExecutionPlan.Workstreams) != 3 { + t.Fatalf("task case report mismatch: %+v", report) + } + args, err := os.ReadFile(argsPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(args), "issue create --title Parallel PoC overlap drill") { + t.Fatalf("issue title did not come from task case:\n%s", args) + } + stdin, err := os.ReadFile(stdinPath) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "## Execution Plan", + "## Parallel PoCs", + "## Context Reuse Checks", + "poc-runtime-routing", + "poc-operator-runbook", + "poc-release-risk", + } { + if !strings.Contains(string(stdin), want) { + t.Fatalf("issue stdin missing %q:\n%s", want, stdin) + } + } +} + +func TestMulticaRuntimeProdSimRejectsUnknownTaskCase(t *testing.T) { + runRoot := filepath.Join(t.TempDir(), ".testdata", "unknown-task-case") + report, err := runMulticaRuntimeProdSimAcceptance(context.Background(), multicaRuntimeProdSimOptions{ + RunRoot: runRoot, + TaskCase: "missing-task-case", + Wait: time.Millisecond, + Poll: time.Millisecond, + }) + if err == nil { + t.Fatal("expected unknown task case error") + } + if !strings.Contains(err.Error(), "unknown Multica task case") { + t.Fatalf("unexpected error: %v", err) + } + if report.Status != "failed" || report.ReportPath == "" || !strings.HasPrefix(report.ReportPath, runRoot) { + t.Fatalf("report mismatch: %+v", report) + } + if _, err := os.Stat(report.ReportPath); err != nil { + t.Fatalf("report was not written: %v", err) + } +} + +func multicaPlanDirs(plan multicaAcceptanceExecutionPlan) []string { + var out []string + for _, stream := range plan.Workstreams { + out = append(out, stream.Directory) + } + for _, role := range plan.Roles { + out = append(out, role.Directory) + } + for _, shared := range plan.SharedContexts { + out = append(out, shared.Directory) + } + return out +} diff --git a/harness/cmd/mnemon-acceptance/root.go b/harness/cmd/mnemon-acceptance/root.go index 9728863b..6e55494a 100644 --- a/harness/cmd/mnemon-acceptance/root.go +++ b/harness/cmd/mnemon-acceptance/root.go @@ -1,8 +1,10 @@ package main import ( + "context" "fmt" "os" + "os/signal" "github.com/spf13/cobra" ) @@ -19,7 +21,9 @@ var rootCmd = &cobra.Command{ } func main() { - if err := rootCmd.Execute(); err != nil { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + if err := rootCmd.ExecuteContext(ctx); 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 index 9e0d92f8..d4279366 100644 --- a/harness/cmd/mnemon-acceptance/root_test.go +++ b/harness/cmd/mnemon-acceptance/root_test.go @@ -14,6 +14,7 @@ func TestRootExposesAcceptanceScenarioCommands(t *testing.T) { "r1-task-sim", "r1-cluster-single-entrypoint", "r1-github-mesh-task-suite", + "multica-provision", "multica-runtime-prod-sim", } { if !commands[want] { diff --git a/harness/cmd/mnemon-harness/agent.go b/harness/cmd/mnemon-harness/agent.go new file mode 100644 index 00000000..3d8e9405 --- /dev/null +++ b/harness/cmd/mnemon-harness/agent.go @@ -0,0 +1,114 @@ +package main + +import ( + "fmt" + "strings" + + participantrole "github.com/mnemon-dev/mnemon/harness/internal/participant" + "github.com/mnemon-dev/mnemon/harness/internal/productconfig" + "github.com/spf13/cobra" +) + +var ( + agentRoot string + agentConfigPath string + agentPrincipal string + agentDisplayName string + agentRole string + agentRuntimeKind string + agentRuntimeMode string +) + +var agentCmd = &cobra.Command{ + Use: "agent", + Short: "Manage harness agents", +} + +var agentAddCmd = &cobra.Command{ + Use: "add --principal PRINCIPAL", + Short: "Add one harness agent", + RunE: runAgentAdd, +} + +var agentListCmd = &cobra.Command{ + Use: "list", + Short: "List harness agents", + RunE: runAgentList, +} + +func init() { + agentCmd.PersistentFlags().StringVar(&agentRoot, "root", ".", "project root") + agentCmd.PersistentFlags().StringVar(&agentConfigPath, "config", "", "harness product config path") + _ = agentCmd.PersistentFlags().MarkHidden("config") + agentAddCmd.Flags().StringVar(&agentPrincipal, "principal", "", "agent principal") + agentAddCmd.Flags().StringVar(&agentDisplayName, "display-name", "", "agent display name") + agentAddCmd.Flags().StringVar(&agentRole, "role", "", "agent role") + agentAddCmd.Flags().StringVar(&agentRuntimeKind, "runtime-kind", productconfig.RuntimeKindCodex, "host runtime kind") + agentAddCmd.Flags().StringVar(&agentRuntimeMode, "runtime-mode", productconfig.RuntimeModeManagedOrHost, "host runtime mode") + _ = agentAddCmd.Flags().MarkHidden("runtime-kind") + _ = agentAddCmd.Flags().MarkHidden("runtime-mode") + agentCmd.AddCommand(agentAddCmd, agentListCmd) + agentCmd.GroupID = groupSpine + rootCmd.AddCommand(agentCmd) +} + +func runAgentAdd(cmd *cobra.Command, args []string) error { + principal := participantrole.NormalizePrincipal(agentPrincipal) + if principal == "" { + return fmt.Errorf("agent add requires --principal") + } + cfg, _, err := loadHarnessProductConfig(agentRoot, agentConfigPath) + if err != nil { + return err + } + next := productconfig.Participant{ + Principal: principal, + DisplayName: strings.TrimSpace(agentDisplayName), + Role: strings.TrimSpace(agentRole), + HostRuntime: productconfig.HostRuntime{ + Kind: strings.TrimSpace(agentRuntimeKind), + Mode: strings.TrimSpace(agentRuntimeMode), + }, + } + cfg.Participants = upsertProductParticipant(cfg.Participants, next) + if len(cfg.Daemon.DriveSources) == 0 { + cfg.Daemon.DriveSources = []string{productconfig.DriveManagedLocal} + } + path, err := saveHarnessProductConfig(agentRoot, agentConfigPath, cfg) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Agent: added %s\n", principal) + fmt.Fprintf(cmd.OutOrStdout(), "Harness config: %s\n", path) + return nil +} + +func runAgentList(cmd *cobra.Command, args []string) error { + cfg, path, err := loadHarnessProductConfig(agentRoot, agentConfigPath) + if err != nil { + return err + } + if len(cfg.Participants) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "Agents: none configured") + fmt.Fprintf(cmd.OutOrStdout(), "Harness config: %s\n", path) + return nil + } + for _, participant := range cfg.Participants { + label := participant.Principal + if participant.DisplayName != "" { + label += " (" + participant.DisplayName + ")" + } + if participant.Role != "" { + label += " - " + participant.Role + } + fmt.Fprintf(cmd.OutOrStdout(), "Agent: %s\n", label) + } + fmt.Fprintf(cmd.OutOrStdout(), "Harness config: %s\n", path) + return nil +} + +func upsertProductParticipant(participants []productconfig.Participant, next productconfig.Participant) []productconfig.Participant { + return participantrole.UpsertByPrincipal(participants, next, func(participant productconfig.Participant) string { + return participant.Principal + }, nil) +} diff --git a/harness/cmd/mnemon-harness/config.go b/harness/cmd/mnemon-harness/config.go new file mode 100644 index 00000000..adba786d --- /dev/null +++ b/harness/cmd/mnemon-harness/config.go @@ -0,0 +1,85 @@ +package main + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/mnemon-dev/mnemon/harness/internal/productconfig" + "github.com/spf13/cobra" +) + +var ( + configRoot string + configPath string +) + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Inspect harness product configuration", +} + +var configValidateCmd = &cobra.Command{ + Use: "validate", + Short: "Validate harness product configuration", + RunE: runConfigValidate, +} + +var configOpenCmd = &cobra.Command{ + Use: "open", + Short: "Open harness product configuration", + RunE: runConfigOpen, +} + +func init() { + configCmd.PersistentFlags().StringVar(&configRoot, "root", ".", "project root") + configCmd.PersistentFlags().StringVar(&configPath, "config", "", "harness product config path") + _ = configCmd.PersistentFlags().MarkHidden("config") + configCmd.AddCommand(configValidateCmd, configOpenCmd) + configCmd.GroupID = groupSpine + rootCmd.AddCommand(configCmd) +} + +func runConfigValidate(cmd *cobra.Command, args []string) error { + path := resolvedProductConfigPath() + cfg, err := productconfig.Load(path) + if err == nil { + fmt.Fprintf(cmd.OutOrStdout(), "Harness config: valid %s\n", path) + fmt.Fprintf(cmd.OutOrStdout(), "Participants: %d\n", len(cfg.Participants)) + return nil + } + if !errors.Is(err, os.ErrNotExist) { + return err + } + legacy, found, err := productconfig.FromLegacy(filepath.Clean(configRoot)) + if err != nil { + return err + } + if !found { + return fmt.Errorf("Harness config is not configured") + } + fmt.Fprintln(cmd.OutOrStdout(), "Harness config: valid legacy bridge") + fmt.Fprintf(cmd.OutOrStdout(), "Participants: %d\n", len(legacy.Participants)) + return nil +} + +func runConfigOpen(cmd *cobra.Command, args []string) error { + path := resolvedProductConfigPath() + editor := strings.TrimSpace(os.Getenv("EDITOR")) + if editor == "" { + fmt.Fprintf(cmd.OutOrStdout(), "Harness config: %s\n", path) + return nil + } + open := exec.CommandContext(cmd.Context(), editor, path) + open.Stdin = os.Stdin + open.Stdout = cmd.OutOrStdout() + open.Stderr = cmd.ErrOrStderr() + return open.Run() +} + +func resolvedProductConfigPath() string { + return productconfig.DefaultPath(filepath.Clean(configRoot), configPath) +} diff --git a/harness/cmd/mnemon-harness/config_daemon_test.go b/harness/cmd/mnemon-harness/config_daemon_test.go new file mode 100644 index 00000000..18ed17ff --- /dev/null +++ b/harness/cmd/mnemon-harness/config_daemon_test.go @@ -0,0 +1,395 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/daemon" + "github.com/mnemon-dev/mnemon/harness/internal/productconfig" + multicasurface "github.com/mnemon-dev/mnemon/harness/internal/surface/multica" +) + +func TestConfigValidateReadsProductConfig(t *testing.T) { + root := t.TempDir() + cfg := productconfig.Default() + cfg.Participants = []productconfig.Participant{{ + Principal: "planner@team", + HostRuntime: productconfig.HostRuntime{ + Kind: productconfig.RuntimeKindCodex, + Mode: productconfig.RuntimeModeManaged, + }, + }} + if err := productconfig.Save(productconfig.DefaultPath(root, ""), cfg); err != nil { + t.Fatal(err) + } + oldRoot, oldPath := configRoot, configPath + configRoot, configPath = root, "" + t.Cleanup(func() { configRoot, configPath = oldRoot, oldPath }) + + cmd, out := testCommand() + if err := runConfigValidate(cmd, nil); err != nil { + t.Fatal(err) + } + if got := out.String(); !strings.Contains(got, "Harness config: valid") || !strings.Contains(got, "Participants: 1") { + t.Fatalf("unexpected config validate output:\n%s", got) + } +} + +func TestConfigValidateReadsLegacyMulticaRegistry(t *testing.T) { + root := t.TempDir() + reg := multicasurface.MulticaRegistry{ + SchemaVersion: 1, + WorkspaceID: "ws-multica", + Participants: []multicasurface.MulticaParticipantRecord{{ + Principal: "planner@team", + AgentName: "mnemon-planner", + AgentID: "agent-planner", + Role: "planner", + }}, + } + if err := multicasurface.SaveMulticaRegistry(multicasurface.MulticaRegistryPath(root, ""), reg); err != nil { + t.Fatal(err) + } + oldRoot, oldPath := configRoot, configPath + configRoot, configPath = root, "" + t.Cleanup(func() { configRoot, configPath = oldRoot, oldPath }) + + cmd, out := testCommand() + if err := runConfigValidate(cmd, nil); err != nil { + t.Fatal(err) + } + if got := out.String(); !strings.Contains(got, "Harness config: valid legacy bridge") || !strings.Contains(got, "Participants: 1") { + t.Fatalf("unexpected config validate output:\n%s", got) + } +} + +func TestDaemonStatusDoesNotMutateMissingConfig(t *testing.T) { + oldRoot := daemonRoot + daemonRoot = t.TempDir() + t.Cleanup(func() { daemonRoot = oldRoot }) + + cmd, out := testCommand() + if err := runDaemonStatus(cmd, nil); err != nil { + t.Fatal(err) + } + got := out.String() + for _, want := range []string{"Harness config: not configured", "Harness daemon: not running"} { + if !strings.Contains(got, want) { + t.Fatalf("daemon status missing %q:\n%s", want, got) + } + } +} + +func TestDaemonStatusShowsConfiguredRoleSummary(t *testing.T) { + root := t.TempDir() + cfg := productconfig.Default() + cfg.Connections.Multica = productconfig.MulticaConnection{Enabled: true, Workspace: "ws-multica", RuntimeBinary: "mnemon-multica-runtime"} + cfg.Connections.GitHub = productconfig.GitHubConnection{Enabled: true, Repo: "mnemon-dev/mnemon-teamwork-example"} + cfg.Connections.Mnemonhub = productconfig.MnemonhubConnection{Enabled: true, Endpoint: "https://hub.example.invalid"} + cfg.Daemon.InteractionWatchers = []string{productconfig.ConnectionMultica, productconfig.ConnectionGitHub, productconfig.ConnectionMnemonhub} + cfg.Daemon.DriveSources = []string{productconfig.DriveManagedLocal} + cfg.Daemon.ProjectionSurfaces = []string{productconfig.ConnectionMultica} + if err := productconfig.Save(productconfig.DefaultPath(root, ""), cfg); err != nil { + t.Fatal(err) + } + oldRoot := daemonRoot + daemonRoot = root + t.Cleanup(func() { daemonRoot = oldRoot }) + + cmd, out := testCommand() + if err := runDaemonStatus(cmd, nil); err != nil { + t.Fatal(err) + } + got := out.String() + for _, want := range []string{ + "Harness config: configured", + "Harness daemon roles: watchers=3 drive=1 surfaces=1", + "Harness daemon role details:", + "multica-watch [interaction]: watcher=multica boundary=activation-carrier", + "github-watch [interaction]: watcher=github boundary=external-interaction", + "mnemonhub-watch [interaction]: watcher=mnemonhub boundary=remote-exchange", + "managed-drive [drive]: drive=managed-local boundary=managed-runtime", + "multica-project [projection]: surface=multica boundary=projection-surface", + "Harness daemon: not running", + } { + if !strings.Contains(got, want) { + t.Fatalf("daemon status missing %q:\n%s", want, got) + } + } +} + +func TestDaemonStatusShowsLegacyMulticaRoleSummary(t *testing.T) { + root := t.TempDir() + reg := multicasurface.MulticaRegistry{ + SchemaVersion: 1, + WorkspaceID: "ws-multica", + Participants: []multicasurface.MulticaParticipantRecord{{ + Principal: "planner@team", + AgentName: "mnemon-planner", + AgentID: "agent-planner", + Role: "planner", + }}, + } + if err := multicasurface.SaveMulticaRegistry(multicasurface.MulticaRegistryPath(root, ""), reg); err != nil { + t.Fatal(err) + } + oldRoot := daemonRoot + daemonRoot = root + t.Cleanup(func() { daemonRoot = oldRoot }) + + cmd, out := testCommand() + if err := runDaemonStatus(cmd, nil); err != nil { + t.Fatal(err) + } + got := out.String() + for _, want := range []string{"Harness config: legacy bridge", "Harness daemon roles: watchers=1 drive=1 surfaces=1"} { + if !strings.Contains(got, want) { + t.Fatalf("daemon status missing %q:\n%s", want, got) + } + } +} + +func TestDaemonStatusShowsWorkerSnapshot(t *testing.T) { + root := t.TempDir() + cfg := productconfig.Default() + cfg.Connections.Multica = productconfig.MulticaConnection{Enabled: true, Workspace: "ws-multica", RuntimeBinary: "mnemon-multica-runtime"} + cfg.Daemon.InteractionWatchers = []string{productconfig.ConnectionMultica} + cfg.Daemon.DriveSources = []string{productconfig.DriveManagedLocal} + if err := productconfig.Save(productconfig.DefaultPath(root, ""), cfg); err != nil { + t.Fatal(err) + } + started := time.Date(2026, 6, 29, 8, 0, 0, 0, time.UTC) + updated := started.Add(time.Minute) + if err := daemon.NewFileSnapshotStore(daemon.StatusSnapshotPath(root, "")).Save(daemon.Snapshot{ + StartedAt: started, + Workers: map[string]daemon.WorkerSnapshot{ + "managed-drive": { + Kind: daemon.WorkerDrive, + Status: "idle", + Message: "wake ledger clean", + UpdatedAt: updated, + }, + "multica-watch": { + Kind: daemon.WorkerInteraction, + Status: "failed", + Error: "metadata cursor rejected", + }, + }, + }); err != nil { + t.Fatal(err) + } + oldRoot := daemonRoot + daemonRoot = root + t.Cleanup(func() { daemonRoot = oldRoot }) + + cmd, out := testCommand() + if err := runDaemonStatus(cmd, nil); err != nil { + t.Fatal(err) + } + got := out.String() + for _, want := range []string{ + "Harness daemon snapshot: workers=2 started=2026-06-29T08:00:00Z", + "managed-drive [drive]: idle (wake ledger clean) updated=2026-06-29T08:01:00Z", + "multica-watch [interaction]: failed error=metadata cursor rejected", + "Harness daemon: not running", + } { + if !strings.Contains(got, want) { + t.Fatalf("daemon status missing %q:\n%s", want, got) + } + } +} + +func TestLoadDaemonSnapshotConfigRejectsInvalidProductConfig(t *testing.T) { + root := t.TempDir() + reg := multicasurface.MulticaRegistry{ + SchemaVersion: 1, + WorkspaceID: "ws-multica", + Participants: []multicasurface.MulticaParticipantRecord{{ + Principal: "planner@team", + AgentName: "mnemon-planner", + AgentID: "agent-planner", + Role: "planner", + }}, + } + if err := multicasurface.SaveMulticaRegistry(multicasurface.MulticaRegistryPath(root, ""), reg); err != nil { + t.Fatal(err) + } + configPath := productconfig.DefaultPath(root, "") + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, []byte(`{"schema_version":99}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + _, ok, err := loadDaemonSnapshotConfig(root) + if err == nil { + t.Fatal("expected invalid product config error") + } + if ok { + t.Fatal("invalid product config should not load through legacy fallback") + } + if !strings.Contains(err.Error(), "unsupported") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAgentAddAndListWriteProductConfig(t *testing.T) { + root := t.TempDir() + oldRoot, oldPath := agentRoot, agentConfigPath + oldPrincipal, oldDisplayName, oldRole := agentPrincipal, agentDisplayName, agentRole + oldKind, oldMode := agentRuntimeKind, agentRuntimeMode + agentRoot = root + agentConfigPath = "" + agentPrincipal = "planner@team" + agentDisplayName = "Planner" + agentRole = "planner" + agentRuntimeKind = productconfig.RuntimeKindCodex + agentRuntimeMode = productconfig.RuntimeModeManagedOrHost + t.Cleanup(func() { + agentRoot, agentConfigPath = oldRoot, oldPath + agentPrincipal, agentDisplayName, agentRole = oldPrincipal, oldDisplayName, oldRole + agentRuntimeKind, agentRuntimeMode = oldKind, oldMode + }) + + cmd, out := testCommand() + if err := runAgentAdd(cmd, nil); err != nil { + t.Fatal(err) + } + if got := out.String(); !strings.Contains(got, "Agent: added planner@team") { + t.Fatalf("unexpected add output:\n%s", got) + } + cfg := loadProductConfigForTest(t, root) + if len(cfg.Participants) != 1 || cfg.Participants[0].Principal != "planner@team" { + t.Fatalf("participant not written: %+v", cfg.Participants) + } + if got := cfg.Participants[0].HostRuntime.Kind; got != productconfig.RuntimeKindCodex { + t.Fatalf("unexpected runtime kind: %q", got) + } + if len(cfg.Daemon.DriveSources) != 1 || cfg.Daemon.DriveSources[0] != productconfig.DriveManagedLocal { + t.Fatalf("managed drive source not configured: %+v", cfg.Daemon.DriveSources) + } + + cmd, out = testCommand() + if err := runAgentList(cmd, nil); err != nil { + t.Fatal(err) + } + if got := out.String(); !strings.Contains(got, "Agent: planner@team (Planner) - planner") { + t.Fatalf("unexpected list output:\n%s", got) + } +} + +func TestConnectCommandsWriteProductConfig(t *testing.T) { + root := t.TempDir() + oldRoot, oldPath := connectRoot, connectConfigPath + oldWorkspace, oldRuntime := connectMulticaWS, connectMulticaRuntime + oldRepo, oldBranch := connectGitHubRepo, connectGitHubBranch + oldEndpoint := connectMnemonhubURL + connectRoot = root + connectConfigPath = "" + connectMulticaWS = "teamwork-grivn" + connectMulticaRuntime = "mnemon-multica-runtime" + connectGitHubRepo = "mnemon-dev/mnemon-teamwork-example" + connectGitHubBranch = "mnemond-planner" + connectMnemonhubURL = "https://hub.example.invalid" + t.Cleanup(func() { + connectRoot, connectConfigPath = oldRoot, oldPath + connectMulticaWS, connectMulticaRuntime = oldWorkspace, oldRuntime + connectGitHubRepo, connectGitHubBranch = oldRepo, oldBranch + connectMnemonhubURL = oldEndpoint + }) + + cmd, _ := testCommand() + if err := runConnectMultica(cmd, nil); err != nil { + t.Fatal(err) + } + if err := runConnectGitHub(cmd, nil); err != nil { + t.Fatal(err) + } + if err := runConnectMnemonhub(cmd, nil); err != nil { + t.Fatal(err) + } + + cfg := loadProductConfigForTest(t, root) + if !cfg.Connections.Multica.Enabled || cfg.Connections.Multica.Workspace != "teamwork-grivn" { + t.Fatalf("multica connection not written: %+v", cfg.Connections.Multica) + } + if got := cfg.Connections.Multica.RuntimeBinary; got != "mnemon-multica-runtime" { + t.Fatalf("unexpected runtime binary: %q", got) + } + if !cfg.Connections.GitHub.Enabled || cfg.Connections.GitHub.Repo != "mnemon-dev/mnemon-teamwork-example" { + t.Fatalf("github connection not written: %+v", cfg.Connections.GitHub) + } + if !cfg.Connections.Mnemonhub.Enabled || cfg.Connections.Mnemonhub.Endpoint != "https://hub.example.invalid" { + t.Fatalf("mnemonhub connection not written: %+v", cfg.Connections.Mnemonhub) + } + for _, want := range []string{productconfig.ConnectionMultica, productconfig.ConnectionGitHub, productconfig.ConnectionMnemonhub} { + if !containsString(cfg.Daemon.InteractionWatchers, want) { + t.Fatalf("interaction watcher %q missing: %+v", want, cfg.Daemon.InteractionWatchers) + } + } + for _, want := range []string{productconfig.ConnectionMultica, productconfig.ConnectionGitHub} { + if !containsString(cfg.Daemon.ProjectionSurfaces, want) { + t.Fatalf("projection surface %q missing: %+v", want, cfg.Daemon.ProjectionSurfaces) + } + } + if containsString(cfg.Daemon.ProjectionSurfaces, productconfig.ConnectionMnemonhub) { + t.Fatalf("mnemonhub must remain an exchange backend, not projection surface: %+v", cfg.Daemon.ProjectionSurfaces) + } + if got := cfg.Sessions.PrimaryActivationCarrier; got != productconfig.ConnectionMultica { + t.Fatalf("unexpected primary activation carrier: %q", got) + } +} + +func TestConnectMnemonhubKeepsExchangeOutOfActivationAndProjection(t *testing.T) { + root := t.TempDir() + oldRoot, oldPath := connectRoot, connectConfigPath + oldEndpoint := connectMnemonhubURL + connectRoot = root + connectConfigPath = "" + connectMnemonhubURL = "https://hub.example.invalid" + t.Cleanup(func() { + connectRoot, connectConfigPath = oldRoot, oldPath + connectMnemonhubURL = oldEndpoint + }) + + cmd, _ := testCommand() + if err := runConnectMnemonhub(cmd, nil); err != nil { + t.Fatal(err) + } + + cfg := loadProductConfigForTest(t, root) + if !cfg.Connections.Mnemonhub.Enabled || cfg.Connections.Mnemonhub.Endpoint != "https://hub.example.invalid" { + t.Fatalf("mnemonhub connection not written: %+v", cfg.Connections.Mnemonhub) + } + if !containsString(cfg.Daemon.InteractionWatchers, productconfig.ConnectionMnemonhub) { + t.Fatalf("mnemonhub watcher missing: %+v", cfg.Daemon.InteractionWatchers) + } + if containsString(cfg.Daemon.ProjectionSurfaces, productconfig.ConnectionMnemonhub) { + t.Fatalf("mnemonhub must remain an exchange backend, not projection surface: %+v", cfg.Daemon.ProjectionSurfaces) + } + if got := cfg.Sessions.PrimaryActivationCarrier; got != "" { + t.Fatalf("mnemonhub must not become primary activation carrier, got %q", got) + } +} + +func loadProductConfigForTest(t *testing.T, root string) productconfig.Config { + t.Helper() + cfg, err := productconfig.Load(filepath.Join(root, productconfig.DefaultRelPath)) + if err != nil { + t.Fatal(err) + } + return cfg +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/harness/cmd/mnemon-harness/connect.go b/harness/cmd/mnemon-harness/connect.go new file mode 100644 index 00000000..50d357a8 --- /dev/null +++ b/harness/cmd/mnemon-harness/connect.go @@ -0,0 +1,143 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/mnemon-dev/mnemon/harness/internal/productconfig" + "github.com/spf13/cobra" +) + +var ( + connectRoot string + connectConfigPath string + connectMulticaWS string + connectMulticaRuntime string + connectGitHubRepo string + connectGitHubBranch string + connectMnemonhubURL string +) + +var connectCmd = &cobra.Command{ + Use: "connect", + Short: "Configure harness external connections", +} + +var connectMulticaCmd = &cobra.Command{ + Use: "multica --workspace WORKSPACE", + Short: "Connect Multica as a harness connection", + RunE: runConnectMultica, +} + +var connectGitHubCmd = &cobra.Command{ + Use: "github --repo OWNER/REPO", + Short: "Connect GitHub as a harness connection", + RunE: runConnectGitHub, +} + +var connectMnemonhubCmd = &cobra.Command{ + Use: "mnemonhub --endpoint URL", + Short: "Connect mnemonhub as a harness connection", + RunE: runConnectMnemonhub, +} + +func init() { + connectCmd.PersistentFlags().StringVar(&connectRoot, "root", ".", "project root") + connectCmd.PersistentFlags().StringVar(&connectConfigPath, "config", "", "harness product config path") + _ = connectCmd.PersistentFlags().MarkHidden("config") + connectMulticaCmd.Flags().StringVar(&connectMulticaWS, "workspace", "", "Multica workspace") + connectMulticaCmd.Flags().StringVar(&connectMulticaRuntime, "runtime-binary", productconfig.DefaultMulticaRuntimeBinary, "Multica runtime binary") + _ = connectMulticaCmd.Flags().MarkHidden("runtime-binary") + connectGitHubCmd.Flags().StringVar(&connectGitHubRepo, "repo", "", "GitHub repository owner/name") + connectGitHubCmd.Flags().StringVar(&connectGitHubBranch, "branch", "", "GitHub publication branch") + connectMnemonhubCmd.Flags().StringVar(&connectMnemonhubURL, "endpoint", "", "mnemonhub endpoint") + connectCmd.AddCommand(connectMulticaCmd, connectGitHubCmd, connectMnemonhubCmd) + connectCmd.GroupID = groupSpine + rootCmd.AddCommand(connectCmd) +} + +func runConnectMultica(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(connectMulticaWS) == "" { + return fmt.Errorf("connect multica requires --workspace") + } + cfg, _, err := loadHarnessProductConfig(connectRoot, connectConfigPath) + if err != nil { + return err + } + cfg.Connections.Multica = productconfig.MulticaConnection{ + Enabled: true, + Workspace: strings.TrimSpace(connectMulticaWS), + RuntimeBinary: strings.TrimSpace(connectMulticaRuntime), + } + cfg.Daemon.InteractionWatchers = appendUniqueString(cfg.Daemon.InteractionWatchers, productconfig.ConnectionMultica) + cfg.Daemon.ProjectionSurfaces = appendUniqueString(cfg.Daemon.ProjectionSurfaces, productconfig.ConnectionMultica) + if cfg.Sessions.PrimaryActivationCarrier == "" { + cfg.Sessions.PrimaryActivationCarrier = productconfig.ConnectionMultica + } + path, err := saveHarnessProductConfig(connectRoot, connectConfigPath, cfg) + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Connection: multica ready") + fmt.Fprintf(cmd.OutOrStdout(), "Harness config: %s\n", path) + return nil +} + +func runConnectGitHub(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(connectGitHubRepo) == "" { + return fmt.Errorf("connect github requires --repo") + } + cfg, _, err := loadHarnessProductConfig(connectRoot, connectConfigPath) + if err != nil { + return err + } + cfg.Connections.GitHub = productconfig.GitHubConnection{ + Enabled: true, + Repo: strings.TrimSpace(connectGitHubRepo), + Branch: strings.TrimSpace(connectGitHubBranch), + } + cfg.Daemon.InteractionWatchers = appendUniqueString(cfg.Daemon.InteractionWatchers, productconfig.ConnectionGitHub) + cfg.Daemon.ProjectionSurfaces = appendUniqueString(cfg.Daemon.ProjectionSurfaces, productconfig.ConnectionGitHub) + path, err := saveHarnessProductConfig(connectRoot, connectConfigPath, cfg) + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Connection: github ready") + fmt.Fprintf(cmd.OutOrStdout(), "Harness config: %s\n", path) + return nil +} + +func runConnectMnemonhub(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(connectMnemonhubURL) == "" { + return fmt.Errorf("connect mnemonhub requires --endpoint") + } + cfg, _, err := loadHarnessProductConfig(connectRoot, connectConfigPath) + if err != nil { + return err + } + cfg.Connections.Mnemonhub = productconfig.MnemonhubConnection{ + Enabled: true, + Endpoint: strings.TrimSpace(connectMnemonhubURL), + } + cfg.Daemon.InteractionWatchers = appendUniqueString(cfg.Daemon.InteractionWatchers, productconfig.ConnectionMnemonhub) + path, err := saveHarnessProductConfig(connectRoot, connectConfigPath, cfg) + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Connection: mnemonhub ready") + fmt.Fprintf(cmd.OutOrStdout(), "Harness config: %s\n", path) + return nil +} + +func appendUniqueString(values []string, next string) []string { + next = strings.TrimSpace(next) + if next == "" { + return values + } + for _, value := range values { + if value == next { + return values + } + } + return append(values, next) +} diff --git a/harness/cmd/mnemon-harness/control.go b/harness/cmd/mnemon-harness/control.go index 721992c4..19b90930 100644 --- a/harness/cmd/mnemon-harness/control.go +++ b/harness/cmd/mnemon-harness/control.go @@ -36,6 +36,9 @@ var ( controlRenderIntent string controlRenderLifecycle string controlRenderSurface string + controlRenderHost string + controlRenderSessionID string + controlRenderInputID string controlRenderMaxChars int controlRenderJSON bool ) @@ -168,6 +171,9 @@ var controlRenderCmd = &cobra.Command{ RenderIntent: controlRenderIntent, Lifecycle: controlRenderLifecycle, Surface: controlRenderSurface, + Host: controlRenderHost, + SessionID: controlRenderSessionID, + InputDigest: controlRenderInputID, Budget: presentation.Budget{MaxChars: controlRenderMaxChars}, }) if err != nil { @@ -280,6 +286,9 @@ func init() { controlRenderCmd.Flags().StringVar(&controlRenderIntent, "intent", presentation.IntentTeamworkEvents, "render intent") controlRenderCmd.Flags().StringVar(&controlRenderLifecycle, "lifecycle", "remind", "host lifecycle") controlRenderCmd.Flags().StringVar(&controlRenderSurface, "surface", "hook", "host surface") + controlRenderCmd.Flags().StringVar(&controlRenderHost, "host", envDefault("MNEMON_RENDER_HOST", ""), "host integration name") + controlRenderCmd.Flags().StringVar(&controlRenderSessionID, "session-id", envDefault("MNEMON_RENDER_SESSION_ID", ""), "render session scope") + controlRenderCmd.Flags().StringVar(&controlRenderInputID, "input-id", envDefault("MNEMON_RENDER_INPUT_ID", ""), "render input or assignment scope") controlRenderCmd.Flags().IntVar(&controlRenderMaxChars, "max-chars", 6000, "maximum rendered body chars") controlRenderCmd.Flags().BoolVar(&controlRenderJSON, "json", false, "emit full render response as JSON") controlCmd.AddCommand(controlObserveCmd, controlPullCmd, controlStatusCmd, controlRenderCmd, controlTeamworkCmd, controlProfileCmd) diff --git a/harness/cmd/mnemon-harness/control_short.go b/harness/cmd/mnemon-harness/control_short.go index a1b1769e..a3862213 100644 --- a/harness/cmd/mnemon-harness/control_short.go +++ b/harness/cmd/mnemon-harness/control_short.go @@ -2,14 +2,19 @@ package main import ( "fmt" + "os" + "path/filepath" "strings" "time" "github.com/mnemon-dev/mnemon/harness/internal/contract" eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" "github.com/spf13/cobra" ) +const controlShortObserveMaxAttempts = 4 + var ( controlTeamworkSignalID string controlTeamworkSignalScope string @@ -117,7 +122,11 @@ var controlTeamworkAssignCmd = &cobra.Command{ "scope": controlTeamworkAssignScope, "ttl": controlTeamworkAssignTTL, } - putString(rule, "assignment_id", controlTeamworkAssignID) + assignmentID := strings.TrimSpace(controlTeamworkAssignID) + if assignmentID == "" { + assignmentID = defaultShortAssignmentID(controlTeamworkAssignScope, controlTeamworkAssignAssignee, controlTeamworkAssignReportOn, controlTeamworkAssignWork) + } + putString(rule, "assignment_id", assignmentID) putString(rule, "signal_ref", controlTeamworkAssignSignalRef) putStrings(rule, "report_on", controlTeamworkAssignReportOn) narrative := map[string]any{ @@ -217,18 +226,85 @@ func controlShortObserve(cmd *cobra.Command, eventType, fallbackIDPrefix string, if err != nil { return err } - rec, err := client.IngestObserve(contract.ActorID(controlPrincipal), contract.ObservationEnvelope{ - ExternalID: shortExternalID(fallbackIDPrefix), - Event: contract.Event{Type: eventType, Payload: payload}, + return withControlShortObserveLock(func() error { + return controlShortObserveLocked(cmd, client, eventType, fallbackIDPrefix, payload) }) - if err != nil { - return fmt.Errorf("channel observe failed (service unreachable or rejected): %w", err) - } - fmt.Fprintf(cmd.OutOrStdout(), "observed seq=%d dup=%v ticked=%v\n", rec.Seq, rec.Dup, rec.Ticked) - if rec.ProcessingError != "" { +} + +func controlShortObserveLocked(cmd *cobra.Command, client *access.Client, eventType, fallbackIDPrefix string, payload map[string]any) error { + externalID := shortExternalID(fallbackIDPrefix) + for attempt := 0; attempt < controlShortObserveMaxAttempts; attempt++ { + attemptExternalID := retryExternalID(externalID, attempt) + rec, err := client.IngestObserve(contract.ActorID(controlPrincipal), contract.ObservationEnvelope{ + ExternalID: attemptExternalID, + Event: contract.Event{Type: eventType, Payload: payload}, + }) + if err != nil { + return fmt.Errorf("channel observe failed (service unreachable or rejected): %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "observed seq=%d dup=%v ticked=%v\n", rec.Seq, rec.Dup, rec.Ticked) + if rec.ProcessingError == "" { + return nil + } fmt.Fprintf(cmd.OutOrStdout(), "processing error: %s\n", rec.ProcessingError) + if !retryableShortObserveProcessingError(rec.ProcessingError) || attempt == controlShortObserveMaxAttempts-1 { + return fmt.Errorf("channel observe processing failed: %s", rec.ProcessingError) + } + nextExternalID := retryExternalID(externalID, attempt+1) + fmt.Fprintf(cmd.OutOrStdout(), "retrying after processing error: attempt=%d external_id=%s\n", attempt+2, nextExternalID) + time.Sleep(time.Duration(100*(1<= '0' && lower[end] <= '9' { + end++ + } + if end > start { + return "tea" + lower[start:end] + } + pos = idx + 1 + } + } + return "" +} + +func assignmentTopic(reportOn []string, work string) string { + combined := strings.ToLower(strings.Join(reportOn, " ") + " " + work) + switch { + case strings.Contains(combined, "root") || strings.Contains(combined, "metadata") || strings.Contains(combined, "run visibility"): + return "root-runtime" + case strings.Contains(combined, "routing") || strings.Contains(combined, "isolation"): + return "routing-isolation" + case strings.Contains(combined, "feedback") || strings.Contains(combined, "status") || strings.Contains(combined, "completion"): + return "feedback-status" + default: + return sanitizeAssignmentToken(combined) + } +} + +func sanitizeAssignmentToken(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + var b strings.Builder + lastDash := false + words := 0 + inWord := false + for _, r := range value { + ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') + if ok { + if !inWord { + words++ + inWord = true + } + if words > 4 { + break + } + b.WriteRune(r) + lastDash = false + continue + } + inWord = false + if b.Len() > 0 && !lastDash { + b.WriteByte('-') + lastDash = true + } + } + return strings.Trim(b.String(), "-") +} + func requireShortFields(fields map[string]string) error { for name, value := range fields { if strings.TrimSpace(value) == "" { diff --git a/harness/cmd/mnemon-harness/control_test.go b/harness/cmd/mnemon-harness/control_test.go index f8eee9c2..f421210d 100644 --- a/harness/cmd/mnemon-harness/control_test.go +++ b/harness/cmd/mnemon-harness/control_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "encoding/json" + "net/http" "net/http/httptest" "os" "path/filepath" @@ -200,6 +201,9 @@ func TestControlRenderPrintsDerivedEventPresentationBody(t *testing.T) { oldIntent := controlRenderIntent oldLifecycle := controlRenderLifecycle oldSurface := controlRenderSurface + oldHost := controlRenderHost + oldSessionID := controlRenderSessionID + oldInputID := controlRenderInputID oldMaxChars := controlRenderMaxChars oldJSON := controlRenderJSON t.Cleanup(func() { @@ -210,6 +214,9 @@ func TestControlRenderPrintsDerivedEventPresentationBody(t *testing.T) { controlRenderIntent = oldIntent controlRenderLifecycle = oldLifecycle controlRenderSurface = oldSurface + controlRenderHost = oldHost + controlRenderSessionID = oldSessionID + controlRenderInputID = oldInputID controlRenderMaxChars = oldMaxChars controlRenderJSON = oldJSON }) @@ -220,6 +227,9 @@ func TestControlRenderPrintsDerivedEventPresentationBody(t *testing.T) { controlRenderIntent = presentation.IntentTeamworkEvents controlRenderLifecycle = "remind" controlRenderSurface = "hook" + controlRenderHost = "" + controlRenderSessionID = "" + controlRenderInputID = "" controlRenderMaxChars = 6000 controlRenderJSON = false @@ -233,6 +243,51 @@ func TestControlRenderPrintsDerivedEventPresentationBody(t *testing.T) { } } +func TestControlRenderCarriesHostSessionScope(t *testing.T) { + var got presentation.Request + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode request: %v", err) + } + _ = json.NewEncoder(w).Encode(presentation.Response{ + SchemaVersion: 1, + Status: presentation.StatusOK, + Body: "ok", + }) + })) + defer srv.Close() + + oldAddr := controlAddr + oldPrincipal := controlPrincipal + oldToken := controlToken + oldTokenFile := controlTokenFile + t.Cleanup(func() { + controlAddr = oldAddr + controlPrincipal = oldPrincipal + controlToken = oldToken + controlTokenFile = oldTokenFile + }) + controlAddr = srv.URL + controlPrincipal = "planner@team" + controlToken = "" + controlTokenFile = "" + + _, err := controlRender(presentation.Request{ + RenderIntent: presentation.IntentTeamworkEvents, + Lifecycle: "remind", + Surface: "hook", + Host: "multica", + SessionID: "multica:session:root-1", + InputDigest: "root-1", + }) + if err != nil { + t.Fatalf("control render: %v", err) + } + if got.Host != "multica" || got.SessionID != "multica:session:root-1" || got.InputDigest != "root-1" { + t.Fatalf("render scope not carried: %+v", got) + } +} + func TestControlShortCommandsEmitR2Payloads(t *testing.T) { refs := []contract.ResourceRef{ {Kind: "agent_profile", ID: "project"}, @@ -377,6 +432,129 @@ func TestControlShortCommandsEmitR2Payloads(t *testing.T) { } } +func TestControlShortObserveRetriesRetryableProcessingError(t *testing.T) { + var attempts int + var externalIDs []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/ingest" { + t.Errorf("unexpected path %s", r.URL.Path) + http.NotFound(w, r) + return + } + if got := r.Header.Get(access.PrincipalHeader); got != "codex-a@project" { + t.Errorf("principal header = %q", got) + } + var env contract.ObservationEnvelope + if err := json.NewDecoder(r.Body).Decode(&env); err != nil { + t.Errorf("decode envelope: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + attempts++ + externalIDs = append(externalIDs, env.ExternalID) + w.Header().Set("Content-Type", "application/json") + if attempts == 1 { + _ = json.NewEncoder(w).Encode(access.IngestReceipt{ + Seq: 41, + Ticked: true, + ProcessingError: "read_stale: resource version advanced", + }) + return + } + _ = json.NewEncoder(w).Encode(access.IngestReceipt{Seq: 42, Ticked: true}) + })) + defer srv.Close() + + oldAddr := controlAddr + oldPrincipal := controlPrincipal + oldToken := controlToken + oldTokenFile := controlTokenFile + oldExtID := controlExtID + t.Cleanup(func() { + controlAddr = oldAddr + controlPrincipal = oldPrincipal + controlToken = oldToken + controlTokenFile = oldTokenFile + controlExtID = oldExtID + }) + controlAddr = srv.URL + controlPrincipal = "codex-a@project" + controlToken = "" + controlTokenFile = "" + controlExtID = "short-retry" + + var buf bytes.Buffer + controlTeamworkSignalCmd.SetOut(&buf) + err := controlShortObserve(controlTeamworkSignalCmd, "teamwork_signal.write_candidate.observed", "teamwork-signal", map[string]any{"rule": map[string]any{"scope": "r2/retry"}}) + if err != nil { + t.Fatalf("short observe retry: %v; output=%q", err, buf.String()) + } + if attempts != 2 { + t.Fatalf("attempts = %d, want 2; output=%q", attempts, buf.String()) + } + if got, want := strings.Join(externalIDs, ","), "short-retry,short-retry-retry-1"; got != want { + t.Fatalf("external ids = %s, want %s", got, want) + } + if !strings.Contains(buf.String(), "retrying after processing error") || !strings.Contains(buf.String(), "observed seq=42") { + t.Fatalf("missing retry/success output: %q", buf.String()) + } +} + +func TestControlTeamworkAssignDefaultsStructuredAssignmentID(t *testing.T) { + var got contract.ObservationEnvelope + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Errorf("decode envelope: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(access.IngestReceipt{Seq: 7, Ticked: true}) + })) + defer srv.Close() + + oldAddr := controlAddr + oldPrincipal := controlPrincipal + oldToken := controlToken + oldTokenFile := controlTokenFile + oldExtID := controlExtID + resetControlShortCommandVars() + t.Cleanup(func() { + controlAddr = oldAddr + controlPrincipal = oldPrincipal + controlToken = oldToken + controlTokenFile = oldTokenFile + controlExtID = oldExtID + resetControlShortCommandVars() + }) + controlAddr = srv.URL + controlPrincipal = "planner@team" + controlToken = "" + controlTokenFile = "" + controlExtID = "assign-default-id" + controlTeamworkAssignID = "" + controlTeamworkAssignAssignee = "researcher@team" + controlTeamworkAssignScope = "TEA-74 Mnemon R2 hub-flow readiness drill" + controlTeamworkAssignTTL = "20m" + controlTeamworkAssignReportOn = []string{"root session metadata", "agent run visibility"} + controlTeamworkAssignWork = "Validate TEA-74 root session metadata and run visibility." + controlTeamworkAssignFeedback = "progress_digest with PASS/FAIL evidence" + controlTeamworkAssignEvidence = []string{"TEA-74 root issue is current session mailbox"} + + var buf bytes.Buffer + controlTeamworkAssignCmd.SetOut(&buf) + if err := controlTeamworkAssignCmd.RunE(controlTeamworkAssignCmd, nil); err != nil { + t.Fatalf("teamwork assign: %v", err) + } + rule, ok := got.Event.Payload["rule"].(map[string]any) + if !ok { + t.Fatalf("payload missing rule: %+v", got.Event.Payload) + } + if got := rule["assignment_id"]; got != "assignment-tea74-root-runtime" { + t.Fatalf("assignment_id = %v, want assignment-tea74-root-runtime", got) + } +} + func mustReadCmd(t *testing.T, path string) []byte { t.Helper() data, err := os.ReadFile(path) diff --git a/harness/cmd/mnemon-harness/daemon.go b/harness/cmd/mnemon-harness/daemon.go new file mode 100644 index 00000000..ea895ac1 --- /dev/null +++ b/harness/cmd/mnemon-harness/daemon.go @@ -0,0 +1,200 @@ +package main + +import ( + "fmt" + "io" + "os" + "path/filepath" + "sort" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/app" + "github.com/mnemon-dev/mnemon/harness/internal/daemon" + "github.com/mnemon-dev/mnemon/harness/internal/productconfig" + "github.com/spf13/cobra" +) + +var ( + daemonRoot string + daemonAddr string + daemonStorePath string + daemonBindingsPath string + daemonAllowNonLoopback bool + daemonIgnoreExternal bool + daemonAllowInsecureRemote bool + daemonSyncInterval time.Duration +) + +var daemonCmd = &cobra.Command{ + Use: "daemon", + Short: "Run and inspect the harness daemon", +} + +var daemonStartCmd = &cobra.Command{ + Use: "start", + Short: "Start the harness daemon", + RunE: runDaemonStart, +} + +var daemonStopCmd = &cobra.Command{ + Use: "stop", + Short: "Show how to stop the harness daemon", + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Fprintln(cmd.OutOrStdout(), "Harness daemon: stop the running process to shut down") + return nil + }, +} + +var daemonStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show harness daemon status", + RunE: runDaemonStatus, +} + +func init() { + daemonCmd.PersistentFlags().StringVar(&daemonRoot, "root", ".", "project root") + daemonCmd.PersistentFlags().StringVar(&daemonStorePath, "store", "", "store path; defaults to the project Local Mnemon store") + daemonStartCmd.Flags().StringVar(&daemonAddr, "addr", "127.0.0.1:8787", "listen address") + daemonStartCmd.Flags().StringVar(&daemonBindingsPath, "bindings", "", "Agent Integration binding file") + daemonStartCmd.Flags().DurationVar(&daemonSyncInterval, "sync-interval", 0, "sync worker cadence (0 = default 30s)") + daemonStartCmd.Flags().BoolVar(&daemonAllowNonLoopback, "allow-nonloopback", false, "explicitly allow listening on a non-loopback address") + daemonStartCmd.Flags().BoolVar(&daemonIgnoreExternal, "ignore-external", false, "ignore external event packages under .mnemon/loops") + daemonStartCmd.Flags().BoolVar(&daemonAllowInsecureRemote, "allow-insecure-remote", false, "allow plaintext Remote Workspace endpoint when explicitly configured") + _ = daemonStartCmd.Flags().MarkHidden("bindings") + daemonCmd.AddCommand(daemonStartCmd, daemonStopCmd, daemonStatusCmd) + daemonCmd.GroupID = groupSpine + rootCmd.AddCommand(daemonCmd) +} + +func runDaemonStart(cmd *cobra.Command, args []string) error { + root := daemonProjectRoot() + boot, err := app.ResolveLocalBoot(root, daemonStorePath, daemonBindingsPath) + if err != nil { + return err + } + addr := daemonAddr + if !cmd.Flags().Changed("addr") { + addr = app.ListenAddrFromEndpoint(boot.Config.Endpoint, daemonAddr) + } + if err := app.ValidateListenAddr(addr, daemonAllowNonLoopback); err != nil { + return err + } + if err := writeConfiguredDaemonSnapshot(root, time.Now().UTC()); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Harness daemon: ready") + fmt.Fprintln(cmd.OutOrStdout(), "Remote Workspace: "+app.RemoteWorkspaceStatus(root)) + return app.RunLocalHTTPServerWithBindings(cmd.Context(), addr, boot.StorePath, boot.Loaded, app.ServeOptions{ + Loops: boot.Config.Loops, + ProjectRoot: root, + IgnoreExternal: daemonIgnoreExternal, + AllowInsecureRemote: daemonAllowInsecureRemote, + SyncInterval: daemonSyncInterval, + }, io.Discard) +} + +func runDaemonStatus(cmd *cobra.Command, args []string) error { + root := daemonProjectRoot() + if cfg, err := productconfig.Load(productconfig.DefaultPath(root, "")); err == nil { + fmt.Fprintln(cmd.OutOrStdout(), "Harness config: configured") + writeDaemonRoleSummary(cmd.OutOrStdout(), cfg) + } else if legacy, found, legacyErr := productconfig.FromLegacy(root); legacyErr == nil && found { + fmt.Fprintln(cmd.OutOrStdout(), "Harness config: legacy bridge") + writeDaemonRoleSummary(cmd.OutOrStdout(), legacy) + } else { + fmt.Fprintln(cmd.OutOrStdout(), "Harness config: not configured") + } + writeDaemonSnapshotSummary(cmd.OutOrStdout(), root) + if cfg, err := app.ReadLocalConfig(root); err == nil { + if _, ok := localServiceStatus(root, cfg, cfg.Principal); ok { + fmt.Fprintln(cmd.OutOrStdout(), "Harness daemon: ready") + return nil + } + } + fmt.Fprintln(cmd.OutOrStdout(), "Harness daemon: not running") + return nil +} + +func writeDaemonRoleSummary(out io.Writer, cfg productconfig.Config) { + summary := daemon.RoleSummary(cfg) + fmt.Fprintf(out, "Harness daemon roles: watchers=%d drive=%d surfaces=%d\n", summary.InteractionWatchers, summary.DriveSources, summary.ProjectionSurfaces) + details := daemon.RoleDetails(cfg) + if len(details) == 0 { + return + } + fmt.Fprintln(out, "Harness daemon role details:") + for _, role := range details { + fmt.Fprintf(out, " - %s [%s]: %s=%s boundary=%s\n", role.WorkerName, role.Kind, role.Label, role.Value, role.Boundary) + } +} + +func writeConfiguredDaemonSnapshot(root string, now time.Time) error { + cfg, ok, err := loadDaemonSnapshotConfig(root) + if err != nil || !ok { + return err + } + snapshot := daemon.ConfiguredSnapshot(cfg, now) + if len(snapshot.Workers) == 0 { + return nil + } + return daemon.NewFileSnapshotStore(daemon.StatusSnapshotPath(root, "")).Save(snapshot) +} + +func loadDaemonSnapshotConfig(root string) (productconfig.Config, bool, error) { + configPath := productconfig.DefaultPath(root, "") + if cfg, err := productconfig.Load(configPath); err == nil { + return cfg, true, nil + } else if !os.IsNotExist(err) { + return productconfig.Config{}, false, err + } else if legacy, found, legacyErr := productconfig.FromLegacy(root); legacyErr == nil && found { + return legacy, true, nil + } else if legacyErr != nil { + return productconfig.Config{}, false, legacyErr + } + return productconfig.Config{}, false, nil +} + +func writeDaemonSnapshotSummary(out io.Writer, root string) { + snapshot, ok, err := daemon.NewFileSnapshotStore(daemon.StatusSnapshotPath(root, "")).Load() + if err != nil { + fmt.Fprintf(out, "Harness daemon snapshot: unavailable (%v)\n", err) + return + } + if !ok { + return + } + fmt.Fprintf(out, "Harness daemon snapshot: workers=%d started=%s\n", len(snapshot.Workers), formatDaemonTime(snapshot.StartedAt)) + names := make([]string, 0, len(snapshot.Workers)) + for name := range snapshot.Workers { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + worker := snapshot.Workers[name] + fmt.Fprintf(out, " - %s [%s]: %s", name, worker.Kind, worker.Status) + if worker.Message != "" { + fmt.Fprintf(out, " (%s)", worker.Message) + } + if worker.Error != "" { + fmt.Fprintf(out, " error=%s", worker.Error) + } + if !worker.UpdatedAt.IsZero() { + fmt.Fprintf(out, " updated=%s", formatDaemonTime(worker.UpdatedAt)) + } + fmt.Fprintln(out) + } +} + +func formatDaemonTime(ts time.Time) string { + if ts.IsZero() { + return "unknown" + } + return ts.UTC().Format(time.RFC3339) +} + +func daemonProjectRoot() string { + if daemonRoot == "" { + return "." + } + return filepath.Clean(daemonRoot) +} diff --git a/harness/cmd/mnemon-harness/doctor.go b/harness/cmd/mnemon-harness/doctor.go new file mode 100644 index 00000000..84b47a4c --- /dev/null +++ b/harness/cmd/mnemon-harness/doctor.go @@ -0,0 +1,97 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/mnemon-dev/mnemon/harness/internal/app" + "github.com/mnemon-dev/mnemon/harness/internal/daemon" + "github.com/mnemon-dev/mnemon/harness/internal/productconfig" + "github.com/spf13/cobra" +) + +var doctorRoot string + +var doctorCmd = &cobra.Command{ + Use: "doctor", + Short: "Check harness event-system readiness", + RunE: runDoctor, +} + +func init() { + doctorCmd.Flags().StringVar(&doctorRoot, "root", ".", "project root") + doctorCmd.GroupID = groupSpine + rootCmd.AddCommand(doctorCmd) +} + +func runDoctor(cmd *cobra.Command, args []string) error { + root := strings.TrimSpace(doctorRoot) + if root == "" { + root = "." + } + cfg, cfgStatus, cfgDetail := doctorProductConfig(root) + fmt.Fprintln(cmd.OutOrStdout(), "Harness doctor") + fmt.Fprintf(cmd.OutOrStdout(), "- Product config: %s", cfgStatus) + if cfgDetail != "" { + fmt.Fprintf(cmd.OutOrStdout(), " (%s)", cfgDetail) + } + fmt.Fprintln(cmd.OutOrStdout()) + if cfgStatus == "configured" || cfgStatus == "legacy bridge" { + fmt.Fprintf(cmd.OutOrStdout(), "- Participants: %d\n", len(cfg.Participants)) + fmt.Fprintf(cmd.OutOrStdout(), "- Daemon roles: watchers=%d drive=%d surfaces=%d\n", len(cfg.Daemon.InteractionWatchers), len(cfg.Daemon.DriveSources), len(cfg.Daemon.ProjectionSurfaces)) + fmt.Fprintf(cmd.OutOrStdout(), "- Connections: %s\n", doctorConnections(cfg)) + } + fmt.Fprintf(cmd.OutOrStdout(), "- Local Mnemon config: %s\n", doctorLocalConfig(root)) + fmt.Fprintf(cmd.OutOrStdout(), "- Daemon snapshot: %s\n", doctorDaemonSnapshot(root)) + return nil +} + +func doctorProductConfig(root string) (productconfig.Config, string, string) { + cfg, err := productconfig.Load(productconfig.DefaultPath(root, "")) + if err == nil { + return cfg, "configured", "" + } + legacy, found, legacyErr := productconfig.FromLegacy(root) + if legacyErr != nil { + return productconfig.Config{}, "invalid", legacyErr.Error() + } + if found { + return legacy, "legacy bridge", "" + } + return productconfig.Config{}, "missing", err.Error() +} + +func doctorConnections(cfg productconfig.Config) string { + var out []string + if cfg.Connections.Multica.Enabled { + out = append(out, "multica") + } + if cfg.Connections.GitHub.Enabled { + out = append(out, "github") + } + if cfg.Connections.Mnemonhub.Enabled { + out = append(out, "mnemonhub") + } + if len(out) == 0 { + return "none" + } + return strings.Join(out, ",") +} + +func doctorLocalConfig(root string) string { + if _, err := app.ReadLocalConfig(root); err != nil { + return "missing" + } + return "configured" +} + +func doctorDaemonSnapshot(root string) string { + snapshot, ok, err := daemon.NewFileSnapshotStore(daemon.StatusSnapshotPath(root, "")).Load() + if err != nil { + return "invalid: " + err.Error() + } + if !ok { + return "missing" + } + return fmt.Sprintf("workers=%d", len(snapshot.Workers)) +} diff --git a/harness/cmd/mnemon-harness/doctor_test.go b/harness/cmd/mnemon-harness/doctor_test.go new file mode 100644 index 00000000..a653f77d --- /dev/null +++ b/harness/cmd/mnemon-harness/doctor_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/daemon" + "github.com/mnemon-dev/mnemon/harness/internal/productconfig" +) + +func TestDoctorReportsMissingConfigurationWithoutMutating(t *testing.T) { + root := t.TempDir() + oldRoot := doctorRoot + doctorRoot = root + t.Cleanup(func() { doctorRoot = oldRoot }) + + cmd, out := testCommand() + if err := runDoctor(cmd, nil); err != nil { + t.Fatal(err) + } + got := out.String() + for _, want := range []string{ + "Harness doctor", + "- Product config: missing", + "- Local Mnemon config: missing", + "- Daemon snapshot: missing", + } { + if !strings.Contains(got, want) { + t.Fatalf("doctor output missing %q:\n%s", want, got) + } + } +} + +func TestDoctorReportsConfiguredProductSurface(t *testing.T) { + root := t.TempDir() + cfg := productconfig.Default() + cfg.Connections.Multica = productconfig.MulticaConnection{Enabled: true, Workspace: "ws-multica", RuntimeBinary: "mnemon-multica-runtime"} + cfg.Connections.GitHub = productconfig.GitHubConnection{Enabled: true, Repo: "mnemon-dev/mnemon-teamwork-example"} + cfg.Participants = []productconfig.Participant{{ + Principal: "planner@team", + HostRuntime: productconfig.HostRuntime{ + Kind: productconfig.RuntimeKindCodex, + Mode: productconfig.RuntimeModeManaged, + }, + }} + cfg.Daemon.InteractionWatchers = []string{productconfig.ConnectionMultica} + cfg.Daemon.DriveSources = []string{productconfig.DriveManagedLocal} + cfg.Daemon.ProjectionSurfaces = []string{productconfig.ConnectionMultica} + if err := productconfig.Save(productconfig.DefaultPath(root, ""), cfg); err != nil { + t.Fatal(err) + } + if err := daemon.NewFileSnapshotStore(daemon.StatusSnapshotPath(root, "")).Save(daemon.Snapshot{ + StartedAt: time.Date(2026, 6, 29, 9, 0, 0, 0, time.UTC), + Workers: map[string]daemon.WorkerSnapshot{ + "multica-watch": {Kind: daemon.WorkerInteraction, Status: "idle"}, + "managed-drive": {Kind: daemon.WorkerDrive, Status: "idle"}, + }, + }); err != nil { + t.Fatal(err) + } + oldRoot := doctorRoot + doctorRoot = root + t.Cleanup(func() { doctorRoot = oldRoot }) + + cmd, out := testCommand() + if err := runDoctor(cmd, nil); err != nil { + t.Fatal(err) + } + got := out.String() + for _, want := range []string{ + "- Product config: configured", + "- Participants: 1", + "- Daemon roles: watchers=1 drive=1 surfaces=1", + "- Connections: multica,github", + "- Local Mnemon config: missing", + "- Daemon snapshot: workers=2", + } { + if !strings.Contains(got, want) { + t.Fatalf("doctor output missing %q:\n%s", want, got) + } + } +} diff --git a/harness/cmd/mnemon-harness/local.go b/harness/cmd/mnemon-harness/local.go index ceb25b73..6c2f00ed 100644 --- a/harness/cmd/mnemon-harness/local.go +++ b/harness/cmd/mnemon-harness/local.go @@ -22,8 +22,9 @@ var ( ) var localCmd = &cobra.Command{ - Use: "local", - Short: "Run and inspect Local Mnemon", + Use: "local", + Short: "Run and inspect Local Mnemon", + Hidden: true, } var localRunCmd = &cobra.Command{ @@ -81,7 +82,7 @@ func init() { localRunCmd.Flags().BoolVar(&localAllowInsecureRemote, "allow-insecure-remote", false, "let the background sync worker use a plaintext http:// Remote Workspace endpoint with a non-loopback host (T2: fail-closed by default)") _ = localRunCmd.Flags().MarkHidden("bindings") localCmd.AddCommand(localRunCmd, localStatusCmd, localStopCmd) - localCmd.GroupID = groupSpine + localCmd.GroupID = groupAdvanced rootCmd.AddCommand(localCmd) } diff --git a/harness/cmd/mnemon-harness/multica.go b/harness/cmd/mnemon-harness/multica.go index a55adcaf..d87d9b65 100644 --- a/harness/cmd/mnemon-harness/multica.go +++ b/harness/cmd/mnemon-harness/multica.go @@ -6,12 +6,15 @@ import ( "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/access" + "github.com/mnemon-dev/mnemon/harness/internal/projection" + multicasurface "github.com/mnemon-dev/mnemon/harness/internal/surface/multica" "github.com/spf13/cobra" ) @@ -59,6 +62,7 @@ var ( multicaProvisionManagedCommand string multicaProvisionManagedWorkspace string multicaProvisionManagedTimeout time.Duration + multicaProvisionAcceptanceBridge bool multicaParticipantRegistry string multicaParticipantProjectRoot string @@ -80,8 +84,9 @@ var ( ) var multicaCmd = &cobra.Command{ - Use: "multica", - Short: "Bridge Multica issues and comments with Local Mnemon", + Use: "multica", + Short: "Bridge Multica issues and comments with Local Mnemon", + Hidden: true, } var multicaProbeCmd = &cobra.Command{ @@ -91,15 +96,17 @@ var multicaProbeCmd = &cobra.Command{ } var multicaImportIssueCmd = &cobra.Command{ - Use: "import-issue", - Short: "Import one Multica issue as a Mnemon teamwork signal", - RunE: runMulticaImportIssue, + Use: "import-issue", + Short: "Import one Multica issue as a Mnemon teamwork signal", + Hidden: true, + RunE: runMulticaImportIssue, } var multicaProjectCommentCmd = &cobra.Command{ - Use: "project-comment", - Short: "Write a Mnemon update as a Multica issue comment", - RunE: runMulticaProjectComment, + Use: "project-comment", + Short: "Write a Mnemon update as a Multica issue comment", + Hidden: true, + RunE: runMulticaProjectComment, } var multicaProvisionCmd = &cobra.Command{ @@ -110,8 +117,9 @@ var multicaProvisionCmd = &cobra.Command{ } var multicaParticipantCmd = &cobra.Command{ - Use: "participant", - Short: "Manage explicit Mnemon participants backed by Multica agents", + Use: "participant", + Short: "Manage explicit Mnemon participants backed by Multica agents", + Hidden: true, } var multicaParticipantRegisterCmd = &cobra.Command{ @@ -245,7 +253,12 @@ func runMulticaImportIssue(cmd *cobra.Command, args []string) error { if err != nil { return err } - draft, err := driver.BuildMulticaIssueTeamworkSignal(issue, driver.MulticaIssueSignalOptions{ + draft, err := multicasurface.BuildIssueTeamworkSignal(multicasurface.IssueSignalMaterial{ + ID: issue.ID, + Identifier: issue.Identifier, + Title: issue.Title, + Description: issue.Description, + }, multicasurface.IssueSignalOptions{ Scope: multicaScope, TTL: multicaTTL, WhyTeamwork: multicaWhyTeamwork, @@ -295,7 +308,11 @@ func runMulticaProjectComment(cmd *cobra.Command, args []string) error { if err != nil { return err } - body := driver.FormatMulticaProjectionComment(multicaCommentTitle, content, multicaCommentEvents) + body := projection.FormatComment(projection.CommentMaterial{ + Title: multicaCommentTitle, + Body: content, + EventIDs: multicaCommentEvents, + }) comment, err := multicaCLI().AddIssueComment(multicaCommandContext(cmd), multicaIssueID, body) if err != nil { return err @@ -356,6 +373,9 @@ func runMulticaParticipantRegister(cmd *cobra.Command, args []string) error { } func runMulticaProvision(cmd *cobra.Command, args []string) error { + if !multicaProvisionAcceptanceBridge { + return fmt.Errorf("multica provision is test-only; use mnemon-acceptance multica-provision") + } ctx := multicaCommandContext(cmd) workspaceID := strings.TrimSpace(multicaWorkspaceID) if workspaceID == "" { @@ -370,11 +390,11 @@ func runMulticaProvision(cmd *cobra.Command, args []string) error { } profileName := strings.TrimSpace(multicaProvisionProfileName) if profileName == "" { - profileName = "mnemon-runtime" + profileName = driver.MulticaRuntimeProfileName } runtimeCommand := strings.TrimSpace(multicaProvisionRuntimeCommand) if runtimeCommand == "" { - runtimeCommand = "mnemon-multica-runtime" + runtimeCommand = driver.MulticaRuntimeCommandName } registryPath := driver.MulticaRegistryPath(multicaProvisionProjectRoot, multicaProvisionRegistry) report := multicaProvisionReport{WorkspaceID: workspaceID, RegistryPath: registryPath} @@ -677,10 +697,7 @@ func ensureMulticaParticipantEnv(ctx context.Context, cli driver.MulticaCLI, par if err != nil { return false, fmt.Errorf("read Multica agent env %s: %w", participant.AgentID, err) } - merged := cloneStringMap(existing) - for key, value := range multicaParticipantRuntimeEnv(cli, participant, registryPath, workspaceID, opts) { - merged[key] = value - } + merged := mergeMulticaParticipantRuntimeEnv(existing, multicaParticipantRuntimeEnv(cli, participant, registryPath, workspaceID, opts)) if sameStringMap(existing, merged) { return false, nil } @@ -692,6 +709,13 @@ func ensureMulticaParticipantEnv(ctx context.Context, cli driver.MulticaCLI, par func multicaParticipantRuntimeEnv(cli driver.MulticaCLI, participant driver.MulticaParticipantRecord, registryPath, workspaceID string, opts multicaParticipantEnvOptions) map[string]string { env := map[string]string{} + registryPath = multicaLocalEnvPath(registryPath) + managedWorkspace := multicaLocalEnvPath(opts.ManagedWorkspace) + controlTokenFile := strings.TrimSpace(opts.ControlTokenFile) + if controlTokenFile == "" && strings.TrimSpace(opts.ControlToken) == "" { + controlTokenFile = defaultMulticaParticipantControlTokenFile(managedWorkspace, participant.Principal) + } + controlTokenFile = multicaLocalEnvPath(controlTokenFile) addStringEnv(env, "MNEMON_HUB_BACKEND", driver.MulticaHubBackend) addStringEnv(env, "MNEMON_MULTICA_REGISTRY", registryPath) addStringEnv(env, "MNEMON_MULTICA_WORKSPACE_ID", workspaceID) @@ -700,18 +724,82 @@ func multicaParticipantRuntimeEnv(cli driver.MulticaCLI, participant driver.Mult addStringEnv(env, "MNEMON_MULTICA_SERVER_URL", multicaServerURL) addStringEnv(env, "MNEMON_CONTROL_ADDR", opts.ControlAddr) addStringEnv(env, "MNEMON_CONTROL_TOKEN", opts.ControlToken) - addStringEnv(env, "MNEMON_CONTROL_TOKEN_FILE", opts.ControlTokenFile) + addStringEnv(env, "MNEMON_CONTROL_TOKEN_FILE", controlTokenFile) addStringEnv(env, "MNEMON_CONTROL_PRINCIPAL", participant.Principal) addStringEnv(env, "MNEMON_HARNESS_BIN", opts.HarnessBin) addStringEnv(env, "MNEMON_MANAGED_RUNTIME", opts.ManagedRuntime) addStringEnv(env, "MNEMON_MANAGED_COMMAND", opts.ManagedCommand) - addStringEnv(env, "MNEMON_MANAGED_WORKSPACE", opts.ManagedWorkspace) + addStringEnv(env, "MNEMON_MANAGED_WORKSPACE", managedWorkspace) if opts.ManagedTimeout > 0 { env["MNEMON_MANAGED_TURN_TIMEOUT"] = opts.ManagedTimeout.String() } return env } +func multicaLocalEnvPath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + if filepath.IsAbs(path) { + return filepath.Clean(path) + } + abs, err := filepath.Abs(path) + if err != nil { + return filepath.Clean(path) + } + return abs +} + +func mergeMulticaParticipantRuntimeEnv(existing, desired map[string]string) map[string]string { + merged := cloneStringMap(existing) + for _, key := range multicaParticipantRuntimeEnvKeys { + delete(merged, key) + } + for key, value := range desired { + if strings.TrimSpace(value) != "" { + merged[key] = value + } + } + return merged +} + +var multicaParticipantRuntimeEnvKeys = []string{ + "MNEMON_HUB_BACKEND", + "MNEMON_MULTICA_REGISTRY", + "MNEMON_MULTICA_WORKSPACE_ID", + "MNEMON_MULTICA_BIN", + "MNEMON_MULTICA_PROFILE", + "MNEMON_MULTICA_SERVER_URL", + "MNEMON_CONTROL_ADDR", + "MNEMON_CONTROL_TOKEN", + "MNEMON_CONTROL_TOKEN_FILE", + "MNEMON_CONTROL_PRINCIPAL", + "MNEMON_HARNESS_BIN", + "MNEMON_MANAGED_RUNTIME", + "MNEMON_MANAGED_COMMAND", + "MNEMON_MANAGED_WORKSPACE", + "MNEMON_MANAGED_TURN_TIMEOUT", +} + +func defaultMulticaParticipantControlTokenFile(workspace, principal string) string { + workspace = strings.TrimSpace(workspace) + principal = strings.TrimSpace(principal) + if workspace == "" || principal == "" { + return "" + } + path := filepath.Join(workspace, ".mnemon", "harness", "channel", "credentials", sanitizeMulticaPrincipal(principal)+".token") + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return "" + } + return path +} + +func sanitizeMulticaPrincipal(principal string) string { + return strings.NewReplacer("@", "-", "/", "-", ":", "-").Replace(principal) +} + func multicaProvisionEnvOptionsFromFlags() multicaParticipantEnvOptions { return multicaParticipantEnvOptions{ ControlAddr: multicaProvisionControlAddr, @@ -921,8 +1009,8 @@ func init() { multicaProvisionCmd.Flags().StringVar(&multicaProvisionRegistry, "registry", "", "Multica registry path") multicaProvisionCmd.Flags().StringVar(&multicaProvisionProjectRoot, "project-root", ".", "project root for the default registry path") - multicaProvisionCmd.Flags().StringVar(&multicaProvisionProfileName, "runtime-profile-name", "mnemon-runtime", "Multica runtime profile display name") - multicaProvisionCmd.Flags().StringVar(&multicaProvisionRuntimeCommand, "runtime-command", "mnemon-multica-runtime", "runtime executable name registered with Multica") + multicaProvisionCmd.Flags().StringVar(&multicaProvisionProfileName, "runtime-profile-name", driver.MulticaRuntimeProfileName, "Multica runtime profile display name") + multicaProvisionCmd.Flags().StringVar(&multicaProvisionRuntimeCommand, "runtime-command", driver.MulticaRuntimeCommandName, "runtime executable name registered with Multica") multicaProvisionCmd.Flags().StringVar(&multicaProvisionRuntimePath, "runtime-path", "", "absolute local executable path for the runtime profile") multicaProvisionCmd.Flags().StringVar(&multicaProvisionAgentPrefix, "agent-prefix", "mnemon", "Multica participant agent name prefix") multicaProvisionCmd.Flags().BoolVar(&multicaProvisionRestartDaemon, "restart-daemon", false, "restart the local Multica daemon after setting the runtime path") @@ -935,6 +1023,8 @@ func init() { multicaProvisionCmd.Flags().StringVar(&multicaProvisionManagedCommand, "managed-command", envDefault("MNEMON_MANAGED_COMMAND", ""), "managed runtime command injected into participant env") multicaProvisionCmd.Flags().StringVar(&multicaProvisionManagedWorkspace, "managed-workspace", envDefault("MNEMON_MANAGED_WORKSPACE", ""), "managed runtime workspace injected into participant env") multicaProvisionCmd.Flags().DurationVar(&multicaProvisionManagedTimeout, "managed-turn-timeout", 0, "managed runtime turn timeout injected into participant env") + multicaProvisionCmd.Flags().BoolVar(&multicaProvisionAcceptanceBridge, "acceptance-bridge", false, "allow mnemon-acceptance to invoke hidden Multica provisioning bridge") + _ = multicaProvisionCmd.Flags().MarkHidden("acceptance-bridge") multicaParticipantRegisterCmd.Flags().StringVar(&multicaParticipantRegistry, "registry", "", "Multica registry path") multicaParticipantRegisterCmd.Flags().StringVar(&multicaParticipantProjectRoot, "project-root", ".", "project root for the default registry path") @@ -956,6 +1046,6 @@ func init() { multicaParticipantCmd.AddCommand(multicaParticipantRegisterCmd) multicaCmd.AddCommand(multicaProbeCmd, multicaParticipantCmd, multicaProvisionCmd, multicaImportIssueCmd, multicaProjectCommentCmd) - multicaCmd.GroupID = groupSpine + multicaCmd.GroupID = groupAdvanced rootCmd.AddCommand(multicaCmd) } diff --git a/harness/cmd/mnemon-harness/multica_test.go b/harness/cmd/mnemon-harness/multica_test.go index 600031dd..f99af2e3 100644 --- a/harness/cmd/mnemon-harness/multica_test.go +++ b/harness/cmd/mnemon-harness/multica_test.go @@ -200,6 +200,17 @@ esac t.Fatal(err) } registryPath := filepath.Join(tmp, "registry.json") + credentialsDir := filepath.Join(tmp, ".mnemon", "harness", "channel", "credentials") + if err := os.MkdirAll(credentialsDir, 0o700); err != nil { + t.Fatal(err) + } + plannerTokenFile := filepath.Join(credentialsDir, "planner-team.token") + implementerTokenFile := filepath.Join(credentialsDir, "implementer-team.token") + for _, path := range []string{plannerTokenFile, implementerTokenFile} { + if err := os.WriteFile(path, []byte("token\n"), 0o600); err != nil { + t.Fatal(err) + } + } multicaBin = bin multicaProfile = "desktop-api.multica.ai" multicaWorkspaceID = "ws-1" @@ -215,6 +226,7 @@ esac multicaProvisionHarnessBin = "/abs/mnemon-harness" multicaProvisionManagedRuntime = "noop" multicaProvisionManagedWorkspace = tmp + multicaProvisionAcceptanceBridge = true multicaJSON = true t.Setenv("MULTICA_ENV_STDIN_PATH", envStdinPath) @@ -261,6 +273,8 @@ esac `"MNEMON_MULTICA_WORKSPACE_ID":"ws-1"`, `"MNEMON_CONTROL_ADDR":"http://127.0.0.1:8787"`, `"MNEMON_CONTROL_PRINCIPAL":"planner@team"`, + `"MNEMON_CONTROL_TOKEN_FILE":"` + plannerTokenFile + `"`, + `"MNEMON_CONTROL_TOKEN_FILE":"` + implementerTokenFile + `"`, `"MNEMON_HARNESS_BIN":"/abs/mnemon-harness"`, `"MNEMON_MANAGED_RUNTIME":"noop"`, `"MNEMON_MANAGED_WORKSPACE":"` + tmp + `"`, @@ -271,6 +285,80 @@ esac } } +func TestMulticaProvisionRejectsDirectHarnessUse(t *testing.T) { + restoreMulticaFlags(t) + + multicaProvisionAcceptanceBridge = false + err := runMulticaProvision(multicaProvisionCmd, nil) + if err == nil { + t.Fatal("direct hidden harness provision should be rejected") + } + if !strings.Contains(err.Error(), "mnemon-acceptance multica-provision") { + t.Fatalf("unexpected direct provision error: %v", err) + } +} + +func TestMergeMulticaParticipantRuntimeEnvPrunesStaleManagedKeys(t *testing.T) { + merged := mergeMulticaParticipantRuntimeEnv(map[string]string{ + "MNEMON_CONTROL_TOKEN": "old-token", + "MNEMON_CONTROL_TOKEN_FILE": "/old/token", + "MNEMON_MANAGED_RUNTIME": "codex-appserver", + "MNEMON_HUB_BACKEND": "old", + "CUSTOM_USER_ENV": "keep", + }, map[string]string{ + "MNEMON_HUB_BACKEND": "multica", + "MNEMON_CONTROL_ADDR": "http://127.0.0.1:8791", + "MNEMON_CONTROL_PRINCIPAL": "planner@team", + "MNEMON_MANAGED_WORKSPACE": "/workspace", + "MNEMON_MULTICA_REGISTRY": "/registry.json", + "MNEMON_MULTICA_WORKSPACE_ID": "ws-1", + }) + for _, stale := range []string{"MNEMON_CONTROL_TOKEN", "MNEMON_CONTROL_TOKEN_FILE", "MNEMON_MANAGED_RUNTIME"} { + if _, ok := merged[stale]; ok { + t.Fatalf("stale managed key %s should be pruned: %+v", stale, merged) + } + } + if merged["CUSTOM_USER_ENV"] != "keep" { + t.Fatalf("unmanaged env should be preserved: %+v", merged) + } + if merged["MNEMON_HUB_BACKEND"] != "multica" || merged["MNEMON_CONTROL_PRINCIPAL"] != "planner@team" { + t.Fatalf("desired managed env not applied: %+v", merged) + } +} + +func TestMulticaParticipantRuntimeEnvUsesAbsoluteLocalPaths(t *testing.T) { + restoreMulticaFlags(t) + + tmp := t.TempDir() + t.Chdir(tmp) + workspace := "managed-workspace" + tokenDir := filepath.Join(workspace, ".mnemon", "harness", "channel", "credentials") + if err := os.MkdirAll(tokenDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tokenDir, "planner-team.token"), []byte("token\n"), 0o600); err != nil { + t.Fatal(err) + } + multicaProfile = "desktop-api.multica.ai" + env := multicaParticipantRuntimeEnv(driver.MulticaCLI{Command: "multica"}, driver.MulticaParticipantRecord{ + Principal: "planner@team", + AgentName: "mnemon-planner", + AgentID: "agent-planner", + }, filepath.Join("state", "registry.json"), "ws-1", multicaParticipantEnvOptions{ + ManagedWorkspace: workspace, + }) + + for _, key := range []string{"MNEMON_MULTICA_REGISTRY", "MNEMON_MANAGED_WORKSPACE", "MNEMON_CONTROL_TOKEN_FILE"} { + value := env[key] + if value == "" || !filepath.IsAbs(value) { + t.Fatalf("%s should be absolute, got %q in %+v", key, value, env) + } + } + if want := filepath.Join(tmp, workspace, ".mnemon", "harness", "channel", "credentials", "planner-team.token"); env["MNEMON_CONTROL_TOKEN_FILE"] != want { + t.Fatalf("token file = %q, want %q", env["MNEMON_CONTROL_TOKEN_FILE"], want) + } +} + func restoreMulticaFlags(t *testing.T) { t.Helper() oldBin := multicaBin @@ -312,6 +400,7 @@ func restoreMulticaFlags(t *testing.T) { oldProvisionManagedCommand := multicaProvisionManagedCommand oldProvisionManagedWorkspace := multicaProvisionManagedWorkspace oldProvisionManagedTimeout := multicaProvisionManagedTimeout + oldProvisionAcceptanceBridge := multicaProvisionAcceptanceBridge oldParticipantRegistry := multicaParticipantRegistry oldParticipantProjectRoot := multicaParticipantProjectRoot oldParticipantAgentID := multicaParticipantAgentID @@ -369,6 +458,7 @@ func restoreMulticaFlags(t *testing.T) { multicaProvisionManagedCommand = oldProvisionManagedCommand multicaProvisionManagedWorkspace = oldProvisionManagedWorkspace multicaProvisionManagedTimeout = oldProvisionManagedTimeout + multicaProvisionAcceptanceBridge = oldProvisionAcceptanceBridge multicaParticipantRegistry = oldParticipantRegistry multicaParticipantProjectRoot = oldParticipantProjectRoot multicaParticipantAgentID = oldParticipantAgentID @@ -424,6 +514,7 @@ func restoreMulticaFlags(t *testing.T) { multicaProvisionManagedCommand = "" multicaProvisionManagedWorkspace = "" multicaProvisionManagedTimeout = 0 + multicaProvisionAcceptanceBridge = false multicaParticipantRegistry = "" multicaParticipantProjectRoot = "." multicaParticipantAgentID = "" diff --git a/harness/cmd/mnemon-harness/product_config_helpers.go b/harness/cmd/mnemon-harness/product_config_helpers.go new file mode 100644 index 00000000..2c23a3f5 --- /dev/null +++ b/harness/cmd/mnemon-harness/product_config_helpers.go @@ -0,0 +1,31 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + + "github.com/mnemon-dev/mnemon/harness/internal/productconfig" +) + +func loadHarnessProductConfig(root, explicit string) (productconfig.Config, string, error) { + path := productconfig.DefaultPath(filepath.Clean(root), explicit) + cfg, err := productconfig.Load(path) + if err == nil { + return cfg, path, nil + } + if !errors.Is(err, os.ErrNotExist) { + return productconfig.Config{}, "", err + } + if legacy, found, legacyErr := productconfig.FromLegacy(filepath.Clean(root)); legacyErr != nil { + return productconfig.Config{}, "", legacyErr + } else if found { + return legacy, path, nil + } + return productconfig.Default(), path, nil +} + +func saveHarnessProductConfig(root, explicit string, cfg productconfig.Config) (string, error) { + path := productconfig.DefaultPath(filepath.Clean(root), explicit) + return path, productconfig.Save(path, cfg) +} diff --git a/harness/cmd/mnemon-harness/root.go b/harness/cmd/mnemon-harness/root.go index 7f6e99b3..ac1e1041 100644 --- a/harness/cmd/mnemon-harness/root.go +++ b/harness/cmd/mnemon-harness/root.go @@ -12,10 +12,10 @@ var version = "dev" var rootCmd = &cobra.Command{ Use: "mnemon-harness", Version: version, - Short: "Mnemon Agent Integration setup", + Short: "Experimental Mnemon event-system harness", CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true}, - Long: "Install Agent Integration for standard events, connect it to Local Mnemon, " + - "and keep Remote Workspace sync as a background concern.", + Long: "Configure the experimental Mnemon event-driven collaboration substrate: Agent Integration, " + + "Local Mnemon, external connections, and daemon workers. Teamwork is a profile on top of this substrate.", } // Command groups are help-only: they change how `--help` lists verbs, never a diff --git a/harness/cmd/mnemon-harness/root_test.go b/harness/cmd/mnemon-harness/root_test.go index d9d41da5..0e5ded38 100644 --- a/harness/cmd/mnemon-harness/root_test.go +++ b/harness/cmd/mnemon-harness/root_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "os" + "sort" "strings" "testing" ) @@ -22,16 +23,30 @@ func TestRootHelpUsesLocalFirstProductSurface(t *testing.T) { t.Fatalf("root help returned error: %v", err) } got := out.String() - for _, want := range []string{"Agent Integration", "Local Mnemon", "Remote Workspace", "standard events", "setup", "local"} { + for _, want := range []string{"event-driven", "collaboration substrate", "Teamwork is a profile", "Agent Integration", "Local Mnemon", "setup", "config", "daemon", "doctor", "status", "session", "agent", "connect"} { if !strings.Contains(got, want) { t.Fatalf("expected root help to contain %q:\n%s", want, got) } } - for _, blocked := range []string{"completion", "eval", "goal", "coordination", "runner", "supervisor", "daemon", "proposal"} { + for _, blocked := range []string{"completion", "eval", "goal", "coordination", "runner", "supervisor", "proposal"} { if strings.Contains(got, blocked) { t.Fatalf("root help leaked unsupported product term %q:\n%s", blocked, got) } } + for _, blocked := range []string{" local", " multica", " sync", " tower", " token"} { + if strings.Contains(got, blocked) { + t.Fatalf("root help leaked debug command %q:\n%s", strings.TrimSpace(blocked), got) + } + } +} + +func TestPublicRootCommandsMatchProductSurface(t *testing.T) { + got := publicRootCommandNames() + want := []string{"agent", "config", "connect", "daemon", "doctor", "session", "setup", "status"} + sort.Strings(want) + if strings.Join(got, "\x00") != strings.Join(want, "\x00") { + t.Fatalf("public root commands mismatch:\ngot: %v\nwant: %v", got, want) + } } func TestRootDoesNotExposeAcceptanceCommands(t *testing.T) { @@ -53,9 +68,17 @@ func TestProductHelpDoesNotExposeInternalVocabulary(t *testing.T) { {"status", "--help"}, {"sync", "--help"}, {"sync", "connect", "--help"}, + {"agent", "--help"}, + {"agent", "add", "--help"}, + {"connect", "--help"}, + {"connect", "multica", "--help"}, + {"session", "--help"}, + {"session", "start", "--help"}, + {"session", "attach", "--help"}, + {"multica", "--help"}, } { got := executeRootForHelp(t, args...) - for _, blocked := range []string{"binding", "channel", "projection", "kernel", "runtime", "sync cursor", "token file", "control-agent"} { + for _, blocked := range []string{"binding", "channel", "projection", "kernel", "runtime", "sync cursor", "token file", "control-agent", "import-issue", "project-comment", "provision", "participant"} { if strings.Contains(strings.ToLower(got), blocked) { t.Fatalf("%q help leaked internal term %q:\n%s", strings.Join(args, " "), blocked, got) } @@ -63,6 +86,45 @@ func TestProductHelpDoesNotExposeInternalVocabulary(t *testing.T) { } } +func TestInternalCommandsStayHidden(t *testing.T) { + for _, path := range [][]string{ + {"control"}, + {"loop"}, + {"local"}, + {"multica"}, + {"multica", "import-issue"}, + {"multica", "participant"}, + {"multica", "project-comment"}, + {"multica", "provision"}, + {"sync"}, + {"token"}, + {"tower"}, + } { + cmd, _, err := rootCmd.Find(path) + if err != nil { + t.Fatalf("find command %q: %v", strings.Join(path, " "), err) + } + if cmd == nil || cmd.Name() != path[len(path)-1] { + t.Fatalf("command %q resolved to %+v", strings.Join(path, " "), cmd) + } + if !cmd.Hidden { + t.Fatalf("internal/debug command %q must remain hidden from public help", strings.Join(path, " ")) + } + } +} + +func publicRootCommandNames() []string { + var names []string + for _, cmd := range rootCmd.Commands() { + if cmd.Hidden || cmd.Name() == "help" { + continue + } + names = append(names, cmd.Name()) + } + sort.Strings(names) + return names +} + func executeRootForHelp(t *testing.T, args ...string) string { t.Helper() var out bytes.Buffer diff --git a/harness/cmd/mnemon-harness/session.go b/harness/cmd/mnemon-harness/session.go new file mode 100644 index 00000000..bb731a4e --- /dev/null +++ b/harness/cmd/mnemon-harness/session.go @@ -0,0 +1,125 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/productconfig" + sessionstore "github.com/mnemon-dev/mnemon/harness/internal/session" + "github.com/spf13/cobra" +) + +var ( + sessionRoot string + sessionStartID string + sessionStartTitle string + sessionStartCarrier string + sessionStartDupPolicy string + sessionAttachID string + sessionAttachSurface string + sessionAttachExternal string + sessionAttachSetPrimary bool +) + +var sessionCmd = &cobra.Command{ + Use: "session", + Short: "Start and attach harness sessions", +} + +var sessionStartCmd = &cobra.Command{ + Use: "start", + Short: "Start a harness session record", + RunE: runSessionStart, +} + +var sessionAttachCmd = &cobra.Command{ + Use: "attach", + Short: "Attach an external reference to a harness session", + RunE: runSessionAttach, +} + +func init() { + sessionCmd.PersistentFlags().StringVar(&sessionRoot, "root", ".", "project root") + sessionStartCmd.Flags().StringVar(&sessionStartID, "id", "", "session id; generated when empty") + sessionStartCmd.Flags().StringVar(&sessionStartTitle, "title", "", "session title") + sessionStartCmd.Flags().StringVar(&sessionStartCarrier, "primary-carrier", "", "primary activation carrier") + sessionStartCmd.Flags().StringVar(&sessionStartDupPolicy, "duplicate-activation-policy", "", "duplicate activation policy") + sessionAttachCmd.Flags().StringVar(&sessionAttachID, "id", "", "session id") + sessionAttachCmd.Flags().StringVar(&sessionAttachSurface, "surface", "", "external surface name") + sessionAttachCmd.Flags().StringVar(&sessionAttachExternal, "external-ref", "", "external reference to attach") + sessionAttachCmd.Flags().BoolVar(&sessionAttachSetPrimary, "primary", false, "make this attachment the primary activation carrier") + sessionCmd.AddCommand(sessionStartCmd, sessionAttachCmd) + sessionCmd.GroupID = groupSpine + rootCmd.AddCommand(sessionCmd) +} + +func runSessionStart(cmd *cobra.Command, args []string) error { + root := sessionProjectRoot() + cfg := sessionConfigDefaults(root) + id := strings.TrimSpace(sessionStartID) + if id == "" { + id = "session-" + time.Now().UTC().Format("20060102T150405Z") + } + carrier := firstSessionValue(sessionStartCarrier, cfg.Sessions.PrimaryActivationCarrier, "local") + dupPolicy := firstSessionValue(sessionStartDupPolicy, cfg.Sessions.DuplicateActivationPolicy, productconfig.DuplicateActivationSuppress) + record, err := sessionstore.NewFileStore(sessionstore.DefaultDir(root, ""), nil).Start(sessionstore.Record{ + ID: id, + Title: sessionStartTitle, + PrimaryActivationCarrier: carrier, + DuplicateActivationPolicy: dupPolicy, + }) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Session: %s\n", record.ID) + fmt.Fprintf(cmd.OutOrStdout(), "Primary activation carrier: %s\n", record.PrimaryActivationCarrier) + fmt.Fprintf(cmd.OutOrStdout(), "Duplicate activation policy: %s\n", record.DuplicateActivationPolicy) + return nil +} + +func runSessionAttach(cmd *cobra.Command, args []string) error { + root := sessionProjectRoot() + if strings.TrimSpace(sessionAttachID) == "" { + return fmt.Errorf("--id is required") + } + record, err := sessionstore.NewFileStore(sessionstore.DefaultDir(root, ""), nil).Attach(sessionAttachID, sessionstore.Attachment{ + Surface: sessionAttachSurface, + ExternalRef: sessionAttachExternal, + Primary: sessionAttachSetPrimary, + }) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Session: %s\n", record.ID) + fmt.Fprintf(cmd.OutOrStdout(), "Attachments: %d\n", len(record.Attachments)) + fmt.Fprintf(cmd.OutOrStdout(), "Primary activation carrier: %s\n", record.PrimaryActivationCarrier) + return nil +} + +func sessionProjectRoot() string { + root := strings.TrimSpace(sessionRoot) + if root == "" { + return "." + } + return root +} + +func sessionConfigDefaults(root string) productconfig.Config { + if cfg, err := productconfig.Load(productconfig.DefaultPath(root, "")); err == nil { + return cfg + } + if cfg, found, err := productconfig.FromLegacy(root); err == nil && found { + return cfg + } + return productconfig.Default() +} + +func firstSessionValue(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/harness/cmd/mnemon-harness/session_test.go b/harness/cmd/mnemon-harness/session_test.go new file mode 100644 index 00000000..780e670f --- /dev/null +++ b/harness/cmd/mnemon-harness/session_test.go @@ -0,0 +1,124 @@ +package main + +import ( + "strings" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/productconfig" + sessionstore "github.com/mnemon-dev/mnemon/harness/internal/session" +) + +func TestSessionStartUsesProductConfigDefaults(t *testing.T) { + root := t.TempDir() + cfg := productconfig.Default() + cfg.Connections.Multica = productconfig.MulticaConnection{Enabled: true, Workspace: "ws-multica", RuntimeBinary: "mnemon-multica-runtime"} + cfg.Sessions.PrimaryActivationCarrier = productconfig.ConnectionMultica + if err := productconfig.Save(productconfig.DefaultPath(root, ""), cfg); err != nil { + t.Fatal(err) + } + restoreSessionFlags(t) + sessionRoot = root + sessionStartID = "release-readiness" + sessionStartTitle = "Release readiness" + + cmd, out := testCommand() + if err := runSessionStart(cmd, nil); err != nil { + t.Fatal(err) + } + got := out.String() + for _, want := range []string{ + "Session: release-readiness", + "Primary activation carrier: multica", + "Duplicate activation policy: suppress", + } { + if !strings.Contains(got, want) { + t.Fatalf("session start missing %q:\n%s", want, got) + } + } + record, err := sessionstore.NewFileStore(sessionstore.DefaultDir(root, ""), nil).Load("release-readiness") + if err != nil { + t.Fatal(err) + } + if record.Title != "Release readiness" || record.PrimaryActivationCarrier != productconfig.ConnectionMultica { + t.Fatalf("session record = %+v", record) + } +} + +func TestSessionAttachRecordsExternalReference(t *testing.T) { + root := t.TempDir() + store := sessionstore.NewFileStore(sessionstore.DefaultDir(root, ""), nil) + if _, err := store.Start(sessionstore.Record{ID: "release-readiness", PrimaryActivationCarrier: "local"}); err != nil { + t.Fatal(err) + } + restoreSessionFlags(t) + sessionRoot = root + sessionAttachID = "release-readiness" + sessionAttachSurface = productconfig.ConnectionMultica + sessionAttachExternal = "issue/root-1" + sessionAttachSetPrimary = true + + cmd, out := testCommand() + if err := runSessionAttach(cmd, nil); err != nil { + t.Fatal(err) + } + got := out.String() + for _, want := range []string{ + "Session: release-readiness", + "Attachments: 1", + "Primary activation carrier: multica", + } { + if !strings.Contains(got, want) { + t.Fatalf("session attach missing %q:\n%s", want, got) + } + } + record, err := store.Load("release-readiness") + if err != nil { + t.Fatal(err) + } + if len(record.Attachments) != 1 || record.Attachments[0].ExternalRef != "issue/root-1" || record.PrimaryActivationCarrier != productconfig.ConnectionMultica { + t.Fatalf("attached session record = %+v", record) + } +} + +func TestSessionAttachRequiresID(t *testing.T) { + restoreSessionFlags(t) + sessionRoot = t.TempDir() + sessionAttachSurface = productconfig.ConnectionMultica + sessionAttachExternal = "issue/root-1" + if err := runSessionAttach(mustTestCommand(t), nil); err == nil { + t.Fatal("expected attach to require --id") + } +} + +func restoreSessionFlags(t *testing.T) { + t.Helper() + oldRoot := sessionRoot + oldStartID := sessionStartID + oldStartTitle := sessionStartTitle + oldStartCarrier := sessionStartCarrier + oldStartDupPolicy := sessionStartDupPolicy + oldAttachID := sessionAttachID + oldAttachSurface := sessionAttachSurface + oldAttachExternal := sessionAttachExternal + oldAttachPrimary := sessionAttachSetPrimary + sessionRoot = "." + sessionStartID = "" + sessionStartTitle = "" + sessionStartCarrier = "" + sessionStartDupPolicy = "" + sessionAttachID = "" + sessionAttachSurface = "" + sessionAttachExternal = "" + sessionAttachSetPrimary = false + t.Cleanup(func() { + sessionRoot = oldRoot + sessionStartID = oldStartID + sessionStartTitle = oldStartTitle + sessionStartCarrier = oldStartCarrier + sessionStartDupPolicy = oldStartDupPolicy + sessionAttachID = oldAttachID + sessionAttachSurface = oldAttachSurface + sessionAttachExternal = oldAttachExternal + sessionAttachSetPrimary = oldAttachPrimary + }) +} diff --git a/harness/cmd/mnemon-harness/sync.go b/harness/cmd/mnemon-harness/sync.go index 58be586c..10084439 100644 --- a/harness/cmd/mnemon-harness/sync.go +++ b/harness/cmd/mnemon-harness/sync.go @@ -37,8 +37,9 @@ var ( ) var syncCmd = &cobra.Command{ - Use: "sync", - Short: "Sync Local Mnemon with Remote Workspace", + Use: "sync", + Short: "Sync Local Mnemon with Remote Workspace", + Hidden: true, } var syncConnectCmd = &cobra.Command{ @@ -88,7 +89,7 @@ func init() { syncRunCmd.Flags().BoolVar(&syncBackground, "background", false, "run until interrupted") syncRunCmd.Flags().DurationVar(&syncInterval, "interval", 30*time.Second, "background sync interval") syncCmd.AddCommand(syncConnectCmd, syncPushCmd, syncPullCmd, syncRunCmd) - syncCmd.GroupID = groupSpine + syncCmd.GroupID = groupAdvanced rootCmd.AddCommand(syncCmd) } diff --git a/harness/cmd/mnemon-harness/sync_test.go b/harness/cmd/mnemon-harness/sync_test.go index 9b9d6624..c3290193 100644 --- a/harness/cmd/mnemon-harness/sync_test.go +++ b/harness/cmd/mnemon-harness/sync_test.go @@ -11,11 +11,14 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/mnemon-dev/mnemon/harness/internal/app" "github.com/mnemon-dev/mnemon/harness/internal/contract" eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/state" + "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub" "github.com/mnemon-dev/mnemon/harness/internal/mnemonhub/exchange" "github.com/mnemon-dev/mnemon/harness/internal/runtime" ) @@ -269,6 +272,127 @@ func TestSyncPullOnceImportsRemoteAssignmentThroughLocalMnemon(t *testing.T) { } } +func TestSyncPushPullOnceRoundTripsThroughMnemonHub(t *testing.T) { + restoreSyncFlags(t) + root := t.TempDir() + storePath := filepath.Join(root, runtime.DefaultStorePath) + ref := contract.ResourceRef{Kind: "progress_digest", ID: "project"} + + localBinding := access.HostAgentBinding("codex@project", "http://127.0.0.1:8787", []contract.ResourceRef{ref}) + local, err := app.OpenLocalRuntime(storePath, access.LoadedBindings{Bindings: []access.ChannelBinding{localBinding}}, nil, nil) + if err != nil { + t.Fatalf("open local runtime: %v", err) + } + if _, _, err := local.API().Ingest("codex@project", contract.ObservationEnvelope{ + ExternalID: "hub-cli-local-progress", + Event: contract.Event{Type: "progress_digest.write_candidate.observed", Payload: cmdR2Progress("manual sync push reaches mnemonhub")}, + }); err != nil { + t.Fatalf("local observe: %v", err) + } + if _, err := local.Tick(); err != nil { + t.Fatalf("local tick: %v", err) + } + if err := local.Close(); err != nil { + t.Fatalf("close local runtime: %v", err) + } + + hubStore, err := state.OpenStore(filepath.Join(t.TempDir(), "hub.db")) + if err != nil { + t.Fatalf("open hub store: %v", err) + } + defer hubStore.Close() + grants := mnemonhub.GrantMap{ + "replica-local@team": {Principal: "replica-local@team", Scopes: []contract.ResourceRef{ref}}, + "replica-other@team": {Principal: "replica-other@team", Scopes: []contract.ResourceRef{ref}}, + } + tokens := map[string]contract.ActorID{ + "tok-local": "replica-local@team", + "tok-other": "replica-other@team", + } + hub := mnemonhub.New(hubStore, grants, func() string { return time.Now().UTC().Format(time.RFC3339) }) + hubSrv := httptest.NewServer(mnemonhub.NewHTTPHandler(hub, mnemonhub.BearerAuthenticator{Tokens: tokens}, nil)) + defer hubSrv.Close() + + foreignFields := remoteProgressFields("hub-cli-remote-entry", "manual sync pull imports from mnemonhub") + foreignMaterial := contract.SyncedEventMaterial{ + OriginReplicaID: "other-replica", + LocalDecisionID: "dec-hub-cli-remote", + LocalIngestSeq: 9, + Actor: "codex@other", + ResourceRef: ref, + ResourceVersion: 1, + FieldsDigest: syncTestDigest(foreignFields), + Fields: foreignFields, + DecidedAt: "2026-06-30T00:00:00Z", + Status: "pending", + } + otherClient, err := access.NewSyncClient(hubSrv.URL, access.SyncClientConfig{Token: "tok-other"}) + if err != nil { + t.Fatalf("build other hub client: %v", err) + } + if resp, err := otherClient.SyncPush(contract.SyncPushRequest{ + ReplicaID: "other-replica", + BatchID: "seed-hub-cli-remote", + Events: syncTestEvents(t, foreignMaterial), + }); err != nil || len(resp.Accepted) != 1 { + t.Fatalf("seed hub remote material: resp=%+v err=%v", resp, err) + } + + syncRoot = root + syncStorePath = storePath + syncRemoteID = "hub" + syncRemoteURL = hubSrv.URL + syncRemoteToken = "tok-local" + var out bytes.Buffer + cmd := mustTestCommand(t) + cmd.SetOut(&out) + if err := runSyncPush(cmd, nil); err != nil { + t.Fatalf("sync push to mnemonhub: %v", err) + } + if !strings.Contains(out.String(), "Sync push: 1 accepted, 0 rejected, 0 conflicts") { + t.Fatalf("unexpected hub push output: %s", out.String()) + } + st, err := syncStatusForTest(storePath) + if err != nil { + t.Fatalf("status after hub push: %v", err) + } + if st.SyncPending != 0 || st.SyncSynced != 1 { + t.Fatalf("hub push must ack local synced event, got %+v", st) + } + hubStatus, err := hub.Status("replica-local@team") + if err != nil || hubStatus.HubEventsReceived != 2 { + t.Fatalf("hub must hold seeded and pushed events: %+v err=%v", hubStatus, err) + } + + out.Reset() + cmd = mustTestCommand(t) + cmd.SetOut(&out) + if err := runSyncPull(cmd, nil); err != nil { + t.Fatalf("sync pull from mnemonhub: %v", err) + } + if !strings.Contains(out.String(), "Sync pull: 1 events") { + t.Fatalf("unexpected hub pull output: %s", out.String()) + } + content := localResourceContentForTest(t, storePath, ref) + if !strings.Contains(content, "manual sync push reaches mnemonhub") || !strings.Contains(content, "manual sync pull imports from mnemonhub") { + t.Fatalf("manual hub round trip not visible through local presentation view:\n%s", content) + } + + out.Reset() + cmd = mustTestCommand(t) + cmd.SetOut(&out) + if err := runSyncPull(cmd, nil); err != nil { + t.Fatalf("second sync pull from mnemonhub: %v", err) + } + if !strings.Contains(out.String(), "Sync pull: 0 events") { + t.Fatalf("second hub pull must be cursor-idempotent, got %s", out.String()) + } + content = localResourceContentForTest(t, storePath, ref) + if strings.Count(content, "manual sync pull imports from mnemonhub") != 1 { + t.Fatalf("second hub pull duplicated imported progress:\n%s", content) + } +} + func TestSyncConnectWritesRemoteConfigWithoutLeakingToken(t *testing.T) { restoreSyncFlags(t) root := t.TempDir() diff --git a/harness/cmd/mnemon-harness/token.go b/harness/cmd/mnemon-harness/token.go index 24d42a6b..67e3348b 100644 --- a/harness/cmd/mnemon-harness/token.go +++ b/harness/cmd/mnemon-harness/token.go @@ -15,8 +15,9 @@ import ( var tokenPrincipal string var tokenCmd = &cobra.Command{ - Use: "token", - Short: "Manage channel credentials", + Use: "token", + Short: "Manage local access credentials", + Hidden: true, } var tokenRotateCmd = &cobra.Command{ @@ -39,7 +40,7 @@ var tokenRotateCmd = &cobra.Command{ func init() { tokenRotateCmd.Flags().StringVar(&tokenPrincipal, "principal", "", "principal whose token to rotate") tokenCmd.AddCommand(tokenRotateCmd) - tokenCmd.GroupID = groupSpine + tokenCmd.GroupID = groupAdvanced rootCmd.AddCommand(tokenCmd) } diff --git a/harness/cmd/mnemon-harness/tower.go b/harness/cmd/mnemon-harness/tower.go index 0eb46669..c79af494 100644 --- a/harness/cmd/mnemon-harness/tower.go +++ b/harness/cmd/mnemon-harness/tower.go @@ -13,13 +13,15 @@ var towerDump bool // towerCmd is the Agent Control Tower (P6, D5: TUI-only, command name `tower`) — the human-visible // boundary over the agent field. It renders the four §3.3 pages (GOAL/FIELD/INBOX/LEDGER) read-only. var towerCmd = &cobra.Command{ - Use: "tower", - Short: "Agent Control Tower — the four-page human boundary over the agent field (GOAL/FIELD/INBOX/LEDGER)", - RunE: runTower, + Use: "tower", + Short: "Agent Control Tower — the four-page human boundary over the agent field (GOAL/FIELD/INBOX/LEDGER)", + Hidden: true, + RunE: runTower, } func init() { towerCmd.Flags().BoolVar(&towerDump, "dump", false, "render a one-shot read-only snapshot of the four pages and exit (headless/scriptable)") + towerCmd.GroupID = groupAdvanced rootCmd.AddCommand(towerCmd) } diff --git a/harness/cmd/mnemon-hub/main.go b/harness/cmd/mnemon-hub/main.go index 36bfbc33..032971e8 100644 --- a/harness/cmd/mnemon-hub/main.go +++ b/harness/cmd/mnemon-hub/main.go @@ -35,6 +35,7 @@ func main() { // generator exit, load replicas.json (fail-closed), take the hub store's single-writer lock, and // serve the three sync verbs (TLS when both cert+key are set) until ctx cancels. func run(ctx context.Context, args []string, out, errw io.Writer) error { + args = normalizeHubArgs(args) fs := flag.NewFlagSet("mnemon-hub", flag.ContinueOnError) fs.SetOutput(errw) addr := fs.String("addr", "127.0.0.1:9787", "listen address") @@ -43,7 +44,13 @@ func run(ctx context.Context, args []string, out, errw io.Writer) error { tlsCert := fs.String("tls-cert", "", "TLS certificate file (TLS is served when --tls-cert and --tls-key are both set)") tlsKey := fs.String("tls-key", "", "TLS private key file") devSelfsigned := fs.String("dev-selfsigned", "", "generate a self-signed dev/e2e cert+key pair into this directory, print their paths, and exit") + fs.Usage = func() { + writeHubHelp(errw, fs) + } if err := fs.Parse(args); err != nil { + if err == flag.ErrHelp { + return nil + } return err } if *devSelfsigned != "" { @@ -82,6 +89,37 @@ func run(ctx context.Context, args []string, out, errw io.Writer) error { return serveHub(ctx, *addr, handler, *tlsCert, *tlsKey, *storePath, out) } +func normalizeHubArgs(args []string) []string { + if len(args) == 0 { + return args + } + switch args[0] { + case "serve": + return args[1:] + case "help": + return []string{"--help"} + default: + return args + } +} + +func writeHubHelp(errw io.Writer, fs *flag.FlagSet) { + fmt.Fprintln(errw, "mnemon-hub is the remote event exchange backend: it handles authenticated replica push, pull, status, cursors, and tenant boundaries.") + fmt.Fprintln(errw) + fmt.Fprintln(errw, "Usage:") + fmt.Fprintln(errw, " mnemon-hub serve --store PATH --replicas PATH [flags]") + fmt.Fprintln(errw, " mnemon-hub --dev-selfsigned DIR") + fmt.Fprintln(errw) + fmt.Fprintln(errw, "Commands:") + fmt.Fprintln(errw, " serve Run the authenticated remote event exchange backend") + fmt.Fprintln(errw, " help Show this help") + fmt.Fprintln(errw) + fmt.Fprintln(errw, "Flags:") + if fs != nil { + fs.PrintDefaults() + } +} + // serveHub listens (so the bound address is printable before any request) and serves until ctx // cancels, then shuts down cleanly. With cert+key it serves TLS natively. func serveHub(ctx context.Context, addr string, handler http.Handler, certFile, keyFile, storePath string, out io.Writer) error { diff --git a/harness/cmd/mnemon-hub/main_test.go b/harness/cmd/mnemon-hub/main_test.go index a053b5bb..92e13d6e 100644 --- a/harness/cmd/mnemon-hub/main_test.go +++ b/harness/cmd/mnemon-hub/main_test.go @@ -7,6 +7,7 @@ import ( "crypto/tls" "encoding/hex" "encoding/json" + "io" "os" "path/filepath" "regexp" @@ -39,6 +40,27 @@ func writeToken(t *testing.T, dir, name, token string) { } } +func TestHelpDescribesRemoteExchangeBackend(t *testing.T) { + for _, args := range [][]string{{"--help"}, {"help"}, {"serve", "--help"}} { + var errw bytes.Buffer + err := run(context.Background(), args, io.Discard, &errw) + if err != nil { + t.Fatalf("%v help should exit successfully, got %v", args, err) + } + got := errw.String() + for _, want := range []string{"remote event exchange backend", "replica push", "pull", "status", "cursors", "tenant boundaries", "Commands:", "serve"} { + if !strings.Contains(got, want) { + t.Fatalf("%v mnemon-hub help missing %q:\n%s", args, want, got) + } + } + for _, blocked := range []string{"managed runtime", "Multica projection", "local drive source"} { + if strings.Contains(got, blocked) { + t.Fatalf("%v mnemon-hub help leaked non-exchange wording %q:\n%s", args, blocked, got) + } + } + } +} + const twoReplicaDoc = `{ "schema_version": 1, "replicas": [ @@ -140,6 +162,9 @@ func TestRunFlagValidation(t *testing.T) { if err := run(context.Background(), []string{"--store", "x.db", "--replicas", "r.json", "--tls-cert", "c.pem"}, &out, &out); err == nil || !strings.Contains(err.Error(), "set together") { t.Fatalf("lone --tls-cert must fail: %v", err) } + if err := run(context.Background(), []string{"serve", "--store", "x.db", "--replicas", "r.json", "--tls-cert", "c.pem"}, &out, &out); err == nil || !strings.Contains(err.Error(), "set together") { + t.Fatalf("serve alias must parse service flags: %v", err) + } } // Full hub integration over native TLS: mnemon-hub serves push/pull/status with the dev self-signed diff --git a/harness/cmd/mnemon-multica-runtime/hub_writer.go b/harness/cmd/mnemon-multica-runtime/hub_writer.go index 847e05e7..7713aeee 100644 --- a/harness/cmd/mnemon-multica-runtime/hub_writer.go +++ b/harness/cmd/mnemon-multica-runtime/hub_writer.go @@ -2,25 +2,24 @@ package main import ( "context" - "encoding/json" + "errors" "fmt" - "strconv" + "sort" "strings" - "sync" "time" "github.com/mnemon-dev/mnemon/harness/internal/contract" "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/access" pview "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation/view" + multicasurface "github.com/mnemon-dev/mnemon/harness/internal/surface/multica" ) func (s *runtimeRPCState) writeMulticaHubArtifacts(ctx context.Context, cli driver.MulticaCLI, client *access.Client, rootIssue driver.MulticaIssue, result *runtimeImportResult) { if result == nil { return } - if !runtimeMulticaHubWriteEnabled(s.Env) { + if !multicasurface.RuntimeHubWriteEnabled(s.Env) { result.HubWriteStatus = "skipped" return } @@ -46,9 +45,9 @@ func (s *runtimeRPCState) writeMulticaHubArtifacts(ctx context.Context, cli driv result.HubWriteErr = fmt.Errorf("pull teamwork view for Multica hub write: %w", err) return } - ledger := driver.NewFileMulticaHubLedger(runtimeMulticaHubLedgerPath(s.Env, s.CWD)) + ledger := driver.NewFileMulticaHubLedger(multicasurface.RuntimeMulticaHubLedgerPath(s.Env, s.CWD)) if result.HubKind == driver.MulticaHubKindSession { - reg, ok, err := runtimeMulticaRegistry(s.Env, s.CWD) + reg, ok, err := multicasurface.RuntimeMulticaRegistry(s.Env, s.CWD) if err != nil { result.HubWriteStatus = "failed" result.HubWriteErr = err @@ -88,38 +87,32 @@ func (s *runtimeRPCState) writeAssignmentMailboxes(ctx context.Context, cli driv return err } var projections []runtimeAssignmentProjection - for _, assignment := range hubViewItems(proj, "assignment") { - item := runtimeAssignmentItem(assignment) + for _, assignment := range multicasurface.RuntimeViewItems(proj, "assignment") { + item := multicasurface.RuntimeAssignmentViewItem(assignment) if item.ID == "" || item.Assignee == "" { continue } - if !hubItemAfterRootIngest(item.IngestSeq, result) { + if !runtimeItemAfterRootIngest(item.IngestSeq, result) { continue } - participant, ok := multicaParticipantForPrincipal(reg, item.Assignee) + if !runtimeAssignmentMatchesCurrentMulticaScope(item, result) { + continue + } + participant, ok := multicasurface.MulticaParticipantForPrincipal(reg, item.Assignee) if !ok || strings.TrimSpace(participant.AgentID) == "" { return fmt.Errorf("no Multica agent mapping for assignment assignee %q", item.Assignee) } - fingerprint := driver.MulticaAssignmentFingerprint(driver.MulticaAssignmentFingerprintInput{ - AssignmentID: item.ID, - Assignee: item.Assignee, - Scope: item.Scope, - ExpectedWork: item.ExpectedWork, - ExpectedFeedback: item.ExpectedFeedback, - SignalRef: item.SignalRef, - ContextRefs: item.ContextRefs, - EvidenceRefs: item.EvidenceRefs, - CorrelationID: result.CorrelationID, + projection := multicasurface.AssignmentMailboxProjectionForRuntimeItem(multicasurface.AssignmentMailboxProjectionMaterial{ + Item: item, + SessionID: result.SessionID, + CorrelationID: result.CorrelationID, + RootIssueID: result.RootIssueID, + SourceIssueID: rootIssue.ID, + ProjectionOwner: result.Principal, + MulticaAgentID: participant.AgentID, + ProjectedAt: s.now(), }) - source := driver.MulticaHubLedgerSource{ - SessionID: result.SessionID, - CorrelationID: result.CorrelationID, - EventID: item.EventID, - AssignmentID: item.ID, - AssignmentFingerprint: fingerprint, - Principal: item.Assignee, - ProjectionKind: "assignment", - } + source := projection.Source if _, ok, err := ledger.Find(driver.MulticaHubKindAssignmentMailbox, source); err != nil { return err } else if ok { @@ -141,29 +134,12 @@ func (s *runtimeRPCState) writeAssignmentMailboxes(ctx context.Context, cli driv } continue } - meta := driver.MulticaHubMetadata{ - SchemaVersion: "1", - HubBackend: driver.MulticaHubBackend, - Kind: driver.MulticaHubKindAssignmentMailbox, - SessionID: result.SessionID, - CorrelationID: result.CorrelationID, - EventID: item.EventID, - EventType: "assignment.accepted", - EventPhase: string(eventmodel.PhaseAccepted), - AssignmentID: item.ID, - AssignmentFingerprint: fingerprint, - Principal: item.Assignee, - SourceIssueID: rootIssue.ID, - RootIssueID: result.RootIssueID, - ProjectionOwner: result.Principal, - MulticaAgentID: participant.AgentID, - ProjectedAt: s.now().UTC().Format(time.RFC3339), - } projections = append(projections, runtimeAssignmentProjection{ Item: item, Participant: participant, Source: source, - Metadata: meta, + Metadata: projection.Metadata, + RootIssue: rootIssue, Result: result, }) } @@ -176,10 +152,11 @@ func (s *runtimeRPCState) writeAssignmentMailboxes(ctx context.Context, cli driv } type runtimeAssignmentProjection struct { - Item runtimeAssignment + Item multicasurface.RuntimeAssignmentItem Participant driver.MulticaParticipantRecord Source driver.MulticaHubLedgerSource Metadata driver.MulticaHubMetadata + RootIssue driver.MulticaIssue Result *runtimeImportResult } @@ -187,143 +164,175 @@ func (s *runtimeRPCState) projectAssignmentMailboxes(ctx context.Context, cli dr if len(projections) == 0 { return 0, nil } - const maxAssignmentWorkers = 3 - workers := maxAssignmentWorkers - if len(projections) < workers { - workers = len(projections) - } - jobs := make(chan runtimeAssignmentProjection) - var wg sync.WaitGroup - var mu sync.Mutex - var ledgerMu sync.Mutex - var firstErr error created := 0 - recordErr := func(err error) { - if err == nil { - return + var errs []error + for _, projection := range projections { + material := assignmentMailboxMaterial(projection.Item, projection.Result, projection.RootIssue, projection.Participant) + child, err := retryMulticaHubValue(ctx, func() (driver.MulticaIssue, error) { + return cli.CreateIssue(ctx, driver.MulticaCreateIssueRequest{ + Title: multicasurface.AssignmentMailboxTitle(material), + Description: multicasurface.AssignmentMailboxDescription(material), + ParentID: projection.Result.RootIssueID, + Status: "in_progress", + Priority: "medium", + AllowDuplicate: true, + }) + }) + if err != nil { + errs = append(errs, fmt.Errorf("create assignment mailbox %s: %w", projection.Item.ID, err)) + continue + } + fullMeta := projection.Metadata.Map() + dispatchMeta := multicasurface.AssignmentMailboxDispatchMetadata(fullMeta) + if err := setMulticaHubMetadataMap(ctx, cli, child.ID, dispatchMeta); err != nil { + errs = append(errs, fmt.Errorf("tag assignment mailbox %s (%s): %w", projection.Item.ID, child.ID, err)) + continue + } + if _, err := retryMulticaHubValue(ctx, func() (driver.MulticaIssue, error) { + return cli.AssignIssue(ctx, child.ID, projection.Participant.AgentID) + }); err != nil { + errs = append(errs, fmt.Errorf("assign assignment mailbox %s (%s): %w", projection.Item.ID, child.ID, err)) + continue + } + if err := ledger.Record(driver.MulticaHubLedgerRecord{ + Kind: driver.MulticaHubKindAssignmentMailbox, + Source: projection.Source, + Target: driver.MulticaHubLedgerTarget{ + RootIssueID: projection.Result.RootIssueID, + ChildIssueID: child.ID, + Status: "created", + }, + }); err != nil { + return created, err } - mu.Lock() - defer mu.Unlock() - if firstErr == nil { - firstErr = err + created++ + if err := setMulticaHubMetadataMap(ctx, cli, child.ID, multicasurface.AssignmentMailboxSupplementalMetadata(fullMeta, dispatchMeta)); err != nil { + errs = append(errs, fmt.Errorf("tag supplemental assignment mailbox %s (%s): %w", projection.Item.ID, child.ID, err)) + continue + } + } + return created, errors.Join(errs...) +} + +func setMulticaHubMetadataMap(ctx context.Context, cli driver.MulticaCLI, issueID string, values map[string]string) error { + keys := make([]string, 0, len(values)) + for key, value := range values { + if strings.TrimSpace(key) == "" || strings.TrimSpace(value) == "" { + continue } + keys = append(keys, key) } - hasErr := func() bool { - mu.Lock() - defer mu.Unlock() - return firstErr != nil + sort.Strings(keys) + var errs []error + for _, key := range keys { + value := values[key] + if err := retryMulticaHubOperation(ctx, func() error { + return cli.SetIssueMetadata(ctx, issueID, key, value, "string") + }); err != nil { + errs = append(errs, fmt.Errorf("set %s: %w", key, err)) + } } - for i := 0; i < workers; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for projection := range jobs { - if hasErr() { - continue - } - child, err := cli.CreateIssue(ctx, driver.MulticaCreateIssueRequest{ - Title: assignmentMailboxTitle(projection.Item), - Description: assignmentMailboxDescription(projection.Item, projection.Result), - ParentID: projection.Result.RootIssueID, - Status: "in_progress", - Priority: "medium", - }) - if err != nil { - recordErr(err) - continue - } - fullMeta := projection.Metadata.Map() - dispatchMeta := assignmentMailboxDispatchMetadata(fullMeta) - if err := cli.SetIssueMetadataMap(ctx, child.ID, dispatchMeta); err != nil { - recordErr(err) - continue - } - if _, err := cli.AssignIssue(ctx, child.ID, projection.Participant.AgentID); err != nil { - recordErr(err) - continue - } - ledgerMu.Lock() - err = ledger.Record(driver.MulticaHubLedgerRecord{ - Kind: driver.MulticaHubKindAssignmentMailbox, - Source: projection.Source, - Target: driver.MulticaHubLedgerTarget{ - RootIssueID: projection.Result.RootIssueID, - ChildIssueID: child.ID, - Status: "created", - }, - }) - ledgerMu.Unlock() - if err != nil { - recordErr(err) - continue - } - mu.Lock() - created++ - mu.Unlock() - if err := cli.SetIssueMetadataMap(ctx, child.ID, assignmentMailboxSupplementalMetadata(fullMeta, dispatchMeta)); err != nil { - recordErr(err) - } + return errors.Join(errs...) +} + +func retryMulticaHubOperation(ctx context.Context, op func() error) error { + _, err := retryMulticaHubValue(ctx, func() (struct{}, error) { + return struct{}{}, op() + }) + return err +} + +func retryMulticaHubValue[T any](ctx context.Context, op func() (T, error)) (T, error) { + var zero T + const attempts = 3 + var err error + for attempt := 0; attempt < attempts; attempt++ { + if attempt > 0 { + timer := time.NewTimer(time.Duration(attempt) * 250 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return zero, ctx.Err() + case <-timer.C: } - }() - } - for _, projection := range projections { - if hasErr() { - break } - jobs <- projection + var value T + value, err = op() + if err == nil { + return value, nil + } } - close(jobs) - wg.Wait() - return created, firstErr + return zero, err } func (s *runtimeRPCState) writeProgressComments(ctx context.Context, cli driver.MulticaCLI, ledger *driver.FileMulticaHubLedger, proj pview.View, result *runtimeImportResult) error { - for _, progress := range hubViewItems(proj, "progress_digest") { - item := runtimeProgressItem(progress) + for _, progress := range multicasurface.RuntimeViewItems(proj, "progress_digest") { + item := multicasurface.RuntimeProgressViewItem(progress) if item.ID == "" || item.AssignmentRef == "" { continue } - if !hubItemAfterRootIngest(item.IngestSeq, result) { + if !runtimeItemAfterRootIngest(item.IngestSeq, result) { continue } - source := driver.MulticaHubLedgerSource{ - SessionID: result.SessionID, - CorrelationID: result.CorrelationID, - EventID: item.EventID, - AssignmentID: item.AssignmentRef, - Principal: item.Actor, - ProjectionKind: "progress", - } - if _, ok, err := ledger.Find(driver.MulticaHubKindFeedbackCarrier, source); err != nil { + progressProjection := multicasurface.ProgressFeedbackProjectionForRuntimeItem(multicasurface.ProgressFeedbackProjectionMaterial{ + Item: item, + SessionID: result.SessionID, + CorrelationID: result.CorrelationID, + }) + if rec, ok, err := ledger.Find(driver.MulticaHubKindFeedbackCarrier, progressProjection.Source); err != nil { return err } else if ok { + child := strings.TrimSpace(rec.Target.ChildIssueID) + if child == "" { + var found bool + child, found, err = findAssignmentTargetFromLedger(ledger, result.SessionID, item.AssignmentRef, item.Actor) + if err != nil { + return err + } + if !found { + child, found, err = findAssignmentTargetFromMulticaHub(ctx, cli, result.RootIssueID, result.SessionID, item.AssignmentRef, item.Actor) + if err != nil { + return err + } + } + if !found { + continue + } + } + if !runtimeProgressMatchesCurrentMulticaScope(item, result, child) { + continue + } + if err := s.ensureProgressIssueStatuses(ctx, cli, result, child, progressProjection.Feedback); err != nil { + return err + } continue } - child, ok, err := findAssignmentTargetFromLedger(ledger, result.SessionID, item.AssignmentRef) + child, ok, err := findAssignmentTargetFromLedger(ledger, result.SessionID, item.AssignmentRef, item.Actor) if err != nil { return err } + if !ok { + child, ok, err = findAssignmentTargetFromMulticaHub(ctx, cli, result.RootIssueID, result.SessionID, item.AssignmentRef, item.Actor) + if err != nil { + return err + } + } if !ok { continue } - commentBody := driver.FormatMulticaProjectionComment("assignment feedback", progressCommentBody(item), []string{item.EventID}) - comment, err := cli.AddIssueComment(ctx, child, commentBody) + if !runtimeProgressMatchesCurrentMulticaScope(item, result, child) { + continue + } + comment, err := cli.AddIssueComment(ctx, child, progressProjection.CommentBody) if err != nil { return err } - if status := multicaStatusForProgress(item); status != "" { - _, _ = cli.SetIssueStatus(ctx, child, status) - } - rootStatus := "in_review" - if multicaProgressCompletesAssignment(item) { - if done, _ := allMulticaAssignmentChildrenDone(ctx, cli, result.RootIssueID, result.SessionID, child); done { - rootStatus = "done" - } + if err := s.ensureProgressIssueStatuses(ctx, cli, result, child, progressProjection.Feedback); err != nil { + return err } - _, _ = cli.SetIssueStatus(ctx, result.RootIssueID, rootStatus) if err := ledger.Record(driver.MulticaHubLedgerRecord{ Kind: driver.MulticaHubKindFeedbackCarrier, - Source: source, + Source: progressProjection.Source, Target: driver.MulticaHubLedgerTarget{ RootIssueID: result.RootIssueID, ChildIssueID: child, @@ -338,388 +347,75 @@ func (s *runtimeRPCState) writeProgressComments(ctx context.Context, cli driver. return nil } -func multicaStatusForProgress(item runtimeProgress) string { - switch strings.ToLower(strings.TrimSpace(item.FeedbackKind)) { - case "blocker": - return "blocked" - case "result": - return "done" - case "progress": - return "in_progress" - } - if strings.TrimSpace(item.Blocker) != "" { - return "blocked" - } - if strings.TrimSpace(item.Result) != "" { - return "done" - } - return "" -} - -func multicaProgressCompletesAssignment(item runtimeProgress) bool { - if strings.EqualFold(strings.TrimSpace(item.FeedbackKind), "result") { - return true - } - return strings.TrimSpace(item.Result) != "" -} - -type runtimeAssignment struct { - ID string - EventID string - IngestSeq int64 - Actor string - Assignee string - Scope string - TTL string - SignalRef string - ExpectedWork string - ExpectedFeedback string - Rationale string - ContextRefs []string - EvidenceRefs []string -} - -type runtimeProgress struct { - ID string - EventID string - IngestSeq int64 - Actor string - AssignmentRef string - Scope string - FeedbackKind string - Summary string - Result string - Blocker string - ArtifactRefs []string - EvidenceRefs []string -} - -func runtimeAssignmentItem(item map[string]any) runtimeAssignment { - id := hubItemFirstString(item, "assignment_id", "id", "declaration_id") - if id == "" { - id = hubItemString(item, "event_id") - } - return runtimeAssignment{ - ID: id, - EventID: hubItemFirstString(item, "event_id", "id", "declaration_id", "assignment_id"), - IngestSeq: hubItemInt64(item, "ingest_seq"), - Actor: hubItemString(item, "actor"), - Assignee: hubItemString(item, "assignee"), - Scope: hubItemString(item, "scope"), - TTL: hubItemString(item, "ttl"), - SignalRef: hubItemString(item, "signal_ref"), - ExpectedWork: hubItemString(item, "expected_work"), - ExpectedFeedback: hubItemString(item, "expected_feedback"), - Rationale: hubItemString(item, "rationale"), - ContextRefs: hubItemStringList(item, "context_refs"), - EvidenceRefs: hubItemStringList(item, "evidence_refs"), - } -} - -func runtimeProgressItem(item map[string]any) runtimeProgress { - id := hubItemFirstString(item, "id", "declaration_id", "event_id") - return runtimeProgress{ - ID: id, - EventID: hubItemFirstString(item, "event_id", "id", "declaration_id"), - IngestSeq: hubItemInt64(item, "ingest_seq"), - Actor: hubItemString(item, "actor"), - AssignmentRef: hubItemString(item, "assignment_ref"), - Scope: hubItemString(item, "scope"), - FeedbackKind: hubItemString(item, "feedback_kind"), - Summary: hubItemString(item, "summary"), - Result: hubItemString(item, "result"), - Blocker: hubItemString(item, "blocker"), - ArtifactRefs: hubItemStringList(item, "artifact_refs"), - EvidenceRefs: hubItemStringList(item, "evidence_refs"), - } -} - -func runtimeMulticaHubWriteEnabled(env []string) bool { - value := strings.TrimSpace(envValue(env, "MNEMON_MULTICA_HUB_WRITE")) - if value == "" { - return true - } - switch strings.ToLower(value) { - case "0", "false", "off", "disabled", "no": - return false - default: - return true - } -} - -func runtimeMulticaRegistry(env []string, cwd string) (driver.MulticaRegistry, bool, error) { - paths := []string{} - if explicit := envValue(env, "MNEMON_MULTICA_REGISTRY"); explicit != "" { - paths = append(paths, explicit) - } - if workspace := envValue(env, "MNEMON_MANAGED_WORKSPACE"); workspace != "" { - paths = append(paths, driver.MulticaRegistryPath(workspace, "")) - } - if strings.TrimSpace(cwd) != "" { - paths = append(paths, driver.MulticaRegistryPath(cwd, "")) - } - for _, path := range paths { - reg, ok, err := driver.LoadMulticaRegistry(path) - if err != nil || ok { - return reg, ok, err - } - } - return driver.MulticaRegistry{}, false, nil -} - -func runtimeMulticaHubLedgerPath(env []string, cwd string) string { - if explicit := envValue(env, "MNEMON_MULTICA_HUB_LEDGER"); explicit != "" { - return driver.MulticaHubLedgerPath("", explicit) - } - if workspace := envValue(env, "MNEMON_MANAGED_WORKSPACE"); workspace != "" { - return driver.MulticaHubLedgerPath(workspace, "") - } - return driver.MulticaHubLedgerPath(cwd, "") -} - -func assignmentMailboxDispatchMetadata(full map[string]string) map[string]string { - keys := []string{ - driver.MulticaMetadataSchemaVersion, - driver.MulticaMetadataHubBackend, - driver.MulticaMetadataKind, - driver.MulticaMetadataSessionID, - driver.MulticaMetadataCorrelationID, - driver.MulticaMetadataEventID, - driver.MulticaMetadataAssignmentID, - driver.MulticaMetadataAssignmentFingerprint, - driver.MulticaMetadataPrincipal, - driver.MulticaMetadataSourceIssueID, - driver.MulticaMetadataRootIssueID, - } - out := map[string]string{} - for _, key := range keys { - if value := strings.TrimSpace(full[key]); value != "" { - out[key] = value - } - } - return out -} - -func assignmentMailboxSupplementalMetadata(full, dispatch map[string]string) map[string]string { - out := map[string]string{} - for key, value := range full { - if _, ok := dispatch[key]; ok { - continue - } - if value = strings.TrimSpace(value); value != "" { - out[key] = value - } - } - return out -} - -func multicaParticipantForPrincipal(reg driver.MulticaRegistry, principal string) (driver.MulticaParticipantRecord, bool) { - for _, participant := range reg.Participants { - if strings.TrimSpace(participant.Principal) == strings.TrimSpace(principal) { - return participant, true +func (s *runtimeRPCState) ensureProgressIssueStatuses(ctx context.Context, cli driver.MulticaCLI, result *runtimeImportResult, child string, material multicasurface.ProgressFeedbackMaterial) error { + if status := multicasurface.ProgressIssueStatus(material); status != "" { + if err := retryMulticaHubOperation(ctx, func() error { + _, err := cli.SetIssueStatus(ctx, child, status) + return err + }); err != nil { + return fmt.Errorf("set assignment feedback issue %s status %s: %w", child, status, err) } } - return driver.MulticaParticipantRecord{}, false -} - -func hubViewItems(proj pview.View, kind string) []map[string]any { - var out []map[string]any - for _, content := range proj.Content { - if string(content.Ref.Kind) != kind { - continue - } - for _, field := range []string{"items", "entries", "declarations"} { - if raw, ok := content.Fields[field]; ok { - out = append(out, hubAnyItems(raw)...) - break - } + allDone := false + if multicasurface.ProgressCompletesAssignment(material) { + var err error + allDone, err = allMulticaAssignmentChildrenDone(ctx, cli, result.RootIssueID, result.SessionID, child) + if err != nil { + return err } } - return out -} - -func hubAnyItems(raw any) []map[string]any { - var out []map[string]any - switch v := raw.(type) { - case []any: - for _, item := range v { - if m, ok := item.(map[string]any); ok { - out = append(out, m) - } + if rootStatus := multicasurface.ProgressRootIssueStatus(material, allDone); rootStatus != "" { + if err := retryMulticaHubOperation(ctx, func() error { + _, err := cli.SetIssueStatus(ctx, result.RootIssueID, rootStatus) + return err + }); err != nil { + return fmt.Errorf("set root issue %s status %s: %w", result.RootIssueID, rootStatus, err) } - case []map[string]any: - out = append(out, v...) } - return out + return nil } -func hubItemFirstString(item map[string]any, keys ...string) string { - for _, key := range keys { - if value := hubItemString(item, key); value != "" { - return value - } - } - return "" +func runtimeAssignmentMatchesCurrentMulticaScope(item multicasurface.RuntimeAssignmentItem, result *runtimeImportResult) bool { + return multicasurface.RuntimeAssignmentMatchesScope(item, runtimeMulticaScopeMaterial(result)) } -func hubItemString(item map[string]any, key string) string { - if value, ok := item[key].(string); ok { - return strings.TrimSpace(value) - } - for _, section := range []string{eventmodel.PayloadRuleKey, eventmodel.PayloadNarrativeKey, eventmodel.PayloadRefsKey} { - if m, ok := item[section].(map[string]any); ok { - if value, ok := m[key].(string); ok { - return strings.TrimSpace(value) - } - } - } - return "" +func runtimeProgressMatchesCurrentMulticaScope(item multicasurface.RuntimeProgressItem, result *runtimeImportResult, childIssueID string) bool { + return multicasurface.RuntimeProgressMatchesScope(item, runtimeMulticaScopeMaterial(result), childIssueID) } -func hubItemInt64(item map[string]any, key string) int64 { - if value, ok := hubInt64(item[key]); ok { - return value - } - for _, section := range []string{eventmodel.PayloadRuleKey, eventmodel.PayloadNarrativeKey, eventmodel.PayloadRefsKey} { - if m, ok := item[section].(map[string]any); ok { - if value, ok := hubInt64(m[key]); ok { - return value - } - } +func runtimeMulticaScopeMaterial(result *runtimeImportResult) multicasurface.RuntimeScopeMaterial { + if result == nil { + return multicasurface.RuntimeScopeMaterial{} } - return 0 -} - -func hubInt64(raw any) (int64, bool) { - switch v := raw.(type) { - case int: - return int64(v), true - case int64: - return v, true - case int32: - return int64(v), true - case float64: - return int64(v), true - case float32: - return int64(v), true - case json.Number: - n, err := v.Int64() - return n, err == nil - case string: - n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) - return n, err == nil - default: - return 0, false + return multicasurface.RuntimeScopeMaterial{ + SessionID: result.SessionID, + RootIssueID: result.RootIssueID, + CorrelationID: result.CorrelationID, + TaskID: result.TaskID, } } -func hubItemAfterRootIngest(ingestSeq int64, result *runtimeImportResult) bool { +func runtimeItemAfterRootIngest(ingestSeq int64, result *runtimeImportResult) bool { if result == nil || result.Receipt == nil || result.Receipt.Seq <= 0 || ingestSeq <= 0 { return true } - return ingestSeq > result.Receipt.Seq + return multicasurface.RuntimeItemAfterRootIngest(ingestSeq, result.Receipt.Seq) } -func hubItemStringList(item map[string]any, key string) []string { - if out := hubStringList(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 := hubStringList(m[key]); len(out) > 0 { - return out - } - } - } - return nil -} - -func hubStringList(raw any) []string { - seen := map[string]bool{} - var out []string - add := func(value string) { - value = strings.TrimSpace(value) - if value == "" || seen[value] { - return - } - seen[value] = true - out = append(out, value) - } - switch v := raw.(type) { - case []string: - for _, item := range v { - add(item) - } - case []any: - for _, item := range v { - if value, ok := item.(string); ok { - add(value) - } - } - case string: - add(v) +func assignmentMailboxMaterial(item multicasurface.RuntimeAssignmentItem, result *runtimeImportResult, rootIssue driver.MulticaIssue, participant driver.MulticaParticipantRecord) multicasurface.AssignmentMailboxMaterial { + material := multicasurface.AssignmentMailboxRuntimeMaterial{ + Item: item, + RootIssueID: rootIssue.ID, + RootIssueIdentifier: rootIssue.Identifier, + RootIssueTitle: rootIssue.Title, + AssigneeAgentName: participant.AgentName, + AssigneeAgentID: participant.AgentID, } - return out -} - -func assignmentMailboxTitle(item runtimeAssignment) string { - scope := strings.TrimSpace(item.Scope) - if scope == "" { - scope = strings.TrimSpace(item.ID) - } - if scope == "" { - scope = "assignment" + if result != nil { + material.SessionID = result.SessionID + material.FallbackRootIssueID = result.RootIssueID } - return "Mnemon assignment " + item.ID + ": " + scope -} - -func assignmentMailboxDescription(item runtimeAssignment, result *runtimeImportResult) string { - var b strings.Builder - b.WriteString("Mnemon assignment mailbox\n\n") - writeLine := func(label, value string) { - value = strings.TrimSpace(value) - if value == "" { - return - } - b.WriteString(label) - b.WriteString(": ") - b.WriteString(value) - b.WriteString("\n") - } - writeLine("Assignment", item.ID) - writeLine("Session", result.SessionID) - writeLine("Scope", item.Scope) - writeLine("Assignee", item.Assignee) - writeLine("Expected work", item.ExpectedWork) - writeLine("Expected feedback", item.ExpectedFeedback) - writeLine("Rationale", item.Rationale) - return strings.TrimSpace(b.String()) -} - -func progressCommentBody(item runtimeProgress) string { - var b strings.Builder - writeLine := func(label, value string) { - value = strings.TrimSpace(value) - if value == "" { - return - } - b.WriteString(label) - b.WriteString(": ") - b.WriteString(value) - b.WriteString("\n") - } - writeLine("Assignment", item.AssignmentRef) - writeLine("Feedback", item.FeedbackKind) - writeLine("Summary", item.Summary) - writeLine("Result", item.Result) - writeLine("Blocker", item.Blocker) - if len(item.ArtifactRefs) > 0 { - writeLine("Artifacts", strings.Join(item.ArtifactRefs, ", ")) - } - if len(item.EvidenceRefs) > 0 { - writeLine("Evidence", strings.Join(item.EvidenceRefs, ", ")) - } - return strings.TrimSpace(b.String()) + return multicasurface.AssignmentMailboxMaterialForRuntimeItem(material) } func findExistingMulticaAssignmentIssue(ctx context.Context, cli driver.MulticaCLI, rootIssueID string, source driver.MulticaHubLedgerSource) (driver.MulticaIssue, bool, error) { @@ -732,42 +428,54 @@ func findExistingMulticaAssignmentIssue(ctx context.Context, cli driver.MulticaC func findExistingMulticaAssignmentIssueInChildren(ctx context.Context, cli driver.MulticaCLI, children []driver.MulticaIssue, source driver.MulticaHubLedgerSource) (driver.MulticaIssue, bool, error) { for _, child := range children { - meta := driver.MulticaIssueHubMetadata(child) - if !meta.IsAssignmentMailbox() { - listed, err := cli.ListIssueMetadata(ctx, child.ID) - if err != nil { - return driver.MulticaIssue{}, false, err - } - meta = driver.ParseMulticaHubMetadata(stringMapToAny(listed)) + meta, err := cli.ResolveIssueHubMetadata(ctx, child) + if err != nil { + return driver.MulticaIssue{}, false, err } if !meta.IsAssignmentMailbox() { continue } - if meta.SessionID == source.SessionID && - meta.AssignmentID == source.AssignmentID && - meta.AssignmentFingerprint == source.AssignmentFingerprint && - meta.Principal == source.Principal { + if multicasurface.AssignmentMailboxMatchesSource(meta, source) { return child, true, nil } } return driver.MulticaIssue{}, false, nil } -func findAssignmentTargetFromLedger(ledger *driver.FileMulticaHubLedger, sessionID, assignmentID string) (string, bool, error) { +func findAssignmentTargetFromLedger(ledger *driver.FileMulticaHubLedger, sessionID, assignmentID, principal string) (string, bool, error) { records, err := ledger.Records() if err != nil { return "", false, err } - for i := len(records) - 1; i >= 0; i-- { - record := records[i] - if record.Kind != driver.MulticaHubKindAssignmentMailbox { + target, ok := multicasurface.SelectAssignmentTarget( + multicasurface.AssignmentTargetCandidatesFromLedgerRecords(records), + sessionID, + assignmentID, + principal, + ) + return target, ok, nil +} + +func findAssignmentTargetFromMulticaHub(ctx context.Context, cli driver.MulticaCLI, rootIssueID, sessionID, assignmentID, principal string) (string, bool, error) { + children, err := cli.ListIssueChildren(ctx, rootIssueID) + if err != nil { + return "", false, err + } + candidates := []multicasurface.AssignmentTargetCandidate{} + for _, child := range children { + meta, err := cli.ResolveIssueHubMetadata(ctx, child) + if err != nil { + return "", false, err + } + if !meta.IsAssignmentMailbox() { continue } - if record.Source.SessionID == sessionID && record.Source.AssignmentID == assignmentID && strings.TrimSpace(record.Target.ChildIssueID) != "" { - return record.Target.ChildIssueID, true, nil + if candidate, ok := multicasurface.AssignmentTargetCandidateFromMailboxMetadata(child.ID, meta); ok { + candidates = append(candidates, candidate) } } - return "", false, nil + target, ok := multicasurface.SelectAssignmentTarget(candidates, sessionID, assignmentID, principal) + return target, ok, nil } func allMulticaAssignmentChildrenDone(ctx context.Context, cli driver.MulticaCLI, rootIssueID, sessionID, justCompletedChildID string) (bool, error) { @@ -777,13 +485,9 @@ func allMulticaAssignmentChildrenDone(ctx context.Context, cli driver.MulticaCLI } seen := false for _, child := range children { - meta := driver.MulticaIssueHubMetadata(child) - if !meta.IsAssignmentMailbox() { - listed, err := cli.ListIssueMetadata(ctx, child.ID) - if err != nil { - return false, err - } - meta = driver.ParseMulticaHubMetadata(stringMapToAny(listed)) + meta, err := cli.ResolveIssueHubMetadata(ctx, child) + if err != nil { + return false, err } if !meta.IsAssignmentMailbox() || meta.SessionID != sessionID { continue @@ -792,20 +496,10 @@ func allMulticaAssignmentChildrenDone(ctx context.Context, cli driver.MulticaCLI if child.ID == justCompletedChildID { continue } - switch strings.ToLower(strings.TrimSpace(child.Status)) { - case "done", "completed", "complete": + if multicasurface.IssueStatusDone(child.Status) { continue - default: - return false, nil } + return false, nil } return seen, nil } - -func stringMapToAny(in map[string]string) map[string]any { - out := make(map[string]any, len(in)) - for key, value := range in { - out[key] = value - } - return out -} diff --git a/harness/cmd/mnemon-multica-runtime/main.go b/harness/cmd/mnemon-multica-runtime/main.go index e8c5147b..956d1da5 100644 --- a/harness/cmd/mnemon-multica-runtime/main.go +++ b/harness/cmd/mnemon-multica-runtime/main.go @@ -3,30 +3,28 @@ package main import ( "bufio" "context" - "crypto/sha256" "encoding/json" "flag" "fmt" "io" "os" - "path/filepath" - "regexp" "runtime" "strings" "time" "unicode" "github.com/mnemon-dev/mnemon/harness/internal/contract" + "github.com/mnemon-dev/mnemon/harness/internal/drive" "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/access" "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation" + "github.com/mnemon-dev/mnemon/harness/internal/projection" + multicasurface "github.com/mnemon-dev/mnemon/harness/internal/surface/multica" ) const runtimeVersion = "dev" -var assignedIssuePattern = regexp.MustCompile(`(?i)(?:assigned\s+issue\s+id\s+is|issue[_\s-]*id)\s*[::]\s*([A-Za-z0-9][A-Za-z0-9._:-]*)`) - type runtimeConfig struct { Args []string Env []string @@ -134,10 +132,18 @@ func runRuntime(cfg runtimeConfig) error { } } if runtimeProbeModeEnabled(cfg.Args) { + if wantsRuntimeHelp(cfg.Args) { + writeRuntimeHelp(cfg.Stdout) + return nil + } return runRuntimeProbe(cfg) } if wantsVersion(cfg.Args) { - fmt.Fprintf(cfg.Stdout, "mnemon-multica-runtime %s\n", runtimeVersion) + fmt.Fprintf(cfg.Stdout, "%s %s\n", multicasurface.MulticaRuntimeCommandName, runtimeVersion) + return nil + } + if wantsRuntimeHelp(cfg.Args) { + writeRuntimeHelp(cfg.Stdout) return nil } return runRuntimeRPC(cfg, cwd) @@ -154,7 +160,7 @@ func runRuntimeRPC(cfg runtimeConfig, cwd string) error { } var msg rpcMessage if err := json.Unmarshal([]byte(line), &msg); err != nil { - fmt.Fprintf(cfg.Stderr, "mnemon-multica-runtime: ignoring invalid rpc line: %v\n", err) + fmt.Fprintf(cfg.Stderr, "%s: ignoring invalid rpc line: %v\n", multicasurface.MulticaRuntimeCommandName, err) continue } if err := state.handle(msg, func(response rpcMessage) error { @@ -180,8 +186,8 @@ func (s *runtimeRPCState) handle(msg rpcMessage, emit func(rpcMessage) error) er return emitAll(rpcMessage{ ID: msg.ID, Result: map[string]any{ - "userAgent": "mnemon-multica-runtime/" + runtimeVersion, - "codexHome": envValue(s.Env, "CODEX_HOME"), + "userAgent": multicasurface.MulticaRuntimeCommandName + "/" + runtimeVersion, + "codexHome": multicasurface.RuntimeEnvValue(s.Env, "CODEX_HOME"), "platformFamily": "unix", "platformOs": runtime.GOOS, }, @@ -195,8 +201,8 @@ func (s *runtimeRPCState) handle(msg rpcMessage, emit func(rpcMessage) error) er Method: "remoteControl/status/changed", Params: map[string]any{ "status": "disabled", - "serverName": "mnemon-multica-runtime", - "installationId": "mnemon-multica-runtime", + "serverName": multicasurface.MulticaRuntimeCommandName, + "installationId": multicasurface.MulticaRuntimeCommandName, }, }, rpcMessage{ @@ -214,9 +220,9 @@ func (s *runtimeRPCState) handle(msg rpcMessage, emit func(rpcMessage) error) er return emitAll(rpcMessage{ID: msg.ID, Result: map[string]any{}}) case "turn/start": s.TurnID = runtimeID("turn", s.now()) - input := extractRuntimeInput(msg.Params) + input := multicasurface.RuntimeInputMaterial(msg.Params) nowMs := s.now().UnixMilli() - userItem := runtimeUserMessage(input) + userItem := multicasurface.RuntimeUserMessage(input.Text) if err := emitAll( rpcMessage{ ID: msg.ID, @@ -234,11 +240,11 @@ func (s *runtimeRPCState) handle(msg rpcMessage, emit func(rpcMessage) error) er }, rpcMessage{ Method: "item/started", - Params: runtimeItemParams(s.ThreadID, s.TurnID, userItem, "startedAtMs", nowMs), + Params: multicasurface.RuntimeItemParams(s.ThreadID, s.TurnID, userItem, "startedAtMs", nowMs), }, rpcMessage{ Method: "item/completed", - Params: runtimeItemParams(s.ThreadID, s.TurnID, userItem, "completedAtMs", nowMs), + Params: multicasurface.RuntimeItemParams(s.ThreadID, s.TurnID, userItem, "completedAtMs", nowMs), }, ); err != nil { return err @@ -249,23 +255,29 @@ func (s *runtimeRPCState) handle(msg rpcMessage, emit func(rpcMessage) error) er return } if event.Trace != nil { - progressErr = emitRuntimeManagedTraceEvent(emit, s.ThreadID, s.TurnID, *event.Trace, s.now()) + progressErr = emitRuntimeMessages(emit, multicasurface.RuntimeManagedTraceMessages(s.ThreadID, s.TurnID, *event.Trace, s.now())) return } if strings.TrimSpace(event.Command) != "" { - progressErr = emitRuntimeCommandExecution(emit, s.ThreadID, s.TurnID, s.nextItemID("call"), s.CWD, event, s.now()) + progressErr = emitRuntimeMessages(emit, multicasurface.RuntimeCommandExecutionMessages(s.ThreadID, s.TurnID, s.nextItemID("call"), s.CWD, multicasurface.RuntimeCommandExecutionMaterial{ + Command: event.Command, + CWD: event.CWD, + Output: event.Output, + ExitCode: event.ExitCode, + DurationMs: event.DurationMs, + }, s.now())) return } text := strings.TrimSpace(event.Text) if text != "" { - progressErr = emitRuntimeAgentMessage(emit, s.ThreadID, s.TurnID, s.nextItemID("msg"), text, "commentary", s.now()) + progressErr = emitRuntimeMessages(emit, multicasurface.RuntimeAgentMessageMessages(s.ThreadID, s.TurnID, s.nextItemID("msg"), text, "commentary", s.now())) } } finalAnswer := s.runTurn(input, progress) if progressErr != nil { return progressErr } - if err := emitRuntimeAgentMessage(emit, s.ThreadID, s.TurnID, s.nextItemID("msg"), finalAnswer, "final_answer", s.now()); err != nil { + if err := emitRuntimeMessages(emit, multicasurface.RuntimeAgentMessageMessages(s.ThreadID, s.TurnID, s.nextItemID("msg"), finalAnswer, "final_answer", s.now())); err != nil { return err } return emitAll( @@ -294,27 +306,28 @@ func (s *runtimeRPCState) nextItemID(prefix string) string { return fmt.Sprintf("%s-%d-%d", prefix, s.now().UTC().UnixNano(), s.ItemSeq) } -func (s *runtimeRPCState) runTurn(input string, progress runtimeProgressSink) string { +func (s *runtimeRPCState) runTurn(input multicasurface.RuntimeInput, progress runtimeProgressSink) string { result := s.importIssue(input, progress) - return formatRuntimeFinalAnswer(result) + return multicasurface.FormatRuntimeFinalAnswer(runtimeResultSummary(result)) } -func (s *runtimeRPCState) importIssue(input string, progress runtimeProgressSink) runtimeImportResult { - taskID := envValue(s.Env, "MULTICA_TASK_ID") - issueID := firstNonEmpty(envValue(s.Env, "MULTICA_ISSUE_ID"), extractAssignedIssueID(input)) +func (s *runtimeRPCState) importIssue(input multicasurface.RuntimeInput, progress runtimeProgressSink) runtimeImportResult { + activation := multicasurface.RuntimeContextFromActivation(s.Env, s.CWD, input) + taskID := activation.TaskID + issueID := activation.IssueIdentity result := runtimeImportResult{ IssueID: issueID, - Principal: resolveRuntimePrincipal(s.Env, s.CWD), + Principal: resolveRuntimePrincipalFromContext(s.Env, s.CWD, activation), TaskID: taskID, } - emitRuntimeProgress(progress, "Mnemon runtime accepted the Multica task for "+displayRuntimePrincipal(result.Principal)+".") + emitRuntimeProgress(progress, "Mnemon runtime accepted the Multica task for "+multicasurface.RuntimePrincipalLabel(result.Principal)+".") if issueID == "" { result.Status = "skipped" result.Err = fmt.Errorf("no Multica issue id was available in task environment or runtime input") emitRuntimeProgress(progress, "No assigned Multica issue id was available; Mnemon skipped this turn.") return result } - cli := runtimeMulticaCLI(s.Env) + cli := runtimeMulticaCLI(s.Env, activation) multicaCtx := context.Background() emitRuntimeProgress(progress, "Loading Multica issue "+issueID+".") issue, err := cli.GetIssue(multicaCtx, issueID) @@ -326,36 +339,42 @@ func (s *runtimeRPCState) importIssue(input string, progress runtimeProgressSink return result } emitRuntimeCommand(progress, "multica issue get "+issueID, "Loaded "+runtimeIssueLabel(issue)+".", 0) + issue = loadRuntimeIssueMetadata(multicaCtx, cli, issue, progress) result.IssueID = issue.ID result.Identifier = issue.Identifier result.Title = issue.Title result.Statement = issue.Description result.HubMetadata = driver.MulticaIssueHubMetadata(issue) - applyMulticaHubMetadata(&result, issue) - result.HubBackend = firstNonEmpty(result.HubBackend, envValue(s.Env, "MNEMON_HUB_BACKEND")) + applyMulticaHubMetadata(&result, result.HubMetadata) + result.HubBackend = firstNonEmpty(result.HubBackend, activation.HubBackend) emitRuntimeProgress(progress, "Loaded "+runtimeIssueLabel(issue)+"; classifying Mnemon hub metadata.") markIssueInProgress(multicaCtx, cli, issue.ID) if result.HubMetadata.IsAssignmentMailbox() { - return s.correlateAssignmentMailbox(multicaCtx, cli, issue, &result, progress) + return s.correlateAssignmentMailbox(multicaCtx, cli, issue, &result, activation, progress) } externalID := "" if taskID != "" { externalID = "multica-task-" + taskID } - draft, err := driver.BuildMulticaIssueTeamworkSignal(issue, driver.MulticaIssueSignalOptions{ - Scope: envDefault(s.Env, "MNEMON_MULTICA_SCOPE", "multica/teamwork"), - TTL: envDefault(s.Env, "MNEMON_MULTICA_TTL", "30m"), + draft, err := multicasurface.BuildIssueTeamworkSignal(multicasurface.IssueSignalMaterial{ + ID: issue.ID, + Identifier: issue.Identifier, + Title: issue.Title, + Description: issue.Description, + }, multicasurface.IssueSignalOptions{ + Scope: multicasurface.RuntimeEnvDefault(s.Env, "MNEMON_MULTICA_SCOPE", "multica/teamwork"), + TTL: multicasurface.RuntimeEnvDefault(s.Env, "MNEMON_MULTICA_TTL", "30m"), WhyTeamwork: "Multica assigned this issue to a Mnemon participant, so Mnemon should admit it through the teamwork protocol.", - WorkspaceID: firstNonEmpty(envValue(s.Env, "MNEMON_MULTICA_WORKSPACE_ID"), envValue(s.Env, "MULTICA_WORKSPACE_ID")), + WorkspaceID: activation.WorkspaceID, TaskID: taskID, - AgentID: envValue(s.Env, "MULTICA_AGENT_ID"), + AgentID: activation.AgentID, Principal: result.Principal, ContextRefs: []string{ - runtimeRef("issue", issue.ID), - runtimeRef("task", taskID), - runtimeRef("agent", envValue(s.Env, "MULTICA_AGENT_ID")), + multicasurface.RuntimeRef("issue", issue.ID), + multicasurface.RuntimeRef("task", taskID), + multicasurface.RuntimeRef("agent", activation.AgentID), }, - EvidenceRefs: []string{runtimeRef("issue", issue.ID)}, + EvidenceRefs: []string{multicasurface.RuntimeRef("issue", issue.ID)}, ExternalID: externalID, }) if err != nil { @@ -364,22 +383,32 @@ func (s *runtimeRPCState) importIssue(input string, progress runtimeProgressSink return result } if strings.EqualFold(result.HubBackend, driver.MulticaHubBackend) { - ensureRootSessionHubFields(&result, issue) + result.HubMetadata = multicasurface.RootSessionHubMetadata(result.HubMetadata, issue.ID) + applyMulticaHubMetadata(&result, result.HubMetadata) addPayloadRuleString(draft.Payload, "hub_backend", driver.MulticaHubBackend) addPayloadRuleString(draft.Payload, "root_issue_id", result.RootIssueID) addPayloadRuleString(draft.Payload, "session_id", result.SessionID) addPayloadRuleString(draft.Payload, "source_issue_id", issue.ID) - if err := cli.SetIssueMetadataMap(multicaCtx, issue.ID, rootSessionMetadata(result, draft, s.now())); err != nil { - result.Status = "failed" - result.Err = fmt.Errorf("set Multica root session metadata: %w", err) - emitRuntimeCommand(progress, "multica issue metadata set "+issue.ID+" mnemon.root-session", result.Err.Error(), 1) - emitRuntimeProgress(progress, "Failed to write Mnemon root session metadata.") - return result + rootSessionMaterial := multicasurface.RootSessionMetadataMaterial{ + HubMetadata: result.HubMetadata, + EventID: draft.ExternalID, + EventType: draft.EventType, + EventPhase: string(eventmodel.PhaseObserved), + Principal: result.Principal, + SourceIssueID: result.IssueID, + ProjectionOwner: result.Principal, + ProjectedAt: s.now(), + } + if err := cli.SetIssueMetadataMap(multicaCtx, issue.ID, multicasurface.RootSessionMetadataMap(rootSessionMaterial)); err != nil { + metadataErr := fmt.Errorf("set Multica root session metadata: %w", err) + emitRuntimeCommand(progress, "multica issue metadata set "+issue.ID+" mnemon.root-session", metadataErr.Error(), 1) + emitRuntimeProgress(progress, "Root session metadata write failed; continuing with Mnemon ingest from issue context.") + } else { + emitRuntimeCommand(progress, "multica issue metadata set "+issue.ID+" mnemon.root-session", "Root session metadata written for "+runtimeIssueLabel(issue)+".", 0) + emitRuntimeProgress(progress, "Root session metadata written for "+runtimeIssueLabel(issue)+".") } - emitRuntimeCommand(progress, "multica issue metadata set "+issue.ID+" mnemon.root-session", "Root session metadata written for "+runtimeIssueLabel(issue)+".", 0) - emitRuntimeProgress(progress, "Root session metadata written for "+runtimeIssueLabel(issue)+".") } - addr := strings.TrimSpace(envValue(s.Env, "MNEMON_CONTROL_ADDR")) + addr := strings.TrimSpace(activation.ControlAddr) if addr == "" { result.Status = "skipped" emitRuntimeProgress(progress, "Local Mnemon control address is not configured; skipping protocol ingest.") @@ -414,30 +443,46 @@ func (s *runtimeRPCState) importIssue(input string, progress runtimeProgressSink emitRuntimeProgress(progress, fmt.Sprintf("Mnemon recorded the issue observation at seq=%d.", rec.Seq)) emitRuntimeProgress(progress, "Waking the managed local agent with [mnemon:wake].") earlyHubDeltas := s.wakeManagedAgentWithHubProjection(multicaCtx, cli, client, issue, &result, progress) - emitRuntimeCommand(progress, "mnemond managed wake --principal "+result.Principal+" [mnemon:wake]", runtimeWakeProgress(result), runtimeExitCode(result.WakeErr)) - emitRuntimeProgress(progress, runtimeWakeProgress(result)) + emitRuntimeCommand(progress, "mnemond managed wake --principal "+result.Principal+" [mnemon:wake]", multicasurface.RuntimeWakeProgress(runtimeResultSummary(result)), runtimeExitCode(result.WakeErr)) + emitRuntimeProgress(progress, multicasurface.RuntimeWakeProgress(runtimeResultSummary(result))) s.writeMulticaHubArtifacts(multicaCtx, cli, client, issue, &result) mergeRuntimeHubProjectionDeltas(&result, earlyHubDeltas) - emitRuntimeCommand(progress, "mnemon multica hub project --issue "+issue.ID, runtimeHubWriteProgress(result), runtimeExitCode(result.HubWriteErr)) - emitRuntimeProgress(progress, runtimeHubWriteProgress(result)) - s.projectImportComment(multicaCtx, cli, issue, draft.ExternalID, &result) - emitRuntimeCommand(progress, "multica issue comment add "+issue.ID, runtimeProjectionProgress(result), runtimeExitCode(result.ProjectionErr)) - emitRuntimeProgress(progress, runtimeProjectionProgress(result)) + emitRuntimeCommand(progress, "mnemon-multica-runtime hub-write --issue "+issue.ID, multicasurface.RuntimeHubWriteProgress(runtimeResultSummary(result)), runtimeExitCode(result.HubWriteErr)) + emitRuntimeProgress(progress, multicasurface.RuntimeHubWriteProgress(runtimeResultSummary(result))) + s.projectImportComment(multicaCtx, cli, issue, draft.ExternalID, draft.EventType, &result) + emitRuntimeCommand(progress, "multica issue comment add "+issue.ID, multicasurface.RuntimeProjectionProgress(runtimeResultSummary(result)), runtimeExitCode(result.ProjectionErr)) + emitRuntimeProgress(progress, multicasurface.RuntimeProjectionProgress(runtimeResultSummary(result))) return result } -func (s *runtimeRPCState) correlateAssignmentMailbox(ctx context.Context, cli driver.MulticaCLI, issue driver.MulticaIssue, result *runtimeImportResult, progress runtimeProgressSink) runtimeImportResult { - ensureAssignmentHubFields(result, issue) +func loadRuntimeIssueMetadata(ctx context.Context, cli driver.MulticaCLI, issue driver.MulticaIssue, progress runtimeProgressSink) driver.MulticaIssue { + if strings.TrimSpace(issue.ID) == "" { + return issue + } + loaded, count, err := cli.LoadIssueMetadata(ctx, issue) + if err != nil { + emitRuntimeCommand(progress, "multica issue metadata list "+issue.ID, err.Error(), 1) + emitRuntimeProgress(progress, "Multica issue metadata list failed; falling back to metadata returned by issue get.") + return issue + } + emitRuntimeCommand(progress, "multica issue metadata list "+issue.ID, fmt.Sprintf("Loaded %d Multica issue metadata keys.", count), 0) + return loaded +} + +func (s *runtimeRPCState) correlateAssignmentMailbox(ctx context.Context, cli driver.MulticaCLI, issue driver.MulticaIssue, result *runtimeImportResult, activation multicasurface.RuntimeContext, progress runtimeProgressSink) runtimeImportResult { + result.HubMetadata = multicasurface.AssignmentMailboxHubMetadata(result.HubMetadata, issue.ID) + applyMulticaHubMetadata(result, result.HubMetadata) result.Status = "correlated" - result.MatchTerms = cleanRuntimeTerms( + result.MatchTerms = drive.CleanManagedWakeMatchTerms( result.AssignmentID, result.AssignmentFingerprint, result.HubMetadata.EventID, string(eventmodel.Subject("assignment", result.AssignmentID)), ) - emitRuntimeCommand(progress, "mnemon multica assignment correlate --issue "+issue.ID, "Assignment mailbox correlated: "+runtimeAssignmentLabel(*result)+".", 0) - emitRuntimeProgress(progress, "Assignment mailbox correlated: "+runtimeAssignmentLabel(*result)+".") - addr := strings.TrimSpace(envValue(s.Env, "MNEMON_CONTROL_ADDR")) + correlationProgress := multicasurface.RuntimeAssignmentCorrelationProgress() + emitRuntimeCommand(progress, "mnemon-multica-runtime assignment-correlate --issue "+issue.ID, correlationProgress, 0) + emitRuntimeProgress(progress, correlationProgress) + addr := strings.TrimSpace(activation.ControlAddr) var client *access.Client if addr == "" { result.WakeStatus = "skipped" @@ -453,24 +498,23 @@ func (s *runtimeRPCState) correlateAssignmentMailbox(ctx context.Context, cli dr } else { emitRuntimeProgress(progress, "Waking assigned local agent with [mnemon:wake].") s.wakeManagedAgent(result, progress) - emitRuntimeCommand(progress, "mnemond managed wake --principal "+result.Principal+" [mnemon:wake]", runtimeWakeProgress(*result), runtimeExitCode(result.WakeErr)) - emitRuntimeProgress(progress, runtimeWakeProgress(*result)) + emitRuntimeCommand(progress, "mnemond managed wake --principal "+result.Principal+" [mnemon:wake]", multicasurface.RuntimeWakeProgress(runtimeResultSummary(*result)), runtimeExitCode(result.WakeErr)) + emitRuntimeProgress(progress, multicasurface.RuntimeWakeProgress(runtimeResultSummary(*result))) s.writeMulticaHubArtifacts(ctx, cli, client, issue, result) - emitRuntimeCommand(progress, "mnemon multica hub project --issue "+issue.ID, runtimeHubWriteProgress(*result), runtimeExitCode(result.HubWriteErr)) - emitRuntimeProgress(progress, runtimeHubWriteProgress(*result)) + emitRuntimeCommand(progress, "mnemon-multica-runtime hub-write --issue "+issue.ID, multicasurface.RuntimeHubWriteProgress(runtimeResultSummary(*result)), runtimeExitCode(result.HubWriteErr)) + emitRuntimeProgress(progress, multicasurface.RuntimeHubWriteProgress(runtimeResultSummary(*result))) } } - s.projectImportComment(ctx, cli, issue, assignmentMailboxMarker(*result), result) - emitRuntimeCommand(progress, "multica issue comment add "+issue.ID, runtimeProjectionProgress(*result), runtimeExitCode(result.ProjectionErr)) - emitRuntimeProgress(progress, runtimeProjectionProgress(*result)) + s.projectImportComment(ctx, cli, issue, multicasurface.AssignmentMailboxMarker(result.HubMetadata, result.IssueID), result.HubMetadata.EventType, result) + emitRuntimeCommand(progress, "multica issue comment add "+issue.ID, multicasurface.RuntimeProjectionProgress(runtimeResultSummary(*result)), runtimeExitCode(result.ProjectionErr)) + emitRuntimeProgress(progress, multicasurface.RuntimeProjectionProgress(runtimeResultSummary(*result))) return *result } -func applyMulticaHubMetadata(result *runtimeImportResult, issue driver.MulticaIssue) { +func applyMulticaHubMetadata(result *runtimeImportResult, meta driver.MulticaHubMetadata) { if result == nil { return } - meta := result.HubMetadata result.HubBackend = firstNonEmpty(meta.HubBackend, result.HubBackend) result.HubKind = firstNonEmpty(meta.Kind, result.HubKind) result.SessionID = firstNonEmpty(meta.SessionID, result.SessionID) @@ -478,50 +522,6 @@ func applyMulticaHubMetadata(result *runtimeImportResult, issue driver.MulticaIs result.RootIssueID = firstNonEmpty(meta.RootIssueID, result.RootIssueID) result.AssignmentID = firstNonEmpty(meta.AssignmentID, result.AssignmentID) result.AssignmentFingerprint = firstNonEmpty(meta.AssignmentFingerprint, result.AssignmentFingerprint) - if result.RootIssueID == "" && meta.IsAssignmentMailbox() { - result.RootIssueID = firstNonEmpty(meta.SourceIssueID, issue.ID) - } -} - -func ensureRootSessionHubFields(result *runtimeImportResult, issue driver.MulticaIssue) { - if result == nil { - return - } - result.HubBackend = driver.MulticaHubBackend - result.HubKind = firstNonEmpty(result.HubKind, driver.MulticaHubKindSession) - result.RootIssueID = firstNonEmpty(result.RootIssueID, issue.ID) - result.SessionID = firstNonEmpty(result.SessionID, driver.MulticaSessionID(result.RootIssueID)) - result.CorrelationID = firstNonEmpty(result.CorrelationID, "multica:issue:"+issue.ID) -} - -func ensureAssignmentHubFields(result *runtimeImportResult, issue driver.MulticaIssue) { - if result == nil { - return - } - result.HubBackend = firstNonEmpty(result.HubBackend, driver.MulticaHubBackend) - result.HubKind = firstNonEmpty(result.HubKind, driver.MulticaHubKindAssignmentMailbox) - result.RootIssueID = firstNonEmpty(result.RootIssueID, result.HubMetadata.SourceIssueID) - result.SessionID = firstNonEmpty(result.SessionID, driver.MulticaSessionID(result.RootIssueID)) - result.CorrelationID = firstNonEmpty(result.CorrelationID, "multica:issue:"+issue.ID) -} - -func rootSessionMetadata(result runtimeImportResult, draft driver.MulticaObservedDraft, now time.Time) map[string]string { - meta := driver.MulticaHubMetadata{ - SchemaVersion: "1", - HubBackend: driver.MulticaHubBackend, - Kind: driver.MulticaHubKindSession, - SessionID: result.SessionID, - CorrelationID: result.CorrelationID, - EventID: draft.ExternalID, - EventType: draft.EventType, - EventPhase: string(eventmodel.PhaseObserved), - Principal: result.Principal, - SourceIssueID: result.IssueID, - RootIssueID: result.RootIssueID, - ProjectionOwner: result.Principal, - ProjectedAt: now.UTC().Format(time.RFC3339), - } - return meta.Map() } func addPayloadRuleString(payload map[string]any, key, value string) { @@ -538,40 +538,38 @@ func addPayloadRuleString(payload map[string]any, key, value string) { rule[key] = value } -func cleanRuntimeTerms(values ...string) []string { - seen := map[string]bool{} - var out []string - for _, value := range values { - value = strings.TrimSpace(value) - if len(value) < 3 || seen[value] { - continue - } - seen[value] = true - out = append(out, value) +func runtimeManagedWakeMatchMaterial(result runtimeImportResult) drive.ManagedWakeMatchMaterial { + return drive.ManagedWakeMatchMaterial{ + MatchTerms: result.MatchTerms, + AssignmentID: result.AssignmentID, + AssignmentFingerprint: result.AssignmentFingerprint, + IssueID: result.IssueID, + Identifier: result.Identifier, + Title: result.Title, + Statement: result.Statement, + TaskID: result.TaskID, } - return out } -func assignmentMailboxMarker(result runtimeImportResult) string { - if result.HubMetadata.EventID != "" { - return result.HubMetadata.EventID - } - if result.AssignmentID != "" { - return "multica-assignment-" + result.AssignmentID +func runtimeManagedWakeMaterial(result runtimeImportResult) multicasurface.RuntimeManagedWakeMaterial { + return multicasurface.RuntimeManagedWakeMaterial{ + IssueID: result.IssueID, + RootIssueID: result.RootIssueID, + AssignmentID: result.AssignmentID, + SessionID: result.SessionID, } - return "multica-issue-" + result.IssueID } func (s *runtimeRPCState) wakeManagedAgent(result *runtimeImportResult, progress runtimeProgressSink) { if result == nil { return } - runtimeName := strings.TrimSpace(envValue(s.Env, "MNEMON_MANAGED_RUNTIME")) + runtimeName := strings.TrimSpace(multicasurface.RuntimeEnvValue(s.Env, "MNEMON_MANAGED_RUNTIME")) if runtimeName == "" || strings.EqualFold(runtimeName, "off") || strings.EqualFold(runtimeName, "disabled") || strings.EqualFold(runtimeName, "none") { result.WakeStatus = "skipped" return } - addr := strings.TrimSpace(envValue(s.Env, "MNEMON_CONTROL_ADDR")) + addr := strings.TrimSpace(multicasurface.RuntimeEnvValue(s.Env, "MNEMON_CONTROL_ADDR")) if addr == "" { result.WakeStatus = "skipped" result.WakeErr = fmt.Errorf("MNEMON_CONTROL_ADDR is not set") @@ -583,7 +581,7 @@ func (s *runtimeRPCState) wakeManagedAgent(result *runtimeImportResult, progress result.WakeErr = err return } - renderCtx, cancel := context.WithTimeout(context.Background(), runtimeTimeout(s.Env)) + renderCtx, cancel := context.WithTimeout(context.Background(), multicasurface.RuntimeTimeout(s.Env)) defer cancel() resp, err := (driver.HTTPRenderClient{ BaseURL: addr, @@ -593,33 +591,36 @@ func (s *runtimeRPCState) wakeManagedAgent(result *runtimeImportResult, progress SchemaVersion: 1, Principal: contract.ActorID(result.Principal), Host: "multica", - Lifecycle: envDefault(s.Env, "MNEMON_MANAGED_RENDER_LIFECYCLE", "remind"), + Lifecycle: multicasurface.RuntimeEnvDefault(s.Env, "MNEMON_MANAGED_RENDER_LIFECYCLE", "remind"), Surface: "runtime", RenderIntent: presentation.IntentTeamworkEvents, + SessionID: result.SessionID, + InputDigest: multicasurface.RuntimeManagedWakeScopeID(runtimeManagedWakeMaterial(*result)), }) if err != nil { result.WakeStatus = "failed" result.WakeErr = fmt.Errorf("render managed wake candidates: %w", err) return } - candidate, ok := managedWakeCandidateForResult(result.Principal, resp, *result) + candidate, ok := drive.ManagedWakeCandidateForRender(result.Principal, resp, runtimeManagedWakeMatchMaterial(*result)) if !ok { result.WakeStatus = "skipped" result.WakeErr = fmt.Errorf("no managed wake candidate in rendered context") return } - client, workspace, err := runtimeManagedTurnClient(s.Env, s.CWD, runtimeName) + managedEnv := multicasurface.RuntimeManagedTurnEnv(s.Env, runtimeManagedWakeMaterial(*result)) + client, workspace, err := runtimeManagedTurnClient(managedEnv, s.CWD, runtimeName) if err != nil { result.WakeStatus = "failed" result.WakeErr = err return } - wakeCtx, cancel := context.WithTimeout(context.Background(), runtimeManagedTurnTimeout(s.Env)) + wakeCtx, cancel := context.WithTimeout(context.Background(), multicasurface.RuntimeManagedTurnTimeout(s.Env)) defer cancel() record, err := (&driver.ManagedAgentDriver{ Principal: result.Principal, Client: client, - Ledger: driver.NewFileManagedWakeLedger(runtimeManagedLedgerPath(s.Env, workspace)), + Ledger: driver.NewFileManagedWakeLedger(multicasurface.RuntimeManagedLedgerPath(s.Env, workspace)), TraceSink: driver.ManagedTurnTraceSinkFunc(func(event driver.ManagedTurnTraceEvent) { if progress != nil { progress(runtimeProgressEvent{Trace: &event}) @@ -647,14 +648,14 @@ type runtimeHubProjectionDelta struct { } func (d runtimeHubProjectionDelta) active() bool { - return d.ChildIssues > 0 || d.FeedbackComments > 0 + return d.ChildIssues > 0 || d.FeedbackComments > 0 || d.Err != nil } func (s *runtimeRPCState) wakeManagedAgentWithHubProjection(ctx context.Context, cli driver.MulticaCLI, client *access.Client, rootIssue driver.MulticaIssue, result *runtimeImportResult, progress runtimeProgressSink) []runtimeHubProjectionDelta { if result == nil || client == nil || !strings.EqualFold(result.HubBackend, driver.MulticaHubBackend) || result.HubKind != driver.MulticaHubKindSession || - !runtimeMulticaHubWriteEnabled(s.Env) { + !multicasurface.RuntimeHubWriteEnabled(s.Env) { s.wakeManagedAgent(result, progress) return nil } @@ -662,7 +663,7 @@ func (s *runtimeRPCState) wakeManagedAgentWithHubProjection(ctx context.Context, deltas := make(chan runtimeHubProjectionDelta, 16) go func(snapshot runtimeImportResult) { defer close(deltas) - ticker := time.NewTicker(runtimeHubProjectionInterval(s.Env)) + ticker := time.NewTicker(multicasurface.RuntimeHubProjectionInterval(s.Env)) defer ticker.Stop() for { select { @@ -724,106 +725,26 @@ func mergeRuntimeHubProjectionDeltas(result *runtimeImportResult, deltas []runti } } -func managedWakeCandidateForResult(principal string, resp presentation.Response, result runtimeImportResult) (driver.ManagedWakeCandidate, bool) { - terms := managedWakeMatchTerms(result) - var fallback driver.ManagedWakeCandidate - for _, env := range resp.Events { - candidates := driver.ManagedWakeCandidatesFromEvents(principal, []eventmodel.EventEnvelope{env}) - if len(candidates) == 0 { - continue - } - candidates[0].RenderAuditID = resp.AuditID - candidates[0].RenderBodyDigest = resp.BodyDigest - if fallback.Principal == "" { - fallback = candidates[0] - } - if len(terms) == 0 || eventNarrativeContainsAny(env, terms) { - return candidates[0], true - } - } - if len(terms) == 0 && fallback.Principal != "" { - return fallback, true - } - return driver.ManagedWakeCandidate{}, false -} - -func managedWakeMatchTerms(result runtimeImportResult) []string { - if len(result.MatchTerms) > 0 { - return cleanRuntimeTerms(result.MatchTerms...) - } - if result.AssignmentID != "" || result.AssignmentFingerprint != "" { - return cleanRuntimeTerms(result.AssignmentID, result.AssignmentFingerprint) - } - raw := []string{result.IssueID, result.Identifier, result.Title, result.TaskID} - var out []string - for _, value := range raw { - value = strings.TrimSpace(value) - if len(value) >= 3 { - out = append(out, value) - } - } - if len(out) > 0 { - return out - } - if value := strings.TrimSpace(result.Statement); len(value) >= 3 { - out = append(out, value) - } - return out -} - -func eventNarrativeContainsAny(env eventmodel.EventEnvelope, terms []string) bool { - body, _ := eventmodel.PayloadNarrative(env.Event.Payload)["body"].(string) - body = strings.ToLower(strings.Join([]string{body, string(env.Event.Subject), env.Event.ID, env.Event.Type}, "\n")) - for _, term := range terms { - if strings.Contains(body, strings.ToLower(term)) { - return true - } - } - return false -} - -func (s *runtimeRPCState) projectImportComment(ctx context.Context, cli driver.MulticaCLI, issue driver.MulticaIssue, externalID string, result *runtimeImportResult) { +func (s *runtimeRPCState) projectImportComment(ctx context.Context, cli driver.MulticaCLI, issue driver.MulticaIssue, externalID, eventType string, result *runtimeImportResult) { if result == nil { return } - if !runtimeProjectionEnabled(s.Env) { + if !multicasurface.RuntimeProjectionCommentsEnabled(s.Env) { result.ProjectionStatus = "skipped" return } title := "issue admitted" - body := "Issue admitted into Mnemon teamwork." if result.HubKind == driver.MulticaHubKindAssignmentMailbox || result.Status == "correlated" { title = "assignment mailbox correlated" - body = "Assignment mailbox correlated with Mnemon teamwork." } - if result.Principal != "" { - body += "\nPrincipal: " + result.Principal - } - if result.TaskID != "" { - body += "\nMultica task: " + result.TaskID - } - if result.HubBackend != "" { - body += "\nHub backend: " + result.HubBackend - } - if result.SessionID != "" { - body += "\nSession: " + result.SessionID - } - if result.RootIssueID != "" { - body += "\nRoot issue: " + result.RootIssueID - } - if result.AssignmentID != "" { - body += "\nAssignment: " + result.AssignmentID - } - if result.Receipt != nil { - body += fmt.Sprintf("\nMnemon ingest: seq=%d duplicate=%v ticked=%v", result.Receipt.Seq, result.Receipt.Dup, result.Receipt.Ticked) - } - if result.WakeStatus != "" { - body += "\nManaged wake: " + result.WakeStatus - } - if result.HubWriteStatus != "" { - body += fmt.Sprintf("\nMultica hub write: %s child_issues=%d feedback_comments=%d", result.HubWriteStatus, result.HubChildIssues, result.HubFeedbackComments) - } - commentBody := driver.FormatMulticaProjectionComment(title, body, []string{externalID}) + commentBody := projection.FormatComment(projection.CommentMaterial{ + Title: title, + Body: multicasurface.RuntimeProjectionCommentBody(runtimeProjectionMaterial(issue, *result)), + EventIDs: []string{externalID}, + EventType: eventType, + SessionID: result.SessionID, + AssignmentID: result.AssignmentID, + }) comment, err := cli.AddIssueComment(ctx, issue.ID, commentBody) if err != nil { result.ProjectionStatus = "failed" @@ -834,14 +755,42 @@ func (s *runtimeRPCState) projectImportComment(ctx context.Context, cli driver.M result.ProjectionCommentID = comment.ID } -func runtimeMulticaCLI(env []string) driver.MulticaCLI { +func runtimeProjectionMaterial(issue driver.MulticaIssue, result runtimeImportResult) multicasurface.RuntimeProjectionMaterial { + material := multicasurface.RuntimeProjectionMaterial{ + AssignmentMailbox: result.HubKind == driver.MulticaHubKindAssignmentMailbox || result.Status == "correlated", + Status: result.Status, + IssueID: issue.ID, + IssueLabel: firstNonEmpty(issue.Identifier, issue.ID), + Principal: result.Principal, + TaskID: result.TaskID, + HubBackend: result.HubBackend, + SessionID: result.SessionID, + RootIssueID: result.RootIssueID, + RootIssueLabel: firstNonEmpty(result.RootIssueID, issue.Identifier), + AssignmentID: result.AssignmentID, + WakeStatus: result.WakeStatus, + WakeTurnID: result.WakeTurnID, + HubWriteStatus: result.HubWriteStatus, + HubChildIssues: result.HubChildIssues, + HubFeedbackComment: result.HubFeedbackComments, + } + if result.Receipt != nil { + material.HasIngestReceipt = true + material.IngestSeq = result.Receipt.Seq + material.IngestDuplicate = result.Receipt.Dup + material.IngestTicked = result.Receipt.Ticked + } + return material +} + +func runtimeMulticaCLI(env []string, activation multicasurface.RuntimeContext) driver.MulticaCLI { return driver.MulticaCLI{ - Command: envValue(env, "MNEMON_MULTICA_BIN"), - Profile: envValue(env, "MNEMON_MULTICA_PROFILE"), - ServerURL: firstNonEmpty(envValue(env, "MNEMON_MULTICA_SERVER_URL"), envValue(env, "MULTICA_SERVER_URL")), - WorkspaceID: firstNonEmpty(envValue(env, "MNEMON_MULTICA_WORKSPACE_ID"), envValue(env, "MULTICA_WORKSPACE_ID")), + Command: multicasurface.RuntimeEnvValue(env, "MNEMON_MULTICA_BIN"), + Profile: multicasurface.RuntimeEnvValue(env, "MNEMON_MULTICA_PROFILE"), + ServerURL: activation.ServerURL, + WorkspaceID: activation.WorkspaceID, Env: append([]string(nil), env...), - Timeout: runtimeTimeout(env), + Timeout: multicasurface.RuntimeTimeout(env), } } @@ -860,8 +809,8 @@ func runtimeControlClient(env []string, addr, principal string) (*access.Client, } func runtimeControlToken(env []string) (string, error) { - token := envValue(env, "MNEMON_CONTROL_TOKEN") - if tokenFile := envValue(env, "MNEMON_CONTROL_TOKEN_FILE"); tokenFile != "" { + token := multicasurface.RuntimeEnvValue(env, "MNEMON_CONTROL_TOKEN") + if tokenFile := multicasurface.RuntimeEnvValue(env, "MNEMON_CONTROL_TOKEN_FILE"); tokenFile != "" { data, err := os.ReadFile(tokenFile) if err != nil { return "", fmt.Errorf("read MNEMON_CONTROL_TOKEN_FILE: %w", err) @@ -872,7 +821,7 @@ func runtimeControlToken(env []string) (string, error) { } func runtimeManagedTurnClient(env []string, cwd, runtimeName string) (driver.ManagedTurnClient, string, error) { - workspace := envDefault(env, "MNEMON_MANAGED_WORKSPACE", cwd) + workspace := multicasurface.RuntimeEnvDefault(env, "MNEMON_MANAGED_WORKSPACE", cwd) if strings.TrimSpace(workspace) == "" { workspace = "." } @@ -882,11 +831,11 @@ func runtimeManagedTurnClient(env []string, cwd, runtimeName string) (driver.Man case "codex-appserver": return driver.CodexAppServerTurnClient{ Principal: resolveRuntimePrincipal(env, cwd), - Command: envDefault(env, "MNEMON_MANAGED_COMMAND", "codex"), + Command: multicasurface.RuntimeEnvDefault(env, "MNEMON_MANAGED_COMMAND", "codex"), Workspace: workspace, Env: append([]string(nil), env...), - TurnTimeout: runtimeManagedTurnTimeout(env), - ClientName: "mnemon-multica-runtime", + TurnTimeout: multicasurface.RuntimeManagedTurnTimeout(env), + ClientName: multicasurface.MulticaRuntimeCommandName, }, workspace, nil default: return nil, workspace, fmt.Errorf("unsupported MNEMON_MANAGED_RUNTIME %q", runtimeName) @@ -903,12 +852,16 @@ func (runtimeNoopTurnClient) StartTurn(_ context.Context, query string) (driver. } func resolveRuntimePrincipal(env []string, cwd string) string { - agentID := envValue(env, "MULTICA_AGENT_ID") - agentName := envValue(env, "MULTICA_AGENT_NAME") - if principal := principalFromRegistry(env, cwd, agentID, agentName); principal != "" { + return resolveRuntimePrincipalFromContext(env, cwd, multicasurface.RuntimeContextFromActivation(env, cwd, multicasurface.RuntimeInput{})) +} + +func resolveRuntimePrincipalFromContext(env []string, cwd string, activation multicasurface.RuntimeContext) string { + agentID := activation.AgentID + agentName := activation.AgentName + if principal := multicasurface.RuntimeMulticaRegistryPrincipal(env, cwd, agentID, agentName); principal != "" { return principal } - if principal := envValue(env, "MNEMON_CONTROL_PRINCIPAL"); principal != "" { + if principal := activation.ControlPrincipal; principal != "" { return principal } if agentName != "" { @@ -917,184 +870,34 @@ func resolveRuntimePrincipal(env []string, cwd string) string { return "multica@runtime" } -func principalFromRegistry(env []string, cwd, agentID, agentName string) string { - paths := []string{} - if explicit := envValue(env, "MNEMON_MULTICA_REGISTRY"); explicit != "" { - paths = append(paths, explicit) +func runtimeResultSummary(result runtimeImportResult) multicasurface.RuntimeResultSummary { + summary := multicasurface.RuntimeResultSummary{ + IssueID: result.IssueID, + Identifier: result.Identifier, + Title: result.Title, + Principal: result.Principal, + Status: result.Status, + ProjectionStatus: result.ProjectionStatus, + ProjectionCommentID: result.ProjectionCommentID, + WakeStatus: result.WakeStatus, + WakeTurnID: result.WakeTurnID, + HubWriteStatus: result.HubWriteStatus, + HubChildIssues: result.HubChildIssues, + HubFeedbackComments: result.HubFeedbackComments, } - if strings.TrimSpace(cwd) != "" { - paths = append(paths, driver.MulticaRegistryPath(cwd, "")) + if result.Err != nil { + summary.Err = result.Err.Error() } - for _, path := range paths { - reg, ok, err := driver.LoadMulticaRegistry(path) - if err != nil || !ok { - continue - } - for _, participant := range reg.Participants { - if agentID != "" && participant.AgentID == agentID && strings.TrimSpace(participant.Principal) != "" { - return strings.TrimSpace(participant.Principal) - } - if agentName != "" && participant.AgentName == agentName && strings.TrimSpace(participant.Principal) != "" { - return strings.TrimSpace(participant.Principal) - } - } - } - return "" -} - -func runtimeRef(kind, id string) string { - kind = strings.TrimSpace(kind) - id = strings.TrimSpace(id) - if kind == "" || id == "" { - return "" - } - return "multica:" + kind + ":" + id -} - -func formatRuntimeFinalAnswer(result runtimeImportResult) string { - var b strings.Builder - if result.IssueID == "" { - b.WriteString("Mnemon Multica runtime did not receive a Multica issue id.") - } else { - label := strings.TrimSpace(result.Identifier) - if label == "" { - label = result.IssueID - } - b.WriteString("Mnemon Multica runtime handled issue ") - b.WriteString(label) - if title := strings.TrimSpace(result.Title); title != "" { - b.WriteString(" (") - b.WriteString(title) - b.WriteString(")") - } - b.WriteString(".") - } - if principal := strings.TrimSpace(result.Principal); principal != "" { - b.WriteString(" Principal: ") - b.WriteString(principal) - b.WriteString(".") - } - if taskID := strings.TrimSpace(result.TaskID); taskID != "" { - b.WriteString(" Multica task: ") - b.WriteString(taskID) - b.WriteString(".") - } - switch result.Status { - case "recorded": - b.WriteString(" Mnemon ingest: recorded") - if result.Receipt != nil { - b.WriteString(fmt.Sprintf(" seq=%d duplicate=%v ticked=%v", result.Receipt.Seq, result.Receipt.Dup, result.Receipt.Ticked)) - } - b.WriteString(".") - case "correlated": - b.WriteString(" Mnemon assignment mailbox: correlated") - if result.AssignmentID != "" { - b.WriteString(" assignment=") - b.WriteString(result.AssignmentID) - } - if result.SessionID != "" { - b.WriteString(" session=") - b.WriteString(result.SessionID) - } - b.WriteString(".") - case "skipped": - b.WriteString(" Mnemon ingest: skipped") - if result.Err != nil { - b.WriteString(" (") - b.WriteString(result.Err.Error()) - b.WriteString(")") - } else { - b.WriteString(" because MNEMON_CONTROL_ADDR is not set") - } - b.WriteString(".") - case "failed": - b.WriteString(" Mnemon ingest: failed") - if result.Err != nil { - b.WriteString(" (") - b.WriteString(result.Err.Error()) - b.WriteString(")") - } - b.WriteString(".") - default: - if result.Err != nil { - b.WriteString(" Mnemon ingest: failed (") - b.WriteString(result.Err.Error()) - b.WriteString(").") - } + if result.ProjectionErr != nil { + summary.ProjectionErr = result.ProjectionErr.Error() } - switch result.ProjectionStatus { - case "commented": - b.WriteString(" Multica projection: comment") - if result.ProjectionCommentID != "" { - b.WriteString("=") - b.WriteString(result.ProjectionCommentID) - } - b.WriteString(".") - case "skipped": - b.WriteString(" Multica projection: skipped.") - case "failed": - b.WriteString(" Multica projection: failed") - if result.ProjectionErr != nil { - b.WriteString(" (") - b.WriteString(result.ProjectionErr.Error()) - b.WriteString(")") - } - b.WriteString(".") - } - switch result.WakeStatus { - case "completed": - b.WriteString(" Managed wake: completed") - if result.WakeTurnID != "" { - b.WriteString(" turn=") - b.WriteString(result.WakeTurnID) - } - b.WriteString(".") - case "skipped": - b.WriteString(" Managed wake: skipped") - if result.WakeErr != nil { - b.WriteString(" (") - b.WriteString(result.WakeErr.Error()) - b.WriteString(")") - } - b.WriteString(".") - case "failed": - b.WriteString(" Managed wake: failed") - if result.WakeErr != nil { - b.WriteString(" (") - b.WriteString(result.WakeErr.Error()) - b.WriteString(")") - } - b.WriteString(".") - default: - if result.WakeStatus != "" { - b.WriteString(" Managed wake: ") - b.WriteString(result.WakeStatus) - b.WriteString(".") - } + if result.WakeErr != nil { + summary.WakeErr = result.WakeErr.Error() } - switch result.HubWriteStatus { - case "created", "commented", "updated", "noop": - b.WriteString(" Multica hub write: ") - b.WriteString(result.HubWriteStatus) - if result.HubChildIssues > 0 { - b.WriteString(fmt.Sprintf(" child_issues=%d", result.HubChildIssues)) - } - if result.HubFeedbackComments > 0 { - b.WriteString(fmt.Sprintf(" feedback_comments=%d", result.HubFeedbackComments)) - } - b.WriteString(".") - case "skipped": - b.WriteString(" Multica hub write: skipped.") - case "failed": - b.WriteString(" Multica hub write: failed") - if result.HubWriteErr != nil { - b.WriteString(" (") - b.WriteString(result.HubWriteErr.Error()) - b.WriteString(")") - } - b.WriteString(".") + if result.HubWriteErr != nil { + summary.HubWriteErr = result.HubWriteErr.Error() } - return strings.TrimSpace(b.String()) + return summary } func (s *runtimeRPCState) threadStartResult(params map[string]any) map[string]any { @@ -1164,242 +967,15 @@ func writeRuntimeRPC(w io.Writer, msg rpcMessage) error { return err } -func emitRuntimeManagedTraceEvent(emit func(rpcMessage) error, threadID, turnID string, event driver.ManagedTurnTraceEvent, now time.Time) error { - switch event.Method { - case "item/started": - item := runtimeManagedTraceItem(event) - if len(item) == 0 { - return nil - } - return emit(rpcMessage{ - Method: "item/started", - Params: runtimeItemParams(threadID, turnID, item, "startedAtMs", now.UTC().UnixMilli()), - }) - case "item/completed": - item := runtimeManagedTraceItem(event) - if len(item) == 0 { - return nil - } - return emit(rpcMessage{ - Method: "item/completed", - Params: runtimeItemParams(threadID, turnID, item, "completedAtMs", now.UTC().UnixMilli()), - }) - case "item/agentMessage/delta": - text := strings.TrimSpace(event.Text) - if text == "" { - return nil - } - return emit(runtimeAgentDelta(threadID, turnID, runtimeManagedTraceItemID(event), text)) - default: - return nil - } -} - -func runtimeManagedTraceItem(event driver.ManagedTurnTraceEvent) map[string]any { - if len(event.Item) == 0 { - return nil - } - item, _ := cloneRuntimeAny(event.Item).(map[string]any) - if item == nil { - return nil - } - item["id"] = runtimeManagedTraceItemID(event) - if _, ok := item["source"]; !ok { - item["source"] = "managedCodexAppServer" - } - if strings.TrimSpace(event.SourceRuntime) != "" { - item["mnemonSourceRuntime"] = event.SourceRuntime - } - if strings.TrimSpace(event.Principal) != "" { - item["mnemonPrincipal"] = event.Principal - } - if strings.TrimSpace(event.TurnID) != "" { - item["mnemonManagedTurnId"] = event.TurnID - } - return item -} - -func runtimeManagedTraceItemID(event driver.ManagedTurnTraceEvent) string { - key := firstNonEmpty(event.ItemID, event.Command, event.Text, event.Kind, event.Method) - return runtimeTextDigestID("managed", event.SourceRuntime+"\n"+event.TurnID+"\n"+key) -} - -func cloneRuntimeAny(value any) any { - switch typed := value.(type) { - case map[string]any: - out := map[string]any{} - for key, item := range typed { - out[key] = cloneRuntimeAny(item) - } - return out - case []any: - out := make([]any, 0, len(typed)) - for _, item := range typed { - out = append(out, cloneRuntimeAny(item)) - } - return out - default: - return typed - } -} - -func emitRuntimeCommandExecution(emit func(rpcMessage) error, threadID, turnID, itemID, fallbackCWD string, event runtimeProgressEvent, now time.Time) error { - command := strings.TrimSpace(event.Command) - if command == "" { - return nil - } - if strings.TrimSpace(itemID) == "" { - itemID = runtimeTextDigestID("call", command+"\n"+event.Output) - } - cwd := strings.TrimSpace(event.CWD) - if cwd == "" { - cwd = fallbackCWD - } - output := strings.TrimSpace(event.Output) - durationMs := event.DurationMs - if durationMs < 0 { - durationMs = 0 - } - nowMs := now.UTC().UnixMilli() - started := runtimeCommandExecution(itemID, command, cwd, "inProgress", "", nil, nil) - completed := runtimeCommandExecution(itemID, command, cwd, "completed", output, event.ExitCode, durationMs) - messages := []rpcMessage{ - { - Method: "item/started", - Params: runtimeItemParams(threadID, turnID, started, "startedAtMs", nowMs), - }, - { - Method: "item/completed", - Params: runtimeItemParams(threadID, turnID, completed, "completedAtMs", nowMs), - }, - } - for _, message := range messages { - if err := emit(message); err != nil { - return err - } - } - return nil -} - -func runtimeCommandExecution(id, command, cwd, status, output string, exitCode any, durationMs any) map[string]any { - item := map[string]any{ - "type": "commandExecution", - "id": id, - "command": command, - "cwd": cwd, - "processId": "mnemon-runtime", - "source": "mnemonRuntime", - "status": status, - "commandActions": []any{map[string]any{"type": "unknown", "command": command}}, - "aggregatedOutput": nil, - "exitCode": nil, - "durationMs": nil, - } - if status == "completed" { - item["aggregatedOutput"] = output - item["exitCode"] = exitCode - item["durationMs"] = durationMs - } - return item -} - -func emitRuntimeAgentMessage(emit func(rpcMessage) error, threadID, turnID, itemID, text, phase string, now time.Time) error { - text = strings.TrimSpace(text) - if text == "" { - return nil - } - phase = strings.TrimSpace(phase) - if phase == "" { - phase = "commentary" - } - if strings.TrimSpace(itemID) == "" { - itemID = runtimeTextDigestID("msg", text) - } - nowMs := now.UTC().UnixMilli() - messages := []rpcMessage{ - { - Method: "item/started", - Params: runtimeItemParams(threadID, turnID, runtimeAgentMessage(itemID, "", phase), "startedAtMs", nowMs), - }, - runtimeAgentDelta(threadID, turnID, itemID, text), - { - Method: "item/completed", - Params: runtimeItemParams(threadID, turnID, runtimeAgentMessage(itemID, text, phase), "completedAtMs", nowMs), - }, - } +func emitRuntimeMessages(emit func(rpcMessage) error, messages []multicasurface.RuntimeRPCMessage) error { for _, message := range messages { - if err := emit(message); err != nil { + if err := emit(rpcMessage{Method: message.Method, Params: message.Params}); err != nil { return err } } return nil } -func runtimeAgentDelta(threadID, turnID, itemID, delta string) rpcMessage { - return rpcMessage{ - Method: "item/agentMessage/delta", - Params: map[string]any{ - "threadId": threadID, - "turnId": turnID, - "itemId": itemID, - "delta": delta, - }, - } -} - -func runtimeItemParams(threadID, turnID string, item map[string]any, timeKey string, timestampMs int64) map[string]any { - params := map[string]any{ - "threadId": threadID, - "turnId": turnID, - "item": item, - } - if timeKey != "" && timestampMs > 0 { - params[timeKey] = timestampMs - } - return params -} - -func runtimeUserMessage(text string) map[string]any { - return map[string]any{ - "type": "userMessage", - "id": runtimeTextDigestID("user", text), - "clientId": nil, - "content": []any{map[string]any{ - "type": "text", - "text": text, - "text_elements": []any{}, - }}, - } -} - -func runtimeAgentMessage(id, text, phase string) map[string]any { - id = strings.TrimSpace(id) - if id == "" { - id = runtimeTextDigestID("msg", text) - } - phase = strings.TrimSpace(phase) - if phase == "" { - phase = "commentary" - } - return map[string]any{ - "type": "agentMessage", - "id": id, - "text": text, - "phase": phase, - "memoryCitation": nil, - } -} - -func runtimeTextDigestID(prefix, text string) string { - prefix = strings.TrimSpace(prefix) - if prefix == "" { - prefix = "item" - } - sum := sha256.Sum256([]byte(text)) - digest := fmt.Sprintf("%x", sum[:])[:24] - return prefix + "_" + digest -} - func emitRuntimeProgress(progress runtimeProgressSink, text string) { if progress != nil { progress(runtimeProgressEvent{Text: text}) @@ -1419,131 +995,16 @@ func runtimeExitCode(err error) int { return 0 } -func displayRuntimePrincipal(principal string) string { - principal = strings.TrimSpace(principal) - if principal == "" { - return "the resolved principal" - } - return principal -} - func runtimeIssueLabel(issue driver.MulticaIssue) string { - if strings.TrimSpace(issue.Identifier) != "" && strings.TrimSpace(issue.Title) != "" { - return issue.Identifier + " (" + issue.Title + ")" - } - if strings.TrimSpace(issue.Identifier) != "" { - return issue.Identifier - } - if strings.TrimSpace(issue.ID) != "" { - return issue.ID - } - return "the Multica issue" -} - -func runtimeAssignmentLabel(result runtimeImportResult) string { - if strings.TrimSpace(result.AssignmentID) != "" { - return result.AssignmentID - } - if strings.TrimSpace(result.AssignmentFingerprint) != "" { - return result.AssignmentFingerprint - } - return "assignment mailbox" -} - -func runtimeWakeProgress(result runtimeImportResult) string { - switch strings.TrimSpace(result.WakeStatus) { - case "": - if result.WakeErr != nil { - return "Managed wake failed: " + result.WakeErr.Error() - } - return "Managed wake did not run." - case "completed": - if strings.TrimSpace(result.WakeTurnID) != "" { - return "Managed wake completed: turn=" + result.WakeTurnID + "." - } - return "Managed wake completed." - case "skipped": - if result.WakeErr != nil { - return "Managed wake skipped: " + result.WakeErr.Error() - } - return "Managed wake skipped." - case "failed": - if result.WakeErr != nil { - return "Managed wake failed: " + result.WakeErr.Error() - } - return "Managed wake failed." - default: - return "Managed wake status: " + result.WakeStatus + "." - } -} - -func runtimeHubWriteProgress(result runtimeImportResult) string { - switch strings.TrimSpace(result.HubWriteStatus) { - case "": - return "" - case "failed": - if result.HubWriteErr != nil { - return "Multica hub projection failed: " + result.HubWriteErr.Error() - } - return "Multica hub projection failed." - case "skipped": - return "Multica hub projection skipped." - default: - return fmt.Sprintf("Multica hub projection %s: child issues=%d, feedback comments=%d.", result.HubWriteStatus, result.HubChildIssues, result.HubFeedbackComments) - } -} - -func runtimeProjectionProgress(result runtimeImportResult) string { - switch strings.TrimSpace(result.ProjectionStatus) { - case "": - return "" - case "failed": - if result.ProjectionErr != nil { - return "Multica comment projection failed: " + result.ProjectionErr.Error() - } - return "Multica comment projection failed." - case "skipped": - return "Multica comment projection skipped." - default: - if strings.TrimSpace(result.ProjectionCommentID) != "" { - return "Multica comment projection completed: comment=" + result.ProjectionCommentID + "." - } - return "Multica comment projection completed." - } + return multicasurface.RuntimeIssueLabel(issue.ID, issue.Identifier, issue.Title) } func markIssueInProgress(ctx context.Context, cli driver.MulticaCLI, issueID string) { _, _ = cli.SetIssueStatus(ctx, issueID, "in_progress") } -func extractRuntimeInput(params map[string]any) string { - input, ok := params["input"].([]any) - if !ok { - return "" - } - var parts []string - for _, item := range input { - obj, ok := item.(map[string]any) - if !ok { - continue - } - if text, _ := obj["text"].(string); strings.TrimSpace(text) != "" { - parts = append(parts, text) - } - } - return strings.Join(parts, "\n") -} - -func extractAssignedIssueID(input string) string { - match := assignedIssuePattern.FindStringSubmatch(input) - if len(match) >= 2 { - return strings.Trim(match[1], " \t\r\n.,;)") - } - return "" -} - func wantsVersion(args []string) bool { - fs := flag.NewFlagSet("mnemon-multica-runtime", flag.ContinueOnError) + fs := flag.NewFlagSet(multicasurface.MulticaRuntimeCommandName, flag.ContinueOnError) fs.SetOutput(io.Discard) version := fs.Bool("version", false, "") _ = fs.Parse(args) @@ -1559,6 +1020,27 @@ func wantsVersion(args []string) bool { return false } +func wantsRuntimeHelp(args []string) bool { + for _, arg := range args { + switch strings.TrimSpace(arg) { + case "help", "--help", "-help", "-h": + return true + } + } + return false +} + +func writeRuntimeHelp(w io.Writer) { + fmt.Fprintf(w, "%s is an external Multica runtime adapter for the Mnemon event system.\n", multicasurface.MulticaRuntimeCommandName) + fmt.Fprintln(w) + fmt.Fprintln(w, "Usage:") + fmt.Fprintf(w, " %s app-server --listen stdio://\n", multicasurface.MulticaRuntimeCommandName) + fmt.Fprintf(w, " %s --version\n", multicasurface.MulticaRuntimeCommandName) + fmt.Fprintf(w, " %s --probe app-server --listen stdio://\n", multicasurface.MulticaRuntimeCommandName) + fmt.Fprintln(w) + fmt.Fprintln(w, "The adapter receives Multica runtime RPC on stdin/stdout, loads issue context through the Multica CLI, submits structured EventMaterial to mnemond when configured, wakes only the locally owned managed agent with [mnemon:wake], streams activation trace, and projects accepted state back to Multica.") +} + func runtimeProbeModeEnabled(args []string) bool { for _, arg := range args { switch strings.TrimSpace(arg) { @@ -1577,24 +1059,6 @@ func stringParam(params map[string]any, key string) string { return strings.TrimSpace(value) } -func envValue(env []string, key string) string { - prefix := key + "=" - for i := len(env) - 1; i >= 0; i-- { - item := env[i] - if strings.HasPrefix(item, prefix) { - return strings.TrimSpace(strings.TrimPrefix(item, prefix)) - } - } - return "" -} - -func envDefault(env []string, key, fallback string) string { - if value := envValue(env, key); value != "" { - return value - } - return fallback -} - func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { @@ -1617,63 +1081,6 @@ func runtimeID(prefix string, now time.Time) string { return fmt.Sprintf("%s-%d", prefix, now.UTC().UnixNano()) } -func runtimeTimeout(env []string) time.Duration { - raw := envValue(env, "MNEMON_MULTICA_RUNTIME_TIMEOUT") - if raw == "" { - return 30 * time.Second - } - d, err := time.ParseDuration(raw) - if err != nil || d <= 0 { - return 30 * time.Second - } - return d -} - -func runtimeManagedTurnTimeout(env []string) time.Duration { - raw := envValue(env, "MNEMON_MANAGED_TURN_TIMEOUT") - if raw == "" { - return 5 * time.Minute - } - d, err := time.ParseDuration(raw) - if err != nil || d <= 0 { - return 5 * time.Minute - } - return d -} - -func runtimeHubProjectionInterval(env []string) time.Duration { - raw := envValue(env, "MNEMON_MULTICA_HUB_PROJECT_INTERVAL") - if raw == "" { - return 5 * time.Second - } - d, err := time.ParseDuration(raw) - if err != nil || d <= 0 { - return 5 * time.Second - } - return d -} - -func runtimeManagedLedgerPath(env []string, workspace string) string { - if explicit := envValue(env, "MNEMON_MANAGED_LEDGER"); explicit != "" { - return explicit - } - root := strings.TrimSpace(workspace) - if root == "" { - root = "." - } - return filepath.Join(root, ".mnemon", "harness", "local", "managed-agent", "wake-ledger.jsonl") -} - -func runtimeProjectionEnabled(env []string) bool { - value := strings.ToLower(envDefault(env, "MNEMON_MULTICA_PROJECT_COMMENTS", "true")) - switch value { - case "0", "false", "no", "off": - return false - default: - return true - } -} - func sanitizePrincipal(value string) string { value = strings.TrimSpace(strings.ToLower(value)) var b strings.Builder diff --git a/harness/cmd/mnemon-multica-runtime/main_test.go b/harness/cmd/mnemon-multica-runtime/main_test.go index c1f4cd7d..6894d681 100644 --- a/harness/cmd/mnemon-multica-runtime/main_test.go +++ b/harness/cmd/mnemon-multica-runtime/main_test.go @@ -12,42 +12,15 @@ import ( "time" "github.com/mnemon-dev/mnemon/harness/internal/contract" + "github.com/mnemon-dev/mnemon/harness/internal/drive" "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/access" "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation" pview "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation/view" + multicasurface "github.com/mnemon-dev/mnemon/harness/internal/surface/multica" ) -func TestRuntimeEnvValueUsesLastValue(t *testing.T) { - env := []string{ - "MNEMON_MANAGED_RUNTIME=codex-appserver", - "MNEMON_MANAGED_RUNTIME=off", - } - if got := envValue(env, "MNEMON_MANAGED_RUNTIME"); got != "off" { - t.Fatalf("envValue = %q, want off", got) - } -} - -func TestRuntimeMulticaHubLedgerPathDefaultsToManagedWorkspace(t *testing.T) { - tmp := t.TempDir() - workspace := filepath.Join(tmp, "managed-workspace") - cwd := filepath.Join(tmp, "task-workdir") - got := runtimeMulticaHubLedgerPath([]string{"MNEMON_MANAGED_WORKSPACE=" + workspace}, cwd) - want := filepath.Join(workspace, driver.MulticaDefaultHubLedgerRelPath) - if got != want { - t.Fatalf("hub ledger path = %q, want %q", got, want) - } - explicit := filepath.Join(tmp, "explicit.jsonl") - got = runtimeMulticaHubLedgerPath([]string{ - "MNEMON_MANAGED_WORKSPACE=" + workspace, - "MNEMON_MULTICA_HUB_LEDGER=" + explicit, - }, cwd) - if got != explicit { - t.Fatalf("explicit hub ledger path = %q, want %q", got, explicit) - } -} - func TestMergeRuntimeHubProjectionDeltasPreservesEarlyDispatchCounts(t *testing.T) { result := runtimeImportResult{HubWriteStatus: "noop"} mergeRuntimeHubProjectionDeltas(&result, []runtimeHubProjectionDelta{ @@ -65,13 +38,13 @@ func TestMergeRuntimeHubProjectionDeltasPreservesEarlyDispatchCounts(t *testing. } func TestManagedWakeMatchTermsPreferStableIssueIdentity(t *testing.T) { - got := managedWakeMatchTerms(runtimeImportResult{ + got := drive.ManagedWakeMatchTerms(runtimeManagedWakeMatchMaterial(runtimeImportResult{ IssueID: "issue-123", Identifier: "TEA-27", Title: "Mnemon R2 hub-flow completion drill", Statement: "Run a small hub-flow readiness drill.", TaskID: "task-123", - }) + })) joined := strings.Join(got, "\n") for _, want := range []string{"issue-123", "TEA-27", "Mnemon R2 hub-flow completion drill", "task-123"} { if !strings.Contains(joined, want) { @@ -98,6 +71,82 @@ func runtimeTestEnv(values ...string) []string { return append(env, values...) } +func TestRuntimeHelpDescribesIndependentAdapter(t *testing.T) { + for _, args := range [][]string{{"--help"}, {"--probe", "--help"}} { + var out bytes.Buffer + err := runRuntime(runtimeConfig{ + Args: args, + CWD: t.TempDir(), + Stdin: strings.NewReader(""), + Stdout: &out, + }) + if err != nil { + t.Fatalf("%v help should exit successfully, got %v", args, err) + } + got := out.String() + for _, want := range []string{ + "external Multica runtime adapter", + "Mnemon event system", + "stdin/stdout", + "structured EventMaterial", + "mnemond", + "[mnemon:wake]", + "projects accepted state back to Multica", + } { + if !strings.Contains(got, want) { + t.Fatalf("%v runtime help missing %q:\n%s", args, want, got) + } + } + for _, blocked := range []string{"mnemon multica", "mnemon-harness multica", "global planner"} { + if strings.Contains(got, blocked) { + t.Fatalf("%v runtime help leaked forbidden command/role wording %q:\n%s", args, blocked, got) + } + } + } +} + +func TestRuntimeImportUsesStructuredIssueIdentity(t *testing.T) { + tmp := t.TempDir() + argsPath := filepath.Join(tmp, "multica.args") + bin := filepath.Join(tmp, "multica") + script := `#!/usr/bin/env sh +printf '%s\n' "$*" >> "$MULTICA_ARGS_PATH" +case "$*" in + *"issue get iss-structured"*) printf '{"id":"iss-structured","identifier":"TEA-77","title":"Structured issue mention","description":"Use structured Multica input metadata instead of prompt text.","status":"todo","priority":"medium"}\n' ;; + *"issue metadata list iss-structured"*) printf '[]\n' ;; + *) printf '{}\n' ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + state := runtimeRPCState{ + CWD: tmp, + Env: runtimeTestEnv( + "MNEMON_MULTICA_BIN="+bin, + "MULTICA_ARGS_PATH="+argsPath, + "MULTICA_TASK_ID=task-structured", + ), + Now: fixedRuntimeTime, + } + + result := state.importIssue(multicasurface.RuntimeInput{ + Text: "Please use the linked issue, not a copied issue id.", + IssueIdentity: "iss-structured", + }, nil) + + if result.IssueID != "iss-structured" || result.Identifier != "TEA-77" { + t.Fatalf("structured issue import mismatch: %+v", result) + } + if result.Status != "skipped" { + t.Fatalf("runtime without control addr should skip ingest after loading issue, got %+v", result) + } + args := mustReadRuntimeTestFile(t, argsPath) + if !strings.Contains(args, "issue get iss-structured --output json") { + t.Fatalf("structured issue identity was not used for issue get:\n%s", args) + } +} + func TestRuntimeImportsAssignedIssueIntoMnemon(t *testing.T) { tmp := t.TempDir() argsPath := filepath.Join(tmp, "multica.args") @@ -261,7 +310,15 @@ esac if err != nil { t.Fatal(err) } - for _, want := range []string{"Mnemon update: issue admitted", "Principal: planner@team", "Managed wake: completed", "mnemon:event=multica-task-task-1"} { + for _, want := range []string{ + "Mnemon update: issue admitted", + "## Mnemon Runtime", + "Issue: [TEA-7](mention://issue/iss-7)", + "Principal: `planner@team`", + "Managed wake: `completed`", + "mnemon:event=multica-task-task-1", + "mnemon:type=teamwork_signal.write_candidate.observed", + } { if !strings.Contains(string(comment), want) { t.Fatalf("comment missing %s:\n%s", want, comment) } @@ -315,7 +372,7 @@ esac if strings.Contains(text, "Loading Multica issue iss-7") && phase == "commentary" { sawCommentaryProgress = true } - if strings.Contains(text, "Mnemon ingest: recorded seq=17") && phase == "final_answer" { + if strings.Contains(text, "Mnemon ingest: recorded") && phase == "final_answer" { sawFinalPhaseAnswer = true } } @@ -334,7 +391,7 @@ esac progressItemIDs[itemID] = true } } - if msg["method"] == "item/agentMessage/delta" && strings.Contains(line, "Mnemon ingest: recorded seq=17") && strings.Contains(line, "Multica projection: comment=comment-1") && strings.Contains(line, "Managed wake: completed turn=noop-turn") { + if msg["method"] == "item/agentMessage/delta" && strings.Contains(line, "Mnemon ingest: recorded") && strings.Contains(line, "Multica projection: comment posted") && strings.Contains(line, "Managed wake: completed") { sawAnswer = true params, _ := msg["params"].(map[string]any) answerItemID, _ = params["itemId"].(string) @@ -395,7 +452,7 @@ func TestRuntimeForwardsManagedCodexTraceItemsOnOuterTurn(t *testing.T) { "phase": "commentary", } for _, event := range []driver.ManagedTurnTraceEvent{traceStarted, traceDelta, traceCompleted} { - if err := emitRuntimeManagedTraceEvent(emit, "outer-thread", "outer-turn", event, fixedRuntimeTime()); err != nil { + if err := emitRuntimeMessages(emit, multicasurface.RuntimeManagedTraceMessages("outer-thread", "outer-turn", event, fixedRuntimeTime())); err != nil { t.Fatal(err) } } @@ -510,20 +567,134 @@ esac } } comment := mustReadRuntimeTestFile(t, commentPath) - for _, want := range []string{"Mnemon update: issue admitted", "Hub backend: multica", "Session: multica:session:root-1"} { + for _, want := range []string{ + "Mnemon update: issue admitted", + "## Mnemon Runtime", + "Issue: [TEA-1](mention://issue/root-1)", + "mnemon:type=teamwork_signal.write_candidate.observed", + "mnemon:session=multica:session:root-1", + } { if !strings.Contains(comment, want) { t.Fatalf("comment missing %q:\n%s", want, comment) } } - if !strings.Contains(out.String(), "Mnemon ingest: recorded seq=21") { + for _, blocked := range []string{"Hub backend:", "Session: `multica:session:root-1`"} { + if strings.Contains(comment, blocked) { + t.Fatalf("runtime comment must not expose machine field %q:\n%s", blocked, comment) + } + } + if !strings.Contains(out.String(), "Mnemon ingest: recorded") { t.Fatalf("runtime output missing ingest evidence:\n%s", out.String()) } } +func TestRuntimeContinuesWhenRootSessionMetadataWriteFails(t *testing.T) { + tmp := t.TempDir() + argsPath := filepath.Join(tmp, "multica.args") + commentPath := filepath.Join(tmp, "comment.txt") + bin := filepath.Join(tmp, "multica") + script := `#!/usr/bin/env sh +printf '%s\n' "$*" >> "$MULTICA_ARGS_PATH" +case "$*" in + *"issue get TEA-9"*) printf '{"id":"iss-9","identifier":"TEA-9","title":"Metadata retry tolerance","description":"Admit the teamwork issue even when Multica metadata projection is temporarily unavailable.","status":"todo","priority":"medium"}\n' ;; + *"issue metadata set iss-9"*) printf 'metadata timeout\n' >&2; exit 42 ;; + *"issue comment add iss-9"*) cat > "$MULTICA_COMMENT_PATH"; printf '{"id":"comment-9","issue_id":"iss-9","content":"ok","type":"comment"}\n' ;; + *) printf '{}\n' ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + var gotPrincipal string + var got contract.ObservationEnvelope + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/ingest": + gotPrincipal = r.Header.Get(access.PrincipalHeader) + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatal(err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(access.IngestReceipt{Seq: 29, Dup: false, Ticked: true}) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + + input := `{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"input":[{"type":"text","text":"Please coordinate @TEA-9."}]}}` + "\n" + var out bytes.Buffer + err := runRuntime(runtimeConfig{ + Args: []string{"app-server", "--listen", "stdio://"}, + Env: runtimeTestEnv( + "MNEMON_MULTICA_BIN="+bin, + "MNEMON_HUB_BACKEND=multica", + "MNEMON_MULTICA_HUB_WRITE=off", + "MNEMON_CONTROL_ADDR="+srv.URL, + "MNEMON_CONTROL_PRINCIPAL=planner@team", + "MULTICA_ARGS_PATH="+argsPath, + "MULTICA_COMMENT_PATH="+commentPath, + "MULTICA_TASK_ID=task-9", + "MULTICA_AGENT_ID=agent-planner", + ), + CWD: tmp, + Stdin: strings.NewReader(input), + Stdout: &out, + Now: fixedRuntimeTime, + }) + if err != nil { + t.Fatal(err) + } + if gotPrincipal != "planner@team" { + t.Fatalf("principal header = %q", gotPrincipal) + } + if got.Event.Type != "teamwork_signal.write_candidate.observed" { + t.Fatalf("event type = %q", got.Event.Type) + } + rule := got.Event.Payload["rule"].(map[string]any) + if rule["root_issue_id"] != "iss-9" || rule["session_id"] != driver.MulticaSessionID("iss-9") { + t.Fatalf("root hub rule mismatch after metadata failure: %+v", rule) + } + output := out.String() + for _, want := range []string{ + "Root session metadata write failed; continuing with Mnemon ingest from issue context.", + "Mnemon ingest: recorded", + "Multica projection: comment posted", + } { + if !strings.Contains(output, want) { + t.Fatalf("runtime output missing %q:\n%s", want, output) + } + } + if strings.Contains(output, "Mnemon ingest: failed") || strings.Contains(output, "Multica updates: failed") { + t.Fatalf("metadata projection failure polluted protocol result:\n%s", output) + } + comment := mustReadRuntimeTestFile(t, commentPath) + for _, want := range []string{ + "Mnemon update: issue admitted", + "Mnemond ingest: seq=29", + "## Mnemon Runtime", + "mnemon:type=teamwork_signal.write_candidate.observed", + "mnemon:session=multica:session:iss-9", + } { + if !strings.Contains(comment, want) { + t.Fatalf("comment missing %q:\n%s", want, comment) + } + } + if strings.Contains(comment, "Hub backend:") { + t.Fatalf("runtime comment must not expose hub backend routing details:\n%s", comment) + } + args := mustReadRuntimeTestFile(t, argsPath) + if !strings.Contains(args, "issue metadata set iss-9 --key mnemon.hub_backend") || !strings.Contains(args, "issue comment add iss-9 --content-stdin --output json") { + t.Fatalf("runtime should attempt metadata projection then continue to comment projection:\n%s", args) + } +} + func TestRuntimeWritesAssignmentMailboxChildIssueFromView(t *testing.T) { tmp := t.TempDir() argsPath := filepath.Join(tmp, "multica.args") commentPath := filepath.Join(tmp, "comment.txt") + childCommentPath := filepath.Join(tmp, "child-comment.txt") createDescriptionPath := filepath.Join(tmp, "child-description.txt") bin := filepath.Join(tmp, "multica") script := `#!/usr/bin/env sh @@ -535,6 +706,8 @@ case "$*" in *"issue create"*) cat > "$MULTICA_CREATE_DESCRIPTION_PATH"; printf '{"id":"child-2","identifier":"TEA-11","title":"Mnemon assignment asg-writer","status":"todo","metadata":{}}\n' ;; *"issue metadata set child-2"*) printf '{}\n' ;; *"issue comment add root-2"*) cat > "$MULTICA_COMMENT_PATH"; printf '{"id":"comment-root-2","issue_id":"root-2","content":"ok","type":"comment"}\n' ;; + *"issue comment add child-2"*) cat > "$MULTICA_CHILD_COMMENT_PATH"; printf '{"id":"comment-child-2","issue_id":"child-2","content":"ok","type":"comment"}\n' ;; + *"issue status child-2"*) printf '{"id":"child-2","status":"in_progress"}\n' ;; *) printf '{}\n' ;; esac ` @@ -554,7 +727,6 @@ esac }); err != nil { t.Fatal(err) } - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/ingest": @@ -606,6 +778,46 @@ esac "context_refs": []any{"multica:issue:root-2"}, "evidence_refs": []any{"multica:issue:root-2"}, }, + }, map[string]any{ + "id": "asg-cross-session", + "ingest_seq": float64(34), + "actor": "planner@team", + "rule": map[string]any{ + "assignment_id": "asg-cross-session", + "assignee": "worker@team", + "scope": "other session child routing", + "ttl": "30m", + }, + "narrative": map[string]any{ + "expected_work": "cross-session assignment from a different Multica root", + "expected_feedback": "progress_digest with result or blocker", + }, + "refs": map[string]any{ + "context_refs": []any{"multica:issue:root-other"}, + }, + }}}, + }, { + Ref: contract.ResourceRef{Kind: "progress_digest", ID: "project"}, + Fields: map[string]any{"items": []any{map[string]any{ + "id": "pg-writer", + "event_id": "pg-writer", + "ingest_seq": float64(33), + "actor": "worker@team", + "assignment_ref": "asg-writer", + "feedback_kind": "progress", + "summary": "Checked release notes against the public changelog.", + "artifact_refs": []any{"multica:issue:root-2"}, + }, map[string]any{ + "id": "pg-cross-session", + "event_id": "pg-cross-session", + "ingest_seq": float64(35), + "actor": "worker@team", + "assignment_ref": "asg-writer", + "feedback_kind": "progress", + "summary": "Cross-session progress must not attach to the current child.", + "refs": map[string]any{ + "evidence_refs": []any{"multica:issue:root-other"}, + }, }}}, }}, }) @@ -628,6 +840,7 @@ esac "MNEMON_MULTICA_HUB_LEDGER="+filepath.Join(tmp, "hub-ledger.jsonl"), "MULTICA_ARGS_PATH="+argsPath, "MULTICA_COMMENT_PATH="+commentPath, + "MULTICA_CHILD_COMMENT_PATH="+childCommentPath, "MULTICA_CREATE_DESCRIPTION_PATH="+createDescriptionPath, "MULTICA_TASK_ID=task-root-2", "MULTICA_AGENT_ID=agent-planner", @@ -640,14 +853,16 @@ esac if err != nil { t.Fatal(err) } - if !strings.Contains(out.String(), "Multica hub write: created child_issues=1") { + if !strings.Contains(out.String(), "Multica updates: 1 assignment mailbox and 1 feedback comment synced") { t.Fatalf("runtime output missing hub write evidence:\n%s", out.String()) } args := mustReadRuntimeTestFile(t, argsPath) for _, want := range []string{ "issue children root-2 --output json", - "issue create --title Mnemon assignment asg-writer: check release notes --output json --description-stdin --parent root-2 --status in_progress --priority medium", + "issue create --title TEA-10: check release notes --output json --description-stdin --parent root-2 --status in_progress --priority medium", "issue metadata set child-2 --key mnemon.kind --value assignment_mailbox --type string --output json", + "issue metadata set child-2 --key mnemon.event_type --value assignment.accepted --type string --output json", + "issue metadata set child-2 --key mnemon.event_phase --value accepted --type string --output json", "issue metadata set child-2 --key mnemon.assignment_id --value asg-writer --type string --output json", "issue metadata set child-2 --key mnemon.principal --value worker@team --type string --output json", "issue assign child-2 --to-id agent-worker --output json", @@ -662,28 +877,652 @@ esac if strings.Contains(args, "asg-stale") { t.Fatalf("stale pre-root assignment must not be projected into current root session:\n%s", args) } - createIdx := strings.Index(args, "issue create --title Mnemon assignment asg-writer") + if strings.Contains(args, "other session child routing") || strings.Contains(args, "asg-cross-session") { + t.Fatalf("cross-session assignment must not be projected into current root session:\n%s", args) + } + createIdx := strings.Index(args, "issue create --title TEA-10: check release notes") metaIdx := strings.Index(args, "issue metadata set child-2 --key mnemon.kind") assignIdx := strings.Index(args, "issue assign child-2 --to-id agent-worker") if createIdx < 0 || metaIdx < 0 || assignIdx < 0 || !(createIdx < metaIdx && metaIdx < assignIdx) { t.Fatalf("assignment mailbox must be created, tagged, then assigned; args:\n%s", args) } + eventTypeIdx := strings.Index(args, "issue metadata set child-2 --key mnemon.event_type") + eventPhaseIdx := strings.Index(args, "issue metadata set child-2 --key mnemon.event_phase") + if eventTypeIdx < 0 || eventPhaseIdx < 0 || !(eventTypeIdx < assignIdx && eventPhaseIdx < assignIdx) { + t.Fatalf("assignment mailbox dispatch metadata must include event type/phase before assignment; args:\n%s", args) + } supplementalIdx := strings.Index(args, "issue metadata set child-2 --key mnemon.projection_owner") if supplementalIdx < 0 || !(assignIdx < supplementalIdx) { t.Fatalf("non-dispatch metadata must not block child assignment; args:\n%s", args) } - if strings.Contains(args, "issue status child-2 in_progress") { - t.Fatalf("assignment mailbox should be created in progress without a separate status call:\n%s", args) + childCommentIdx := strings.Index(args, "issue comment add child-2") + childStatusIdx := strings.Index(args, "issue status child-2 in_progress") + if childCommentIdx < 0 || childStatusIdx < 0 || !(childCommentIdx < childStatusIdx) { + t.Fatalf("progress feedback should comment before updating child status; args:\n%s", args) } description := mustReadRuntimeTestFile(t, createDescriptionPath) - for _, want := range []string{"Mnemon assignment mailbox", "Expected work: check release notes against the public changelog", "Expected feedback: progress_digest with result or blocker"} { + for _, want := range []string{ + "## Assignment", + "check release notes against the public changelog", + "Root issue: [TEA-10](mention://issue/root-2) - Coordinate docs release", + "Assignee: `worker@team` (mnemon-worker)", + "Scope: check release notes", + "## Feedback", + "Expected feedback: result or blocker", + "Progress path: Mnemon runtime progress, result, or blocker feedback", + } { if !strings.Contains(description, want) { t.Fatalf("description missing %q:\n%s", want, description) } } + for _, blocked := range []string{"mnemon.", "Session:", "Assignment: `asg-writer`", "assignment_ref", "progress_digest"} { + if strings.Contains(description, blocked) { + t.Fatalf("description must not expose machine field %q:\n%s", blocked, description) + } + } comment := mustReadRuntimeTestFile(t, commentPath) - if !strings.Contains(comment, "Multica hub write: created child_issues=1") { - t.Fatalf("root comment missing hub write summary:\n%s", comment) + for _, want := range []string{ + "Projection status: updates synced", + "Assignments created: 1", + "Feedback comments added: 1", + } { + if !strings.Contains(comment, want) { + t.Fatalf("root comment missing %q:\n%s", want, comment) + } + } + for _, blocked := range []string{"Multica hub write", "child_issues", "feedback_comments"} { + if strings.Contains(comment, blocked) { + t.Fatalf("root comment must not expose machine field %q:\n%s", blocked, comment) + } + } + childComment := mustReadRuntimeTestFile(t, childCommentPath) + if strings.Contains(childComment, "Cross-session progress") { + t.Fatalf("cross-session progress must not be projected into current assignment child:\n%s", childComment) + } + for _, want := range []string{ + "Mnemon update: assignment feedback", + "## Feedback", + "Status: progress update", + "## Summary", + "Checked release notes against the public changelog.", + "mnemon:event=pg-writer", + "mnemon:type=progress_digest.accepted", + "mnemon:session=multica:session:root-2", + "mnemon:assignment=asg-writer", + } { + if !strings.Contains(childComment, want) { + t.Fatalf("child comment missing %q:\n%s", want, childComment) + } + } + for _, blocked := range []string{"Assignment: `asg-writer`", "Feedback: `progress`", "assignment_ref"} { + if strings.Contains(childComment, blocked) { + t.Fatalf("child comment must not expose machine field %q:\n%s", blocked, childComment) + } + } +} + +func TestRuntimeRoutesDuplicateAssignmentIDProgressByPrincipal(t *testing.T) { + tmp := t.TempDir() + argsPath := filepath.Join(tmp, "multica.args") + rootCommentPath := filepath.Join(tmp, "root-comment.txt") + routingCommentPath := filepath.Join(tmp, "routing-comment.txt") + metadataCommentPath := filepath.Join(tmp, "metadata-comment.txt") + bin := filepath.Join(tmp, "multica") + script := `#!/usr/bin/env sh +printf '%s\n' "$*" >> "$MULTICA_ARGS_PATH" +case "$*" in + *"issue get root-dupe"*) printf '{"id":"root-dupe","identifier":"TEA-30","title":"Duplicate assignment id routing","description":"Validate duplicate assignment ids do not cross-route feedback.","status":"todo","priority":"medium"}\n' ;; + *"issue metadata set root-dupe"*) printf '{}\n' ;; + *"issue children root-dupe"*) printf '{"children":[]}\n' ;; + *"issue create --title TEA-30: child routing isolation"*) printf '{"id":"child-routing","identifier":"TEA-31","title":"TEA-30: child routing isolation","status":"todo","metadata":{}}\n' ;; + *"issue create --title TEA-30: root metadata run visibility"*) printf '{"id":"child-metadata","identifier":"TEA-32","title":"TEA-30: root metadata run visibility","status":"todo","metadata":{}}\n' ;; + *"issue metadata set child-routing"*) printf '{}\n' ;; + *"issue metadata set child-metadata"*) printf '{}\n' ;; + *"issue assign child-routing --to-id agent-impl"*) printf '{"id":"child-routing","status":"in_progress"}\n' ;; + *"issue assign child-metadata --to-id agent-researcher"*) printf '{"id":"child-metadata","status":"in_progress"}\n' ;; + *"issue comment add child-routing"*) cat > "$MULTICA_ROUTING_COMMENT_PATH"; printf '{"id":"comment-routing","issue_id":"child-routing","content":"ok","type":"comment"}\n' ;; + *"issue comment add child-metadata"*) cat > "$MULTICA_METADATA_COMMENT_PATH"; printf '{"id":"comment-metadata","issue_id":"child-metadata","content":"ok","type":"comment"}\n' ;; + *"issue comment add root-dupe"*) cat > "$MULTICA_ROOT_COMMENT_PATH"; printf '{"id":"comment-root","issue_id":"root-dupe","content":"ok","type":"comment"}\n' ;; + *"issue status child-routing"*) printf '{"id":"child-routing","status":"done"}\n' ;; + *"issue status child-metadata"*) printf '{"id":"child-metadata","status":"done"}\n' ;; + *"issue status root-dupe"*) printf '{"id":"root-dupe","status":"in_review"}\n' ;; + *) printf '{}\n' ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + registryPath := filepath.Join(tmp, "registry.json") + if err := driver.SaveMulticaRegistry(registryPath, driver.MulticaRegistry{ + SchemaVersion: 1, + WorkspaceID: "ws-1", + Participants: []driver.MulticaParticipantRecord{ + {Principal: "implementer@team", AgentName: "mnemon-implementer", AgentID: "agent-impl", Role: "implementer"}, + {Principal: "researcher@team", AgentName: "mnemon-researcher", AgentID: "agent-researcher", Role: "researcher"}, + }, + }); err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/ingest": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(access.IngestReceipt{Seq: 31, Dup: false, Ticked: true}) + case "/presentation-view": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(pview.View{ + Ref: "view-duplicate-assignment-ids", + Digest: "digest-duplicate-assignment-ids", + Content: []pview.ResourceContent{{ + Ref: contract.ResourceRef{Kind: "assignment", ID: "project"}, + Fields: map[string]any{"items": []any{ + map[string]any{ + "id": "local/planner-team/3", + "ingest_seq": float64(32), + "actor": "planner@team", + "rule": map[string]any{ + "assignment_id": "assignment-tea30-root-runtime", + "assignee": "implementer@team", + "scope": "multica-hub-readiness-drill/TEA-30/stage1/child-routing-isolation", + "ttl": "30m", + }, + "narrative": map[string]any{ + "expected_work": "Validate child routing isolation.", + "expected_feedback": "progress_digest with result or blocker", + }, + "refs": map[string]any{"context_refs": []any{"multica:issue:root-dupe"}}, + }, + map[string]any{ + "id": "local/planner-team/5", + "ingest_seq": float64(33), + "actor": "planner@team", + "rule": map[string]any{ + "assignment_id": "assignment-tea30-root-runtime", + "assignee": "researcher@team", + "scope": "multica-hub-readiness-drill/TEA-30/stage1/root-metadata-run-visibility", + "ttl": "30m", + }, + "narrative": map[string]any{ + "expected_work": "Validate root metadata visibility.", + "expected_feedback": "progress_digest with result or blocker", + }, + "refs": map[string]any{"context_refs": []any{"multica:issue:root-dupe"}}, + }, + }}, + }, { + Ref: contract.ResourceRef{Kind: "progress_digest", ID: "project"}, + Fields: map[string]any{"items": []any{ + map[string]any{ + "id": "pg-routing", + "event_id": "pg-routing", + "ingest_seq": float64(34), + "actor": "implementer@team", + "assignment_ref": "assignment-tea30-root-runtime", + "feedback_kind": "result", + "summary": "Routing slice completed by implementer.", + "result": "Child routing isolation passed.", + }, + map[string]any{ + "id": "pg-metadata", + "event_id": "pg-metadata", + "ingest_seq": float64(35), + "actor": "researcher@team", + "assignment_ref": "assignment-tea30-root-runtime", + "feedback_kind": "result", + "summary": "Metadata slice completed by researcher.", + "result": "Root metadata visibility passed.", + }, + }}, + }}, + }) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + + input := `{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"input":[{"type":"text","text":"Your assigned issue ID is: root-dupe"}]}}` + "\n" + var out bytes.Buffer + err := runRuntime(runtimeConfig{ + Args: []string{"app-server", "--listen", "stdio://"}, + Env: runtimeTestEnv( + "MNEMON_MULTICA_BIN="+bin, + "MNEMON_HUB_BACKEND=multica", + "MNEMON_CONTROL_ADDR="+srv.URL, + "MNEMON_CONTROL_PRINCIPAL=planner@team", + "MNEMON_MULTICA_REGISTRY="+registryPath, + "MNEMON_MULTICA_HUB_LEDGER="+filepath.Join(tmp, "hub-ledger.jsonl"), + "MULTICA_ARGS_PATH="+argsPath, + "MULTICA_ROOT_COMMENT_PATH="+rootCommentPath, + "MULTICA_ROUTING_COMMENT_PATH="+routingCommentPath, + "MULTICA_METADATA_COMMENT_PATH="+metadataCommentPath, + "MULTICA_TASK_ID=task-root-dupe", + "MULTICA_AGENT_ID=agent-planner", + ), + CWD: tmp, + Stdin: strings.NewReader(input), + Stdout: &out, + Now: fixedRuntimeTime, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Multica updates: 2 assignment mailboxes and 2 feedback comments synced") { + t.Fatalf("runtime output missing duplicate-id hub write evidence:\n%s", out.String()) + } + args := mustReadRuntimeTestFile(t, argsPath) + for _, want := range []string{ + "issue create --title TEA-30: child routing isolation --output json --description-stdin --parent root-dupe --status in_progress --priority medium", + "issue create --title TEA-30: root metadata run visibility --output json --description-stdin --parent root-dupe --status in_progress --priority medium", + "issue comment add child-routing --content-stdin --output json", + "issue comment add child-metadata --content-stdin --output json", + } { + if !strings.Contains(args, want) { + t.Fatalf("args missing %q:\n%s", want, args) + } + } + routingComment := mustReadRuntimeTestFile(t, routingCommentPath) + if !strings.Contains(routingComment, "Routing slice completed by implementer.") || strings.Contains(routingComment, "Metadata slice completed by researcher.") { + t.Fatalf("routing child received wrong feedback:\n%s", routingComment) + } + metadataComment := mustReadRuntimeTestFile(t, metadataCommentPath) + if !strings.Contains(metadataComment, "Metadata slice completed by researcher.") || strings.Contains(metadataComment, "Routing slice completed by implementer.") { + t.Fatalf("metadata child received wrong feedback:\n%s", metadataComment) + } +} + +func TestRuntimeContinuesProjectingAssignmentMailboxesAfterSupplementalMetadataFailure(t *testing.T) { + tmp := t.TempDir() + argsPath := filepath.Join(tmp, "multica.args") + commentPath := filepath.Join(tmp, "comment.txt") + createDescriptionPath := filepath.Join(tmp, "child-description.txt") + bin := filepath.Join(tmp, "multica") + script := `#!/usr/bin/env sh +printf '%s\n' "$*" >> "$MULTICA_ARGS_PATH" +case "$*" in + *"issue get root-retry"*) printf '{"id":"root-retry","identifier":"TEA-12","title":"Retry child projection","description":"Split retry validation across two assignees.","status":"todo","priority":"medium"}\n' ;; + *"issue metadata set root-retry"*) printf '{}\n' ;; + *"issue children root-retry"*) printf '{"children":[]}\n' ;; + *"issue create --title TEA-12: first slice"*) cat > "$MULTICA_CREATE_DESCRIPTION_PATH"; printf '{"id":"child-one","identifier":"TEA-13","title":"TEA-12: first slice","status":"todo","metadata":{}}\n' ;; + *"issue create --title TEA-12: second slice"*) cat > "$MULTICA_CREATE_DESCRIPTION_PATH"; printf '{"id":"child-two","identifier":"TEA-14","title":"TEA-12: second slice","status":"todo","metadata":{}}\n' ;; + *"issue metadata set child-one --key mnemon.projection_owner"*) printf 'metadata conflict\n' >&2; exit 42 ;; + *"issue metadata set child-one"*) printf '{}\n' ;; + *"issue metadata set child-two"*) printf '{}\n' ;; + *"issue assign child-one"*) printf '{"id":"child-one","status":"in_progress"}\n' ;; + *"issue assign child-two"*) printf '{"id":"child-two","status":"in_progress"}\n' ;; + *"issue comment add root-retry"*) cat > "$MULTICA_COMMENT_PATH"; printf '{"id":"comment-root-retry","issue_id":"root-retry","content":"ok","type":"comment"}\n' ;; + *) printf '{}\n' ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + registryPath := filepath.Join(tmp, "registry.json") + if err := driver.SaveMulticaRegistry(registryPath, driver.MulticaRegistry{ + SchemaVersion: 1, + WorkspaceID: "ws-1", + Participants: []driver.MulticaParticipantRecord{{ + Principal: "worker@team", + AgentName: "mnemon-worker", + AgentID: "agent-worker", + Role: "implementer", + }}, + }); err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/ingest": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(access.IngestReceipt{Seq: 31, Dup: false, Ticked: true}) + case "/presentation-view": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(pview.View{ + Ref: "view-retry-assignments", + Digest: "digest-retry-assignments", + Content: []pview.ResourceContent{{ + Ref: contract.ResourceRef{Kind: "assignment", ID: "project"}, + Fields: map[string]any{"items": []any{ + map[string]any{ + "id": "asg-one", + "ingest_seq": float64(32), + "actor": "planner@team", + "rule": map[string]any{ + "assignment_id": "asg-one", + "assignee": "worker@team", + "scope": "first slice", + "ttl": "30m", + }, + "narrative": map[string]any{ + "expected_work": "check the first projection slice", + "expected_feedback": "progress_digest with result or blocker", + }, + "refs": map[string]any{"context_refs": []any{"multica:issue:root-retry"}}, + }, + map[string]any{ + "id": "asg-two", + "ingest_seq": float64(33), + "actor": "planner@team", + "rule": map[string]any{ + "assignment_id": "asg-two", + "assignee": "worker@team", + "scope": "second slice", + "ttl": "30m", + }, + "narrative": map[string]any{ + "expected_work": "check the second projection slice", + "expected_feedback": "progress_digest with result or blocker", + }, + "refs": map[string]any{"context_refs": []any{"multica:issue:root-retry"}}, + }, + }}, + }}, + }) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + + input := `{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"input":[{"type":"text","text":"Your assigned issue ID is: root-retry"}]}}` + "\n" + var out bytes.Buffer + err := runRuntime(runtimeConfig{ + Args: []string{"app-server", "--listen", "stdio://"}, + Env: runtimeTestEnv( + "MNEMON_MULTICA_BIN="+bin, + "MNEMON_HUB_BACKEND=multica", + "MNEMON_CONTROL_ADDR="+srv.URL, + "MNEMON_CONTROL_PRINCIPAL=planner@team", + "MNEMON_MULTICA_REGISTRY="+registryPath, + "MNEMON_MULTICA_HUB_LEDGER="+filepath.Join(tmp, "hub-ledger.jsonl"), + "MULTICA_ARGS_PATH="+argsPath, + "MULTICA_COMMENT_PATH="+commentPath, + "MULTICA_CREATE_DESCRIPTION_PATH="+createDescriptionPath, + "MULTICA_TASK_ID=task-root-retry", + "MULTICA_AGENT_ID=agent-planner", + ), + CWD: tmp, + Stdin: strings.NewReader(input), + Stdout: &out, + Now: fixedRuntimeTime, + }) + if err != nil { + t.Fatal(err) + } + output := out.String() + if !strings.Contains(output, "Multica updates: failed") { + t.Fatalf("runtime should surface the partial metadata failure:\n%s", output) + } + args := mustReadRuntimeTestFile(t, argsPath) + for _, want := range []string{ + "issue create --title TEA-12: first slice --output json --description-stdin --parent root-retry --status in_progress --priority medium", + "issue assign child-one --to-id agent-worker --output json", + "issue create --title TEA-12: second slice --output json --description-stdin --parent root-retry --status in_progress --priority medium", + "issue assign child-two --to-id agent-worker --output json", + } { + if !strings.Contains(args, want) { + t.Fatalf("args missing %q:\n%s", want, args) + } + } + if got := strings.Count(args, "issue metadata set child-one --key mnemon.projection_owner"); got != 3 { + t.Fatalf("supplemental metadata should be retried three times, got %d:\n%s", got, args) + } +} + +func TestRuntimeProjectsProgressToExistingAssignmentMailboxFromHubMetadata(t *testing.T) { + tmp := t.TempDir() + argsPath := filepath.Join(tmp, "multica.args") + rootCommentPath := filepath.Join(tmp, "root-comment.txt") + childCommentPath := filepath.Join(tmp, "child-comment.txt") + bin := filepath.Join(tmp, "multica") + script := `#!/usr/bin/env sh +printf '%s\n' "$*" >> "$MULTICA_ARGS_PATH" +case "$*" in + *"issue get root-existing"*) printf '{"id":"root-existing","identifier":"TEA-20","title":"Existing assignment mailbox","description":"Project progress after runtime restart.","status":"todo","priority":"medium"}\n' ;; + *"issue metadata set root-existing"*) printf '{}\n' ;; + *"issue children root-existing"*) printf '{"children":[{"id":"child-existing","identifier":"TEA-21","title":"TEA-20: existing mailbox","status":"todo","metadata":{"mnemon.hub_backend":"multica","mnemon.kind":"assignment_mailbox","mnemon.session_id":"multica:session:root-existing","mnemon.assignment_id":"asg-existing","mnemon.principal":"worker@team","mnemon.root_issue_id":"root-existing"}}]}\n' ;; + *"issue comment add child-existing"*) cat > "$MULTICA_CHILD_COMMENT_PATH"; printf '{"id":"comment-child-existing","issue_id":"child-existing","content":"ok","type":"comment"}\n' ;; + *"issue comment add root-existing"*) cat > "$MULTICA_ROOT_COMMENT_PATH"; printf '{"id":"comment-root-existing","issue_id":"root-existing","content":"ok","type":"comment"}\n' ;; + *"issue status child-existing"*) printf '{"id":"child-existing","status":"done"}\n' ;; + *"issue status root-existing"*) printf '{"id":"root-existing","status":"done"}\n' ;; + *) printf '{}\n' ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + registryPath := filepath.Join(tmp, "registry.json") + if err := driver.SaveMulticaRegistry(registryPath, driver.MulticaRegistry{ + SchemaVersion: 1, + WorkspaceID: "ws-1", + Participants: []driver.MulticaParticipantRecord{{ + Principal: "worker@team", + AgentName: "mnemon-worker", + AgentID: "agent-worker", + Role: "implementer", + }}, + }); err != nil { + t.Fatal(err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/ingest": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(access.IngestReceipt{Seq: 41, Dup: false, Ticked: true}) + case "/presentation-view": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(pview.View{ + Ref: "view-existing-progress", + Digest: "digest-existing-progress", + Content: []pview.ResourceContent{{ + Ref: contract.ResourceRef{Kind: "progress_digest", ID: "project"}, + Fields: map[string]any{"items": []any{map[string]any{ + "id": "pg-existing", + "event_id": "pg-existing", + "ingest_seq": float64(42), + "actor": "worker@team", + "assignment_ref": "asg-existing", + "feedback_kind": "result", + "summary": "Validated the existing assignment mailbox after restart.", + "result": "Existing Multica child issue received feedback without a local assignment ledger entry.", + }}}, + }}, + }) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + + input := `{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"input":[{"type":"text","text":"Your assigned issue ID is: root-existing"}]}}` + "\n" + var out bytes.Buffer + err := runRuntime(runtimeConfig{ + Args: []string{"app-server", "--listen", "stdio://"}, + Env: runtimeTestEnv( + "MNEMON_MULTICA_BIN="+bin, + "MNEMON_HUB_BACKEND=multica", + "MNEMON_CONTROL_ADDR="+srv.URL, + "MNEMON_CONTROL_PRINCIPAL=planner@team", + "MNEMON_MULTICA_REGISTRY="+registryPath, + "MNEMON_MULTICA_HUB_LEDGER="+filepath.Join(tmp, "empty-hub-ledger.jsonl"), + "MULTICA_ARGS_PATH="+argsPath, + "MULTICA_ROOT_COMMENT_PATH="+rootCommentPath, + "MULTICA_CHILD_COMMENT_PATH="+childCommentPath, + "MULTICA_TASK_ID=task-root-existing", + "MULTICA_AGENT_ID=agent-planner", + ), + CWD: tmp, + Stdin: strings.NewReader(input), + Stdout: &out, + Now: fixedRuntimeTime, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Multica updates: 1 feedback comment posted") { + t.Fatalf("runtime output missing recovered feedback projection:\n%s", out.String()) + } + args := mustReadRuntimeTestFile(t, argsPath) + if strings.Contains(args, "issue create") || strings.Contains(args, "issue assign child-existing") { + t.Fatalf("existing assignment mailbox should be reused, not recreated or reassigned:\n%s", args) + } + for _, want := range []string{ + "issue children root-existing --output json", + "issue comment add child-existing --content-stdin --output json", + "issue status child-existing done --output json", + "issue status root-existing done --output json", + } { + if !strings.Contains(args, want) { + t.Fatalf("args missing %q:\n%s", want, args) + } + } + childComment := mustReadRuntimeTestFile(t, childCommentPath) + for _, want := range []string{ + "Mnemon update: assignment feedback", + "Status: result", + "Existing Multica child issue received feedback without a local assignment ledger entry.", + "mnemon:event=pg-existing", + "mnemon:type=progress_digest.accepted", + "mnemon:session=multica:session:root-existing", + "mnemon:assignment=asg-existing", + } { + if !strings.Contains(childComment, want) { + t.Fatalf("child comment missing %q:\n%s", want, childComment) + } + } + rootComment := mustReadRuntimeTestFile(t, rootCommentPath) + for _, want := range []string{ + "Projection status: feedback posted", + "Feedback comments added: 1", + } { + if !strings.Contains(rootComment, want) { + t.Fatalf("root comment missing %q:\n%s", want, rootComment) + } + } +} + +func TestRuntimeRepairsProgressStatusWhenFeedbackLedgerAlreadyExists(t *testing.T) { + tmp := t.TempDir() + argsPath := filepath.Join(tmp, "multica.args") + rootCommentPath := filepath.Join(tmp, "root-comment.txt") + bin := filepath.Join(tmp, "multica") + script := `#!/usr/bin/env sh +printf '%s\n' "$*" >> "$MULTICA_ARGS_PATH" +case "$*" in + *"issue get root-existing"*) printf '{"id":"root-existing","identifier":"TEA-20","title":"Existing assignment mailbox","description":"Repair status after restart.","status":"in_review","priority":"medium"}\n' ;; + *"issue metadata set root-existing"*) printf '{}\n' ;; + *"issue children root-existing"*) printf '{"children":[{"id":"child-existing","identifier":"TEA-21","title":"TEA-20: existing mailbox","status":"in_progress","metadata":{"mnemon.hub_backend":"multica","mnemon.kind":"assignment_mailbox","mnemon.session_id":"multica:session:root-existing","mnemon.assignment_id":"asg-existing","mnemon.principal":"worker@team","mnemon.root_issue_id":"root-existing"}}]}\n' ;; + *"issue comment add child-existing"*) printf 'unexpected duplicate child comment\n' >&2; exit 7 ;; + *"issue comment add root-existing"*) cat > "$MULTICA_ROOT_COMMENT_PATH"; printf '{"id":"comment-root-existing","issue_id":"root-existing","content":"ok","type":"comment"}\n' ;; + *"issue status child-existing"*) printf '{"id":"child-existing","status":"done"}\n' ;; + *"issue status root-existing"*) printf '{"id":"root-existing","status":"done"}\n' ;; + *) printf '{}\n' ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + ledgerPath := filepath.Join(tmp, "hub-ledger.jsonl") + ledger := driver.NewFileMulticaHubLedger(ledgerPath) + if err := ledger.Record(driver.MulticaHubLedgerRecord{ + Kind: driver.MulticaHubKindFeedbackCarrier, + Source: driver.MulticaHubLedgerSource{ + SessionID: "multica:session:root-existing", + CorrelationID: "multica:issue:root-existing", + EventID: "pg-existing", + AssignmentID: "asg-existing", + Principal: "worker@team", + ProjectionKind: "progress", + }, + Target: driver.MulticaHubLedgerTarget{ + RootIssueID: "root-existing", + ChildIssueID: "child-existing", + CommentID: "comment-already-projected", + Status: "commented", + }, + }); err != nil { + t.Fatal(err) + } + registryPath := filepath.Join(tmp, "registry.json") + if err := driver.SaveMulticaRegistry(registryPath, driver.MulticaRegistry{ + SchemaVersion: 1, + WorkspaceID: "ws-1", + Participants: []driver.MulticaParticipantRecord{{ + Principal: "worker@team", + AgentName: "mnemon-worker", + AgentID: "agent-worker", + Role: "implementer", + }}, + }); err != nil { + t.Fatal(err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/ingest": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(access.IngestReceipt{Seq: 41, Dup: false, Ticked: true}) + case "/presentation-view": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(pview.View{ + Ref: "view-existing-progress", + Digest: "digest-existing-progress", + Content: []pview.ResourceContent{{ + Ref: contract.ResourceRef{Kind: "progress_digest", ID: "project"}, + Fields: map[string]any{"items": []any{map[string]any{ + "id": "pg-existing", + "event_id": "pg-existing", + "ingest_seq": float64(42), + "actor": "worker@team", + "assignment_ref": "asg-existing", + "feedback_kind": "result", + "summary": "Validated the existing assignment mailbox after restart.", + "result": "Existing Multica child issue received feedback without a local assignment ledger entry.", + }}}, + }}, + }) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer srv.Close() + + input := `{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"input":[{"type":"text","text":"Your assigned issue ID is: root-existing"}]}}` + "\n" + var out bytes.Buffer + err := runRuntime(runtimeConfig{ + Args: []string{"app-server", "--listen", "stdio://"}, + Env: runtimeTestEnv( + "MNEMON_MULTICA_BIN="+bin, + "MNEMON_HUB_BACKEND=multica", + "MNEMON_CONTROL_ADDR="+srv.URL, + "MNEMON_CONTROL_PRINCIPAL=planner@team", + "MNEMON_MULTICA_REGISTRY="+registryPath, + "MNEMON_MULTICA_HUB_LEDGER="+ledgerPath, + "MULTICA_ARGS_PATH="+argsPath, + "MULTICA_ROOT_COMMENT_PATH="+rootCommentPath, + "MULTICA_TASK_ID=task-root-existing", + "MULTICA_AGENT_ID=agent-planner", + ), + CWD: tmp, + Stdin: strings.NewReader(input), + Stdout: &out, + Now: fixedRuntimeTime, + }) + if err != nil { + t.Fatal(err) + } + args := mustReadRuntimeTestFile(t, argsPath) + if strings.Contains(args, "issue comment add child-existing") { + t.Fatalf("existing feedback ledger should suppress duplicate child comment:\n%s", args) + } + for _, want := range []string{ + "issue status child-existing done --output json", + "issue status root-existing done --output json", + } { + if !strings.Contains(args, want) { + t.Fatalf("args missing status repair %q:\n%s", want, args) + } } } @@ -695,7 +1534,8 @@ func TestRuntimeCorrelatesAssignmentMailboxWithoutNewIngest(t *testing.T) { script := `#!/usr/bin/env sh printf '%s\n' "$*" >> "$MULTICA_ARGS_PATH" case "$*" in - *"issue get child-1"*) printf '{"id":"child-1","identifier":"TEA-2","title":"Assignment mailbox","description":"Visible assignment summary only.","status":"todo","metadata":{"mnemon.hub_backend":"multica","mnemon.kind":"assignment_mailbox","mnemon.session_id":"multica:session:root-1","mnemon.root_issue_id":"root-1","mnemon.assignment_id":"asg-1","mnemon.assignment_fingerprint":"sha256:abc","mnemon.event_id":"event-assignment-1","mnemon.principal":"worker@team"}}\n' ;; + *"issue get child-1"*) printf '{"id":"child-1","identifier":"TEA-2","title":"Assignment mailbox","description":"Visible assignment summary only.","status":"todo","metadata":{}}\n' ;; + *"issue metadata list child-1"*) printf '[{"key":"mnemon.hub_backend","value":"multica"},{"key":"mnemon.kind","value":"assignment_mailbox"},{"key":"mnemon.session_id","value":"multica:session:root-1"},{"key":"mnemon.root_issue_id","value":"root-1"},{"key":"mnemon.assignment_id","value":"asg-1"},{"key":"mnemon.assignment_fingerprint","value":"sha256:abc"},{"key":"mnemon.event_id","value":"event-assignment-1"},{"key":"mnemon.event_type","value":"assignment.accepted"},{"key":"mnemon.principal","value":"worker@team"}]\n' ;; *"issue comment add child-1"*) cat > "$MULTICA_COMMENT_PATH"; printf '{"id":"comment-child","issue_id":"child-1","content":"ok","type":"comment"}\n' ;; *) printf '{}\n' ;; esac @@ -776,36 +1616,52 @@ esac if ingestCalled { t.Fatal("assignment mailbox dispatch must not ingest a new teamwork signal") } - if !strings.Contains(out.String(), "Mnemon assignment mailbox: correlated assignment=asg-1") || !strings.Contains(out.String(), "Managed wake: completed turn=noop-turn") { + if !strings.Contains(out.String(), "Mnemon assignment mailbox: correlated") || !strings.Contains(out.String(), "Managed wake: completed") { t.Fatalf("runtime output missing correlation/wake evidence:\n%s", out.String()) } + args := mustReadRuntimeTestFile(t, argsPath) + if !strings.Contains(args, "issue metadata list child-1 --output json") { + t.Fatalf("assignment mailbox classification must query Multica issue metadata:\n%s", args) + } comment := mustReadRuntimeTestFile(t, commentPath) for _, want := range []string{ "Mnemon update: assignment mailbox correlated", - "Assignment: asg-1", + "## Mnemon Runtime", + "Root issue: [root-1](mention://issue/root-1)", "mnemon:event=event-assignment-1", - "Managed wake: completed", + "mnemon:type=assignment.accepted", + "mnemon:session=multica:session:root-1", + "mnemon:assignment=asg-1", + "Managed wake: `completed`", } { if !strings.Contains(comment, want) { t.Fatalf("comment missing %q:\n%s", want, comment) } } + for _, blocked := range []string{"Assignment: `asg-1`", "Session: `multica:session:root-1`"} { + if strings.Contains(comment, blocked) { + t.Fatalf("assignment mailbox comment must not expose machine field %q:\n%s", blocked, comment) + } + } } func TestMulticaStatusForProgressIsRuleBased(t *testing.T) { for _, tc := range []struct { name string - item runtimeProgress + item multicasurface.RuntimeProgressItem want string }{ - {name: "progress", item: runtimeProgress{FeedbackKind: "progress"}, want: "in_progress"}, - {name: "result", item: runtimeProgress{FeedbackKind: "result"}, want: "done"}, - {name: "blocker", item: runtimeProgress{FeedbackKind: "blocker"}, want: "blocked"}, - {name: "blocker narrative fallback", item: runtimeProgress{Blocker: "waiting on access"}, want: "blocked"}, - {name: "result narrative fallback", item: runtimeProgress{Result: "validated"}, want: "done"}, - {name: "unknown", item: runtimeProgress{Summary: "not enough signal"}, want: ""}, + {name: "progress", item: multicasurface.RuntimeProgressItem{FeedbackKind: "progress"}, want: "in_progress"}, + {name: "waiting", item: multicasurface.RuntimeProgressItem{FeedbackKind: "waiting"}, want: "todo"}, + {name: "review", item: multicasurface.RuntimeProgressItem{FeedbackKind: "review"}, want: "in_review"}, + {name: "result", item: multicasurface.RuntimeProgressItem{FeedbackKind: "result"}, want: "done"}, + {name: "blocker", item: multicasurface.RuntimeProgressItem{FeedbackKind: "blocker"}, want: "blocked"}, + {name: "canceled", item: multicasurface.RuntimeProgressItem{FeedbackKind: "canceled"}, want: "cancelled"}, + {name: "blocker narrative fallback", item: multicasurface.RuntimeProgressItem{Blocker: "waiting on access"}, want: "blocked"}, + {name: "result narrative fallback", item: multicasurface.RuntimeProgressItem{Result: "validated"}, want: "done"}, + {name: "unknown", item: multicasurface.RuntimeProgressItem{Summary: "not enough signal"}, want: ""}, } { - if got := multicaStatusForProgress(tc.item); got != tc.want { + if got := multicasurface.ProgressIssueStatus(multicasurface.ProgressFeedbackMaterialForRuntimeItem(tc.item)); got != tc.want { t.Fatalf("%s: status = %q, want %q", tc.name, got, tc.want) } } diff --git a/harness/cmd/mnemon-multica-runtime/probe.go b/harness/cmd/mnemon-multica-runtime/probe.go index b526f55a..e8321a44 100644 --- a/harness/cmd/mnemon-multica-runtime/probe.go +++ b/harness/cmd/mnemon-multica-runtime/probe.go @@ -10,6 +10,8 @@ import ( "runtime" "strings" "time" + + multicasurface "github.com/mnemon-dev/mnemon/harness/internal/surface/multica" ) type probeRecorder struct { @@ -64,7 +66,7 @@ func runRuntimeProbe(cfg runtimeConfig) error { return err } if wantsVersion(cfg.Args) { - fmt.Fprintf(cfg.Stdout, "mnemon-multica-runtime %s (probe mode)\n", runtimeVersion) + fmt.Fprintf(cfg.Stdout, "%s %s (probe mode)\n", multicasurface.MulticaRuntimeCommandName, runtimeVersion) return rec.record(probeEvent{Kind: "version"}) } return runProbeRPC(cfg, rec, cwd) @@ -120,7 +122,7 @@ func (s *probeRPCState) handle(msg rpcMessage) []rpcMessage { return []rpcMessage{{ ID: msg.ID, Result: map[string]any{ - "userAgent": "mnemon-multica-runtime/" + runtimeVersion + " probe", + "userAgent": multicasurface.MulticaRuntimeCommandName + "/" + runtimeVersion + " probe", "codexHome": os.Getenv("CODEX_HOME"), "platformFamily": "unix", "platformOs": runtime.GOOS, @@ -133,7 +135,7 @@ func (s *probeRPCState) handle(msg rpcMessage) []rpcMessage { Method: "remoteControl/status/changed", Params: map[string]any{ "status": "disabled", - "serverName": "mnemon-multica-runtime", + "serverName": multicasurface.MulticaRuntimeCommandName, "installationId": "mnemon-runtime-probe", }, }, @@ -306,9 +308,9 @@ func extractProbeInput(params map[string]any) string { } func newProbeRecorder(env []string, cwd string, now func() time.Time) (*probeRecorder, error) { - path := envValue(env, "MNEMON_MULTICA_PROBE_LOG") + path := multicasurface.RuntimeEnvValue(env, "MNEMON_MULTICA_PROBE_LOG") if path == "" { - dir := envValue(env, "MNEMON_MULTICA_PROBE_DIR") + dir := multicasurface.RuntimeEnvValue(env, "MNEMON_MULTICA_PROBE_DIR") if dir == "" { home, err := os.UserHomeDir() if err != nil || strings.TrimSpace(home) == "" { diff --git a/harness/cmd/mnemond/agent.go b/harness/cmd/mnemond/agent.go index 93375342..2b701832 100644 --- a/harness/cmd/mnemond/agent.go +++ b/harness/cmd/mnemond/agent.go @@ -21,6 +21,9 @@ func runAgent(ctx context.Context, args []string, out, errw io.Writer) error { return fmt.Errorf("agent requires a subcommand") } switch args[0] { + case "help", "--help", "-help", "-h": + writeAgentHelp(errw) + return flag.ErrHelp case "run": return runAgentRun(ctx, args[1:], out, errw) default: @@ -28,6 +31,16 @@ func runAgent(ctx context.Context, args []string, out, errw io.Writer) error { } } +func writeAgentHelp(errw io.Writer) { + fmt.Fprintln(errw, "mnemond agent commands operate local managed-agent drive sources for one local principal.") + fmt.Fprintln(errw) + fmt.Fprintln(errw, "Usage:") + fmt.Fprintln(errw, " mnemond agent run [flags]") + fmt.Fprintln(errw) + fmt.Fprintln(errw, "Commands:") + fmt.Fprintln(errw, " run Render local wake candidates and send only [mnemon:wake] to the selected managed runtime") +} + func runAgentRun(ctx context.Context, args []string, out, errw io.Writer) error { fs := flag.NewFlagSet("mnemond agent run", flag.ContinueOnError) fs.SetOutput(errw) @@ -45,6 +58,12 @@ func runAgentRun(ctx context.Context, args []string, out, errw io.Writer) error 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") + fs.Usage = func() { + fmt.Fprintln(errw, "mnemond agent run is a local drive source: it renders wake candidates and sends only the [mnemon:wake] sentinel to the selected managed runtime.") + fmt.Fprintln(errw) + fmt.Fprintln(errw, "Usage of mnemond agent run:") + fs.PrintDefaults() + } if err := fs.Parse(args); err != nil { return err } diff --git a/harness/cmd/mnemond/daemon.go b/harness/cmd/mnemond/daemon.go index 97f0d4c6..4ecb50e6 100644 --- a/harness/cmd/mnemond/daemon.go +++ b/harness/cmd/mnemond/daemon.go @@ -24,10 +24,18 @@ func daemonPaths(root string) (dir, pidPath, logPath string) { } // rootFlag parses --root for the lifecycle verbs that take no serve flags (down/status/logs). -func rootFlag(args []string, errw io.Writer) (string, error) { - fs := flag.NewFlagSet("mnemond", flag.ContinueOnError) +func rootFlag(args []string, errw io.Writer, verb ...string) (string, error) { + name := "mnemond" + if len(verb) > 0 && strings.TrimSpace(verb[0]) != "" { + name = "mnemond " + strings.TrimSpace(verb[0]) + } + fs := flag.NewFlagSet(name, flag.ContinueOnError) fs.SetOutput(errw) root := fs.String("root", ".", "project root") + fs.Usage = func() { + fmt.Fprintf(errw, "Usage of %s:\n", name) + fs.PrintDefaults() + } if err := fs.Parse(args); err != nil { return "", err } @@ -71,6 +79,9 @@ func daemonUp(args []string, out, errw io.Writer) error { if pid, alive := readLivePid(pidPath); alive { return fmt.Errorf("already running (pid %d); run `mnemond down` first", pid) } + if err := ensureListenAddrAvailable(cfg.listenAddr); err != nil { + return err + } if err := os.MkdirAll(dir, 0o700); err != nil { return err } @@ -107,6 +118,14 @@ func daemonUp(args []string, out, errw io.Writer) error { return nil } +func ensureListenAddrAvailable(addr string) error { + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("listen address %s is unavailable: %w", addr, err) + } + return ln.Close() +} + // waitListening confirms the detached child came up: it polls for the child to accept a TCP // connection on its listen address (a strong readiness signal that also catches a bind failure), // failing fast if the child exits during startup. @@ -129,7 +148,7 @@ func waitListening(pid int, addr string) error { // traps for graceful shutdown), waits for it to exit, and removes the pidfile. A stale or absent // pidfile is reported, not an error — `down` is idempotent. func daemonDown(args []string, out, errw io.Writer) error { - root, err := rootFlag(args, errw) + root, err := rootFlag(args, errw, "down") if err != nil { return err } @@ -193,7 +212,7 @@ func daemonReload(args []string, out, errw io.Writer) error { // daemonStatus reports whether the recorded daemon is alive. func daemonStatus(args []string, out, errw io.Writer) error { - root, err := rootFlag(args, errw) + root, err := rootFlag(args, errw, "status") if err != nil { return err } @@ -208,7 +227,7 @@ func daemonStatus(args []string, out, errw io.Writer) error { // daemonLogs prints the daemon's captured stdout/stderr. func daemonLogs(args []string, out, errw io.Writer) error { - root, err := rootFlag(args, errw) + root, err := rootFlag(args, errw, "logs") if err != nil { return err } diff --git a/harness/cmd/mnemond/daemon_test.go b/harness/cmd/mnemond/daemon_test.go index b2679041..1d2aa2cb 100644 --- a/harness/cmd/mnemond/daemon_test.go +++ b/harness/cmd/mnemond/daemon_test.go @@ -2,11 +2,16 @@ package main import ( "bytes" + "context" + "io" + "net" "os" "path/filepath" "strconv" "strings" "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/app" ) // status/down/logs operate on the pidfile + logfile under .mnemon/harness/local without spawning a @@ -101,6 +106,32 @@ func TestDaemonLogsNoFileYet(t *testing.T) { } } +func TestDaemonUpRefusesOccupiedListenAddress(t *testing.T) { + root := t.TempDir() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + addr := ln.Addr().String() + if _, err := app.New(root).Setup(context.Background(), io.Discard, io.Discard, app.SetupOptions{ + Host: "codex", + Principal: "planner@team", + ControlURL: "http://" + addr, + }); err != nil { + t.Fatalf("setup: %v", err) + } + var out bytes.Buffer + err = daemonUp([]string{"--root", root, "--addr", addr}, &out, &out) + if err == nil || !strings.Contains(err.Error(), "listen address") || !strings.Contains(err.Error(), "unavailable") { + t.Fatalf("daemon up should refuse occupied address, got %v output=%q", err, out.String()) + } + _, pidPath, _ := daemonPaths(root) + if _, err := os.Stat(pidPath); !os.IsNotExist(err) { + t.Fatalf("daemon up must not write pidfile when address is occupied, stat err=%v", err) + } +} + func TestDaemonPathsUnderLocalStateDir(t *testing.T) { _, pidPath, logPath := daemonPaths("/proj") if pidPath != filepath.FromSlash("/proj/.mnemon/harness/local/mnemond.pid") { diff --git a/harness/cmd/mnemond/doctor.go b/harness/cmd/mnemond/doctor.go new file mode 100644 index 00000000..da8acd7b --- /dev/null +++ b/harness/cmd/mnemond/doctor.go @@ -0,0 +1,82 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/mnemon-dev/mnemon/harness/internal/app" +) + +func daemonDoctor(args []string, out, errw io.Writer) error { + fs := flag.NewFlagSet("mnemond doctor", flag.ContinueOnError) + fs.SetOutput(errw) + rootFlag := fs.String("root", ".", "project root") + fs.Usage = func() { + fmt.Fprintln(errw, "mnemond doctor checks the local event node setup, boot chain, and background process state.") + fmt.Fprintln(errw) + fmt.Fprintln(errw, "Usage of mnemond doctor:") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return err + } + root := strings.TrimSpace(*rootFlag) + if root == "" { + root = "." + } + root = filepath.Clean(root) + fmt.Fprintln(out, "mnemond doctor") + writeLocalConfigDoctor(out, root) + writeLocalBootDoctor(out, root) + writeBackgroundDoctor(out, root) + fmt.Fprintf(out, "- Remote Workspace: %s\n", app.RemoteWorkspaceStatus(root)) + return nil +} + +func writeLocalConfigDoctor(out io.Writer, root string) { + cfg, err := app.ReadLocalConfig(root) + if err != nil { + if os.IsNotExist(err) { + fmt.Fprintln(out, "- Local event node config: missing") + fmt.Fprintln(out, "- Setup remediation: mnemon-harness setup --host codex") + return + } + fmt.Fprintf(out, "- Local event node config: invalid (%v)\n", err) + return + } + fmt.Fprintln(out, "- Local event node config: configured") + fmt.Fprintf(out, "- Endpoint: %s\n", cfg.Endpoint) + fmt.Fprintf(out, "- Principal: %s\n", cfg.Principal) +} + +func writeLocalBootDoctor(out io.Writer, root string) { + boot, err := app.ResolveLocalBoot(root, "", "") + if err != nil { + if errors.Is(err, app.ErrLocalNotSetup) { + fmt.Fprintln(out, "- Boot chain: not ready (setup required)") + return + } + fmt.Fprintf(out, "- Boot chain: invalid (%v)\n", err) + return + } + fmt.Fprintf(out, "- Boot chain: ready (bindings=%d)\n", len(boot.Loaded.Bindings)) + fmt.Fprintf(out, "- Store: %s\n", boot.StorePath) +} + +func writeBackgroundDoctor(out io.Writer, root string) { + _, pidPath, _ := daemonPaths(root) + pid, alive := readLivePid(pidPath) + switch { + case alive: + fmt.Fprintf(out, "- Background daemon: running (pid %d)\n", pid) + case pid > 0: + fmt.Fprintf(out, "- Background daemon: stopped (stale pidfile pid %d)\n", pid) + default: + fmt.Fprintln(out, "- Background daemon: stopped") + } +} diff --git a/harness/cmd/mnemond/doctor_test.go b/harness/cmd/mnemond/doctor_test.go new file mode 100644 index 00000000..dad6ab82 --- /dev/null +++ b/harness/cmd/mnemond/doctor_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/app" +) + +func TestDoctorReportsMissingLocalConfigWithoutMutating(t *testing.T) { + root := t.TempDir() + var out bytes.Buffer + if err := daemonDoctor([]string{"--root", root}, &out, &out); err != nil { + t.Fatalf("doctor: %v", err) + } + got := out.String() + for _, want := range []string{ + "mnemond doctor", + "- Local event node config: missing", + "- Setup remediation: mnemon-harness setup --host codex", + "- Boot chain: not ready (setup required)", + "- Background daemon: stopped", + "- Remote Workspace: not connected", + } { + if !strings.Contains(got, want) { + t.Fatalf("doctor output missing %q:\n%s", want, got) + } + } + if _, err := os.Stat(filepath.Join(root, ".mnemon")); !os.IsNotExist(err) { + t.Fatalf("doctor must not create local state, stat err=%v", err) + } +} + +func TestDoctorReportsConfiguredLocalEventNode(t *testing.T) { + root := t.TempDir() + if _, err := app.New(root).Setup(context.Background(), io.Discard, io.Discard, app.SetupOptions{ + Host: "codex", + Principal: "codex@project", + ControlURL: "http://127.0.0.1:9007", + UseToken: true, + }); err != nil { + t.Fatalf("setup: %v", err) + } + var out bytes.Buffer + if err := daemonDoctor([]string{"--root", root}, &out, &out); err != nil { + t.Fatalf("doctor: %v", err) + } + got := out.String() + for _, want := range []string{ + "- Local event node config: configured", + "- Endpoint: http://127.0.0.1:9007", + "- Principal: codex@project", + "- Boot chain: ready (bindings=1)", + "- Store: " + filepath.Join(root, ".mnemon", "harness", "local", "governed.db"), + "- Background daemon: stopped", + } { + if !strings.Contains(got, want) { + t.Fatalf("doctor output missing %q:\n%s", want, got) + } + } +} + +func TestDoctorReportsBackgroundDaemonState(t *testing.T) { + root := t.TempDir() + dir, pidPath, _ := daemonPaths(root) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(pidPath, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o600); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + if err := daemonDoctor([]string{"--root", root}, &out, &out); err != nil { + t.Fatalf("doctor: %v", err) + } + if got := out.String(); !strings.Contains(got, "- Background daemon: running (pid "+strconv.Itoa(os.Getpid())+")") { + t.Fatalf("doctor did not report live daemon pid:\n%s", got) + } +} diff --git a/harness/cmd/mnemond/main.go b/harness/cmd/mnemond/main.go index 9a2f2c98..7286be89 100644 --- a/harness/cmd/mnemond/main.go +++ b/harness/cmd/mnemond/main.go @@ -1,9 +1,9 @@ -// mnemond is the LOCAL governance daemon: the standalone-daemon packaging of the exact -// `mnemon-harness local run` boot path (P1 D13 — the mnemond name now belongs to the local -// trust domain; the remote hub binary builds as mnemon-hub). It is the LOCAL trust domain -// main: it imports internal/app and shares the boot face in app/localboot.go with `local run`, -// so flags, banner, T1 loopback floor, and serve behavior stay alias-identical. One daemon per -// project store (the store's single-writer flock enforces it). +// mnemond is the Local Mnemon event node: the standalone packaging of the exact +// `mnemon-harness local run` boot path. The remote exchange binary builds as +// mnemon-hub. This main imports internal/app and shares the boot face in +// app/localboot.go with `local run`, so flags, banner, T1 loopback floor, and +// serve behavior stay alias-identical. One daemon per project store (the store's +// single-writer flock enforces it). // // mnemond is a real daemon (P2 / PD8): `up` starts the serve loop as a detached background // process (pidfile + log under .mnemon/harness/local/), `down` stops it, `status` reports it, @@ -13,6 +13,7 @@ package main import ( "context" + "errors" "flag" "fmt" "io" @@ -41,23 +42,75 @@ func run(ctx context.Context, args []string, out, errw io.Writer) error { if len(args) > 0 { switch args[0] { case "up": - return daemonUp(args[1:], out, errw) + if err := daemonUp(args[1:], out, errw); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + return nil case "down": - return daemonDown(args[1:], out, errw) + if err := daemonDown(args[1:], out, errw); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + return nil case "reload": - return daemonReload(args[1:], out, errw) + if err := daemonReload(args[1:], out, errw); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + return nil case "status": - return daemonStatus(args[1:], out, errw) + if err := daemonStatus(args[1:], out, errw); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + return nil + case "doctor": + if err := daemonDoctor(args[1:], out, errw); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + return nil case "logs": - return daemonLogs(args[1:], out, errw) + if err := daemonLogs(args[1:], out, errw); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + return nil case "agent": - return runAgent(ctx, args[1:], out, errw) + if err := runAgent(ctx, args[1:], out, errw); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + return nil + case "help": + if _, err := parseServe([]string{"--help"}, errw); err != nil && !errors.Is(err, flag.ErrHelp) { + return err + } + return nil case "serve": args = args[1:] } } cfg, err := parseServe(args, errw) if err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } return err } return serveForeground(ctx, cfg, out) @@ -86,6 +139,9 @@ func parseServe(args []string, errw io.Writer) (serveConfig, error) { allowNonLoopback := fs.Bool("allow-nonloopback", false, "explicitly allow listening on a non-loopback address (T1: loopback-only by default)") ignoreExternal := fs.Bool("ignore-external", false, "boot the embedded-only capability catalog, ignoring external packages under .mnemon/loops (each ignored package is named on stderr)") allowInsecureRemote := fs.Bool("allow-insecure-remote", false, "let the background sync worker use a plaintext http:// Remote Workspace endpoint with a non-loopback host (T2: fail-closed by default)") + fs.Usage = func() { + writeMnemondHelp(errw, fs) + } if err := fs.Parse(args); err != nil { return serveConfig{}, err } @@ -120,6 +176,31 @@ func parseServe(args []string, errw io.Writer) (serveConfig, error) { }, nil } +func writeMnemondHelp(errw io.Writer, fs *flag.FlagSet) { + fmt.Fprintln(errw, "mnemond is the Local Mnemon event node: it serves local event API, admission, state, presentation, and drive candidates.") + fmt.Fprintln(errw) + fmt.Fprintln(errw, "Usage:") + fmt.Fprintln(errw, " mnemond serve [flags]") + fmt.Fprintln(errw, " mnemond [flags]") + fmt.Fprintln(errw, " mnemond up|down|reload|status|doctor|logs [flags]") + fmt.Fprintln(errw, " mnemond agent run [flags]") + fmt.Fprintln(errw) + fmt.Fprintln(errw, "Commands:") + fmt.Fprintln(errw, " serve Run the local event node in the foreground") + fmt.Fprintln(errw, " up Start the local event node in the background") + fmt.Fprintln(errw, " down Stop the background local event node") + fmt.Fprintln(errw, " reload Restart the background local event node") + fmt.Fprintln(errw, " status Show background local event node status") + fmt.Fprintln(errw, " doctor Check local event node readiness") + fmt.Fprintln(errw, " logs Show background local event node logs") + fmt.Fprintln(errw, " agent run Local managed-agent drive source using [mnemon:wake]") + fmt.Fprintln(errw) + fmt.Fprintln(errw, "Flags:") + if fs != nil { + fs.PrintDefaults() + } +} + // serveForeground runs the governed HTTP server in the foreground until ctx cancels — the body of // `mnemond serve` and the process the daemon child runs. func serveForeground(ctx context.Context, cfg serveConfig, out io.Writer) error { diff --git a/harness/cmd/mnemond/main_test.go b/harness/cmd/mnemond/main_test.go index b61b7233..702e813e 100644 --- a/harness/cmd/mnemond/main_test.go +++ b/harness/cmd/mnemond/main_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "io" "strings" @@ -41,3 +42,71 @@ func TestRunRefusesNonLoopbackAddr(t *testing.T) { t.Fatalf("non-loopback --addr must be refused (T1), got: %v", err) } } + +func TestHelpDescribesLocalEventNode(t *testing.T) { + for _, args := range [][]string{{"--help"}, {"help"}} { + var errw bytes.Buffer + err := run(context.Background(), args, io.Discard, &errw) + if err != nil { + t.Fatalf("%v help should exit successfully, got %v", args, err) + } + got := errw.String() + for _, want := range []string{"Local Mnemon event node", "local event API", "admission", "state", "presentation", "drive candidates", "Commands:", "serve", "status", "agent run"} { + if !strings.Contains(got, want) { + t.Fatalf("%v mnemond help missing %q:\n%s", args, want, got) + } + } + } +} + +func TestAgentHelpListsLocalDriveSourceCommand(t *testing.T) { + var errw bytes.Buffer + err := run(context.Background(), []string{"agent", "--help"}, io.Discard, &errw) + if err != nil { + t.Fatalf("agent help should exit successfully, got %v", err) + } + got := errw.String() + for _, want := range []string{"local managed-agent drive sources", "mnemond agent run", "[mnemon:wake]", "managed runtime"} { + if !strings.Contains(got, want) { + t.Fatalf("agent help missing %q:\n%s", want, got) + } + } +} + +func TestLifecycleHelpExitsSuccessfully(t *testing.T) { + for _, tc := range []struct { + args []string + want string + }{ + {[]string{"serve", "--help"}, "Local Mnemon event node"}, + {[]string{"up", "--help"}, "Local Mnemon event node"}, + {[]string{"reload", "--help"}, "Local Mnemon event node"}, + {[]string{"down", "--help"}, "Usage of mnemond down"}, + {[]string{"status", "--help"}, "Usage of mnemond status"}, + {[]string{"logs", "--help"}, "Usage of mnemond logs"}, + {[]string{"doctor", "--help"}, "Usage of mnemond doctor"}, + } { + var errw bytes.Buffer + err := run(context.Background(), tc.args, io.Discard, &errw) + if err != nil { + t.Fatalf("%v help should exit successfully, got %v", tc.args, err) + } + if got := errw.String(); !strings.Contains(got, tc.want) { + t.Fatalf("%v help missing %q:\n%s", tc.args, tc.want, got) + } + } +} + +func TestAgentRunHelpFramesLocalDriveSource(t *testing.T) { + var errw bytes.Buffer + err := run(context.Background(), []string{"agent", "run", "--help"}, io.Discard, &errw) + if err != nil { + t.Fatalf("agent run help should exit successfully, got %v", err) + } + got := errw.String() + for _, want := range []string{"local drive source", "[mnemon:wake]", "managed runtime"} { + if !strings.Contains(got, want) { + t.Fatalf("agent run help missing %q:\n%s", want, got) + } + } +} diff --git a/harness/internal/activationtrace/trace.go b/harness/internal/activationtrace/trace.go new file mode 100644 index 00000000..dc539f61 --- /dev/null +++ b/harness/internal/activationtrace/trace.go @@ -0,0 +1,286 @@ +package activationtrace + +import ( + "encoding/json" + "fmt" + "strings" +) + +const ( + SourceCodexAppServer = "codex-appserver" + TextLimit = 12000 +) + +type Event struct { + Kind string `json:"kind"` + SourceRuntime string `json:"source_runtime"` + Principal string `json:"principal,omitempty"` + TurnID string `json:"turn_id,omitempty"` + ItemID string `json:"item_id,omitempty"` + Method string `json:"method,omitempty"` + ItemType string `json:"item_type,omitempty"` + Phase string `json:"phase,omitempty"` + Text string `json:"text,omitempty"` + Command string `json:"command,omitempty"` + CWD string `json:"cwd,omitempty"` + Status string `json:"status,omitempty"` + ExitCode any `json:"exit_code,omitempty"` + Output string `json:"output,omitempty"` + Item map[string]any `json:"item,omitempty"` +} + +type Sink interface { + OnManagedTurnTrace(Event) +} + +type SinkFunc func(Event) + +func (f SinkFunc) OnManagedTurnTrace(event Event) { + if f != nil { + f(event) + } +} + +func EventsFromCodexNotifications(principal string, notifications []map[string]any) []Event { + var out []Event + for _, notification := range notifications { + event, ok := eventFromCodexNotification(principal, notification) + if ok { + out = append(out, event) + } + } + return out +} + +func eventFromCodexNotification(principal string, notification map[string]any) (Event, bool) { + method := stringFromMap(notification, "method") + params, _ := notification["params"].(map[string]any) + event := Event{ + SourceRuntime: SourceCodexAppServer, + Principal: strings.TrimSpace(principal), + Method: method, + TurnID: stringFromMap(params, "turnId"), + } + switch method { + case "item/started", "item/completed": + rawItem, _ := params["item"].(map[string]any) + if len(rawItem) == 0 { + return Event{}, false + } + item := sanitizeMap(rawItem) + event.Item = item + event.ItemID = firstNonEmptyString(stringFromMap(item, "id"), stringFromMap(params, "itemId")) + event.ItemType = stringFromMap(item, "type") + event.Phase = stringFromMap(item, "phase") + event.Text = firstNonEmptyString(stringFromMap(item, "text"), stringFromMap(item, "aggregatedOutput")) + event.Command = stringFromMap(item, "command") + event.CWD = stringFromMap(item, "cwd") + event.Status = stringFromMap(item, "status") + event.ExitCode = item["exitCode"] + event.Output = stringFromMap(item, "aggregatedOutput") + event.Kind = kindForItem(event.ItemType) + return event, true + case "item/agentMessage/delta": + event.Kind = "agent_message_delta" + event.ItemID = stringFromMap(params, "itemId") + event.Text = sanitizeString("delta", stringFromMap(params, "delta")) + event.ItemType = "agentMessage" + return event, strings.TrimSpace(event.Text) != "" || strings.TrimSpace(event.ItemID) != "" + case "turn/completed": + event.Kind = "turn_completed" + event.Status = "completed" + return event, true + case "turn/failed": + event.Kind = "turn_failed" + event.Status = "failed" + event.Text = sanitizeAny("error", params["error"]) + return event, true + default: + return Event{}, false + } +} + +func kindForItem(itemType string) string { + switch strings.TrimSpace(itemType) { + case "agentMessage": + return "agent_message" + case "commandExecution": + return "command_execution" + case "fileChange", "patchApply", "patch_apply": + return "file_change" + default: + if strings.TrimSpace(itemType) == "" { + return "item" + } + return "item" + } +} + +func sanitizeMap(in map[string]any) map[string]any { + out := map[string]any{} + for key, value := range in { + out[key] = sanitizeValue(key, value) + } + return out +} + +func sanitizeValue(key string, value any) any { + if isSensitiveKey(key) { + if value == nil { + return nil + } + return "[redacted]" + } + switch typed := value.(type) { + case map[string]any: + return sanitizeMap(typed) + case []any: + out := make([]any, 0, len(typed)) + for _, item := range typed { + out = append(out, sanitizeValue(key, item)) + } + return out + case string: + return sanitizeString(key, typed) + default: + return value + } +} + +func sanitizeAny(key string, value any) string { + if value == nil { + return "" + } + if text, ok := value.(string); ok { + return sanitizeString(key, text) + } + data, err := json.Marshal(sanitizeValue(key, value)) + if err != nil { + return sanitizeString(key, fmt.Sprint(value)) + } + return sanitizeString(key, string(data)) +} + +func sanitizeString(key, value string) string { + value = redactSecrets(key, value) + if len(value) <= TextLimit { + return value + } + return value[:TextLimit] + "\n[truncated]" +} + +func redactSecrets(key, value string) string { + if isSensitiveKey(key) { + if value == "" { + return "" + } + return "[redacted]" + } + redacted := value + for _, marker := range sensitiveMarkers() { + redacted = redactMarkerAssignments(redacted, marker) + } + redacted = redactBearerTokens(redacted) + return redacted +} + +func redactBearerTokens(value string) string { + searchFrom := 0 + for { + lower := strings.ToLower(value) + if searchFrom >= len(value) { + return value + } + relative := strings.Index(lower[searchFrom:], "bearer ") + if relative < 0 { + return value + } + tokenStart := searchFrom + relative + len("bearer ") + tokenEnd := tokenStart + for tokenEnd < len(value) { + ch := value[tokenEnd] + if ch == ' ' || ch == '\n' || ch == '\t' || ch == ',' || ch == ';' || ch == '"' || ch == '\'' { + break + } + tokenEnd++ + } + if tokenEnd == tokenStart { + searchFrom = tokenEnd + continue + } + value = value[:tokenStart] + "[redacted]" + value[tokenEnd:] + searchFrom = tokenStart + len("[redacted]") + } +} + +func redactMarkerAssignments(value, marker string) string { + needle := strings.ToLower(marker) + searchFrom := 0 + for { + lower := strings.ToLower(value) + if searchFrom >= len(value) { + return value + } + relative := strings.Index(lower[searchFrom:], needle) + if relative < 0 { + return value + } + idx := searchFrom + relative + if idx < 0 { + return value + } + end := idx + len(marker) + if end >= len(value) || (value[end] != '=' && value[end] != ':') { + searchFrom = end + continue + } + if strings.HasPrefix(strings.ToLower(value[end:]), "=[redacted]") || strings.HasPrefix(strings.ToLower(value[end:]), ":[redacted]") { + searchFrom = end + len("=[redacted]") + continue + } + separator := value[end] + end++ + for end < len(value) { + ch := value[end] + if ch == ' ' || ch == '\n' || ch == '\t' || ch == ',' || ch == ';' { + break + } + end++ + } + value = value[:idx+len(marker)] + string(separator) + "[redacted]" + value[end:] + searchFrom = idx + len(marker) + len("=[redacted]") + } +} + +func isSensitiveKey(key string) bool { + key = strings.ToUpper(strings.TrimSpace(key)) + for _, marker := range sensitiveMarkers() { + if strings.Contains(key, marker) { + return true + } + } + return false +} + +func sensitiveMarkers() []string { + return []string{"TOKEN", "SECRET", "PASSWORD", "API_KEY", "AUTH", "CREDENTIAL", "COOKIE"} +} + +func stringFromMap(values map[string]any, key string) string { + if values == nil { + return "" + } + if value, ok := values[key].(string); ok { + return strings.TrimSpace(value) + } + return "" +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/harness/internal/activationtrace/trace_test.go b/harness/internal/activationtrace/trace_test.go new file mode 100644 index 00000000..4eab3627 --- /dev/null +++ b/harness/internal/activationtrace/trace_test.go @@ -0,0 +1,74 @@ +package activationtrace + +import ( + "strings" + "testing" +) + +func TestEventsFromCodexNotificationsRedactsAndBounds(t *testing.T) { + longOutput := strings.Repeat("x", TextLimit+20) + events := EventsFromCodexNotifications("planner@team", []map[string]any{ + { + "method": "item/completed", + "params": map[string]any{ + "turnId": "inner-turn", + "item": map[string]any{ + "type": "commandExecution", + "id": "call-1", + "command": "TOKEN=raw-secret curl -H 'Authorization: Bearer ghp_secret' https://api.github.com", + "cwd": "/tmp/work", + "status": "completed", + "exitCode": 0, + "aggregatedOutput": "API_KEY=abc123 authorization: bearer mat_secret " + longOutput, + "authToken": "must-not-leak", + }, + }, + }, + }) + if len(events) != 1 { + t.Fatalf("events = %+v, want one", events) + } + got := events[0] + if got.Kind != "command_execution" || got.ItemType != "commandExecution" || got.Principal != "planner@team" { + t.Fatalf("unexpected trace event: %+v", got) + } + joined := got.Command + "\n" + got.Output + "\n" + strings.TrimSpace(testString(got.Item["authToken"])) + for _, forbidden := range []string{"raw-secret", "abc123", "must-not-leak", "ghp_secret", "mat_secret"} { + if strings.Contains(joined, forbidden) { + t.Fatalf("trace leaked %q: %+v", forbidden, got) + } + } + if !strings.Contains(got.Command, "TOKEN=[redacted]") || !strings.Contains(got.Command, "Bearer [redacted]") || + !strings.Contains(got.Output, "API_KEY=[redacted]") || !strings.Contains(got.Output, "bearer [redacted]") { + t.Fatalf("trace did not redact command/output: command=%q output=%q", got.Command, got.Output[:80]) + } + if !strings.Contains(got.Output, "[truncated]") { + t.Fatalf("long output was not bounded: len=%d", len(got.Output)) + } +} + +func TestEventsFromCodexNotificationsDoesNotTreatTraceAsCanonicalEvent(t *testing.T) { + events := EventsFromCodexNotifications("planner@team", []map[string]any{ + { + "method": "turn/completed", + "params": map[string]any{"turnId": "turn-1"}, + }, + }) + if len(events) != 1 { + t.Fatalf("events = %+v, want one", events) + } + got := events[0] + if got.Kind != "turn_completed" || got.Status != "completed" { + t.Fatalf("unexpected trace event: %+v", got) + } + if got.Item != nil { + t.Fatalf("turn trace should not carry canonical event payload: %+v", got.Item) + } +} + +func testString(value any) string { + if text, ok := value.(string); ok { + return text + } + return "" +} diff --git a/harness/internal/app/sync_github_live_test.go b/harness/internal/app/sync_github_live_test.go index 2eacf5a3..13bdb5c7 100644 --- a/harness/internal/app/sync_github_live_test.go +++ b/harness/internal/app/sync_github_live_test.go @@ -1,6 +1,7 @@ package app import ( + "context" "net/http" "os" "path/filepath" @@ -26,6 +27,9 @@ func TestGitHubLivePublishPullImport(t *testing.T) { if err != nil { t.Fatalf("github publication store: %v", err) } + if err := store.EnsureBranches(context.Background(), []string{cfg.branchA, cfg.branchB}, "main"); err != nil { + t.Fatalf("ensure live GitHub branches: %v", err) + } remoteA := liveGitHubRemote(t, store, cfg.repo, cfg.branchA) remoteB := liveGitHubRemote(t, store, cfg.repo, cfg.branchB) @@ -103,11 +107,11 @@ func liveGitHubTestConfig(t *testing.T) liveGitHubConfig { } branchA := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_BRANCH_A")) if branchA == "" { - branchA = "mnemon/agent-a" + branchA = "mnemon/live/" + time.Now().UTC().Format("20060102T150405.000000000Z") + "/a" } branchB := strings.TrimSpace(os.Getenv("MNEMON_GITHUB_BRANCH_B")) if branchB == "" { - branchB = "mnemon/agent-b" + branchB = strings.TrimSuffix(branchA, "/a") + "/b" } return liveGitHubConfig{repo: repo, branchA: branchA, branchB: branchB, token: token} } diff --git a/harness/internal/coreguard/command_boundary_test.go b/harness/internal/coreguard/command_boundary_test.go new file mode 100644 index 00000000..35b6e4b8 --- /dev/null +++ b/harness/internal/coreguard/command_boundary_test.go @@ -0,0 +1,125 @@ +package coreguard + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +type commandImportBoundary struct { + dir string + forbids []string + rationale string +} + +var commandImportBoundaries = []commandImportBoundary{ + { + dir: "mnemond", + forbids: []string{ + "harness/internal/mnemonhub", + "harness/internal/productconfig", + "harness/internal/projection", + "harness/internal/surface/", + "harness/cmd/", + }, + rationale: "mnemond is the local event node, not a remote exchange backend or external projection surface", + }, + { + dir: "mnemon-hub", + forbids: []string{ + "harness/internal/activationtrace", + "harness/internal/app", + "harness/internal/codexapp", + "harness/internal/daemon", + "harness/internal/drive", + "harness/internal/driver", + "harness/internal/hostagent", + "harness/internal/mnemond/access", + "harness/internal/mnemond/admission", + "harness/internal/mnemond/presentation", + "harness/internal/productconfig", + "harness/internal/projection", + "harness/internal/runtime", + "harness/internal/surface/", + "harness/cmd/", + }, + rationale: "mnemon-hub is the remote event exchange backend and must not grow local runtime, hostagent, or projection behavior", + }, + { + dir: "mnemon-multica-runtime", + forbids: []string{ + "harness/internal/app", + "harness/internal/codexapp", + "harness/internal/eventstore", + "harness/internal/hostagent", + "harness/internal/mnemond/admission", + "harness/internal/mnemond/state", + "harness/internal/mnemonhub", + "harness/internal/productconfig", + "harness/internal/runtime", + "harness/cmd/", + }, + rationale: "mnemon-multica-runtime is an external adapter; it may call mnemond access/render APIs but must not own local state, hub exchange, or product config", + }, +} + +func TestCommandImportBoundaryGuardLogicIsNotVacuous(t *testing.T) { + if !commandImportForbidden("github.com/mnemon-dev/mnemon/harness/internal/mnemonhub", commandImportBoundaries[0].forbids) { + t.Fatal("mnemond command boundary must flag mnemonhub imports") + } + if !commandImportForbidden("github.com/mnemon-dev/mnemon/harness/internal/app", commandImportBoundaries[1].forbids) { + t.Fatal("mnemon-hub command boundary must flag app imports") + } + if !commandImportForbidden("github.com/mnemon-dev/mnemon/harness/internal/mnemond/access", commandImportBoundaries[1].forbids) { + t.Fatal("mnemon-hub command boundary must flag local mnemond access imports") + } + if commandImportForbidden("github.com/mnemon-dev/mnemon/harness/internal/mnemond/access", commandImportBoundaries[2].forbids) { + t.Fatal("mnemon-multica-runtime must be allowed to call mnemond access APIs") + } +} + +func TestServiceCommandsKeepR2Boundaries(t *testing.T) { + for _, boundary := range commandImportBoundaries { + assertCommandAvoidsForbiddenImports(t, boundary) + } +} + +func assertCommandAvoidsForbiddenImports(t *testing.T, boundary commandImportBoundary) { + t.Helper() + root := filepath.Join("..", "..", "cmd", boundary.dir) + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.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 commandImportForbidden(importPath, boundary.forbids) { + t.Errorf("%s imports forbidden package %q: %s", path, importPath, boundary.rationale) + } + } + return nil + }) + if err != nil { + t.Fatalf("walk command %s: %v", boundary.dir, err) + } +} + +func commandImportForbidden(path string, forbids []string) bool { + for _, forbidden := range forbids { + if strings.Contains(path, forbidden) { + return true + } + } + return false +} diff --git a/harness/internal/coreguard/mainaxis_test.go b/harness/internal/coreguard/mainaxis_test.go index 9fef1516..234a6d24 100644 --- a/harness/internal/coreguard/mainaxis_test.go +++ b/harness/internal/coreguard/mainaxis_test.go @@ -10,11 +10,16 @@ import ( type mainAxisOwner string const ( - ownerEvent mainAxisOwner = "event" - ownerHostAgent mainAxisOwner = "hostagent" - ownerMnemond mainAxisOwner = "mnemond" - ownerMnemonhub mainAxisOwner = "mnemonhub" - ownerGuard mainAxisOwner = "guard" + ownerEvent mainAxisOwner = "event" + ownerHostAgent mainAxisOwner = "hostagent" + ownerMnemond mainAxisOwner = "mnemond" + ownerMnemonhub mainAxisOwner = "mnemonhub" + ownerParticipant mainAxisOwner = "participant" + ownerInteractionPoint mainAxisOwner = "interaction-point" + ownerDriveSource mainAxisOwner = "drive-source" + ownerProjectionSurface mainAxisOwner = "projection-surface" + ownerProductConfig mainAxisOwner = "product-config" + ownerGuard mainAxisOwner = "guard" ) type packageMainAxis struct { @@ -25,23 +30,31 @@ type packageMainAxis struct { // packageMainAxisInventory is the executable inventory for the main-axis convergence goal. It does // not claim the current package names are final; it prevents new top-level implementation concepts -// from appearing without an explicit owner under hostagent, mnemond, mnemonhub, or event. +// from appearing without an explicit owner under the R2 architecture layers. var packageMainAxisInventory = map[string]packageMainAxis{ - "app": {owner: ownerMnemond, role: "daemon wiring and local mnemond boot", target: "mnemond"}, - "assembler": {owner: ownerMnemond, role: "mnemond policy/runtime assembly", target: "mnemond"}, - "assets": {owner: ownerHostAgent, role: "hostagent integration and event-policy assets", target: "hostagent/assets"}, - "codexapp": {owner: ownerHostAgent, role: "Codex hostagent appserver adapter", target: "hostagent/codexapp"}, - "config": {owner: ownerMnemond, role: "mnemond local configuration", target: "mnemond/config"}, - "contract": {owner: ownerEvent, role: "event and mnemond boundary DTOs", target: "event/contract"}, - "coreguard": {owner: ownerGuard, role: "architecture guard tests", target: "coreguard"}, - "driver": {owner: ownerMnemond, role: "mnemond tick driver", target: "mnemond/daemon"}, - "event": {owner: ownerEvent, role: "canonical event and envelope model", target: "event"}, - "eventstore": {owner: ownerEvent, role: "event envelope append/read facade", target: "event/store"}, - "hostagent": {owner: ownerHostAgent, role: "hostagent setup and thin shims", target: "hostagent"}, - "mnemonhub": {owner: ownerMnemonhub, role: "remote accepted event exchange server and exchange mechanics", target: "mnemonhub"}, - "replay": {owner: ownerMnemond, role: "mnemond determinism verification", target: "mnemond/replay"}, - "runtime": {owner: ownerMnemond, role: "local mnemond event runtime", target: "mnemond"}, - "ui": {owner: ownerMnemond, role: "read-only mnemond operator observability", target: "mnemond/observe"}, + "activationtrace": {owner: ownerProjectionSurface, role: "runtime activation trace material for external run displays", target: "projection/activationtrace"}, + "app": {owner: ownerMnemond, role: "daemon wiring and local mnemond boot", target: "mnemond"}, + "assembler": {owner: ownerMnemond, role: "mnemond policy/runtime assembly", target: "mnemond"}, + "assets": {owner: ownerHostAgent, role: "hostagent integration and event-policy assets", target: "hostagent/assets"}, + "codexapp": {owner: ownerHostAgent, role: "Codex hostagent appserver adapter", target: "hostagent/codexapp"}, + "config": {owner: ownerMnemond, role: "mnemond local configuration", target: "mnemond/config"}, + "contract": {owner: ownerEvent, role: "event and mnemond boundary DTOs", target: "event/contract"}, + "coreguard": {owner: ownerGuard, role: "architecture guard tests", target: "coreguard"}, + "daemon": {owner: ownerMnemond, role: "harness daemon worker supervision for local event node operation", target: "mnemond/daemon"}, + "drive": {owner: ownerDriveSource, role: "managed local agent drive source and wake loop primitives", target: "drive/managed-local"}, + "driver": {owner: ownerMnemond, role: "legacy facade for mnemond and adapter helper code during R2 cleanup", target: "mnemond/daemon"}, + "event": {owner: ownerEvent, role: "canonical event and envelope model", target: "event"}, + "eventstore": {owner: ownerEvent, role: "event envelope append/read facade", target: "event/store"}, + "hostagent": {owner: ownerHostAgent, role: "hostagent setup and thin shims", target: "hostagent"}, + "interaction": {owner: ownerInteractionPoint, role: "external stimulus material separated into rule/narrative/refs", target: "interaction/event-material"}, + "mnemonhub": {owner: ownerMnemonhub, role: "remote accepted event exchange server and exchange mechanics", target: "mnemonhub"}, + "participant": {owner: ownerParticipant, role: "principal identity and participant list helpers shared by product config and adapters", target: "participant"}, + "productconfig": {owner: ownerProductConfig, role: "harness product configuration for agents, connections, daemon workers, and surfaces", target: "product/config"}, + "projection": {owner: ownerProjectionSurface, role: "accepted/derived state material prepared for external projection surfaces", target: "projection"}, + "replay": {owner: ownerMnemond, role: "mnemond determinism verification", target: "mnemond/replay"}, + "runtime": {owner: ownerMnemond, role: "local mnemond event runtime", target: "mnemond"}, + "session": {owner: ownerProductConfig, role: "harness session records and external attachment policy", target: "product/session"}, + "ui": {owner: ownerMnemond, role: "read-only mnemond operator observability", target: "mnemond/observe"}, } var demotedMainAxisPackages = map[string]bool{} @@ -58,6 +71,7 @@ var nestedMainAxisInventory = map[string]packageMainAxis{ }, "mnemond/state": {owner: ownerMnemond, role: "local event/state store and materialized-state applier", target: "mnemond/state"}, "mnemonhub/exchange": {owner: ownerMnemonhub, role: "mnemonhub event exchange client, cursors, and local ledger acknowledgements", target: "mnemonhub/exchange"}, + "surface/multica": {owner: ownerProjectionSurface, role: "Multica surface registry, metadata, and projection ledger primitives", target: "projection/surface/multica"}, } var retiredTopLevelImplementationPackages = []string{ @@ -77,7 +91,7 @@ func TestInternalPackagesHaveMainAxisOwner(t *testing.T) { for _, pkg := range topLevelInternalPackages(t) { inv, ok := packageMainAxisInventory[pkg] if !ok { - t.Errorf("harness/internal/%s has no main-axis owner; classify it under hostagent, mnemond, mnemonhub, or event before adding a new top-level concept", pkg) + t.Errorf("harness/internal/%s has no main-axis owner; classify it under an accepted R2 layer before adding a new top-level concept", pkg) continue } if inv.owner == "" || strings.TrimSpace(inv.role) == "" || strings.TrimSpace(inv.target) == "" { diff --git a/harness/internal/coreguard/multica_runtime_role_boundary_test.go b/harness/internal/coreguard/multica_runtime_role_boundary_test.go new file mode 100644 index 00000000..1c66e908 --- /dev/null +++ b/harness/internal/coreguard/multica_runtime_role_boundary_test.go @@ -0,0 +1,112 @@ +package coreguard + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +var multicaRuntimeProjectionForbiddenImports = []string{ + "harness/internal/app", + "harness/internal/hostagent", + "harness/internal/mnemond/admission", + "harness/internal/mnemond/state", + "harness/internal/mnemonhub", + "harness/internal/productconfig", + "harness/internal/runtime", +} + +func TestMulticaRuntimeProjectionWriterDoesNotOwnIngestOrDrive(t *testing.T) { + path := filepath.Join("..", "..", "cmd", "mnemon-multica-runtime", "hub_writer.go") + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + for _, imp := range file.Imports { + importPath := strings.Trim(imp.Path.Value, `"`) + if roleImportForbidden(importPath, multicaRuntimeProjectionForbiddenImports) { + t.Errorf("Multica hub projection writer imports forbidden package %q; projection may read views and write Multica artifacts, but must not own local ingest, drive, product config, or hub exchange", importPath) + } + } + ast.Inspect(file, func(node ast.Node) bool { + switch n := node.(type) { + case *ast.SelectorExpr: + switch n.Sel.Name { + case "IngestObserve", "IngestObservedEnvelope", "Observe", "Wake": + t.Errorf("Multica hub projection writer calls %s at %s; projection must not ingest governed events or drive managed turns", n.Sel.Name, fset.Position(n.Pos())) + } + case *ast.CompositeLit: + if selectorName(n.Type) == "ManagedAgentDriver" { + t.Errorf("Multica hub projection writer constructs ManagedAgentDriver at %s; managed drive belongs outside projection", fset.Position(n.Pos())) + } + } + return true + }) +} + +func TestMulticaRuntimeRoleBoundaryGuardLogicIsNotVacuous(t *testing.T) { + if !roleImportForbidden("github.com/mnemon-dev/mnemon/harness/internal/mnemonhub", multicaRuntimeProjectionForbiddenImports) { + t.Fatal("Multica projection writer guard must flag mnemonhub imports") + } + if roleImportForbidden("github.com/mnemon-dev/mnemon/harness/internal/mnemond/access", multicaRuntimeProjectionForbiddenImports) { + t.Fatal("Multica projection writer guard must allow read-only mnemond access") + } + if selectorName(&ast.SelectorExpr{Sel: ast.NewIdent("ManagedAgentDriver")}) != "ManagedAgentDriver" { + t.Fatal("selectorName helper must identify selector names") + } +} + +func TestMulticaRuntimeDoesNotAdvertiseRootMnemonMulticaCommands(t *testing.T) { + root := filepath.Join("..", "..", "cmd", "mnemon-multica-runtime") + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + return err + } + ast.Inspect(file, func(node ast.Node) bool { + lit, ok := node.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return true + } + value, err := strconv.Unquote(lit.Value) + if err != nil { + t.Errorf("unquote string literal in %s: %v", path, err) + return true + } + for _, forbidden := range []string{"mnemon multica", "mnemon-harness multica"} { + if strings.Contains(value, forbidden) { + t.Errorf("%s advertises forbidden root/harness Multica command shape %q at %s; runtime progress should name the adapter process", path, forbidden, fset.Position(lit.Pos())) + } + } + return true + }) + return nil + }) + if err != nil { + t.Fatalf("walk Multica runtime command: %v", err) + } +} + +func selectorName(expr ast.Expr) string { + switch e := expr.(type) { + case *ast.Ident: + return e.Name + case *ast.SelectorExpr: + return e.Sel.Name + default: + return "" + } +} diff --git a/harness/internal/coreguard/r2_role_boundary_test.go b/harness/internal/coreguard/r2_role_boundary_test.go new file mode 100644 index 00000000..7408ef6e --- /dev/null +++ b/harness/internal/coreguard/r2_role_boundary_test.go @@ -0,0 +1,112 @@ +package coreguard + +import ( + "strings" + "testing" +) + +type roleImportBoundary struct { + pkg string + forbids []string + rationale string +} + +var projectionSurfaceImportBoundaries = []roleImportBoundary{ + { + pkg: "projection", + forbids: []string{ + "harness/internal/mnemond/access", + "harness/internal/mnemond/admission", + "harness/internal/mnemond/state", + "harness/internal/runtime", + "harness/internal/eventstore", + "harness/internal/app", + "harness/internal/assembler", + }, + rationale: "projection packages render accepted state into external artifacts and must not ingest governed events", + }, + { + pkg: "surface/multica", + forbids: []string{ + "harness/internal/mnemond/access", + "harness/internal/mnemond/admission", + "harness/internal/mnemond/state", + "harness/internal/runtime", + "harness/internal/eventstore", + "harness/internal/app", + "harness/internal/assembler", + }, + rationale: "the Multica surface is a projection/adapter boundary, not a local mnemond write path", + }, +} + +var activationTraceImportBoundary = roleImportBoundary{ + pkg: "activationtrace", + forbids: []string{ + "harness/internal/contract", + "harness/internal/event", + "harness/internal/eventstore", + "harness/internal/mnemond/access", + "harness/internal/mnemond/admission", + "harness/internal/mnemond/state", + "harness/internal/runtime", + }, + rationale: "activation trace is non-canonical run display material and must not become EventEnvelope material", +} + +var driverProjectionImportBoundary = roleImportBoundary{ + pkg: "driver", + forbids: []string{"harness/internal/projection"}, + rationale: "driver is a CLI adapter facade during R2 cleanup; projection formatting must stay in projection/surface packages", +} + +func TestR2RoleBoundaryGuardLogicIsNotVacuous(t *testing.T) { + if !roleImportForbidden("github.com/mnemon-dev/mnemon/harness/internal/mnemond/access", projectionSurfaceImportBoundaries[0].forbids) { + t.Fatal("projection boundary guard must flag mnemond access imports") + } + if roleImportForbidden("github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation/view", projectionSurfaceImportBoundaries[0].forbids) { + t.Fatal("projection boundary guard must allow read-only presentation view imports") + } + if !roleImportForbidden("github.com/mnemon-dev/mnemon/harness/internal/event", activationTraceImportBoundary.forbids) { + t.Fatal("activation trace boundary guard must flag event model imports") + } + if !roleImportForbidden("github.com/mnemon-dev/mnemon/harness/internal/projection", driverProjectionImportBoundary.forbids) { + t.Fatal("driver projection boundary guard must flag projection imports") + } +} + +func TestProjectionSurfacesDoNotIngestGovernedEvents(t *testing.T) { + for _, boundary := range projectionSurfaceImportBoundaries { + assertPackageAvoidsForbiddenImports(t, boundary) + } +} + +func TestActivationTraceNeverWritesEventMaterial(t *testing.T) { + assertPackageAvoidsForbiddenImports(t, activationTraceImportBoundary) +} + +func TestDriverDoesNotOwnProjectionFormatting(t *testing.T) { + assertPackageAvoidsForbiddenImports(t, driverProjectionImportBoundary) +} + +func assertPackageAvoidsForbiddenImports(t *testing.T, boundary roleImportBoundary) { + t.Helper() + _, files := packageFiles(t, boundary.pkg) + for _, f := range files { + for _, imp := range f.Imports { + path := strings.Trim(imp.Path.Value, `"`) + if roleImportForbidden(path, boundary.forbids) { + t.Errorf("package %q imports forbidden package %q: %s", boundary.pkg, path, boundary.rationale) + } + } + } +} + +func roleImportForbidden(path string, forbids []string) bool { + for _, forbidden := range forbids { + if strings.Contains(path, forbidden) { + return true + } + } + return false +} diff --git a/harness/internal/coreguard/root_cli_boundary_test.go b/harness/internal/coreguard/root_cli_boundary_test.go new file mode 100644 index 00000000..db563645 --- /dev/null +++ b/harness/internal/coreguard/root_cli_boundary_test.go @@ -0,0 +1,174 @@ +package coreguard + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +var rootCLIForbiddenCommands = map[string]bool{ + "acceptance": true, + "daemon": true, + "harness": true, + "hub": true, + "mnemond": true, + "mnemon-acceptance": true, + "mnemon-harness": true, + "mnemon-hub": true, + "mnemon-multica-runtime": true, + "mnemon-runtime-multica": true, + "mnemonhub": true, + "multica": true, + "multica-runtime-prod-sim": true, +} + +var rootCLIForbiddenSurfaceStrings = []string{ + "mnemond", + "mnemon-acceptance", + "mnemon-harness", + "mnemon-hub", + "mnemon-multica-runtime", + "mnemon-runtime-multica", + "mnemonhub", + "multica-runtime-prod-sim", +} + +func TestRootMnemonCLIDoesNotImportHarnessCluster(t *testing.T) { + for _, file := range rootCLIGoFiles(t) { + fset := token.NewFileSet() + parsed, err := parser.ParseFile(fset, file, nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse imports %s: %v", file, err) + } + for _, imp := range parsed.Imports { + importPath := strings.Trim(imp.Path.Value, `"`) + if strings.Contains(importPath, "github.com/mnemon-dev/mnemon/harness/") { + t.Errorf("root mnemon CLI imports R2 harness cluster package %q in %s", importPath, file) + } + } + } +} + +func TestRootMnemonCLIDoesNotNameR2ClusterSurfaces(t *testing.T) { + for _, file := range rootCLIGoFiles(t) { + fset := token.NewFileSet() + parsed, err := parser.ParseFile(fset, file, nil, 0) + if err != nil { + t.Fatalf("parse file %s: %v", file, err) + } + ast.Inspect(parsed, func(node ast.Node) bool { + lit, ok := node.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return true + } + value, err := strconv.Unquote(lit.Value) + if err != nil { + t.Errorf("unquote string literal in %s: %v", file, err) + return true + } + for _, forbidden := range rootCLIForbiddenSurfaceStrings { + if strings.Contains(value, forbidden) { + t.Errorf("root mnemon CLI names R2 cluster surface %q in %s; keep this phase under harness/cmd", forbidden, file) + } + } + return true + }) + } +} + +func TestRootMnemonCLIDoesNotExposeR2ClusterCommands(t *testing.T) { + for _, file := range rootCLIGoFiles(t) { + fset := token.NewFileSet() + parsed, err := parser.ParseFile(fset, file, nil, 0) + if err != nil { + t.Fatalf("parse file %s: %v", file, err) + } + ast.Inspect(parsed, func(node ast.Node) bool { + kv, ok := node.(*ast.KeyValueExpr) + if !ok { + return true + } + key, ok := kv.Key.(*ast.Ident) + if !ok || key.Name != "Use" { + return true + } + value, ok := kv.Value.(*ast.BasicLit) + if !ok || value.Kind != token.STRING { + return true + } + use, err := strconv.Unquote(value.Value) + if err != nil { + t.Errorf("unquote Use value in %s: %v", file, err) + return true + } + command := firstCommandToken(use) + if rootCLIForbiddenCommands[command] { + t.Errorf("root mnemon CLI exposes R2 cluster command %q in %s; keep it under harness/cmd", command, file) + } + return true + }) + } +} + +func TestRootCLIBoundaryGuardLogicIsNotVacuous(t *testing.T) { + for _, command := range []string{"mnemond", "mnemon-hub", "multica", "acceptance"} { + if !rootCLIForbiddenCommands[command] { + t.Fatalf("root CLI boundary guard must forbid %q", command) + } + } + for _, surface := range []string{"mnemond", "mnemon-harness", "mnemon-multica-runtime"} { + if !containsRootCLIForbiddenSurface(surface) { + t.Fatalf("root CLI surface guard must forbid %q", surface) + } + } + for _, command := range []string{"remember", "recall", "search", "setup"} { + if rootCLIForbiddenCommands[command] { + t.Fatalf("root CLI boundary guard must allow existing memory command %q", command) + } + } +} + +func containsRootCLIForbiddenSurface(surface string) bool { + for _, forbidden := range rootCLIForbiddenSurfaceStrings { + if forbidden == surface { + return true + } + } + return false +} + +func rootCLIGoFiles(t *testing.T) []string { + t.Helper() + root := filepath.Join("..", "..", "..", "cmd") + entries, err := os.ReadDir(root) + if err != nil { + t.Fatalf("read root cmd dir: %v", err) + } + var files []string + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go") { + files = append(files, filepath.Join(root, name)) + } + } + if len(files) == 0 { + t.Fatalf("root cmd dir has no non-test Go files") + } + return files +} + +func firstCommandToken(use string) string { + use = strings.TrimSpace(use) + if use == "" { + return "" + } + return strings.Fields(use)[0] +} diff --git a/harness/internal/daemon/configured.go b/harness/internal/daemon/configured.go new file mode 100644 index 00000000..6dbd79a1 --- /dev/null +++ b/harness/internal/daemon/configured.go @@ -0,0 +1,125 @@ +package daemon + +import ( + "strings" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/productconfig" +) + +type ConfiguredRoleSummary struct { + InteractionWatchers int + DriveSources int + ProjectionSurfaces int +} + +type ConfiguredRoleDetail struct { + WorkerName string + Kind WorkerKind + Label string + Value string + Boundary string +} + +func RoleSummary(cfg productconfig.Config) ConfiguredRoleSummary { + var summary ConfiguredRoleSummary + for _, role := range RoleDetails(cfg) { + switch role.Label { + case "watcher": + summary.InteractionWatchers++ + case "drive": + summary.DriveSources++ + case "surface": + summary.ProjectionSurfaces++ + } + } + return summary +} + +func RoleDetails(cfg productconfig.Config) []ConfiguredRoleDetail { + var details []ConfiguredRoleDetail + for _, watcher := range cfg.Daemon.InteractionWatchers { + watcher = strings.TrimSpace(watcher) + if watcher == "" { + continue + } + boundary := "external-interaction" + if watcher == productconfig.ConnectionMultica { + boundary = "activation-carrier" + } + if watcher == productconfig.ConnectionMnemonhub { + boundary = "remote-exchange" + } + details = append(details, ConfiguredRoleDetail{ + WorkerName: workerName(watcher + "-watch"), + Kind: WorkerInteraction, + Label: "watcher", + Value: watcher, + Boundary: boundary, + }) + } + for _, source := range cfg.Daemon.DriveSources { + source = strings.TrimSpace(source) + if source == "" { + continue + } + name := source + "-drive" + if source == productconfig.DriveManagedLocal { + name = "managed-drive" + } + details = append(details, ConfiguredRoleDetail{ + WorkerName: workerName(name), + Kind: WorkerDrive, + Label: "drive", + Value: source, + Boundary: "managed-runtime", + }) + } + for _, surface := range cfg.Daemon.ProjectionSurfaces { + surface = strings.TrimSpace(surface) + if surface == "" { + continue + } + details = append(details, ConfiguredRoleDetail{ + WorkerName: workerName(surface + "-project"), + Kind: WorkerProjection, + Label: "surface", + Value: surface, + Boundary: "projection-surface", + }) + } + return details +} + +func ConfiguredSnapshot(cfg productconfig.Config, now time.Time) Snapshot { + snapshot := Snapshot{StartedAt: now.UTC(), Workers: map[string]WorkerSnapshot{}} + add := func(name string, kind WorkerKind, message string) { + name = workerName(name) + if name == "" { + return + } + snapshot.Workers[name] = WorkerSnapshot{ + Kind: kind, + Status: "configured", + Message: strings.TrimSpace(message), + StartedAt: snapshot.StartedAt, + UpdatedAt: snapshot.StartedAt, + } + } + for _, role := range RoleDetails(cfg) { + add(role.WorkerName, role.Kind, role.Label+"="+role.Value) + } + if len(snapshot.Workers) > 0 { + add("status-readiness", WorkerStatus, "daemon status snapshot") + } + return snapshot +} + +func workerName(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + replacer := strings.NewReplacer(" ", "-", "/", "-", "\\", "-", ":", "-") + return replacer.Replace(value) +} diff --git a/harness/internal/daemon/configured_test.go b/harness/internal/daemon/configured_test.go new file mode 100644 index 00000000..8abaaba9 --- /dev/null +++ b/harness/internal/daemon/configured_test.go @@ -0,0 +1,102 @@ +package daemon + +import ( + "testing" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/productconfig" +) + +func TestConfiguredSnapshotIncludesConfiguredRoles(t *testing.T) { + now := time.Date(2026, 6, 29, 9, 50, 0, 0, time.UTC) + cfg := productconfig.Default() + cfg.Daemon.InteractionWatchers = []string{" ", productconfig.ConnectionMultica} + cfg.Daemon.DriveSources = []string{"", productconfig.DriveManagedLocal} + cfg.Daemon.ProjectionSurfaces = []string{"\t", productconfig.ConnectionMultica} + + snapshot := ConfiguredSnapshot(cfg, now) + for name, want := range map[string]WorkerKind{ + "multica-watch": WorkerInteraction, + "managed-drive": WorkerDrive, + "multica-project": WorkerProjection, + "status-readiness": WorkerStatus, + } { + worker, ok := snapshot.Workers[name] + if !ok { + t.Fatalf("snapshot missing worker %q: %+v", name, snapshot.Workers) + } + if worker.Kind != want || worker.Status != "configured" || !worker.StartedAt.Equal(now) || !worker.UpdatedAt.Equal(now) { + t.Fatalf("worker %q mismatch: %+v", name, worker) + } + } + for _, name := range []string{"-watch", "-drive", "-project"} { + if _, ok := snapshot.Workers[name]; ok { + t.Fatalf("snapshot should skip empty worker name %q: %+v", name, snapshot.Workers) + } + } +} + +func TestConfiguredSnapshotKeepsMnemonhubAsExchangeWatcher(t *testing.T) { + now := time.Date(2026, 6, 29, 10, 5, 0, 0, time.UTC) + cfg := productconfig.Default() + cfg.Connections.Mnemonhub = productconfig.MnemonhubConnection{Enabled: true, Endpoint: "https://hub.example.invalid"} + cfg.Daemon.InteractionWatchers = []string{productconfig.ConnectionMnemonhub} + + snapshot := ConfiguredSnapshot(cfg, now) + worker, ok := snapshot.Workers["mnemonhub-watch"] + if !ok { + t.Fatalf("snapshot missing mnemonhub watcher: %+v", snapshot.Workers) + } + if worker.Kind != WorkerInteraction || worker.Status != "configured" || worker.Message != "watcher=mnemonhub" { + t.Fatalf("mnemonhub watcher mismatch: %+v", worker) + } + if _, ok := snapshot.Workers["mnemonhub-project"]; ok { + t.Fatalf("mnemonhub must remain a remote exchange watcher, not projection worker: %+v", snapshot.Workers) + } +} + +func TestRoleDetailsDescribeDaemonBoundaries(t *testing.T) { + cfg := productconfig.Default() + cfg.Daemon.InteractionWatchers = []string{ + productconfig.ConnectionMultica, + productconfig.ConnectionMnemonhub, + } + cfg.Daemon.DriveSources = []string{productconfig.DriveManagedLocal} + cfg.Daemon.ProjectionSurfaces = []string{productconfig.ConnectionMultica} + + details := RoleDetails(cfg) + if len(details) != 4 { + t.Fatalf("role details mismatch: %+v", details) + } + for _, want := range []ConfiguredRoleDetail{ + {WorkerName: "multica-watch", Kind: WorkerInteraction, Label: "watcher", Value: productconfig.ConnectionMultica, Boundary: "activation-carrier"}, + {WorkerName: "mnemonhub-watch", Kind: WorkerInteraction, Label: "watcher", Value: productconfig.ConnectionMnemonhub, Boundary: "remote-exchange"}, + {WorkerName: "managed-drive", Kind: WorkerDrive, Label: "drive", Value: productconfig.DriveManagedLocal, Boundary: "managed-runtime"}, + {WorkerName: "multica-project", Kind: WorkerProjection, Label: "surface", Value: productconfig.ConnectionMultica, Boundary: "projection-surface"}, + } { + if !hasRoleDetail(details, want) { + t.Fatalf("missing role detail %+v in %+v", want, details) + } + } +} + +func TestRoleSummaryReflectsConfiguredDaemonRoles(t *testing.T) { + cfg := productconfig.Default() + cfg.Daemon.InteractionWatchers = []string{productconfig.ConnectionMultica, productconfig.ConnectionGitHub} + cfg.Daemon.DriveSources = []string{productconfig.DriveManagedLocal} + cfg.Daemon.ProjectionSurfaces = []string{productconfig.ConnectionMultica} + + got := RoleSummary(cfg) + if got.InteractionWatchers != 2 || got.DriveSources != 1 || got.ProjectionSurfaces != 1 { + t.Fatalf("role summary mismatch: %+v", got) + } +} + +func hasRoleDetail(details []ConfiguredRoleDetail, want ConfiguredRoleDetail) bool { + for _, detail := range details { + if detail == want { + return true + } + } + return false +} diff --git a/harness/internal/daemon/snapshot_store.go b/harness/internal/daemon/snapshot_store.go new file mode 100644 index 00000000..9d0ab6b2 --- /dev/null +++ b/harness/internal/daemon/snapshot_store.go @@ -0,0 +1,76 @@ +package daemon + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +const DefaultStatusSnapshotRelPath = ".mnemon/harness/daemon/status.json" + +type FileSnapshotStore struct { + path string +} + +func StatusSnapshotPath(root, explicit string) string { + if strings.TrimSpace(explicit) != "" { + return strings.TrimSpace(explicit) + } + root = strings.TrimSpace(root) + if root == "" { + root = "." + } + return filepath.Join(root, DefaultStatusSnapshotRelPath) +} + +func NewFileSnapshotStore(path string) *FileSnapshotStore { + return &FileSnapshotStore{path: strings.TrimSpace(path)} +} + +func (s *FileSnapshotStore) Load() (Snapshot, bool, error) { + if s == nil || strings.TrimSpace(s.path) == "" { + return Snapshot{}, false, fmt.Errorf("daemon status snapshot path is required") + } + data, err := os.ReadFile(s.path) + if os.IsNotExist(err) { + return Snapshot{}, false, nil + } + if err != nil { + return Snapshot{}, false, err + } + if len(strings.TrimSpace(string(data))) == 0 { + return Snapshot{}, false, nil + } + var snapshot Snapshot + if err := json.Unmarshal(data, &snapshot); err != nil { + return Snapshot{}, false, fmt.Errorf("parse daemon status snapshot: %w", err) + } + if snapshot.Workers == nil { + snapshot.Workers = map[string]WorkerSnapshot{} + } + return snapshot, true, nil +} + +func (s *FileSnapshotStore) Save(snapshot Snapshot) error { + if s == nil || strings.TrimSpace(s.path) == "" { + return fmt.Errorf("daemon status snapshot path is required") + } + if snapshot.Workers == nil { + snapshot.Workers = map[string]WorkerSnapshot{} + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(snapshot, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, s.path) +} diff --git a/harness/internal/daemon/snapshot_store_test.go b/harness/internal/daemon/snapshot_store_test.go new file mode 100644 index 00000000..a4e0cda8 --- /dev/null +++ b/harness/internal/daemon/snapshot_store_test.go @@ -0,0 +1,61 @@ +package daemon + +import ( + "path/filepath" + "testing" + "time" +) + +func TestFileSnapshotStorePersistsDaemonStatus(t *testing.T) { + root := t.TempDir() + path := StatusSnapshotPath(root, "") + started := time.Unix(100, 0).UTC() + updated := started.Add(time.Minute) + store := NewFileSnapshotStore(path) + if snapshot, ok, err := store.Load(); err != nil || ok || len(snapshot.Workers) != 0 { + t.Fatalf("initial load = snapshot=%+v ok=%v err=%v", snapshot, ok, err) + } + if err := store.Save(Snapshot{ + StartedAt: started, + Workers: map[string]WorkerSnapshot{ + "multica-watch": { + Kind: WorkerInteraction, + Status: "idle", + Message: "cursor=7", + StartedAt: started, + UpdatedAt: updated, + }, + }, + }); err != nil { + t.Fatal(err) + } + + resumed := NewFileSnapshotStore(path) + snapshot, ok, err := resumed.Load() + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("saved snapshot should load") + } + worker := snapshot.Workers["multica-watch"] + if !snapshot.StartedAt.Equal(started) || worker.Kind != WorkerInteraction || worker.Status != "idle" || worker.Message != "cursor=7" || !worker.UpdatedAt.Equal(updated) { + t.Fatalf("loaded snapshot = %+v", snapshot) + } +} + +func TestStatusSnapshotPathHonorsExplicitPath(t *testing.T) { + explicit := filepath.Join(t.TempDir(), "status.json") + if got := StatusSnapshotPath("/ignored", explicit); got != explicit { + t.Fatalf("StatusSnapshotPath explicit = %q, want %q", got, explicit) + } +} + +func TestFileSnapshotStoreRequiresPath(t *testing.T) { + if _, _, err := NewFileSnapshotStore(" ").Load(); err == nil { + t.Fatal("expected load to reject empty path") + } + if err := NewFileSnapshotStore(" ").Save(Snapshot{}); err == nil { + t.Fatal("expected save to reject empty path") + } +} diff --git a/harness/internal/daemon/state.go b/harness/internal/daemon/state.go new file mode 100644 index 00000000..5fb245ab --- /dev/null +++ b/harness/internal/daemon/state.go @@ -0,0 +1,175 @@ +package daemon + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +type CursorRecord struct { + Cursor string `json:"cursor"` + UpdatedAt time.Time `json:"updated_at"` +} + +type FileCursorStore struct { + path string + now Clock + + mu sync.Mutex + loaded bool + records map[string]CursorRecord +} + +func NewFileCursorStore(path string, now Clock) *FileCursorStore { + if now == nil { + now = func() time.Time { return time.Now().UTC() } + } + return &FileCursorStore{path: strings.TrimSpace(path), now: now, records: map[string]CursorRecord{}} +} + +func (s *FileCursorStore) Load(worker string) (CursorRecord, bool, error) { + worker = strings.TrimSpace(worker) + if worker == "" { + return CursorRecord{}, false, fmt.Errorf("daemon cursor worker name is required") + } + s.mu.Lock() + defer s.mu.Unlock() + if err := s.loadLocked(); err != nil { + return CursorRecord{}, false, err + } + record, ok := s.records[worker] + return record, ok, nil +} + +func (s *FileCursorStore) Save(worker, cursor string) error { + worker = strings.TrimSpace(worker) + if worker == "" { + return fmt.Errorf("daemon cursor worker name is required") + } + s.mu.Lock() + defer s.mu.Unlock() + if err := s.loadLocked(); err != nil { + return err + } + s.records[worker] = CursorRecord{Cursor: strings.TrimSpace(cursor), UpdatedAt: s.now()} + return s.writeLocked() +} + +func (s *FileCursorStore) loadLocked() error { + if s.loaded { + return nil + } + s.loaded = true + if s.path == "" { + return fmt.Errorf("daemon cursor store path is required") + } + data, err := os.ReadFile(s.path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if len(strings.TrimSpace(string(data))) == 0 { + return nil + } + if err := json.Unmarshal(data, &s.records); err != nil { + return fmt.Errorf("parse daemon cursor store: %w", err) + } + if s.records == nil { + s.records = map[string]CursorRecord{} + } + return nil +} + +func (s *FileCursorStore) writeLocked() error { + if s.path == "" { + return fmt.Errorf("daemon cursor store path is required") + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(s.records, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +type BackoffPolicy struct { + Base time.Duration + Max time.Duration +} + +func (p BackoffPolicy) Duration(attempt int) time.Duration { + base := p.Base + if base <= 0 { + base = time.Second + } + max := p.Max + if max <= 0 { + max = 30 * time.Second + } + if max < base { + max = base + } + if attempt <= 0 { + return base + } + backoff := base + for i := 0; i < attempt; i++ { + if backoff >= max/2 { + return max + } + backoff *= 2 + } + if backoff > max { + return max + } + return backoff +} + +type InFlightGuard struct { + mu sync.Mutex + keys map[string]bool +} + +func NewInFlightGuard() *InFlightGuard { + return &InFlightGuard{keys: map[string]bool{}} +} + +func (g *InFlightGuard) TryStart(key string) bool { + key = strings.TrimSpace(key) + if key == "" { + return false + } + g.mu.Lock() + defer g.mu.Unlock() + if g.keys == nil { + g.keys = map[string]bool{} + } + if g.keys[key] { + return false + } + g.keys[key] = true + return true +} + +func (g *InFlightGuard) Done(key string) { + key = strings.TrimSpace(key) + if key == "" { + return + } + g.mu.Lock() + defer g.mu.Unlock() + delete(g.keys, key) +} diff --git a/harness/internal/daemon/state_test.go b/harness/internal/daemon/state_test.go new file mode 100644 index 00000000..3a399c64 --- /dev/null +++ b/harness/internal/daemon/state_test.go @@ -0,0 +1,74 @@ +package daemon + +import ( + "path/filepath" + "testing" + "time" +) + +func TestFileCursorStoreResumesCursors(t *testing.T) { + now := time.Unix(100, 0).UTC() + path := filepath.Join(t.TempDir(), "daemon", "cursors.json") + store := NewFileCursorStore(path, func() time.Time { return now }) + if _, ok, err := store.Load("multica-watch"); err != nil || ok { + t.Fatalf("initial load = ok=%v err=%v", ok, err) + } + if err := store.Save("multica-watch", "issue:cursor:7"); err != nil { + t.Fatal(err) + } + + resumed := NewFileCursorStore(path, func() time.Time { return now.Add(time.Hour) }) + record, ok, err := resumed.Load("multica-watch") + if err != nil { + t.Fatal(err) + } + if !ok || record.Cursor != "issue:cursor:7" || !record.UpdatedAt.Equal(now) { + t.Fatalf("resumed cursor = %+v ok=%v", record, ok) + } +} + +func TestFileCursorStoreValidatesWorkerName(t *testing.T) { + store := NewFileCursorStore(filepath.Join(t.TempDir(), "cursors.json"), nil) + if err := store.Save(" ", "cursor"); err == nil { + t.Fatal("expected save to reject empty worker name") + } + if _, _, err := store.Load(" "); err == nil { + t.Fatal("expected load to reject empty worker name") + } +} + +func TestBackoffPolicyCapsRetries(t *testing.T) { + policy := BackoffPolicy{Base: time.Second, Max: 5 * time.Second} + for _, tc := range []struct { + attempt int + want time.Duration + }{ + {attempt: -1, want: time.Second}, + {attempt: 0, want: time.Second}, + {attempt: 1, want: 2 * time.Second}, + {attempt: 2, want: 4 * time.Second}, + {attempt: 3, want: 5 * time.Second}, + {attempt: 10, want: 5 * time.Second}, + } { + if got := policy.Duration(tc.attempt); got != tc.want { + t.Fatalf("Duration(%d) = %s, want %s", tc.attempt, got, tc.want) + } + } +} + +func TestInFlightGuardSuppressesDuplicateWork(t *testing.T) { + guard := NewInFlightGuard() + if guard.TryStart("") { + t.Fatal("empty key must not start") + } + if !guard.TryStart("projection:root-1") { + t.Fatal("first start should win") + } + if guard.TryStart("projection:root-1") { + t.Fatal("duplicate start should be suppressed") + } + guard.Done("projection:root-1") + if !guard.TryStart("projection:root-1") { + t.Fatal("completed key should be startable again") + } +} diff --git a/harness/internal/daemon/supervisor.go b/harness/internal/daemon/supervisor.go new file mode 100644 index 00000000..46c7b403 --- /dev/null +++ b/harness/internal/daemon/supervisor.go @@ -0,0 +1,177 @@ +package daemon + +import ( + "context" + "fmt" + "sync" + "time" +) + +type WorkerKind string + +const ( + WorkerInteraction WorkerKind = "interaction" + WorkerDrive WorkerKind = "drive" + WorkerProjection WorkerKind = "projection" + WorkerStatus WorkerKind = "status" +) + +type Worker interface { + Name() string + Kind() WorkerKind + Run(context.Context, Reporter) error +} + +type Reporter interface { + Report(WorkerReport) +} + +type WorkerReport struct { + Name string + Kind WorkerKind + Status string + Message string + At time.Time +} + +type Snapshot struct { + StartedAt time.Time `json:"started_at"` + Workers map[string]WorkerSnapshot `json:"workers"` +} + +type WorkerSnapshot struct { + Kind WorkerKind `json:"kind"` + Status string `json:"status"` + Message string `json:"message,omitempty"` + StartedAt time.Time `json:"started_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + Error string `json:"error,omitempty"` +} + +type Clock func() time.Time + +type Supervisor struct { + workers []Worker + now Clock + + mu sync.Mutex + started bool + snapshot Snapshot +} + +func NewSupervisor(workers []Worker, now Clock) *Supervisor { + if now == nil { + now = func() time.Time { return time.Now().UTC() } + } + return &Supervisor{workers: append([]Worker(nil), workers...), now: now} +} + +func (s *Supervisor) Run(ctx context.Context) error { + if len(s.workers) == 0 { + return fmt.Errorf("daemon supervisor requires at least one worker") + } + startedAt := s.now() + s.mu.Lock() + if s.started { + s.mu.Unlock() + return fmt.Errorf("daemon supervisor already started") + } + s.started = true + s.snapshot = Snapshot{StartedAt: startedAt, Workers: map[string]WorkerSnapshot{}} + for _, worker := range s.workers { + name := worker.Name() + s.snapshot.Workers[name] = WorkerSnapshot{ + Kind: worker.Kind(), + Status: "starting", + StartedAt: startedAt, + UpdatedAt: startedAt, + } + } + s.mu.Unlock() + + var wg sync.WaitGroup + errs := make(chan error, len(s.workers)) + for _, worker := range s.workers { + worker := worker + wg.Add(1) + go func() { + defer wg.Done() + name := worker.Name() + s.setWorker(name, WorkerSnapshot{ + Kind: worker.Kind(), + Status: "running", + StartedAt: startedAt, + UpdatedAt: s.now(), + }) + if err := worker.Run(ctx, s); err != nil { + s.setWorker(name, WorkerSnapshot{ + Kind: worker.Kind(), + Status: "failed", + StartedAt: startedAt, + UpdatedAt: s.now(), + Error: err.Error(), + }) + errs <- fmt.Errorf("%s worker %q: %w", worker.Kind(), name, err) + return + } + status := "stopped" + if ctx.Err() == nil { + status = "completed" + } + s.setWorker(name, WorkerSnapshot{ + Kind: worker.Kind(), + Status: status, + StartedAt: startedAt, + UpdatedAt: s.now(), + }) + }() + } + wg.Wait() + close(errs) + for err := range errs { + return err + } + return nil +} + +func (s *Supervisor) Report(report WorkerReport) { + if report.At.IsZero() { + report.At = s.now() + } + s.mu.Lock() + defer s.mu.Unlock() + if s.snapshot.Workers == nil { + s.snapshot.Workers = map[string]WorkerSnapshot{} + } + current := s.snapshot.Workers[report.Name] + if current.StartedAt.IsZero() { + current.StartedAt = report.At + } + current.Kind = report.Kind + current.Status = report.Status + current.Message = report.Message + current.UpdatedAt = report.At + s.snapshot.Workers[report.Name] = current +} + +func (s *Supervisor) Snapshot() Snapshot { + s.mu.Lock() + defer s.mu.Unlock() + out := Snapshot{StartedAt: s.snapshot.StartedAt, Workers: map[string]WorkerSnapshot{}} + for name, worker := range s.snapshot.Workers { + out.Workers[name] = worker + } + return out +} + +func (s *Supervisor) setWorker(name string, snapshot WorkerSnapshot) { + s.mu.Lock() + defer s.mu.Unlock() + if s.snapshot.Workers == nil { + s.snapshot.Workers = map[string]WorkerSnapshot{} + } + if current := s.snapshot.Workers[name]; !current.StartedAt.IsZero() && snapshot.StartedAt.IsZero() { + snapshot.StartedAt = current.StartedAt + } + s.snapshot.Workers[name] = snapshot +} diff --git a/harness/internal/daemon/supervisor_test.go b/harness/internal/daemon/supervisor_test.go new file mode 100644 index 00000000..50d6e3a0 --- /dev/null +++ b/harness/internal/daemon/supervisor_test.go @@ -0,0 +1,90 @@ +package daemon + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestSupervisorRunsWorkersAndRecordsReports(t *testing.T) { + now := time.Unix(100, 0).UTC() + ctx := context.Background() + supervisor := NewSupervisor([]Worker{ + workerFunc{ + name: "multica-watch", + kind: WorkerInteraction, + run: func(ctx context.Context, reporter Reporter) error { + reporter.Report(WorkerReport{Name: "multica-watch", Kind: WorkerInteraction, Status: "idle", Message: "cursor=7"}) + return nil + }, + }, + workerFunc{ + name: "managed-drive", + kind: WorkerDrive, + run: func(context.Context, Reporter) error { + return nil + }, + }, + }, func() time.Time { return now }) + + if err := supervisor.Run(ctx); err != nil { + t.Fatal(err) + } + snapshot := supervisor.Snapshot() + if len(snapshot.Workers) != 2 { + t.Fatalf("snapshot workers = %+v", snapshot.Workers) + } + if snapshot.Workers["managed-drive"].Status != "completed" { + t.Fatalf("drive worker status = %+v", snapshot.Workers["managed-drive"]) + } + if snapshot.Workers["multica-watch"].Kind != WorkerInteraction { + t.Fatalf("interaction worker kind = %+v", snapshot.Workers["multica-watch"]) + } +} + +func TestSupervisorCapturesWorkerFailure(t *testing.T) { + supervisor := NewSupervisor([]Worker{ + workerFunc{ + name: "project-multica", + kind: WorkerProjection, + run: func(context.Context, Reporter) error { + return errors.New("projection failed") + }, + }, + }, func() time.Time { return time.Unix(100, 0).UTC() }) + + err := supervisor.Run(context.Background()) + if err == nil { + t.Fatal("expected worker error") + } + snapshot := supervisor.Snapshot() + got := snapshot.Workers["project-multica"] + if got.Status != "failed" || got.Error != "projection failed" { + t.Fatalf("failure snapshot = %+v", got) + } +} + +func TestSupervisorRequiresWorkers(t *testing.T) { + if err := NewSupervisor(nil, nil).Run(context.Background()); err == nil { + t.Fatal("expected no-worker error") + } +} + +type workerFunc struct { + name string + kind WorkerKind + run func(context.Context, Reporter) error +} + +func (w workerFunc) Name() string { + return w.name +} + +func (w workerFunc) Kind() WorkerKind { + return w.kind +} + +func (w workerFunc) Run(ctx context.Context, reporter Reporter) error { + return w.run(ctx, reporter) +} diff --git a/harness/internal/daemon/work_ledger.go b/harness/internal/daemon/work_ledger.go new file mode 100644 index 00000000..7f7f2fc8 --- /dev/null +++ b/harness/internal/daemon/work_ledger.go @@ -0,0 +1,195 @@ +package daemon + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +const ( + WorkKindProjection = "projection" + WorkKindWake = "wake" +) + +type WorkLedgerRecord struct { + Kind string `json:"kind"` + Key string `json:"key"` + Status string `json:"status"` + Attempts int `json:"attempts,omitempty"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type FileWorkLedger struct { + path string + now Clock + + mu sync.Mutex + loaded bool + records map[string]WorkLedgerRecord +} + +func NewFileWorkLedger(path string, now Clock) *FileWorkLedger { + if now == nil { + now = func() time.Time { return time.Now().UTC() } + } + return &FileWorkLedger{path: strings.TrimSpace(path), now: now, records: map[string]WorkLedgerRecord{}} +} + +func (l *FileWorkLedger) Seen(kind, key string) (bool, error) { + _, ok, err := l.Load(kind, key) + return ok, err +} + +func (l *FileWorkLedger) Load(kind, key string) (WorkLedgerRecord, bool, error) { + kind, key, err := cleanWorkLedgerKey(kind, key) + if err != nil { + return WorkLedgerRecord{}, false, err + } + l.mu.Lock() + defer l.mu.Unlock() + if err := l.loadLocked(); err != nil { + return WorkLedgerRecord{}, false, err + } + record, ok := l.records[workLedgerMapKey(kind, key)] + return record, ok, nil +} + +func (l *FileWorkLedger) Records(kind string) ([]WorkLedgerRecord, error) { + kind = strings.TrimSpace(kind) + l.mu.Lock() + defer l.mu.Unlock() + if err := l.loadLocked(); err != nil { + return nil, err + } + out := make([]WorkLedgerRecord, 0, len(l.records)) + for _, record := range l.records { + if kind == "" || record.Kind == kind { + out = append(out, record) + } + } + sortWorkLedgerRecords(out) + return out, nil +} + +func (l *FileWorkLedger) Record(record WorkLedgerRecord) error { + kind, key, err := cleanWorkLedgerKey(record.Kind, record.Key) + if err != nil { + return err + } + now := l.now() + createdAtProvided := !record.CreatedAt.IsZero() + record.Kind = kind + record.Key = key + record.Status = strings.TrimSpace(record.Status) + if record.Status == "" { + record.Status = "recorded" + } + if record.CreatedAt.IsZero() { + record.CreatedAt = now + } + if record.UpdatedAt.IsZero() { + record.UpdatedAt = now + } + l.mu.Lock() + defer l.mu.Unlock() + if err := l.loadLocked(); err != nil { + return err + } + mapKey := workLedgerMapKey(kind, key) + if existing, ok := l.records[mapKey]; ok && !createdAtProvided { + record.CreatedAt = existing.CreatedAt + } + l.records[mapKey] = record + return l.writeLocked() +} + +func (l *FileWorkLedger) loadLocked() error { + if l.loaded { + return nil + } + l.loaded = true + if strings.TrimSpace(l.path) == "" { + return fmt.Errorf("daemon work ledger path is required") + } + data, err := os.ReadFile(l.path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if len(strings.TrimSpace(string(data))) == 0 { + return nil + } + var records []WorkLedgerRecord + if err := json.Unmarshal(data, &records); err != nil { + return fmt.Errorf("parse daemon work ledger: %w", err) + } + for _, record := range records { + kind, key, err := cleanWorkLedgerKey(record.Kind, record.Key) + if err != nil { + continue + } + record.Kind = kind + record.Key = key + l.records[workLedgerMapKey(kind, key)] = record + } + return nil +} + +func (l *FileWorkLedger) writeLocked() error { + if strings.TrimSpace(l.path) == "" { + return fmt.Errorf("daemon work ledger path is required") + } + if err := os.MkdirAll(filepath.Dir(l.path), 0o755); err != nil { + return err + } + records := make([]WorkLedgerRecord, 0, len(l.records)) + for _, record := range l.records { + records = append(records, record) + } + sortWorkLedgerRecords(records) + data, err := json.MarshalIndent(records, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + tmp := l.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, l.path) +} + +func cleanWorkLedgerKey(kind, key string) (string, string, error) { + kind = strings.TrimSpace(kind) + key = strings.TrimSpace(key) + if kind == "" { + return "", "", fmt.Errorf("daemon work ledger kind is required") + } + if key == "" { + return "", "", fmt.Errorf("daemon work ledger key is required") + } + return kind, key, nil +} + +func workLedgerMapKey(kind, key string) string { + return kind + "\x00" + key +} + +func sortWorkLedgerRecords(records []WorkLedgerRecord) { + sort.Slice(records, func(i, j int) bool { + if records[i].Kind == records[j].Kind { + return records[i].Key < records[j].Key + } + return records[i].Kind < records[j].Kind + }) +} diff --git a/harness/internal/daemon/work_ledger_test.go b/harness/internal/daemon/work_ledger_test.go new file mode 100644 index 00000000..593bc2e8 --- /dev/null +++ b/harness/internal/daemon/work_ledger_test.go @@ -0,0 +1,87 @@ +package daemon + +import ( + "path/filepath" + "testing" + "time" +) + +func TestFileWorkLedgerPersistsProjectionAndWakeRecords(t *testing.T) { + now := time.Unix(100, 0).UTC() + path := filepath.Join(t.TempDir(), "daemon", "work-ledger.json") + ledger := NewFileWorkLedger(path, func() time.Time { return now }) + if seen, err := ledger.Seen(WorkKindProjection, "multica:comment:root-1"); err != nil || seen { + t.Fatalf("initial seen = %v err=%v", seen, err) + } + if err := ledger.Record(WorkLedgerRecord{ + Kind: WorkKindProjection, + Key: "multica:comment:root-1", + Status: "completed", + Message: "comment=comment-1", + }); err != nil { + t.Fatal(err) + } + if err := ledger.Record(WorkLedgerRecord{ + Kind: WorkKindWake, + Key: "managed:assignment:asg-1", + Status: "failed", + Attempts: 2, + Error: "turn timeout", + }); err != nil { + t.Fatal(err) + } + + resumed := NewFileWorkLedger(path, func() time.Time { return now.Add(time.Hour) }) + projection, ok, err := resumed.Load(WorkKindProjection, "multica:comment:root-1") + if err != nil { + t.Fatal(err) + } + if !ok || projection.Status != "completed" || projection.Message != "comment=comment-1" || !projection.CreatedAt.Equal(now) || !projection.UpdatedAt.Equal(now) { + t.Fatalf("projection record = %+v ok=%v", projection, ok) + } + wake, ok, err := resumed.Load(WorkKindWake, "managed:assignment:asg-1") + if err != nil { + t.Fatal(err) + } + if !ok || wake.Status != "failed" || wake.Attempts != 2 || wake.Error != "turn timeout" { + t.Fatalf("wake record = %+v ok=%v", wake, ok) + } +} + +func TestFileWorkLedgerDedupesByKindAndKey(t *testing.T) { + now := time.Unix(100, 0).UTC() + later := now.Add(time.Hour) + path := filepath.Join(t.TempDir(), "work-ledger.json") + clock := now + ledger := NewFileWorkLedger(path, func() time.Time { return clock }) + if err := ledger.Record(WorkLedgerRecord{Kind: WorkKindProjection, Key: "target-1", Status: "started"}); err != nil { + t.Fatal(err) + } + clock = later + if err := ledger.Record(WorkLedgerRecord{Kind: WorkKindProjection, Key: "target-1", Status: "completed"}); err != nil { + t.Fatal(err) + } + records, err := NewFileWorkLedger(path, nil).Records(WorkKindProjection) + if err != nil { + t.Fatal(err) + } + if len(records) != 1 { + t.Fatalf("records = %+v, want one deduped record", records) + } + if records[0].Status != "completed" || !records[0].CreatedAt.Equal(now) || !records[0].UpdatedAt.Equal(later) { + t.Fatalf("deduped record = %+v", records[0]) + } +} + +func TestFileWorkLedgerValidatesKindAndKey(t *testing.T) { + ledger := NewFileWorkLedger(filepath.Join(t.TempDir(), "work-ledger.json"), nil) + if err := ledger.Record(WorkLedgerRecord{Kind: " ", Key: "target"}); err == nil { + t.Fatal("expected record to reject empty kind") + } + if err := ledger.Record(WorkLedgerRecord{Kind: WorkKindWake, Key: " "}); err == nil { + t.Fatal("expected record to reject empty key") + } + if _, _, err := ledger.Load(" ", "target"); err == nil { + t.Fatal("expected load to reject empty kind") + } +} diff --git a/harness/internal/drive/agent.go b/harness/internal/drive/agent.go new file mode 100644 index 00000000..d2a8079a --- /dev/null +++ b/harness/internal/drive/agent.go @@ -0,0 +1,218 @@ +package drive + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "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 + FinalAnswer string +} + +type ManagedWakeCandidate struct { + 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"` + 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 { + 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() + record, ok := l.seen[managedWakeKey(candidate)] + return ok && managedWakeRecordHandled(record) +} + +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 +} + +func managedWakeRecordHandled(record ManagedWakeRecord) bool { + return strings.EqualFold(strings.TrimSpace(record.Status), "completed") +} + +type ManagedAgentDriver struct { + Principal string + Client ManagedTurnClient + Ledger ManagedWakeLedger + TraceSink ManagedTurnTraceSink + 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", + RenderAuditID: candidate.RenderAuditID, + RenderBodyDigest: candidate.RenderBodyDigest, + } + var result ManagedTurnResult + var err error + if traced, ok := d.Client.(ManagedTurnClientWithTrace); ok && d.TraceSink != nil { + result, err = traced.StartTurnWithTrace(ctx, ManagedWakeQuery, d.TraceSink) + } else { + 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.FinalAnswer = result.FinalAnswer + 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/drive/agent_test.go b/harness/internal/drive/agent_test.go new file mode 100644 index 00000000..890b6e60 --- /dev/null +++ b/harness/internal/drive/agent_test.go @@ -0,0 +1,121 @@ +package drive + +import ( + "context" + "errors" + "testing" + "time" + + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" +) + +type turnClientFunc func(context.Context, string) (ManagedTurnResult, error) + +func (f turnClientFunc) StartTurn(ctx context.Context, query string) (ManagedTurnResult, error) { + return f(ctx, query) +} + +func TestManagedAgentDriverUsesWakeSentinelOnly(t *testing.T) { + var gotQuery string + driver := &ManagedAgentDriver{ + Principal: "codex-a@project", + Client: turnClientFunc(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: turnClientFunc(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: turnClientFunc(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: turnClientFunc(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("failed wake should be audited but remain retryable") + } + if len(ledger.seen) != 1 { + t.Fatal("failure should still be recorded locally for audit") + } +} + +func TestManagedWakeCandidatesFromEventsUseNarrativeDigest(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]) + } +} diff --git a/harness/internal/drive/runtime.go b/harness/internal/drive/runtime.go new file mode 100644 index 00000000..9683926f --- /dev/null +++ b/harness/internal/drive/runtime.go @@ -0,0 +1,402 @@ +package drive + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "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() + record, ok := l.seen[managedWakeKey(candidate)] + return ok && managedWakeRecordHandled(record) +} + +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 { + Principal string + Command string + Workspace string + Env []string + TurnTimeout time.Duration + RequestTimeout time.Duration + ClientName string + ClientVersion string +} + +func (c CodexAppServerTurnClient) StartTurn(ctx context.Context, query string) (ManagedTurnResult, error) { + return c.StartTurnWithTrace(ctx, query, nil) +} + +func (c CodexAppServerTurnClient) StartTurnWithTrace(ctx context.Context, query string, sink ManagedTurnTraceSink) (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}, + "capabilities": codexAppServerCapabilities(), + }, requestTimeout); err != nil { + return ManagedTurnResult{}, fmt.Errorf("initialize: %w", err) + } + developerInstructions, err := CodexAppServerDeveloperInstructions(ctx, workspace, c.Env, ManagedWakeQuery) + if err != nil { + return ManagedTurnResult{}, fmt.Errorf("load hook context: %w", err) + } + threadParams := map[string]any{ + "cwd": workspace, + "approvalPolicy": "never", + "sandbox": "danger-full-access", + "ephemeral": true, + } + if developerInstructions != "" { + threadParams["developerInstructions"] = developerInstructions + } + thread, err := server.Request("thread/start", threadParams, 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") + } + turnParams := map[string]any{ + "threadId": threadID, + "input": []map[string]any{{"type": "text", "text": ManagedWakeQuery}}, + "cwd": workspace, + "approvalPolicy": "never", + "sandboxPolicy": map[string]any{"type": "dangerFullAccess"}, + } + before := server.NotificationCount() + if _, err := server.Request("turn/start", turnParams, requestTimeout); err != nil { + return ManagedTurnResult{}, fmt.Errorf("turn/start: %w", err) + } + emitTrace := func(notifications []map[string]any) { + if sink == nil { + return + } + for _, event := range ManagedTurnTraceEventsFromCodexNotifications(c.Principal, notifications) { + sink.OnManagedTurnTrace(event) + } + } + if _, err := server.WaitNotificationWithCallback("turn/completed", turnTimeout, before, emitTrace); 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 CodexAppServerDeveloperInstructions(ctx context.Context, workspace string, env []string, query string) (string, error) { + additionalContext, err := CodexAppServerAdditionalContext(ctx, workspace, env, query) + if err != nil { + return "", err + } + return formatCodexAppServerDeveloperInstructions(additionalContext), nil +} + +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 formatCodexAppServerDeveloperInstructions(entries map[string]any) string { + if len(entries) == 0 { + return "" + } + keys := make([]string, 0, len(entries)) + for key := range entries { + keys = append(keys, key) + } + sort.Strings(keys) + var b strings.Builder + b.WriteString("Mnemon managed wake context.\n") + b.WriteString("The user input for this turn is only the local wake sentinel `[mnemon:wake]`. Use the application context below as the governed Mnemon context for deciding whether to read or record state. If a teamwork signal says assignment or self-assignment may be useful, act through the Mnemon commands described in the guide instead of answering that no task was supplied.\n") + for _, key := range keys { + value := additionalContextEntryValue(entries[key]) + if value == "" { + continue + } + b.WriteString("\n## ") + b.WriteString(key) + b.WriteString("\n") + b.WriteString(value) + b.WriteString("\n") + } + return strings.TrimSpace(b.String()) +} + +func additionalContextEntryValue(entry any) string { + switch v := entry.(type) { + case string: + return strings.TrimSpace(v) + case map[string]any: + if value, ok := v["value"].(string); ok { + return strings.TrimSpace(value) + } + } + return "" +} + +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/drive/runtime_test.go b/harness/internal/drive/runtime_test.go new file mode 100644 index 00000000..90b613bf --- /dev/null +++ b/harness/internal/drive/runtime_test.go @@ -0,0 +1,211 @@ +package drive + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + 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 TestFileManagedWakeLedgerAllowsRetryAfterFailed(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 err := ledger.Record(ManagedWakeRecord{ + Principal: candidate.Principal, + DerivedEventID: candidate.DerivedEventID, + BodyDigest: candidate.BodyDigest, + Status: "failed", + Error: "timeout", + }); err != nil { + t.Fatal(err) + } + if ledger.Seen(candidate) { + t.Fatal("failed wake must remain retryable") + } + reopened := NewFileManagedWakeLedger(path) + if reopened.Seen(candidate) { + t.Fatal("failed wake must remain retryable after reload") + } + if err := reopened.Record(ManagedWakeRecord{ + Principal: candidate.Principal, + DerivedEventID: candidate.DerivedEventID, + BodyDigest: candidate.BodyDigest, + Status: "completed", + }); err != nil { + t.Fatal(err) + } + if !reopened.Seen(candidate) { + t.Fatal("completed retry should mark candidate handled") + } +} + +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") + } +} + +func TestCodexAppServerTurnClientInjectsHookContextAsDeveloperInstructions(t *testing.T) { + workspace := t.TempDir() + requestLog := filepath.Join(workspace, "requests.jsonl") + fakeCodex := filepath.Join(workspace, "fake-codex") + if err := os.WriteFile(fakeCodex, []byte(`#!/usr/bin/env bash +while IFS= read -r line; do + printf '%s\n' "$line" >> "$CODEX_REQUEST_LOG" + case "$line" in + *'"method":"initialize"'*) + printf '{"jsonrpc":"2.0","id":1,"result":{"userAgent":"fake"}}\n' + ;; + *'"method":"thread/start"'*) + printf '{"jsonrpc":"2.0","id":2,"result":{"thread":{"id":"thread-1"}}}\n' + ;; + *'"method":"turn/start"'*) + printf '{"jsonrpc":"2.0","id":3,"result":{"turn":{"id":"turn-1","status":"inProgress"}}}\n' + printf '{"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thread-1","turnId":"turn-1","item":{"type":"agentMessage","id":"msg-1","text":"handled wake","phase":"final_answer"}}}\n' + printf '{"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"thread-1","turn":{"id":"turn-1","status":"completed"}}}\n' + ;; + esac +done +`), 0o755); err != nil { + t.Fatal(err) + } + hookDir := filepath.Join(workspace, ".codex", "hooks", "mnemon-r1") + if err := os.MkdirAll(hookDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(hookDir, "prime.sh"), []byte(`#!/usr/bin/env bash +printf '{"systemMessage":"prime guide loaded"}\n' +`), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(hookDir, "remind.sh"), []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) + } + + client := CodexAppServerTurnClient{ + Command: fakeCodex, + Workspace: workspace, + Env: append(os.Environ(), "CODEX_REQUEST_LOG="+requestLog), + TurnTimeout: 5 * time.Second, + RequestTimeout: 5 * time.Second, + } + result, err := client.StartTurn(context.Background(), ManagedWakeQuery) + if err != nil { + t.Fatal(err) + } + if result.Status != "completed" || !strings.Contains(result.FinalAnswer, "handled wake") { + t.Fatalf("result = %+v", result) + } + + raw, err := os.ReadFile(requestLog) + if err != nil { + t.Fatal(err) + } + var threadStart, turnStart map[string]any + for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { + var msg map[string]any + if err := json.Unmarshal([]byte(line), &msg); err != nil { + t.Fatalf("decode request %q: %v", line, err) + } + switch msg["method"] { + case "thread/start": + threadStart = msg + case "turn/start": + turnStart = msg + } + } + if threadStart == nil || turnStart == nil { + t.Fatalf("missing thread/start or turn/start request:\n%s", raw) + } + threadParams := threadStart["params"].(map[string]any) + instructions, _ := threadParams["developerInstructions"].(string) + for _, want := range []string{"Mnemon managed wake context", "prime guide loaded", "remind rendered governed context", "assignment or self-assignment may be useful"} { + if !strings.Contains(instructions, want) { + t.Fatalf("developerInstructions missing %q:\n%s", want, instructions) + } + } + if _, ok := threadParams["additionalContext"]; ok { + t.Fatalf("thread/start must not use removed additionalContext field: %+v", threadParams) + } + turnParams := turnStart["params"].(map[string]any) + if _, ok := turnParams["additionalContext"]; ok { + t.Fatalf("turn/start must not use removed additionalContext field: %+v", turnParams) + } + input := turnParams["input"].([]any) + first := input[0].(map[string]any) + if first["text"] != ManagedWakeQuery { + t.Fatalf("turn input = %+v, want sentinel only", first) + } +} diff --git a/harness/internal/drive/selection.go b/harness/internal/drive/selection.go new file mode 100644 index 00000000..2359e540 --- /dev/null +++ b/harness/internal/drive/selection.go @@ -0,0 +1,119 @@ +package drive + +import ( + "strings" + + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation" +) + +type ManagedWakeMatchMaterial struct { + MatchTerms []string + AssignmentID string + AssignmentFingerprint string + IssueID string + Identifier string + Title string + Statement string + TaskID string +} + +func ManagedWakeCandidateForRender(principal string, resp presentation.Response, material ManagedWakeMatchMaterial) (ManagedWakeCandidate, bool) { + strongTerms := ManagedWakeStrongMatchTerms(material) + if len(strongTerms) > 0 { + for _, env := range resp.Events { + candidates := ManagedWakeCandidatesFromEvents(principal, []eventmodel.EventEnvelope{env}) + if len(candidates) == 0 { + continue + } + candidate := candidates[0] + candidate.RenderAuditID = resp.AuditID + candidate.RenderBodyDigest = resp.BodyDigest + if eventNarrativeContainsAny(env, strongTerms) { + return candidate, true + } + } + return ManagedWakeCandidate{}, false + } + + terms := ManagedWakeMatchTerms(material) + var fallback ManagedWakeCandidate + for _, env := range resp.Events { + candidates := ManagedWakeCandidatesFromEvents(principal, []eventmodel.EventEnvelope{env}) + if len(candidates) == 0 { + continue + } + candidate := candidates[0] + candidate.RenderAuditID = resp.AuditID + candidate.RenderBodyDigest = resp.BodyDigest + if fallback.Principal == "" { + fallback = candidate + } + if len(terms) == 0 || eventNarrativeContainsAny(env, terms) { + return candidate, true + } + } + if len(terms) == 0 && fallback.Principal != "" { + return fallback, true + } + return ManagedWakeCandidate{}, false +} + +func ManagedWakeStrongMatchTerms(material ManagedWakeMatchMaterial) []string { + if len(material.MatchTerms) > 0 { + return CleanManagedWakeMatchTerms(material.MatchTerms...) + } + if material.AssignmentID != "" || material.AssignmentFingerprint != "" { + return CleanManagedWakeMatchTerms(material.AssignmentID, material.AssignmentFingerprint) + } + return CleanManagedWakeMatchTerms(material.IssueID, material.Identifier, material.TaskID) +} + +func ManagedWakeMatchTerms(material ManagedWakeMatchMaterial) []string { + if len(material.MatchTerms) > 0 { + return CleanManagedWakeMatchTerms(material.MatchTerms...) + } + if material.AssignmentID != "" || material.AssignmentFingerprint != "" { + return CleanManagedWakeMatchTerms(material.AssignmentID, material.AssignmentFingerprint) + } + raw := []string{material.IssueID, material.Identifier, material.Title, material.TaskID} + var out []string + for _, value := range raw { + value = strings.TrimSpace(value) + if len(value) >= 3 { + out = append(out, value) + } + } + if len(out) > 0 { + return out + } + if value := strings.TrimSpace(material.Statement); len(value) >= 3 { + out = append(out, value) + } + return out +} + +func CleanManagedWakeMatchTerms(values ...string) []string { + seen := map[string]bool{} + var out []string + for _, value := range values { + value = strings.TrimSpace(value) + if len(value) < 3 || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + return out +} + +func eventNarrativeContainsAny(env eventmodel.EventEnvelope, terms []string) bool { + body, _ := eventmodel.PayloadNarrative(env.Event.Payload)["body"].(string) + body = strings.ToLower(strings.Join([]string{body, string(env.Event.Subject), env.Event.ID, env.Event.Type}, "\n")) + for _, term := range terms { + if strings.Contains(body, strings.ToLower(term)) { + return true + } + } + return false +} diff --git a/harness/internal/drive/selection_test.go b/harness/internal/drive/selection_test.go new file mode 100644 index 00000000..ed4907a5 --- /dev/null +++ b/harness/internal/drive/selection_test.go @@ -0,0 +1,119 @@ +package drive + +import ( + "strings" + "testing" + + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation" +) + +func TestManagedWakeCandidateForRenderMatchesStableIssueIdentity(t *testing.T) { + resp := presentation.Response{ + AuditID: "audit-1", + BodyDigest: "sha256:render", + Events: []eventmodel.EventEnvelope{ + renderedWakeEnvelope("derived:first", "assignment/asg-other", "Assignment ASG-OTHER is available."), + renderedWakeEnvelope("derived:second", "assignment/asg-27", "Please handle TEA-27 for the Mnemon R2 hub-flow completion drill."), + }, + } + candidate, ok := ManagedWakeCandidateForRender("planner@team", resp, ManagedWakeMatchMaterial{ + IssueID: "issue-123", + Identifier: "TEA-27", + Title: "Mnemon R2 hub-flow completion drill", + TaskID: "task-123", + }) + if !ok { + t.Fatal("expected matching wake candidate") + } + if candidate.DerivedEventID != "derived:second" { + t.Fatalf("selected candidate = %+v, want second event", candidate) + } + if candidate.RenderAuditID != resp.AuditID || candidate.RenderBodyDigest != resp.BodyDigest { + t.Fatalf("candidate should carry render metadata: %+v", candidate) + } +} + +func TestManagedWakeCandidateForRenderRequiresStrongRootIdentity(t *testing.T) { + resp := presentation.Response{ + AuditID: "audit-1", + BodyDigest: "sha256:render", + Events: []eventmodel.EventEnvelope{ + renderedWakeEnvelope("derived:stale", "assignment/asg-stale", "Assignment asg-stale is yours: Mnemon R2 Multica hub live validation. Expected work: inspect an older mailbox."), + renderedWakeEnvelopeOfType("derived:current", "teamwork.signal_open", "teamwork_signal/sig-current", "Teamwork signal is open: Mnemon R2 Multica hub live validation. Context refs: multica:issue:issue-current, multica:task:task-current."), + }, + } + candidate, ok := ManagedWakeCandidateForRender("planner@team", resp, ManagedWakeMatchMaterial{ + IssueID: "issue-current", + Identifier: "TEA-54", + Title: "Mnemon R2 Multica hub live validation", + TaskID: "task-current", + }) + if !ok { + t.Fatal("expected current root signal candidate") + } + if candidate.DerivedEventID != "derived:current" { + t.Fatalf("selected candidate = %+v, want current root signal", candidate) + } + + _, ok = ManagedWakeCandidateForRender("planner@team", presentation.Response{ + Events: []eventmodel.EventEnvelope{ + renderedWakeEnvelope("derived:stale", "assignment/asg-stale", "Assignment asg-stale is yours: Mnemon R2 Multica hub live validation."), + }, + }, ManagedWakeMatchMaterial{ + IssueID: "issue-current", + Identifier: "TEA-54", + Title: "Mnemon R2 Multica hub live validation", + TaskID: "task-current", + }) + if ok { + t.Fatal("stale title-only assignment must not satisfy a root Multica wake") + } +} + +func TestManagedWakeMatchTermsPreferStableIssueIdentity(t *testing.T) { + got := ManagedWakeMatchTerms(ManagedWakeMatchMaterial{ + IssueID: "issue-123", + Identifier: "TEA-27", + Title: "Mnemon R2 hub-flow completion drill", + Statement: "Run a small hub-flow readiness drill.", + TaskID: "task-123", + }) + joined := strings.Join(got, "\n") + for _, want := range []string{"issue-123", "TEA-27", "Mnemon R2 hub-flow completion drill", "task-123"} { + if !strings.Contains(joined, want) { + t.Fatalf("match terms missing %q: %+v", want, got) + } + } + if strings.Contains(joined, "Run a small hub-flow readiness drill.") { + t.Fatalf("root issue matching must not prefer reusable statement text: %+v", got) + } +} + +func TestManagedWakeMatchTermsPreferAssignmentMailboxIdentity(t *testing.T) { + got := ManagedWakeMatchTerms(ManagedWakeMatchMaterial{ + AssignmentID: "assignment-final-routing-mailbox", + AssignmentFingerprint: "fp-1", + IssueID: "child-issue", + Identifier: "TEA-16", + }) + if strings.Join(got, "\n") != "assignment-final-routing-mailbox\nfp-1" { + t.Fatalf("assignment terms = %+v", got) + } +} + +func renderedWakeEnvelope(id, subject, body string) eventmodel.EventEnvelope { + return renderedWakeEnvelopeOfType(id, "assignment.work_available", subject, body) +} + +func renderedWakeEnvelopeOfType(id, typ, subject, body string) eventmodel.EventEnvelope { + return eventmodel.DerivedEnvelope(eventmodel.Event{ + SchemaVersion: eventmodel.SchemaVersion, + ID: id, + Type: typ, + Subject: eventmodel.EventSubject(subject), + Actor: "mnemond@local", + Audience: "planner@team", + Payload: eventmodel.BuildPayload(nil, map[string]any{"body": body}, nil), + }, "2026-06-29T10:00:00Z", "2026-06-29T10:05:00Z", "work", nil) +} diff --git a/harness/internal/drive/trace.go b/harness/internal/drive/trace.go new file mode 100644 index 00000000..521a0046 --- /dev/null +++ b/harness/internal/drive/trace.go @@ -0,0 +1,21 @@ +package drive + +import ( + "context" + + "github.com/mnemon-dev/mnemon/harness/internal/activationtrace" +) + +const ManagedTurnTraceSourceCodexAppServer = activationtrace.SourceCodexAppServer + +type ManagedTurnTraceEvent = activationtrace.Event +type ManagedTurnTraceSink = activationtrace.Sink +type ManagedTurnTraceSinkFunc = activationtrace.SinkFunc + +type ManagedTurnClientWithTrace interface { + StartTurnWithTrace(ctx context.Context, query string, sink ManagedTurnTraceSink) (ManagedTurnResult, error) +} + +func ManagedTurnTraceEventsFromCodexNotifications(principal string, notifications []map[string]any) []ManagedTurnTraceEvent { + return activationtrace.EventsFromCodexNotifications(principal, notifications) +} diff --git a/harness/internal/driver/managed_agent.go b/harness/internal/driver/managed_agent.go index cc68cb9c..c74ea26a 100644 --- a/harness/internal/driver/managed_agent.go +++ b/harness/internal/driver/managed_agent.go @@ -1,213 +1,24 @@ package driver import ( - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "sync" - "time" - + "github.com/mnemon-dev/mnemon/harness/internal/drive" 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 - FinalAnswer string -} - -type ManagedWakeCandidate struct { - Principal string - DerivedEventID string - BodyDigest string - Reason string - RenderAuditID string - RenderBodyDigest string -} +const ManagedWakeQuery = drive.ManagedWakeQuery -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"` - 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 { - Seen(ManagedWakeCandidate) bool - Record(ManagedWakeRecord) error -} - -type MemoryManagedWakeLedger struct { - mu sync.Mutex - seen map[string]ManagedWakeRecord -} +type ManagedTurnClient = drive.ManagedTurnClient +type ManagedTurnResult = drive.ManagedTurnResult +type ManagedWakeCandidate = drive.ManagedWakeCandidate +type ManagedWakeRecord = drive.ManagedWakeRecord +type ManagedWakeLedger = drive.ManagedWakeLedger +type MemoryManagedWakeLedger = drive.MemoryManagedWakeLedger +type ManagedAgentDriver = drive.ManagedAgentDriver 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 - TraceSink ManagedTurnTraceSink - 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", - RenderAuditID: candidate.RenderAuditID, - RenderBodyDigest: candidate.RenderBodyDigest, - } - var result ManagedTurnResult - var err error - if traced, ok := d.Client.(ManagedTurnClientWithTrace); ok && d.TraceSink != nil { - result, err = traced.StartTurnWithTrace(ctx, ManagedWakeQuery, d.TraceSink) - } else { - 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.FinalAnswer = result.FinalAnswer - 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() + return drive.NewMemoryManagedWakeLedger() } 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 + return drive.ManagedWakeCandidatesFromEvents(principal, events) } diff --git a/harness/internal/driver/managed_agent_test.go b/harness/internal/driver/managed_agent_test.go index 9ac75688..1a781a1e 100644 --- a/harness/internal/driver/managed_agent_test.go +++ b/harness/internal/driver/managed_agent_test.go @@ -75,12 +75,14 @@ func TestManagedAgentDriverDedupesAndCoolsDown(t *testing.T) { func TestManagedAgentDriverRecordsFailureWithoutChangingQuery(t *testing.T) { ledger := NewMemoryManagedWakeLedger() + attempts := 0 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) } + attempts++ return ManagedTurnResult{}, errors.New("runtime unavailable") }), Ledger: ledger, @@ -93,8 +95,14 @@ func TestManagedAgentDriverRecordsFailureWithoutChangingQuery(t *testing.T) { 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") + if ledger.Seen(candidate) { + t.Fatal("failed wake should be audited but remain retryable") + } + if _, err := driver.Wake(context.Background(), candidate); err == nil { + t.Fatal("retry should still surface runtime failure") + } + if attempts != 2 { + t.Fatalf("failed wake should retry same candidate, attempts=%d", attempts) } } diff --git a/harness/internal/driver/managed_runtime.go b/harness/internal/driver/managed_runtime.go index df2ccf4b..cba74852 100644 --- a/harness/internal/driver/managed_runtime.go +++ b/harness/internal/driver/managed_runtime.go @@ -1,355 +1,24 @@ package driver import ( - "bufio" - "bytes" "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" - "os/exec" - "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/drive" "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 -} +type HTTPRenderClient = drive.HTTPRenderClient +type FileManagedWakeLedger = drive.FileManagedWakeLedger +type CodexAppServerTurnClient = drive.CodexAppServerTurnClient 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 + return drive.ManagedWakeCandidatesFromRender(principal, resp) } 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 { - Principal string - Command string - Workspace string - Env []string - TurnTimeout time.Duration - RequestTimeout time.Duration - ClientName string - ClientVersion string -} - -func (c CodexAppServerTurnClient) StartTurn(ctx context.Context, query string) (ManagedTurnResult, error) { - return c.StartTurnWithTrace(ctx, query, nil) -} - -func (c CodexAppServerTurnClient) StartTurnWithTrace(ctx context.Context, query string, sink ManagedTurnTraceSink) (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}, - "capabilities": codexAppServerCapabilities(), - }, requestTimeout); err != nil { - return ManagedTurnResult{}, fmt.Errorf("initialize: %w", err) - } - 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) - } - threadID := codexapp.ThreadID(thread) - if threadID == "" { - return ManagedTurnResult{}, fmt.Errorf("thread/start returned no thread id") - } - 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"}, - } - 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) - } - emitTrace := func(notifications []map[string]any) { - if sink == nil { - return - } - for _, event := range ManagedTurnTraceEventsFromCodexNotifications(c.Principal, notifications) { - sink.OnManagedTurnTrace(event) - } - } - if _, err := server.WaitNotificationWithCallback("turn/completed", turnTimeout, before, emitTrace); 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 + return drive.NewFileManagedWakeLedger(path) } 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, - } + return drive.CodexAppServerAdditionalContext(ctx, workspace, env, query) } diff --git a/harness/internal/driver/managed_trace.go b/harness/internal/driver/managed_trace.go index 259a3e1c..5bc76d04 100644 --- a/harness/internal/driver/managed_trace.go +++ b/harness/internal/driver/managed_trace.go @@ -2,251 +2,23 @@ package driver import ( "context" - "encoding/json" - "fmt" - "strings" + + "github.com/mnemon-dev/mnemon/harness/internal/activationtrace" ) const ( - ManagedTurnTraceSourceCodexAppServer = "codex-appserver" - managedTurnTraceTextLimit = 12000 + ManagedTurnTraceSourceCodexAppServer = activationtrace.SourceCodexAppServer + managedTurnTraceTextLimit = activationtrace.TextLimit ) -type ManagedTurnTraceEvent struct { - Kind string `json:"kind"` - SourceRuntime string `json:"source_runtime"` - Principal string `json:"principal,omitempty"` - TurnID string `json:"turn_id,omitempty"` - ItemID string `json:"item_id,omitempty"` - Method string `json:"method,omitempty"` - ItemType string `json:"item_type,omitempty"` - Phase string `json:"phase,omitempty"` - Text string `json:"text,omitempty"` - Command string `json:"command,omitempty"` - CWD string `json:"cwd,omitempty"` - Status string `json:"status,omitempty"` - ExitCode any `json:"exit_code,omitempty"` - Output string `json:"output,omitempty"` - Item map[string]any `json:"item,omitempty"` -} - -type ManagedTurnTraceSink interface { - OnManagedTurnTrace(ManagedTurnTraceEvent) -} - -type ManagedTurnTraceSinkFunc func(ManagedTurnTraceEvent) - -func (f ManagedTurnTraceSinkFunc) OnManagedTurnTrace(event ManagedTurnTraceEvent) { - if f != nil { - f(event) - } -} +type ManagedTurnTraceEvent = activationtrace.Event +type ManagedTurnTraceSink = activationtrace.Sink +type ManagedTurnTraceSinkFunc = activationtrace.SinkFunc type ManagedTurnClientWithTrace interface { StartTurnWithTrace(ctx context.Context, query string, sink ManagedTurnTraceSink) (ManagedTurnResult, error) } func ManagedTurnTraceEventsFromCodexNotifications(principal string, notifications []map[string]any) []ManagedTurnTraceEvent { - var out []ManagedTurnTraceEvent - for _, notification := range notifications { - event, ok := managedTurnTraceEventFromCodexNotification(principal, notification) - if ok { - out = append(out, event) - } - } - return out -} - -func managedTurnTraceEventFromCodexNotification(principal string, notification map[string]any) (ManagedTurnTraceEvent, bool) { - method := stringFromMap(notification, "method") - params, _ := notification["params"].(map[string]any) - event := ManagedTurnTraceEvent{ - SourceRuntime: ManagedTurnTraceSourceCodexAppServer, - Principal: strings.TrimSpace(principal), - Method: method, - TurnID: stringFromMap(params, "turnId"), - } - switch method { - case "item/started", "item/completed": - rawItem, _ := params["item"].(map[string]any) - if len(rawItem) == 0 { - return ManagedTurnTraceEvent{}, false - } - item := sanitizeManagedTraceMap(rawItem) - event.Item = item - event.ItemID = firstNonEmptyString(stringFromMap(item, "id"), stringFromMap(params, "itemId")) - event.ItemType = stringFromMap(item, "type") - event.Phase = stringFromMap(item, "phase") - event.Text = firstNonEmptyString(stringFromMap(item, "text"), stringFromMap(item, "aggregatedOutput")) - event.Command = stringFromMap(item, "command") - event.CWD = stringFromMap(item, "cwd") - event.Status = stringFromMap(item, "status") - event.ExitCode = item["exitCode"] - event.Output = stringFromMap(item, "aggregatedOutput") - event.Kind = managedTraceKindForItem(event.ItemType) - return event, true - case "item/agentMessage/delta": - event.Kind = "agent_message_delta" - event.ItemID = stringFromMap(params, "itemId") - event.Text = sanitizeManagedTraceString("delta", stringFromMap(params, "delta")) - event.ItemType = "agentMessage" - return event, strings.TrimSpace(event.Text) != "" || strings.TrimSpace(event.ItemID) != "" - case "turn/completed": - event.Kind = "turn_completed" - event.Status = "completed" - return event, true - case "turn/failed": - event.Kind = "turn_failed" - event.Status = "failed" - event.Text = sanitizeManagedTraceAny("error", params["error"]) - return event, true - default: - return ManagedTurnTraceEvent{}, false - } -} - -func managedTraceKindForItem(itemType string) string { - switch strings.TrimSpace(itemType) { - case "agentMessage": - return "agent_message" - case "commandExecution": - return "command_execution" - case "fileChange", "patchApply", "patch_apply": - return "file_change" - default: - if strings.TrimSpace(itemType) == "" { - return "item" - } - return "item" - } -} - -func sanitizeManagedTraceMap(in map[string]any) map[string]any { - out := map[string]any{} - for key, value := range in { - out[key] = sanitizeManagedTraceValue(key, value) - } - return out -} - -func sanitizeManagedTraceValue(key string, value any) any { - if isSensitiveManagedTraceKey(key) { - if value == nil { - return nil - } - return "[redacted]" - } - switch typed := value.(type) { - case map[string]any: - return sanitizeManagedTraceMap(typed) - case []any: - out := make([]any, 0, len(typed)) - for _, item := range typed { - out = append(out, sanitizeManagedTraceValue(key, item)) - } - return out - case string: - return sanitizeManagedTraceString(key, typed) - default: - return value - } -} - -func sanitizeManagedTraceAny(key string, value any) string { - if value == nil { - return "" - } - if text, ok := value.(string); ok { - return sanitizeManagedTraceString(key, text) - } - data, err := json.Marshal(sanitizeManagedTraceValue(key, value)) - if err != nil { - return sanitizeManagedTraceString(key, fmt.Sprint(value)) - } - return sanitizeManagedTraceString(key, string(data)) -} - -func sanitizeManagedTraceString(key, value string) string { - value = redactManagedTraceSecrets(key, value) - if len(value) <= managedTurnTraceTextLimit { - return value - } - return value[:managedTurnTraceTextLimit] + "\n[truncated]" -} - -func redactManagedTraceSecrets(key, value string) string { - if isSensitiveManagedTraceKey(key) { - if value == "" { - return "" - } - return "[redacted]" - } - redacted := value - for _, marker := range managedTraceSensitiveMarkers() { - redacted = redactMarkerAssignments(redacted, marker) - } - return redacted -} - -func redactMarkerAssignments(value, marker string) string { - needle := strings.ToLower(marker) - searchFrom := 0 - for { - lower := strings.ToLower(value) - if searchFrom >= len(value) { - return value - } - relative := strings.Index(lower[searchFrom:], needle) - if relative < 0 { - return value - } - idx := searchFrom + relative - if idx < 0 { - return value - } - end := idx + len(marker) - if end >= len(value) || (value[end] != '=' && value[end] != ':') { - searchFrom = end - continue - } - if strings.HasPrefix(strings.ToLower(value[end:]), "=[redacted]") || strings.HasPrefix(strings.ToLower(value[end:]), ":[redacted]") { - searchFrom = end + len("=[redacted]") - continue - } - separator := value[end] - end++ - for end < len(value) { - ch := value[end] - if ch == ' ' || ch == '\n' || ch == '\t' || ch == ',' || ch == ';' { - break - } - end++ - } - value = value[:idx+len(marker)] + string(separator) + "[redacted]" + value[end:] - searchFrom = idx + len(marker) + len("=[redacted]") - } -} - -func isSensitiveManagedTraceKey(key string) bool { - key = strings.ToUpper(strings.TrimSpace(key)) - for _, marker := range managedTraceSensitiveMarkers() { - if strings.Contains(key, marker) { - return true - } - } - return false -} - -func managedTraceSensitiveMarkers() []string { - return []string{"TOKEN", "SECRET", "PASSWORD", "API_KEY", "AUTH", "CREDENTIAL", "COOKIE"} -} - -func stringFromMap(values map[string]any, key string) string { - if values == nil { - return "" - } - if value, ok := values[key].(string); ok { - return strings.TrimSpace(value) - } - return "" + return activationtrace.EventsFromCodexNotifications(principal, notifications) } diff --git a/harness/internal/driver/managed_trace_test.go b/harness/internal/driver/managed_trace_test.go index 2c3e7cf3..404316aa 100644 --- a/harness/internal/driver/managed_trace_test.go +++ b/harness/internal/driver/managed_trace_test.go @@ -16,11 +16,11 @@ func TestManagedTurnTraceEventsFromCodexNotificationsRedactsAndBounds(t *testing "item": map[string]any{ "type": "commandExecution", "id": "call-1", - "command": "TOKEN=raw-secret multica issue get TEA-1", + "command": "TOKEN=raw-secret curl -H 'Authorization: Bearer ghp_secret' https://api.github.com", "cwd": "/tmp/work", "status": "completed", "exitCode": 0, - "aggregatedOutput": "API_KEY=abc123 " + longOutput, + "aggregatedOutput": "API_KEY=abc123 authorization: bearer mat_secret " + longOutput, "authToken": "must-not-leak", }, }, @@ -34,12 +34,13 @@ func TestManagedTurnTraceEventsFromCodexNotificationsRedactsAndBounds(t *testing t.Fatalf("unexpected trace event: %+v", got) } joined := got.Command + "\n" + got.Output + "\n" + strings.TrimSpace(traceTestString(got.Item["authToken"])) - for _, forbidden := range []string{"raw-secret", "abc123", "must-not-leak"} { + for _, forbidden := range []string{"raw-secret", "abc123", "must-not-leak", "ghp_secret", "mat_secret"} { if strings.Contains(joined, forbidden) { t.Fatalf("trace leaked %q: %+v", forbidden, got) } } - if !strings.Contains(got.Command, "TOKEN=[redacted]") || !strings.Contains(got.Output, "API_KEY=[redacted]") { + if !strings.Contains(got.Command, "TOKEN=[redacted]") || !strings.Contains(got.Command, "Bearer [redacted]") || + !strings.Contains(got.Output, "API_KEY=[redacted]") || !strings.Contains(got.Output, "bearer [redacted]") { t.Fatalf("trace did not redact command/output: command=%q output=%q", got.Command, got.Output[:80]) } if !strings.Contains(got.Output, "[truncated]") { diff --git a/harness/internal/driver/multica.go b/harness/internal/driver/multica.go index 98937550..2172b6c4 100644 --- a/harness/internal/driver/multica.go +++ b/harness/internal/driver/multica.go @@ -13,12 +13,14 @@ import ( "sync" "time" - eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + multicasurface "github.com/mnemon-dev/mnemon/harness/internal/surface/multica" ) const ( - MulticaDefaultCommand = "multica" - MulticaExternalSource = "multica" + MulticaDefaultCommand = "multica" + MulticaRuntimeCommandName = multicasurface.MulticaRuntimeCommandName + MulticaRuntimeProfileName = multicasurface.MulticaRuntimeProfileName + MulticaExternalSource = multicasurface.MulticaExternalSource ) type MulticaCLI struct { @@ -741,6 +743,18 @@ func (c MulticaCLI) ListIssueMetadata(ctx context.Context, issueID string) (map[ return decodeMulticaMetadataOutput([]byte(out.Stdout)) } +func (c MulticaCLI) LoadIssueMetadata(ctx context.Context, issue MulticaIssue) (MulticaIssue, int, error) { + if strings.TrimSpace(issue.ID) == "" { + return issue, 0, nil + } + listed, err := c.ListIssueMetadata(ctx, issue.ID) + if err != nil { + return issue, 0, err + } + issue.Metadata = multicasurface.MergeIssueMetadata(issue.Metadata, listed) + return issue, len(listed), nil +} + func (c MulticaCLI) ListIssueChildren(ctx context.Context, parentID string) ([]MulticaIssue, error) { parentID = strings.TrimSpace(parentID) if parentID == "" { @@ -842,110 +856,6 @@ func (c MulticaCLI) globalArgs(args []string) []string { return append(out, args...) } -type MulticaIssueSignalOptions struct { - Scope string - TTL string - WhyTeamwork string - WorkspaceID string - TaskID string - AgentID string - Principal string - EvidenceRefs []string - ContextRefs []string - ExternalID string -} - -type MulticaObservedDraft struct { - EventType string `json:"event_type"` - ExternalID string `json:"external_id"` - Payload map[string]any `json:"payload"` -} - -func BuildMulticaIssueTeamworkSignal(issue MulticaIssue, opts MulticaIssueSignalOptions) (MulticaObservedDraft, error) { - if strings.TrimSpace(issue.ID) == "" { - return MulticaObservedDraft{}, fmt.Errorf("multica issue id is required") - } - title := strings.TrimSpace(issue.Title) - if title == "" { - title = strings.TrimSpace(issue.Identifier) - } - if title == "" { - title = issue.ID - } - scope := strings.TrimSpace(opts.Scope) - if scope == "" { - scope = "multica/teamwork" - } - ttl := strings.TrimSpace(opts.TTL) - if ttl == "" { - ttl = "30m" - } - correlation := "multica:issue:" + issue.ID - rule := map[string]any{ - "external_source": MulticaExternalSource, - "external_issue_id": issue.ID, - "external_issue_identifier": strings.TrimSpace(issue.Identifier), - "correlation_id": correlation, - "scope": scope, - "ttl": ttl, - } - addMulticaRuleString(rule, "external_workspace_id", opts.WorkspaceID) - addMulticaRuleString(rule, "external_task_id", opts.TaskID) - addMulticaRuleString(rule, "external_agent_id", opts.AgentID) - addMulticaRuleString(rule, "principal", opts.Principal) - narrative := map[string]any{ - "title": title, - "statement": multicaIssueStatement(issue, title), - "why_teamwork": strings.TrimSpace(opts.WhyTeamwork), - } - if narrative["why_teamwork"] == "" { - narrative["why_teamwork"] = "The Multica issue is being bridged into Mnemon so local agents can decide whether and how to coordinate." - } - refs := map[string]any{ - "context_refs": append([]string{correlation}, cleanMulticaRefs(opts.ContextRefs)...), - } - if evidence := cleanMulticaRefs(opts.EvidenceRefs); len(evidence) > 0 { - refs["evidence_refs"] = evidence - } else { - refs["evidence_refs"] = []string{correlation} - } - externalID := strings.TrimSpace(opts.ExternalID) - if externalID == "" { - externalID = "multica-issue-" + issue.ID - } - return MulticaObservedDraft{ - EventType: "teamwork_signal.write_candidate.observed", - ExternalID: externalID, - Payload: eventmodel.BuildPayload(rule, narrative, refs), - }, nil -} - -func FormatMulticaProjectionComment(title string, body string, eventIDs []string) string { - title = strings.TrimSpace(title) - body = strings.TrimSpace(body) - var b strings.Builder - if title != "" { - b.WriteString("Mnemon update: ") - b.WriteString(title) - b.WriteString("\n\n") - } else { - b.WriteString("Mnemon update\n\n") - } - if body != "" { - b.WriteString(body) - b.WriteString("\n") - } - if ids := cleanMulticaRefs(eventIDs); len(ids) > 0 { - b.WriteString("\n") - for _, id := range ids { - b.WriteString("mnemon:event=") - b.WriteString(id) - b.WriteString("\n") - } - } - return strings.TrimSpace(b.String()) -} - func DecodeMulticaIssue(r io.Reader) (MulticaIssue, error) { var issue MulticaIssue if err := json.NewDecoder(r).Decode(&issue); err != nil { @@ -1114,31 +1024,3 @@ func decodeMulticaStringMap(data []byte) (map[string]string, error) { } return out, nil } - -func multicaIssueStatement(issue MulticaIssue, fallback string) string { - if strings.TrimSpace(issue.Description) != "" { - return strings.TrimSpace(issue.Description) - } - return fallback -} - -func cleanMulticaRefs(values []string) []string { - var out []string - seen := map[string]bool{} - for _, value := range values { - value = strings.TrimSpace(value) - if value == "" || seen[value] { - continue - } - seen[value] = true - out = append(out, value) - } - return out -} - -func addMulticaRuleString(rule map[string]any, key, value string) { - value = strings.TrimSpace(value) - if value != "" { - rule[key] = value - } -} diff --git a/harness/internal/driver/multica_hub.go b/harness/internal/driver/multica_hub.go index 23a5f9dd..08435f7c 100644 --- a/harness/internal/driver/multica_hub.go +++ b/harness/internal/driver/multica_hub.go @@ -1,320 +1,72 @@ package driver import ( - "bufio" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - "time" + "context" + + multicasurface "github.com/mnemon-dev/mnemon/harness/internal/surface/multica" ) const ( - MulticaHubBackend = "multica" - - MulticaDefaultHubLedgerRelPath = ".mnemon/harness/multica/hub-ledger.jsonl" - - MulticaMetadataSchemaVersion = "mnemon.schema_version" - MulticaMetadataHubBackend = "mnemon.hub_backend" - MulticaMetadataKind = "mnemon.kind" - MulticaMetadataSessionID = "mnemon.session_id" - MulticaMetadataCorrelationID = "mnemon.correlation_id" - MulticaMetadataEventID = "mnemon.event_id" - MulticaMetadataEventType = "mnemon.event_type" - MulticaMetadataEventPhase = "mnemon.event_phase" - MulticaMetadataAssignmentID = "mnemon.assignment_id" - MulticaMetadataAssignmentFingerprint = "mnemon.assignment_fingerprint" - MulticaMetadataPrincipal = "mnemon.principal" - MulticaMetadataSourceIssueID = "mnemon.source_issue_id" - MulticaMetadataRootIssueID = "mnemon.root_issue_id" - MulticaMetadataProjectionOwner = "mnemon.projection_owner" - MulticaMetadataMulticaAgentID = "mnemon.multica_agent_id" - MulticaMetadataProjectedAt = "mnemon.projected_at" - MulticaMetadataEnvelopeDigest = "mnemon.envelope_digest" - MulticaHubKindSession = "session_mailbox" - MulticaHubKindAssignmentMailbox = "assignment_mailbox" - MulticaHubKindFeedbackCarrier = "feedback_carrier" - MulticaHubKindAssignmentProjectionOld = "assignment_projection" + MulticaHubBackend = multicasurface.MulticaHubBackend + + MulticaDefaultHubLedgerRelPath = multicasurface.MulticaDefaultHubLedgerRelPath + + MulticaMetadataSchemaVersion = multicasurface.MulticaMetadataSchemaVersion + MulticaMetadataHubBackend = multicasurface.MulticaMetadataHubBackend + MulticaMetadataKind = multicasurface.MulticaMetadataKind + MulticaMetadataSessionID = multicasurface.MulticaMetadataSessionID + MulticaMetadataCorrelationID = multicasurface.MulticaMetadataCorrelationID + MulticaMetadataEventID = multicasurface.MulticaMetadataEventID + MulticaMetadataEventType = multicasurface.MulticaMetadataEventType + MulticaMetadataEventPhase = multicasurface.MulticaMetadataEventPhase + MulticaMetadataAssignmentID = multicasurface.MulticaMetadataAssignmentID + MulticaMetadataAssignmentFingerprint = multicasurface.MulticaMetadataAssignmentFingerprint + MulticaMetadataPrincipal = multicasurface.MulticaMetadataPrincipal + MulticaMetadataSourceIssueID = multicasurface.MulticaMetadataSourceIssueID + MulticaMetadataRootIssueID = multicasurface.MulticaMetadataRootIssueID + MulticaMetadataProjectionOwner = multicasurface.MulticaMetadataProjectionOwner + MulticaMetadataMulticaAgentID = multicasurface.MulticaMetadataMulticaAgentID + MulticaMetadataProjectedAt = multicasurface.MulticaMetadataProjectedAt + MulticaMetadataEnvelopeDigest = multicasurface.MulticaMetadataEnvelopeDigest + MulticaHubKindSession = multicasurface.MulticaHubKindSession + MulticaHubKindAssignmentMailbox = multicasurface.MulticaHubKindAssignmentMailbox + MulticaHubKindFeedbackCarrier = multicasurface.MulticaHubKindFeedbackCarrier + MulticaHubKindAssignmentProjectionOld = multicasurface.MulticaHubKindAssignmentProjectionOld ) -type MulticaHubMetadata struct { - SchemaVersion string - HubBackend string - Kind string - SessionID string - CorrelationID string - EventID string - EventType string - EventPhase string - AssignmentID string - AssignmentFingerprint string - Principal string - SourceIssueID string - RootIssueID string - ProjectionOwner string - MulticaAgentID string - ProjectedAt string - EnvelopeDigest string -} - -type MulticaAssignmentFingerprintInput struct { - AssignmentID string `json:"assignment_id,omitempty"` - Assignee string `json:"assignee,omitempty"` - Scope string `json:"scope,omitempty"` - ExpectedWork string `json:"expected_work,omitempty"` - ExpectedFeedback string `json:"expected_feedback,omitempty"` - SignalRef string `json:"signal_ref,omitempty"` - ContextRefs []string `json:"context_refs,omitempty"` - EvidenceRefs []string `json:"evidence_refs,omitempty"` - CorrelationID string `json:"correlation_id,omitempty"` -} - -type MulticaHubLedgerRecord struct { - SchemaVersion int `json:"schema_version"` - Kind string `json:"kind"` - Source MulticaHubLedgerSource `json:"source"` - Target MulticaHubLedgerTarget `json:"target"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at,omitempty"` -} - -type MulticaHubLedgerSource struct { - SessionID string `json:"session_id,omitempty"` - CorrelationID string `json:"correlation_id,omitempty"` - EventID string `json:"event_id,omitempty"` - AssignmentID string `json:"assignment_id,omitempty"` - AssignmentFingerprint string `json:"assignment_fingerprint,omitempty"` - Principal string `json:"principal,omitempty"` - ProjectionKind string `json:"projection_kind,omitempty"` -} - -type MulticaHubLedgerTarget struct { - RootIssueID string `json:"root_issue_id,omitempty"` - ChildIssueID string `json:"child_issue_id,omitempty"` - CommentID string `json:"comment_id,omitempty"` - Status string `json:"status,omitempty"` -} - -type FileMulticaHubLedger struct { - path string - loaded bool - loadErr error - records []MulticaHubLedgerRecord -} +type MulticaHubMetadata = multicasurface.MulticaHubMetadata +type MulticaAssignmentFingerprintInput = multicasurface.MulticaAssignmentFingerprintInput +type MulticaHubLedgerRecord = multicasurface.MulticaHubLedgerRecord +type MulticaHubLedgerSource = multicasurface.MulticaHubLedgerSource +type MulticaHubLedgerTarget = multicasurface.MulticaHubLedgerTarget +type FileMulticaHubLedger = multicasurface.FileMulticaHubLedger func MulticaHubLedgerPath(root, explicit string) string { - if strings.TrimSpace(explicit) != "" { - return strings.TrimSpace(explicit) - } - root = strings.TrimSpace(root) - if root == "" { - root = "." - } - return filepath.Join(root, MulticaDefaultHubLedgerRelPath) + return multicasurface.MulticaHubLedgerPath(root, explicit) } func NewFileMulticaHubLedger(path string) *FileMulticaHubLedger { - return &FileMulticaHubLedger{path: strings.TrimSpace(path)} -} - -func (l *FileMulticaHubLedger) Records() ([]MulticaHubLedgerRecord, error) { - if err := l.load(); err != nil { - return nil, err - } - return append([]MulticaHubLedgerRecord(nil), l.records...), nil -} - -func (l *FileMulticaHubLedger) Find(kind string, source MulticaHubLedgerSource) (MulticaHubLedgerRecord, bool, error) { - if err := l.load(); err != nil { - return MulticaHubLedgerRecord{}, false, err - } - want := multicaHubLedgerKey(kind, source) - for i := len(l.records) - 1; i >= 0; i-- { - rec := l.records[i] - if multicaHubLedgerKey(rec.Kind, rec.Source) == want { - return rec, true, nil - } - } - return MulticaHubLedgerRecord{}, false, nil -} - -func (l *FileMulticaHubLedger) Record(record MulticaHubLedgerRecord) error { - if err := l.load(); err != nil { - return err - } - if strings.TrimSpace(l.path) == "" { - return fmt.Errorf("multica hub ledger path is required") - } - key := multicaHubLedgerKey(record.Kind, record.Source) - for _, existing := range l.records { - if multicaHubLedgerKey(existing.Kind, existing.Source) == key && existing.Target == record.Target { - return nil - } - } - if record.SchemaVersion == 0 { - record.SchemaVersion = 1 - } - now := time.Now().UTC().Format(time.RFC3339) - if strings.TrimSpace(record.CreatedAt) == "" { - record.CreatedAt = now - } - for i := len(l.records) - 1; i >= 0; i-- { - if multicaHubLedgerKey(l.records[i].Kind, l.records[i].Source) == key { - record.CreatedAt = l.records[i].CreatedAt - record.UpdatedAt = now - break - } - } - 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.upsertLoaded(record) - return nil -} - -func (l *FileMulticaHubLedger) load() error { - if l.loaded { - return l.loadErr - } - l.loaded = true - if strings.TrimSpace(l.path) == "" { - l.loadErr = fmt.Errorf("multica hub ledger path is required") - return l.loadErr - } - f, err := os.Open(l.path) - if os.IsNotExist(err) { - return nil - } - if err != nil { - l.loadErr = err - return err - } - defer f.Close() - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - var record MulticaHubLedgerRecord - if err := json.Unmarshal([]byte(line), &record); err != nil { - l.loadErr = err - return err - } - l.upsertLoaded(record) - } - if err := scanner.Err(); err != nil { - l.loadErr = err - return err - } - return nil -} - -func (l *FileMulticaHubLedger) upsertLoaded(record MulticaHubLedgerRecord) { - key := multicaHubLedgerKey(record.Kind, record.Source) - for i := range l.records { - if multicaHubLedgerKey(l.records[i].Kind, l.records[i].Source) == key { - l.records[i] = record - return - } - } - l.records = append(l.records, record) -} - -func multicaHubLedgerKey(kind string, source MulticaHubLedgerSource) string { - return strings.Join([]string{ - strings.TrimSpace(kind), - strings.TrimSpace(source.SessionID), - strings.TrimSpace(source.CorrelationID), - strings.TrimSpace(source.EventID), - strings.TrimSpace(source.AssignmentID), - strings.TrimSpace(source.AssignmentFingerprint), - strings.TrimSpace(source.Principal), - strings.TrimSpace(source.ProjectionKind), - }, "\x00") + return multicasurface.NewFileMulticaHubLedger(path) } func ParseMulticaHubMetadata(raw map[string]any) MulticaHubMetadata { - meta := NormalizeMulticaMetadata(raw) - return MulticaHubMetadata{ - SchemaVersion: meta[MulticaMetadataSchemaVersion], - HubBackend: meta[MulticaMetadataHubBackend], - Kind: meta[MulticaMetadataKind], - SessionID: meta[MulticaMetadataSessionID], - CorrelationID: meta[MulticaMetadataCorrelationID], - EventID: meta[MulticaMetadataEventID], - EventType: meta[MulticaMetadataEventType], - EventPhase: meta[MulticaMetadataEventPhase], - AssignmentID: meta[MulticaMetadataAssignmentID], - AssignmentFingerprint: meta[MulticaMetadataAssignmentFingerprint], - Principal: meta[MulticaMetadataPrincipal], - SourceIssueID: meta[MulticaMetadataSourceIssueID], - RootIssueID: meta[MulticaMetadataRootIssueID], - ProjectionOwner: meta[MulticaMetadataProjectionOwner], - MulticaAgentID: meta[MulticaMetadataMulticaAgentID], - ProjectedAt: meta[MulticaMetadataProjectedAt], - EnvelopeDigest: meta[MulticaMetadataEnvelopeDigest], - } + return multicasurface.ParseMulticaHubMetadata(raw) } -func (m MulticaHubMetadata) Map() map[string]string { - out := map[string]string{} - add := func(key, value string) { - value = strings.TrimSpace(value) - if value != "" { - out[key] = value - } - } - add(MulticaMetadataSchemaVersion, firstNonEmptyString(m.SchemaVersion, "1")) - add(MulticaMetadataHubBackend, firstNonEmptyString(m.HubBackend, MulticaHubBackend)) - add(MulticaMetadataKind, m.Kind) - add(MulticaMetadataSessionID, m.SessionID) - add(MulticaMetadataCorrelationID, m.CorrelationID) - add(MulticaMetadataEventID, m.EventID) - add(MulticaMetadataEventType, m.EventType) - add(MulticaMetadataEventPhase, m.EventPhase) - add(MulticaMetadataAssignmentID, m.AssignmentID) - add(MulticaMetadataAssignmentFingerprint, m.AssignmentFingerprint) - add(MulticaMetadataPrincipal, m.Principal) - add(MulticaMetadataSourceIssueID, m.SourceIssueID) - add(MulticaMetadataRootIssueID, m.RootIssueID) - add(MulticaMetadataProjectionOwner, m.ProjectionOwner) - add(MulticaMetadataMulticaAgentID, m.MulticaAgentID) - add(MulticaMetadataProjectedAt, m.ProjectedAt) - add(MulticaMetadataEnvelopeDigest, m.EnvelopeDigest) - return out +func MulticaIssueHubMetadata(issue MulticaIssue) MulticaHubMetadata { + return ParseMulticaHubMetadata(issue.Metadata) } -func (m MulticaHubMetadata) IsAssignmentMailbox() bool { - if strings.TrimSpace(m.HubBackend) != "" && m.HubBackend != MulticaHubBackend { - return false +func (c MulticaCLI) ResolveIssueHubMetadata(ctx context.Context, issue MulticaIssue) (MulticaHubMetadata, error) { + meta := MulticaIssueHubMetadata(issue) + if meta.IsAssignmentMailbox() { + return meta, nil } - switch strings.TrimSpace(m.Kind) { - case MulticaHubKindAssignmentMailbox, MulticaHubKindAssignmentProjectionOld: - return strings.TrimSpace(m.AssignmentID) != "" || strings.TrimSpace(m.AssignmentFingerprint) != "" - default: - return false + listed, err := c.ListIssueMetadata(ctx, issue.ID) + if err != nil { + return MulticaHubMetadata{}, err } -} - -func MulticaIssueHubMetadata(issue MulticaIssue) MulticaHubMetadata { - return ParseMulticaHubMetadata(issue.Metadata) + return multicasurface.ParseMulticaHubListedMetadata(listed), nil } func IsMulticaAssignmentMailboxIssue(issue MulticaIssue) bool { @@ -322,123 +74,13 @@ func IsMulticaAssignmentMailboxIssue(issue MulticaIssue) bool { } func NormalizeMulticaMetadata(raw any) map[string]string { - out := map[string]string{} - merge := func(key string, value any) { - key = strings.TrimSpace(key) - if key == "" { - return - } - if key == "metadata" { - for k, v := range NormalizeMulticaMetadata(value) { - out[k] = v - } - return - } - out[key] = multicaMetadataString(value) - } - switch v := raw.(type) { - case nil: - case map[string]string: - for key, value := range v { - merge(key, value) - } - case map[string]any: - if key := firstNonEmptyString(anyString(v["key"]), anyString(v["name"])); key != "" { - merge(key, firstPresent(v, "value", "val", "metadata_value")) - return out - } - for key, value := range v { - merge(key, value) - } - case []any: - for _, item := range v { - m, ok := item.(map[string]any) - if !ok { - continue - } - key := firstNonEmptyString(anyString(m["key"]), anyString(m["name"])) - if key == "" { - for k, value := range NormalizeMulticaMetadata(m) { - out[k] = value - } - continue - } - merge(key, firstPresent(m, "value", "val", "metadata_value")) - } - } - return out + return multicasurface.NormalizeMulticaMetadata(raw) } func MulticaAssignmentFingerprint(input MulticaAssignmentFingerprintInput) string { - input.AssignmentID = strings.TrimSpace(input.AssignmentID) - input.Assignee = strings.TrimSpace(input.Assignee) - input.Scope = strings.TrimSpace(input.Scope) - input.ExpectedWork = strings.TrimSpace(input.ExpectedWork) - input.ExpectedFeedback = strings.TrimSpace(input.ExpectedFeedback) - input.SignalRef = strings.TrimSpace(input.SignalRef) - input.CorrelationID = strings.TrimSpace(input.CorrelationID) - input.ContextRefs = cleanMulticaRefs(input.ContextRefs) - input.EvidenceRefs = cleanMulticaRefs(input.EvidenceRefs) - sort.Strings(input.ContextRefs) - sort.Strings(input.EvidenceRefs) - data, _ := json.Marshal(input) - sum := sha256.Sum256(data) - return "sha256:" + hex.EncodeToString(sum[:]) + return multicasurface.MulticaAssignmentFingerprint(input) } func MulticaSessionID(rootIssueID string) string { - rootIssueID = strings.TrimSpace(rootIssueID) - if rootIssueID == "" { - return "" - } - return "multica:session:" + rootIssueID -} - -func multicaMetadataString(value any) string { - switch v := value.(type) { - case nil: - return "" - case string: - return strings.TrimSpace(v) - case json.Number: - return strings.TrimSpace(v.String()) - case bool: - if v { - return "true" - } - return "false" - case float64: - return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", v), "0"), ".") - default: - data, err := json.Marshal(v) - if err != nil { - return strings.TrimSpace(fmt.Sprint(v)) - } - return strings.TrimSpace(string(data)) - } -} - -func anyString(value any) string { - if s, ok := value.(string); ok { - return strings.TrimSpace(s) - } - return "" -} - -func firstPresent(m map[string]any, keys ...string) any { - for _, key := range keys { - if value, ok := m[key]; ok { - return value - } - } - return nil -} - -func firstNonEmptyString(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" + return multicasurface.MulticaSessionID(rootIssueID) } diff --git a/harness/internal/driver/multica_registry.go b/harness/internal/driver/multica_registry.go index 2086d032..163ca649 100644 --- a/harness/internal/driver/multica_registry.go +++ b/harness/internal/driver/multica_registry.go @@ -1,94 +1,30 @@ package driver import ( - "encoding/json" - "errors" - "os" - "path/filepath" - "strings" + multicasurface "github.com/mnemon-dev/mnemon/harness/internal/surface/multica" ) -const MulticaDefaultRegistryRelPath = ".mnemon/harness/multica/registry.json" +const MulticaDefaultRegistryRelPath = multicasurface.MulticaDefaultRegistryRelPath -type MulticaRegistry struct { - SchemaVersion int `json:"schema_version"` - WorkspaceID string `json:"workspace_id"` - RuntimeProfileID string `json:"runtime_profile_id"` - RuntimeID string `json:"runtime_id"` - Participants []MulticaParticipantRecord `json:"participants"` -} - -type MulticaParticipantRecord struct { - Principal string `json:"principal"` - AgentName string `json:"multica_agent_name"` - AgentID string `json:"multica_agent_id"` - Role string `json:"role"` -} +type MulticaRegistry = multicasurface.MulticaRegistry +type MulticaParticipantRecord = multicasurface.MulticaParticipantRecord func MulticaRegistryPath(root, explicit string) string { - if strings.TrimSpace(explicit) != "" { - return strings.TrimSpace(explicit) - } - root = strings.TrimSpace(root) - if root == "" { - root = "." - } - return filepath.Join(root, MulticaDefaultRegistryRelPath) + return multicasurface.MulticaRegistryPath(root, explicit) } func LoadMulticaRegistry(path string) (MulticaRegistry, bool, error) { - data, err := os.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { - return MulticaRegistry{}, false, nil - } - if err != nil { - return MulticaRegistry{}, false, err - } - var reg MulticaRegistry - if err := json.Unmarshal(data, ®); err != nil { - return MulticaRegistry{}, false, err - } - return reg, true, nil + return multicasurface.LoadMulticaRegistry(path) } func SaveMulticaRegistry(path string, reg MulticaRegistry) error { - if reg.SchemaVersion == 0 { - reg.SchemaVersion = 1 - } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - data, err := json.MarshalIndent(reg, "", " ") - if err != nil { - return err - } - data = append(data, '\n') - return os.WriteFile(path, data, 0o600) + return multicasurface.SaveMulticaRegistry(path, reg) } func DefaultMulticaParticipantRecords(prefix string) []MulticaParticipantRecord { - prefix = strings.TrimSpace(prefix) - if prefix == "" { - prefix = "mnemon" - } - roles := []string{"planner", "researcher", "implementer", "reviewer", "integrator"} - out := make([]MulticaParticipantRecord, 0, len(roles)) - for _, role := range roles { - out = append(out, MulticaParticipantRecord{ - Principal: role + "@team", - AgentName: prefix + "-" + role, - Role: role, - }) - } - return out + return multicasurface.DefaultMulticaParticipantRecords(prefix) } func UpsertMulticaParticipantRecord(records []MulticaParticipantRecord, next MulticaParticipantRecord) []MulticaParticipantRecord { - for i := range records { - if records[i].Principal == next.Principal || records[i].AgentName == next.AgentName { - records[i] = next - return records - } - } - return append(records, next) + return multicasurface.UpsertMulticaParticipantRecord(records, next) } diff --git a/harness/internal/driver/multica_test.go b/harness/internal/driver/multica_test.go index 945ec2e0..83d1fe2e 100644 --- a/harness/internal/driver/multica_test.go +++ b/harness/internal/driver/multica_test.go @@ -8,51 +8,6 @@ import ( "testing" ) -func TestBuildMulticaIssueTeamworkSignalSeparatesRuleAndNarrative(t *testing.T) { - draft, err := BuildMulticaIssueTeamworkSignal(MulticaIssue{ - ID: "iss-123", - Identifier: "MUL-123", - Title: "Validate bridge", - Description: "Check that Multica issue context can start Mnemon teamwork.", - }, MulticaIssueSignalOptions{ - Scope: "multica/poc", - TTL: "45m", - WhyTeamwork: "The task needs more than one local agent.", - WorkspaceID: "workspace-1", - TaskID: "task-1", - AgentID: "agent-1", - Principal: "planner@team", - EvidenceRefs: []string{"multica:issue/iss-123"}, - ExternalID: "multica-task-task-1", - }) - if err != nil { - t.Fatal(err) - } - if draft.EventType != "teamwork_signal.write_candidate.observed" { - t.Fatalf("event type = %q", draft.EventType) - } - rule := draft.Payload["rule"].(map[string]any) - if rule["external_source"] != MulticaExternalSource || rule["external_issue_id"] != "iss-123" || rule["scope"] != "multica/poc" { - t.Fatalf("rule mapping mismatch: %+v", rule) - } - if rule["external_workspace_id"] != "workspace-1" || rule["external_task_id"] != "task-1" || rule["external_agent_id"] != "agent-1" || rule["principal"] != "planner@team" { - t.Fatalf("runtime rule mapping mismatch: %+v", rule) - } - if draft.ExternalID != "multica-task-task-1" { - t.Fatalf("external id = %q", draft.ExternalID) - } - narrative := draft.Payload["narrative"].(map[string]any) - if narrative["statement"] != "Check that Multica issue context can start Mnemon teamwork." { - t.Fatalf("narrative statement mismatch: %+v", narrative) - } - if _, ok := narrative["external_issue_id"]; ok { - t.Fatalf("narrative must not carry rule ids: %+v", narrative) - } - if _, ok := narrative["external_task_id"]; ok { - t.Fatalf("narrative must not carry runtime ids: %+v", narrative) - } -} - func TestMulticaCLIAddCommentUsesStdin(t *testing.T) { tmp := t.TempDir() argsPath := filepath.Join(tmp, "args.txt") @@ -305,23 +260,6 @@ printf '{"id":"issue-1","identifier":"TEA-9","title":"Run teamwork","status":"to } } -func TestFormatMulticaProjectionCommentCarriesStableMarkers(t *testing.T) { - got := FormatMulticaProjectionComment("assignment finished", "Worker reported passing evidence.", []string{"ev-1", "ev-1", "ev-2"}) - for _, want := range []string{ - "Mnemon update: assignment finished", - "Worker reported passing evidence.", - "mnemon:event=ev-1", - "mnemon:event=ev-2", - } { - if !strings.Contains(got, want) { - t.Fatalf("projection comment missing %q:\n%s", want, got) - } - } - if strings.Count(got, "mnemon:event=ev-1") != 1 { - t.Fatalf("projection comment should dedupe markers:\n%s", got) - } -} - func TestMulticaHubMetadataDetectsAssignmentMailbox(t *testing.T) { issue := MulticaIssue{ ID: "issue-2", @@ -348,6 +286,97 @@ func TestMulticaHubMetadataDetectsAssignmentMailbox(t *testing.T) { } } +func TestMulticaCLIResolveIssueHubMetadata(t *testing.T) { + tmp := t.TempDir() + argsPath := filepath.Join(tmp, "args.txt") + bin := filepath.Join(tmp, "multica") + script := `#!/usr/bin/env sh +printf '%s\n' "$*" >> "$MULTICA_ARGS_PATH" +case "$*" in + *"issue metadata list child-listed"*) printf '[{"key":"mnemon.hub_backend","value":"multica"},{"key":"mnemon.kind","value":"assignment_mailbox"},{"key":"mnemon.assignment_id","value":"assignment-listed"},{"key":"mnemon.principal","value":"worker@team"}]\n' ;; + *"issue metadata list child-embedded"*) printf 'embedded metadata should not be listed\n' >&2; exit 42 ;; + *) printf '{}\n' ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + cli := MulticaCLI{ + Command: bin, + Env: append(os.Environ(), + "MULTICA_ARGS_PATH="+argsPath, + ), + } + listed, err := cli.ResolveIssueHubMetadata(context.Background(), MulticaIssue{ID: "child-listed"}) + if err != nil { + t.Fatal(err) + } + if !listed.IsAssignmentMailbox() || listed.AssignmentID != "assignment-listed" || listed.Principal != "worker@team" { + t.Fatalf("listed metadata = %+v", listed) + } + embedded, err := cli.ResolveIssueHubMetadata(context.Background(), MulticaIssue{ + ID: "child-embedded", + Metadata: map[string]any{ + MulticaMetadataHubBackend: MulticaHubBackend, + MulticaMetadataKind: MulticaHubKindAssignmentMailbox, + MulticaMetadataAssignmentID: "assignment-embedded", + }, + }) + if err != nil { + t.Fatal(err) + } + if embedded.AssignmentID != "assignment-embedded" { + t.Fatalf("embedded metadata = %+v", embedded) + } + args := mustReadDriverTestFile(t, argsPath) + if strings.Contains(args, "child-embedded") { + t.Fatalf("embedded assignment metadata should avoid metadata list fallback:\n%s", args) + } +} + +func TestMulticaCLILoadIssueMetadataMergesListedMetadata(t *testing.T) { + tmp := t.TempDir() + argsPath := filepath.Join(tmp, "args.txt") + bin := filepath.Join(tmp, "multica") + script := `#!/usr/bin/env sh +printf '%s\n' "$*" >> "$MULTICA_ARGS_PATH" +case "$*" in + *"issue metadata list issue-1"*) printf '[{"key":"mnemon.kind","value":"session_mailbox"},{"key":"listed","value":"yes"}]\n' ;; + *) printf '{}\n' ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + cli := MulticaCLI{ + Command: bin, + Env: append(os.Environ(), + "MULTICA_ARGS_PATH="+argsPath, + ), + } + loaded, count, err := cli.LoadIssueMetadata(context.Background(), MulticaIssue{ + ID: "issue-1", + Metadata: map[string]any{ + MulticaMetadataKind: "stale", + "base": "kept", + }, + }) + if err != nil { + t.Fatal(err) + } + if count != 2 || loaded.Metadata[MulticaMetadataKind] != MulticaHubKindSession || loaded.Metadata["base"] != "kept" || loaded.Metadata["listed"] != "yes" { + t.Fatalf("loaded metadata count=%d metadata=%+v", count, loaded.Metadata) + } + empty, count, err := cli.LoadIssueMetadata(context.Background(), MulticaIssue{Title: "missing id"}) + if err != nil || count != 0 || empty.Title != "missing id" { + t.Fatalf("empty metadata load = count:%d issue:%+v err:%v", count, empty, err) + } + args := mustReadDriverTestFile(t, argsPath) + if !strings.Contains(args, "issue metadata list issue-1 --output json") || strings.Contains(args, "missing id") { + t.Fatalf("metadata load args mismatch:\n%s", args) + } +} + func TestMulticaAssignmentFingerprintStable(t *testing.T) { left := MulticaAssignmentFingerprint(MulticaAssignmentFingerprintInput{ AssignmentID: " assignment-1 ", diff --git a/harness/internal/interaction/material.go b/harness/internal/interaction/material.go new file mode 100644 index 00000000..3b99f8cf --- /dev/null +++ b/harness/internal/interaction/material.go @@ -0,0 +1,39 @@ +package interaction + +import ( + "strings" + + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" +) + +type EventMaterial struct { + EventType string `json:"event_type"` + ExternalID string `json:"external_id"` + Payload map[string]any `json:"payload"` +} + +func BuildPayload(rule, narrative, refs map[string]any) map[string]any { + return eventmodel.BuildPayload(rule, narrative, refs) +} + +func PutString(values map[string]any, key, value string) { + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if key != "" && value != "" { + values[key] = value + } +} + +func CleanStrings(values []string) []string { + var out []string + seen := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + return out +} diff --git a/harness/internal/interaction/material_test.go b/harness/internal/interaction/material_test.go new file mode 100644 index 00000000..2223aa56 --- /dev/null +++ b/harness/internal/interaction/material_test.go @@ -0,0 +1,30 @@ +package interaction + +import "testing" + +func TestEventMaterialBuildPayloadSeparatesRuleNarrativeRefs(t *testing.T) { + rule := map[string]any{"ttl": "30m"} + narrative := map[string]any{"statement": "do the work"} + refs := map[string]any{"evidence_refs": []string{"ev-1"}} + material := EventMaterial{ + EventType: "teamwork_signal.write_candidate.observed", + ExternalID: "external-1", + Payload: BuildPayload(rule, narrative, refs), + } + if material.Payload["rule"].(map[string]any)["ttl"] != "30m" { + t.Fatalf("rule payload missing: %+v", material.Payload) + } + if material.Payload["narrative"].(map[string]any)["statement"] != "do the work" { + t.Fatalf("narrative payload missing: %+v", material.Payload) + } + if len(material.Payload["refs"].(map[string]any)["evidence_refs"].([]string)) != 1 { + t.Fatalf("refs payload missing: %+v", material.Payload) + } +} + +func TestCleanStringsTrimsAndDedupes(t *testing.T) { + got := CleanStrings([]string{" a ", "", "b", "a"}) + if len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Fatalf("clean strings = %+v, want [a b]", got) + } +} diff --git a/harness/internal/mnemond/presentation/render_test.go b/harness/internal/mnemond/presentation/render_test.go index 5678b85d..54a6639d 100644 --- a/harness/internal/mnemond/presentation/render_test.go +++ b/harness/internal/mnemond/presentation/render_test.go @@ -85,6 +85,130 @@ func TestTeamworkSignalPresentationCarriesContextRefs(t *testing.T) { } } +func TestMulticaTeamworkSignalPresentationKeepsHandoffInMnemonProtocol(t *testing.T) { + now := mustTime(t, "2026-06-24T10:00:00Z") + proj := view.View{Ref: "proj_multica_signal", Digest: "digest_multica_signal", Content: []view.ResourceContent{ + content("agent_profile", "project", []any{map[string]any{"id": "p1", "actor": "planner@team", "freshness": "fresh", "summary": "A profile"}}), + content("teamwork_signal", "project", []any{map[string]any{ + "id": "sig1", + "statement": "Validate the Multica hub flow", + }}), + }} + resp, err := (Renderer{Now: func() time.Time { return now }}).RenderPresentation(context.Background(), + Request{Principal: "planner@team", Host: "multica", Lifecycle: "remind", RenderIntent: IntentTeamworkEvents}, proj) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "For Multica-backed teamwork", + "Mnemon assignment events", + "runtime projects Multica assignment mailboxes", + } { + if !strings.Contains(resp.Body, want) { + t.Fatalf("Multica teamwork cue missing %q:\n%s", want, resp.Body) + } + } + + plain, err := (Renderer{Now: func() time.Time { return now }}).RenderPresentation(context.Background(), + Request{Principal: "planner@team", Host: "codex", Lifecycle: "remind", RenderIntent: IntentTeamworkEvents}, proj) + if err != nil { + t.Fatal(err) + } + if strings.Contains(plain.Body, "Multica-backed teamwork") { + t.Fatalf("non-Multica cue should stay host-neutral:\n%s", plain.Body) + } +} + +func TestMulticaTeamworkPresentationFiltersOtherSessions(t *testing.T) { + now := mustTime(t, "2026-06-24T10:00:00Z") + proj := view.View{Ref: "proj_multica_scope", Digest: "digest_multica_scope", Content: []view.ResourceContent{ + content("agent_profile", "project", []any{map[string]any{"id": "p1", "actor": "planner@team", "freshness": "fresh", "summary": "A profile"}}), + content("teamwork_signal", "project", []any{ + map[string]any{ + "id": "sig-current", + "statement": "Current Multica validation", + "session_id": "multica:session:root-current", + "root_issue_id": "root-current", + }, + }), + content("assignment", "project", []any{ + map[string]any{ + "id": "asg-stale", + "actor": "planner@team", + "assignee": "planner@team", + "scope": "old Multica validation", + "expected_work": "work on stale child issue", + "session_id": "multica:session:root-stale", + "root_issue_id": "root-stale", + "ttl": "30m", + "created_at": "2026-06-24T09:45:00Z", + }, + }), + }} + resp, err := (Renderer{Now: func() time.Time { return now }}).RenderPresentation(context.Background(), + Request{ + Principal: "planner@team", + Host: "multica", + Lifecycle: "remind", + RenderIntent: IntentTeamworkEvents, + SessionID: "multica:session:root-current", + InputDigest: "root-current", + }, proj) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(resp.Body, "Current Multica validation") { + t.Fatalf("current signal should remain visible:\n%s", resp.Body) + } + if strings.Contains(resp.Body, "stale child issue") || strings.Contains(resp.Body, "asg-stale") { + t.Fatalf("Multica render must not leak stale session assignment:\n%s", resp.Body) + } +} + +func TestMulticaAssignmentMailboxPresentationKeepsCurrentAssignment(t *testing.T) { + now := mustTime(t, "2026-06-24T10:00:00Z") + proj := view.View{Ref: "proj_multica_assignment_scope", Digest: "digest_multica_assignment_scope", Content: []view.ResourceContent{ + content("assignment", "project", []any{ + map[string]any{ + "id": "asg-current", + "actor": "planner@team", + "assignee": "worker@team", + "scope": "current mailbox work", + "expected_work": "inspect current mailbox", + "ttl": "30m", + "created_at": "2026-06-24T09:45:00Z", + }, + map[string]any{ + "id": "asg-stale", + "actor": "planner@team", + "assignee": "worker@team", + "scope": "stale mailbox work", + "expected_work": "inspect stale mailbox", + "ttl": "30m", + "created_at": "2026-06-24T09:45:00Z", + }, + }), + }} + resp, err := (Renderer{Now: func() time.Time { return now }}).RenderPresentation(context.Background(), + Request{ + Principal: "worker@team", + Host: "multica", + Lifecycle: "remind", + RenderIntent: IntentTeamworkEvents, + SessionID: "multica:session:root-current", + InputDigest: "asg-current", + }, proj) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(resp.Body, "inspect current mailbox") { + t.Fatalf("current assignment should remain visible:\n%s", resp.Body) + } + if strings.Contains(resp.Body, "inspect stale mailbox") || strings.Contains(resp.Body, "asg-stale") { + t.Fatalf("Multica assignment render must not leak stale assignment:\n%s", resp.Body) + } +} + func TestRenderPresentationScopeAndAssignmentState(t *testing.T) { now := mustTime(t, "2026-06-24T10:00:00Z") reqB := Request{Principal: "codex-b@project", Host: "codex", Lifecycle: "nudge", RenderIntent: IntentTeamworkEvents} diff --git a/harness/internal/mnemond/presentation/teamwork_presenter.go b/harness/internal/mnemond/presentation/teamwork_presenter.go index 7004096b..ed5f8821 100644 --- a/harness/internal/mnemond/presentation/teamwork_presenter.go +++ b/harness/internal/mnemond/presentation/teamwork_presenter.go @@ -44,6 +44,7 @@ func DeriveEventEnvelopes(req Request, proj view.View, now time.Time) []eventmod } derivedAt, expiresAt := derivedTimes(now) items := teamworkItems(proj) + items = scopeTeamworkItemsForRequest(req, items) var events []eventmodel.EventEnvelope appendDerived := func(eventType, subject string, causedBy []string, body string, suggested []string) { model := eventmodel.Event{ @@ -78,6 +79,9 @@ func DeriveEventEnvelopes(req Request, proj view.View, now time.Time) []eventmod id := teamworkItemID(signal) subject := "teamwork_signal/" + id body := fmt.Sprintf("Teamwork signal is open: %s. Assignment or self-assignment may be useful when you choose to act.", statement) + if multicaHubRender(req) { + body += " For Multica-backed teamwork, create handoffs as Mnemon assignment events; the runtime projects Multica assignment mailboxes from accepted assignments." + } if refs := signalContextRefs(signal); len(refs) > 0 { body = fmt.Sprintf("%s Context refs: %s.", body, strings.Join(refs, ", ")) } @@ -144,6 +148,68 @@ func DeriveEventEnvelopes(req Request, proj view.View, now time.Time) []eventmod return events } +func multicaHubRender(req Request) bool { + return strings.EqualFold(strings.TrimSpace(req.Host), "multica") +} + +func scopeTeamworkItemsForRequest(req Request, items map[string][]map[string]any) map[string][]map[string]any { + if !multicaHubRender(req) { + return items + } + sessionID := strings.TrimSpace(req.SessionID) + currentID := strings.TrimSpace(req.InputDigest) + if sessionID == "" && currentID == "" { + return items + } + out := map[string][]map[string]any{} + for kind, list := range items { + switch kind { + case "agent_profile": + out[kind] = append(out[kind], list...) + continue + } + for _, item := range list { + if multicaTeamworkItemInScope(kind, item, sessionID, currentID) { + out[kind] = append(out[kind], item) + } + } + } + return out +} + +func multicaTeamworkItemInScope(kind string, item map[string]any, sessionID, currentID string) bool { + for _, key := range []string{"session_id", "root_issue_id", "source_issue_id", "assignment_id", "assignment_ref", "id", "declaration_id"} { + value := itemString(item, key) + if value != "" && (value == sessionID || value == currentID) { + return true + } + } + if kind == "assignment" && teamworkItemID(item) == currentID { + return true + } + for _, key := range []string{"context_refs", "evidence_refs", "artifact_refs"} { + for _, ref := range itemStringList(item, key) { + if multicaRefMatchesScope(ref, sessionID, currentID) { + return true + } + } + } + return false +} + +func multicaRefMatchesScope(ref, sessionID, currentID string) bool { + ref = strings.TrimSpace(ref) + if ref == "" { + return false + } + for _, want := range []string{sessionID, currentID} { + if want != "" && strings.Contains(ref, want) { + return true + } + } + return false +} + func DeriveProfileEventEnvelopes(req Request, proj view.View) []eventmodel.EventEnvelope { events := DeriveEventEnvelopes(req, proj, time.Time{}) var profile []eventmodel.EventEnvelope diff --git a/harness/internal/mnemonhub/boundary_test.go b/harness/internal/mnemonhub/boundary_test.go index 2c07aa68..3b31dc28 100644 --- a/harness/internal/mnemonhub/boundary_test.go +++ b/harness/internal/mnemonhub/boundary_test.go @@ -23,10 +23,14 @@ func TestHubImportBoundaryExcludesLocalTrustDomain(t *testing.T) { t.Fatalf("go list -deps: %v\n%s", err, out) } forbidden := map[string]bool{ - "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access": true, - "github.com/mnemon-dev/mnemon/harness/internal/runtime": true, - "github.com/mnemon-dev/mnemon/harness/internal/app": true, - "github.com/mnemon-dev/mnemon/harness/internal/hostagent": true, + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access": true, + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/admission": true, + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation": true, + "github.com/mnemon-dev/mnemon/harness/internal/runtime": true, + "github.com/mnemon-dev/mnemon/harness/internal/app": true, + "github.com/mnemon-dev/mnemon/harness/internal/daemon": true, + "github.com/mnemon-dev/mnemon/harness/internal/driver": true, + "github.com/mnemon-dev/mnemon/harness/internal/hostagent": true, } for _, dep := range strings.Split(strings.TrimSpace(string(out)), "\n") { if forbidden[strings.TrimSpace(dep)] { diff --git a/harness/internal/mnemonhub/exchange/backend/github/publication_store.go b/harness/internal/mnemonhub/exchange/backend/github/publication_store.go index 5faf23e0..58b97c28 100644 --- a/harness/internal/mnemonhub/exchange/backend/github/publication_store.go +++ b/harness/internal/mnemonhub/exchange/backend/github/publication_store.go @@ -116,13 +116,13 @@ func (s *GitHubPublicationStore) ListEvents(ctx context.Context, branch string, if strings.TrimSpace(cursor) == head { return exchange.PublicationListResult{NextCursor: head}, nil } - paths, err := s.listEventPaths(ctx, branch, prefix) + paths, err := s.listChangedEventPaths(ctx, head, prefix, cursor) if err != nil { return exchange.PublicationListResult{}, err } events := make([]exchange.PublicationStoredEvent, 0, len(paths)) for _, path := range paths { - file, found, err := s.readFileWithSHA(ctx, branch, path) + file, found, err := s.readFileWithSHA(ctx, head, path) if err != nil { return exchange.PublicationListResult{}, err } @@ -268,6 +268,13 @@ type githubContentEntry struct { Path string `json:"path"` } +type githubCompareResponse struct { + Files []struct { + Filename string `json:"filename"` + Status string `json:"status"` + } `json:"files"` +} + type githubPutFileRequest struct { Message string `json:"message"` Content string `json:"content"` @@ -365,6 +372,53 @@ func (s *GitHubPublicationStore) branchHead(ctx context.Context, branch string) return strings.TrimSpace(out.Object.SHA), nil } +func (s *GitHubPublicationStore) listChangedEventPaths(ctx context.Context, head, prefix, cursor string) ([]string, error) { + cursor = strings.TrimSpace(cursor) + if cursor != "" { + paths, ok, err := s.compareEventPaths(ctx, cursor, head, prefix) + if err != nil { + return nil, err + } + if ok { + return paths, nil + } + } + return s.listEventPaths(ctx, head, prefix) +} + +func (s *GitHubPublicationStore) compareEventPaths(ctx context.Context, base, head, prefix string) ([]string, bool, error) { + if strings.TrimSpace(base) == "" || strings.TrimSpace(head) == "" || base == head { + return nil, true, nil + } + var out githubCompareResponse + status, err := s.do(ctx, http.MethodGet, "/repos/"+s.owner+"/"+s.repo+"/compare/"+escapeGitHubPath(base)+"..."+escapeGitHubPath(head), nil, nil, &out) + if err != nil { + if apiErr, ok := err.(*githubAPIError); ok && (apiErr.Status == http.StatusNotFound || apiErr.Status == http.StatusUnprocessableEntity) { + return nil, false, nil + } + return nil, false, err + } + if status != http.StatusOK { + return nil, false, fmt.Errorf("github compare returned status %d", status) + } + seen := map[string]bool{} + var paths []string + for _, file := range out.Files { + path := strings.TrimSpace(file.Filename) + if path == "" || !strings.HasPrefix(path, prefix) || seen[path] { + continue + } + switch strings.TrimSpace(file.Status) { + case "removed", "deleted": + continue + } + seen[path] = true + paths = append(paths, path) + } + sort.Strings(paths) + return paths, true, nil +} + func (s *GitHubPublicationStore) listEventPaths(ctx context.Context, branch, prefix string) ([]string, error) { entries, err := s.listDir(ctx, branch, prefix) if err != nil { diff --git a/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go b/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go index 98b86b90..675f8622 100644 --- a/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go +++ b/harness/internal/mnemonhub/exchange/backend/github/publication_store_test.go @@ -1,6 +1,7 @@ package githubbackend import ( + "bytes" "context" "encoding/base64" "encoding/json" @@ -79,8 +80,8 @@ func TestGitHubPublicationStoreWriteFileUpdatesWithSHA(t *testing.T) { func TestGitHubPublicationStoreListEventsUsesBranchHeadCursor(t *testing.T) { fake := newFakeGitHubPublicationAPI(t) fake.head = "head-2" - fake.files["mnemon/mnemond-b:"+exchange.PublicationEventRoot+"/replica-b/progress_digest/project/000000000001-dec-b.json"] = fakeGitHubFile{body: []byte(`{"id":"b"}`), sha: "sha-b"} - fake.files["mnemon/mnemond-b:"+exchange.PublicationEventRoot+"/replica-c/progress_digest/project/000000000001-dec-c.json"] = fakeGitHubFile{body: []byte(`{"id":"c"}`), sha: "sha-c"} + fake.files["head-2:"+exchange.PublicationEventRoot+"/replica-b/progress_digest/project/000000000001-dec-b.json"] = fakeGitHubFile{body: []byte(`{"id":"b"}`), sha: "sha-b"} + fake.files["head-2:"+exchange.PublicationEventRoot+"/replica-c/progress_digest/project/000000000001-dec-c.json"] = fakeGitHubFile{body: []byte(`{"id":"c"}`), sha: "sha-c"} store, err := NewPublicationStore(PublicationStoreConfig{ Repo: "mnemon-dev/mnemon-teamwork-example", BaseURL: fake.server.URL, @@ -97,6 +98,9 @@ func TestGitHubPublicationStoreListEventsUsesBranchHeadCursor(t *testing.T) { if len(list.Events) != 2 || list.NextCursor != "head-2" { t.Fatalf("list = %+v, want two events at head-2", list) } + if !stringSliceContains(fake.contentRefs, "head-2") { + t.Fatalf("list events must read contents at branch head sha, refs=%v", fake.contentRefs) + } again, err := store.ListEvents(context.Background(), "mnemon/mnemond-b", exchange.PublicationEventRoot, list.NextCursor) if err != nil { t.Fatalf("list after head cursor: %v", err) @@ -106,6 +110,39 @@ func TestGitHubPublicationStoreListEventsUsesBranchHeadCursor(t *testing.T) { } } +func TestGitHubPublicationStoreListEventsUsesCompareAfterCursor(t *testing.T) { + fake := newFakeGitHubPublicationAPI(t) + fake.head = "head-2" + oldPath := exchange.PublicationEventRoot + "/replica-b/progress_digest/project/000000000001-dec-b.json" + newPath := exchange.PublicationEventRoot + "/replica-b/progress_digest/project/000000000002-dec-b.json" + fake.files["head-1:"+oldPath] = fakeGitHubFile{body: []byte(`{"id":"old"}`), sha: "sha-old"} + fake.files["head-2:"+oldPath] = fakeGitHubFile{body: []byte(`{"id":"old"}`), sha: "sha-old"} + fake.files["head-2:"+newPath] = fakeGitHubFile{body: []byte(`{"id":"new"}`), sha: "sha-new"} + fake.files["head-2:README.md"] = fakeGitHubFile{body: []byte(`# ignored`), sha: "sha-readme"} + store, err := NewPublicationStore(PublicationStoreConfig{ + Repo: "mnemon-dev/mnemon-teamwork-example", + BaseURL: fake.server.URL, + HTTPClient: fake.server.Client(), + }) + if err != nil { + t.Fatal(err) + } + + list, err := store.ListEvents(context.Background(), "mnemon/mnemond-b", exchange.PublicationEventRoot, "head-1") + if err != nil { + t.Fatalf("list events after cursor: %v", err) + } + if len(list.Events) != 1 || list.Events[0].Path != newPath || string(list.Events[0].Body) != `{"id":"new"}` || list.NextCursor != "head-2" { + t.Fatalf("list after cursor = %+v, want only changed publication event", list) + } + if len(fake.compareRefs) != 1 || fake.compareRefs[0] != "head-1...head-2" { + t.Fatalf("compare refs = %v, want head-1...head-2", fake.compareRefs) + } + if fake.dirLists != 0 { + t.Fatalf("incremental list should not scan directories, dirLists=%d", fake.dirLists) + } +} + func TestGitHubPublicationStoreEnsureBranchCreatesMissingBranchFromMain(t *testing.T) { fake := newFakeGitHubPublicationAPI(t) fake.refs["main"] = "main-sha" @@ -220,6 +257,9 @@ func TestGitHubPublicationStoreLiveGated(t *testing.T) { if err != nil { t.Fatal(err) } + if err := store.EnsureBranch(context.Background(), branch, "main"); err != nil { + t.Fatalf("ensure live branch: %v", err) + } path := exchange.PublicationEventRoot + "/live-smoke/progress_digest/project/000000000001-live-smoke.json" body := []byte(`{"schema_version":1,"source":"mnemon live smoke"}`) res, err := store.PutEvent(context.Background(), branch, path, body) @@ -244,7 +284,10 @@ type fakeGitHubPublicationAPI struct { refs map[string]string missingRefs map[string]bool refReads map[string]int + contentRefs []string + compareRefs []string head string + dirLists int puts int creates int lastSHA string @@ -311,15 +354,34 @@ func (f *fakeGitHubPublicationAPI) handle(w http.ResponseWriter, r *http.Request f.refs[branch] = req.SHA f.creates++ writeJSON(w, http.StatusCreated, map[string]any{"ref": req.Ref, "object": map[string]any{"sha": req.SHA}}) + case r.Method == http.MethodGet && strings.HasPrefix(tail, "compare/"): + ref := strings.TrimPrefix(tail, "compare/") + f.compareRefs = append(f.compareRefs, ref) + base, head, ok := strings.Cut(ref, "...") + if !ok || base == "" || head == "" { + writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": "invalid compare"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"files": f.compareFiles(base, head)}) case strings.HasPrefix(tail, "contents/"): path := strings.TrimPrefix(tail, "contents/") branch := r.URL.Query().Get("ref") + f.contentRefs = append(f.contentRefs, branch) f.handleContents(w, r, branch, path) default: http.NotFound(w, r) } } +func stringSliceContains(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + func (f *fakeGitHubPublicationAPI) handleContents(w http.ResponseWriter, r *http.Request, branch, path string) { switch r.Method { case http.MethodGet: @@ -336,6 +398,7 @@ func (f *fakeGitHubPublicationAPI) handleContents(w http.ResponseWriter, r *http } entries := f.dirEntries(branch, path) if len(entries) > 0 { + f.dirLists++ writeJSON(w, http.StatusOK, entries) return } @@ -376,6 +439,26 @@ func (f *fakeGitHubPublicationAPI) handleContents(w http.ResponseWriter, r *http } } +func (f *fakeGitHubPublicationAPI) compareFiles(base, head string) []map[string]any { + prefix := head + ":" + var out []map[string]any + for key, file := range f.files { + if !strings.HasPrefix(key, prefix) { + continue + } + path := strings.TrimPrefix(key, prefix) + status := "added" + if previous, ok := f.files[base+":"+path]; ok { + if bytes.Equal(previous.body, file.body) { + continue + } + status = "modified" + } + out = append(out, map[string]any{"filename": path, "status": status}) + } + return out +} + func (f *fakeGitHubPublicationAPI) dirEntries(branch, dir string) []map[string]any { prefix := branch + ":" + strings.TrimSuffix(dir, "/") + "/" seen := map[string]map[string]any{} diff --git a/harness/internal/participant/doc.go b/harness/internal/participant/doc.go new file mode 100644 index 00000000..11af6891 --- /dev/null +++ b/harness/internal/participant/doc.go @@ -0,0 +1,6 @@ +// Package participant owns lightweight participant identity helpers. +// +// It treats principal strings as stable participant keys for product config, +// adapters, and command surfaces. It does not own runtime execution, external +// projection, or mnemond admission semantics. +package participant diff --git a/harness/internal/participant/participant.go b/harness/internal/participant/participant.go new file mode 100644 index 00000000..b35d1c80 --- /dev/null +++ b/harness/internal/participant/participant.go @@ -0,0 +1,57 @@ +package participant + +import ( + "fmt" + "strings" +) + +// NormalizePrincipal returns the stable product-config key for a participant. +func NormalizePrincipal(value string) string { + return strings.TrimSpace(value) +} + +func RequirePrincipal(label, value string) (string, error) { + principal := NormalizePrincipal(value) + if principal == "" { + if strings.TrimSpace(label) == "" { + label = "participant" + } + return "", fmt.Errorf("%s principal is required", strings.TrimSpace(label)) + } + return principal, nil +} + +func ValidateUniquePrincipals[T any](items []T, label string, principalOf func(T) string) error { + label = strings.TrimSpace(label) + if label == "" { + label = "participant" + } + seen := map[string]bool{} + for _, item := range items { + principal := NormalizePrincipal(principalOf(item)) + if principal == "" { + return fmt.Errorf("%s principal is required", label) + } + if seen[principal] { + return fmt.Errorf("duplicate %s principal %q", label, principal) + } + seen[principal] = true + } + return nil +} + +func UpsertByPrincipal[T any](items []T, next T, principalOf func(T) string, merge func(existing, next T) T) []T { + nextPrincipal := NormalizePrincipal(principalOf(next)) + for i := range items { + if NormalizePrincipal(principalOf(items[i])) != nextPrincipal { + continue + } + if merge == nil { + items[i] = next + } else { + items[i] = merge(items[i], next) + } + return items + } + return append(items, next) +} diff --git a/harness/internal/participant/participant_test.go b/harness/internal/participant/participant_test.go new file mode 100644 index 00000000..f2eb6b80 --- /dev/null +++ b/harness/internal/participant/participant_test.go @@ -0,0 +1,56 @@ +package participant + +import ( + "strings" + "testing" +) + +type record struct { + Principal string + DisplayName string +} + +func TestRequirePrincipalTrimsAndRejectsEmpty(t *testing.T) { + principal, err := RequirePrincipal("participant", " planner@team ") + if err != nil { + t.Fatal(err) + } + if principal != "planner@team" { + t.Fatalf("principal = %q", principal) + } + if _, err := RequirePrincipal("multica registry participant", " "); err == nil || !strings.Contains(err.Error(), "multica registry participant principal is required") { + t.Fatalf("expected principal required error, got %v", err) + } +} + +func TestValidateUniquePrincipals(t *testing.T) { + items := []record{{Principal: "planner@team"}, {Principal: " reviewer@team "}} + if err := ValidateUniquePrincipals(items, "participant", func(item record) string { return item.Principal }); err != nil { + t.Fatal(err) + } + items = append(items, record{Principal: "planner@team"}) + if err := ValidateUniquePrincipals(items, "participant", func(item record) string { return item.Principal }); err == nil || !strings.Contains(err.Error(), "duplicate participant principal") { + t.Fatalf("expected duplicate participant error, got %v", err) + } +} + +func TestUpsertByPrincipalCanReplaceOrMerge(t *testing.T) { + items := []record{{Principal: "planner@team", DisplayName: "planner"}} + items = UpsertByPrincipal(items, record{Principal: " planner@team ", DisplayName: "lead"}, func(item record) string { + return item.Principal + }, nil) + if len(items) != 1 || items[0].DisplayName != "lead" { + t.Fatalf("replace upsert mismatch: %+v", items) + } + items = UpsertByPrincipal(items, record{Principal: "reviewer@team", DisplayName: "reviewer"}, func(item record) string { + return item.Principal + }, func(existing, next record) record { + if existing.DisplayName == "" { + existing.DisplayName = next.DisplayName + } + return existing + }) + if len(items) != 2 { + t.Fatalf("append upsert mismatch: %+v", items) + } +} diff --git a/harness/internal/productconfig/config.go b/harness/internal/productconfig/config.go new file mode 100644 index 00000000..b13fedef --- /dev/null +++ b/harness/internal/productconfig/config.go @@ -0,0 +1,508 @@ +package productconfig + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + participantrole "github.com/mnemon-dev/mnemon/harness/internal/participant" + multicasurface "github.com/mnemon-dev/mnemon/harness/internal/surface/multica" +) + +const ( + SchemaVersion = 1 + DefaultRelPath = ".mnemon/harness/config.json" + + RuntimeKindCodex = "codex" + RuntimeKindConfigured = "configured-host-agent" + RuntimeModeAttached = "attached" + RuntimeModeManaged = "managed" + RuntimeModeManagedOrHost = "managed-or-attached" + + ConnectionMultica = "multica" + ConnectionGitHub = "github" + ConnectionMnemonhub = "mnemonhub" + + DefaultMulticaRuntimeBinary = multicasurface.MulticaRuntimeCommandName + + DriveManagedLocal = "managed-local" + + DuplicateActivationSuppress = "suppress" + DuplicateActivationAllow = "allow" +) + +type Config struct { + SchemaVersion int `json:"schema_version"` + Team Team `json:"team,omitempty"` + Participants []Participant `json:"participants,omitempty"` + Connections Connections `json:"connections,omitempty"` + Daemon Daemon `json:"daemon,omitempty"` + Sessions SessionDefaults `json:"sessions,omitempty"` +} + +type Team struct { + Name string `json:"name,omitempty"` + Profile string `json:"profile,omitempty"` +} + +type Participant struct { + Principal string `json:"principal"` + DisplayName string `json:"display_name,omitempty"` + Role string `json:"role,omitempty"` + HostRuntime HostRuntime `json:"host_runtime"` +} + +type HostRuntime struct { + Kind string `json:"kind"` + Mode string `json:"mode"` +} + +type Connections struct { + Multica MulticaConnection `json:"multica,omitempty"` + GitHub GitHubConnection `json:"github,omitempty"` + Mnemonhub MnemonhubConnection `json:"mnemonhub,omitempty"` +} + +type MulticaConnection struct { + Enabled bool `json:"enabled,omitempty"` + Workspace string `json:"workspace,omitempty"` + RuntimeBinary string `json:"runtime_binary,omitempty"` +} + +type GitHubConnection struct { + Enabled bool `json:"enabled,omitempty"` + Repo string `json:"repo,omitempty"` + Branch string `json:"branch,omitempty"` +} + +type MnemonhubConnection struct { + Enabled bool `json:"enabled,omitempty"` + Endpoint string `json:"endpoint,omitempty"` +} + +type Daemon struct { + InteractionWatchers []string `json:"interaction_watchers,omitempty"` + DriveSources []string `json:"drive_sources,omitempty"` + ProjectionSurfaces []string `json:"projection_surfaces,omitempty"` +} + +type SessionDefaults struct { + PrimaryActivationCarrier string `json:"primary_activation_carrier,omitempty"` + DuplicateActivationPolicy string `json:"duplicate_activation_policy,omitempty"` +} + +func DefaultPath(root, explicit string) string { + if strings.TrimSpace(explicit) != "" { + return filepath.Clean(explicit) + } + root = strings.TrimSpace(root) + if root == "" { + root = "." + } + return filepath.Join(root, DefaultRelPath) +} + +func Default() Config { + return Config{ + SchemaVersion: SchemaVersion, + Team: Team{ + Name: "default", + Profile: "teamwork", + }, + Sessions: SessionDefaults{ + DuplicateActivationPolicy: DuplicateActivationSuppress, + }, + } +} + +func Load(path string) (Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return Config{}, err + } + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + var cfg Config + if err := dec.Decode(&cfg); err != nil { + return Config{}, fmt.Errorf("parse product config: %w", err) + } + if err := cfg.Validate(); err != nil { + return Config{}, err + } + return cfg, nil +} + +func Save(path string, cfg Config) error { + if cfg.SchemaVersion == 0 { + cfg.SchemaVersion = SchemaVersion + } + if err := cfg.Validate(); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(path, data, 0o600) +} + +func (cfg Config) Validate() error { + if cfg.SchemaVersion != SchemaVersion { + return fmt.Errorf("product config schema_version %d unsupported (want %d)", cfg.SchemaVersion, SchemaVersion) + } + if err := participantrole.ValidateUniquePrincipals(cfg.Participants, "participant", func(participant Participant) string { + return participant.Principal + }); err != nil { + return err + } + for _, participant := range cfg.Participants { + principal := participantrole.NormalizePrincipal(participant.Principal) + if strings.TrimSpace(participant.HostRuntime.Kind) == "" { + return fmt.Errorf("participant %q host_runtime.kind is required", principal) + } + if strings.TrimSpace(participant.HostRuntime.Mode) == "" { + return fmt.Errorf("participant %q host_runtime.mode is required", principal) + } + } + if cfg.Connections.Multica.Enabled { + if strings.TrimSpace(cfg.Connections.Multica.Workspace) == "" { + return fmt.Errorf("multica connection workspace is required when enabled") + } + } + if cfg.Connections.GitHub.Enabled { + if strings.TrimSpace(cfg.Connections.GitHub.Repo) == "" { + return fmt.Errorf("github connection repo is required when enabled") + } + } + if cfg.Connections.Mnemonhub.Enabled { + if strings.TrimSpace(cfg.Connections.Mnemonhub.Endpoint) == "" { + return fmt.Errorf("mnemonhub connection endpoint is required when enabled") + } + } + if err := validatePrimaryActivationCarrier(cfg.Sessions.PrimaryActivationCarrier, cfg); err != nil { + return err + } + switch strings.TrimSpace(cfg.Sessions.DuplicateActivationPolicy) { + case "", DuplicateActivationSuppress, DuplicateActivationAllow: + default: + return fmt.Errorf("duplicate_activation_policy must be %q or %q", DuplicateActivationSuppress, DuplicateActivationAllow) + } + if err := validateDaemonCarriers("interaction watcher", cfg.Daemon.InteractionWatchers, cfg); err != nil { + return err + } + if err := validateProjectionSurfaces(cfg.Daemon.ProjectionSurfaces, cfg); err != nil { + return err + } + if err := validateDaemonDriveSources(cfg.Daemon.DriveSources); err != nil { + return err + } + return nil +} + +func validateDaemonCarriers(label string, values []string, cfg Config) error { + seen := map[string]bool{} + for _, value := range values { + carrier := strings.TrimSpace(value) + if carrier == "" { + return fmt.Errorf("%s cannot be empty", label) + } + if err := validateCarrier(label, carrier, cfg); err != nil { + return err + } + if seen[carrier] { + return fmt.Errorf("duplicate %s %q", label, carrier) + } + seen[carrier] = true + } + return nil +} + +func validatePrimaryActivationCarrier(carrier string, cfg Config) error { + carrier = strings.TrimSpace(carrier) + if carrier == "" { + return nil + } + if carrier == ConnectionMnemonhub { + return fmt.Errorf("primary activation carrier %q is unsupported; mnemonhub is a remote exchange backend", carrier) + } + return validateCarrier("primary activation carrier", carrier, cfg) +} + +func validateProjectionSurfaces(values []string, cfg Config) error { + seen := map[string]bool{} + for _, value := range values { + surface := strings.TrimSpace(value) + if surface == "" { + return fmt.Errorf("projection surface cannot be empty") + } + if surface == ConnectionMnemonhub { + return fmt.Errorf("projection surface %q is unsupported; mnemonhub is a remote exchange backend", surface) + } + if err := validateCarrier("projection surface", surface, cfg); err != nil { + return err + } + if seen[surface] { + return fmt.Errorf("duplicate projection surface %q", surface) + } + seen[surface] = true + } + return nil +} + +func validateDaemonDriveSources(values []string) error { + seen := map[string]bool{} + for _, source := range values { + source = strings.TrimSpace(source) + if source == "" { + return fmt.Errorf("drive source cannot be empty") + } + if source != DriveManagedLocal { + return fmt.Errorf("unsupported drive source %q", source) + } + if seen[source] { + return fmt.Errorf("duplicate drive source %q", source) + } + seen[source] = true + } + return nil +} + +func validateCarrier(label, carrier string, cfg Config) error { + carrier = strings.TrimSpace(carrier) + if carrier == "" { + return nil + } + switch carrier { + case ConnectionMultica: + if !cfg.Connections.Multica.Enabled { + return fmt.Errorf("%s %q is not enabled", label, carrier) + } + case ConnectionGitHub: + if !cfg.Connections.GitHub.Enabled { + return fmt.Errorf("%s %q is not enabled", label, carrier) + } + case ConnectionMnemonhub: + if !cfg.Connections.Mnemonhub.Enabled { + return fmt.Errorf("%s %q is not enabled", label, carrier) + } + default: + return fmt.Errorf("%s %q is unsupported", label, carrier) + } + return nil +} + +type legacyLocalConfig struct { + SchemaVersion int `json:"schema_version"` + Principal string `json:"principal"` + Loops []string `json:"loops"` +} + +type legacyRemotesDoc struct { + SchemaVersion int `json:"schema_version"` + Current string `json:"current,omitempty"` + Remotes []legacyRemoteEntry `json:"remotes"` +} + +type legacyRemoteEntry struct { + Backend string `json:"backend,omitempty"` + ID string `json:"id"` + Endpoint string `json:"endpoint,omitempty"` + Repo string `json:"repo,omitempty"` + Branch string `json:"branch,omitempty"` +} + +func FromLegacy(root string) (Config, bool, error) { + cfg := Default() + found := false + local, ok, err := loadLegacyLocal(root) + if err != nil { + return Config{}, false, err + } + if ok { + found = true + if principal := participantrole.NormalizePrincipal(local.Principal); principal != "" { + cfg.Participants = append(cfg.Participants, Participant{ + Principal: principal, + HostRuntime: HostRuntime{ + Kind: RuntimeKindConfigured, + Mode: RuntimeModeAttached, + }, + }) + } + } + remotes, ok, err := loadLegacyRemotes(root) + if err != nil { + return Config{}, false, err + } + if ok { + found = true + for _, remote := range remotes.Remotes { + backend := strings.TrimSpace(remote.Backend) + if backend == "" { + backend = ConnectionMnemonhub + } + switch backend { + case "http", ConnectionMnemonhub: + cfg.Connections.Mnemonhub.Enabled = true + if strings.TrimSpace(cfg.Connections.Mnemonhub.Endpoint) == "" { + cfg.Connections.Mnemonhub.Endpoint = strings.TrimSpace(remote.Endpoint) + } + case ConnectionGitHub: + cfg.Connections.GitHub.Enabled = true + if strings.TrimSpace(cfg.Connections.GitHub.Repo) == "" { + cfg.Connections.GitHub.Repo = strings.TrimSpace(remote.Repo) + cfg.Connections.GitHub.Branch = strings.TrimSpace(remote.Branch) + } + } + } + } + multicaRegistry, ok, err := loadLegacyMulticaRegistry(root) + if err != nil { + return Config{}, false, err + } + if ok { + found = true + if err := bridgeMulticaRegistry(&cfg, multicaRegistry); err != nil { + return Config{}, false, err + } + } + if cfg.Connections.Mnemonhub.Enabled { + cfg.Daemon.InteractionWatchers = appendCarrier(cfg.Daemon.InteractionWatchers, ConnectionMnemonhub) + } + if cfg.Connections.GitHub.Enabled { + cfg.Daemon.InteractionWatchers = appendCarrier(cfg.Daemon.InteractionWatchers, ConnectionGitHub) + cfg.Daemon.ProjectionSurfaces = appendCarrier(cfg.Daemon.ProjectionSurfaces, ConnectionGitHub) + } + if len(cfg.Participants) > 0 { + cfg.Daemon.DriveSources = appendCarrier(cfg.Daemon.DriveSources, DriveManagedLocal) + } + if err := cfg.Validate(); err != nil { + return Config{}, false, err + } + return cfg, found, nil +} + +func bridgeMulticaRegistry(cfg *Config, reg multicasurface.MulticaRegistry) error { + cfg.Connections.Multica.Enabled = true + if strings.TrimSpace(cfg.Connections.Multica.Workspace) == "" { + cfg.Connections.Multica.Workspace = strings.TrimSpace(reg.WorkspaceID) + } + if strings.TrimSpace(cfg.Connections.Multica.RuntimeBinary) == "" { + cfg.Connections.Multica.RuntimeBinary = DefaultMulticaRuntimeBinary + } + cfg.Daemon.InteractionWatchers = appendCarrier(cfg.Daemon.InteractionWatchers, ConnectionMultica) + cfg.Daemon.ProjectionSurfaces = appendCarrier(cfg.Daemon.ProjectionSurfaces, ConnectionMultica) + if strings.TrimSpace(cfg.Sessions.PrimaryActivationCarrier) == "" { + cfg.Sessions.PrimaryActivationCarrier = ConnectionMultica + } + for _, record := range reg.Participants { + participant, err := participantFromMulticaRegistryRecord(record) + if err != nil { + return err + } + cfg.Participants = mergeParticipant(cfg.Participants, participant) + } + if len(cfg.Participants) > 0 { + cfg.Daemon.DriveSources = appendCarrier(cfg.Daemon.DriveSources, DriveManagedLocal) + } + return nil +} + +func participantFromMulticaRegistryRecord(record multicasurface.MulticaParticipantRecord) (Participant, error) { + principal, err := participantrole.RequirePrincipal("multica registry participant", record.Principal) + if err != nil { + return Participant{}, err + } + displayName := strings.TrimSpace(record.AgentName) + if displayName == "" { + displayName = strings.TrimSpace(record.AgentID) + } + return Participant{ + Principal: principal, + DisplayName: displayName, + Role: strings.TrimSpace(record.Role), + HostRuntime: HostRuntime{ + Kind: RuntimeKindCodex, + Mode: RuntimeModeManagedOrHost, + }, + }, nil +} + +func mergeParticipant(participants []Participant, next Participant) []Participant { + return participantrole.UpsertByPrincipal(participants, next, func(participant Participant) string { + return participant.Principal + }, func(existing, next Participant) Participant { + if strings.TrimSpace(existing.DisplayName) == "" { + existing.DisplayName = next.DisplayName + } + if strings.TrimSpace(existing.Role) == "" { + existing.Role = next.Role + } + if strings.TrimSpace(existing.HostRuntime.Kind) == "" { + existing.HostRuntime.Kind = next.HostRuntime.Kind + } + if strings.TrimSpace(existing.HostRuntime.Mode) == "" { + existing.HostRuntime.Mode = next.HostRuntime.Mode + } + return existing + }) +} + +func loadLegacyLocal(root string) (legacyLocalConfig, bool, error) { + path := filepath.Join(root, ".mnemon", "harness", "local", "config.json") + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return legacyLocalConfig{}, false, nil + } + if err != nil { + return legacyLocalConfig{}, false, err + } + var cfg legacyLocalConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return legacyLocalConfig{}, false, fmt.Errorf("parse legacy local config: %w", err) + } + if cfg.SchemaVersion != 1 { + return legacyLocalConfig{}, false, fmt.Errorf("legacy local config schema_version %d unsupported (want 1)", cfg.SchemaVersion) + } + return cfg, true, nil +} + +func loadLegacyRemotes(root string) (legacyRemotesDoc, bool, error) { + path := filepath.Join(root, ".mnemon", "harness", "sync", "remotes.json") + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return legacyRemotesDoc{}, false, nil + } + if err != nil { + return legacyRemotesDoc{}, false, err + } + var doc legacyRemotesDoc + if err := json.Unmarshal(data, &doc); err != nil { + return legacyRemotesDoc{}, false, fmt.Errorf("parse legacy remote config: %w", err) + } + if doc.SchemaVersion != 1 { + return legacyRemotesDoc{}, false, fmt.Errorf("legacy remote config schema_version %d unsupported (want 1)", doc.SchemaVersion) + } + return doc, true, nil +} + +func loadLegacyMulticaRegistry(root string) (multicasurface.MulticaRegistry, bool, error) { + return multicasurface.LoadMulticaRegistry(multicasurface.MulticaRegistryPath(root, "")) +} + +func appendCarrier(values []string, value string) []string { + for _, existing := range values { + if existing == value { + return values + } + } + return append(values, value) +} diff --git a/harness/internal/productconfig/config_test.go b/harness/internal/productconfig/config_test.go new file mode 100644 index 00000000..7a365918 --- /dev/null +++ b/harness/internal/productconfig/config_test.go @@ -0,0 +1,289 @@ +package productconfig + +import ( + "os" + "path/filepath" + "strings" + "testing" + + multicasurface "github.com/mnemon-dev/mnemon/harness/internal/surface/multica" +) + +func TestConfigRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "config.json") + cfg := Default() + cfg.Participants = []Participant{{ + Principal: "planner@team", + HostRuntime: HostRuntime{ + Kind: RuntimeKindCodex, + Mode: RuntimeModeManagedOrHost, + }, + }} + cfg.Connections.Multica = MulticaConnection{ + Enabled: true, + Workspace: "teamwork-grivn", + RuntimeBinary: "mnemon-multica-runtime", + } + cfg.Daemon.InteractionWatchers = []string{ConnectionMultica} + cfg.Daemon.ProjectionSurfaces = []string{ConnectionMultica} + cfg.Daemon.DriveSources = []string{DriveManagedLocal} + cfg.Sessions.PrimaryActivationCarrier = ConnectionMultica + + if err := Save(path, cfg); err != nil { + t.Fatal(err) + } + got, err := Load(path) + if err != nil { + t.Fatal(err) + } + if got.SchemaVersion != SchemaVersion || got.Connections.Multica.RuntimeBinary != "mnemon-multica-runtime" || len(got.Participants) != 1 { + t.Fatalf("config mismatch: %+v", got) + } +} + +func TestConfigValidateRejectsCrossLayerLeaks(t *testing.T) { + cfg := Default() + cfg.SchemaVersion = SchemaVersion + cfg.Participants = []Participant{{ + Principal: "planner@team", + HostRuntime: HostRuntime{ + Kind: RuntimeKindCodex, + Mode: RuntimeModeManaged, + }, + }} + cfg.Daemon.ProjectionSurfaces = []string{ConnectionMultica} + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "not enabled") { + t.Fatalf("expected disabled projection surface error, got %v", err) + } + + cfg = Default() + cfg.Connections.Mnemonhub = MnemonhubConnection{Enabled: true, Endpoint: "https://hub.example"} + cfg.Daemon.ProjectionSurfaces = []string{ConnectionMnemonhub} + err = cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "remote exchange backend") { + t.Fatalf("expected mnemonhub projection surface error, got %v", err) + } + + cfg = Default() + cfg.Connections.Mnemonhub = MnemonhubConnection{Enabled: true, Endpoint: "https://hub.example"} + cfg.Sessions.PrimaryActivationCarrier = ConnectionMnemonhub + err = cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "primary activation carrier") || !strings.Contains(err.Error(), "remote exchange backend") { + t.Fatalf("expected mnemonhub primary activation carrier error, got %v", err) + } +} + +func TestConfigValidateRejectsDuplicateParticipants(t *testing.T) { + cfg := Default() + cfg.Participants = []Participant{ + {Principal: "planner@team", HostRuntime: HostRuntime{Kind: RuntimeKindCodex, Mode: RuntimeModeManaged}}, + {Principal: "planner@team", HostRuntime: HostRuntime{Kind: RuntimeKindCodex, Mode: RuntimeModeManaged}}, + } + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "duplicate participant") { + t.Fatalf("expected duplicate participant error, got %v", err) + } +} + +func TestConfigValidateRejectsDuplicateDaemonRoles(t *testing.T) { + base := Default() + base.Connections.Multica = MulticaConnection{Enabled: true, Workspace: "ws-multica"} + cases := []struct { + name string + edit func(*Config) + want string + }{ + { + name: "watcher", + edit: func(cfg *Config) { + cfg.Daemon.InteractionWatchers = []string{ConnectionMultica, " " + ConnectionMultica + " "} + }, + want: "duplicate interaction watcher", + }, + { + name: "empty watcher", + edit: func(cfg *Config) { + cfg.Daemon.InteractionWatchers = []string{" "} + }, + want: "interaction watcher cannot be empty", + }, + { + name: "surface", + edit: func(cfg *Config) { + cfg.Daemon.ProjectionSurfaces = []string{ConnectionMultica, " " + ConnectionMultica} + }, + want: "duplicate projection surface", + }, + { + name: "empty surface", + edit: func(cfg *Config) { + cfg.Daemon.ProjectionSurfaces = []string{""} + }, + want: "projection surface cannot be empty", + }, + { + name: "drive", + edit: func(cfg *Config) { + cfg.Daemon.DriveSources = []string{DriveManagedLocal, " " + DriveManagedLocal} + }, + want: "duplicate drive source", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := base + tc.edit(&cfg) + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("expected %q error, got %v", tc.want, err) + } + }) + } +} + +func TestFromLegacyBridgesLocalAndRemoteConfigs(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".mnemon", "harness", "local"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, ".mnemon", "harness", "sync"), 0o755); err != nil { + t.Fatal(err) + } + local := `{ + "schema_version": 1, + "mode": "local", + "endpoint": "http://127.0.0.1:8787", + "principal": "planner@team", + "loops": ["assignment"], + "binding_file": ".mnemon/harness/channel/bindings.json", + "store_path": ".mnemon/harness/local/governed.db" +}` + remotes := `{ + "schema_version": 1, + "current": "mesh", + "remotes": [ + {"id": "mesh", "backend": "github", "repo": "mnemon-dev/mnemon-teamwork-example", "branch": "mnemond-planner", "credential_ref": ".mnemon/harness/sync/credentials/self.token"}, + {"id": "hub", "backend": "http", "endpoint": "https://hub.example", "credential_ref": ".mnemon/harness/sync/credentials/hub.token"} + ] +}` + if err := os.WriteFile(filepath.Join(root, ".mnemon", "harness", "local", "config.json"), []byte(local), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".mnemon", "harness", "sync", "remotes.json"), []byte(remotes), 0o600); err != nil { + t.Fatal(err) + } + + cfg, found, err := FromLegacy(root) + if err != nil { + t.Fatal(err) + } + if !found { + t.Fatal("legacy configs should be found") + } + if len(cfg.Participants) != 1 || cfg.Participants[0].Principal != "planner@team" { + t.Fatalf("participant bridge mismatch: %+v", cfg.Participants) + } + if !cfg.Connections.GitHub.Enabled || cfg.Connections.GitHub.Repo != "mnemon-dev/mnemon-teamwork-example" { + t.Fatalf("github bridge mismatch: %+v", cfg.Connections.GitHub) + } + if !cfg.Connections.Mnemonhub.Enabled || cfg.Connections.Mnemonhub.Endpoint != "https://hub.example" { + t.Fatalf("mnemonhub bridge mismatch: %+v", cfg.Connections.Mnemonhub) + } + if !stringSliceContains(cfg.Daemon.InteractionWatchers, ConnectionMnemonhub) { + t.Fatalf("mnemonhub watcher missing: %+v", cfg.Daemon.InteractionWatchers) + } + if stringSliceContains(cfg.Daemon.ProjectionSurfaces, ConnectionMnemonhub) { + t.Fatalf("mnemonhub must not be bridged as a projection surface: %+v", cfg.Daemon.ProjectionSurfaces) + } + if len(cfg.Daemon.DriveSources) != 1 || cfg.Daemon.DriveSources[0] != DriveManagedLocal { + t.Fatalf("drive sources mismatch: %+v", cfg.Daemon.DriveSources) + } +} + +func TestFromLegacyBridgesMulticaRegistry(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".mnemon", "harness", "local"), 0o755); err != nil { + t.Fatal(err) + } + local := `{ + "schema_version": 1, + "principal": "planner@team", + "loops": ["assignment"] +}` + if err := os.WriteFile(filepath.Join(root, ".mnemon", "harness", "local", "config.json"), []byte(local), 0o600); err != nil { + t.Fatal(err) + } + reg := multicasurface.MulticaRegistry{ + SchemaVersion: 1, + WorkspaceID: "ws-multica", + RuntimeProfileID: "profile-1", + RuntimeID: "runtime-1", + Participants: []multicasurface.MulticaParticipantRecord{ + { + Principal: "planner@team", + AgentName: "mnemon-planner", + AgentID: "agent-planner", + Role: "planner", + }, + { + Principal: "reviewer@team", + AgentName: "mnemon-reviewer", + AgentID: "agent-reviewer", + Role: "reviewer", + }, + }, + } + if err := multicasurface.SaveMulticaRegistry(multicasurface.MulticaRegistryPath(root, ""), reg); err != nil { + t.Fatal(err) + } + + cfg, found, err := FromLegacy(root) + if err != nil { + t.Fatal(err) + } + if !found { + t.Fatal("legacy multica registry should be found") + } + if !cfg.Connections.Multica.Enabled || cfg.Connections.Multica.Workspace != "ws-multica" || cfg.Connections.Multica.RuntimeBinary != "mnemon-multica-runtime" { + t.Fatalf("multica bridge mismatch: %+v", cfg.Connections.Multica) + } + if cfg.Sessions.PrimaryActivationCarrier != ConnectionMultica { + t.Fatalf("primary carrier = %q", cfg.Sessions.PrimaryActivationCarrier) + } + if !stringSliceContains(cfg.Daemon.InteractionWatchers, ConnectionMultica) || !stringSliceContains(cfg.Daemon.ProjectionSurfaces, ConnectionMultica) { + t.Fatalf("daemon multica roles missing: %+v", cfg.Daemon) + } + planner := participantByPrincipal(cfg.Participants, "planner@team") + if planner.HostRuntime.Kind != RuntimeKindConfigured || planner.HostRuntime.Mode != RuntimeModeAttached { + t.Fatalf("planner host runtime should come from legacy local config: %+v", planner) + } + if planner.DisplayName != "mnemon-planner" || planner.Role != "planner" { + t.Fatalf("planner multica display fields not merged: %+v", planner) + } + reviewer := participantByPrincipal(cfg.Participants, "reviewer@team") + if reviewer.HostRuntime.Kind != RuntimeKindCodex || reviewer.HostRuntime.Mode != RuntimeModeManagedOrHost || reviewer.DisplayName != "mnemon-reviewer" { + t.Fatalf("reviewer participant mismatch: %+v", reviewer) + } + if !stringSliceContains(cfg.Daemon.DriveSources, DriveManagedLocal) { + t.Fatalf("drive source missing: %+v", cfg.Daemon.DriveSources) + } +} + +func participantByPrincipal(participants []Participant, principal string) Participant { + for _, participant := range participants { + if participant.Principal == principal { + return participant + } + } + return Participant{} +} + +func stringSliceContains(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/harness/internal/projection/comment.go b/harness/internal/projection/comment.go new file mode 100644 index 00000000..41e0baad --- /dev/null +++ b/harness/internal/projection/comment.go @@ -0,0 +1,68 @@ +package projection + +import ( + "strings" +) + +type CommentMaterial struct { + Title string + Body string + EventIDs []string + EventType string + SessionID string + AssignmentID string +} + +func FormatComment(material CommentMaterial) string { + title := strings.TrimSpace(material.Title) + body := strings.TrimSpace(material.Body) + var b strings.Builder + if title != "" { + b.WriteString("Mnemon update: ") + b.WriteString(title) + b.WriteString("\n\n") + } else { + b.WriteString("Mnemon update\n\n") + } + if body != "" { + b.WriteString(body) + b.WriteString("\n") + } + if ids := cleanStrings(material.EventIDs); len(ids) > 0 { + b.WriteString("\n") + for _, id := range ids { + writeMarker(&b, "event", id) + } + writeMarker(&b, "type", material.EventType) + writeMarker(&b, "session", material.SessionID) + writeMarker(&b, "assignment", material.AssignmentID) + } + return strings.TrimSpace(b.String()) +} + +func writeMarker(b *strings.Builder, key, value string) { + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if key == "" || value == "" { + return + } + b.WriteString("mnemon:") + b.WriteString(key) + b.WriteString("=") + b.WriteString(value) + b.WriteString("\n") +} + +func cleanStrings(values []string) []string { + out := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + return out +} diff --git a/harness/internal/projection/comment_test.go b/harness/internal/projection/comment_test.go new file mode 100644 index 00000000..c1588f04 --- /dev/null +++ b/harness/internal/projection/comment_test.go @@ -0,0 +1,33 @@ +package projection + +import ( + "strings" + "testing" +) + +func TestFormatCommentCarriesStableMarkers(t *testing.T) { + got := FormatComment(CommentMaterial{ + Title: "assignment finished", + Body: "Worker reported passing evidence.", + EventIDs: []string{"ev-1", "ev-1", "ev-2"}, + EventType: "progress_digest.accepted", + SessionID: "session-1", + AssignmentID: "asg-1", + }) + for _, want := range []string{ + "Mnemon update: assignment finished", + "Worker reported passing evidence.", + "mnemon:event=ev-1", + "mnemon:event=ev-2", + "mnemon:type=progress_digest.accepted", + "mnemon:session=session-1", + "mnemon:assignment=asg-1", + } { + if !strings.Contains(got, want) { + t.Fatalf("projection comment missing %q:\n%s", want, got) + } + } + if strings.Count(got, "mnemon:event=ev-1") != 1 { + t.Fatalf("projection comment should dedupe markers:\n%s", got) + } +} diff --git a/harness/internal/runtime/runtimehandler.go b/harness/internal/runtime/runtimehandler.go index 61b99f1f..ca85ab8b 100644 --- a/harness/internal/runtime/runtimehandler.go +++ b/harness/internal/runtime/runtimehandler.go @@ -2,13 +2,21 @@ package runtime import ( "encoding/json" + "fmt" "net/http" + "strings" + "time" "github.com/mnemon-dev/mnemon/harness/internal/contract" eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" ) +const ( + syncTickMaxAttempts = 4 + syncTickMaxCycles = 8 +) + // NewRuntimeHandler is the Local Mnemon HTTP channel endpoint over a Runtime. // It differs from the api-only access.NewHTTPHandler in two ways the Runtime makes possible: // @@ -42,7 +50,7 @@ func NewRuntimeHandler(rt *Runtime, auth access.Authenticator) http.Handler { rec := access.IngestReceipt{Seq: seq, Dup: dup} if !dup { rec.Ticked = true - if _, terr := rt.Tick(); terr != nil { + if terr := tickRuntimeWithRetry(rt); terr != nil { rec.ProcessingError = terr.Error() } } @@ -71,7 +79,7 @@ func NewRuntimeHandler(rt *Runtime, auth access.Authenticator) http.Handler { // already processed on its first ingest, so it is not re-ticked. if !dup { rec.Ticked = true - if _, terr := rt.Tick(); terr != nil { + if terr := tickRuntimeWithRetry(rt); terr != nil { rec.ProcessingError = terr.Error() } } @@ -165,3 +173,49 @@ func NewRuntimeHandler(rt *Runtime, auth access.Authenticator) http.Handler { }) return mux } + +func tickRuntimeWithRetry(rt *Runtime) error { + var lastErr error + for cycle := 0; cycle < syncTickMaxCycles; cycle++ { + for attempt := 0; attempt < syncTickMaxAttempts; attempt++ { + decisions, err := rt.Tick() + if err == nil { + if len(decisions) == 0 { + return nil + } + lastErr = nil + break + } + lastErr = err + if !retryableRuntimeTickError(err) || attempt == syncTickMaxAttempts-1 { + return err + } + time.Sleep(time.Duration(25*(1<= 0; i-- { + rec := l.records[i] + if multicaHubLedgerKey(rec.Kind, rec.Source) == want { + return rec, true, nil + } + } + return MulticaHubLedgerRecord{}, false, nil +} + +func (l *FileMulticaHubLedger) Record(record MulticaHubLedgerRecord) error { + if err := l.load(); err != nil { + return err + } + if strings.TrimSpace(l.path) == "" { + return fmt.Errorf("multica hub ledger path is required") + } + key := multicaHubLedgerKey(record.Kind, record.Source) + for _, existing := range l.records { + if multicaHubLedgerKey(existing.Kind, existing.Source) == key && existing.Target == record.Target { + return nil + } + } + if record.SchemaVersion == 0 { + record.SchemaVersion = 1 + } + now := time.Now().UTC().Format(time.RFC3339) + if strings.TrimSpace(record.CreatedAt) == "" { + record.CreatedAt = now + } + for i := len(l.records) - 1; i >= 0; i-- { + if multicaHubLedgerKey(l.records[i].Kind, l.records[i].Source) == key { + record.CreatedAt = l.records[i].CreatedAt + record.UpdatedAt = now + break + } + } + 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.upsertLoaded(record) + return nil +} + +func (l *FileMulticaHubLedger) load() error { + if l.loaded { + return l.loadErr + } + l.loaded = true + if strings.TrimSpace(l.path) == "" { + l.loadErr = fmt.Errorf("multica hub ledger path is required") + return l.loadErr + } + f, err := os.Open(l.path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + l.loadErr = err + return err + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var record MulticaHubLedgerRecord + if err := json.Unmarshal([]byte(line), &record); err != nil { + l.loadErr = err + return err + } + l.upsertLoaded(record) + } + if err := scanner.Err(); err != nil { + l.loadErr = err + return err + } + return nil +} + +func (l *FileMulticaHubLedger) upsertLoaded(record MulticaHubLedgerRecord) { + key := multicaHubLedgerKey(record.Kind, record.Source) + for i := range l.records { + if multicaHubLedgerKey(l.records[i].Kind, l.records[i].Source) == key { + l.records[i] = record + return + } + } + l.records = append(l.records, record) +} + +func AssignmentTargetCandidatesFromLedgerRecords(records []MulticaHubLedgerRecord) []AssignmentTargetCandidate { + out := []AssignmentTargetCandidate{} + for i := len(records) - 1; i >= 0; i-- { + record := records[i] + if record.Kind != MulticaHubKindAssignmentMailbox { + continue + } + out = append(out, AssignmentTargetCandidate{ + SessionID: record.Source.SessionID, + AssignmentID: record.Source.AssignmentID, + Principal: record.Source.Principal, + ChildIssueID: record.Target.ChildIssueID, + }) + } + return out +} + +func AssignmentTargetCandidateFromMailboxMetadata(childIssueID string, meta MulticaHubMetadata) (AssignmentTargetCandidate, bool) { + if !meta.IsAssignmentMailbox() { + return AssignmentTargetCandidate{}, false + } + return AssignmentTargetCandidate{ + SessionID: meta.SessionID, + AssignmentID: meta.AssignmentID, + Principal: meta.Principal, + ChildIssueID: childIssueID, + }, true +} + +func SelectAssignmentTarget(candidates []AssignmentTargetCandidate, sessionID, assignmentID, principal string) (string, bool) { + sessionID = strings.TrimSpace(sessionID) + assignmentID = strings.TrimSpace(assignmentID) + principal = strings.TrimSpace(principal) + var fallback string + matches := 0 + for _, candidate := range candidates { + childIssueID := strings.TrimSpace(candidate.ChildIssueID) + if strings.TrimSpace(candidate.SessionID) != sessionID || + strings.TrimSpace(candidate.AssignmentID) != assignmentID || + childIssueID == "" { + continue + } + matches++ + if principal != "" && strings.TrimSpace(candidate.Principal) == principal { + return childIssueID, true + } + if fallback == "" { + fallback = childIssueID + } + } + if principal == "" || matches == 1 { + return fallback, fallback != "" + } + return "", false +} + +func multicaHubLedgerKey(kind string, source MulticaHubLedgerSource) string { + return strings.Join([]string{ + strings.TrimSpace(kind), + strings.TrimSpace(source.SessionID), + strings.TrimSpace(source.CorrelationID), + strings.TrimSpace(source.EventID), + strings.TrimSpace(source.AssignmentID), + strings.TrimSpace(source.AssignmentFingerprint), + strings.TrimSpace(source.Principal), + strings.TrimSpace(source.ProjectionKind), + }, "\x00") +} + +func ParseMulticaHubMetadata(raw map[string]any) MulticaHubMetadata { + return multicaHubMetadataFromMap(NormalizeMulticaMetadata(raw)) +} + +func ParseMulticaHubListedMetadata(raw map[string]string) MulticaHubMetadata { + return multicaHubMetadataFromMap(NormalizeMulticaMetadata(raw)) +} + +func multicaHubMetadataFromMap(meta map[string]string) MulticaHubMetadata { + return MulticaHubMetadata{ + SchemaVersion: meta[MulticaMetadataSchemaVersion], + HubBackend: meta[MulticaMetadataHubBackend], + Kind: meta[MulticaMetadataKind], + SessionID: meta[MulticaMetadataSessionID], + CorrelationID: meta[MulticaMetadataCorrelationID], + EventID: meta[MulticaMetadataEventID], + EventType: meta[MulticaMetadataEventType], + EventPhase: meta[MulticaMetadataEventPhase], + AssignmentID: meta[MulticaMetadataAssignmentID], + AssignmentFingerprint: meta[MulticaMetadataAssignmentFingerprint], + Principal: meta[MulticaMetadataPrincipal], + SourceIssueID: meta[MulticaMetadataSourceIssueID], + RootIssueID: meta[MulticaMetadataRootIssueID], + ProjectionOwner: meta[MulticaMetadataProjectionOwner], + MulticaAgentID: meta[MulticaMetadataMulticaAgentID], + ProjectedAt: meta[MulticaMetadataProjectedAt], + EnvelopeDigest: meta[MulticaMetadataEnvelopeDigest], + } +} + +func (m MulticaHubMetadata) Map() map[string]string { + out := map[string]string{} + add := func(key, value string) { + value = strings.TrimSpace(value) + if value != "" { + out[key] = value + } + } + add(MulticaMetadataSchemaVersion, firstNonEmptyString(m.SchemaVersion, "1")) + add(MulticaMetadataHubBackend, firstNonEmptyString(m.HubBackend, MulticaHubBackend)) + add(MulticaMetadataKind, m.Kind) + add(MulticaMetadataSessionID, m.SessionID) + add(MulticaMetadataCorrelationID, m.CorrelationID) + add(MulticaMetadataEventID, m.EventID) + add(MulticaMetadataEventType, m.EventType) + add(MulticaMetadataEventPhase, m.EventPhase) + add(MulticaMetadataAssignmentID, m.AssignmentID) + add(MulticaMetadataAssignmentFingerprint, m.AssignmentFingerprint) + add(MulticaMetadataPrincipal, m.Principal) + add(MulticaMetadataSourceIssueID, m.SourceIssueID) + add(MulticaMetadataRootIssueID, m.RootIssueID) + add(MulticaMetadataProjectionOwner, m.ProjectionOwner) + add(MulticaMetadataMulticaAgentID, m.MulticaAgentID) + add(MulticaMetadataProjectedAt, m.ProjectedAt) + add(MulticaMetadataEnvelopeDigest, m.EnvelopeDigest) + return out +} + +func (m MulticaHubMetadata) IsAssignmentMailbox() bool { + if strings.TrimSpace(m.HubBackend) != "" && m.HubBackend != MulticaHubBackend { + return false + } + switch strings.TrimSpace(m.Kind) { + case MulticaHubKindAssignmentMailbox, MulticaHubKindAssignmentProjectionOld: + return strings.TrimSpace(m.AssignmentID) != "" || strings.TrimSpace(m.AssignmentFingerprint) != "" + default: + return false + } +} + +func AssignmentMailboxMatchesSource(meta MulticaHubMetadata, source MulticaHubLedgerSource) bool { + return meta.IsAssignmentMailbox() && + strings.TrimSpace(meta.SessionID) == strings.TrimSpace(source.SessionID) && + strings.TrimSpace(meta.AssignmentID) == strings.TrimSpace(source.AssignmentID) && + strings.TrimSpace(meta.AssignmentFingerprint) == strings.TrimSpace(source.AssignmentFingerprint) && + strings.TrimSpace(meta.Principal) == strings.TrimSpace(source.Principal) +} + +func RootSessionHubMetadata(meta MulticaHubMetadata, issueID string) MulticaHubMetadata { + issueID = strings.TrimSpace(issueID) + meta.HubBackend = MulticaHubBackend + meta.Kind = firstNonEmptyString(meta.Kind, MulticaHubKindSession) + meta.RootIssueID = firstNonEmptyString(meta.RootIssueID, issueID) + meta.SessionID = firstNonEmptyString(meta.SessionID, MulticaSessionID(meta.RootIssueID)) + if issueID != "" { + meta.CorrelationID = firstNonEmptyString(meta.CorrelationID, "multica:issue:"+issueID) + } + return meta +} + +func AssignmentMailboxHubMetadata(meta MulticaHubMetadata, issueID string) MulticaHubMetadata { + issueID = strings.TrimSpace(issueID) + meta.HubBackend = firstNonEmptyString(meta.HubBackend, MulticaHubBackend) + meta.Kind = firstNonEmptyString(meta.Kind, MulticaHubKindAssignmentMailbox) + meta.RootIssueID = firstNonEmptyString(meta.RootIssueID, meta.SourceIssueID, issueID) + meta.SessionID = firstNonEmptyString(meta.SessionID, MulticaSessionID(meta.RootIssueID)) + if issueID != "" { + meta.CorrelationID = firstNonEmptyString(meta.CorrelationID, "multica:issue:"+issueID) + } + return meta +} + +func AssignmentMailboxMarker(meta MulticaHubMetadata, issueID string) string { + if strings.TrimSpace(meta.EventID) != "" { + return strings.TrimSpace(meta.EventID) + } + if strings.TrimSpace(meta.AssignmentID) != "" { + return "multica-assignment-" + strings.TrimSpace(meta.AssignmentID) + } + issueID = strings.TrimSpace(issueID) + if issueID == "" { + return "" + } + return "multica-issue-" + issueID +} + +func RootSessionMetadataMap(material RootSessionMetadataMaterial) map[string]string { + meta := RootSessionHubMetadata(material.HubMetadata, material.SourceIssueID) + meta.EventID = firstNonEmptyString(material.EventID, meta.EventID) + meta.EventType = firstNonEmptyString(material.EventType, meta.EventType) + meta.EventPhase = firstNonEmptyString(material.EventPhase, meta.EventPhase) + meta.Principal = firstNonEmptyString(material.Principal, meta.Principal) + meta.SourceIssueID = firstNonEmptyString(material.SourceIssueID, meta.SourceIssueID) + meta.ProjectionOwner = firstNonEmptyString(material.ProjectionOwner, meta.ProjectionOwner) + if !material.ProjectedAt.IsZero() { + meta.ProjectedAt = firstNonEmptyString(meta.ProjectedAt, material.ProjectedAt.UTC().Format(time.RFC3339)) + } + return meta.Map() +} + +func AssignmentMailboxProjectionForRuntimeItem(material AssignmentMailboxProjectionMaterial) AssignmentMailboxProjection { + item := material.Item + fingerprint := MulticaAssignmentFingerprint(MulticaAssignmentFingerprintInput{ + AssignmentID: item.ID, + Assignee: item.Assignee, + Scope: item.Scope, + ExpectedWork: item.ExpectedWork, + ExpectedFeedback: item.ExpectedFeedback, + SignalRef: item.SignalRef, + ContextRefs: item.ContextRefs, + EvidenceRefs: item.EvidenceRefs, + CorrelationID: material.CorrelationID, + }) + source := MulticaHubLedgerSource{ + SessionID: material.SessionID, + CorrelationID: material.CorrelationID, + EventID: item.EventID, + AssignmentID: item.ID, + AssignmentFingerprint: fingerprint, + Principal: item.Assignee, + ProjectionKind: "assignment", + } + meta := MulticaHubMetadata{ + SchemaVersion: "1", + HubBackend: MulticaHubBackend, + Kind: MulticaHubKindAssignmentMailbox, + SessionID: material.SessionID, + CorrelationID: material.CorrelationID, + EventID: item.EventID, + EventType: "assignment.accepted", + EventPhase: "accepted", + AssignmentID: item.ID, + AssignmentFingerprint: fingerprint, + Principal: item.Assignee, + SourceIssueID: material.SourceIssueID, + RootIssueID: material.RootIssueID, + ProjectionOwner: material.ProjectionOwner, + MulticaAgentID: material.MulticaAgentID, + } + if !material.ProjectedAt.IsZero() { + meta.ProjectedAt = material.ProjectedAt.UTC().Format(time.RFC3339) + } + return AssignmentMailboxProjection{ + Fingerprint: fingerprint, + Source: source, + Metadata: meta, + } +} + +func AssignmentMailboxDispatchMetadata(full map[string]string) map[string]string { + keys := []string{ + MulticaMetadataSchemaVersion, + MulticaMetadataHubBackend, + MulticaMetadataKind, + MulticaMetadataSessionID, + MulticaMetadataCorrelationID, + MulticaMetadataEventID, + MulticaMetadataEventType, + MulticaMetadataEventPhase, + MulticaMetadataAssignmentID, + MulticaMetadataAssignmentFingerprint, + MulticaMetadataPrincipal, + MulticaMetadataSourceIssueID, + MulticaMetadataRootIssueID, + } + out := map[string]string{} + for _, key := range keys { + if value := strings.TrimSpace(full[key]); value != "" { + out[key] = value + } + } + return out +} + +func AssignmentMailboxSupplementalMetadata(full, dispatch map[string]string) map[string]string { + out := map[string]string{} + for key, value := range full { + if _, ok := dispatch[key]; ok { + continue + } + if value = strings.TrimSpace(value); value != "" { + out[key] = value + } + } + return out +} + +func NormalizeMulticaMetadata(raw any) map[string]string { + out := map[string]string{} + merge := func(key string, value any) { + key = strings.TrimSpace(key) + if key == "" { + return + } + if key == "metadata" { + for k, v := range NormalizeMulticaMetadata(value) { + out[k] = v + } + return + } + out[key] = multicaMetadataString(value) + } + switch v := raw.(type) { + case nil: + case map[string]string: + for key, value := range v { + merge(key, value) + } + case map[string]any: + if key := firstNonEmptyString(anyString(v["key"]), anyString(v["name"])); key != "" { + merge(key, firstPresent(v, "value", "val", "metadata_value")) + return out + } + for key, value := range v { + merge(key, value) + } + case []any: + for _, item := range v { + m, ok := item.(map[string]any) + if !ok { + continue + } + key := firstNonEmptyString(anyString(m["key"]), anyString(m["name"])) + if key == "" { + for k, value := range NormalizeMulticaMetadata(m) { + out[k] = value + } + continue + } + merge(key, firstPresent(m, "value", "val", "metadata_value")) + } + } + return out +} + +func MulticaAssignmentFingerprint(input MulticaAssignmentFingerprintInput) string { + input.AssignmentID = strings.TrimSpace(input.AssignmentID) + input.Assignee = strings.TrimSpace(input.Assignee) + input.Scope = strings.TrimSpace(input.Scope) + input.ExpectedWork = strings.TrimSpace(input.ExpectedWork) + input.ExpectedFeedback = strings.TrimSpace(input.ExpectedFeedback) + input.SignalRef = strings.TrimSpace(input.SignalRef) + input.CorrelationID = strings.TrimSpace(input.CorrelationID) + input.ContextRefs = cleanMulticaRefs(input.ContextRefs) + input.EvidenceRefs = cleanMulticaRefs(input.EvidenceRefs) + sort.Strings(input.ContextRefs) + sort.Strings(input.EvidenceRefs) + data, _ := json.Marshal(input) + sum := sha256.Sum256(data) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func MulticaSessionID(rootIssueID string) string { + rootIssueID = strings.TrimSpace(rootIssueID) + if rootIssueID == "" { + return "" + } + return "multica:session:" + rootIssueID +} + +func cleanMulticaRefs(values []string) []string { + var out []string + seen := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + return out +} + +func multicaMetadataString(value any) string { + switch v := value.(type) { + case nil: + return "" + case string: + return strings.TrimSpace(v) + case json.Number: + return strings.TrimSpace(v.String()) + case bool: + if v { + return "true" + } + return "false" + case float64: + return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", v), "0"), ".") + default: + data, err := json.Marshal(v) + if err != nil { + return strings.TrimSpace(fmt.Sprint(v)) + } + return strings.TrimSpace(string(data)) + } +} + +func anyString(value any) string { + if s, ok := value.(string); ok { + return strings.TrimSpace(s) + } + return "" +} + +func firstPresent(m map[string]any, keys ...string) any { + for _, key := range keys { + if value, ok := m[key]; ok { + return value + } + } + return nil +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/harness/internal/surface/multica/hub_test.go b/harness/internal/surface/multica/hub_test.go new file mode 100644 index 00000000..8d7a322f --- /dev/null +++ b/harness/internal/surface/multica/hub_test.go @@ -0,0 +1,392 @@ +package multica + +import ( + "path/filepath" + "strings" + "testing" + "time" +) + +func TestHubMetadataDetectsAssignmentMailbox(t *testing.T) { + meta := ParseMulticaHubMetadata(map[string]any{ + "metadata": []any{ + map[string]any{"key": MulticaMetadataHubBackend, "value": MulticaHubBackend}, + map[string]any{"key": MulticaMetadataKind, "value": MulticaHubKindAssignmentMailbox}, + map[string]any{"key": MulticaMetadataAssignmentID, "value": "assignment-1"}, + map[string]any{"key": MulticaMetadataRootIssueID, "value": "root-1"}, + map[string]any{"key": MulticaMetadataPrincipal, "value": "worker@team"}, + }, + }) + if !meta.IsAssignmentMailbox() { + t.Fatalf("metadata was not detected as assignment mailbox: %+v", meta) + } + if meta.RootIssueID != "root-1" || meta.Principal != "worker@team" { + t.Fatalf("metadata mismatch: %+v", meta) + } + back := meta.Map() + if back[MulticaMetadataHubBackend] != MulticaHubBackend { + t.Fatalf("metadata map missing backend: %+v", back) + } +} + +func TestParseMulticaHubListedMetadata(t *testing.T) { + meta := ParseMulticaHubListedMetadata(map[string]string{ + MulticaMetadataHubBackend: MulticaHubBackend, + MulticaMetadataKind: MulticaHubKindAssignmentMailbox, + MulticaMetadataSessionID: "session-1", + MulticaMetadataAssignmentID: "asg-1", + MulticaMetadataAssignmentFingerprint: "sha256:abc", + MulticaMetadataPrincipal: "worker@team", + }) + if !meta.IsAssignmentMailbox() || + meta.SessionID != "session-1" || + meta.AssignmentID != "asg-1" || + meta.AssignmentFingerprint != "sha256:abc" || + meta.Principal != "worker@team" { + t.Fatalf("listed metadata mismatch: %+v", meta) + } +} + +func TestAssignmentFingerprintStable(t *testing.T) { + left := MulticaAssignmentFingerprint(MulticaAssignmentFingerprintInput{ + AssignmentID: " assignment-1 ", + Assignee: "worker@team", + Scope: "docs", + ExpectedWork: "write the API notes", + ExpectedFeedback: "summary", + ContextRefs: []string{"ctx-b", "ctx-a", "ctx-a"}, + EvidenceRefs: []string{" ev-1 "}, + CorrelationID: "session-1", + }) + right := MulticaAssignmentFingerprint(MulticaAssignmentFingerprintInput{ + AssignmentID: "assignment-1", + Assignee: " worker@team ", + Scope: "docs", + ExpectedWork: "write the API notes", + ExpectedFeedback: "summary", + ContextRefs: []string{"ctx-a", "ctx-b"}, + EvidenceRefs: []string{"ev-1"}, + CorrelationID: "session-1", + }) + if left != right { + t.Fatalf("fingerprint should be stable across whitespace/order/dedup:\nleft=%s\nright=%s", left, right) + } + if !strings.HasPrefix(left, "sha256:") { + t.Fatalf("fingerprint should carry algorithm prefix: %q", left) + } +} + +func TestRootSessionHubMetadataDefaults(t *testing.T) { + meta := RootSessionHubMetadata(MulticaHubMetadata{Principal: "planner@team"}, "root-1") + if meta.HubBackend != MulticaHubBackend || + meta.Kind != MulticaHubKindSession || + meta.RootIssueID != "root-1" || + meta.SessionID != MulticaSessionID("root-1") || + meta.CorrelationID != "multica:issue:root-1" || + meta.Principal != "planner@team" { + t.Fatalf("root session metadata mismatch: %+v", meta) + } +} + +func TestAssignmentMailboxHubMetadataDefaults(t *testing.T) { + meta := AssignmentMailboxHubMetadata(MulticaHubMetadata{ + SourceIssueID: "root-1", + AssignmentID: "asg-1", + Principal: "worker@team", + }, "child-1") + if meta.HubBackend != MulticaHubBackend || + meta.Kind != MulticaHubKindAssignmentMailbox || + meta.RootIssueID != "root-1" || + meta.SessionID != MulticaSessionID("root-1") || + meta.CorrelationID != "multica:issue:child-1" || + meta.AssignmentID != "asg-1" || + meta.Principal != "worker@team" { + t.Fatalf("assignment mailbox metadata mismatch: %+v", meta) + } +} + +func TestAssignmentMailboxMatchesSource(t *testing.T) { + source := MulticaHubLedgerSource{ + SessionID: "session-1", + AssignmentID: "asg-1", + AssignmentFingerprint: "sha256:abc", + Principal: "worker@team", + } + meta := MulticaHubMetadata{ + Kind: MulticaHubKindAssignmentMailbox, + SessionID: " session-1 ", + AssignmentID: " asg-1 ", + AssignmentFingerprint: " sha256:abc ", + Principal: " worker@team ", + } + if !AssignmentMailboxMatchesSource(meta, source) { + t.Fatalf("metadata should match source: meta=%+v source=%+v", meta, source) + } + meta.Principal = "other@team" + if AssignmentMailboxMatchesSource(meta, source) { + t.Fatalf("principal mismatch should not match: meta=%+v source=%+v", meta, source) + } + meta.Principal = "worker@team" + meta.Kind = MulticaHubKindSession + if AssignmentMailboxMatchesSource(meta, source) { + t.Fatal("non-assignment mailbox metadata should not match") + } +} + +func TestAssignmentMailboxMarkerPrefersEventThenAssignmentThenIssue(t *testing.T) { + if got := AssignmentMailboxMarker(MulticaHubMetadata{EventID: "event-1", AssignmentID: "asg-1"}, "child-1"); got != "event-1" { + t.Fatalf("event marker = %q", got) + } + if got := AssignmentMailboxMarker(MulticaHubMetadata{AssignmentID: "asg-1"}, "child-1"); got != "multica-assignment-asg-1" { + t.Fatalf("assignment marker = %q", got) + } + if got := AssignmentMailboxMarker(MulticaHubMetadata{}, "child-1"); got != "multica-issue-child-1" { + t.Fatalf("issue marker = %q", got) + } +} + +func TestRootSessionMetadataMap(t *testing.T) { + now := time.Unix(100, 0).UTC() + meta := RootSessionMetadataMap(RootSessionMetadataMaterial{ + HubMetadata: MulticaHubMetadata{Principal: "planner@team"}, + EventID: "multica-task-task-1", + EventType: "teamwork_signal.write_candidate.observed", + EventPhase: "observed", + SourceIssueID: "root-1", + ProjectionOwner: "planner@team", + ProjectedAt: now, + }) + for key, want := range map[string]string{ + MulticaMetadataHubBackend: MulticaHubBackend, + MulticaMetadataKind: MulticaHubKindSession, + MulticaMetadataRootIssueID: "root-1", + MulticaMetadataSessionID: MulticaSessionID("root-1"), + MulticaMetadataCorrelationID: "multica:issue:root-1", + MulticaMetadataEventID: "multica-task-task-1", + MulticaMetadataEventType: "teamwork_signal.write_candidate.observed", + MulticaMetadataEventPhase: "observed", + MulticaMetadataPrincipal: "planner@team", + MulticaMetadataSourceIssueID: "root-1", + MulticaMetadataProjectionOwner: "planner@team", + MulticaMetadataProjectedAt: now.Format(time.RFC3339), + } { + if got := meta[key]; got != want { + t.Fatalf("metadata[%s] = %q, want %q: %+v", key, got, want, meta) + } + } +} + +func TestAssignmentMailboxProjectionForRuntimeItem(t *testing.T) { + now := time.Unix(200, 0).UTC() + projection := AssignmentMailboxProjectionForRuntimeItem(AssignmentMailboxProjectionMaterial{ + Item: RuntimeAssignmentItem{ + ID: " assignment-1 ", + EventID: "event-1", + Assignee: "worker@team", + Scope: "release/readiness", + ExpectedWork: "Check release notes.", + ExpectedFeedback: "result or blocker", + SignalRef: "sig-1", + ContextRefs: []string{"ctx-b", "ctx-a", "ctx-a"}, + EvidenceRefs: []string{" ev-1 "}, + }, + SessionID: "multica:session:root-1", + CorrelationID: "multica:issue:root-1", + RootIssueID: "root-1", + SourceIssueID: "root-1", + ProjectionOwner: "planner@team", + MulticaAgentID: "agent-worker", + ProjectedAt: now, + }) + if projection.Fingerprint == "" || !strings.HasPrefix(projection.Fingerprint, "sha256:") { + t.Fatalf("fingerprint missing algorithm prefix: %+v", projection) + } + if projection.Source.SessionID != "multica:session:root-1" || + projection.Source.CorrelationID != "multica:issue:root-1" || + projection.Source.EventID != "event-1" || + projection.Source.AssignmentID != " assignment-1 " || + projection.Source.AssignmentFingerprint != projection.Fingerprint || + projection.Source.Principal != "worker@team" || + projection.Source.ProjectionKind != "assignment" { + t.Fatalf("source mismatch: %+v", projection.Source) + } + meta := projection.Metadata + if meta.SchemaVersion != "1" || + meta.HubBackend != MulticaHubBackend || + meta.Kind != MulticaHubKindAssignmentMailbox || + meta.SessionID != "multica:session:root-1" || + meta.CorrelationID != "multica:issue:root-1" || + meta.EventID != "event-1" || + meta.EventType != "assignment.accepted" || + meta.EventPhase != "accepted" || + meta.AssignmentID != " assignment-1 " || + meta.AssignmentFingerprint != projection.Fingerprint || + meta.Principal != "worker@team" || + meta.SourceIssueID != "root-1" || + meta.RootIssueID != "root-1" || + meta.ProjectionOwner != "planner@team" || + meta.MulticaAgentID != "agent-worker" || + meta.ProjectedAt != now.Format(time.RFC3339) { + t.Fatalf("metadata mismatch: %+v", meta) + } +} + +func TestAssignmentMailboxMetadataGroupsDispatchBeforeSupplemental(t *testing.T) { + full := map[string]string{ + MulticaMetadataSchemaVersion: " 1 ", + MulticaMetadataHubBackend: MulticaHubBackend, + MulticaMetadataKind: MulticaHubKindAssignmentMailbox, + MulticaMetadataSessionID: "session-1", + MulticaMetadataCorrelationID: "correlation-1", + MulticaMetadataEventID: "event-1", + MulticaMetadataEventType: "assignment.accepted", + MulticaMetadataEventPhase: "accepted", + MulticaMetadataAssignmentID: "asg-1", + MulticaMetadataAssignmentFingerprint: "sha256:abc", + MulticaMetadataPrincipal: "worker@team", + MulticaMetadataSourceIssueID: "root-1", + MulticaMetadataRootIssueID: "root-1", + MulticaMetadataProjectionOwner: " planner@team ", + MulticaMetadataProjectedAt: "2026-06-29T09:00:00Z", + MulticaMetadataEnvelopeDigest: "", + } + dispatch := AssignmentMailboxDispatchMetadata(full) + for _, key := range []string{ + MulticaMetadataSchemaVersion, + MulticaMetadataHubBackend, + MulticaMetadataKind, + MulticaMetadataSessionID, + MulticaMetadataCorrelationID, + MulticaMetadataEventID, + MulticaMetadataEventType, + MulticaMetadataEventPhase, + MulticaMetadataAssignmentID, + MulticaMetadataAssignmentFingerprint, + MulticaMetadataPrincipal, + MulticaMetadataSourceIssueID, + MulticaMetadataRootIssueID, + } { + if strings.TrimSpace(full[key]) == "" { + continue + } + if got := dispatch[key]; got != strings.TrimSpace(full[key]) { + t.Fatalf("dispatch metadata[%s] = %q, want %q: %+v", key, got, strings.TrimSpace(full[key]), dispatch) + } + } + if _, ok := dispatch[MulticaMetadataProjectionOwner]; ok { + t.Fatalf("projection owner should be supplemental, not dispatch metadata: %+v", dispatch) + } + + supplemental := AssignmentMailboxSupplementalMetadata(full, dispatch) + if supplemental[MulticaMetadataProjectionOwner] != "planner@team" { + t.Fatalf("supplemental metadata should trim projection owner: %+v", supplemental) + } + if _, ok := supplemental[MulticaMetadataAssignmentID]; ok { + t.Fatalf("supplemental metadata must not duplicate dispatch keys: %+v", supplemental) + } + if _, ok := supplemental[MulticaMetadataEnvelopeDigest]; ok { + t.Fatalf("empty supplemental metadata must be omitted: %+v", supplemental) + } +} + +func TestFileHubLedgerDedupesRecords(t *testing.T) { + path := filepath.Join(t.TempDir(), "hub-ledger.jsonl") + ledger := NewFileMulticaHubLedger(path) + source := MulticaHubLedgerSource{ + SessionID: "session-1", + AssignmentID: "assignment-1", + AssignmentFingerprint: "sha256:abc", + Principal: "worker@team", + ProjectionKind: "assignment", + } + record := MulticaHubLedgerRecord{ + Kind: MulticaHubKindAssignmentMailbox, + Source: source, + Target: MulticaHubLedgerTarget{ + RootIssueID: "root-1", + ChildIssueID: "child-1", + Status: "created", + }, + } + if err := ledger.Record(record); err != nil { + t.Fatal(err) + } + if err := ledger.Record(record); err != nil { + t.Fatal(err) + } + records, err := NewFileMulticaHubLedger(path).Records() + if err != nil { + t.Fatal(err) + } + if len(records) != 1 { + t.Fatalf("ledger should keep one record for the same source key, got %d: %+v", len(records), records) + } + found, ok, err := NewFileMulticaHubLedger(path).Find(MulticaHubKindAssignmentMailbox, source) + if err != nil { + t.Fatal(err) + } + if !ok || found.Target.ChildIssueID != "child-1" { + t.Fatalf("ledger find mismatch: ok=%v record=%+v", ok, found) + } +} + +func TestSelectAssignmentTargetDisambiguatesDuplicateAssignments(t *testing.T) { + candidates := []AssignmentTargetCandidate{ + {SessionID: "session-1", AssignmentID: "asg-1", Principal: "implementer@team", ChildIssueID: "child-impl"}, + {SessionID: "session-1", AssignmentID: "asg-1", Principal: "researcher@team", ChildIssueID: "child-research"}, + } + if got, ok := SelectAssignmentTarget(candidates, "session-1", "asg-1", "researcher@team"); !ok || got != "child-research" { + t.Fatalf("principal target = %q ok=%v", got, ok) + } + if got, ok := SelectAssignmentTarget(candidates, "session-1", "asg-1", "writer@team"); ok || got != "" { + t.Fatalf("ambiguous unmatched principal should not fallback, got %q ok=%v", got, ok) + } + if got, ok := SelectAssignmentTarget(candidates[:1], "session-1", "asg-1", "writer@team"); !ok || got != "child-impl" { + t.Fatalf("single candidate fallback = %q ok=%v", got, ok) + } + if got, ok := SelectAssignmentTarget(candidates, "session-1", "asg-1", ""); !ok || got != "child-impl" { + t.Fatalf("empty principal fallback = %q ok=%v", got, ok) + } +} + +func TestAssignmentTargetCandidatesFromLedgerRecordsPreferNewestRecordOrder(t *testing.T) { + records := []MulticaHubLedgerRecord{ + { + Kind: MulticaHubKindAssignmentMailbox, + Source: MulticaHubLedgerSource{ + SessionID: "session-1", + AssignmentID: "asg-1", + Principal: "worker@team", + }, + Target: MulticaHubLedgerTarget{ChildIssueID: "child-old"}, + }, + { + Kind: MulticaHubKindAssignmentMailbox, + Source: MulticaHubLedgerSource{ + SessionID: "session-1", + AssignmentID: "asg-1", + Principal: "worker@team", + }, + Target: MulticaHubLedgerTarget{ChildIssueID: "child-new"}, + }, + } + if got, ok := SelectAssignmentTarget(AssignmentTargetCandidatesFromLedgerRecords(records), "session-1", "asg-1", "worker@team"); !ok || got != "child-new" { + t.Fatalf("ledger target = %q ok=%v", got, ok) + } +} + +func TestAssignmentTargetCandidateFromMailboxMetadata(t *testing.T) { + candidate, ok := AssignmentTargetCandidateFromMailboxMetadata("child-1", MulticaHubMetadata{ + HubBackend: MulticaHubBackend, + Kind: MulticaHubKindAssignmentMailbox, + SessionID: "session-1", + AssignmentID: "asg-1", + Principal: "worker@team", + }) + if !ok || candidate.ChildIssueID != "child-1" || candidate.SessionID != "session-1" || candidate.AssignmentID != "asg-1" || candidate.Principal != "worker@team" { + t.Fatalf("candidate mismatch: ok=%v candidate=%+v", ok, candidate) + } + if _, ok := AssignmentTargetCandidateFromMailboxMetadata("child-2", MulticaHubMetadata{Kind: MulticaHubKindSession}); ok { + t.Fatal("non-assignment mailbox metadata should not produce a candidate") + } +} diff --git a/harness/internal/surface/multica/issue_identity.go b/harness/internal/surface/multica/issue_identity.go new file mode 100644 index 00000000..225cb074 --- /dev/null +++ b/harness/internal/surface/multica/issue_identity.go @@ -0,0 +1,38 @@ +package multica + +import ( + "regexp" + "strings" +) + +var ( + assignedIssuePattern = regexp.MustCompile(`(?i)(?:assigned\s+issue\s+id\s+is|issue[_\s-]*id)\s*[::]\s*([A-Za-z0-9][A-Za-z0-9._:-]*)`) + multicaIssueMentionPattern = regexp.MustCompile(`(?i)mention://issue/([A-Za-z0-9][A-Za-z0-9._:-]*)`) + multicaTaggedIssuePattern = regexp.MustCompile(`(?i)(?:^|[\s([{"'])[@#]([A-Z][A-Z0-9]+-\d+)(?:$|[\s\])}.,;:'"])`) +) + +// ExtractIssueIdentity returns the best issue lookup key from Multica runtime input. +// +// The returned value may be a stable mention target from mention://issue/, a +// daemon-compatible legacy issue id field, or a visible issue identifier tag +// such as @TEA-123. Callers must resolve identifier tags through Multica before +// building rule-facing Mnemon event material. +func ExtractIssueIdentity(input string) string { + match := multicaIssueMentionPattern.FindStringSubmatch(input) + if len(match) >= 2 { + return cleanIssueIdentity(match[1]) + } + match = assignedIssuePattern.FindStringSubmatch(input) + if len(match) >= 2 { + return cleanIssueIdentity(match[1]) + } + match = multicaTaggedIssuePattern.FindStringSubmatch(input) + if len(match) >= 2 { + return cleanIssueIdentity(match[1]) + } + return "" +} + +func cleanIssueIdentity(value string) string { + return strings.Trim(value, " \t\r\n.,;)") +} diff --git a/harness/internal/surface/multica/issue_identity_test.go b/harness/internal/surface/multica/issue_identity_test.go new file mode 100644 index 00000000..85fad2aa --- /dev/null +++ b/harness/internal/surface/multica/issue_identity_test.go @@ -0,0 +1,26 @@ +package multica + +import "testing" + +func TestExtractIssueIdentityAcceptsMentionsLegacyFieldsAndTags(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {name: "explicit assigned issue", input: "Your assigned issue ID is: iss-7\nPlease work on it.", want: "iss-7"}, + {name: "legacy issue id", input: "issue_id: iss-8", want: "iss-8"}, + {name: "multica mention", input: "Open [TEA-51](mention://issue/issue-51) for the current task.", want: "issue-51"}, + {name: "mention beats legacy text", input: "Your assigned issue ID is: stale\nOpen [TEA-52](mention://issue/issue-52).", want: "issue-52"}, + {name: "at identifier tag", input: "Please handle @TEA-49 next.", want: "TEA-49"}, + {name: "hash identifier tag", input: "Review #TEA-50.", want: "TEA-50"}, + {name: "non issue tag", input: "Please coordinate with @team.", want: ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := ExtractIssueIdentity(tc.input); got != tc.want { + t.Fatalf("ExtractIssueIdentity = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/harness/internal/surface/multica/issue_signal.go b/harness/internal/surface/multica/issue_signal.go new file mode 100644 index 00000000..58443cd3 --- /dev/null +++ b/harness/internal/surface/multica/issue_signal.go @@ -0,0 +1,105 @@ +package multica + +import ( + "fmt" + "strings" + + "github.com/mnemon-dev/mnemon/harness/internal/interaction" +) + +const MulticaExternalSource = "multica" + +type IssueSignalMaterial struct { + ID string + Identifier string + Title string + Description string +} + +type IssueSignalOptions struct { + Scope string + TTL string + WhyTeamwork string + WorkspaceID string + TaskID string + AgentID string + Principal string + EvidenceRefs []string + ContextRefs []string + ExternalID string +} + +type ObservedDraft = interaction.EventMaterial + +func BuildIssueTeamworkSignal(issue IssueSignalMaterial, opts IssueSignalOptions) (ObservedDraft, error) { + if strings.TrimSpace(issue.ID) == "" { + return ObservedDraft{}, fmt.Errorf("multica issue id is required") + } + title := strings.TrimSpace(issue.Title) + if title == "" { + title = strings.TrimSpace(issue.Identifier) + } + if title == "" { + title = issue.ID + } + scope := strings.TrimSpace(opts.Scope) + if scope == "" { + scope = "multica/teamwork" + } + ttl := strings.TrimSpace(opts.TTL) + if ttl == "" { + ttl = "30m" + } + correlation := "multica:issue:" + issue.ID + rule := map[string]any{ + "external_source": MulticaExternalSource, + "external_issue_id": issue.ID, + "external_issue_identifier": strings.TrimSpace(issue.Identifier), + "correlation_id": correlation, + "scope": scope, + "ttl": ttl, + } + putRuleString(rule, "external_workspace_id", opts.WorkspaceID) + putRuleString(rule, "external_task_id", opts.TaskID) + putRuleString(rule, "external_agent_id", opts.AgentID) + putRuleString(rule, "principal", opts.Principal) + narrative := map[string]any{ + "title": title, + "statement": issueSignalStatement(issue, title), + "why_teamwork": strings.TrimSpace(opts.WhyTeamwork), + } + if narrative["why_teamwork"] == "" { + narrative["why_teamwork"] = "The Multica issue is being bridged into Mnemon so local agents can decide whether and how to coordinate." + } + refs := map[string]any{ + "context_refs": append([]string{correlation}, cleanMulticaRefs(opts.ContextRefs)...), + } + if evidence := cleanMulticaRefs(opts.EvidenceRefs); len(evidence) > 0 { + refs["evidence_refs"] = evidence + } else { + refs["evidence_refs"] = []string{correlation} + } + externalID := strings.TrimSpace(opts.ExternalID) + if externalID == "" { + externalID = "multica-issue-" + issue.ID + } + return ObservedDraft{ + EventType: "teamwork_signal.write_candidate.observed", + ExternalID: externalID, + Payload: interaction.BuildPayload(rule, narrative, refs), + }, nil +} + +func issueSignalStatement(issue IssueSignalMaterial, fallback string) string { + if strings.TrimSpace(issue.Description) != "" { + return strings.TrimSpace(issue.Description) + } + return fallback +} + +func putRuleString(rule map[string]any, key, value string) { + value = strings.TrimSpace(value) + if value != "" { + rule[key] = value + } +} diff --git a/harness/internal/surface/multica/issue_signal_test.go b/harness/internal/surface/multica/issue_signal_test.go new file mode 100644 index 00000000..8fc23a06 --- /dev/null +++ b/harness/internal/surface/multica/issue_signal_test.go @@ -0,0 +1,74 @@ +package multica + +import "testing" + +func TestBuildIssueTeamworkSignalSeparatesRuleNarrativeRefs(t *testing.T) { + draft, err := BuildIssueTeamworkSignal(IssueSignalMaterial{ + ID: "iss-123", + Identifier: "MUL-123", + Title: "Validate bridge", + Description: "Check that Multica issue context can start Mnemon teamwork.", + }, IssueSignalOptions{ + Scope: "multica/poc", + TTL: "45m", + WhyTeamwork: "The task needs more than one local agent.", + WorkspaceID: "workspace-1", + TaskID: "task-1", + AgentID: "agent-1", + Principal: "planner@team", + EvidenceRefs: []string{"multica:issue/iss-123"}, + ExternalID: "multica-task-task-1", + }) + if err != nil { + t.Fatal(err) + } + if draft.EventType != "teamwork_signal.write_candidate.observed" { + t.Fatalf("event type = %q", draft.EventType) + } + rule := draft.Payload["rule"].(map[string]any) + if rule["external_source"] != MulticaExternalSource || rule["external_issue_id"] != "iss-123" || rule["scope"] != "multica/poc" { + t.Fatalf("rule mapping mismatch: %+v", rule) + } + if rule["external_workspace_id"] != "workspace-1" || rule["external_task_id"] != "task-1" || rule["external_agent_id"] != "agent-1" || rule["principal"] != "planner@team" { + t.Fatalf("runtime rule mapping mismatch: %+v", rule) + } + if draft.ExternalID != "multica-task-task-1" { + t.Fatalf("external id = %q", draft.ExternalID) + } + narrative := draft.Payload["narrative"].(map[string]any) + if narrative["statement"] != "Check that Multica issue context can start Mnemon teamwork." { + t.Fatalf("narrative statement mismatch: %+v", narrative) + } + if _, ok := narrative["external_issue_id"]; ok { + t.Fatalf("narrative must not carry rule ids: %+v", narrative) + } + if _, ok := narrative["external_task_id"]; ok { + t.Fatalf("narrative must not carry runtime ids: %+v", narrative) + } + refs := draft.Payload["refs"].(map[string]any) + if got := refs["evidence_refs"].([]string); len(got) != 1 || got[0] != "multica:issue/iss-123" { + t.Fatalf("evidence refs mismatch: %+v", refs) + } +} + +func TestBuildIssueTeamworkSignalDefaults(t *testing.T) { + draft, err := BuildIssueTeamworkSignal(IssueSignalMaterial{ID: "iss-1"}, IssueSignalOptions{}) + if err != nil { + t.Fatal(err) + } + rule := draft.Payload["rule"].(map[string]any) + if rule["scope"] != "multica/teamwork" || rule["ttl"] != "30m" || rule["correlation_id"] != "multica:issue:iss-1" { + t.Fatalf("default rule mismatch: %+v", rule) + } + narrative := draft.Payload["narrative"].(map[string]any) + if narrative["title"] != "iss-1" || narrative["statement"] != "iss-1" || narrative["why_teamwork"] == "" { + t.Fatalf("default narrative mismatch: %+v", narrative) + } + refs := draft.Payload["refs"].(map[string]any) + if got := refs["evidence_refs"].([]string); len(got) != 1 || got[0] != "multica:issue:iss-1" { + t.Fatalf("default evidence refs mismatch: %+v", refs) + } + if draft.ExternalID != "multica-issue-iss-1" { + t.Fatalf("default external id = %q", draft.ExternalID) + } +} diff --git a/harness/internal/surface/multica/metadata.go b/harness/internal/surface/multica/metadata.go new file mode 100644 index 00000000..4faa5edf --- /dev/null +++ b/harness/internal/surface/multica/metadata.go @@ -0,0 +1,16 @@ +package multica + +import "strings" + +func MergeIssueMetadata(base map[string]any, listed map[string]string) map[string]any { + merged := map[string]any{} + for key, value := range base { + merged[key] = value + } + for key, value := range listed { + if strings.TrimSpace(key) != "" { + merged[key] = value + } + } + return merged +} diff --git a/harness/internal/surface/multica/metadata_test.go b/harness/internal/surface/multica/metadata_test.go new file mode 100644 index 00000000..49e24c21 --- /dev/null +++ b/harness/internal/surface/multica/metadata_test.go @@ -0,0 +1,32 @@ +package multica + +import "testing" + +func TestMergeIssueMetadataUsesListedValuesAsAuthoritative(t *testing.T) { + base := map[string]any{ + MulticaMetadataKind: "embedded", + MulticaMetadataSessionID: "embedded-session", + "local": 7, + } + listed := map[string]string{ + MulticaMetadataKind: MulticaHubKindAssignmentMailbox, + MulticaMetadataCorrelationID: "multica:issue:child-1", + "": "ignored", + } + got := MergeIssueMetadata(base, listed) + if got[MulticaMetadataKind] != MulticaHubKindAssignmentMailbox { + t.Fatalf("listed metadata should override embedded value: %+v", got) + } + if got[MulticaMetadataSessionID] != "embedded-session" || got["local"] != 7 { + t.Fatalf("base metadata not preserved: %+v", got) + } + if got[MulticaMetadataCorrelationID] != "multica:issue:child-1" { + t.Fatalf("listed metadata not merged: %+v", got) + } + if _, ok := got[""]; ok { + t.Fatalf("empty listed key should be ignored: %+v", got) + } + if base[MulticaMetadataKind] != "embedded" { + t.Fatalf("base metadata mutated: %+v", base) + } +} diff --git a/harness/internal/surface/multica/projection.go b/harness/internal/surface/multica/projection.go new file mode 100644 index 00000000..b420c52e --- /dev/null +++ b/harness/internal/surface/multica/projection.go @@ -0,0 +1,658 @@ +package multica + +import ( + "fmt" + "strings" + + commentprojection "github.com/mnemon-dev/mnemon/harness/internal/projection" +) + +type AssignmentMailboxMaterial struct { + ID string + SessionID string + Scope string + Assignee string + AssigneeDisplay string + RootIssueID string + RootIssueLabel string + RootIssueTitle string + ExpectedWork string + ExpectedFeedback string + Rationale string +} + +type AssignmentMailboxRuntimeMaterial struct { + Item RuntimeAssignmentItem + SessionID string + RootIssueID string + FallbackRootIssueID string + RootIssueIdentifier string + RootIssueTitle string + AssigneeAgentName string + AssigneeAgentID string +} + +type RootSessionMaterial struct { + Request string + WorkMode string + Handoffs []string + Validation []string + Completion string +} + +type ProgressFeedbackMaterial struct { + AssignmentRef string + FeedbackKind string + Summary string + Result string + Blocker string + ArtifactRefs []string + EvidenceRefs []string +} + +type RuntimeProjectionMaterial struct { + AssignmentMailbox bool + Status string + IssueID string + IssueLabel string + Principal string + TaskID string + HubBackend string + SessionID string + RootIssueID string + RootIssueLabel string + AssignmentID string + HasIngestReceipt bool + IngestSeq int64 + IngestDuplicate bool + IngestTicked bool + WakeStatus string + WakeTurnID string + HubWriteStatus string + HubChildIssues int + HubFeedbackComment int +} + +type ProgressFeedbackProjectionMaterial struct { + Item RuntimeProgressItem + SessionID string + CorrelationID string +} + +type ProgressFeedbackProjection struct { + Source MulticaHubLedgerSource + Feedback ProgressFeedbackMaterial + CommentBody string +} + +func AssignmentMailboxTitle(item AssignmentMailboxMaterial) string { + topic := assignmentTitleTopic(item) + root := strings.TrimSpace(item.RootIssueLabel) + if root != "" && topic != "" { + return trimTitle(root + ": " + topic) + } + if topic == "" { + topic = "assignment" + } + return trimTitle("Assignment: " + topic) +} + +func AssignmentMailboxMaterialForRuntimeItem(material AssignmentMailboxRuntimeMaterial) AssignmentMailboxMaterial { + item := material.Item + rootIssueID := firstNonEmptyString(material.RootIssueID, material.FallbackRootIssueID) + return AssignmentMailboxMaterial{ + ID: item.ID, + SessionID: material.SessionID, + Scope: item.Scope, + Assignee: item.Assignee, + AssigneeDisplay: firstNonEmptyString(material.AssigneeAgentName, material.AssigneeAgentID), + RootIssueID: rootIssueID, + RootIssueLabel: firstNonEmptyString(material.RootIssueIdentifier, rootIssueID), + RootIssueTitle: material.RootIssueTitle, + ExpectedWork: item.ExpectedWork, + ExpectedFeedback: item.ExpectedFeedback, + Rationale: item.Rationale, + } +} + +func RootSessionDescription(item RootSessionMaterial) string { + var b strings.Builder + request := strings.TrimSpace(item.Request) + if request == "" { + request = "Coordinate this request through Mnemon teamwork in Multica." + } + b.WriteString("## Request\n\n") + b.WriteString(request) + b.WriteString("\n\n") + + b.WriteString("## Teamwork\n\n") + writeBullet(&b, "Work mode", firstNonEmptyString(item.WorkMode, "Mnemon teamwork with Multica issue visibility")) + writeBullet(&b, "Assignment path", "Accepted assignments appear as child issues assigned to target agents") + writeBullet(&b, "Feedback path", "Progress, results, and blockers appear as child issue comments and statuses") + + if handoffs := cleanStrings(item.Handoffs); len(handoffs) > 0 { + b.WriteString("\n## Handoffs\n\n") + writeList(&b, handoffs) + } + if validation := cleanStrings(item.Validation); len(validation) > 0 { + b.WriteString("\n## Validation\n\n") + writeList(&b, validation) + } + if completion := strings.TrimSpace(item.Completion); completion != "" { + b.WriteString("\n## Completion\n\n") + b.WriteString(completion) + b.WriteString("\n") + } + return strings.TrimSpace(b.String()) +} + +func AssignmentMailboxDescription(item AssignmentMailboxMaterial) string { + var b strings.Builder + b.WriteString("## Assignment\n\n") + if work := visibleProtocolText(item.ExpectedWork); work != "" { + b.WriteString(work) + b.WriteString("\n\n") + } else if scope := strings.TrimSpace(item.Scope); scope != "" { + b.WriteString(scope) + b.WriteString("\n\n") + } + b.WriteString("## Context\n\n") + writeBullet(&b, "Root issue", rootIssueReference(item)) + writeBullet(&b, "Assignee", assigneeReference(item)) + writeBullet(&b, "Scope", item.Scope) + if rationale := visibleProtocolText(item.Rationale); rationale != "" { + b.WriteString("\n## Rationale\n\n") + b.WriteString(rationale) + b.WriteString("\n") + } + b.WriteString("\n## Feedback\n\n") + if feedback := visibleExpectedFeedback(item.ExpectedFeedback); feedback != "" { + writeBullet(&b, "Expected feedback", feedback) + } + if strings.TrimSpace(item.ExpectedFeedback) == "" { + b.WriteString("Report progress, results, or blockers through the Mnemon runtime path.\n") + } else { + writeBullet(&b, "Progress path", "Mnemon runtime progress, result, or blocker feedback") + } + return strings.TrimSpace(b.String()) +} + +func ProgressFeedbackProjectionForRuntimeItem(material ProgressFeedbackProjectionMaterial) ProgressFeedbackProjection { + item := material.Item + feedback := ProgressFeedbackMaterialForRuntimeItem(item) + return ProgressFeedbackProjection{ + Source: MulticaHubLedgerSource{ + SessionID: material.SessionID, + CorrelationID: material.CorrelationID, + EventID: item.EventID, + AssignmentID: item.AssignmentRef, + Principal: item.Actor, + ProjectionKind: "progress", + }, + Feedback: feedback, + CommentBody: commentprojection.FormatComment(commentprojection.CommentMaterial{ + Title: "assignment feedback", + Body: ProgressCommentBody(feedback), + EventIDs: []string{item.EventID}, + EventType: "progress_digest.accepted", + SessionID: material.SessionID, + AssignmentID: item.AssignmentRef, + }), + } +} + +func ProgressFeedbackMaterialForRuntimeItem(item RuntimeProgressItem) ProgressFeedbackMaterial { + return ProgressFeedbackMaterial{ + AssignmentRef: item.AssignmentRef, + FeedbackKind: item.FeedbackKind, + Summary: item.Summary, + Result: item.Result, + Blocker: item.Blocker, + ArtifactRefs: item.ArtifactRefs, + EvidenceRefs: item.EvidenceRefs, + } +} + +func ProgressCommentBody(item ProgressFeedbackMaterial) string { + var b strings.Builder + b.WriteString("## Feedback\n\n") + if status := visibleFeedbackStatus(item.FeedbackKind); status != "" { + writeBullet(&b, "Status", status) + } + if summary := strings.TrimSpace(item.Summary); summary != "" { + b.WriteString("\n## Summary\n\n") + b.WriteString(summary) + b.WriteString("\n") + } + if result := strings.TrimSpace(item.Result); result != "" { + b.WriteString("\n## Result\n\n") + b.WriteString(result) + b.WriteString("\n") + } + if blocker := strings.TrimSpace(item.Blocker); blocker != "" { + b.WriteString("\n## Blocker\n\n") + b.WriteString(blocker) + b.WriteString("\n") + } + if len(item.ArtifactRefs) > 0 { + b.WriteString("\n## Artifacts\n\n") + writeList(&b, item.ArtifactRefs) + } + if len(item.EvidenceRefs) > 0 { + b.WriteString("\n## Evidence\n\n") + writeList(&b, item.EvidenceRefs) + } + return strings.TrimSpace(b.String()) +} + +func RuntimeProjectionCommentBody(item RuntimeProjectionMaterial) string { + var b strings.Builder + if item.AssignmentMailbox { + b.WriteString("## Mnemon Runtime\n\n") + writeBullet(&b, "Status", firstNonEmptyString(item.Status, "correlated")) + writeBullet(&b, "Root issue", IssueMention(firstNonEmptyString(item.RootIssueLabel, item.RootIssueID), item.RootIssueID)) + } else { + b.WriteString("## Mnemon Runtime\n\n") + writeBullet(&b, "Status", item.Status) + writeBullet(&b, "Issue", IssueMention(firstNonEmptyString(item.IssueLabel, item.IssueID), item.IssueID)) + writeBullet(&b, "Principal", codeSpan(item.Principal)) + if item.HasIngestReceipt { + writeBullet(&b, "Mnemond ingest", fmt.Sprintf("seq=%d duplicate=%v ticked=%v", item.IngestSeq, item.IngestDuplicate, item.IngestTicked)) + } + } + if item.WakeStatus != "" || item.HubWriteStatus != "" { + b.WriteString("\n## Effects\n\n") + writeBullet(&b, "Managed wake", codeSpan(item.WakeStatus)) + writeBullet(&b, "Managed turn", codeSpan(item.WakeTurnID)) + if item.HubWriteStatus != "" { + writeBullet(&b, "Projection status", visibleProjectionStatus(item.HubWriteStatus)) + if item.HubChildIssues > 0 { + writeBullet(&b, "Assignments created", fmt.Sprintf("%d", item.HubChildIssues)) + } + if item.HubFeedbackComment > 0 { + writeBullet(&b, "Feedback comments added", fmt.Sprintf("%d", item.HubFeedbackComment)) + } + } + } + return strings.TrimSpace(b.String()) +} + +func IssueMention(label, issueID string) string { + issueID = strings.TrimSpace(issueID) + label = strings.TrimSpace(label) + if label == "" { + label = issueID + } + if issueID == "" { + return label + } + return "[" + escapeMarkdownLinkLabel(label) + "](mention://issue/" + issueID + ")" +} + +func ProgressIssueStatus(item ProgressFeedbackMaterial) string { + switch canonicalProgressStatus(item.FeedbackKind) { + case "blocker": + return StatusBlocked + case "result": + return StatusDone + case "progress": + return StatusInProgress + case "review": + return StatusInReview + case "waiting": + return StatusTodo + case "cancelled": + return StatusCancelled + } + if strings.TrimSpace(item.Blocker) != "" { + return StatusBlocked + } + if strings.TrimSpace(item.Result) != "" { + return StatusDone + } + return "" +} + +func ProgressRootIssueStatus(item ProgressFeedbackMaterial, allAssignmentsDone bool) string { + switch ProgressIssueStatus(item) { + case StatusBlocked: + return StatusBlocked + case StatusCancelled: + return StatusCancelled + case StatusDone: + if allAssignmentsDone { + return StatusDone + } + return StatusInReview + case StatusInReview: + return StatusInReview + case StatusInProgress: + return StatusInProgress + case StatusTodo: + return StatusTodo + case StatusBacklog: + return StatusBacklog + default: + return "" + } +} + +func ProgressCompletesAssignment(item ProgressFeedbackMaterial) bool { + return ProgressIssueStatus(item) == StatusDone +} + +func IssueStatusDone(status string) bool { + return CanonicalIssueStatus(status) == StatusDone +} + +func assignmentTitleTopic(item AssignmentMailboxMaterial) string { + scope := firstSentence(item.Scope) + if topic := scopePathTitleTopic(scope, item.RootIssueLabel); topic != "" { + return topic + } + idTopic := assignmentIDTitleTopic(item.ID, item.RootIssueLabel) + if idTopic != "" && (broadAssignmentScope(scope) || machineReferenceScope(scope)) { + return idTopic + } + for _, candidate := range []string{scope, firstSentence(item.ExpectedWork), idTopic, item.ID} { + candidate = stripTitleRootLabel(candidate, item.RootIssueLabel) + if strings.TrimSpace(candidate) != "" { + return candidate + } + } + return "" +} + +func scopePathTitleTopic(scope, rootLabel string) string { + scope = strings.TrimSpace(scope) + if scope == "" || !strings.Contains(scope, "/") || strings.Contains(scope, "://") { + return "" + } + parts := strings.Split(scope, "/") + if len(parts) < 3 { + return "" + } + for i := len(parts) - 1; i >= 0; i-- { + part := strings.TrimSpace(parts[i]) + if part == "" { + continue + } + part = stripTitleRootLabel(part, rootLabel) + part = strings.Trim(strings.ReplaceAll(strings.ReplaceAll(part, "_", " "), "-", " "), " ") + if part == "" || stageLikeTitlePart(part) { + continue + } + return part + } + return "" +} + +func stageLikeTitlePart(value string) bool { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" || value == "stage" || value == "phase" { + return true + } + if strings.HasPrefix(value, "stage") && len(value) > len("stage") { + for _, r := range value[len("stage"):] { + if r < '0' || r > '9' { + return false + } + } + return true + } + if strings.HasPrefix(value, "phase") && len(value) > len("phase") { + for _, r := range value[len("phase"):] { + if r < '0' || r > '9' { + return false + } + } + return true + } + return false +} + +func broadAssignmentScope(scope string) bool { + lower := strings.ToLower(strings.TrimSpace(scope)) + return strings.Contains(lower, "drill") || + strings.Contains(lower, "validation") || + strings.Contains(lower, "readiness") || + strings.Contains(lower, "hub-flow") +} + +func machineReferenceScope(scope string) bool { + scope = strings.TrimSpace(strings.ToLower(scope)) + if scope == "" { + return false + } + return strings.HasPrefix(scope, "multica:") || strings.HasPrefix(scope, "github:") || strings.Contains(scope, "://") +} + +func assignmentIDTitleTopic(id, rootLabel string) string { + id = strings.TrimSpace(id) + if id == "" { + return "" + } + root := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(rootLabel), "-", "")) + rootParts := titleRootParts(rootLabel) + parts := strings.FieldsFunc(strings.ToLower(id), func(r rune) bool { + return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9') + }) + var out []string + for i := 0; i < len(parts); i++ { + part := parts[i] + if len(rootParts) > 0 && titlePartsMatch(parts[i:], rootParts) { + i += len(rootParts) - 1 + continue + } + switch part { + case "", "assignment", "asg", "r1", "r2", "drill", "readiness", root: + continue + } + if root != "" && strings.TrimPrefix(part, root) == "" { + continue + } + out = append(out, part) + } + joined := strings.Join(out, " ") + if strings.IndexFunc(joined, func(r rune) bool { return r >= 'a' && r <= 'z' }) < 0 { + return "" + } + return joined +} + +func titleRootParts(rootLabel string) []string { + return strings.FieldsFunc(strings.ToLower(strings.TrimSpace(rootLabel)), func(r rune) bool { + return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9') + }) +} + +func titlePartsMatch(parts, want []string) bool { + if len(want) == 0 || len(parts) < len(want) { + return false + } + for i, part := range want { + if parts[i] != part { + return false + } + } + return true +} + +func stripTitleRootLabel(value, label string) string { + value = strings.TrimSpace(value) + label = strings.TrimSpace(label) + if value == "" || label == "" { + return value + } + if strings.EqualFold(value, label) { + return "" + } + lower := strings.ToLower(value) + for _, separator := range []string{": ", " - ", " -- ", " "} { + prefix := label + separator + if strings.HasPrefix(lower, strings.ToLower(prefix)) { + return strings.TrimSpace(value[len(prefix):]) + } + } + return value +} + +func rootIssueReference(item AssignmentMailboxMaterial) string { + label := firstNonEmptyString(item.RootIssueLabel, item.RootIssueID) + ref := IssueMention(label, item.RootIssueID) + if title := strings.TrimSpace(item.RootIssueTitle); title != "" { + if ref != "" { + return ref + " - " + title + } + return title + } + return ref +} + +func assigneeReference(item AssignmentMailboxMaterial) string { + principal := codeSpan(item.Assignee) + display := strings.TrimSpace(item.AssigneeDisplay) + if display == "" { + return principal + } + if principal == "" { + return display + } + return principal + " (" + display + ")" +} + +func visibleExpectedFeedback(value string) string { + value = visibleProtocolText(value) + if value == "" { + return "" + } + lower := strings.ToLower(value) + for _, prefix := range []string{"runtime feedback with "} { + if strings.HasPrefix(lower, prefix) { + return strings.TrimSpace(value[len(prefix):]) + } + } + if lower == "runtime feedback" { + return "progress, result, or blocker" + } + return value +} + +func visibleProtocolText(value string) string { + value = strings.TrimSpace(value) + value = strings.ReplaceAll(value, "progress_digest", "runtime feedback") + value = strings.ReplaceAll(value, "progress digest", "runtime feedback") + value = strings.ReplaceAll(value, "feedback_kind=", "status=") + value = strings.ReplaceAll(value, "feedback_kind", "status") + return strings.TrimSpace(value) +} + +func visibleFeedbackStatus(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "": + return "" + case "progress": + return "progress update" + case "result": + return "result" + case "blocker": + return "blocked" + case "review": + return "ready for review" + case "waiting": + return "waiting" + case "cancelled", "canceled": + return "cancelled" + default: + return strings.ReplaceAll(strings.TrimSpace(value), "_", " ") + } +} + +func visibleProjectionStatus(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "": + return "" + case "created": + return "updates created" + case "updated": + return "updates synced" + case "commented": + return "feedback posted" + case "skipped": + return "no visible updates needed" + case "failed": + return "update failed" + default: + return strings.ReplaceAll(strings.TrimSpace(value), "_", " ") + } +} + +func writeBullet(b *strings.Builder, label, value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + b.WriteString("- ") + b.WriteString(label) + b.WriteString(": ") + b.WriteString(value) + b.WriteString("\n") +} + +func writeList(b *strings.Builder, values []string) { + for _, value := range cleanStrings(values) { + b.WriteString("- ") + b.WriteString(value) + b.WriteString("\n") + } +} + +func codeSpan(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + return "`" + strings.ReplaceAll(value, "`", "'") + "`" +} + +func firstSentence(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + for _, sep := range []string{". ", "\n"} { + if before, _, ok := strings.Cut(value, sep); ok { + return strings.TrimSpace(before) + } + } + return value +} + +func trimTitle(value string) string { + value = strings.Join(strings.Fields(strings.TrimSpace(value)), " ") + const max = 96 + if len(value) <= max { + return value + } + return strings.TrimSpace(value[:max-1]) + "..." +} + +func escapeMarkdownLinkLabel(value string) string { + value = strings.ReplaceAll(value, "[", `\[`) + value = strings.ReplaceAll(value, "]", `\]`) + return value +} + +func cleanStrings(values []string) []string { + out := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + return out +} diff --git a/harness/internal/surface/multica/projection_test.go b/harness/internal/surface/multica/projection_test.go new file mode 100644 index 00000000..1d93549b --- /dev/null +++ b/harness/internal/surface/multica/projection_test.go @@ -0,0 +1,447 @@ +package multica + +import ( + "strings" + "testing" +) + +func TestRootSessionDescriptionIsStructuredVisibleText(t *testing.T) { + body := RootSessionDescription(RootSessionMaterial{ + Request: "Run a Multica readiness drill.", + WorkMode: "Mnemon teamwork with Multica issue visibility.", + Handoffs: []string{ + "Route root visibility and child routing checks to separate teammates.", + "Route a final integration check after teammate feedback is visible.", + }, + Validation: []string{ + "Root issue carries session metadata and shows run activity.", + "Accepted assignments become child issue mailboxes assigned to target agents.", + "Final root status reflects completion.", + }, + Completion: "Finish when child feedback comments are visible and the root issue reaches a terminal status.", + }) + for _, want := range []string{ + "## Request", + "Run a Multica readiness drill.", + "## Teamwork", + "Work mode: Mnemon teamwork with Multica issue visibility.", + "Assignment path: Accepted assignments appear as child issues assigned to target agents", + "Feedback path: Progress, results, and blockers appear as child issue comments and statuses", + "## Handoffs", + "Route root visibility and child routing checks to separate teammates.", + "## Validation", + "Root issue carries session metadata and shows run activity.", + "## Completion", + } { + if !strings.Contains(body, want) { + t.Fatalf("root session description missing %q:\n%s", want, body) + } + } + for _, blocked := range []string{"mnemon.", "session_id", "assignment_id", "assignment_ref", "progress_digest", "hub_backend", "projection owner"} { + if strings.Contains(strings.ToLower(body), blocked) { + t.Fatalf("root session description must not expose machine field %q:\n%s", blocked, body) + } + } +} + +func TestAssignmentMailboxMaterial(t *testing.T) { + item := AssignmentMailboxMaterial{ + ID: "asg-1", + SessionID: "session-1", + Scope: "runtime/readiness", + Assignee: "researcher@team", + AssigneeDisplay: "mnemon-researcher", + RootIssueID: "root-1", + RootIssueLabel: "TEA-1", + RootIssueTitle: "Validate runtime", + ExpectedWork: "Check runtime output.", + ExpectedFeedback: "Brief result.", + } + if got := AssignmentMailboxTitle(item); got != "TEA-1: runtime/readiness" { + t.Fatalf("title = %q", got) + } + body := AssignmentMailboxDescription(item) + for _, want := range []string{ + "## Assignment", + "Root issue: [TEA-1](mention://issue/root-1) - Validate runtime", + "Assignee: `researcher@team` (mnemon-researcher)", + "Scope: runtime/readiness", + "## Feedback", + "Expected feedback: Brief result.", + "Progress path: Mnemon runtime progress, result, or blocker feedback", + } { + if !strings.Contains(body, want) { + t.Fatalf("description missing %q:\n%s", want, body) + } + } + for _, blocked := range []string{"Session:", "Assignment: `asg-1`", "progress_digest", "assignment_ref", "mnemon."} { + if strings.Contains(body, blocked) { + t.Fatalf("visible description must not expose machine field %q:\n%s", blocked, body) + } + } +} + +func TestAssignmentMailboxMaterialForRuntimeItem(t *testing.T) { + item := AssignmentMailboxMaterialForRuntimeItem(AssignmentMailboxRuntimeMaterial{ + Item: RuntimeAssignmentItem{ + ID: "asg-1", + Scope: "runtime/readiness", + Assignee: "researcher@team", + ExpectedWork: "Check runtime output.", + ExpectedFeedback: "Brief result.", + Rationale: "Route to the researcher mailbox.", + }, + SessionID: "multica:session:root-1", + RootIssueID: "root-1", + FallbackRootIssueID: "fallback-root", + RootIssueIdentifier: "TEA-1", + RootIssueTitle: "Validate runtime", + AssigneeAgentName: "mnemon-researcher", + AssigneeAgentID: "agent-researcher", + }) + if item.ID != "asg-1" || + item.SessionID != "multica:session:root-1" || + item.RootIssueID != "root-1" || + item.RootIssueLabel != "TEA-1" || + item.RootIssueTitle != "Validate runtime" || + item.Assignee != "researcher@team" || + item.AssigneeDisplay != "mnemon-researcher" || + item.ExpectedWork != "Check runtime output." || + item.ExpectedFeedback != "Brief result." || + item.Rationale != "Route to the researcher mailbox." { + t.Fatalf("assignment mailbox material mismatch: %+v", item) + } +} + +func TestAssignmentMailboxTitleStripsRootLabelFromScope(t *testing.T) { + item := AssignmentMailboxMaterial{ + ID: "asg-1", + Scope: "TEA-14 Mnemon R2 Multica hub final validation", + RootIssueLabel: "TEA-14", + } + if got := AssignmentMailboxTitle(item); got != "TEA-14: Mnemon R2 Multica hub final validation" { + t.Fatalf("title = %q", got) + } +} + +func TestAssignmentMailboxTitleUsesAssignmentIDForBroadDrillScope(t *testing.T) { + item := AssignmentMailboxMaterial{ + ID: "assignment-tea66-routing-isolation", + Scope: "TEA-66 Mnemon R2 hub-flow readiness drill", + RootIssueLabel: "TEA-66", + ExpectedWork: "Validate assignment child issue routing and stale or cross-session isolation.", + } + if got := AssignmentMailboxTitle(item); got != "TEA-66: routing isolation" { + t.Fatalf("title = %q", got) + } +} + +func TestAssignmentMailboxTitleUsesPathScopeTopicBeforeBroadIDFallback(t *testing.T) { + item := AssignmentMailboxMaterial{ + ID: "assignment-tea88-root-runtime", + Scope: "multica-hub-readiness-drill/TEA-88/stage1/root-metadata-run-visibility", + RootIssueLabel: "TEA-88", + ExpectedWork: "Validate root session metadata and run visibility.", + } + if got := AssignmentMailboxTitle(item); got != "TEA-88: root metadata run visibility" { + t.Fatalf("title = %q", got) + } +} + +func TestAssignmentMailboxTitleUsesAssignmentIDForReadinessScope(t *testing.T) { + item := AssignmentMailboxMaterial{ + ID: "r2-drill-child-routing-isolation", + Scope: "mnemon-r2-multica-hub-flow-readiness", + RootIssueLabel: "TEA-95", + ExpectedWork: "Validate child routing isolation.", + } + if got := AssignmentMailboxTitle(item); got != "TEA-95: child routing isolation" { + t.Fatalf("title = %q", got) + } +} + +func TestAssignmentMailboxTitleDropsRootIdentifierTokensFromAssignmentID(t *testing.T) { + item := AssignmentMailboxMaterial{ + ID: "tea-104-root-visibility", + Scope: "mnemon-r2-multica-hub-flow-readiness", + RootIssueLabel: "TEA-104", + ExpectedWork: "Validate root session visibility.", + } + if got := AssignmentMailboxTitle(item); got != "TEA-104: root visibility" { + t.Fatalf("title = %q", got) + } +} + +func TestAssignmentMailboxTitleUsesAssignmentIDForMachineReferenceScope(t *testing.T) { + item := AssignmentMailboxMaterial{ + ID: "assignment-tea84-root-runtime", + Scope: "multica:issue:582a0697-df9b-4e81-84f9-da72b0a685e5", + RootIssueLabel: "TEA-84", + ExpectedWork: "Validate root session metadata and run visibility.", + } + if got := AssignmentMailboxTitle(item); got != "TEA-84: root runtime" { + t.Fatalf("title = %q", got) + } +} + +func TestAssignmentMailboxDescriptionNormalizesProtocolFeedback(t *testing.T) { + body := AssignmentMailboxDescription(AssignmentMailboxMaterial{ + ID: "asg-1", + Scope: "release validation", + ExpectedFeedback: "progress_digest with result or blocker", + }) + if !strings.Contains(body, "Expected feedback: result or blocker") { + t.Fatalf("description should expose human feedback wording:\n%s", body) + } + if strings.Contains(body, "progress_digest") { + t.Fatalf("description should keep protocol event type out of visible text:\n%s", body) + } +} + +func TestAssignmentMailboxDescriptionNormalizesProtocolWorkAndRationale(t *testing.T) { + body := AssignmentMailboxDescription(AssignmentMailboxMaterial{ + ID: "asg-1", + Scope: "release validation", + ExpectedWork: "Report a progress_digest result with exact evidence.", + Rationale: "Use feedback_kind=result only after checking Multica evidence.", + }) + for _, blocked := range []string{"progress_digest", "feedback_kind"} { + if strings.Contains(body, blocked) { + t.Fatalf("description should keep protocol word %q out of visible text:\n%s", blocked, body) + } + } + for _, want := range []string{ + "Report a runtime feedback result with exact evidence.", + "Use status=result only after checking Multica evidence.", + } { + if !strings.Contains(body, want) { + t.Fatalf("description missing normalized text %q:\n%s", want, body) + } + } +} + +func TestAssignmentMailboxDescriptionNormalizesFeedbackKindProtocolWording(t *testing.T) { + body := AssignmentMailboxDescription(AssignmentMailboxMaterial{ + ID: "asg-1", + Scope: "release validation", + ExpectedFeedback: "progress_digest feedback_kind=result or blocker with exact Multica evidence", + }) + for _, blocked := range []string{"progress_digest", "feedback_kind"} { + if strings.Contains(body, blocked) { + t.Fatalf("description should keep protocol word %q out of visible text:\n%s", blocked, body) + } + } + if !strings.Contains(body, "runtime feedback status=result or blocker") { + t.Fatalf("description missing readable feedback wording:\n%s", body) + } +} + +func TestProgressFeedbackMaterial(t *testing.T) { + item := ProgressFeedbackMaterial{ + AssignmentRef: "asg-1", + FeedbackKind: "result", + Summary: "Done", + Result: "Validated", + ArtifactRefs: []string{"run-1"}, + EvidenceRefs: []string{"comment-1"}, + } + body := ProgressCommentBody(item) + for _, want := range []string{ + "## Feedback", + "Status: result", + "## Summary", + "Done", + "## Result", + "Validated", + "## Artifacts", + "- run-1", + "## Evidence", + "- comment-1", + } { + if !strings.Contains(body, want) { + t.Fatalf("progress body missing %q:\n%s", want, body) + } + } + for _, blocked := range []string{"Assignment: `asg-1`", "Feedback: `result`", "progress_digest", "assignment_ref", "mnemon."} { + if strings.Contains(body, blocked) { + t.Fatalf("progress body must keep machine field %q in metadata/markers, not visible text:\n%s", blocked, body) + } + } + if got := ProgressIssueStatus(item); got != "done" { + t.Fatalf("progress status = %q", got) + } + if !ProgressCompletesAssignment(item) { + t.Fatal("result progress should complete assignment") + } + if !IssueStatusDone("completed") { + t.Fatal("completed should be terminal") + } +} + +func TestProgressFeedbackProjectionForRuntimeItem(t *testing.T) { + projection := ProgressFeedbackProjectionForRuntimeItem(ProgressFeedbackProjectionMaterial{ + Item: RuntimeProgressItem{ + EventID: "pg-1", + Actor: "worker@team", + AssignmentRef: "asg-1", + FeedbackKind: "result", + Summary: "Validated the assignment mailbox.", + Result: "Comment projection is visible in Multica.", + ArtifactRefs: []string{"run-1"}, + EvidenceRefs: []string{"comment-1"}, + }, + SessionID: "multica:session:root-1", + CorrelationID: "multica:issue:root-1", + }) + if projection.Source.SessionID != "multica:session:root-1" || + projection.Source.CorrelationID != "multica:issue:root-1" || + projection.Source.EventID != "pg-1" || + projection.Source.AssignmentID != "asg-1" || + projection.Source.Principal != "worker@team" || + projection.Source.ProjectionKind != "progress" { + t.Fatalf("progress source mismatch: %+v", projection.Source) + } + if projection.Feedback.AssignmentRef != "asg-1" || projection.Feedback.FeedbackKind != "result" { + t.Fatalf("feedback material mismatch: %+v", projection.Feedback) + } + for _, want := range []string{ + "Mnemon update: assignment feedback", + "Status: result", + "Validated the assignment mailbox.", + "Comment projection is visible in Multica.", + "mnemon:event=pg-1", + "mnemon:type=progress_digest.accepted", + "mnemon:session=multica:session:root-1", + "mnemon:assignment=asg-1", + } { + if !strings.Contains(projection.CommentBody, want) { + t.Fatalf("comment body missing %q:\n%s", want, projection.CommentBody) + } + } +} + +func TestRuntimeProjectionCommentBodyForIntake(t *testing.T) { + body := RuntimeProjectionCommentBody(RuntimeProjectionMaterial{ + Status: "recorded", + IssueID: "issue-1", + IssueLabel: "TEA-1", + Principal: "planner@team", + TaskID: "task-1", + HubBackend: "multica", + SessionID: "multica:session:issue-1", + HasIngestReceipt: true, + IngestSeq: 42, + IngestDuplicate: false, + IngestTicked: true, + WakeStatus: "completed", + WakeTurnID: "turn-1", + HubWriteStatus: "created", + HubChildIssues: 2, + HubFeedbackComment: 1, + }) + for _, want := range []string{ + "## Mnemon Runtime", + "Issue: [TEA-1](mention://issue/issue-1)", + "Principal: `planner@team`", + "Mnemond ingest: seq=42 duplicate=false ticked=true", + "Managed wake: `completed`", + "Managed turn: `turn-1`", + "Projection status: updates created", + "Assignments created: 2", + "Feedback comments added: 1", + } { + if !strings.Contains(body, want) { + t.Fatalf("runtime projection body missing %q:\n%s", want, body) + } + } + for _, blocked := range []string{"Hub backend:", "Multica hub write", "child_issues", "feedback_comments"} { + if strings.Contains(body, blocked) { + t.Fatalf("runtime projection body must not expose machine field %q:\n%s", blocked, body) + } + } +} + +func TestRuntimeProjectionCommentBodyForAssignmentMailbox(t *testing.T) { + body := RuntimeProjectionCommentBody(RuntimeProjectionMaterial{ + AssignmentMailbox: true, + Status: "correlated", + RootIssueID: "root-1", + RootIssueLabel: "TEA-1", + AssignmentID: "asg-1", + SessionID: "multica:session:root-1", + WakeStatus: "completed", + }) + for _, want := range []string{ + "## Mnemon Runtime", + "Status: correlated", + "Root issue: [TEA-1](mention://issue/root-1)", + "Managed wake: `completed`", + } { + if !strings.Contains(body, want) { + t.Fatalf("assignment runtime projection body missing %q:\n%s", want, body) + } + } + for _, blocked := range []string{"Assignment: `asg-1`", "Session: `multica:session:root-1`"} { + if strings.Contains(body, blocked) { + t.Fatalf("assignment runtime projection should keep %q in metadata, not comment text:\n%s", blocked, body) + } + } +} + +func TestCanonicalIssueStatus(t *testing.T) { + for _, tc := range []struct { + in string + want string + }{ + {in: "backlog", want: StatusBacklog}, + {in: "waiting", want: StatusTodo}, + {in: "in progress", want: StatusInProgress}, + {in: "review", want: StatusInReview}, + {in: "completed", want: StatusDone}, + {in: "blocked", want: StatusBlocked}, + {in: "canceled", want: StatusCancelled}, + {in: "cancelled", want: StatusCancelled}, + {in: "unknown", want: ""}, + } { + if got := CanonicalIssueStatus(tc.in); got != tc.want { + t.Fatalf("CanonicalIssueStatus(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestProgressIssueStatusMapping(t *testing.T) { + for _, tc := range []struct { + name string + item ProgressFeedbackMaterial + want string + }{ + {name: "waiting", item: ProgressFeedbackMaterial{FeedbackKind: "waiting"}, want: StatusTodo}, + {name: "in progress", item: ProgressFeedbackMaterial{FeedbackKind: "progress"}, want: StatusInProgress}, + {name: "review", item: ProgressFeedbackMaterial{FeedbackKind: "review"}, want: StatusInReview}, + {name: "done", item: ProgressFeedbackMaterial{FeedbackKind: "result"}, want: StatusDone}, + {name: "blocked", item: ProgressFeedbackMaterial{FeedbackKind: "blocker"}, want: StatusBlocked}, + {name: "canceled", item: ProgressFeedbackMaterial{FeedbackKind: "canceled"}, want: StatusCancelled}, + {name: "blocked fallback", item: ProgressFeedbackMaterial{Blocker: "waiting on access"}, want: StatusBlocked}, + {name: "done fallback", item: ProgressFeedbackMaterial{Result: "validated"}, want: StatusDone}, + {name: "unknown", item: ProgressFeedbackMaterial{Summary: "no lifecycle signal"}, want: ""}, + } { + if got := ProgressIssueStatus(tc.item); got != tc.want { + t.Fatalf("%s: ProgressIssueStatus = %q, want %q", tc.name, got, tc.want) + } + } +} + +func TestProgressRootIssueStatusMapping(t *testing.T) { + if got := ProgressRootIssueStatus(ProgressFeedbackMaterial{FeedbackKind: "progress"}, false); got != StatusInProgress { + t.Fatalf("progress root status = %q", got) + } + if got := ProgressRootIssueStatus(ProgressFeedbackMaterial{FeedbackKind: "blocker"}, false); got != StatusBlocked { + t.Fatalf("blocked root status = %q", got) + } + if got := ProgressRootIssueStatus(ProgressFeedbackMaterial{FeedbackKind: "result"}, false); got != StatusInReview { + t.Fatalf("partial result root status = %q", got) + } + if got := ProgressRootIssueStatus(ProgressFeedbackMaterial{FeedbackKind: "result"}, true); got != StatusDone { + t.Fatalf("complete result root status = %q", got) + } +} diff --git a/harness/internal/surface/multica/registry.go b/harness/internal/surface/multica/registry.go new file mode 100644 index 00000000..e55432d6 --- /dev/null +++ b/harness/internal/surface/multica/registry.go @@ -0,0 +1,144 @@ +package multica + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" +) + +const MulticaDefaultRegistryRelPath = ".mnemon/harness/multica/registry.json" + +type MulticaRegistry struct { + SchemaVersion int `json:"schema_version"` + WorkspaceID string `json:"workspace_id"` + RuntimeProfileID string `json:"runtime_profile_id"` + RuntimeID string `json:"runtime_id"` + Participants []MulticaParticipantRecord `json:"participants"` +} + +type MulticaParticipantRecord struct { + Principal string `json:"principal"` + AgentName string `json:"multica_agent_name"` + AgentID string `json:"multica_agent_id"` + Role string `json:"role"` +} + +func MulticaRegistryPath(root, explicit string) string { + if strings.TrimSpace(explicit) != "" { + return strings.TrimSpace(explicit) + } + root = strings.TrimSpace(root) + if root == "" { + root = "." + } + return filepath.Join(root, MulticaDefaultRegistryRelPath) +} + +func LoadMulticaRegistry(path string) (MulticaRegistry, bool, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return MulticaRegistry{}, false, nil + } + if err != nil { + return MulticaRegistry{}, false, err + } + var reg MulticaRegistry + if err := json.Unmarshal(data, ®); err != nil { + return MulticaRegistry{}, false, err + } + return reg, true, nil +} + +func SaveMulticaRegistry(path string, reg MulticaRegistry) error { + if reg.SchemaVersion == 0 { + reg.SchemaVersion = 1 + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(reg, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(path, data, 0o600) +} + +func DefaultMulticaParticipantRecords(prefix string) []MulticaParticipantRecord { + prefix = strings.TrimSpace(prefix) + if prefix == "" { + prefix = "mnemon" + } + roles := []string{"planner", "researcher", "implementer", "reviewer", "integrator"} + out := make([]MulticaParticipantRecord, 0, len(roles)) + for _, role := range roles { + out = append(out, MulticaParticipantRecord{ + Principal: role + "@team", + AgentName: prefix + "-" + role, + Role: role, + }) + } + return out +} + +func UpsertMulticaParticipantRecord(records []MulticaParticipantRecord, next MulticaParticipantRecord) []MulticaParticipantRecord { + for i := range records { + if records[i].Principal == next.Principal || records[i].AgentName == next.AgentName { + records[i] = next + return records + } + } + return append(records, next) +} + +func MulticaParticipantForPrincipal(reg MulticaRegistry, principal string) (MulticaParticipantRecord, bool) { + principal = strings.TrimSpace(principal) + for _, participant := range reg.Participants { + if strings.TrimSpace(participant.Principal) == principal { + return participant, true + } + } + return MulticaParticipantRecord{}, false +} + +func FirstMulticaParticipantWithAgentID(reg MulticaRegistry) (MulticaParticipantRecord, bool) { + for _, participant := range reg.Participants { + if strings.TrimSpace(participant.AgentID) != "" { + return participant, true + } + } + return MulticaParticipantRecord{}, false +} + +func MulticaPrincipalForAgent(reg MulticaRegistry, agentID, agentName string) string { + agentID = strings.TrimSpace(agentID) + agentName = strings.TrimSpace(agentName) + for _, participant := range reg.Participants { + principal := strings.TrimSpace(participant.Principal) + if principal == "" { + continue + } + if agentID != "" && strings.TrimSpace(participant.AgentID) == agentID { + return principal + } + if agentName != "" && strings.TrimSpace(participant.AgentName) == agentName { + return principal + } + } + return "" +} + +func RuntimeMulticaRegistryPrincipal(env []string, cwd, agentID, agentName string) string { + for _, path := range RuntimeMulticaRegistryPaths(env, cwd) { + reg, ok, err := LoadMulticaRegistry(path) + if err != nil || !ok { + continue + } + if principal := MulticaPrincipalForAgent(reg, agentID, agentName); principal != "" { + return principal + } + } + return "" +} diff --git a/harness/internal/surface/multica/registry_test.go b/harness/internal/surface/multica/registry_test.go new file mode 100644 index 00000000..82aed949 --- /dev/null +++ b/harness/internal/surface/multica/registry_test.go @@ -0,0 +1,106 @@ +package multica + +import ( + "path/filepath" + "testing" +) + +func TestDefaultParticipantRecords(t *testing.T) { + got := DefaultMulticaParticipantRecords("mnemon") + if len(got) != 5 { + t.Fatalf("participants len = %d", len(got)) + } + want := map[string]string{ + "planner@team": "mnemon-planner", + "researcher@team": "mnemon-researcher", + "implementer@team": "mnemon-implementer", + "reviewer@team": "mnemon-reviewer", + "integrator@team": "mnemon-integrator", + } + for _, participant := range got { + if want[participant.Principal] != participant.AgentName { + t.Fatalf("unexpected participant: %+v", participant) + } + } +} + +func TestRegistryRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "registry.json") + _, ok, err := LoadMulticaRegistry(path) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("missing registry should return ok=false") + } + want := MulticaRegistry{ + WorkspaceID: "ws-1", + RuntimeProfileID: "profile-1", + RuntimeID: "runtime-1", + Participants: []MulticaParticipantRecord{{ + Principal: "planner@team", + AgentName: "mnemon-planner", + AgentID: "agent-1", + Role: "planner", + }}, + } + if err := SaveMulticaRegistry(path, want); err != nil { + t.Fatal(err) + } + got, ok, err := LoadMulticaRegistry(path) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("saved registry should exist") + } + if got.SchemaVersion != 1 || got.WorkspaceID != "ws-1" || len(got.Participants) != 1 || got.Participants[0].AgentID != "agent-1" { + t.Fatalf("registry mismatch: %+v", got) + } +} + +func TestMulticaParticipantLookups(t *testing.T) { + reg := MulticaRegistry{Participants: []MulticaParticipantRecord{ + { + Principal: "planner@team", + AgentName: "mnemon-planner", + }, + { + Principal: "reviewer@team", + AgentName: "mnemon-reviewer", + AgentID: "agent-reviewer", + }, + }} + participant, ok := MulticaParticipantForPrincipal(reg, " planner@team ") + if !ok || participant.AgentName != "mnemon-planner" { + t.Fatalf("participant lookup = ok:%v %+v", ok, participant) + } + participant, ok = FirstMulticaParticipantWithAgentID(reg) + if !ok || participant.Principal != "reviewer@team" { + t.Fatalf("first participant with agent id = ok:%v %+v", ok, participant) + } + if got := MulticaPrincipalForAgent(reg, " agent-reviewer ", ""); got != "reviewer@team" { + t.Fatalf("principal by agent id = %q", got) + } + if got := MulticaPrincipalForAgent(reg, "", " mnemon-reviewer "); got != "reviewer@team" { + t.Fatalf("principal by agent name = %q", got) + } +} + +func TestRuntimeMulticaRegistryPrincipalUsesManagedWorkspace(t *testing.T) { + tmp := t.TempDir() + workspace := filepath.Join(tmp, "managed-workspace") + if err := SaveMulticaRegistry(MulticaRegistryPath(workspace, ""), MulticaRegistry{ + Participants: []MulticaParticipantRecord{{ + Principal: "reviewer@team", + AgentName: "mnemon-reviewer", + AgentID: "agent-reviewer", + }}, + }); err != nil { + t.Fatal(err) + } + got := RuntimeMulticaRegistryPrincipal([]string{"MNEMON_MANAGED_WORKSPACE=" + workspace}, tmp, "agent-reviewer", "") + if got != "reviewer@team" { + t.Fatalf("runtime registry principal = %q", got) + } +} diff --git a/harness/internal/surface/multica/runtime_config.go b/harness/internal/surface/multica/runtime_config.go new file mode 100644 index 00000000..fa7c7625 --- /dev/null +++ b/harness/internal/surface/multica/runtime_config.go @@ -0,0 +1,175 @@ +package multica + +import ( + "path/filepath" + "strings" + "time" +) + +const ( + MulticaRuntimeCommandName = "mnemon-multica-runtime" + MulticaRuntimeProfileName = "mnemon-runtime" +) + +type RuntimeManagedWakeMaterial struct { + IssueID string + RootIssueID string + AssignmentID string + SessionID string +} + +func RuntimeEnvValue(env []string, key string) string { + prefix := key + "=" + for i := len(env) - 1; i >= 0; i-- { + item := env[i] + if strings.HasPrefix(item, prefix) { + return strings.TrimSpace(strings.TrimPrefix(item, prefix)) + } + } + return "" +} + +func RuntimeEnvDefault(env []string, key, fallback string) string { + if value := RuntimeEnvValue(env, key); value != "" { + return value + } + return fallback +} + +func RuntimeTimeout(env []string) time.Duration { + return runtimeDuration(env, []string{"MNEMON_MULTICA_RUNTIME_TIMEOUT", "MULTICA_HTTP_TIMEOUT"}, 30*time.Second) +} + +func RuntimeManagedTurnTimeout(env []string) time.Duration { + return runtimeDuration(env, []string{"MNEMON_MANAGED_TURN_TIMEOUT"}, 5*time.Minute) +} + +func RuntimeHubProjectionInterval(env []string) time.Duration { + return runtimeDuration(env, []string{"MNEMON_MULTICA_HUB_PROJECT_INTERVAL"}, 5*time.Second) +} + +func RuntimeManagedLedgerPath(env []string, workspace string) string { + if explicit := RuntimeEnvValue(env, "MNEMON_MANAGED_LEDGER"); explicit != "" { + return explicit + } + root := strings.TrimSpace(workspace) + if root == "" { + root = "." + } + return filepath.Join(root, ".mnemon", "harness", "local", "managed-agent", "wake-ledger.jsonl") +} + +func RuntimeMulticaHubLedgerPath(env []string, cwd string) string { + if explicit := RuntimeEnvValue(env, "MNEMON_MULTICA_HUB_LEDGER"); explicit != "" { + return MulticaHubLedgerPath("", explicit) + } + if workspace := RuntimeEnvValue(env, "MNEMON_MANAGED_WORKSPACE"); workspace != "" { + return MulticaHubLedgerPath(workspace, "") + } + return MulticaHubLedgerPath(cwd, "") +} + +func RuntimeMulticaRegistryPaths(env []string, cwd string) []string { + paths := []string{} + add := func(path string) { + path = strings.TrimSpace(path) + if path == "" { + return + } + for _, existing := range paths { + if existing == path { + return + } + } + paths = append(paths, path) + } + if explicit := RuntimeEnvValue(env, "MNEMON_MULTICA_REGISTRY"); explicit != "" { + add(explicit) + } + if workspace := RuntimeEnvValue(env, "MNEMON_MANAGED_WORKSPACE"); workspace != "" { + add(MulticaRegistryPath(workspace, "")) + } + if strings.TrimSpace(cwd) != "" { + add(MulticaRegistryPath(cwd, "")) + } + return paths +} + +func RuntimeMulticaRegistry(env []string, cwd string) (MulticaRegistry, bool, error) { + for _, path := range RuntimeMulticaRegistryPaths(env, cwd) { + reg, ok, err := LoadMulticaRegistry(path) + if err != nil || ok { + return reg, ok, err + } + } + return MulticaRegistry{}, false, nil +} + +func RuntimeManagedWakeScopeID(material RuntimeManagedWakeMaterial) string { + return firstNonEmptyRuntimeString(material.AssignmentID, material.RootIssueID, material.IssueID) +} + +func RuntimeManagedTurnEnv(env []string, material RuntimeManagedWakeMaterial) []string { + out := append([]string(nil), env...) + add := func(key, value string) { + value = strings.TrimSpace(value) + if value == "" || RuntimeEnvValue(out, key) != "" { + return + } + out = append(out, key+"="+value) + } + add("MNEMON_RENDER_HOST", "multica") + add("MNEMON_RENDER_SESSION_ID", material.SessionID) + add("MNEMON_RENDER_INPUT_ID", RuntimeManagedWakeScopeID(material)) + return out +} + +func RuntimeProjectionCommentsEnabled(env []string) bool { + value := strings.ToLower(RuntimeEnvDefault(env, "MNEMON_MULTICA_PROJECT_COMMENTS", "true")) + switch value { + case "0", "false", "no", "off": + return false + default: + return true + } +} + +func RuntimeHubWriteEnabled(env []string) bool { + value := strings.TrimSpace(RuntimeEnvValue(env, "MNEMON_MULTICA_HUB_WRITE")) + if value == "" { + return true + } + switch strings.ToLower(value) { + case "0", "false", "off", "disabled", "no": + return false + default: + return true + } +} + +func firstNonEmptyRuntimeString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func runtimeDuration(env []string, keys []string, fallback time.Duration) time.Duration { + raw := "" + for _, key := range keys { + raw = RuntimeEnvValue(env, key) + if raw != "" { + break + } + } + if raw == "" { + return fallback + } + d, err := time.ParseDuration(raw) + if err != nil || d <= 0 { + return fallback + } + return d +} diff --git a/harness/internal/surface/multica/runtime_config_test.go b/harness/internal/surface/multica/runtime_config_test.go new file mode 100644 index 00000000..c2f27724 --- /dev/null +++ b/harness/internal/surface/multica/runtime_config_test.go @@ -0,0 +1,220 @@ +package multica + +import ( + "path/filepath" + "testing" + "time" +) + +func TestRuntimeEnvValueUsesLastValue(t *testing.T) { + env := []string{ + "MNEMON_MANAGED_RUNTIME=codex-appserver", + "MNEMON_MANAGED_RUNTIME=off", + } + if got := RuntimeEnvValue(env, "MNEMON_MANAGED_RUNTIME"); got != "off" { + t.Fatalf("RuntimeEnvValue = %q, want off", got) + } + if got := RuntimeEnvDefault(env, "MISSING", "fallback"); got != "fallback" { + t.Fatalf("RuntimeEnvDefault = %q, want fallback", got) + } +} + +func TestRuntimeContextFromActivationNormalizesDaemonAndRuntimeMetadata(t *testing.T) { + ctx := RuntimeContextFromActivation([]string{ + "MULTICA_TASK_ID=task-1", + "MULTICA_ISSUE_ID=iss-env", + "MULTICA_AGENT_ID=agent-1", + "MULTICA_AGENT_NAME=mnemon-planner", + "MULTICA_WORKSPACE_ID=ws-daemon", + "MNEMON_MULTICA_WORKSPACE_ID=ws-mnemon", + "MULTICA_SERVER_URL=https://api.multica.ai", + "MNEMON_MULTICA_SERVER_URL=https://desktop-api.multica.ai", + "MNEMON_HUB_BACKEND=multica", + "MNEMON_CONTROL_ADDR=http://127.0.0.1:8787", + "MNEMON_CONTROL_PRINCIPAL=planner@team", + }, "/repo", RuntimeInput{ + Text: "Ignore copied @TEA-1.", + IssueIdentity: "iss-input", + IssueIdentitySource: RuntimeIssueSourceInput, + }) + if ctx.IssueIdentity != "iss-env" || ctx.IssueIdentitySource != RuntimeIssueSourceEnv { + t.Fatalf("issue identity/source = %q/%q", ctx.IssueIdentity, ctx.IssueIdentitySource) + } + if ctx.TaskID != "task-1" || ctx.AgentID != "agent-1" || ctx.AgentName != "mnemon-planner" { + t.Fatalf("task/agent metadata mismatch: %+v", ctx) + } + if ctx.WorkspaceID != "ws-mnemon" || ctx.ServerURL != "https://desktop-api.multica.ai" { + t.Fatalf("workspace/server metadata mismatch: %+v", ctx) + } + if ctx.HubBackend != "multica" || ctx.ControlAddr != "http://127.0.0.1:8787" || ctx.ControlPrincipal != "planner@team" { + t.Fatalf("Mnemon control metadata mismatch: %+v", ctx) + } +} + +func TestRuntimeContextFromActivationFallsBackToStructuredInput(t *testing.T) { + ctx := RuntimeContextFromActivation(nil, "", RuntimeInput{ + Text: "Please review the linked issue.", + IssueIdentity: "iss-selected", + IssueIdentitySource: RuntimeIssueSourceInput, + }) + if ctx.IssueIdentity != "iss-selected" || ctx.IssueIdentitySource != RuntimeIssueSourceInput { + t.Fatalf("structured input issue mismatch: %+v", ctx) + } +} + +func TestRuntimeContextFromActivationFallsBackToVisibleIssueTag(t *testing.T) { + ctx := RuntimeContextFromActivation(nil, "", RuntimeInput{Text: "Please handle @TEA-50 next."}) + if ctx.IssueIdentity != "TEA-50" || ctx.IssueIdentitySource != RuntimeIssueSourceInputText { + t.Fatalf("visible tag issue mismatch: %+v", ctx) + } +} + +func TestMulticaRuntimeCommandNamePinned(t *testing.T) { + if MulticaRuntimeCommandName != "mnemon-multica-runtime" { + t.Fatalf("MulticaRuntimeCommandName = %q", MulticaRuntimeCommandName) + } + if MulticaRuntimeProfileName != "mnemon-runtime" { + t.Fatalf("MulticaRuntimeProfileName = %q", MulticaRuntimeProfileName) + } +} + +func TestRuntimeTimeoutUsesMulticaHTTPFallback(t *testing.T) { + if got := RuntimeTimeout([]string{"MULTICA_HTTP_TIMEOUT=2m"}); got != 2*time.Minute { + t.Fatalf("RuntimeTimeout fallback = %s, want 2m", got) + } + if got := RuntimeTimeout([]string{"MULTICA_HTTP_TIMEOUT=2m", "MNEMON_MULTICA_RUNTIME_TIMEOUT=15s"}); got != 15*time.Second { + t.Fatalf("RuntimeTimeout override = %s, want 15s", got) + } + if got := RuntimeTimeout([]string{"MNEMON_MULTICA_RUNTIME_TIMEOUT=bad"}); got != 30*time.Second { + t.Fatalf("RuntimeTimeout invalid = %s, want default 30s", got) + } +} + +func TestRuntimeAdapterSwitches(t *testing.T) { + if !RuntimeProjectionCommentsEnabled(nil) || !RuntimeHubWriteEnabled(nil) { + t.Fatal("runtime switches should default on") + } + if RuntimeProjectionCommentsEnabled([]string{"MNEMON_MULTICA_PROJECT_COMMENTS=off"}) { + t.Fatal("projection comments should honor off") + } + if RuntimeHubWriteEnabled([]string{"MNEMON_MULTICA_HUB_WRITE=disabled"}) { + t.Fatal("hub write should honor disabled") + } +} + +func TestRuntimeManagedLedgerPath(t *testing.T) { + tmp := t.TempDir() + workspace := filepath.Join(tmp, "managed-workspace") + want := filepath.Join(workspace, ".mnemon", "harness", "local", "managed-agent", "wake-ledger.jsonl") + if got := RuntimeManagedLedgerPath(nil, workspace); got != want { + t.Fatalf("RuntimeManagedLedgerPath = %q, want %q", got, want) + } + explicit := filepath.Join(tmp, "explicit.jsonl") + if got := RuntimeManagedLedgerPath([]string{"MNEMON_MANAGED_LEDGER=" + explicit}, workspace); got != explicit { + t.Fatalf("explicit RuntimeManagedLedgerPath = %q, want %q", got, explicit) + } +} + +func TestRuntimeMulticaHubLedgerPath(t *testing.T) { + tmp := t.TempDir() + workspace := filepath.Join(tmp, "managed-workspace") + cwd := filepath.Join(tmp, "task-workdir") + got := RuntimeMulticaHubLedgerPath([]string{"MNEMON_MANAGED_WORKSPACE=" + workspace}, cwd) + want := filepath.Join(workspace, MulticaDefaultHubLedgerRelPath) + if got != want { + t.Fatalf("hub ledger path = %q, want %q", got, want) + } + explicit := filepath.Join(tmp, "explicit.jsonl") + got = RuntimeMulticaHubLedgerPath([]string{ + "MNEMON_MANAGED_WORKSPACE=" + workspace, + "MNEMON_MULTICA_HUB_LEDGER=" + explicit, + }, cwd) + if got != explicit { + t.Fatalf("explicit hub ledger path = %q, want %q", got, explicit) + } +} + +func TestRuntimeMulticaRegistryPaths(t *testing.T) { + tmp := t.TempDir() + explicit := filepath.Join(tmp, "explicit-registry.json") + workspace := filepath.Join(tmp, "managed-workspace") + cwd := filepath.Join(tmp, "task-workdir") + got := RuntimeMulticaRegistryPaths([]string{ + "MNEMON_MULTICA_REGISTRY=" + explicit, + "MNEMON_MANAGED_WORKSPACE=" + workspace, + }, cwd) + want := []string{ + explicit, + MulticaRegistryPath(workspace, ""), + MulticaRegistryPath(cwd, ""), + } + if len(got) != len(want) { + t.Fatalf("registry paths len = %d, want %d: %+v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("registry path %d = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestRuntimeMulticaRegistryLoadsManagedWorkspace(t *testing.T) { + tmp := t.TempDir() + workspace := filepath.Join(tmp, "managed-workspace") + path := MulticaRegistryPath(workspace, "") + if err := SaveMulticaRegistry(path, MulticaRegistry{ + WorkspaceID: "ws-1", + Participants: []MulticaParticipantRecord{{ + Principal: "planner@team", + AgentName: "mnemon-planner", + AgentID: "agent-planner", + }}, + }); err != nil { + t.Fatal(err) + } + reg, ok, err := RuntimeMulticaRegistry([]string{"MNEMON_MANAGED_WORKSPACE=" + workspace}, tmp) + if err != nil { + t.Fatal(err) + } + if !ok || reg.WorkspaceID != "ws-1" { + t.Fatalf("registry = ok:%v %+v", ok, reg) + } +} + +func TestRuntimeManagedWakeScopeIDPrefersAssignmentThenRoot(t *testing.T) { + if got := RuntimeManagedWakeScopeID(RuntimeManagedWakeMaterial{ + AssignmentID: "asg-1", + RootIssueID: "root-1", + IssueID: "child-1", + }); got != "asg-1" { + t.Fatalf("assignment mailbox scope = %q, want asg-1", got) + } + if got := RuntimeManagedWakeScopeID(RuntimeManagedWakeMaterial{ + RootIssueID: "root-1", + IssueID: "root-1", + }); got != "root-1" { + t.Fatalf("root session scope = %q, want root-1", got) + } +} + +func TestRuntimeManagedTurnEnvInjectsRenderScope(t *testing.T) { + env := RuntimeManagedTurnEnv([]string{"EXISTING=1"}, RuntimeManagedWakeMaterial{ + SessionID: "multica:session:root-1", + RootIssueID: "root-1", + AssignmentID: "asg-1", + }) + if got := RuntimeEnvValue(env, "MNEMON_RENDER_HOST"); got != "multica" { + t.Fatalf("render host = %q", got) + } + if got := RuntimeEnvValue(env, "MNEMON_RENDER_SESSION_ID"); got != "multica:session:root-1" { + t.Fatalf("render session = %q", got) + } + if got := RuntimeEnvValue(env, "MNEMON_RENDER_INPUT_ID"); got != "asg-1" { + t.Fatalf("render input = %q", got) + } + + preserved := RuntimeManagedTurnEnv([]string{"MNEMON_RENDER_HOST=custom"}, RuntimeManagedWakeMaterial{SessionID: "session-1", RootIssueID: "root-1"}) + if got := RuntimeEnvValue(preserved, "MNEMON_RENDER_HOST"); got != "custom" { + t.Fatalf("managed env should preserve explicit host, got %q", got) + } +} diff --git a/harness/internal/surface/multica/runtime_context.go b/harness/internal/surface/multica/runtime_context.go new file mode 100644 index 00000000..94047a37 --- /dev/null +++ b/harness/internal/surface/multica/runtime_context.go @@ -0,0 +1,60 @@ +package multica + +import "strings" + +const ( + RuntimeIssueSourceEnv = "env:MULTICA_ISSUE_ID" + RuntimeIssueSourceInput = "input:issue" + RuntimeIssueSourceInputText = "input:text" + RuntimeIssueSourceUnresolved = "" +) + +type RuntimeContext struct { + CWD string + Text string + IssueIdentity string + IssueIdentitySource string + TaskID string + AgentID string + AgentName string + WorkspaceID string + ServerURL string + HubBackend string + ControlAddr string + ControlPrincipal string +} + +func RuntimeContextFromActivation(env []string, cwd string, input RuntimeInput) RuntimeContext { + issueIdentity, issueSource := runtimeIssueIdentityFromActivation(env, input) + return RuntimeContext{ + CWD: strings.TrimSpace(cwd), + Text: strings.TrimSpace(input.Text), + IssueIdentity: issueIdentity, + IssueIdentitySource: issueSource, + TaskID: RuntimeEnvValue(env, "MULTICA_TASK_ID"), + AgentID: RuntimeEnvValue(env, "MULTICA_AGENT_ID"), + AgentName: RuntimeEnvValue(env, "MULTICA_AGENT_NAME"), + WorkspaceID: firstNonEmptyRuntimeString(RuntimeEnvValue(env, "MNEMON_MULTICA_WORKSPACE_ID"), RuntimeEnvValue(env, "MULTICA_WORKSPACE_ID")), + ServerURL: firstNonEmptyRuntimeString(RuntimeEnvValue(env, "MNEMON_MULTICA_SERVER_URL"), RuntimeEnvValue(env, "MULTICA_SERVER_URL")), + HubBackend: RuntimeEnvValue(env, "MNEMON_HUB_BACKEND"), + ControlAddr: RuntimeEnvValue(env, "MNEMON_CONTROL_ADDR"), + ControlPrincipal: RuntimeEnvValue(env, "MNEMON_CONTROL_PRINCIPAL"), + } +} + +func runtimeIssueIdentityFromActivation(env []string, input RuntimeInput) (string, string) { + if issue := cleanIssueIdentity(RuntimeEnvValue(env, "MULTICA_ISSUE_ID")); issue != "" { + return issue, RuntimeIssueSourceEnv + } + if issue := cleanIssueIdentity(input.IssueIdentity); issue != "" { + source := strings.TrimSpace(input.IssueIdentitySource) + if source == "" { + source = RuntimeIssueSourceInput + } + return issue, source + } + if issue := ExtractIssueIdentity(input.Text); issue != "" { + return issue, RuntimeIssueSourceInputText + } + return "", RuntimeIssueSourceUnresolved +} diff --git a/harness/internal/surface/multica/runtime_items.go b/harness/internal/surface/multica/runtime_items.go new file mode 100644 index 00000000..59d4f59c --- /dev/null +++ b/harness/internal/surface/multica/runtime_items.go @@ -0,0 +1,394 @@ +package multica + +import ( + "crypto/sha256" + "fmt" + "strings" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/activationtrace" +) + +type RuntimeRPCMessage struct { + Method string + Params map[string]any +} + +type RuntimeCommandExecutionMaterial struct { + Command string + CWD string + Output string + ExitCode int + DurationMs int64 +} + +type RuntimeInput struct { + Text string + IssueIdentity string + IssueIdentitySource string +} + +func RuntimeManagedTraceMessages(threadID, turnID string, event activationtrace.Event, now time.Time) []RuntimeRPCMessage { + switch event.Method { + case "item/started": + item := runtimeManagedTraceItem(event) + if len(item) == 0 { + return nil + } + return []RuntimeRPCMessage{{ + Method: "item/started", + Params: RuntimeItemParams(threadID, turnID, item, "startedAtMs", now.UTC().UnixMilli()), + }} + case "item/completed": + item := runtimeManagedTraceItem(event) + if len(item) == 0 { + return nil + } + return []RuntimeRPCMessage{{ + Method: "item/completed", + Params: RuntimeItemParams(threadID, turnID, item, "completedAtMs", now.UTC().UnixMilli()), + }} + case "item/agentMessage/delta": + text := strings.TrimSpace(event.Text) + if text == "" { + return nil + } + return []RuntimeRPCMessage{runtimeAgentDelta(threadID, turnID, runtimeManagedTraceItemID(event), text)} + default: + return nil + } +} + +func RuntimeCommandExecutionMessages(threadID, turnID, itemID, fallbackCWD string, event RuntimeCommandExecutionMaterial, now time.Time) []RuntimeRPCMessage { + command := strings.TrimSpace(event.Command) + if command == "" { + return nil + } + if strings.TrimSpace(itemID) == "" { + itemID = runtimeTextDigestID("call", command+"\n"+event.Output) + } + cwd := strings.TrimSpace(event.CWD) + if cwd == "" { + cwd = fallbackCWD + } + output := strings.TrimSpace(event.Output) + durationMs := event.DurationMs + if durationMs < 0 { + durationMs = 0 + } + nowMs := now.UTC().UnixMilli() + started := runtimeCommandExecution(itemID, command, cwd, "inProgress", "", nil, nil) + completed := runtimeCommandExecution(itemID, command, cwd, "completed", output, event.ExitCode, durationMs) + return []RuntimeRPCMessage{ + { + Method: "item/started", + Params: RuntimeItemParams(threadID, turnID, started, "startedAtMs", nowMs), + }, + { + Method: "item/completed", + Params: RuntimeItemParams(threadID, turnID, completed, "completedAtMs", nowMs), + }, + } +} + +func RuntimeAgentMessageMessages(threadID, turnID, itemID, text, phase string, now time.Time) []RuntimeRPCMessage { + text = strings.TrimSpace(text) + if text == "" { + return nil + } + phase = strings.TrimSpace(phase) + if phase == "" { + phase = "commentary" + } + if strings.TrimSpace(itemID) == "" { + itemID = runtimeTextDigestID("msg", text) + } + nowMs := now.UTC().UnixMilli() + return []RuntimeRPCMessage{ + { + Method: "item/started", + Params: RuntimeItemParams(threadID, turnID, runtimeAgentMessage(itemID, "", phase), "startedAtMs", nowMs), + }, + runtimeAgentDelta(threadID, turnID, itemID, text), + { + Method: "item/completed", + Params: RuntimeItemParams(threadID, turnID, runtimeAgentMessage(itemID, text, phase), "completedAtMs", nowMs), + }, + } +} + +func RuntimeItemParams(threadID, turnID string, item map[string]any, timeKey string, timestampMs int64) map[string]any { + params := map[string]any{ + "threadId": threadID, + "turnId": turnID, + "item": item, + } + if timeKey != "" && timestampMs > 0 { + params[timeKey] = timestampMs + } + return params +} + +func RuntimeUserMessage(text string) map[string]any { + return map[string]any{ + "type": "userMessage", + "id": runtimeTextDigestID("user", text), + "clientId": nil, + "content": []any{map[string]any{ + "type": "text", + "text": text, + "text_elements": []any{}, + }}, + } +} + +func RuntimeTextInput(params map[string]any) string { + return RuntimeInputMaterial(params).Text +} + +func RuntimeInputMaterial(params map[string]any) RuntimeInput { + input, ok := params["input"].([]any) + if !ok { + return RuntimeInput{} + } + var parts []string + structuredIssue := "" + structuredSource := "" + textIssue := "" + textSource := "" + for _, item := range input { + obj, ok := item.(map[string]any) + if !ok { + continue + } + if structuredIssue == "" { + structuredIssue = runtimeStructuredIssueIdentity(obj) + if structuredIssue != "" { + structuredSource = RuntimeIssueSourceInput + } + } + if text, _ := obj["text"].(string); strings.TrimSpace(text) != "" { + parts = append(parts, text) + if textIssue == "" { + textIssue = ExtractIssueIdentity(text) + if textIssue != "" { + textSource = RuntimeIssueSourceInputText + } + } + } + } + issueIdentity := firstNonEmptyString(structuredIssue, textIssue) + issueSource := "" + switch issueIdentity { + case structuredIssue: + issueSource = structuredSource + case textIssue: + issueSource = textSource + } + return RuntimeInput{ + Text: strings.Join(parts, "\n"), + IssueIdentity: issueIdentity, + IssueIdentitySource: issueSource, + } +} + +func RuntimeRef(kind, id string) string { + kind = strings.TrimSpace(kind) + id = strings.TrimSpace(id) + if kind == "" || id == "" { + return "" + } + return "multica:" + kind + ":" + id +} + +func runtimeStructuredIssueIdentity(raw any) string { + switch value := raw.(type) { + case nil: + return "" + case string: + return ExtractIssueIdentity(value) + case []any: + for _, item := range value { + if issue := runtimeStructuredIssueIdentity(item); issue != "" { + return issue + } + } + case map[string]any: + for _, key := range []string{"issue_id", "issueId", "issueID", "target_issue_id", "targetIssueId"} { + if issue := cleanIssueIdentity(anyString(value[key])); issue != "" { + return issue + } + } + for _, key := range []string{"url", "href", "uri", "ref", "reference"} { + if issue := ExtractIssueIdentity(anyString(value[key])); issue != "" { + return issue + } + } + if runtimeMapLooksLikeIssueRef(value) { + for _, key := range []string{"id", "target_id", "targetId", "resource_id", "resourceId"} { + if issue := cleanIssueIdentity(anyString(value[key])); issue != "" { + return issue + } + } + } + for _, key := range []string{"identifier", "issue_identifier", "issueIdentifier", "label", "tag"} { + if issue := runtimeIssueIdentifierTag(anyString(value[key])); issue != "" { + return issue + } + } + for _, key := range []string{"issue", "target", "resource", "mention", "mentions", "entities", "text_elements", "references", "tags"} { + if issue := runtimeStructuredIssueIdentity(value[key]); issue != "" { + return issue + } + } + for _, item := range value { + switch item.(type) { + case map[string]any, []any: + if issue := runtimeStructuredIssueIdentity(item); issue != "" { + return issue + } + } + } + } + return "" +} + +func runtimeMapLooksLikeIssueRef(value map[string]any) bool { + for _, key := range []string{"type", "kind", "resource", "resource_type", "resourceType", "entity", "entity_type", "entityType", "target_type", "targetType"} { + if strings.Contains(strings.ToLower(anyString(value[key])), "issue") { + return true + } + } + if _, ok := value["issue"]; ok { + return true + } + if issue := runtimeIssueIdentifierTag(anyString(value["identifier"])); issue != "" { + return true + } + return false +} + +func runtimeIssueIdentifierTag(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + if issue := ExtractIssueIdentity(value); issue != "" { + return issue + } + value = strings.TrimLeft(value, "@#") + if value == "" { + return "" + } + return ExtractIssueIdentity("@" + value) +} + +func runtimeManagedTraceItem(event activationtrace.Event) map[string]any { + if len(event.Item) == 0 { + return nil + } + item, _ := cloneRuntimeAny(event.Item).(map[string]any) + if item == nil { + return nil + } + item["id"] = runtimeManagedTraceItemID(event) + if _, ok := item["source"]; !ok { + item["source"] = "managedCodexAppServer" + } + if strings.TrimSpace(event.SourceRuntime) != "" { + item["mnemonSourceRuntime"] = event.SourceRuntime + } + if strings.TrimSpace(event.Principal) != "" { + item["mnemonPrincipal"] = event.Principal + } + if strings.TrimSpace(event.TurnID) != "" { + item["mnemonManagedTurnId"] = event.TurnID + } + return item +} + +func runtimeManagedTraceItemID(event activationtrace.Event) string { + key := firstNonEmptyString(event.ItemID, event.Command, event.Text, event.Kind, event.Method) + return runtimeTextDigestID("managed", event.SourceRuntime+"\n"+event.TurnID+"\n"+key) +} + +func cloneRuntimeAny(value any) any { + switch typed := value.(type) { + case map[string]any: + out := map[string]any{} + for key, item := range typed { + out[key] = cloneRuntimeAny(item) + } + return out + case []any: + out := make([]any, 0, len(typed)) + for _, item := range typed { + out = append(out, cloneRuntimeAny(item)) + } + return out + default: + return typed + } +} + +func runtimeCommandExecution(id, command, cwd, status, output string, exitCode any, durationMs any) map[string]any { + item := map[string]any{ + "type": "commandExecution", + "id": id, + "command": command, + "cwd": cwd, + "processId": "mnemon-runtime", + "source": "mnemonRuntime", + "status": status, + "commandActions": []any{map[string]any{"type": "unknown", "command": command}}, + "aggregatedOutput": nil, + "exitCode": nil, + "durationMs": nil, + } + if status == "completed" { + item["aggregatedOutput"] = output + item["exitCode"] = exitCode + item["durationMs"] = durationMs + } + return item +} + +func runtimeAgentDelta(threadID, turnID, itemID, delta string) RuntimeRPCMessage { + return RuntimeRPCMessage{ + Method: "item/agentMessage/delta", + Params: map[string]any{ + "threadId": threadID, + "turnId": turnID, + "itemId": itemID, + "delta": delta, + }, + } +} + +func runtimeAgentMessage(id, text, phase string) map[string]any { + id = strings.TrimSpace(id) + if id == "" { + id = runtimeTextDigestID("msg", text) + } + phase = strings.TrimSpace(phase) + if phase == "" { + phase = "commentary" + } + return map[string]any{ + "type": "agentMessage", + "id": id, + "text": text, + "phase": phase, + "memoryCitation": nil, + } +} + +func runtimeTextDigestID(prefix, text string) string { + prefix = strings.TrimSpace(prefix) + if prefix == "" { + prefix = "item" + } + sum := sha256.Sum256([]byte(text)) + digest := fmt.Sprintf("%x", sum[:])[:24] + return prefix + "_" + digest +} diff --git a/harness/internal/surface/multica/runtime_items_test.go b/harness/internal/surface/multica/runtime_items_test.go new file mode 100644 index 00000000..cef6f2ed --- /dev/null +++ b/harness/internal/surface/multica/runtime_items_test.go @@ -0,0 +1,232 @@ +package multica + +import ( + "strings" + "testing" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/activationtrace" +) + +func TestRuntimeManagedTraceMessagesRemapItemIDs(t *testing.T) { + now := time.Date(2026, 6, 29, 7, 30, 0, 0, time.UTC) + started := activationtrace.Event{ + SourceRuntime: activationtrace.SourceCodexAppServer, + Principal: "planner@team", + TurnID: "inner-turn", + ItemID: "inner-msg", + Method: "item/started", + Item: map[string]any{ + "type": "agentMessage", + "id": "inner-msg", + "text": "", + "phase": "commentary", + "nested": map[string]any{ + "id": "nested-original", + }, + }, + } + delta := started + delta.Method = "item/agentMessage/delta" + delta.Text = "native progress" + delta.Item = nil + completed := started + completed.Method = "item/completed" + completed.Item = map[string]any{ + "type": "agentMessage", + "id": "inner-msg", + "text": "native progress", + "phase": "commentary", + } + + var messages []RuntimeRPCMessage + for _, event := range []activationtrace.Event{started, delta, completed} { + messages = append(messages, RuntimeManagedTraceMessages("outer-thread", "outer-turn", event, now)...) + } + if len(messages) != 3 { + t.Fatalf("messages = %+v, want 3", messages) + } + + var itemID string + for i, message := range messages { + if message.Params["threadId"] != "outer-thread" || message.Params["turnId"] != "outer-turn" { + t.Fatalf("message %d attached to wrong turn: %+v", i, message) + } + switch message.Method { + case "item/started", "item/completed": + item, _ := message.Params["item"].(map[string]any) + if item["id"] == "inner-msg" { + t.Fatalf("message %d leaked managed runtime item id: %+v", i, message) + } + if item["mnemonManagedTurnId"] != "inner-turn" || item["mnemonPrincipal"] != "planner@team" || item["mnemonSourceRuntime"] != activationtrace.SourceCodexAppServer { + t.Fatalf("message %d missing managed trace metadata: %+v", i, item) + } + if itemID == "" { + itemID, _ = item["id"].(string) + } else if item["id"] != itemID { + t.Fatalf("managed trace item id changed: first=%q got=%q", itemID, item["id"]) + } + case "item/agentMessage/delta": + if message.Params["itemId"] != itemID { + t.Fatalf("delta item id = %q, want %q", message.Params["itemId"], itemID) + } + if message.Params["delta"] != "native progress" { + t.Fatalf("delta params = %+v", message.Params) + } + default: + t.Fatalf("unexpected method %q", message.Method) + } + } + if started.Item["id"] != "inner-msg" { + t.Fatalf("input item mutated: %+v", started.Item) + } +} + +func TestRuntimeCommandExecutionMessagesUseStableRuntimeShape(t *testing.T) { + messages := RuntimeCommandExecutionMessages("thread-1", "turn-1", "", "/workspace", RuntimeCommandExecutionMaterial{ + Command: "multica issue get iss-1", + Output: "Loaded TEA-1", + ExitCode: 0, + DurationMs: -1, + }, time.Date(2026, 6, 29, 7, 31, 0, 0, time.UTC)) + if len(messages) != 2 { + t.Fatalf("messages = %+v, want start+complete", messages) + } + started, _ := messages[0].Params["item"].(map[string]any) + completed, _ := messages[1].Params["item"].(map[string]any) + if messages[0].Method != "item/started" || started["status"] != "inProgress" || started["cwd"] != "/workspace" { + t.Fatalf("unexpected started command item: %+v", messages[0]) + } + if messages[1].Method != "item/completed" || completed["status"] != "completed" || completed["aggregatedOutput"] != "Loaded TEA-1" { + t.Fatalf("unexpected completed command item: %+v", messages[1]) + } + if completed["durationMs"] != int64(0) { + t.Fatalf("durationMs = %+v, want 0", completed["durationMs"]) + } + if id, _ := started["id"].(string); !strings.HasPrefix(id, "call_") || completed["id"] != id { + t.Fatalf("command item id mismatch start=%+v completed=%+v", started["id"], completed["id"]) + } +} + +func TestRuntimeAgentMessageMessagesUsePhaseAndDelta(t *testing.T) { + messages := RuntimeAgentMessageMessages("thread-1", "turn-1", "msg-1", "done", "final_answer", time.Date(2026, 6, 29, 7, 32, 0, 0, time.UTC)) + if len(messages) != 3 { + t.Fatalf("messages = %+v, want start+delta+complete", messages) + } + started, _ := messages[0].Params["item"].(map[string]any) + completed, _ := messages[2].Params["item"].(map[string]any) + if started["phase"] != "final_answer" || completed["phase"] != "final_answer" { + t.Fatalf("phase mismatch start=%+v completed=%+v", started, completed) + } + if messages[1].Method != "item/agentMessage/delta" || messages[1].Params["delta"] != "done" || messages[1].Params["itemId"] != "msg-1" { + t.Fatalf("unexpected delta message: %+v", messages[1]) + } +} + +func TestRuntimeTextInputExtractsOnlyTextItems(t *testing.T) { + got := RuntimeTextInput(map[string]any{ + "input": []any{ + map[string]any{"type": "text", "text": "Open [TEA-1](mention://issue/iss-1)."}, + map[string]any{"type": "image", "url": "ignored"}, + map[string]any{"type": "text", "text": " "}, + map[string]any{"type": "text", "text": "Then summarize."}, + "ignored", + }, + }) + want := "Open [TEA-1](mention://issue/iss-1).\nThen summarize." + if got != want { + t.Fatalf("RuntimeTextInput() = %q, want %q", got, want) + } + if got := RuntimeTextInput(map[string]any{"input": "not-list"}); got != "" { + t.Fatalf("non-list input should be ignored, got %q", got) + } +} + +func TestRuntimeInputMaterialExtractsStructuredIssueIdentity(t *testing.T) { + got := RuntimeInputMaterial(map[string]any{ + "input": []any{ + map[string]any{ + "type": "text", + "id": "item-1", + "text": "Please review the linked issue.", + "text_elements": []any{ + map[string]any{ + "type": "mention", + "target_type": "issue", + "target_id": "iss-49", + "text": "@TEA-49", + }, + }, + }, + }, + }) + if got.Text != "Please review the linked issue." { + t.Fatalf("visible text changed: %+v", got) + } + if got.IssueIdentity != "iss-49" { + t.Fatalf("structured issue identity = %q, want iss-49", got.IssueIdentity) + } + if got.IssueIdentitySource != RuntimeIssueSourceInput { + t.Fatalf("structured issue source = %q, want %q", got.IssueIdentitySource, RuntimeIssueSourceInput) + } +} + +func TestRuntimeInputMaterialFallsBackToVisibleIssueTag(t *testing.T) { + got := RuntimeInputMaterial(map[string]any{ + "input": []any{ + map[string]any{"type": "text", "id": "item-1", "text": "Please handle @TEA-50 next."}, + }, + }) + if got.IssueIdentity != "TEA-50" { + t.Fatalf("visible issue tag identity = %q, want TEA-50", got.IssueIdentity) + } + if got.IssueIdentitySource != RuntimeIssueSourceInputText { + t.Fatalf("visible issue source = %q, want %q", got.IssueIdentitySource, RuntimeIssueSourceInputText) + } +} + +func TestRuntimeInputMaterialPrefersStructuredIssueOverVisibleTag(t *testing.T) { + got := RuntimeInputMaterial(map[string]any{ + "input": []any{ + map[string]any{"type": "text", "text": "Ignore the stale copied tag @TEA-1."}, + map[string]any{ + "type": "text", + "text": "Use the selected Multica issue tag.", + "text_elements": []any{ + map[string]any{ + "type": "mention", + "target_type": "issue", + "target_id": "iss-selected", + "text": "@TEA-99", + }, + }, + }, + }, + }) + if got.IssueIdentity != "iss-selected" { + t.Fatalf("structured issue identity should win over visible tag, got %q", got.IssueIdentity) + } +} + +func TestRuntimeInputMaterialIgnoresNonIssueItemID(t *testing.T) { + got := RuntimeInputMaterial(map[string]any{ + "input": []any{ + map[string]any{"type": "text", "id": "item-1", "text": "Coordinate with @team."}, + }, + }) + if got.IssueIdentity != "" { + t.Fatalf("non-issue item id should be ignored, got %q", got.IssueIdentity) + } +} + +func TestRuntimeRefNormalizesMulticaRefs(t *testing.T) { + if got := RuntimeRef(" issue ", " iss-1 "); got != "multica:issue:iss-1" { + t.Fatalf("RuntimeRef() = %q", got) + } + if got := RuntimeRef("", "iss-1"); got != "" { + t.Fatalf("empty kind should produce no ref, got %q", got) + } + if got := RuntimeRef("issue", ""); got != "" { + t.Fatalf("empty id should produce no ref, got %q", got) + } +} diff --git a/harness/internal/surface/multica/runtime_result.go b/harness/internal/surface/multica/runtime_result.go new file mode 100644 index 00000000..cfd08660 --- /dev/null +++ b/harness/internal/surface/multica/runtime_result.go @@ -0,0 +1,255 @@ +package multica + +import ( + "fmt" + "strings" +) + +type RuntimeResultSummary struct { + IssueID string + Identifier string + Title string + Principal string + Status string + Err string + ProjectionStatus string + ProjectionCommentID string + ProjectionErr string + WakeStatus string + WakeTurnID string + WakeErr string + HubWriteStatus string + HubChildIssues int + HubFeedbackComments int + HubWriteErr string +} + +func FormatRuntimeFinalAnswer(result RuntimeResultSummary) string { + var b strings.Builder + if result.IssueID == "" { + b.WriteString("Mnemon Multica runtime did not receive a Multica issue id.") + } else { + label := strings.TrimSpace(result.Identifier) + if label == "" { + label = result.IssueID + } + b.WriteString("Mnemon Multica runtime handled issue ") + b.WriteString(label) + if title := strings.TrimSpace(result.Title); title != "" { + b.WriteString(" (") + b.WriteString(title) + b.WriteString(")") + } + b.WriteString(".") + } + if principal := strings.TrimSpace(result.Principal); principal != "" { + b.WriteString(" Principal: ") + b.WriteString(principal) + b.WriteString(".") + } + switch result.Status { + case "recorded": + b.WriteString(" Mnemon ingest: recorded.") + case "correlated": + b.WriteString(" Mnemon assignment mailbox: correlated.") + case "skipped": + b.WriteString(" Mnemon ingest: skipped") + if result.Err != "" { + b.WriteString(" (") + b.WriteString(result.Err) + b.WriteString(")") + } else { + b.WriteString(" because MNEMON_CONTROL_ADDR is not set") + } + b.WriteString(".") + case "failed": + b.WriteString(" Mnemon ingest: failed") + if result.Err != "" { + b.WriteString(" (") + b.WriteString(result.Err) + b.WriteString(")") + } + b.WriteString(".") + default: + if result.Err != "" { + b.WriteString(" Mnemon ingest: failed (") + b.WriteString(result.Err) + b.WriteString(").") + } + } + switch result.ProjectionStatus { + case "commented": + b.WriteString(" Multica projection: comment posted.") + case "skipped": + b.WriteString(" Multica projection: skipped.") + case "failed": + b.WriteString(" Multica projection: failed") + if result.ProjectionErr != "" { + b.WriteString(" (") + b.WriteString(result.ProjectionErr) + b.WriteString(")") + } + b.WriteString(".") + } + switch result.WakeStatus { + case "completed": + b.WriteString(" Managed wake: completed.") + case "skipped": + b.WriteString(" Managed wake: skipped") + if result.WakeErr != "" { + b.WriteString(" (") + b.WriteString(result.WakeErr) + b.WriteString(")") + } + b.WriteString(".") + case "failed": + b.WriteString(" Managed wake: failed") + if result.WakeErr != "" { + b.WriteString(" (") + b.WriteString(result.WakeErr) + b.WriteString(")") + } + b.WriteString(".") + default: + if result.WakeStatus != "" { + b.WriteString(" Managed wake: ") + b.WriteString(result.WakeStatus) + b.WriteString(".") + } + } + switch result.HubWriteStatus { + case "created", "commented", "updated", "noop": + b.WriteString(" Multica updates: ") + b.WriteString(RuntimeHubWriteSummary(result)) + case "skipped": + b.WriteString(" Multica updates: skipped.") + case "failed": + b.WriteString(" Multica updates: failed") + if result.HubWriteErr != "" { + b.WriteString(" (") + b.WriteString(result.HubWriteErr) + b.WriteString(")") + } + b.WriteString(".") + } + return strings.TrimSpace(b.String()) +} + +func RuntimePrincipalLabel(principal string) string { + principal = strings.TrimSpace(principal) + if principal == "" { + return "the resolved principal" + } + return principal +} + +func RuntimeIssueLabel(id, identifier, title string) string { + if strings.TrimSpace(identifier) != "" && strings.TrimSpace(title) != "" { + return strings.TrimSpace(identifier) + " (" + strings.TrimSpace(title) + ")" + } + if strings.TrimSpace(identifier) != "" { + return strings.TrimSpace(identifier) + } + if strings.TrimSpace(id) != "" { + return strings.TrimSpace(id) + } + return "the Multica issue" +} + +func RuntimeAssignmentCorrelationProgress() string { + return "Mnemon assignment mailbox: correlated." +} + +func RuntimeWakeProgress(result RuntimeResultSummary) string { + switch strings.TrimSpace(result.WakeStatus) { + case "": + if result.WakeErr != "" { + return "Managed wake failed: " + result.WakeErr + } + return "Managed wake did not run." + case "completed": + if strings.TrimSpace(result.WakeTurnID) != "" { + return "Managed wake completed: turn=" + result.WakeTurnID + "." + } + return "Managed wake completed." + case "skipped": + if result.WakeErr != "" { + return "Managed wake skipped: " + result.WakeErr + } + return "Managed wake skipped." + case "failed": + if result.WakeErr != "" { + return "Managed wake failed: " + result.WakeErr + } + return "Managed wake failed." + default: + return "Managed wake status: " + result.WakeStatus + "." + } +} + +func RuntimeHubWriteProgress(result RuntimeResultSummary) string { + switch strings.TrimSpace(result.HubWriteStatus) { + case "": + return "" + case "failed": + if result.HubWriteErr != "" { + return "Multica updates failed: " + result.HubWriteErr + } + return "Multica updates failed." + case "skipped": + return "Multica updates skipped." + default: + return "Multica updates: " + RuntimeHubWriteSummary(result) + } +} + +func RuntimeHubWriteSummary(result RuntimeResultSummary) string { + status := strings.TrimSpace(result.HubWriteStatus) + var parts []string + if result.HubChildIssues > 0 { + parts = append(parts, fmt.Sprintf("%d %s", result.HubChildIssues, runtimePluralize(result.HubChildIssues, "assignment mailbox", "assignment mailboxes"))) + } + if result.HubFeedbackComments > 0 { + parts = append(parts, fmt.Sprintf("%d %s", result.HubFeedbackComments, runtimePluralize(result.HubFeedbackComments, "feedback comment", "feedback comments"))) + } + switch { + case len(parts) > 0 && status == "created": + return strings.Join(parts, " and ") + " created." + case len(parts) > 0 && status == "commented": + return strings.Join(parts, " and ") + " posted." + case len(parts) > 0: + return strings.Join(parts, " and ") + " synced." + case status == "noop": + return "no visible updates needed." + case status != "": + return strings.ReplaceAll(status, "_", " ") + "." + default: + return "no visible updates needed." + } +} + +func RuntimeProjectionProgress(result RuntimeResultSummary) string { + switch strings.TrimSpace(result.ProjectionStatus) { + case "": + return "" + case "failed": + if result.ProjectionErr != "" { + return "Multica comment projection failed: " + result.ProjectionErr + } + return "Multica comment projection failed." + case "skipped": + return "Multica comment projection skipped." + default: + if strings.TrimSpace(result.ProjectionCommentID) != "" { + return "Multica comment projection completed: comment=" + result.ProjectionCommentID + "." + } + return "Multica comment projection completed." + } +} + +func runtimePluralize(n int, singular, plural string) string { + if n == 1 { + return singular + } + return plural +} diff --git a/harness/internal/surface/multica/runtime_result_test.go b/harness/internal/surface/multica/runtime_result_test.go new file mode 100644 index 00000000..a4ccbbb2 --- /dev/null +++ b/harness/internal/surface/multica/runtime_result_test.go @@ -0,0 +1,84 @@ +package multica + +import ( + "strings" + "testing" +) + +func TestFormatRuntimeFinalAnswerSummarizesRuntimeOutcome(t *testing.T) { + got := FormatRuntimeFinalAnswer(RuntimeResultSummary{ + IssueID: "iss-1", + Identifier: "TEA-1", + Title: "Runtime adapter cleanup", + Principal: "planner@team", + Status: "recorded", + ProjectionStatus: "commented", + WakeStatus: "completed", + HubWriteStatus: "updated", + HubChildIssues: 1, + HubFeedbackComments: 2, + }) + for _, want := range []string{ + "Mnemon Multica runtime handled issue TEA-1 (Runtime adapter cleanup).", + "Principal: planner@team.", + "Mnemon ingest: recorded.", + "Multica projection: comment posted.", + "Managed wake: completed.", + "Multica updates: 1 assignment mailbox and 2 feedback comments synced.", + } { + if !strings.Contains(got, want) { + t.Fatalf("final answer missing %q:\n%s", want, got) + } + } +} + +func TestRuntimeFinalAnswerCarriesFailures(t *testing.T) { + got := FormatRuntimeFinalAnswer(RuntimeResultSummary{ + IssueID: "iss-2", + Status: "failed", + Err: "ingest refused", + ProjectionStatus: "failed", + ProjectionErr: "comment rejected", + WakeStatus: "failed", + WakeErr: "render unavailable", + HubWriteStatus: "failed", + HubWriteErr: "metadata timeout", + }) + for _, want := range []string{ + "Mnemon ingest: failed (ingest refused).", + "Multica projection: failed (comment rejected).", + "Managed wake: failed (render unavailable).", + "Multica updates: failed (metadata timeout).", + } { + if !strings.Contains(got, want) { + t.Fatalf("failure answer missing %q:\n%s", want, got) + } + } +} + +func TestRuntimeProgressSummaries(t *testing.T) { + if got := RuntimeWakeProgress(RuntimeResultSummary{WakeStatus: "completed", WakeTurnID: "turn-1"}); got != "Managed wake completed: turn=turn-1." { + t.Fatalf("wake progress = %q", got) + } + if got := RuntimeHubWriteProgress(RuntimeResultSummary{HubWriteStatus: "commented", HubFeedbackComments: 1}); got != "Multica updates: 1 feedback comment posted." { + t.Fatalf("hub progress = %q", got) + } + if got := RuntimeProjectionProgress(RuntimeResultSummary{ProjectionStatus: "commented", ProjectionCommentID: "comment-1"}); got != "Multica comment projection completed: comment=comment-1." { + t.Fatalf("projection progress = %q", got) + } + if got := RuntimeAssignmentCorrelationProgress(); got != "Mnemon assignment mailbox: correlated." { + t.Fatalf("assignment correlation progress = %q", got) + } +} + +func TestRuntimeLabels(t *testing.T) { + if got := RuntimePrincipalLabel(""); got != "the resolved principal" { + t.Fatalf("empty principal label = %q", got) + } + if got := RuntimeIssueLabel("iss-1", "TEA-1", "Issue title"); got != "TEA-1 (Issue title)" { + t.Fatalf("issue label = %q", got) + } + if got := RuntimeIssueLabel("iss-1", "", ""); got != "iss-1" { + t.Fatalf("fallback issue label = %q", got) + } +} diff --git a/harness/internal/surface/multica/runtime_scope.go b/harness/internal/surface/multica/runtime_scope.go new file mode 100644 index 00000000..0376b963 --- /dev/null +++ b/harness/internal/surface/multica/runtime_scope.go @@ -0,0 +1,93 @@ +package multica + +import "strings" + +type RuntimeScopeMaterial struct { + SessionID string + RootIssueID string + CorrelationID string + TaskID string +} + +func RuntimeExplicitScopeMatches(sessionID, rootIssueID string, material RuntimeScopeMaterial) bool { + if sessionID = strings.TrimSpace(sessionID); sessionID != "" && strings.TrimSpace(material.SessionID) != "" && sessionID != strings.TrimSpace(material.SessionID) { + return false + } + if rootIssueID = strings.TrimSpace(rootIssueID); rootIssueID != "" && strings.TrimSpace(material.RootIssueID) != "" && rootIssueID != strings.TrimSpace(material.RootIssueID) { + return false + } + return true +} + +func RuntimeRefsMatchScope(refs []string, material RuntimeScopeMaterial, extraIssueIDs ...string) bool { + scoped := false + for _, ref := range refs { + isScoped, matches := RuntimeScopeRefMatches(ref, material, extraIssueIDs...) + if !isScoped { + continue + } + scoped = true + if matches { + return true + } + } + return !scoped +} + +func RuntimeScopeRefMatches(ref string, material RuntimeScopeMaterial, extraIssueIDs ...string) (bool, bool) { + ref = strings.TrimSpace(ref) + if ref == "" { + return false, false + } + lower := strings.ToLower(ref) + prefixes := []string{"multica:issue:", "multica:issue/", "mention://issue/", "multica:session:", "multica:session/", "multica:task:", "multica:task/"} + scoped := false + for _, prefix := range prefixes { + if strings.HasPrefix(lower, prefix) { + scoped = true + break + } + } + if !scoped { + return false, false + } + candidates := []string{} + if root := strings.TrimSpace(material.RootIssueID); root != "" { + candidates = append(candidates, + "multica:issue:"+root, + "multica:issue/"+root, + "mention://issue/"+root, + "multica:session:"+root, + "multica:session/"+root, + ) + } + if session := strings.TrimSpace(material.SessionID); session != "" { + candidates = append(candidates, session) + } + if correlation := strings.TrimSpace(material.CorrelationID); correlation != "" { + candidates = append(candidates, correlation) + } + if task := strings.TrimSpace(material.TaskID); task != "" { + candidates = append(candidates, + "multica:task:"+task, + "multica:task/"+task, + ) + } + for _, issueID := range extraIssueIDs { + issueID = strings.TrimSpace(issueID) + if issueID == "" { + continue + } + candidates = append(candidates, + "multica:issue:"+issueID, + "multica:issue/"+issueID, + "mention://issue/"+issueID, + ) + } + for _, candidate := range candidates { + if ref == candidate { + return true, true + } + } + return true, false +} diff --git a/harness/internal/surface/multica/runtime_scope_test.go b/harness/internal/surface/multica/runtime_scope_test.go new file mode 100644 index 00000000..fb739c5c --- /dev/null +++ b/harness/internal/surface/multica/runtime_scope_test.go @@ -0,0 +1,58 @@ +package multica + +import "testing" + +func TestRuntimeExplicitScopeMatchesChecksKnownSessionAndRoot(t *testing.T) { + material := RuntimeScopeMaterial{ + SessionID: "multica:session:root-1", + RootIssueID: "root-1", + } + if !RuntimeExplicitScopeMatches("multica:session:root-1", "root-1", material) { + t.Fatal("matching explicit scope rejected") + } + if RuntimeExplicitScopeMatches("multica:session:other", "root-1", material) { + t.Fatal("mismatched session accepted") + } + if RuntimeExplicitScopeMatches("multica:session:root-1", "other-root", material) { + t.Fatal("mismatched root accepted") + } + if !RuntimeExplicitScopeMatches("", "", material) { + t.Fatal("unscoped item should remain eligible") + } +} + +func TestRuntimeRefsMatchScopeUsesMulticaStructuredRefs(t *testing.T) { + material := RuntimeScopeMaterial{ + SessionID: "multica:session:root-1", + RootIssueID: "root-1", + CorrelationID: "multica:issue:root-1", + TaskID: "task-1", + } + for _, refs := range [][]string{ + {"multica:issue:root-1"}, + {"multica:issue/root-1"}, + {"mention://issue/root-1"}, + {"multica:session:root-1"}, + {"multica:session/root-1"}, + {"multica:task:task-1"}, + {"multica:task/task-1"}, + {"unrelated", "mention://issue/child-1"}, + } { + if !RuntimeRefsMatchScope(refs, material, "child-1") { + t.Fatalf("refs should match current scope: %v", refs) + } + } +} + +func TestRuntimeRefsMatchScopeRejectsOtherMulticaScope(t *testing.T) { + material := RuntimeScopeMaterial{SessionID: "multica:session:root-1", RootIssueID: "root-1"} + if RuntimeRefsMatchScope([]string{"multica:issue:other-root"}, material) { + t.Fatal("other issue scope accepted") + } + if RuntimeRefsMatchScope([]string{"multica:task:other-task"}, material) { + t.Fatal("other task scope accepted") + } + if !RuntimeRefsMatchScope([]string{"local:event:1", "docs/example"}, material) { + t.Fatal("unscoped refs should not filter item out") + } +} diff --git a/harness/internal/surface/multica/runtime_view_item.go b/harness/internal/surface/multica/runtime_view_item.go new file mode 100644 index 00000000..8338ca8f --- /dev/null +++ b/harness/internal/surface/multica/runtime_view_item.go @@ -0,0 +1,250 @@ +package multica + +import ( + "encoding/json" + "strconv" + "strings" + + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + pview "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation/view" +) + +type RuntimeAssignmentItem struct { + ID string + EventID string + IngestSeq int64 + SessionID string + RootIssueID string + Actor string + Assignee string + Scope string + TTL string + SignalRef string + ExpectedWork string + ExpectedFeedback string + Rationale string + ContextRefs []string + EvidenceRefs []string +} + +type RuntimeProgressItem struct { + ID string + EventID string + IngestSeq int64 + SessionID string + RootIssueID string + Actor string + AssignmentRef string + Scope string + FeedbackKind string + Summary string + Result string + Blocker string + ContextRefs []string + ArtifactRefs []string + EvidenceRefs []string +} + +func RuntimeAssignmentViewItem(item map[string]any) RuntimeAssignmentItem { + id := runtimeViewItemFirstString(item, "assignment_id", "id", "declaration_id") + if id == "" { + id = runtimeViewItemString(item, "event_id") + } + return RuntimeAssignmentItem{ + ID: id, + EventID: runtimeViewItemFirstString(item, "event_id", "id", "declaration_id", "assignment_id"), + IngestSeq: runtimeViewItemInt64(item, "ingest_seq"), + SessionID: runtimeViewItemString(item, "session_id"), + RootIssueID: runtimeViewItemString(item, "root_issue_id"), + Actor: runtimeViewItemString(item, "actor"), + Assignee: runtimeViewItemString(item, "assignee"), + Scope: runtimeViewItemString(item, "scope"), + TTL: runtimeViewItemString(item, "ttl"), + SignalRef: runtimeViewItemString(item, "signal_ref"), + ExpectedWork: runtimeViewItemString(item, "expected_work"), + ExpectedFeedback: runtimeViewItemString(item, "expected_feedback"), + Rationale: runtimeViewItemString(item, "rationale"), + ContextRefs: runtimeViewItemStringList(item, "context_refs"), + EvidenceRefs: runtimeViewItemStringList(item, "evidence_refs"), + } +} + +func RuntimeProgressViewItem(item map[string]any) RuntimeProgressItem { + id := runtimeViewItemFirstString(item, "id", "declaration_id", "event_id") + return RuntimeProgressItem{ + ID: id, + EventID: runtimeViewItemFirstString(item, "event_id", "id", "declaration_id"), + IngestSeq: runtimeViewItemInt64(item, "ingest_seq"), + SessionID: runtimeViewItemString(item, "session_id"), + RootIssueID: runtimeViewItemString(item, "root_issue_id"), + Actor: runtimeViewItemString(item, "actor"), + AssignmentRef: runtimeViewItemString(item, "assignment_ref"), + Scope: runtimeViewItemString(item, "scope"), + FeedbackKind: runtimeViewItemString(item, "feedback_kind"), + Summary: runtimeViewItemString(item, "summary"), + Result: runtimeViewItemString(item, "result"), + Blocker: runtimeViewItemString(item, "blocker"), + ContextRefs: runtimeViewItemStringList(item, "context_refs"), + ArtifactRefs: runtimeViewItemStringList(item, "artifact_refs"), + EvidenceRefs: runtimeViewItemStringList(item, "evidence_refs"), + } +} + +func RuntimeViewItems(proj pview.View, kind string) []map[string]any { + var out []map[string]any + for _, content := range proj.Content { + if string(content.Ref.Kind) != kind { + continue + } + for _, field := range []string{"items", "entries", "declarations"} { + if raw, ok := content.Fields[field]; ok { + out = append(out, runtimeViewAnyItems(raw)...) + break + } + } + } + return out +} + +func RuntimeAssignmentMatchesScope(item RuntimeAssignmentItem, scope RuntimeScopeMaterial) bool { + if !RuntimeExplicitScopeMatches(item.SessionID, item.RootIssueID, scope) { + return false + } + refs := append([]string{}, item.ContextRefs...) + refs = append(refs, item.EvidenceRefs...) + return RuntimeRefsMatchScope(refs, scope) +} + +func RuntimeProgressMatchesScope(item RuntimeProgressItem, scope RuntimeScopeMaterial, extraIssueIDs ...string) bool { + if !RuntimeExplicitScopeMatches(item.SessionID, item.RootIssueID, scope) { + return false + } + refs := append([]string{}, item.ContextRefs...) + refs = append(refs, item.EvidenceRefs...) + refs = append(refs, item.ArtifactRefs...) + return RuntimeRefsMatchScope(refs, scope, extraIssueIDs...) +} + +func RuntimeItemAfterRootIngest(ingestSeq, rootIngestSeq int64) bool { + if rootIngestSeq <= 0 || ingestSeq <= 0 { + return true + } + return ingestSeq > rootIngestSeq +} + +func runtimeViewAnyItems(raw any) []map[string]any { + var out []map[string]any + switch v := raw.(type) { + case []any: + for _, item := range v { + if m, ok := item.(map[string]any); ok { + out = append(out, m) + } + } + case []map[string]any: + out = append(out, v...) + } + return out +} + +func runtimeViewItemFirstString(item map[string]any, keys ...string) string { + for _, key := range keys { + if value := runtimeViewItemString(item, key); value != "" { + return value + } + } + return "" +} + +func runtimeViewItemString(item map[string]any, key string) string { + if value, ok := item[key].(string); ok { + return strings.TrimSpace(value) + } + for _, section := range []string{eventmodel.PayloadRuleKey, eventmodel.PayloadNarrativeKey, eventmodel.PayloadRefsKey} { + if m, ok := item[section].(map[string]any); ok { + if value, ok := m[key].(string); ok { + return strings.TrimSpace(value) + } + } + } + return "" +} + +func runtimeViewItemInt64(item map[string]any, key string) int64 { + if value, ok := runtimeViewInt64(item[key]); ok { + return value + } + for _, section := range []string{eventmodel.PayloadRuleKey, eventmodel.PayloadNarrativeKey, eventmodel.PayloadRefsKey} { + if m, ok := item[section].(map[string]any); ok { + if value, ok := runtimeViewInt64(m[key]); ok { + return value + } + } + } + return 0 +} + +func runtimeViewInt64(raw any) (int64, bool) { + switch v := raw.(type) { + case int: + return int64(v), true + case int64: + return v, true + case int32: + return int64(v), true + case float64: + return int64(v), true + case float32: + return int64(v), true + case json.Number: + n, err := v.Int64() + return n, err == nil + case string: + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + return n, err == nil + default: + return 0, false + } +} + +func runtimeViewItemStringList(item map[string]any, key string) []string { + if out := runtimeViewStringList(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 := runtimeViewStringList(m[key]); len(out) > 0 { + return out + } + } + } + return nil +} + +func runtimeViewStringList(raw any) []string { + seen := map[string]bool{} + var out []string + add := func(value string) { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + return + } + seen[value] = true + out = append(out, value) + } + switch v := raw.(type) { + case []string: + for _, item := range v { + add(item) + } + case []any: + for _, item := range v { + if value, ok := item.(string); ok { + add(value) + } + } + case string: + add(v) + } + return out +} diff --git a/harness/internal/surface/multica/runtime_view_item_test.go b/harness/internal/surface/multica/runtime_view_item_test.go new file mode 100644 index 00000000..7d0b7388 --- /dev/null +++ b/harness/internal/surface/multica/runtime_view_item_test.go @@ -0,0 +1,143 @@ +package multica + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + pview "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation/view" +) + +func TestRuntimeAssignmentViewItemReadsStructuredPayloadSections(t *testing.T) { + item := RuntimeAssignmentViewItem(map[string]any{ + "id": "event-1", + "ingest_seq": json.Number("42"), + "actor": "planner@team", + eventmodel.PayloadRuleKey: map[string]any{ + "assignment_id": "asg-1", + "assignee": "worker@team", + "scope": "release/readiness", + "ttl": "30m", + "signal_ref": "sig-1", + "session_id": "multica:session:root-1", + "root_issue_id": "root-1", + }, + eventmodel.PayloadNarrativeKey: map[string]any{ + "expected_work": "Check the release note flow.", + "expected_feedback": "result or blocker", + "rationale": "Validate routing.", + }, + eventmodel.PayloadRefsKey: map[string]any{ + "context_refs": []any{" multica:issue:root-1 ", "multica:issue:root-1"}, + "evidence_refs": "evidence-1", + }, + }) + if item.ID != "asg-1" || item.EventID != "event-1" || item.IngestSeq != 42 { + t.Fatalf("assignment identity mismatch: %+v", item) + } + if item.Assignee != "worker@team" || item.Scope != "release/readiness" || item.TTL != "30m" || item.SignalRef != "sig-1" { + t.Fatalf("assignment rule fields mismatch: %+v", item) + } + if item.ExpectedWork == "" || item.ExpectedFeedback == "" || item.Rationale == "" { + t.Fatalf("assignment narrative fields missing: %+v", item) + } + if !reflect.DeepEqual(item.ContextRefs, []string{"multica:issue:root-1"}) { + t.Fatalf("context refs = %#v", item.ContextRefs) + } + if !reflect.DeepEqual(item.EvidenceRefs, []string{"evidence-1"}) { + t.Fatalf("evidence refs = %#v", item.EvidenceRefs) + } +} + +func TestRuntimeProgressViewItemReadsRefsAndNarrative(t *testing.T) { + item := RuntimeProgressViewItem(map[string]any{ + "event_id": "pg-1", + "ingest_seq": "51", + eventmodel.PayloadRuleKey: map[string]any{ + "assignment_ref": "asg-1", + "feedback_kind": "result", + "scope": "release/readiness", + "session_id": "multica:session:root-1", + "root_issue_id": "root-1", + }, + eventmodel.PayloadNarrativeKey: map[string]any{ + "summary": "Done", + "result": "Validated", + }, + eventmodel.PayloadRefsKey: map[string]any{ + "context_refs": []string{"multica:issue:root-1"}, + "artifact_refs": []any{"artifact-1", "artifact-1"}, + "evidence_refs": []any{"evidence-1"}, + }, + }) + if item.ID != "pg-1" || item.EventID != "pg-1" || item.IngestSeq != 51 { + t.Fatalf("progress identity mismatch: %+v", item) + } + if item.AssignmentRef != "asg-1" || item.FeedbackKind != "result" || item.Summary != "Done" || item.Result != "Validated" { + t.Fatalf("progress fields mismatch: %+v", item) + } + if !reflect.DeepEqual(item.ArtifactRefs, []string{"artifact-1"}) { + t.Fatalf("artifact refs = %#v", item.ArtifactRefs) + } +} + +func TestRuntimeViewItemsSelectsKindAndCompatibleItemFields(t *testing.T) { + view := pview.View{Content: []pview.ResourceContent{ + { + Ref: contract.ResourceRef{Kind: "assignment", ID: "project"}, + Fields: map[string]any{ + "items": []any{ + map[string]any{"id": "asg-items"}, + "ignored", + }, + }, + }, + { + Ref: contract.ResourceRef{Kind: "assignment", ID: "team"}, + Fields: map[string]any{ + "entries": []map[string]any{{"id": "asg-entries"}}, + }, + }, + { + Ref: contract.ResourceRef{Kind: "progress_digest", ID: "project"}, + Fields: map[string]any{ + "declarations": []any{map[string]any{"id": "pg-1"}}, + }, + }, + }} + assignments := RuntimeViewItems(view, "assignment") + if len(assignments) != 2 { + t.Fatalf("assignment items = %d, want 2: %#v", len(assignments), assignments) + } + if assignments[0]["id"] != "asg-items" || assignments[1]["id"] != "asg-entries" { + t.Fatalf("assignment order/content mismatch: %#v", assignments) + } + progress := RuntimeViewItems(view, "progress_digest") + if len(progress) != 1 || progress[0]["id"] != "pg-1" { + t.Fatalf("progress items mismatch: %#v", progress) + } +} + +func TestRuntimeViewItemsMatchScopeAndRootIngest(t *testing.T) { + scope := RuntimeScopeMaterial{SessionID: "multica:session:root-1", RootIssueID: "root-1"} + if !RuntimeAssignmentMatchesScope(RuntimeAssignmentItem{ + SessionID: "multica:session:root-1", + RootIssueID: "root-1", + ContextRefs: []string{"multica:issue:root-1"}, + }, scope) { + t.Fatal("assignment should match current scope") + } + if RuntimeProgressMatchesScope(RuntimeProgressItem{ + ContextRefs: []string{"multica:issue:other-root"}, + }, scope) { + t.Fatal("progress from another scoped issue should not match") + } + if !RuntimeItemAfterRootIngest(11, 10) || RuntimeItemAfterRootIngest(9, 10) { + t.Fatal("root ingest sequence filter mismatch") + } + if !RuntimeItemAfterRootIngest(0, 10) || !RuntimeItemAfterRootIngest(9, 0) { + t.Fatal("unknown sequence should remain eligible") + } +} diff --git a/harness/internal/surface/multica/status.go b/harness/internal/surface/multica/status.go new file mode 100644 index 00000000..cf55e357 --- /dev/null +++ b/harness/internal/surface/multica/status.go @@ -0,0 +1,66 @@ +package multica + +import "strings" + +const ( + StatusBacklog = "backlog" + StatusTodo = "todo" + StatusInProgress = "in_progress" + StatusInReview = "in_review" + StatusDone = "done" + StatusBlocked = "blocked" + StatusCancelled = "cancelled" +) + +func CanonicalIssueStatus(status string) string { + status = normalizeStatusToken(status) + switch status { + case StatusBacklog, StatusTodo, StatusInProgress, StatusInReview, StatusDone, StatusBlocked, StatusCancelled: + return status + case "pending", "queued": + return StatusBacklog + case "open", "wait", "waiting", "to_do": + return StatusTodo + case "active", "started", "working", "progress": + return StatusInProgress + case "review", "reviewing", "ready_for_review": + return StatusInReview + case "complete", "completed", "closed", "resolved": + return StatusDone + case "blocker": + return StatusBlocked + case "abort", "aborted", "cancel", "canceled": + return StatusCancelled + default: + return "" + } +} + +func canonicalProgressStatus(kind string) string { + switch normalizeStatusToken(kind) { + case "blocker", "blocked": + return "blocker" + case "result", "done", "complete", "completed": + return "result" + case "progress", "in_progress", "working", "started": + return "progress" + case "review", "in_review", "reviewing", "ready_for_review": + return "review" + case "wait", "waiting", "todo", "to_do": + return "waiting" + case "cancel", "canceled", "cancelled", "aborted": + return "cancelled" + default: + return "" + } +} + +func normalizeStatusToken(status string) string { + status = strings.ToLower(strings.TrimSpace(status)) + status = strings.ReplaceAll(status, "-", "_") + status = strings.ReplaceAll(status, " ", "_") + for strings.Contains(status, "__") { + status = strings.ReplaceAll(status, "__", "_") + } + return status +}