From f78a39140553aa80f3eab320c9fb99df3341621b Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 15:47:58 +0800 Subject: [PATCH 01/24] feat: add multica issue bridge Introduce a mnemon-harness multica surface backed by the local Multica CLI. The first slice probes local readiness, imports Multica issues as Mnemon teamwork signals, and projects Mnemon updates back to issue comments without changing the core event model. Validation: go test ./harness/internal/driver ./harness/cmd/mnemon-harness; make harness-build; live probe against Multica v0.3.31 desktop profile. --- harness/cmd/mnemon-harness/multica.go | 327 +++++++++++++++++++ harness/cmd/mnemon-harness/multica_test.go | 158 +++++++++ harness/internal/driver/multica.go | 355 +++++++++++++++++++++ harness/internal/driver/multica_test.go | 122 +++++++ 4 files changed, 962 insertions(+) create mode 100644 harness/cmd/mnemon-harness/multica.go create mode 100644 harness/cmd/mnemon-harness/multica_test.go create mode 100644 harness/internal/driver/multica.go create mode 100644 harness/internal/driver/multica_test.go diff --git a/harness/cmd/mnemon-harness/multica.go b/harness/cmd/mnemon-harness/multica.go new file mode 100644 index 00000000..09f10640 --- /dev/null +++ b/harness/cmd/mnemon-harness/multica.go @@ -0,0 +1,327 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "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/spf13/cobra" +) + +var ( + multicaBin string + multicaProfile string + multicaServerURL string + multicaWorkspaceID string + multicaTimeout time.Duration + multicaJSON bool + + multicaIssueID string + multicaIssueJSON string + multicaScope string + multicaTTL string + multicaWhyTeamwork string + multicaEvidenceRefs []string + multicaContextRefs []string + multicaDryRun bool + + multicaLocalAddr string + multicaLocalPrincipal string + multicaLocalToken string + multicaLocalTokenFile string + + multicaCommentContent string + multicaCommentFile string + multicaCommentStdin bool + multicaCommentTitle string + multicaCommentEvents []string +) + +var multicaCmd = &cobra.Command{ + Use: "multica", + Short: "Bridge Multica issues and comments with Local Mnemon", +} + +var multicaProbeCmd = &cobra.Command{ + Use: "probe", + Short: "Report local Multica CLI and service readiness", + RunE: runMulticaProbe, +} + +var multicaImportIssueCmd = &cobra.Command{ + Use: "import-issue", + Short: "Import one Multica issue as a Mnemon teamwork signal", + RunE: runMulticaImportIssue, +} + +var multicaProjectCommentCmd = &cobra.Command{ + Use: "project-comment", + Short: "Write a Mnemon update as a Multica issue comment", + RunE: runMulticaProjectComment, +} + +type multicaProbeReport struct { + Command string `json:"command"` + Profile string `json:"profile,omitempty"` + ServerURL string `json:"server_url,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty"` + Version *driver.MulticaVersion `json:"version,omitempty"` + VersionError string `json:"version_error,omitempty"` + AuthStatus string `json:"auth_status,omitempty"` + AuthError string `json:"auth_error,omitempty"` + DaemonStatus *driver.MulticaDaemonStatus `json:"daemon_status,omitempty"` + DaemonError string `json:"daemon_error,omitempty"` + IntegrationReady bool `json:"integration_ready"` +} + +func runMulticaProbe(cmd *cobra.Command, args []string) error { + cli := multicaCLI() + report := multicaProbeReport{ + Command: multicaCLICommand(cli), + Profile: strings.TrimSpace(multicaProfile), + ServerURL: strings.TrimSpace(multicaServerURL), + WorkspaceID: strings.TrimSpace(multicaWorkspaceID), + } + if version, err := cli.Version(cmd.Context()); err != nil { + report.VersionError = err.Error() + } else { + report.Version = &version + } + if status, err := cli.AuthStatus(cmd.Context()); err != nil { + report.AuthStatus = status + report.AuthError = err.Error() + } else { + report.AuthStatus = status + } + if daemon, err := cli.DaemonStatus(cmd.Context()); err != nil { + report.DaemonStatus = &daemon + report.DaemonError = err.Error() + } else { + report.DaemonStatus = &daemon + } + authenticated := report.AuthStatus != "" && report.AuthError == "" && !strings.Contains(strings.ToLower(report.AuthStatus), "not authenticated") + daemonRunning := report.DaemonStatus != nil && strings.EqualFold(strings.TrimSpace(report.DaemonStatus.Status), "running") + report.IntegrationReady = report.Version != nil && authenticated && daemonRunning + if multicaJSON { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(report) + } + if report.Version != nil { + fmt.Fprintf(cmd.OutOrStdout(), "Multica CLI: %s (%s)\n", report.Version.Version, report.Command) + } else { + fmt.Fprintf(cmd.OutOrStdout(), "Multica CLI: unavailable (%s)\n", report.VersionError) + } + if report.AuthStatus != "" { + fmt.Fprintf(cmd.OutOrStdout(), "Auth: %s\n", report.AuthStatus) + } + if report.DaemonStatus != nil && strings.TrimSpace(report.DaemonStatus.Status) != "" { + fmt.Fprintf(cmd.OutOrStdout(), "Daemon: %s\n", report.DaemonStatus.Status) + } else if report.DaemonError != "" { + fmt.Fprintf(cmd.OutOrStdout(), "Daemon: unavailable (%s)\n", report.DaemonError) + } + if !report.IntegrationReady { + fmt.Fprintln(cmd.OutOrStdout(), "Live Multica writes need an authenticated Multica profile.") + } + return nil +} + +func runMulticaImportIssue(cmd *cobra.Command, args []string) error { + issue, err := loadMulticaIssue(cmd.Context(), cmd.InOrStdin()) + if err != nil { + return err + } + draft, err := driver.BuildMulticaIssueTeamworkSignal(issue, driver.MulticaIssueSignalOptions{ + Scope: multicaScope, + TTL: multicaTTL, + WhyTeamwork: multicaWhyTeamwork, + EvidenceRefs: multicaEvidenceRefs, + ContextRefs: multicaContextRefs, + }) + if err != nil { + return err + } + if multicaDryRun { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(draft) + } + client, principal, err := multicaLocalClient() + if err != nil { + return err + } + rec, err := client.IngestObserve(contract.ActorID(principal), contract.ObservationEnvelope{ + ExternalID: draft.ExternalID, + Event: contract.Event{ + Type: draft.EventType, + Payload: draft.Payload, + }, + }) + if err != nil { + return fmt.Errorf("Local Mnemon observe failed: %w", err) + } + if multicaJSON { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(map[string]any{ + "event_type": draft.EventType, + "external_id": draft.ExternalID, + "receipt": rec, + }) + } + fmt.Fprintf(cmd.OutOrStdout(), "imported issue %s as %s seq=%d dup=%v ticked=%v\n", issue.ID, draft.EventType, rec.Seq, rec.Dup, rec.Ticked) + return nil +} + +func runMulticaProjectComment(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(multicaIssueID) == "" { + return fmt.Errorf("--issue-id is required") + } + content, err := readMulticaTextInput(cmd.InOrStdin(), multicaCommentContent, multicaCommentFile, multicaCommentStdin) + if err != nil { + return err + } + body := driver.FormatMulticaProjectionComment(multicaCommentTitle, content, multicaCommentEvents) + comment, err := multicaCLI().AddIssueComment(cmd.Context(), multicaIssueID, body) + if err != nil { + return err + } + if multicaJSON { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(comment) + } + fmt.Fprintf(cmd.OutOrStdout(), "commented issue %s comment=%s\n", multicaIssueID, comment.ID) + return nil +} + +func loadMulticaIssue(ctx context.Context, stdin io.Reader) (driver.MulticaIssue, error) { + switch { + case strings.TrimSpace(multicaIssueJSON) != "": + if multicaIssueJSON == "-" { + return driver.DecodeMulticaIssue(stdin) + } + f, err := os.Open(multicaIssueJSON) + if err != nil { + return driver.MulticaIssue{}, err + } + defer f.Close() + return driver.DecodeMulticaIssue(f) + case strings.TrimSpace(multicaIssueID) != "": + return multicaCLI().GetIssue(ctx, multicaIssueID) + default: + return driver.MulticaIssue{}, fmt.Errorf("one of --issue-id or --issue-json is required") + } +} + +func multicaLocalClient() (*access.Client, string, error) { + addr := strings.TrimSpace(multicaLocalAddr) + if addr == "" { + return nil, "", fmt.Errorf("--addr is required") + } + token := strings.TrimSpace(multicaLocalToken) + if strings.TrimSpace(multicaLocalTokenFile) != "" { + data, err := os.ReadFile(multicaLocalTokenFile) + if err != nil { + return nil, "", fmt.Errorf("read --token-file: %w", err) + } + token = strings.TrimSpace(string(data)) + } + if token != "" { + return access.NewClientWithToken(addr, token), strings.TrimSpace(multicaLocalPrincipal), nil + } + principal := strings.TrimSpace(multicaLocalPrincipal) + if principal == "" { + return nil, "", fmt.Errorf("--principal is required when --token is not provided") + } + return access.NewClient(addr, contract.ActorID(principal)), principal, nil +} + +func multicaCLI() driver.MulticaCLI { + return driver.MulticaCLI{ + Command: strings.TrimSpace(multicaBin), + Profile: strings.TrimSpace(multicaProfile), + ServerURL: strings.TrimSpace(multicaServerURL), + WorkspaceID: strings.TrimSpace(multicaWorkspaceID), + Timeout: multicaTimeout, + Env: os.Environ(), + } +} + +func multicaCLICommand(cli driver.MulticaCLI) string { + if strings.TrimSpace(cli.Command) != "" { + return strings.TrimSpace(cli.Command) + } + return driver.MulticaDefaultCommand +} + +func readMulticaTextInput(stdin io.Reader, inline, path string, useStdin bool) (string, error) { + sources := 0 + if strings.TrimSpace(inline) != "" { + sources++ + } + if strings.TrimSpace(path) != "" { + sources++ + } + if useStdin { + sources++ + } + if sources != 1 { + return "", fmt.Errorf("exactly one of --content, --content-file, or --content-stdin is required") + } + if strings.TrimSpace(inline) != "" { + return strings.TrimSpace(inline), nil + } + if strings.TrimSpace(path) != "" { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return strings.TrimSpace(string(data)), nil + } + data, err := io.ReadAll(stdin) + if err != nil { + return "", err + } + return strings.TrimSpace(string(data)), nil +} + +func init() { + multicaCmd.PersistentFlags().StringVar(&multicaBin, "multica-bin", envDefault("MNEMON_MULTICA_BIN", ""), "Multica CLI path") + multicaCmd.PersistentFlags().StringVar(&multicaProfile, "multica-profile", envDefault("MNEMON_MULTICA_PROFILE", ""), "Multica CLI profile") + multicaCmd.PersistentFlags().StringVar(&multicaServerURL, "multica-server-url", envDefault("MNEMON_MULTICA_SERVER_URL", ""), "Multica server URL") + multicaCmd.PersistentFlags().StringVar(&multicaWorkspaceID, "multica-workspace-id", envDefault("MNEMON_MULTICA_WORKSPACE_ID", ""), "Multica workspace ID") + multicaCmd.PersistentFlags().DurationVar(&multicaTimeout, "multica-timeout", 30*time.Second, "Multica CLI timeout") + multicaCmd.PersistentFlags().BoolVar(&multicaJSON, "json", false, "emit JSON") + + multicaImportIssueCmd.Flags().StringVar(&multicaIssueID, "issue-id", "", "Multica issue ID") + multicaImportIssueCmd.Flags().StringVar(&multicaIssueJSON, "issue-json", "", "read a Multica issue JSON file, or '-' for stdin") + multicaImportIssueCmd.Flags().StringVar(&multicaScope, "scope", "multica/teamwork", "Mnemon teamwork scope") + multicaImportIssueCmd.Flags().StringVar(&multicaTTL, "ttl", "30m", "Mnemon teamwork signal TTL") + multicaImportIssueCmd.Flags().StringVar(&multicaWhyTeamwork, "why-teamwork", "", "natural-language reason this issue needs teamwork") + multicaImportIssueCmd.Flags().StringArrayVar(&multicaEvidenceRefs, "evidence", nil, "evidence reference; may be repeated") + multicaImportIssueCmd.Flags().StringArrayVar(&multicaContextRefs, "context-ref", nil, "context reference; may be repeated") + multicaImportIssueCmd.Flags().StringVar(&multicaLocalAddr, "addr", envDefault("MNEMON_CONTROL_ADDR", "http://127.0.0.1:8787"), "Local Mnemon URL") + multicaImportIssueCmd.Flags().StringVar(&multicaLocalPrincipal, "principal", envDefault("MNEMON_CONTROL_PRINCIPAL", ""), "local principal") + multicaImportIssueCmd.Flags().StringVar(&multicaLocalToken, "token", envDefault("MNEMON_CONTROL_TOKEN", ""), "Local Mnemon bearer token") + multicaImportIssueCmd.Flags().StringVar(&multicaLocalTokenFile, "token-file", envDefault("MNEMON_CONTROL_TOKEN_FILE", ""), "read Local Mnemon bearer token from a file") + multicaImportIssueCmd.Flags().BoolVar(&multicaDryRun, "dry-run", false, "print the Mnemon observed draft without writing") + + multicaProjectCommentCmd.Flags().StringVar(&multicaIssueID, "issue-id", "", "Multica issue ID") + multicaProjectCommentCmd.Flags().StringVar(&multicaCommentContent, "content", "", "comment body") + multicaProjectCommentCmd.Flags().StringVar(&multicaCommentFile, "content-file", "", "read comment body from a file") + multicaProjectCommentCmd.Flags().BoolVar(&multicaCommentStdin, "content-stdin", false, "read comment body from stdin") + multicaProjectCommentCmd.Flags().StringVar(&multicaCommentTitle, "title", "", "short Mnemon update title") + multicaProjectCommentCmd.Flags().StringArrayVar(&multicaCommentEvents, "event", nil, "Mnemon event marker; may be repeated") + + multicaCmd.AddCommand(multicaProbeCmd, multicaImportIssueCmd, multicaProjectCommentCmd) + multicaCmd.GroupID = groupSpine + rootCmd.AddCommand(multicaCmd) +} diff --git a/harness/cmd/mnemon-harness/multica_test.go b/harness/cmd/mnemon-harness/multica_test.go new file mode 100644 index 00000000..b108730c --- /dev/null +++ b/harness/cmd/mnemon-harness/multica_test.go @@ -0,0 +1,158 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" +) + +func TestMulticaImportIssueWritesObservedDraftToLocalIngest(t *testing.T) { + restoreMulticaFlags(t) + + issuePath := filepath.Join(t.TempDir(), "issue.json") + if err := os.WriteFile(issuePath, []byte(`{ + "id": "iss-7", + "identifier": "MUL-7", + "title": "Coordinate adapter validation", + "description": "Validate that a Multica issue can be handed to local teamwork without leaking rule ids into narrative." + }`), 0o600); err != nil { + t.Fatal(err) + } + + var got contract.ObservationEnvelope + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/ingest" { + t.Fatalf("path = %q", r.URL.Path) + } + if r.Header.Get("X-Mnemon-Principal") != "codex@project" { + t.Fatalf("principal header = %q", r.Header.Get("X-Mnemon-Principal")) + } + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatal(err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"seq": 7, "dup": false, "ticked": true}) + })) + defer srv.Close() + + multicaIssueJSON = issuePath + multicaScope = "multica/poc" + multicaTTL = "45m" + multicaWhyTeamwork = "Adapter validation needs multiple local agents." + multicaContextRefs = []string{"multica:workspace:test"} + multicaEvidenceRefs = []string{"multica:issue:iss-7"} + multicaLocalAddr = srv.URL + multicaLocalPrincipal = "codex@project" + multicaJSON = true + + var out bytes.Buffer + multicaImportIssueCmd.SetOut(&out) + t.Cleanup(func() { + multicaImportIssueCmd.SetOut(os.Stdout) + }) + if err := runMulticaImportIssue(multicaImportIssueCmd, nil); err != nil { + t.Fatalf("import issue: %v", err) + } + + if got.ExternalID != "multica-issue-iss-7" { + t.Fatalf("external id = %q", got.ExternalID) + } + 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["external_source"] != "multica" || rule["external_issue_id"] != "iss-7" || rule["ttl"] != "45m" { + t.Fatalf("rule payload mismatch: %+v", rule) + } + narrative, _ := got.Event.Payload["narrative"].(map[string]any) + if narrative["title"] != "Coordinate adapter validation" { + t.Fatalf("narrative title mismatch: %+v", narrative) + } + if _, ok := narrative["external_issue_id"]; ok { + t.Fatalf("narrative must not carry rule ids: %+v", narrative) + } + refs, _ := got.Event.Payload["refs"].(map[string]any) + if refs == nil || len(refs) == 0 { + t.Fatalf("refs payload missing: %+v", got.Event.Payload) + } +} + +func restoreMulticaFlags(t *testing.T) { + t.Helper() + oldBin := multicaBin + oldProfile := multicaProfile + oldServerURL := multicaServerURL + oldWorkspaceID := multicaWorkspaceID + oldTimeout := multicaTimeout + oldJSON := multicaJSON + oldIssueID := multicaIssueID + oldIssueJSON := multicaIssueJSON + oldScope := multicaScope + oldTTL := multicaTTL + oldWhy := multicaWhyTeamwork + oldEvidence := multicaEvidenceRefs + oldContext := multicaContextRefs + oldDryRun := multicaDryRun + oldAddr := multicaLocalAddr + oldPrincipal := multicaLocalPrincipal + oldToken := multicaLocalToken + oldTokenFile := multicaLocalTokenFile + oldContent := multicaCommentContent + oldFile := multicaCommentFile + oldStdin := multicaCommentStdin + oldTitle := multicaCommentTitle + oldEvents := multicaCommentEvents + t.Cleanup(func() { + multicaBin = oldBin + multicaProfile = oldProfile + multicaServerURL = oldServerURL + multicaWorkspaceID = oldWorkspaceID + multicaTimeout = oldTimeout + multicaJSON = oldJSON + multicaIssueID = oldIssueID + multicaIssueJSON = oldIssueJSON + multicaScope = oldScope + multicaTTL = oldTTL + multicaWhyTeamwork = oldWhy + multicaEvidenceRefs = oldEvidence + multicaContextRefs = oldContext + multicaDryRun = oldDryRun + multicaLocalAddr = oldAddr + multicaLocalPrincipal = oldPrincipal + multicaLocalToken = oldToken + multicaLocalTokenFile = oldTokenFile + multicaCommentContent = oldContent + multicaCommentFile = oldFile + multicaCommentStdin = oldStdin + multicaCommentTitle = oldTitle + multicaCommentEvents = oldEvents + }) + multicaBin = "" + multicaProfile = "" + multicaServerURL = "" + multicaWorkspaceID = "" + multicaJSON = false + multicaIssueID = "" + multicaIssueJSON = "" + multicaScope = "multica/teamwork" + multicaTTL = "30m" + multicaWhyTeamwork = "" + multicaEvidenceRefs = nil + multicaContextRefs = nil + multicaDryRun = false + multicaLocalAddr = "http://127.0.0.1:8787" + multicaLocalPrincipal = "" + multicaLocalToken = "" + multicaLocalTokenFile = "" + multicaCommentContent = "" + multicaCommentFile = "" + multicaCommentStdin = false + multicaCommentTitle = "" + multicaCommentEvents = nil +} diff --git a/harness/internal/driver/multica.go b/harness/internal/driver/multica.go new file mode 100644 index 00000000..b64cc75f --- /dev/null +++ b/harness/internal/driver/multica.go @@ -0,0 +1,355 @@ +package driver + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os/exec" + "strings" + "time" + + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" +) + +const ( + MulticaDefaultCommand = "multica" + MulticaExternalSource = "multica" +) + +type MulticaCLI struct { + Command string + Profile string + ServerURL string + WorkspaceID string + Env []string + Timeout time.Duration +} + +type MulticaVersion struct { + Version string `json:"version"` + Commit string `json:"commit"` + Date string `json:"date"` + OS string `json:"os"` + Arch string `json:"arch"` + Go string `json:"go"` +} + +type MulticaDaemonStatus struct { + Status string `json:"status"` + PID int `json:"pid,omitempty"` + DaemonID string `json:"daemon_id,omitempty"` + DeviceName string `json:"device_name,omitempty"` + ServerURL string `json:"server_url,omitempty"` + Agents []string `json:"agents,omitempty"` + Profile string `json:"profile,omitempty"` + Raw map[string]any `json:"raw,omitempty"` + RawText string `json:"raw_text,omitempty"` + CommandError string `json:"command_error,omitempty"` +} + +type MulticaIssue struct { + ID string `json:"id"` + Identifier string `json:"identifier"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + Priority string `json:"priority"` + Metadata map[string]any `json:"metadata"` +} + +type MulticaComment struct { + ID string `json:"id"` + IssueID string `json:"issue_id"` + Content string `json:"content"` + Type string `json:"type"` +} + +type MulticaCommandResult struct { + Stdout string + Stderr string +} + +type MulticaCommandError struct { + Args []string + Stdout string + Stderr string + Err error +} + +func (e MulticaCommandError) Error() string { + detail := strings.TrimSpace(e.Stderr) + if detail == "" { + detail = strings.TrimSpace(e.Stdout) + } + if detail == "" { + detail = e.Err.Error() + } + return fmt.Sprintf("multica %s failed: %s", strings.Join(e.Args, " "), detail) +} + +func (e MulticaCommandError) Unwrap() error { + return e.Err +} + +func (c MulticaCLI) Version(ctx context.Context) (MulticaVersion, error) { + out, err := c.Run(ctx, []string{"version", "--output", "json"}, "") + if err != nil { + return MulticaVersion{}, err + } + var version MulticaVersion + if err := json.Unmarshal([]byte(out.Stdout), &version); err != nil { + return MulticaVersion{}, fmt.Errorf("decode multica version: %w", err) + } + return version, nil +} + +func (c MulticaCLI) AuthStatus(ctx context.Context) (string, error) { + out, err := c.Run(ctx, []string{"auth", "status"}, "") + if err != nil { + return strings.TrimSpace(out.Stdout + out.Stderr), err + } + status := strings.TrimSpace(out.Stdout) + if status == "" { + status = strings.TrimSpace(out.Stderr) + } + return status, nil +} + +func (c MulticaCLI) DaemonStatus(ctx context.Context) (MulticaDaemonStatus, error) { + out, err := c.Run(ctx, []string{"daemon", "status", "--output", "json"}, "") + status := MulticaDaemonStatus{RawText: strings.TrimSpace(out.Stdout)} + if strings.TrimSpace(out.Stdout) != "" { + var raw map[string]any + if decodeErr := json.Unmarshal([]byte(out.Stdout), &raw); decodeErr == nil { + status.Raw = raw + status.Status, _ = raw["status"].(string) + status.DaemonID, _ = raw["daemon_id"].(string) + status.DeviceName, _ = raw["device_name"].(string) + status.ServerURL, _ = raw["server_url"].(string) + status.Profile, _ = raw["profile"].(string) + if pid, ok := raw["pid"].(float64); ok { + status.PID = int(pid) + } + if agents, ok := raw["agents"].([]any); ok { + for _, agent := range agents { + if s, ok := agent.(string); ok { + status.Agents = append(status.Agents, s) + } + } + } + } + } + if err != nil { + status.CommandError = err.Error() + return status, err + } + return status, nil +} + +func (c MulticaCLI) GetIssue(ctx context.Context, issueID string) (MulticaIssue, error) { + issueID = strings.TrimSpace(issueID) + if issueID == "" { + return MulticaIssue{}, fmt.Errorf("multica issue id is required") + } + out, err := c.Run(ctx, []string{"issue", "get", issueID, "--output", "json"}, "") + if err != nil { + return MulticaIssue{}, err + } + var issue MulticaIssue + if err := json.Unmarshal([]byte(out.Stdout), &issue); err != nil { + return MulticaIssue{}, fmt.Errorf("decode multica issue: %w", err) + } + return issue, nil +} + +func (c MulticaCLI) AddIssueComment(ctx context.Context, issueID, content string) (MulticaComment, error) { + issueID = strings.TrimSpace(issueID) + content = strings.TrimSpace(content) + if issueID == "" { + return MulticaComment{}, fmt.Errorf("multica issue id is required") + } + if content == "" { + return MulticaComment{}, fmt.Errorf("multica comment content is required") + } + out, err := c.Run(ctx, []string{"issue", "comment", "add", issueID, "--content-stdin", "--output", "json"}, content) + if err != nil { + return MulticaComment{}, err + } + var comment MulticaComment + if err := json.Unmarshal([]byte(out.Stdout), &comment); err != nil { + return MulticaComment{}, fmt.Errorf("decode multica comment: %w", err) + } + return comment, nil +} + +func (c MulticaCLI) Run(ctx context.Context, args []string, stdin string) (MulticaCommandResult, error) { + command := strings.TrimSpace(c.Command) + if command == "" { + command = MulticaDefaultCommand + } + fullArgs := c.globalArgs(args) + timeout := c.Timeout + if timeout <= 0 { + timeout = 30 * time.Second + } + runCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + cmd := exec.CommandContext(runCtx, command, fullArgs...) + if len(c.Env) > 0 { + cmd.Env = append([]string(nil), c.Env...) + } + if stdin != "" { + cmd.Stdin = strings.NewReader(stdin) + } + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + result := MulticaCommandResult{Stdout: stdout.String(), Stderr: stderr.String()} + if errors.Is(runCtx.Err(), context.DeadlineExceeded) { + return result, MulticaCommandError{Args: fullArgs, Stdout: result.Stdout, Stderr: result.Stderr, Err: runCtx.Err()} + } + if err != nil { + return result, MulticaCommandError{Args: fullArgs, Stdout: result.Stdout, Stderr: result.Stderr, Err: err} + } + return result, nil +} + +func (c MulticaCLI) globalArgs(args []string) []string { + var out []string + if strings.TrimSpace(c.Profile) != "" { + out = append(out, "--profile", strings.TrimSpace(c.Profile)) + } + if strings.TrimSpace(c.ServerURL) != "" { + out = append(out, "--server-url", strings.TrimSpace(c.ServerURL)) + } + if strings.TrimSpace(c.WorkspaceID) != "" { + out = append(out, "--workspace-id", strings.TrimSpace(c.WorkspaceID)) + } + return append(out, args...) +} + +type MulticaIssueSignalOptions struct { + Scope string + TTL string + WhyTeamwork string + EvidenceRefs []string + ContextRefs []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, + } + 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} + } + return MulticaObservedDraft{ + EventType: "teamwork_signal.write_candidate.observed", + ExternalID: "multica-issue-" + issue.ID, + 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 { + return MulticaIssue{}, err + } + return issue, 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 +} diff --git a/harness/internal/driver/multica_test.go b/harness/internal/driver/multica_test.go new file mode 100644 index 00000000..c8579bcf --- /dev/null +++ b/harness/internal/driver/multica_test.go @@ -0,0 +1,122 @@ +package driver + +import ( + "context" + "os" + "path/filepath" + "strings" + "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.", + EvidenceRefs: []string{"multica:issue/iss-123"}, + }) + 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) + } + 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) + } +} + +func TestMulticaCLIAddCommentUsesStdin(t *testing.T) { + tmp := t.TempDir() + 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" +printf '{"id":"comment-1","issue_id":"iss-1","content":"ok","type":"comment"}\n' +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + cli := MulticaCLI{ + Command: bin, + Profile: "desktop-api.multica.ai", + Env: append(os.Environ(), + "MULTICA_ARGS_PATH="+argsPath, + "MULTICA_STDIN_PATH="+stdinPath, + ), + } + comment, err := cli.AddIssueComment(context.Background(), "iss-1", "progress with sensitive details") + if err != nil { + t.Fatal(err) + } + if comment.ID != "comment-1" { + t.Fatalf("comment = %+v", comment) + } + args, err := os.ReadFile(argsPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(args), "--profile desktop-api.multica.ai issue comment add iss-1 --content-stdin --output json") { + t.Fatalf("args did not use expected CLI shape: %q", string(args)) + } + if strings.Contains(string(args), "sensitive details") { + t.Fatalf("comment content must not be passed as an argument: %q", string(args)) + } + stdin, err := os.ReadFile(stdinPath) + if err != nil { + t.Fatal(err) + } + if string(stdin) != "progress with sensitive details" { + t.Fatalf("stdin = %q", string(stdin)) + } +} + +func TestMulticaCLIAuthStatusFallsBackToStderr(t *testing.T) { + tmp := t.TempDir() + bin := filepath.Join(tmp, "multica") + script := `#!/usr/bin/env sh +printf 'Server: https://api.multica.ai\nUser: Test User\nToken: mul_test...\n' >&2 +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + status, err := (MulticaCLI{Command: bin}).AuthStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(status, "Test User") { + t.Fatalf("auth status did not include stderr content: %q", status) + } +} + +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) + } +} From 2b525648e08dcb6ec595c5feaf129234bb392f5b Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 16:29:20 +0800 Subject: [PATCH 02/24] test: add multica runtime protocol probe Add a codex-family app-server probe command for Multica runtime integration. It records argv/env/stdin JSON-RPC frames with secret redaction and responds to the minimal initialize, thread/start, thread/name/set, and turn/start flow used by Multica's codex backend. Validation: go test ./harness/cmd/mnemon-multica-runtime-probe ./harness/internal/driver ./harness/cmd/mnemon-harness. Live probe against Multica v0.3.31 confirmed custom codex profile registration, command invocation as 'app-server --listen stdio://', task-scoped MULTICA_* env injection, and completed run message delivery. --- .../cmd/mnemon-multica-runtime-probe/main.go | 463 ++++++++++++++++++ .../mnemon-multica-runtime-probe/main_test.go | 101 ++++ 2 files changed, 564 insertions(+) create mode 100644 harness/cmd/mnemon-multica-runtime-probe/main.go create mode 100644 harness/cmd/mnemon-multica-runtime-probe/main_test.go diff --git a/harness/cmd/mnemon-multica-runtime-probe/main.go b/harness/cmd/mnemon-multica-runtime-probe/main.go new file mode 100644 index 00000000..3360b836 --- /dev/null +++ b/harness/cmd/mnemon-multica-runtime-probe/main.go @@ -0,0 +1,463 @@ +package main + +import ( + "bufio" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +const probeVersion = "dev" + +type probeConfig struct { + Args []string + Env []string + CWD string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + Now func() time.Time +} + +type probeRecorder struct { + path string + now func() time.Time +} + +type probeEvent struct { + TS string `json:"ts"` + Kind string `json:"kind"` + Args []string `json:"args,omitempty"` + CWD string `json:"cwd,omitempty"` + Env map[string]any `json:"env,omitempty"` + Line string `json:"line,omitempty"` + Method string `json:"method,omitempty"` + ID any `json:"id,omitempty"` + Params map[string]any `json:"params,omitempty"` + Error string `json:"error,omitempty"` +} + +type rpcMessage struct { + JSONRPC string `json:"jsonrpc,omitempty"` + ID any `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params map[string]any `json:"params,omitempty"` + Result any `json:"result,omitempty"` + Error any `json:"error,omitempty"` +} + +func main() { + if err := runProbe(probeConfig{ + Args: os.Args[1:], + Env: os.Environ(), + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + Now: time.Now, + }); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func runProbe(cfg probeConfig) error { + if cfg.Stdin == nil { + cfg.Stdin = strings.NewReader("") + } + if cfg.Stdout == nil { + cfg.Stdout = io.Discard + } + if cfg.Stderr == nil { + cfg.Stderr = io.Discard + } + if cfg.Now == nil { + cfg.Now = time.Now + } + cwd := strings.TrimSpace(cfg.CWD) + if cwd == "" { + var err error + cwd, err = os.Getwd() + if err != nil { + return err + } + } + rec, err := newProbeRecorder(cfg.Env, cwd, cfg.Now) + if err != nil { + return err + } + if err := rec.record(probeEvent{ + Kind: "process_start", + Args: append([]string(nil), cfg.Args...), + CWD: cwd, + Env: redactProbeEnv(cfg.Env), + }); err != nil { + return err + } + if wantsVersion(cfg.Args) { + fmt.Fprintf(cfg.Stdout, "mnemon-multica-runtime-probe %s\n", probeVersion) + return rec.record(probeEvent{Kind: "version"}) + } + return runProbeRPC(cfg, rec, cwd) +} + +func runProbeRPC(cfg probeConfig, rec *probeRecorder, cwd string) error { + scanner := bufio.NewScanner(cfg.Stdin) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + state := probeRPCState{CWD: cwd, Now: cfg.Now} + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + if err := rec.record(probeEvent{Kind: "rpc_line", Line: line}); err != nil { + return err + } + var msg rpcMessage + if err := json.Unmarshal([]byte(line), &msg); err != nil { + _ = rec.record(probeEvent{Kind: "rpc_decode_error", Line: line, Error: err.Error()}) + continue + } + if err := rec.record(probeEvent{Kind: "rpc_request", ID: msg.ID, Method: msg.Method, Params: msg.Params}); err != nil { + return err + } + responses := state.handle(msg) + for _, response := range responses { + if err := writeProbeRPC(cfg.Stdout, response); err != nil { + return err + } + if err := rec.record(probeEvent{Kind: "rpc_response", ID: response.ID, Method: response.Method}); err != nil { + return err + } + } + } + if err := scanner.Err(); err != nil { + _ = rec.record(probeEvent{Kind: "stdin_error", Error: err.Error()}) + return err + } + return rec.record(probeEvent{Kind: "process_exit"}) +} + +type probeRPCState struct { + CWD string + ThreadID string + TurnID string + Now func() time.Time +} + +func (s *probeRPCState) handle(msg rpcMessage) []rpcMessage { + switch msg.Method { + case "initialize": + return []rpcMessage{{ + ID: msg.ID, + Result: map[string]any{ + "userAgent": "mnemon-multica-runtime-probe/" + probeVersion, + "codexHome": os.Getenv("CODEX_HOME"), + "platformFamily": "unix", + "platformOs": runtime.GOOS, + }, + }} + case "thread/start", "thread/resume": + s.ThreadID = firstNonEmpty(stringParam(msg.Params, "threadId"), probeID("thread", s.now())) + return []rpcMessage{ + { + Method: "remoteControl/status/changed", + Params: map[string]any{ + "status": "disabled", + "serverName": "mnemon-multica-runtime-probe", + "installationId": "mnemon-probe", + }, + }, + { + ID: msg.ID, + Result: s.threadStartResult(msg.Params), + }, + { + Method: "thread/started", + Params: map[string]any{ + "thread": s.threadObject(), + }, + }, + } + case "thread/name/set", "session/set_model": + return []rpcMessage{{ID: msg.ID, Result: map[string]any{}}} + case "turn/start": + s.TurnID = probeID("turn", s.now()) + input := extractProbeInput(msg.Params) + return []rpcMessage{ + { + ID: msg.ID, + Result: map[string]any{ + "turn": s.turnObject("inProgress"), + }, + }, + { + Method: "thread/status/changed", + Params: map[string]any{"threadId": s.ThreadID, "status": map[string]any{"type": "active", "activeFlags": []any{}}}, + }, + { + Method: "turn/started", + Params: map[string]any{"threadId": s.ThreadID, "turn": s.turnObject("inProgress")}, + }, + { + Method: "item/started", + Params: map[string]any{"threadId": s.ThreadID, "turnId": s.TurnID, "item": probeAgentMessage("")}, + }, + { + Method: "item/agentMessage/delta", + Params: map[string]any{ + "threadId": s.ThreadID, + "turnId": s.TurnID, + "itemId": "mnemon-probe-message", + "delta": probeFinalAnswer(input), + }, + }, + { + Method: "item/completed", + Params: map[string]any{"threadId": s.ThreadID, "turnId": s.TurnID, "item": probeAgentMessage(probeFinalAnswer(input))}, + }, + { + Method: "thread/status/changed", + Params: map[string]any{"threadId": s.ThreadID, "status": map[string]any{"type": "idle"}}, + }, + { + Method: "turn/completed", + Params: map[string]any{"threadId": s.ThreadID, "turn": s.turnObject("completed")}, + }, + } + default: + if msg.ID == nil { + return nil + } + return []rpcMessage{{ID: msg.ID, Result: map[string]any{}}} + } +} + +func (s *probeRPCState) threadStartResult(params map[string]any) map[string]any { + if cwd := stringParam(params, "cwd"); cwd != "" { + s.CWD = cwd + } + return map[string]any{ + "thread": s.threadObject(), + "model": "mnemon-probe", + "modelProvider": "mnemon", + "cwd": s.CWD, + "runtimeWorkspaceRoots": []string{s.CWD}, + "instructionSources": []string{}, + "approvalPolicy": "never", + "sandbox": map[string]any{"type": "dangerFullAccess"}, + "reasoningEffort": "", + "multiAgentMode": "explicitRequestOnly", + } +} + +func (s *probeRPCState) threadObject() map[string]any { + return map[string]any{ + "id": s.ThreadID, + "sessionId": s.ThreadID, + "ephemeral": true, + "modelProvider": "mnemon", + "createdAt": s.now().Unix(), + "updatedAt": s.now().Unix(), + "recencyAt": s.now().Unix(), + "status": map[string]any{"type": "idle"}, + "cwd": s.CWD, + "cliVersion": probeVersion, + "source": "multica", + "turns": []any{}, + } +} + +func (s *probeRPCState) turnObject(status string) map[string]any { + turn := map[string]any{ + "id": s.TurnID, + "items": []any{}, + "itemsView": "notLoaded", + "status": status, + "error": nil, + "startedAt": s.now().Unix(), + } + if status == "completed" { + turn["completedAt"] = s.now().Unix() + turn["durationMs"] = 1 + } + return turn +} + +func (s *probeRPCState) now() time.Time { + if s.Now != nil { + return s.Now().UTC() + } + return time.Now().UTC() +} + +func writeProbeRPC(w io.Writer, msg rpcMessage) error { + data, err := json.Marshal(msg) + if err != nil { + return err + } + _, err = fmt.Fprintln(w, string(data)) + return err +} + +func probeAgentMessage(text string) map[string]any { + return map[string]any{ + "type": "agentMessage", + "id": "mnemon-probe-message", + "text": text, + "phase": "final_answer", + "memoryCitation": nil, + } +} + +func probeFinalAnswer(input string) string { + input = strings.TrimSpace(input) + if input == "" { + input = "no input" + } + return "Mnemon Multica runtime probe completed. Observed turn input: " + input +} + +func extractProbeInput(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 newProbeRecorder(env []string, cwd string, now func() time.Time) (*probeRecorder, error) { + path := envValue(env, "MNEMON_MULTICA_PROBE_LOG") + if path == "" { + dir := envValue(env, "MNEMON_MULTICA_PROBE_DIR") + if dir == "" { + home, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(home) == "" { + home = cwd + } + dir = filepath.Join(home, ".mnemon", "harness", "multica", "probe") + } + if now == nil { + now = time.Now + } + path = filepath.Join(dir, "probe-"+now().UTC().Format("20060102T150405Z")+"-"+fmt.Sprint(os.Getpid())+".jsonl") + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + return &probeRecorder{path: path, now: now}, nil +} + +func (r *probeRecorder) record(event probeEvent) error { + if r == nil || strings.TrimSpace(r.path) == "" { + return nil + } + now := time.Now + if r.now != nil { + now = r.now + } + event.TS = now().UTC().Format(time.RFC3339Nano) + f, err := os.OpenFile(r.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return err + } + enc := json.NewEncoder(f) + if err := enc.Encode(event); err != nil { + _ = f.Close() + return err + } + return f.Close() +} + +func wantsVersion(args []string) bool { + fs := flag.NewFlagSet("mnemon-multica-runtime-probe", flag.ContinueOnError) + fs.SetOutput(io.Discard) + version := fs.Bool("version", false, "") + _ = fs.Parse(args) + if *version { + return true + } + for _, arg := range args { + switch arg { + case "version", "--version", "-version", "-v": + return true + } + } + return false +} + +func redactProbeEnv(env []string) map[string]any { + out := map[string]any{} + for _, item := range env { + key, value, ok := strings.Cut(item, "=") + if !ok { + continue + } + if isSensitiveProbeEnv(key) { + if value == "" { + out[key] = "" + } else { + out[key] = "[redacted]" + } + continue + } + out[key] = value + } + return out +} + +func isSensitiveProbeEnv(key string) bool { + key = strings.ToUpper(key) + for _, marker := range []string{"TOKEN", "SECRET", "PASSWORD", "API_KEY", "AUTH", "CREDENTIAL", "COOKIE"} { + if strings.Contains(key, marker) { + return true + } + } + return false +} + +func envValue(env []string, key string) string { + prefix := key + "=" + for _, item := range env { + if strings.HasPrefix(item, prefix) { + return strings.TrimSpace(strings.TrimPrefix(item, prefix)) + } + } + return "" +} + +func stringParam(params map[string]any, key string) string { + if params == nil { + return "" + } + value, _ := params[key].(string) + return strings.TrimSpace(value) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func probeID(prefix string, ts time.Time) string { + return fmt.Sprintf("%s-%d", prefix, ts.UTC().UnixNano()) +} diff --git a/harness/cmd/mnemon-multica-runtime-probe/main_test.go b/harness/cmd/mnemon-multica-runtime-probe/main_test.go new file mode 100644 index 00000000..647e0e2c --- /dev/null +++ b/harness/cmd/mnemon-multica-runtime-probe/main_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestProbeVersionRedactsEnvironmentInLog(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "probe.jsonl") + var out bytes.Buffer + err := runProbe(probeConfig{ + Args: []string{"--version"}, + Env: []string{ + "MNEMON_MULTICA_PROBE_LOG=" + logPath, + "MULTICA_TASK_ID=task-1", + "MULTICA_TOKEN=mat_secret", + }, + CWD: t.TempDir(), + Stdin: strings.NewReader(""), + Stdout: &out, + Now: fixedProbeTime, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "mnemon-multica-runtime-probe") { + t.Fatalf("version output = %q", out.String()) + } + logData, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(logData), "mat_secret") { + t.Fatalf("log leaked token:\n%s", logData) + } + if !strings.Contains(string(logData), `"MULTICA_TASK_ID":"task-1"`) || !strings.Contains(string(logData), `"MULTICA_TOKEN":"[redacted]"`) { + t.Fatalf("log missing expected env capture:\n%s", logData) + } +} + +func TestProbeSpeaksMinimalCodexAppServerRPC(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "probe.jsonl") + input := strings.Join([]string{ + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"multica","version":"test"}}}`, + `{"jsonrpc":"2.0","method":"initialized"}`, + `{"jsonrpc":"2.0","id":2,"method":"thread/start","params":{"cwd":"/tmp/work","ephemeral":true}}`, + `{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"threadId":"thread-known","input":[{"type":"text","text":"hello"}]}}`, + }, "\n") + "\n" + var out bytes.Buffer + err := runProbe(probeConfig{ + Args: []string{"app-server", "--listen", "stdio://"}, + Env: []string{"MNEMON_MULTICA_PROBE_LOG=" + logPath}, + CWD: t.TempDir(), + Stdin: strings.NewReader(input), + Stdout: &out, + Now: fixedProbeTime, + }) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) < 8 { + t.Fatalf("expected several rpc responses/notifications, got %d:\n%s", len(lines), out.String()) + } + var sawInit, sawThread, sawTurnComplete bool + for _, line := range lines { + var msg map[string]any + if err := json.Unmarshal([]byte(line), &msg); err != nil { + t.Fatalf("invalid rpc line %q: %v", line, err) + } + switch { + case msg["id"] == float64(1): + sawInit = true + case msg["id"] == float64(2): + sawThread = true + case msg["method"] == "turn/completed": + sawTurnComplete = true + } + } + if !sawInit || !sawThread || !sawTurnComplete { + t.Fatalf("missing expected rpc responses init=%v thread=%v turnComplete=%v:\n%s", sawInit, sawThread, sawTurnComplete, out.String()) + } + logData, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`"method":"initialize"`, `"method":"initialized"`, `"method":"thread/start"`, `"method":"turn/start"`, `"kind":"process_exit"`} { + if !strings.Contains(string(logData), want) { + t.Fatalf("log missing %s:\n%s", want, logData) + } + } +} + +func fixedProbeTime() time.Time { + return time.Date(2026, 6, 28, 8, 0, 0, 0, time.UTC) +} From e09b006b52b1723fb4c46655078439d1d4df020d Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 16:33:23 +0800 Subject: [PATCH 03/24] feat: expand multica cli driver Add structured Multica CLI wrappers for runtime profiles, runtimes, agents, issue creation, assignment, status, metadata, runs, and run messages. The wrappers keep long issue descriptions on stdin and preserve explicit profile/workspace scoping for later provisioning and acceptance flows. Validation: go test ./harness/internal/driver ./harness/cmd/mnemon-harness ./harness/cmd/mnemon-multica-runtime-probe. Live check confirmed the temporary probe runtime profile was cleaned up and default Multica runtimes remain online. --- harness/internal/driver/multica.go | 385 ++++++++++++++++++++++++ harness/internal/driver/multica_test.go | 138 +++++++++ 2 files changed, 523 insertions(+) diff --git a/harness/internal/driver/multica.go b/harness/internal/driver/multica.go index b64cc75f..254bbb02 100644 --- a/harness/internal/driver/multica.go +++ b/harness/internal/driver/multica.go @@ -60,6 +60,44 @@ type MulticaIssue struct { Metadata map[string]any `json:"metadata"` } +type MulticaRuntimeProfile struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + CommandName string `json:"command_name"` + ProtocolFamily string `json:"protocol_family"` + Enabled bool `json:"enabled"` + Visibility string `json:"visibility"` + WorkspaceID string `json:"workspace_id"` +} + +type MulticaRuntime struct { + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + Status string `json:"status"` + RuntimeMode string `json:"runtime_mode"` + ProfileID string `json:"profile_id"` + LaunchHeader string `json:"launch_header"` + Metadata map[string]any `json:"metadata"` + WorkspaceID string `json:"workspace_id"` +} + +type MulticaAgent struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Instructions string `json:"instructions"` + RuntimeID string `json:"runtime_id"` + RuntimeMode string `json:"runtime_mode"` + Status string `json:"status"` + Visibility string `json:"visibility"` + ArchivedAt string `json:"archived_at"` + CustomArgs []string `json:"custom_args"` + RuntimeConfig map[string]any `json:"runtime_config"` + WorkspaceID string `json:"workspace_id"` +} + type MulticaComment struct { ID string `json:"id"` IssueID string `json:"issue_id"` @@ -67,6 +105,62 @@ type MulticaComment struct { Type string `json:"type"` } +type MulticaIssueRun struct { + ID string `json:"id"` + IssueID string `json:"issue_id"` + AgentID string `json:"agent_id"` + RuntimeID string `json:"runtime_id"` + Status string `json:"status"` + Kind string `json:"kind"` + Attempt int `json:"attempt"` + Result map[string]any `json:"result"` + WorkDir string `json:"work_dir"` + CreatedAt string `json:"created_at"` + StartedAt string `json:"started_at"` + CompletedAt string `json:"completed_at"` + WorkspaceID string `json:"workspace_id"` +} + +type MulticaRunMessage struct { + TaskID string `json:"task_id"` + IssueID string `json:"issue_id"` + Seq int64 `json:"seq"` + Type string `json:"type"` + Content string `json:"content"` + CreatedAt string `json:"created_at"` +} + +type MulticaCreateRuntimeProfileRequest struct { + DisplayName string + Description string + ProtocolFamily string + CommandName string +} + +type MulticaCreateAgentRequest struct { + Name string + Description string + Instructions string + RuntimeID string + Visibility string + Model string + ThinkingLevel string + MaxConcurrentTasks int + CustomArgsJSON string + RuntimeConfigJSON string +} + +type MulticaCreateIssueRequest struct { + Title string + Description string + AssigneeID string + Assignee string + ParentID string + Status string + Priority string + AllowDuplicate bool +} + type MulticaCommandResult struct { Stdout string Stderr string @@ -185,6 +279,297 @@ func (c MulticaCLI) AddIssueComment(ctx context.Context, issueID, content string return comment, nil } +func (c MulticaCLI) ListRuntimeProfiles(ctx context.Context) ([]MulticaRuntimeProfile, error) { + out, err := c.Run(ctx, []string{"runtime", "profile", "list", "--output", "json"}, "") + if err != nil { + return nil, err + } + var profiles []MulticaRuntimeProfile + if err := json.Unmarshal([]byte(out.Stdout), &profiles); err != nil { + return nil, fmt.Errorf("decode multica runtime profiles: %w", err) + } + return profiles, nil +} + +func (c MulticaCLI) CreateRuntimeProfile(ctx context.Context, req MulticaCreateRuntimeProfileRequest) (MulticaRuntimeProfile, error) { + if strings.TrimSpace(req.DisplayName) == "" { + return MulticaRuntimeProfile{}, fmt.Errorf("multica runtime profile display name is required") + } + if strings.TrimSpace(req.ProtocolFamily) == "" { + return MulticaRuntimeProfile{}, fmt.Errorf("multica runtime profile protocol family is required") + } + if strings.TrimSpace(req.CommandName) == "" { + return MulticaRuntimeProfile{}, fmt.Errorf("multica runtime profile command name is required") + } + args := []string{ + "runtime", "profile", "create", + "--display-name", strings.TrimSpace(req.DisplayName), + "--protocol-family", strings.TrimSpace(req.ProtocolFamily), + "--command-name", strings.TrimSpace(req.CommandName), + "--output", "json", + } + if strings.TrimSpace(req.Description) != "" { + args = append(args, "--description", strings.TrimSpace(req.Description)) + } + out, err := c.Run(ctx, args, "") + if err != nil { + return MulticaRuntimeProfile{}, err + } + var profile MulticaRuntimeProfile + if err := json.Unmarshal([]byte(out.Stdout), &profile); err != nil { + return MulticaRuntimeProfile{}, fmt.Errorf("decode multica runtime profile: %w", err) + } + return profile, nil +} + +func (c MulticaCLI) SetRuntimeProfilePath(ctx context.Context, profileID, path string) error { + profileID = strings.TrimSpace(profileID) + path = strings.TrimSpace(path) + if profileID == "" { + return fmt.Errorf("multica runtime profile id is required") + } + if path == "" { + return fmt.Errorf("multica runtime profile path is required") + } + _, err := c.Run(ctx, []string{"runtime", "profile", "set-path", profileID, "--path", path}, "") + return err +} + +func (c MulticaCLI) DeleteRuntimeProfile(ctx context.Context, profileID string) error { + profileID = strings.TrimSpace(profileID) + if profileID == "" { + return fmt.Errorf("multica runtime profile id is required") + } + _, err := c.Run(ctx, []string{"runtime", "profile", "delete", profileID}, "") + return err +} + +func (c MulticaCLI) ListRuntimes(ctx context.Context) ([]MulticaRuntime, error) { + out, err := c.Run(ctx, []string{"runtime", "list", "--output", "json"}, "") + if err != nil { + return nil, err + } + var runtimes []MulticaRuntime + if err := json.Unmarshal([]byte(out.Stdout), &runtimes); err != nil { + return nil, fmt.Errorf("decode multica runtimes: %w", err) + } + return runtimes, nil +} + +func (c MulticaCLI) ListAgents(ctx context.Context, includeArchived bool) ([]MulticaAgent, error) { + args := []string{"agent", "list", "--output", "json"} + if includeArchived { + args = append(args, "--include-archived") + } + out, err := c.Run(ctx, args, "") + if err != nil { + return nil, err + } + var agents []MulticaAgent + if err := json.Unmarshal([]byte(out.Stdout), &agents); err != nil { + return nil, fmt.Errorf("decode multica agents: %w", err) + } + return agents, nil +} + +func (c MulticaCLI) CreateAgent(ctx context.Context, req MulticaCreateAgentRequest) (MulticaAgent, error) { + if strings.TrimSpace(req.Name) == "" { + return MulticaAgent{}, fmt.Errorf("multica agent name is required") + } + if strings.TrimSpace(req.RuntimeID) == "" { + return MulticaAgent{}, fmt.Errorf("multica agent runtime id is required") + } + args := []string{ + "agent", "create", + "--name", strings.TrimSpace(req.Name), + "--runtime-id", strings.TrimSpace(req.RuntimeID), + "--output", "json", + } + if strings.TrimSpace(req.Description) != "" { + args = append(args, "--description", strings.TrimSpace(req.Description)) + } + if strings.TrimSpace(req.Instructions) != "" { + args = append(args, "--instructions", strings.TrimSpace(req.Instructions)) + } + if strings.TrimSpace(req.Visibility) != "" { + args = append(args, "--visibility", strings.TrimSpace(req.Visibility)) + } + if strings.TrimSpace(req.Model) != "" { + args = append(args, "--model", strings.TrimSpace(req.Model)) + } + if strings.TrimSpace(req.ThinkingLevel) != "" { + args = append(args, "--thinking-level", strings.TrimSpace(req.ThinkingLevel)) + } + if req.MaxConcurrentTasks > 0 { + args = append(args, "--max-concurrent-tasks", fmt.Sprint(req.MaxConcurrentTasks)) + } + if strings.TrimSpace(req.CustomArgsJSON) != "" { + args = append(args, "--custom-args", strings.TrimSpace(req.CustomArgsJSON)) + } + if strings.TrimSpace(req.RuntimeConfigJSON) != "" { + args = append(args, "--runtime-config", strings.TrimSpace(req.RuntimeConfigJSON)) + } + out, err := c.Run(ctx, args, "") + if err != nil { + return MulticaAgent{}, err + } + var agent MulticaAgent + if err := json.Unmarshal([]byte(out.Stdout), &agent); err != nil { + return MulticaAgent{}, fmt.Errorf("decode multica agent: %w", err) + } + return agent, nil +} + +func (c MulticaCLI) ArchiveAgent(ctx context.Context, agentID string) (MulticaAgent, error) { + agentID = strings.TrimSpace(agentID) + if agentID == "" { + return MulticaAgent{}, fmt.Errorf("multica agent id is required") + } + out, err := c.Run(ctx, []string{"agent", "archive", agentID, "--output", "json"}, "") + if err != nil { + return MulticaAgent{}, err + } + var agent MulticaAgent + if err := json.Unmarshal([]byte(out.Stdout), &agent); err != nil { + return MulticaAgent{}, fmt.Errorf("decode archived multica agent: %w", err) + } + return agent, nil +} + +func (c MulticaCLI) CreateIssue(ctx context.Context, req MulticaCreateIssueRequest) (MulticaIssue, error) { + if strings.TrimSpace(req.Title) == "" { + return MulticaIssue{}, fmt.Errorf("multica issue title is required") + } + args := []string{"issue", "create", "--title", strings.TrimSpace(req.Title), "--output", "json"} + stdin := "" + if strings.TrimSpace(req.Description) != "" { + args = append(args, "--description-stdin") + stdin = req.Description + } + if strings.TrimSpace(req.AssigneeID) != "" { + args = append(args, "--assignee-id", strings.TrimSpace(req.AssigneeID)) + } + if strings.TrimSpace(req.Assignee) != "" { + args = append(args, "--assignee", strings.TrimSpace(req.Assignee)) + } + if strings.TrimSpace(req.ParentID) != "" { + args = append(args, "--parent", strings.TrimSpace(req.ParentID)) + } + if strings.TrimSpace(req.Status) != "" { + args = append(args, "--status", strings.TrimSpace(req.Status)) + } + if strings.TrimSpace(req.Priority) != "" { + args = append(args, "--priority", strings.TrimSpace(req.Priority)) + } + if req.AllowDuplicate { + args = append(args, "--allow-duplicate") + } + out, err := c.Run(ctx, args, stdin) + if err != nil { + return MulticaIssue{}, err + } + var issue MulticaIssue + if err := json.Unmarshal([]byte(out.Stdout), &issue); err != nil { + return MulticaIssue{}, fmt.Errorf("decode created multica issue: %w", err) + } + return issue, nil +} + +func (c MulticaCLI) AssignIssue(ctx context.Context, issueID, assigneeID string) (MulticaIssue, error) { + issueID = strings.TrimSpace(issueID) + assigneeID = strings.TrimSpace(assigneeID) + if issueID == "" { + return MulticaIssue{}, fmt.Errorf("multica issue id is required") + } + if assigneeID == "" { + return MulticaIssue{}, fmt.Errorf("multica assignee id is required") + } + out, err := c.Run(ctx, []string{"issue", "assign", issueID, "--to-id", assigneeID, "--output", "json"}, "") + if err != nil { + return MulticaIssue{}, err + } + var issue MulticaIssue + if err := json.Unmarshal([]byte(out.Stdout), &issue); err != nil { + return MulticaIssue{}, fmt.Errorf("decode assigned multica issue: %w", err) + } + return issue, nil +} + +func (c MulticaCLI) SetIssueStatus(ctx context.Context, issueID, status string) (MulticaIssue, error) { + issueID = strings.TrimSpace(issueID) + status = strings.TrimSpace(status) + if issueID == "" { + return MulticaIssue{}, fmt.Errorf("multica issue id is required") + } + if status == "" { + return MulticaIssue{}, fmt.Errorf("multica issue status is required") + } + out, err := c.Run(ctx, []string{"issue", "status", issueID, status, "--output", "json"}, "") + if err != nil { + return MulticaIssue{}, err + } + var issue MulticaIssue + if err := json.Unmarshal([]byte(out.Stdout), &issue); err != nil { + return MulticaIssue{}, fmt.Errorf("decode multica issue status: %w", err) + } + return issue, nil +} + +func (c MulticaCLI) SetIssueMetadata(ctx context.Context, issueID, key, value, valueType string) error { + issueID = strings.TrimSpace(issueID) + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if issueID == "" { + return fmt.Errorf("multica issue id is required") + } + if key == "" { + return fmt.Errorf("multica metadata key is required") + } + args := []string{"issue", "metadata", "set", issueID, "--key", key, "--value", value} + if strings.TrimSpace(valueType) != "" { + args = append(args, "--type", strings.TrimSpace(valueType)) + } + args = append(args, "--output", "json") + _, err := c.Run(ctx, args, "") + return err +} + +func (c MulticaCLI) ListIssueRuns(ctx context.Context, issueID string) ([]MulticaIssueRun, error) { + issueID = strings.TrimSpace(issueID) + if issueID == "" { + return nil, fmt.Errorf("multica issue id is required") + } + out, err := c.Run(ctx, []string{"issue", "runs", issueID, "--output", "json"}, "") + if err != nil { + return nil, err + } + var runs []MulticaIssueRun + if err := json.Unmarshal([]byte(out.Stdout), &runs); err != nil { + return nil, fmt.Errorf("decode multica issue runs: %w", err) + } + return runs, nil +} + +func (c MulticaCLI) ListRunMessages(ctx context.Context, taskID, issueID string) ([]MulticaRunMessage, error) { + taskID = strings.TrimSpace(taskID) + if taskID == "" { + return nil, fmt.Errorf("multica task id is required") + } + args := []string{"issue", "run-messages", taskID, "--output", "json"} + if strings.TrimSpace(issueID) != "" { + args = append(args, "--issue", strings.TrimSpace(issueID)) + } + out, err := c.Run(ctx, args, "") + if err != nil { + return nil, err + } + var messages []MulticaRunMessage + if err := json.Unmarshal([]byte(out.Stdout), &messages); err != nil { + return nil, fmt.Errorf("decode multica run messages: %w", err) + } + return messages, nil +} + func (c MulticaCLI) Run(ctx context.Context, args []string, stdin string) (MulticaCommandResult, error) { command := strings.TrimSpace(c.Command) if command == "" { diff --git a/harness/internal/driver/multica_test.go b/harness/internal/driver/multica_test.go index c8579bcf..04caec23 100644 --- a/harness/internal/driver/multica_test.go +++ b/harness/internal/driver/multica_test.go @@ -104,6 +104,135 @@ printf 'Server: https://api.multica.ai\nUser: Test User\nToken: mul_test.. } } +func TestMulticaCLIProvisioningCommandsUseExpectedShapes(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 + *"runtime profile create"*) printf '{"id":"profile-1","display_name":"Mnemon","command_name":"mnemon-multica-runtime","protocol_family":"codex","enabled":true,"visibility":"workspace","workspace_id":"ws-1"}\n' ;; + *"runtime list"*) printf '[{"id":"runtime-1","name":"Mnemon (Mac)","provider":"codex","status":"online","profile_id":"profile-1","workspace_id":"ws-1"}]\n' ;; + *"agent create"*) printf '{"id":"agent-1","name":"mnemon-planner","runtime_id":"runtime-1","status":"idle","visibility":"private","workspace_id":"ws-1"}\n' ;; + *) printf '{}\n' ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + cli := MulticaCLI{ + Command: bin, + Profile: "desktop-api.multica.ai", + WorkspaceID: "ws-1", + Env: append(os.Environ(), + "MULTICA_ARGS_PATH="+argsPath, + ), + } + profile, err := cli.CreateRuntimeProfile(context.Background(), MulticaCreateRuntimeProfileRequest{ + DisplayName: "Mnemon", + Description: "Mnemon runtime", + ProtocolFamily: "codex", + CommandName: "mnemon-multica-runtime", + }) + if err != nil { + t.Fatal(err) + } + if profile.ID != "profile-1" || profile.ProtocolFamily != "codex" { + t.Fatalf("profile = %+v", profile) + } + args := mustReadDriverTestFile(t, argsPath) + for _, want := range []string{ + "--profile desktop-api.multica.ai --workspace-id ws-1 runtime profile create", + "--display-name Mnemon", + "--protocol-family codex", + "--command-name mnemon-multica-runtime", + "--output json", + } { + if !strings.Contains(args, want) { + t.Fatalf("create profile args missing %q: %q", want, args) + } + } + runtimes, err := cli.ListRuntimes(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(runtimes) != 1 || runtimes[0].ProfileID != "profile-1" { + t.Fatalf("runtimes = %+v", runtimes) + } + agent, err := cli.CreateAgent(context.Background(), MulticaCreateAgentRequest{ + Name: "mnemon-planner", + RuntimeID: "runtime-1", + Instructions: "Use Mnemon render context.", + Visibility: "private", + MaxConcurrentTasks: 1, + }) + if err != nil { + t.Fatal(err) + } + if agent.ID != "agent-1" { + t.Fatalf("agent = %+v", agent) + } + args = mustReadDriverTestFile(t, argsPath) + for _, want := range []string{ + "agent create", + "--name mnemon-planner", + "--runtime-id runtime-1", + "--instructions Use Mnemon render context.", + "--max-concurrent-tasks 1", + } { + if !strings.Contains(args, want) { + t.Fatalf("create agent args missing %q: %q", want, args) + } + } +} + +func TestMulticaCLICreateIssueUsesDescriptionStdin(t *testing.T) { + tmp := t.TempDir() + 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" +printf '{"id":"issue-1","identifier":"TEA-9","title":"Run teamwork","status":"todo","priority":"low"}\n' +` + 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, + "MULTICA_STDIN_PATH="+stdinPath, + ), + } + issue, err := cli.CreateIssue(context.Background(), MulticaCreateIssueRequest{ + Title: "Run teamwork", + Description: "Long task description with context that must not live in argv.", + AssigneeID: "agent-1", + Status: "todo", + Priority: "low", + AllowDuplicate: true, + }) + if err != nil { + t.Fatal(err) + } + if issue.ID != "issue-1" { + t.Fatalf("issue = %+v", issue) + } + args := mustReadDriverTestFile(t, argsPath) + if !strings.Contains(args, "issue create --title Run teamwork --output json --description-stdin --assignee-id agent-1 --status todo --priority low --allow-duplicate") { + t.Fatalf("issue create args mismatch: %q", args) + } + if strings.Contains(args, "Long task description") { + t.Fatalf("description must not be passed in argv: %q", args) + } + stdin := mustReadDriverTestFile(t, stdinPath) + if stdin != "Long task description with context that must not live in argv." { + t.Fatalf("stdin = %q", stdin) + } +} + func TestFormatMulticaProjectionCommentCarriesStableMarkers(t *testing.T) { got := FormatMulticaProjectionComment("assignment finished", "Worker reported passing evidence.", []string{"ev-1", "ev-1", "ev-2"}) for _, want := range []string{ @@ -120,3 +249,12 @@ func TestFormatMulticaProjectionCommentCarriesStableMarkers(t *testing.T) { t.Fatalf("projection comment should dedupe markers:\n%s", got) } } + +func mustReadDriverTestFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return strings.TrimSpace(string(data)) +} From 630168e49c632355130322e105f6a4beecb46905 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 16:39:06 +0800 Subject: [PATCH 04/24] feat: provision multica participant agents Add a Multica participant registry and a mnemon-harness multica provision command. Provisioning creates or reuses a codex-family runtime profile, resolves an online runtime, creates/restores/updates the five default Mnemon participant agents, and writes the local Multica registry for later runtime and projection use. Validation: go test ./harness/internal/driver ./harness/cmd/mnemon-harness ./harness/cmd/mnemon-multica-runtime-probe. Live probe verified the authenticated Multica daemon remains ready with default runtimes after temporary probe cleanup. --- harness/cmd/mnemon-harness/multica.go | 267 +++++++++++++++++- harness/cmd/mnemon-harness/multica_test.go | 94 ++++++ harness/internal/driver/multica.go | 63 +++++ harness/internal/driver/multica_registry.go | 94 ++++++ .../internal/driver/multica_registry_test.go | 60 ++++ 5 files changed, 572 insertions(+), 6 deletions(-) create mode 100644 harness/internal/driver/multica_registry.go create mode 100644 harness/internal/driver/multica_registry_test.go diff --git a/harness/cmd/mnemon-harness/multica.go b/harness/cmd/mnemon-harness/multica.go index 09f10640..c4fdb1f2 100644 --- a/harness/cmd/mnemon-harness/multica.go +++ b/harness/cmd/mnemon-harness/multica.go @@ -42,6 +42,15 @@ var ( multicaCommentStdin bool multicaCommentTitle string multicaCommentEvents []string + + multicaProvisionRegistry string + multicaProvisionProjectRoot string + multicaProvisionProfileName string + multicaProvisionRuntimeCommand string + multicaProvisionRuntimePath string + multicaProvisionAgentPrefix string + multicaProvisionRestartDaemon bool + multicaProvisionWait time.Duration ) var multicaCmd = &cobra.Command{ @@ -67,6 +76,12 @@ var multicaProjectCommentCmd = &cobra.Command{ RunE: runMulticaProjectComment, } +var multicaProvisionCmd = &cobra.Command{ + Use: "provision", + Short: "Provision Multica runtime profile and Mnemon participant agents", + RunE: runMulticaProvision, +} + type multicaProbeReport struct { Command string `json:"command"` Profile string `json:"profile,omitempty"` @@ -81,7 +96,22 @@ type multicaProbeReport struct { IntegrationReady bool `json:"integration_ready"` } +type multicaProvisionReport struct { + WorkspaceID string `json:"workspace_id"` + RuntimeProfile driver.MulticaRuntimeProfile `json:"runtime_profile"` + Runtime driver.MulticaRuntime `json:"runtime"` + Participants []driver.MulticaParticipantRecord `json:"participants"` + RegistryPath string `json:"registry_path"` + RestartedDaemon bool `json:"restarted_daemon"` + CreatedProfile bool `json:"created_profile"` + CreatedAgents []string `json:"created_agents,omitempty"` + RestoredAgents []string `json:"restored_agents,omitempty"` + UpdatedAgents []string `json:"updated_agents,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + func runMulticaProbe(cmd *cobra.Command, args []string) error { + ctx := multicaCommandContext(cmd) cli := multicaCLI() report := multicaProbeReport{ Command: multicaCLICommand(cli), @@ -89,18 +119,18 @@ func runMulticaProbe(cmd *cobra.Command, args []string) error { ServerURL: strings.TrimSpace(multicaServerURL), WorkspaceID: strings.TrimSpace(multicaWorkspaceID), } - if version, err := cli.Version(cmd.Context()); err != nil { + if version, err := cli.Version(ctx); err != nil { report.VersionError = err.Error() } else { report.Version = &version } - if status, err := cli.AuthStatus(cmd.Context()); err != nil { + if status, err := cli.AuthStatus(ctx); err != nil { report.AuthStatus = status report.AuthError = err.Error() } else { report.AuthStatus = status } - if daemon, err := cli.DaemonStatus(cmd.Context()); err != nil { + if daemon, err := cli.DaemonStatus(ctx); err != nil { report.DaemonStatus = &daemon report.DaemonError = err.Error() } else { @@ -134,7 +164,8 @@ func runMulticaProbe(cmd *cobra.Command, args []string) error { } func runMulticaImportIssue(cmd *cobra.Command, args []string) error { - issue, err := loadMulticaIssue(cmd.Context(), cmd.InOrStdin()) + ctx := multicaCommandContext(cmd) + issue, err := loadMulticaIssue(ctx, cmd.InOrStdin()) if err != nil { return err } @@ -189,7 +220,7 @@ func runMulticaProjectComment(cmd *cobra.Command, args []string) error { return err } body := driver.FormatMulticaProjectionComment(multicaCommentTitle, content, multicaCommentEvents) - comment, err := multicaCLI().AddIssueComment(cmd.Context(), multicaIssueID, body) + comment, err := multicaCLI().AddIssueComment(multicaCommandContext(cmd), multicaIssueID, body) if err != nil { return err } @@ -202,6 +233,214 @@ func runMulticaProjectComment(cmd *cobra.Command, args []string) error { return nil } +func runMulticaProvision(cmd *cobra.Command, args []string) error { + ctx := multicaCommandContext(cmd) + workspaceID := strings.TrimSpace(multicaWorkspaceID) + if workspaceID == "" { + return fmt.Errorf("--multica-workspace-id is required for provision") + } + cli := multicaCLI() + if err := ensureMulticaReady(ctx, cli); err != nil { + return err + } + profileName := strings.TrimSpace(multicaProvisionProfileName) + if profileName == "" { + profileName = "mnemon-runtime" + } + runtimeCommand := strings.TrimSpace(multicaProvisionRuntimeCommand) + if runtimeCommand == "" { + runtimeCommand = "mnemon-multica-runtime" + } + report := multicaProvisionReport{WorkspaceID: workspaceID} + profile, created, err := ensureMulticaRuntimeProfile(ctx, cli, profileName, runtimeCommand) + if err != nil { + return err + } + report.RuntimeProfile = profile + report.CreatedProfile = created + if strings.TrimSpace(multicaProvisionRuntimePath) != "" { + if err := cli.SetRuntimeProfilePath(ctx, profile.ID, multicaProvisionRuntimePath); err != nil { + return err + } + } + if multicaProvisionRestartDaemon { + if _, err := cli.Run(ctx, []string{"daemon", "restart"}, ""); err != nil { + return err + } + report.RestartedDaemon = true + } + runtime, err := waitMulticaRuntimeForProfile(ctx, cli, profile.ID, multicaProvisionWait) + if err != nil { + return err + } + report.Runtime = runtime + participants := driver.DefaultMulticaParticipantRecords(multicaProvisionAgentPrefix) + agents, err := cli.ListAgents(ctx, true) + if err != nil { + return err + } + for _, participant := range participants { + agent, action, err := ensureMulticaParticipantAgent(ctx, cli, agents, runtime.ID, participant) + if err != nil { + return err + } + participant.AgentID = agent.ID + report.Participants = driver.UpsertMulticaParticipantRecord(report.Participants, participant) + switch action { + case "created": + report.CreatedAgents = append(report.CreatedAgents, agent.ID) + case "restored": + report.RestoredAgents = append(report.RestoredAgents, agent.ID) + case "updated": + report.UpdatedAgents = append(report.UpdatedAgents, agent.ID) + } + agents = upsertMulticaAgent(agents, agent) + } + registryPath := driver.MulticaRegistryPath(multicaProvisionProjectRoot, multicaProvisionRegistry) + report.RegistryPath = registryPath + if err := driver.SaveMulticaRegistry(registryPath, driver.MulticaRegistry{ + SchemaVersion: 1, + WorkspaceID: workspaceID, + RuntimeProfileID: profile.ID, + RuntimeID: runtime.ID, + Participants: report.Participants, + }); err != nil { + return err + } + if !multicaProvisionRestartDaemon && strings.TrimSpace(multicaProvisionRuntimePath) != "" && created { + report.Warnings = append(report.Warnings, "if the runtime did not appear, restart the Multica daemon so the per-machine runtime path is loaded") + } + if multicaJSON { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(report) + } + fmt.Fprintf(cmd.OutOrStdout(), "provisioned Multica runtime %s with %d Mnemon participants\n", report.Runtime.ID, len(report.Participants)) + fmt.Fprintf(cmd.OutOrStdout(), "registry: %s\n", report.RegistryPath) + return nil +} + +func ensureMulticaReady(ctx context.Context, cli driver.MulticaCLI) error { + if _, err := cli.Version(ctx); err != nil { + return err + } + auth, err := cli.AuthStatus(ctx) + if err != nil { + return err + } + if auth == "" || strings.Contains(strings.ToLower(auth), "not authenticated") { + return fmt.Errorf("Multica profile is not authenticated") + } + status, err := cli.DaemonStatus(ctx) + if err != nil { + return err + } + if !strings.EqualFold(strings.TrimSpace(status.Status), "running") { + return fmt.Errorf("Multica daemon is not running") + } + return nil +} + +func ensureMulticaRuntimeProfile(ctx context.Context, cli driver.MulticaCLI, profileName, runtimeCommand string) (driver.MulticaRuntimeProfile, bool, error) { + profiles, err := cli.ListRuntimeProfiles(ctx) + if err != nil { + return driver.MulticaRuntimeProfile{}, false, err + } + for _, profile := range profiles { + if profile.DisplayName == profileName && profile.ProtocolFamily == "codex" { + return profile, false, nil + } + } + profile, err := cli.CreateRuntimeProfile(ctx, driver.MulticaCreateRuntimeProfileRequest{ + DisplayName: profileName, + Description: "Mnemon teamwork runtime adapter", + ProtocolFamily: "codex", + CommandName: runtimeCommand, + }) + return profile, true, err +} + +func waitMulticaRuntimeForProfile(ctx context.Context, cli driver.MulticaCLI, profileID string, wait time.Duration) (driver.MulticaRuntime, error) { + deadline := time.Now().Add(wait) + for { + runtimes, err := cli.ListRuntimes(ctx) + if err != nil { + return driver.MulticaRuntime{}, err + } + for _, runtime := range runtimes { + if runtime.ProfileID == profileID && strings.EqualFold(runtime.Status, "online") { + return runtime, nil + } + } + if wait <= 0 || time.Now().After(deadline) { + return driver.MulticaRuntime{}, fmt.Errorf("no online Multica runtime found for profile %s", profileID) + } + select { + case <-ctx.Done(): + return driver.MulticaRuntime{}, ctx.Err() + case <-time.After(2 * time.Second): + } + } +} + +func ensureMulticaParticipantAgent(ctx context.Context, cli driver.MulticaCLI, agents []driver.MulticaAgent, runtimeID string, participant driver.MulticaParticipantRecord) (driver.MulticaAgent, string, error) { + req := driver.MulticaCreateAgentRequest{ + Name: participant.AgentName, + Description: "Mnemon teamwork participant: " + participant.Principal, + Instructions: multicaParticipantInstructions(participant), + RuntimeID: runtimeID, + Visibility: "private", + MaxConcurrentTasks: 1, + } + for _, agent := range agents { + if agent.Name != participant.AgentName { + continue + } + if strings.TrimSpace(agent.ArchivedAt) != "" { + restored, err := cli.RestoreAgent(ctx, agent.ID) + if err != nil { + return driver.MulticaAgent{}, "", err + } + updated, err := cli.UpdateAgent(ctx, restored.ID, req) + if err != nil { + return driver.MulticaAgent{}, "", err + } + return updated, "restored", nil + } + if agent.RuntimeID != runtimeID || agent.Description != req.Description || agent.Instructions != req.Instructions { + updated, err := cli.UpdateAgent(ctx, agent.ID, req) + if err != nil { + return driver.MulticaAgent{}, "", err + } + return updated, "updated", nil + } + return agent, "reused", nil + } + agent, err := cli.CreateAgent(ctx, req) + if err != nil { + return driver.MulticaAgent{}, "", err + } + return agent, "created", nil +} + +func multicaParticipantInstructions(participant driver.MulticaParticipantRecord) string { + return strings.Join([]string{ + "You are the Multica-visible identity for Mnemon principal " + participant.Principal + ".", + "Treat Multica issues as product-facing task surfaces; Mnemon events remain the teamwork protocol source of truth.", + "When the Mnemon runtime wakes you, rely on Mnemon-rendered context rather than copying issue metadata into your own prompt.", + }, "\n") +} + +func upsertMulticaAgent(agents []driver.MulticaAgent, next driver.MulticaAgent) []driver.MulticaAgent { + for i := range agents { + if agents[i].ID == next.ID { + agents[i] = next + return agents + } + } + return append(agents, next) +} + func loadMulticaIssue(ctx context.Context, stdin io.Reader) (driver.MulticaIssue, error) { switch { case strings.TrimSpace(multicaIssueJSON) != "": @@ -221,6 +460,13 @@ func loadMulticaIssue(ctx context.Context, stdin io.Reader) (driver.MulticaIssue } } +func multicaCommandContext(cmd *cobra.Command) context.Context { + if cmd != nil && cmd.Context() != nil { + return cmd.Context() + } + return context.Background() +} + func multicaLocalClient() (*access.Client, string, error) { addr := strings.TrimSpace(multicaLocalAddr) if addr == "" { @@ -321,7 +567,16 @@ func init() { multicaProjectCommentCmd.Flags().StringVar(&multicaCommentTitle, "title", "", "short Mnemon update title") multicaProjectCommentCmd.Flags().StringArrayVar(&multicaCommentEvents, "event", nil, "Mnemon event marker; may be repeated") - multicaCmd.AddCommand(multicaProbeCmd, multicaImportIssueCmd, multicaProjectCommentCmd) + 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(&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") + multicaProvisionCmd.Flags().DurationVar(&multicaProvisionWait, "wait", 30*time.Second, "time to wait for the runtime to appear online") + + multicaCmd.AddCommand(multicaProbeCmd, multicaProvisionCmd, multicaImportIssueCmd, multicaProjectCommentCmd) multicaCmd.GroupID = groupSpine rootCmd.AddCommand(multicaCmd) } diff --git a/harness/cmd/mnemon-harness/multica_test.go b/harness/cmd/mnemon-harness/multica_test.go index b108730c..ed3dfb7d 100644 --- a/harness/cmd/mnemon-harness/multica_test.go +++ b/harness/cmd/mnemon-harness/multica_test.go @@ -7,9 +7,11 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "github.com/mnemon-dev/mnemon/harness/internal/contract" + "github.com/mnemon-dev/mnemon/harness/internal/driver" ) func TestMulticaImportIssueWritesObservedDraftToLocalIngest(t *testing.T) { @@ -83,6 +85,74 @@ func TestMulticaImportIssueWritesObservedDraftToLocalIngest(t *testing.T) { } } +func TestMulticaProvisionCreatesParticipantRegistry(t *testing.T) { + restoreMulticaFlags(t) + + tmp := t.TempDir() + bin := filepath.Join(tmp, "multica") + script := `#!/usr/bin/env sh +case "$*" in + *"version --output json"*) printf '{"version":"v0.3.31","commit":"test","date":"now","os":"darwin","arch":"arm64","go":"go"}\n' ;; + *"auth status"*) printf 'Server: https://api.multica.ai\nUser: Test\n' >&2 ;; + *"daemon status --output json"*) printf '{"status":"running"}\n' ;; + *"runtime profile list"*) printf '[]\n' ;; + *"runtime profile create"*) printf '{"id":"profile-1","display_name":"mnemon-runtime","command_name":"mnemon-multica-runtime","protocol_family":"codex","enabled":true,"workspace_id":"ws-1"}\n' ;; + *"runtime profile set-path"*) printf '{}\n' ;; + *"runtime list"*) printf '[{"id":"runtime-1","name":"Mnemon (Mac)","provider":"codex","status":"online","profile_id":"profile-1","workspace_id":"ws-1"}]\n' ;; + *"agent list"*) printf '[]\n' ;; + *"agent create"*) name=""; prev=""; for arg in "$@"; do if [ "$prev" = "--name" ]; then name="$arg"; fi; prev="$arg"; done; printf '{"id":"agent-%s","name":"%s","runtime_id":"runtime-1","status":"idle","visibility":"private","workspace_id":"ws-1"}\n' "$name" "$name" ;; + *) printf '{}\n' ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + registryPath := filepath.Join(tmp, "registry.json") + multicaBin = bin + multicaProfile = "desktop-api.multica.ai" + multicaWorkspaceID = "ws-1" + multicaProvisionRegistry = registryPath + multicaProvisionProjectRoot = tmp + multicaProvisionProfileName = "mnemon-runtime" + multicaProvisionRuntimeCommand = "mnemon-multica-runtime" + multicaProvisionRuntimePath = "/abs/mnemon-multica-runtime" + multicaProvisionAgentPrefix = "mnemon" + multicaProvisionRestartDaemon = false + multicaProvisionWait = 0 + multicaJSON = true + + var out bytes.Buffer + multicaProvisionCmd.SetOut(&out) + t.Cleanup(func() { + multicaProvisionCmd.SetOut(os.Stdout) + }) + if err := runMulticaProvision(multicaProvisionCmd, nil); err != nil { + t.Fatalf("provision: %v", err) + } + var report multicaProvisionReport + if err := json.Unmarshal(out.Bytes(), &report); err != nil { + t.Fatalf("provision output must be JSON: %v\n%s", err, out.String()) + } + if report.RuntimeProfile.ID != "profile-1" || report.Runtime.ID != "runtime-1" || len(report.Participants) != 5 { + t.Fatalf("report mismatch: %+v", report) + } + reg, ok, err := driver.LoadMulticaRegistry(registryPath) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("registry was not written") + } + if reg.WorkspaceID != "ws-1" || reg.RuntimeProfileID != "profile-1" || reg.RuntimeID != "runtime-1" || len(reg.Participants) != 5 { + t.Fatalf("registry mismatch: %+v", reg) + } + for _, participant := range reg.Participants { + if !strings.HasPrefix(participant.AgentID, "agent-mnemon-") { + t.Fatalf("participant missing agent id: %+v", participant) + } + } +} + func restoreMulticaFlags(t *testing.T) { t.Helper() oldBin := multicaBin @@ -108,6 +178,14 @@ func restoreMulticaFlags(t *testing.T) { oldStdin := multicaCommentStdin oldTitle := multicaCommentTitle oldEvents := multicaCommentEvents + oldProvisionRegistry := multicaProvisionRegistry + oldProvisionProjectRoot := multicaProvisionProjectRoot + oldProvisionProfileName := multicaProvisionProfileName + oldProvisionRuntimeCommand := multicaProvisionRuntimeCommand + oldProvisionRuntimePath := multicaProvisionRuntimePath + oldProvisionAgentPrefix := multicaProvisionAgentPrefix + oldProvisionRestartDaemon := multicaProvisionRestartDaemon + oldProvisionWait := multicaProvisionWait t.Cleanup(func() { multicaBin = oldBin multicaProfile = oldProfile @@ -132,6 +210,14 @@ func restoreMulticaFlags(t *testing.T) { multicaCommentStdin = oldStdin multicaCommentTitle = oldTitle multicaCommentEvents = oldEvents + multicaProvisionRegistry = oldProvisionRegistry + multicaProvisionProjectRoot = oldProvisionProjectRoot + multicaProvisionProfileName = oldProvisionProfileName + multicaProvisionRuntimeCommand = oldProvisionRuntimeCommand + multicaProvisionRuntimePath = oldProvisionRuntimePath + multicaProvisionAgentPrefix = oldProvisionAgentPrefix + multicaProvisionRestartDaemon = oldProvisionRestartDaemon + multicaProvisionWait = oldProvisionWait }) multicaBin = "" multicaProfile = "" @@ -155,4 +241,12 @@ func restoreMulticaFlags(t *testing.T) { multicaCommentStdin = false multicaCommentTitle = "" multicaCommentEvents = nil + multicaProvisionRegistry = "" + multicaProvisionProjectRoot = "." + multicaProvisionProfileName = "mnemon-runtime" + multicaProvisionRuntimeCommand = "mnemon-multica-runtime" + multicaProvisionRuntimePath = "" + multicaProvisionAgentPrefix = "mnemon" + multicaProvisionRestartDaemon = false + multicaProvisionWait = 30 * 1_000_000_000 } diff --git a/harness/internal/driver/multica.go b/harness/internal/driver/multica.go index 254bbb02..22093e34 100644 --- a/harness/internal/driver/multica.go +++ b/harness/internal/driver/multica.go @@ -436,6 +436,69 @@ func (c MulticaCLI) ArchiveAgent(ctx context.Context, agentID string) (MulticaAg return agent, nil } +func (c MulticaCLI) RestoreAgent(ctx context.Context, agentID string) (MulticaAgent, error) { + agentID = strings.TrimSpace(agentID) + if agentID == "" { + return MulticaAgent{}, fmt.Errorf("multica agent id is required") + } + out, err := c.Run(ctx, []string{"agent", "restore", agentID, "--output", "json"}, "") + if err != nil { + return MulticaAgent{}, err + } + var agent MulticaAgent + if err := json.Unmarshal([]byte(out.Stdout), &agent); err != nil { + return MulticaAgent{}, fmt.Errorf("decode restored multica agent: %w", err) + } + return agent, nil +} + +func (c MulticaCLI) UpdateAgent(ctx context.Context, agentID string, req MulticaCreateAgentRequest) (MulticaAgent, error) { + agentID = strings.TrimSpace(agentID) + if agentID == "" { + return MulticaAgent{}, fmt.Errorf("multica agent id is required") + } + args := []string{"agent", "update", agentID, "--output", "json"} + if strings.TrimSpace(req.Name) != "" { + args = append(args, "--name", strings.TrimSpace(req.Name)) + } + if strings.TrimSpace(req.Description) != "" { + args = append(args, "--description", strings.TrimSpace(req.Description)) + } + if strings.TrimSpace(req.Instructions) != "" { + args = append(args, "--instructions", strings.TrimSpace(req.Instructions)) + } + if strings.TrimSpace(req.RuntimeID) != "" { + args = append(args, "--runtime-id", strings.TrimSpace(req.RuntimeID)) + } + if strings.TrimSpace(req.Visibility) != "" { + args = append(args, "--visibility", strings.TrimSpace(req.Visibility)) + } + if strings.TrimSpace(req.Model) != "" { + args = append(args, "--model", strings.TrimSpace(req.Model)) + } + if strings.TrimSpace(req.ThinkingLevel) != "" { + args = append(args, "--thinking-level", strings.TrimSpace(req.ThinkingLevel)) + } + if req.MaxConcurrentTasks > 0 { + args = append(args, "--max-concurrent-tasks", fmt.Sprint(req.MaxConcurrentTasks)) + } + if strings.TrimSpace(req.CustomArgsJSON) != "" { + args = append(args, "--custom-args", strings.TrimSpace(req.CustomArgsJSON)) + } + if strings.TrimSpace(req.RuntimeConfigJSON) != "" { + args = append(args, "--runtime-config", strings.TrimSpace(req.RuntimeConfigJSON)) + } + out, err := c.Run(ctx, args, "") + if err != nil { + return MulticaAgent{}, err + } + var agent MulticaAgent + if err := json.Unmarshal([]byte(out.Stdout), &agent); err != nil { + return MulticaAgent{}, fmt.Errorf("decode updated multica agent: %w", err) + } + return agent, nil +} + func (c MulticaCLI) CreateIssue(ctx context.Context, req MulticaCreateIssueRequest) (MulticaIssue, error) { if strings.TrimSpace(req.Title) == "" { return MulticaIssue{}, fmt.Errorf("multica issue title is required") diff --git a/harness/internal/driver/multica_registry.go b/harness/internal/driver/multica_registry.go new file mode 100644 index 00000000..2086d032 --- /dev/null +++ b/harness/internal/driver/multica_registry.go @@ -0,0 +1,94 @@ +package driver + +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) +} diff --git a/harness/internal/driver/multica_registry_test.go b/harness/internal/driver/multica_registry_test.go new file mode 100644 index 00000000..f102b1ef --- /dev/null +++ b/harness/internal/driver/multica_registry_test.go @@ -0,0 +1,60 @@ +package driver + +import ( + "path/filepath" + "testing" +) + +func TestDefaultMulticaParticipantRecords(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 TestMulticaRegistryRoundTrip(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) + } +} From d19e88b41f7b871448c92e2b25e683cd87d5bcf8 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 16:48:43 +0800 Subject: [PATCH 05/24] feat: add multica runtime issue import Add a codex-family Multica runtime shim that resolves assigned issues, maps Multica agents to Mnemon principals, and ingests observed teamwork signals without mixing runtime IDs into narrative payloads. Validation: go test ./harness/cmd/mnemon-multica-runtime ./harness/internal/driver ./harness/cmd/mnemon-harness ./harness/cmd/mnemon-multica-runtime-probe --- harness/cmd/mnemon-multica-runtime/main.go | 646 ++++++++++++++++++ .../cmd/mnemon-multica-runtime/main_test.go | 185 +++++ harness/internal/driver/multica.go | 22 +- harness/internal/driver/multica_test.go | 14 + 4 files changed, 866 insertions(+), 1 deletion(-) create mode 100644 harness/cmd/mnemon-multica-runtime/main.go create mode 100644 harness/cmd/mnemon-multica-runtime/main_test.go diff --git a/harness/cmd/mnemon-multica-runtime/main.go b/harness/cmd/mnemon-multica-runtime/main.go new file mode 100644 index 00000000..2e86626d --- /dev/null +++ b/harness/cmd/mnemon-multica-runtime/main.go @@ -0,0 +1,646 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "regexp" + "runtime" + "strings" + "time" + "unicode" + + "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" +) + +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 + CWD string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + Now func() time.Time +} + +type rpcMessage struct { + JSONRPC string `json:"jsonrpc,omitempty"` + ID any `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params map[string]any `json:"params,omitempty"` + Result any `json:"result,omitempty"` + Error any `json:"error,omitempty"` +} + +type runtimeRPCState struct { + CWD string + ThreadID string + TurnID string + Env []string + Now func() time.Time +} + +type runtimeImportResult struct { + IssueID string + Identifier string + Title string + Principal string + TaskID string + Status string + Receipt *access.IngestReceipt + Err error +} + +func main() { + if err := runRuntime(runtimeConfig{ + Args: os.Args[1:], + Env: os.Environ(), + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + Now: time.Now, + }); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func runRuntime(cfg runtimeConfig) error { + if cfg.Stdin == nil { + cfg.Stdin = strings.NewReader("") + } + if cfg.Stdout == nil { + cfg.Stdout = io.Discard + } + if cfg.Stderr == nil { + cfg.Stderr = io.Discard + } + if cfg.Now == nil { + cfg.Now = time.Now + } + cwd := strings.TrimSpace(cfg.CWD) + if cwd == "" { + var err error + cwd, err = os.Getwd() + if err != nil { + return err + } + } + if wantsVersion(cfg.Args) { + fmt.Fprintf(cfg.Stdout, "mnemon-multica-runtime %s\n", runtimeVersion) + return nil + } + return runRuntimeRPC(cfg, cwd) +} + +func runRuntimeRPC(cfg runtimeConfig, cwd string) error { + scanner := bufio.NewScanner(cfg.Stdin) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + state := runtimeRPCState{CWD: cwd, Env: cfg.Env, Now: cfg.Now} + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + 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) + continue + } + responses := state.handle(msg) + for _, response := range responses { + if err := writeRuntimeRPC(cfg.Stdout, response); err != nil { + return err + } + } + } + return scanner.Err() +} + +func (s *runtimeRPCState) handle(msg rpcMessage) []rpcMessage { + switch msg.Method { + case "initialize": + return []rpcMessage{{ + ID: msg.ID, + Result: map[string]any{ + "userAgent": "mnemon-multica-runtime/" + runtimeVersion, + "codexHome": envValue(s.Env, "CODEX_HOME"), + "platformFamily": "unix", + "platformOs": runtime.GOOS, + }, + }} + case "initialized": + return nil + case "thread/start", "thread/resume": + s.ThreadID = firstNonEmpty(stringParam(msg.Params, "threadId"), runtimeID("thread", s.now())) + return []rpcMessage{ + { + Method: "remoteControl/status/changed", + Params: map[string]any{ + "status": "disabled", + "serverName": "mnemon-multica-runtime", + "installationId": "mnemon-multica-runtime", + }, + }, + { + ID: msg.ID, + Result: s.threadStartResult(msg.Params), + }, + { + Method: "thread/started", + Params: map[string]any{ + "thread": s.threadObject(), + }, + }, + } + case "thread/name/set", "session/set_model": + return []rpcMessage{{ID: msg.ID, Result: map[string]any{}}} + case "turn/start": + s.TurnID = runtimeID("turn", s.now()) + input := extractRuntimeInput(msg.Params) + finalAnswer := s.runTurn(input) + return []rpcMessage{ + { + ID: msg.ID, + Result: map[string]any{ + "turn": s.turnObject("inProgress"), + }, + }, + { + Method: "thread/status/changed", + Params: map[string]any{"threadId": s.ThreadID, "status": map[string]any{"type": "active", "activeFlags": []any{}}}, + }, + { + Method: "turn/started", + Params: map[string]any{"threadId": s.ThreadID, "turn": s.turnObject("inProgress")}, + }, + { + Method: "item/started", + Params: map[string]any{"threadId": s.ThreadID, "turnId": s.TurnID, "item": runtimeAgentMessage("")}, + }, + { + Method: "item/agentMessage/delta", + Params: map[string]any{ + "threadId": s.ThreadID, + "turnId": s.TurnID, + "itemId": "mnemon-runtime-message", + "delta": finalAnswer, + }, + }, + { + Method: "item/completed", + Params: map[string]any{"threadId": s.ThreadID, "turnId": s.TurnID, "item": runtimeAgentMessage(finalAnswer)}, + }, + { + Method: "thread/status/changed", + Params: map[string]any{"threadId": s.ThreadID, "status": map[string]any{"type": "idle"}}, + }, + { + Method: "turn/completed", + Params: map[string]any{"threadId": s.ThreadID, "turn": s.turnObject("completed")}, + }, + } + default: + if msg.ID == nil { + return nil + } + return []rpcMessage{{ID: msg.ID, Result: map[string]any{}}} + } +} + +func (s *runtimeRPCState) runTurn(input string) string { + result := s.importIssue(input) + return formatRuntimeFinalAnswer(result) +} + +func (s *runtimeRPCState) importIssue(input string) runtimeImportResult { + taskID := envValue(s.Env, "MULTICA_TASK_ID") + issueID := firstNonEmpty(envValue(s.Env, "MULTICA_ISSUE_ID"), extractAssignedIssueID(input)) + result := runtimeImportResult{ + IssueID: issueID, + Principal: resolveRuntimePrincipal(s.Env, s.CWD), + TaskID: taskID, + } + if issueID == "" { + result.Status = "skipped" + result.Err = fmt.Errorf("no Multica issue id was available in task environment or runtime input") + return result + } + ctx, cancel := context.WithTimeout(context.Background(), runtimeTimeout(s.Env)) + defer cancel() + cli := runtimeMulticaCLI(s.Env) + issue, err := cli.GetIssue(ctx, issueID) + if err != nil { + result.Status = "failed" + result.Err = fmt.Errorf("fetch Multica issue %s: %w", issueID, err) + return result + } + result.IssueID = issue.ID + result.Identifier = issue.Identifier + result.Title = issue.Title + 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"), + 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")), + TaskID: taskID, + AgentID: envValue(s.Env, "MULTICA_AGENT_ID"), + Principal: result.Principal, + ContextRefs: []string{ + runtimeRef("issue", issue.ID), + runtimeRef("task", taskID), + runtimeRef("agent", envValue(s.Env, "MULTICA_AGENT_ID")), + }, + EvidenceRefs: []string{runtimeRef("issue", issue.ID)}, + ExternalID: externalID, + }) + if err != nil { + result.Status = "failed" + result.Err = err + return result + } + addr := strings.TrimSpace(envValue(s.Env, "MNEMON_CONTROL_ADDR")) + if addr == "" { + result.Status = "skipped" + return result + } + client, err := runtimeControlClient(s.Env, addr, result.Principal) + if err != nil { + result.Status = "failed" + result.Err = err + return result + } + rec, err := client.IngestObserve(contract.ActorID(result.Principal), contract.ObservationEnvelope{ + ExternalID: draft.ExternalID, + Event: contract.Event{ + Type: draft.EventType, + CorrelationID: payloadRuleString(draft.Payload, "correlation_id"), + Payload: draft.Payload, + }, + }) + if err != nil { + result.Status = "failed" + result.Err = fmt.Errorf("ingest Mnemon observation: %w", err) + return result + } + result.Status = "recorded" + result.Receipt = &rec + return result +} + +func runtimeMulticaCLI(env []string) 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")), + Env: append([]string(nil), env...), + Timeout: runtimeTimeout(env), + } +} + +func runtimeControlClient(env []string, addr, principal string) (*access.Client, error) { + token := envValue(env, "MNEMON_CONTROL_TOKEN") + if tokenFile := envValue(env, "MNEMON_CONTROL_TOKEN_FILE"); tokenFile != "" { + data, err := os.ReadFile(tokenFile) + if err != nil { + return nil, fmt.Errorf("read MNEMON_CONTROL_TOKEN_FILE: %w", err) + } + token = strings.TrimSpace(string(data)) + } + if token != "" { + return access.NewClientWithToken(addr, token), nil + } + if strings.TrimSpace(principal) == "" { + return nil, fmt.Errorf("Mnemon principal is required when MNEMON_CONTROL_TOKEN is not set") + } + return access.NewClient(addr, contract.ActorID(principal)), nil +} + +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 principal + } + if principal := envValue(env, "MNEMON_CONTROL_PRINCIPAL"); principal != "" { + return principal + } + if agentName != "" { + return sanitizePrincipal(agentName) + "@multica" + } + 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) + } + if strings.TrimSpace(cwd) != "" { + paths = append(paths, driver.MulticaRegistryPath(cwd, "")) + } + 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 "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(").") + } + } + return strings.TrimSpace(b.String()) +} + +func (s *runtimeRPCState) threadStartResult(params map[string]any) map[string]any { + if cwd := stringParam(params, "cwd"); cwd != "" { + s.CWD = cwd + } + return map[string]any{ + "thread": s.threadObject(), + "model": "mnemon-runtime", + "modelProvider": "mnemon", + "cwd": s.CWD, + "runtimeWorkspaceRoots": []string{s.CWD}, + "instructionSources": []string{}, + "approvalPolicy": "never", + "sandbox": map[string]any{"type": "dangerFullAccess"}, + "reasoningEffort": "", + "multiAgentMode": "explicitRequestOnly", + } +} + +func (s *runtimeRPCState) threadObject() map[string]any { + return map[string]any{ + "id": s.ThreadID, + "sessionId": s.ThreadID, + "ephemeral": true, + "modelProvider": "mnemon", + "createdAt": s.now().Unix(), + "updatedAt": s.now().Unix(), + "recencyAt": s.now().Unix(), + "status": map[string]any{"type": "idle"}, + "cwd": s.CWD, + "cliVersion": runtimeVersion, + "source": "multica", + "turns": []any{}, + } +} + +func (s *runtimeRPCState) turnObject(status string) map[string]any { + turn := map[string]any{ + "id": s.TurnID, + "items": []any{}, + "itemsView": "notLoaded", + "status": status, + "error": nil, + "startedAt": s.now().Unix(), + } + if status == "completed" { + turn["completedAt"] = s.now().Unix() + turn["durationMs"] = 1 + } + return turn +} + +func (s *runtimeRPCState) now() time.Time { + if s.Now != nil { + return s.Now().UTC() + } + return time.Now().UTC() +} + +func writeRuntimeRPC(w io.Writer, msg rpcMessage) error { + data, err := json.Marshal(msg) + if err != nil { + return err + } + _, err = fmt.Fprintln(w, string(data)) + return err +} + +func runtimeAgentMessage(text string) map[string]any { + return map[string]any{ + "type": "agentMessage", + "id": "mnemon-runtime-message", + "text": text, + "phase": "final_answer", + "memoryCitation": nil, + } +} + +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.SetOutput(io.Discard) + version := fs.Bool("version", false, "") + _ = fs.Parse(args) + if *version { + return true + } + for _, arg := range args { + switch arg { + case "version", "--version", "-version", "-v": + return true + } + } + return false +} + +func stringParam(params map[string]any, key string) string { + if params == nil { + return "" + } + value, _ := params[key].(string) + return strings.TrimSpace(value) +} + +func envValue(env []string, key string) string { + prefix := key + "=" + for _, item := range env { + 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) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func payloadRuleString(payload map[string]any, key string) string { + rule, _ := payload["rule"].(map[string]any) + value, _ := rule[key].(string) + return strings.TrimSpace(value) +} + +func runtimeID(prefix string, now time.Time) string { + if prefix == "" { + prefix = "id" + } + 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 sanitizePrincipal(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + var b strings.Builder + lastDash := false + for _, r := range value { + ok := unicode.IsLetter(r) || unicode.IsDigit(r) + if ok { + b.WriteRune(r) + lastDash = false + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + out := strings.Trim(b.String(), "-") + if out == "" { + return "multica-agent" + } + return out +} diff --git a/harness/cmd/mnemon-multica-runtime/main_test.go b/harness/cmd/mnemon-multica-runtime/main_test.go new file mode 100644 index 00000000..30a251cc --- /dev/null +++ b/harness/cmd/mnemon-multica-runtime/main_test.go @@ -0,0 +1,185 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "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" +) + +func TestRuntimeImportsAssignedIssueIntoMnemon(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-7"*) printf '{"id":"iss-7","identifier":"TEA-7","title":"Prepare teamwork acceptance","description":"Validate runtime issue import through real Multica task context.","status":"todo","priority":"medium"}\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", + 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) + } + + var gotPrincipal string + var got contract.ObservationEnvelope + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/ingest" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + 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: 17, Dup: false, Ticked: true}) + })) + defer srv.Close() + + input := strings.Join([]string{ + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`, + `{"jsonrpc":"2.0","method":"initialized"}`, + `{"jsonrpc":"2.0","id":2,"method":"thread/start","params":{"cwd":"` + tmp + `","ephemeral":true}}`, + `{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"threadId":"thread-1","input":[{"type":"text","text":"Your assigned issue ID is: iss-7\nPlease work on it."}]}}`, + }, "\n") + "\n" + + var out bytes.Buffer + err := runRuntime(runtimeConfig{ + Args: []string{"app-server", "--listen", "stdio://"}, + Env: append(os.Environ(), + "MNEMON_MULTICA_BIN="+bin, + "MNEMON_MULTICA_REGISTRY="+registryPath, + "MNEMON_CONTROL_ADDR="+srv.URL, + "MNEMON_CONTROL_PRINCIPAL=wrong@team", + "MULTICA_ARGS_PATH="+argsPath, + "MULTICA_WORKSPACE_ID=ws-1", + "MULTICA_TASK_ID=task-1", + "MULTICA_AGENT_ID=agent-1", + "MULTICA_AGENT_NAME=mnemon-planner", + "MULTICA_SERVER_URL=https://desktop-api.multica.ai", + ), + 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.ExternalID != "multica-task-task-1" { + t.Fatalf("external id = %q", got.ExternalID) + } + 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["external_issue_id"] != "iss-7" || rule["external_task_id"] != "task-1" || rule["external_agent_id"] != "agent-1" || rule["external_workspace_id"] != "ws-1" || rule["principal"] != "planner@team" { + t.Fatalf("rule mismatch: %+v", rule) + } + narrative := got.Event.Payload["narrative"].(map[string]any) + if narrative["statement"] != "Validate runtime issue import through real Multica task context." { + t.Fatalf("narrative mismatch: %+v", narrative) + } + if _, ok := narrative["external_task_id"]; ok { + t.Fatalf("narrative leaked rule ids: %+v", narrative) + } + if got.Event.CorrelationID != "multica:issue:iss-7" { + t.Fatalf("correlation id = %q", got.Event.CorrelationID) + } + + args, err := os.ReadFile(argsPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(args), "Validate runtime issue import") { + t.Fatalf("issue narrative leaked into CLI arguments: %q", string(args)) + } + if !strings.Contains(string(args), "issue get iss-7 --output json") { + t.Fatalf("unexpected multica args: %q", string(args)) + } + + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + var sawComplete, sawAnswer bool + for _, line := range lines { + var msg map[string]any + if err := json.Unmarshal([]byte(line), &msg); err != nil { + t.Fatalf("invalid rpc line %q: %v", line, err) + } + if msg["method"] == "turn/completed" { + sawComplete = true + } + if msg["method"] == "item/agentMessage/delta" && strings.Contains(line, "Mnemon ingest: recorded seq=17") { + sawAnswer = true + } + } + if !sawComplete || !sawAnswer { + t.Fatalf("missing expected runtime response complete=%v answer=%v:\n%s", sawComplete, sawAnswer, out.String()) + } +} + +func TestRuntimeSkipsIngestWhenControlAddressIsUnset(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" +printf '{"id":"iss-8","identifier":"TEA-8","title":"No local mnemond","description":"The runtime can still complete a Multica turn when local ingest is not configured."}\n' +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + input := `{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"input":[{"type":"text","text":"Your assigned issue ID is: iss-8"}]}}` + "\n" + var out bytes.Buffer + err := runRuntime(runtimeConfig{ + Args: []string{"app-server", "--listen", "stdio://"}, + Env: append(os.Environ(), + "MNEMON_MULTICA_BIN="+bin, + "MULTICA_ARGS_PATH="+argsPath, + "MULTICA_TASK_ID=task-8", + "MULTICA_AGENT_NAME=mnemon-reviewer", + ), + CWD: tmp, + Stdin: strings.NewReader(input), + Stdout: &out, + Now: fixedRuntimeTime, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "Mnemon ingest: skipped because MNEMON_CONTROL_ADDR is not set") { + t.Fatalf("runtime output did not report skipped ingest:\n%s", out.String()) + } +} + +func fixedRuntimeTime() time.Time { + return time.Date(2026, 6, 28, 9, 0, 0, 0, time.UTC) +} diff --git a/harness/internal/driver/multica.go b/harness/internal/driver/multica.go index 22093e34..a02aadfa 100644 --- a/harness/internal/driver/multica.go +++ b/harness/internal/driver/multica.go @@ -686,8 +686,13 @@ 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 { @@ -724,6 +729,10 @@ func BuildMulticaIssueTeamworkSignal(issue MulticaIssue, opts MulticaIssueSignal "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), @@ -740,9 +749,13 @@ func BuildMulticaIssueTeamworkSignal(issue MulticaIssue, opts MulticaIssueSignal } 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: "multica-issue-" + issue.ID, + ExternalID: externalID, Payload: eventmodel.BuildPayload(rule, narrative, refs), }, nil } @@ -801,3 +814,10 @@ func cleanMulticaRefs(values []string) []string { } 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_test.go b/harness/internal/driver/multica_test.go index 04caec23..d44ec535 100644 --- a/harness/internal/driver/multica_test.go +++ b/harness/internal/driver/multica_test.go @@ -18,7 +18,12 @@ func TestBuildMulticaIssueTeamworkSignalSeparatesRuleAndNarrative(t *testing.T) 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) @@ -30,6 +35,12 @@ func TestBuildMulticaIssueTeamworkSignalSeparatesRuleAndNarrative(t *testing.T) 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) @@ -37,6 +48,9 @@ func TestBuildMulticaIssueTeamworkSignalSeparatesRuleAndNarrative(t *testing.T) 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) { From 9c008e99c134ddf1713b537786be1f6db4553610 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 16:53:16 +0800 Subject: [PATCH 06/24] feat: configure multica participant runtime env Provision now writes explicit Multica agent custom_env for the Mnemon runtime registry, workspace, CLI, and optional local mnemond control settings using the audited agent env command over stdin. Validation: go test ./harness/internal/driver ./harness/cmd/mnemon-harness ./harness/cmd/mnemon-multica-runtime --- harness/cmd/mnemon-harness/multica.go | 105 ++++++++++++++++++--- harness/cmd/mnemon-harness/multica_test.go | 31 ++++++ harness/internal/driver/multica.go | 48 ++++++++++ harness/internal/driver/multica_test.go | 58 ++++++++++++ 4 files changed, 231 insertions(+), 11 deletions(-) diff --git a/harness/cmd/mnemon-harness/multica.go b/harness/cmd/mnemon-harness/multica.go index c4fdb1f2..655bf3f2 100644 --- a/harness/cmd/mnemon-harness/multica.go +++ b/harness/cmd/mnemon-harness/multica.go @@ -43,14 +43,17 @@ var ( multicaCommentTitle string multicaCommentEvents []string - multicaProvisionRegistry string - multicaProvisionProjectRoot string - multicaProvisionProfileName string - multicaProvisionRuntimeCommand string - multicaProvisionRuntimePath string - multicaProvisionAgentPrefix string - multicaProvisionRestartDaemon bool - multicaProvisionWait time.Duration + multicaProvisionRegistry string + multicaProvisionProjectRoot string + multicaProvisionProfileName string + multicaProvisionRuntimeCommand string + multicaProvisionRuntimePath string + multicaProvisionAgentPrefix string + multicaProvisionRestartDaemon bool + multicaProvisionWait time.Duration + multicaProvisionControlAddr string + multicaProvisionControlToken string + multicaProvisionControlTokenFile string ) var multicaCmd = &cobra.Command{ @@ -107,6 +110,7 @@ type multicaProvisionReport struct { CreatedAgents []string `json:"created_agents,omitempty"` RestoredAgents []string `json:"restored_agents,omitempty"` UpdatedAgents []string `json:"updated_agents,omitempty"` + UpdatedEnv []string `json:"updated_env,omitempty"` Warnings []string `json:"warnings,omitempty"` } @@ -239,6 +243,9 @@ func runMulticaProvision(cmd *cobra.Command, args []string) error { if workspaceID == "" { return fmt.Errorf("--multica-workspace-id is required for provision") } + if strings.TrimSpace(multicaProvisionControlToken) != "" && strings.TrimSpace(multicaProvisionControlTokenFile) != "" { + return fmt.Errorf("--mnemon-control-token and --mnemon-control-token-file are mutually exclusive") + } cli := multicaCLI() if err := ensureMulticaReady(ctx, cli); err != nil { return err @@ -251,7 +258,8 @@ func runMulticaProvision(cmd *cobra.Command, args []string) error { if runtimeCommand == "" { runtimeCommand = "mnemon-multica-runtime" } - report := multicaProvisionReport{WorkspaceID: workspaceID} + registryPath := driver.MulticaRegistryPath(multicaProvisionProjectRoot, multicaProvisionRegistry) + report := multicaProvisionReport{WorkspaceID: workspaceID, RegistryPath: registryPath} profile, created, err := ensureMulticaRuntimeProfile(ctx, cli, profileName, runtimeCommand) if err != nil { return err @@ -296,8 +304,6 @@ func runMulticaProvision(cmd *cobra.Command, args []string) error { } agents = upsertMulticaAgent(agents, agent) } - registryPath := driver.MulticaRegistryPath(multicaProvisionProjectRoot, multicaProvisionRegistry) - report.RegistryPath = registryPath if err := driver.SaveMulticaRegistry(registryPath, driver.MulticaRegistry{ SchemaVersion: 1, WorkspaceID: workspaceID, @@ -307,9 +313,21 @@ func runMulticaProvision(cmd *cobra.Command, args []string) error { }); err != nil { return err } + for _, participant := range report.Participants { + updated, err := ensureMulticaParticipantEnv(ctx, cli, participant, registryPath, workspaceID) + if err != nil { + return err + } + if updated { + report.UpdatedEnv = append(report.UpdatedEnv, participant.AgentID) + } + } if !multicaProvisionRestartDaemon && strings.TrimSpace(multicaProvisionRuntimePath) != "" && created { report.Warnings = append(report.Warnings, "if the runtime did not appear, restart the Multica daemon so the per-machine runtime path is loaded") } + if strings.TrimSpace(multicaProvisionControlAddr) == "" { + report.Warnings = append(report.Warnings, "participant env does not include MNEMON_CONTROL_ADDR; runtime turns can complete but will skip local mnemond ingest") + } if multicaJSON { enc := json.NewEncoder(cmd.OutOrStdout()) enc.SetIndent("", " ") @@ -423,6 +441,68 @@ func ensureMulticaParticipantAgent(ctx context.Context, cli driver.MulticaCLI, a return agent, "created", nil } +func ensureMulticaParticipantEnv(ctx context.Context, cli driver.MulticaCLI, participant driver.MulticaParticipantRecord, registryPath, workspaceID string) (bool, error) { + if strings.TrimSpace(participant.AgentID) == "" { + return false, fmt.Errorf("participant %s is missing a Multica agent id", participant.Principal) + } + existing, err := cli.GetAgentEnv(ctx, participant.AgentID) + 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) { + merged[key] = value + } + if sameStringMap(existing, merged) { + return false, nil + } + if _, err := cli.SetAgentEnv(ctx, participant.AgentID, merged); err != nil { + return false, fmt.Errorf("write Multica agent env %s: %w", participant.AgentID, err) + } + return true, nil +} + +func multicaParticipantRuntimeEnv(cli driver.MulticaCLI, participant driver.MulticaParticipantRecord, registryPath, workspaceID string) map[string]string { + env := map[string]string{} + addStringEnv(env, "MNEMON_MULTICA_REGISTRY", registryPath) + addStringEnv(env, "MNEMON_MULTICA_WORKSPACE_ID", workspaceID) + addStringEnv(env, "MNEMON_MULTICA_BIN", multicaCLICommand(cli)) + addStringEnv(env, "MNEMON_MULTICA_PROFILE", multicaProfile) + addStringEnv(env, "MNEMON_MULTICA_SERVER_URL", multicaServerURL) + addStringEnv(env, "MNEMON_CONTROL_ADDR", multicaProvisionControlAddr) + addStringEnv(env, "MNEMON_CONTROL_TOKEN", multicaProvisionControlToken) + addStringEnv(env, "MNEMON_CONTROL_TOKEN_FILE", multicaProvisionControlTokenFile) + addStringEnv(env, "MNEMON_CONTROL_PRINCIPAL", participant.Principal) + return env +} + +func addStringEnv(env map[string]string, key, value string) { + value = strings.TrimSpace(value) + if value != "" { + env[key] = value + } +} + +func cloneStringMap(in map[string]string) map[string]string { + out := map[string]string{} + for key, value := range in { + out[key] = value + } + return out +} + +func sameStringMap(a, b map[string]string) bool { + if len(a) != len(b) { + return false + } + for key, value := range a { + if b[key] != value { + return false + } + } + return true +} + func multicaParticipantInstructions(participant driver.MulticaParticipantRecord) string { return strings.Join([]string{ "You are the Multica-visible identity for Mnemon principal " + participant.Principal + ".", @@ -575,6 +655,9 @@ func init() { 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") multicaProvisionCmd.Flags().DurationVar(&multicaProvisionWait, "wait", 30*time.Second, "time to wait for the runtime to appear online") + multicaProvisionCmd.Flags().StringVar(&multicaProvisionControlAddr, "mnemon-control-addr", envDefault("MNEMON_CONTROL_ADDR", ""), "Local Mnemon URL injected into participant runtime env") + multicaProvisionCmd.Flags().StringVar(&multicaProvisionControlToken, "mnemon-control-token", envDefault("MNEMON_CONTROL_TOKEN", ""), "Local Mnemon bearer token injected into participant runtime env") + multicaProvisionCmd.Flags().StringVar(&multicaProvisionControlTokenFile, "mnemon-control-token-file", envDefault("MNEMON_CONTROL_TOKEN_FILE", ""), "Local Mnemon bearer token file injected into participant runtime env") multicaCmd.AddCommand(multicaProbeCmd, multicaProvisionCmd, multicaImportIssueCmd, multicaProjectCommentCmd) multicaCmd.GroupID = groupSpine diff --git a/harness/cmd/mnemon-harness/multica_test.go b/harness/cmd/mnemon-harness/multica_test.go index ed3dfb7d..04433c90 100644 --- a/harness/cmd/mnemon-harness/multica_test.go +++ b/harness/cmd/mnemon-harness/multica_test.go @@ -90,6 +90,7 @@ func TestMulticaProvisionCreatesParticipantRegistry(t *testing.T) { tmp := t.TempDir() bin := filepath.Join(tmp, "multica") + envStdinPath := filepath.Join(tmp, "agent-env.jsonl") script := `#!/usr/bin/env sh case "$*" in *"version --output json"*) printf '{"version":"v0.3.31","commit":"test","date":"now","os":"darwin","arch":"arm64","go":"go"}\n' ;; @@ -101,6 +102,8 @@ case "$*" in *"runtime list"*) printf '[{"id":"runtime-1","name":"Mnemon (Mac)","provider":"codex","status":"online","profile_id":"profile-1","workspace_id":"ws-1"}]\n' ;; *"agent list"*) printf '[]\n' ;; *"agent create"*) name=""; prev=""; for arg in "$@"; do if [ "$prev" = "--name" ]; then name="$arg"; fi; prev="$arg"; done; printf '{"id":"agent-%s","name":"%s","runtime_id":"runtime-1","status":"idle","visibility":"private","workspace_id":"ws-1"}\n' "$name" "$name" ;; + *"agent env get"*) printf '{}\n' ;; + *"agent env set"*) cat >> "$MULTICA_ENV_STDIN_PATH"; printf '\n' >> "$MULTICA_ENV_STDIN_PATH"; printf '{}\n' ;; *) printf '{}\n' ;; esac ` @@ -119,7 +122,9 @@ esac multicaProvisionAgentPrefix = "mnemon" multicaProvisionRestartDaemon = false multicaProvisionWait = 0 + multicaProvisionControlAddr = "http://127.0.0.1:8787" multicaJSON = true + t.Setenv("MULTICA_ENV_STDIN_PATH", envStdinPath) var out bytes.Buffer multicaProvisionCmd.SetOut(&out) @@ -136,6 +141,9 @@ esac if report.RuntimeProfile.ID != "profile-1" || report.Runtime.ID != "runtime-1" || len(report.Participants) != 5 { t.Fatalf("report mismatch: %+v", report) } + if len(report.UpdatedEnv) != 5 { + t.Fatalf("expected env updates for every participant: %+v", report) + } reg, ok, err := driver.LoadMulticaRegistry(registryPath) if err != nil { t.Fatal(err) @@ -151,6 +159,20 @@ esac t.Fatalf("participant missing agent id: %+v", participant) } } + envStdin, err := os.ReadFile(envStdinPath) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `"MNEMON_MULTICA_REGISTRY":"` + registryPath + `"`, + `"MNEMON_MULTICA_WORKSPACE_ID":"ws-1"`, + `"MNEMON_CONTROL_ADDR":"http://127.0.0.1:8787"`, + `"MNEMON_CONTROL_PRINCIPAL":"planner@team"`, + } { + if !strings.Contains(string(envStdin), want) { + t.Fatalf("agent env stdin missing %s:\n%s", want, envStdin) + } + } } func restoreMulticaFlags(t *testing.T) { @@ -186,6 +208,9 @@ func restoreMulticaFlags(t *testing.T) { oldProvisionAgentPrefix := multicaProvisionAgentPrefix oldProvisionRestartDaemon := multicaProvisionRestartDaemon oldProvisionWait := multicaProvisionWait + oldProvisionControlAddr := multicaProvisionControlAddr + oldProvisionControlToken := multicaProvisionControlToken + oldProvisionControlTokenFile := multicaProvisionControlTokenFile t.Cleanup(func() { multicaBin = oldBin multicaProfile = oldProfile @@ -218,6 +243,9 @@ func restoreMulticaFlags(t *testing.T) { multicaProvisionAgentPrefix = oldProvisionAgentPrefix multicaProvisionRestartDaemon = oldProvisionRestartDaemon multicaProvisionWait = oldProvisionWait + multicaProvisionControlAddr = oldProvisionControlAddr + multicaProvisionControlToken = oldProvisionControlToken + multicaProvisionControlTokenFile = oldProvisionControlTokenFile }) multicaBin = "" multicaProfile = "" @@ -249,4 +277,7 @@ func restoreMulticaFlags(t *testing.T) { multicaProvisionAgentPrefix = "mnemon" multicaProvisionRestartDaemon = false multicaProvisionWait = 30 * 1_000_000_000 + multicaProvisionControlAddr = "" + multicaProvisionControlToken = "" + multicaProvisionControlTokenFile = "" } diff --git a/harness/internal/driver/multica.go b/harness/internal/driver/multica.go index a02aadfa..676cfb7e 100644 --- a/harness/internal/driver/multica.go +++ b/harness/internal/driver/multica.go @@ -499,6 +499,54 @@ func (c MulticaCLI) UpdateAgent(ctx context.Context, agentID string, req Multica return agent, nil } +func (c MulticaCLI) GetAgentEnv(ctx context.Context, agentID string) (map[string]string, error) { + agentID = strings.TrimSpace(agentID) + if agentID == "" { + return nil, fmt.Errorf("multica agent id is required") + } + out, err := c.Run(ctx, []string{"agent", "env", "get", agentID, "--output", "json"}, "") + if err != nil { + return nil, err + } + var env map[string]string + if err := json.Unmarshal([]byte(out.Stdout), &env); err != nil { + return nil, fmt.Errorf("decode multica agent env: %w", err) + } + if env == nil { + env = map[string]string{} + } + return env, nil +} + +func (c MulticaCLI) SetAgentEnv(ctx context.Context, agentID string, env map[string]string) (map[string]string, error) { + agentID = strings.TrimSpace(agentID) + if agentID == "" { + return nil, fmt.Errorf("multica agent id is required") + } + if env == nil { + env = map[string]string{} + } + data, err := json.Marshal(env) + if err != nil { + return nil, fmt.Errorf("encode multica agent env: %w", err) + } + out, err := c.Run(ctx, []string{"agent", "env", "set", agentID, "--custom-env-stdin", "--output", "json"}, string(data)) + if err != nil { + return nil, err + } + if strings.TrimSpace(out.Stdout) == "" { + return env, nil + } + var updated map[string]string + if err := json.Unmarshal([]byte(out.Stdout), &updated); err != nil { + return nil, fmt.Errorf("decode updated multica agent env: %w", err) + } + if updated == nil { + updated = map[string]string{} + } + return updated, nil +} + func (c MulticaCLI) CreateIssue(ctx context.Context, req MulticaCreateIssueRequest) (MulticaIssue, error) { if strings.TrimSpace(req.Title) == "" { return MulticaIssue{}, fmt.Errorf("multica issue title is required") diff --git a/harness/internal/driver/multica_test.go b/harness/internal/driver/multica_test.go index d44ec535..b9c28bde 100644 --- a/harness/internal/driver/multica_test.go +++ b/harness/internal/driver/multica_test.go @@ -118,6 +118,64 @@ printf 'Server: https://api.multica.ai\nUser: Test User\nToken: mul_test.. } } +func TestMulticaCLIAgentEnvUsesStdin(t *testing.T) { + tmp := t.TempDir() + 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-1"*) printf '{"EXISTING":"****"}\n' ;; + *"agent env set agent-1"*) cat "$MULTICA_STDIN_PATH" ;; + *) 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, + "MULTICA_STDIN_PATH="+stdinPath, + ), + } + env, err := cli.GetAgentEnv(context.Background(), "agent-1") + if err != nil { + t.Fatal(err) + } + if env["EXISTING"] != "****" { + t.Fatalf("env = %+v", env) + } + updated, err := cli.SetAgentEnv(context.Background(), "agent-1", map[string]string{ + "EXISTING": "****", + "MNEMON_CONTROL_TOKEN": "secret-token", + "MNEMON_MULTICA_REGISTRY": "/tmp/registry.json", + }) + if err != nil { + t.Fatal(err) + } + if updated["MNEMON_CONTROL_TOKEN"] != "secret-token" { + t.Fatalf("updated env = %+v", updated) + } + args, err := os.ReadFile(argsPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(args), "secret-token") || !strings.Contains(string(args), "agent env set agent-1 --custom-env-stdin --output json") { + t.Fatalf("env update used unsafe args: %q", string(args)) + } + stdin, err := os.ReadFile(stdinPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(stdin), `"MNEMON_CONTROL_TOKEN":"secret-token"`) { + t.Fatalf("env JSON was not written to stdin: %s", stdin) + } +} + func TestMulticaCLIProvisioningCommandsUseExpectedShapes(t *testing.T) { tmp := t.TempDir() argsPath := filepath.Join(tmp, "args.txt") From 2c475700f0ec2e3b9aafc2da54d7a882ebde9af1 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 16:54:49 +0800 Subject: [PATCH 07/24] feat: project multica runtime imports After a Multica runtime task is admitted into Mnemon, append a stable Mnemon update comment back to the source issue so the product surface reflects runtime progress without changing canonical teamwork state. Validation: go test ./harness/cmd/mnemon-multica-runtime ./harness/internal/driver ./harness/cmd/mnemon-harness --- harness/cmd/mnemon-multica-runtime/main.go | 78 +++++++++++++++++-- .../cmd/mnemon-multica-runtime/main_test.go | 16 +++- 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/harness/cmd/mnemon-multica-runtime/main.go b/harness/cmd/mnemon-multica-runtime/main.go index 2e86626d..261f507d 100644 --- a/harness/cmd/mnemon-multica-runtime/main.go +++ b/harness/cmd/mnemon-multica-runtime/main.go @@ -51,14 +51,17 @@ type runtimeRPCState struct { } type runtimeImportResult struct { - IssueID string - Identifier string - Title string - Principal string - TaskID string - Status string - Receipt *access.IngestReceipt - Err error + IssueID string + Identifier string + Title string + Principal string + TaskID string + Status string + Receipt *access.IngestReceipt + ProjectionStatus string + ProjectionCommentID string + ProjectionErr error + Err error } func main() { @@ -299,9 +302,39 @@ func (s *runtimeRPCState) importIssue(input string) runtimeImportResult { } result.Status = "recorded" result.Receipt = &rec + s.projectImportComment(ctx, cli, issue, draft.ExternalID, &result) return result } +func (s *runtimeRPCState) projectImportComment(ctx context.Context, cli driver.MulticaCLI, issue driver.MulticaIssue, externalID string, result *runtimeImportResult) { + if result == nil { + return + } + if !runtimeProjectionEnabled(s.Env) { + result.ProjectionStatus = "skipped" + return + } + body := "Issue admitted into Mnemon teamwork." + if result.Principal != "" { + body += "\nPrincipal: " + result.Principal + } + if result.TaskID != "" { + body += "\nMultica task: " + result.TaskID + } + if result.Receipt != nil { + body += fmt.Sprintf("\nMnemon ingest: seq=%d duplicate=%v ticked=%v", result.Receipt.Seq, result.Receipt.Dup, result.Receipt.Ticked) + } + commentBody := driver.FormatMulticaProjectionComment("issue admitted", body, []string{externalID}) + comment, err := cli.AddIssueComment(ctx, issue.ID, commentBody) + if err != nil { + result.ProjectionStatus = "failed" + result.ProjectionErr = err + return + } + result.ProjectionStatus = "commented" + result.ProjectionCommentID = comment.ID +} + func runtimeMulticaCLI(env []string) driver.MulticaCLI { return driver.MulticaCLI{ Command: envValue(env, "MNEMON_MULTICA_BIN"), @@ -440,6 +473,25 @@ func formatRuntimeFinalAnswer(result runtimeImportResult) string { b.WriteString(").") } } + 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(".") + } return strings.TrimSpace(b.String()) } @@ -622,6 +674,16 @@ func runtimeTimeout(env []string) time.Duration { return d } +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 30a251cc..20eaa33a 100644 --- a/harness/cmd/mnemon-multica-runtime/main_test.go +++ b/harness/cmd/mnemon-multica-runtime/main_test.go @@ -19,11 +19,13 @@ import ( func TestRuntimeImportsAssignedIssueIntoMnemon(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 iss-7"*) printf '{"id":"iss-7","identifier":"TEA-7","title":"Prepare teamwork acceptance","description":"Validate runtime issue import through real Multica task context.","status":"todo","priority":"medium"}\n' ;; + *"issue comment add iss-7"*) cat > "$MULTICA_COMMENT_PATH"; printf '{"id":"comment-1","issue_id":"iss-7","content":"ok","type":"comment"}\n' ;; *) printf '{}\n' ;; esac ` @@ -77,6 +79,7 @@ esac "MNEMON_CONTROL_ADDR="+srv.URL, "MNEMON_CONTROL_PRINCIPAL=wrong@team", "MULTICA_ARGS_PATH="+argsPath, + "MULTICA_COMMENT_PATH="+commentPath, "MULTICA_WORKSPACE_ID=ws-1", "MULTICA_TASK_ID=task-1", "MULTICA_AGENT_ID=agent-1", @@ -123,9 +126,18 @@ esac if strings.Contains(string(args), "Validate runtime issue import") { t.Fatalf("issue narrative leaked into CLI arguments: %q", string(args)) } - if !strings.Contains(string(args), "issue get iss-7 --output json") { + if !strings.Contains(string(args), "issue comment add iss-7 --content-stdin --output json") { t.Fatalf("unexpected multica args: %q", string(args)) } + comment, err := os.ReadFile(commentPath) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"Mnemon update: issue admitted", "Principal: planner@team", "mnemon:event=multica-task-task-1"} { + if !strings.Contains(string(comment), want) { + t.Fatalf("comment missing %s:\n%s", want, comment) + } + } lines := strings.Split(strings.TrimSpace(out.String()), "\n") var sawComplete, sawAnswer bool @@ -137,7 +149,7 @@ esac if msg["method"] == "turn/completed" { sawComplete = true } - if msg["method"] == "item/agentMessage/delta" && strings.Contains(line, "Mnemon ingest: recorded seq=17") { + if msg["method"] == "item/agentMessage/delta" && strings.Contains(line, "Mnemon ingest: recorded seq=17") && strings.Contains(line, "Multica projection: comment=comment-1") { sawAnswer = true } } From 5714b30251e06ad935b381c35014bf92f9f2a5a9 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 16:58:28 +0800 Subject: [PATCH 08/24] feat: wake managed agents from multica runtime The Multica runtime can now render local Mnemon wake candidates and drive the addressed managed agent with the sentinel-only wake query when MNEMON_MANAGED_RUNTIME is explicitly configured. Projection comments include the managed wake status. Validation: go test ./harness/cmd/mnemon-multica-runtime ./harness/internal/driver ./harness/cmd/mnemon-harness ./harness/cmd/mnemond ./harness/cmd/mnemon-acceptance --- harness/cmd/mnemon-multica-runtime/main.go | 186 +++++++++++++++++- .../cmd/mnemon-multica-runtime/main_test.go | 51 ++++- 2 files changed, 222 insertions(+), 15 deletions(-) diff --git a/harness/cmd/mnemon-multica-runtime/main.go b/harness/cmd/mnemon-multica-runtime/main.go index 261f507d..92f43dc9 100644 --- a/harness/cmd/mnemon-multica-runtime/main.go +++ b/harness/cmd/mnemon-multica-runtime/main.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "os" + "path/filepath" "regexp" "runtime" "strings" @@ -17,6 +18,7 @@ import ( "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/mnemond/presentation" ) const runtimeVersion = "dev" @@ -61,6 +63,9 @@ type runtimeImportResult struct { ProjectionStatus string ProjectionCommentID string ProjectionErr error + WakeStatus string + WakeTurnID string + WakeErr error Err error } @@ -302,10 +307,83 @@ func (s *runtimeRPCState) importIssue(input string) runtimeImportResult { } result.Status = "recorded" result.Receipt = &rec + s.wakeManagedAgent(&result) s.projectImportComment(ctx, cli, issue, draft.ExternalID, &result) return result } +func (s *runtimeRPCState) wakeManagedAgent(result *runtimeImportResult) { + if result == nil { + return + } + runtimeName := strings.TrimSpace(envValue(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")) + if addr == "" { + result.WakeStatus = "skipped" + result.WakeErr = fmt.Errorf("MNEMON_CONTROL_ADDR is not set") + return + } + token, err := runtimeControlToken(s.Env) + if err != nil { + result.WakeStatus = "failed" + result.WakeErr = err + return + } + renderCtx, cancel := context.WithTimeout(context.Background(), runtimeTimeout(s.Env)) + defer cancel() + resp, err := (driver.HTTPRenderClient{ + BaseURL: addr, + Token: token, + Principal: contract.ActorID(result.Principal), + }).Render(renderCtx, presentation.Request{ + SchemaVersion: 1, + Principal: contract.ActorID(result.Principal), + Host: "multica", + Lifecycle: envDefault(s.Env, "MNEMON_MANAGED_RENDER_LIFECYCLE", "remind"), + Surface: "runtime", + RenderIntent: presentation.IntentTeamworkEvents, + }) + if err != nil { + result.WakeStatus = "failed" + result.WakeErr = fmt.Errorf("render managed wake candidates: %w", err) + return + } + candidates := driver.ManagedWakeCandidatesFromRender(result.Principal, resp) + if len(candidates) == 0 { + result.WakeStatus = "skipped" + result.WakeErr = fmt.Errorf("no managed wake candidate in rendered context") + return + } + client, workspace, err := runtimeManagedTurnClient(s.Env, s.CWD, runtimeName) + if err != nil { + result.WakeStatus = "failed" + result.WakeErr = err + return + } + wakeCtx, cancel := context.WithTimeout(context.Background(), runtimeManagedTurnTimeout(s.Env)) + defer cancel() + record, err := (&driver.ManagedAgentDriver{ + Principal: result.Principal, + Client: client, + Ledger: driver.NewFileManagedWakeLedger(runtimeManagedLedgerPath(s.Env, workspace)), + Now: func() time.Time { return s.now() }, + }).Wake(wakeCtx, candidates[0]) + result.WakeTurnID = record.TurnID + if err != nil { + result.WakeStatus = "failed" + result.WakeErr = err + return + } + result.WakeStatus = record.Status + if result.WakeStatus == "" { + result.WakeStatus = "completed" + } +} + func (s *runtimeRPCState) projectImportComment(ctx context.Context, cli driver.MulticaCLI, issue driver.MulticaIssue, externalID string, result *runtimeImportResult) { if result == nil { return @@ -324,6 +402,9 @@ func (s *runtimeRPCState) projectImportComment(ctx context.Context, cli driver.M 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 + } commentBody := driver.FormatMulticaProjectionComment("issue admitted", body, []string{externalID}) comment, err := cli.AddIssueComment(ctx, issue.ID, commentBody) if err != nil { @@ -347,21 +428,60 @@ func runtimeMulticaCLI(env []string) driver.MulticaCLI { } func runtimeControlClient(env []string, addr, principal string) (*access.Client, error) { + token, err := runtimeControlToken(env) + if err != nil { + return nil, err + } + if token != "" { + return access.NewClientWithToken(addr, token), nil + } + if strings.TrimSpace(principal) == "" { + return nil, fmt.Errorf("Mnemon principal is required when MNEMON_CONTROL_TOKEN is not set") + } + return access.NewClient(addr, contract.ActorID(principal)), nil +} + +func runtimeControlToken(env []string) (string, error) { token := envValue(env, "MNEMON_CONTROL_TOKEN") if tokenFile := envValue(env, "MNEMON_CONTROL_TOKEN_FILE"); tokenFile != "" { data, err := os.ReadFile(tokenFile) if err != nil { - return nil, fmt.Errorf("read MNEMON_CONTROL_TOKEN_FILE: %w", err) + return "", fmt.Errorf("read MNEMON_CONTROL_TOKEN_FILE: %w", err) } token = strings.TrimSpace(string(data)) } - if token != "" { - return access.NewClientWithToken(addr, token), nil + return token, nil +} + +func runtimeManagedTurnClient(env []string, cwd, runtimeName string) (driver.ManagedTurnClient, string, error) { + workspace := envDefault(env, "MNEMON_MANAGED_WORKSPACE", cwd) + if strings.TrimSpace(workspace) == "" { + workspace = "." + } + switch strings.TrimSpace(runtimeName) { + case "noop": + return runtimeNoopTurnClient{}, workspace, nil + case "codex-appserver": + return driver.CodexAppServerTurnClient{ + Principal: resolveRuntimePrincipal(env, cwd), + Command: envDefault(env, "MNEMON_MANAGED_COMMAND", "codex"), + Workspace: workspace, + Env: append([]string(nil), env...), + TurnTimeout: runtimeManagedTurnTimeout(env), + ClientName: "mnemon-multica-runtime", + }, workspace, nil + default: + return nil, workspace, fmt.Errorf("unsupported MNEMON_MANAGED_RUNTIME %q", runtimeName) } - if strings.TrimSpace(principal) == "" { - return nil, fmt.Errorf("Mnemon principal is required when MNEMON_CONTROL_TOKEN is not set") +} + +type runtimeNoopTurnClient struct{} + +func (runtimeNoopTurnClient) StartTurn(_ context.Context, query string) (driver.ManagedTurnResult, error) { + if strings.TrimSpace(query) != driver.ManagedWakeQuery { + return driver.ManagedTurnResult{}, fmt.Errorf("unexpected managed wake query %q", query) } - return access.NewClient(addr, contract.ActorID(principal)), nil + return driver.ManagedTurnResult{TurnID: "noop-turn", Status: "completed"}, nil } func resolveRuntimePrincipal(env []string, cwd string) string { @@ -492,6 +612,37 @@ func formatRuntimeFinalAnswer(result runtimeImportResult) string { } 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(".") + } + } return strings.TrimSpace(b.String()) } @@ -674,6 +825,29 @@ func runtimeTimeout(env []string) time.Duration { 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 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 { diff --git a/harness/cmd/mnemon-multica-runtime/main_test.go b/harness/cmd/mnemon-multica-runtime/main_test.go index 20eaa33a..db07bd62 100644 --- a/harness/cmd/mnemon-multica-runtime/main_test.go +++ b/harness/cmd/mnemon-multica-runtime/main_test.go @@ -13,7 +13,9 @@ import ( "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" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation" ) func TestRuntimeImportsAssignedIssueIntoMnemon(t *testing.T) { @@ -51,15 +53,44 @@ esac var gotPrincipal string var got contract.ObservationEnvelope srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/ingest" { + 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: 17, Dup: false, Ticked: true}) + case "/render": + if r.Header.Get(access.PrincipalHeader) != "planner@team" { + t.Fatalf("render principal header = %q", r.Header.Get(access.PrincipalHeader)) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(presentation.Response{ + SchemaVersion: 1, + Status: presentation.StatusOK, + AuditID: "render-audit-1", + BodyDigest: "sha256:render-body", + Events: []eventmodel.EventEnvelope{{ + SchemaVersion: eventmodel.SchemaVersion, + Phase: eventmodel.PhaseDerived, + Event: eventmodel.Event{ + SchemaVersion: eventmodel.SchemaVersion, + ID: "derived-1", + Type: "assignment.brief.derived", + Subject: "assignment/asg-1", + Actor: "mnemond", + Audience: "planner@team", + Payload: eventmodel.BuildPayload(nil, map[string]any{ + "body": "assignment for iss-7", + }, nil), + }, + Meta: map[string]any{"presentation_hint": "work"}, + }}, + }) + default: t.Fatalf("unexpected path %s", r.URL.Path) } - 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: 17, Dup: false, Ticked: true}) })) defer srv.Close() @@ -78,6 +109,8 @@ esac "MNEMON_MULTICA_REGISTRY="+registryPath, "MNEMON_CONTROL_ADDR="+srv.URL, "MNEMON_CONTROL_PRINCIPAL=wrong@team", + "MNEMON_MANAGED_RUNTIME=noop", + "MNEMON_MANAGED_LEDGER="+filepath.Join(tmp, "wake-ledger.jsonl"), "MULTICA_ARGS_PATH="+argsPath, "MULTICA_COMMENT_PATH="+commentPath, "MULTICA_WORKSPACE_ID=ws-1", @@ -133,7 +166,7 @@ esac if err != nil { t.Fatal(err) } - for _, want := range []string{"Mnemon update: issue admitted", "Principal: planner@team", "mnemon:event=multica-task-task-1"} { + for _, want := range []string{"Mnemon update: issue admitted", "Principal: planner@team", "Managed wake: completed", "mnemon:event=multica-task-task-1"} { if !strings.Contains(string(comment), want) { t.Fatalf("comment missing %s:\n%s", want, comment) } @@ -149,7 +182,7 @@ esac if msg["method"] == "turn/completed" { sawComplete = true } - if msg["method"] == "item/agentMessage/delta" && strings.Contains(line, "Mnemon ingest: recorded seq=17") && strings.Contains(line, "Multica projection: comment=comment-1") { + 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") { sawAnswer = true } } From f95ca096350d821a9922a71715256c804a202230 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 17:03:21 +0800 Subject: [PATCH 09/24] test: add multica runtime prod sim acceptance Add a test-only Multica runtime prod-sim command that starts from a real Multica issue assignment, waits for runtime run evidence, and verifies Mnemon ingest and optional managed wake signals from run messages. Validation: go test ./harness/cmd/mnemon-multica-runtime ./harness/internal/driver ./harness/cmd/mnemon-harness ./harness/cmd/mnemond ./harness/cmd/mnemon-acceptance --- .../acceptance_multica_runtime.go | 356 ++++++++++++++++++ .../acceptance_multica_runtime_test.go | 95 +++++ harness/cmd/mnemon-acceptance/root_test.go | 1 + 3 files changed, 452 insertions(+) create mode 100644 harness/cmd/mnemon-acceptance/acceptance_multica_runtime.go create mode 100644 harness/cmd/mnemon-acceptance/acceptance_multica_runtime_test.go diff --git a/harness/cmd/mnemon-acceptance/acceptance_multica_runtime.go b/harness/cmd/mnemon-acceptance/acceptance_multica_runtime.go new file mode 100644 index 00000000..8277750d --- /dev/null +++ b/harness/cmd/mnemon-acceptance/acceptance_multica_runtime.go @@ -0,0 +1,356 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/driver" + "github.com/spf13/cobra" +) + +var ( + acceptanceMulticaBin string + acceptanceMulticaProfile string + acceptanceMulticaServerURL string + acceptanceMulticaWorkspaceID string + acceptanceMulticaRegistry string + acceptanceMulticaAssigneePrincipal string + acceptanceMulticaIssueTitle string + acceptanceMulticaIssueDescription string + acceptanceMulticaWait time.Duration + acceptanceMulticaPoll time.Duration + acceptanceMulticaRequireIngest bool + acceptanceMulticaRequireManagedWake bool +) + +var acceptanceMulticaRuntimeCmd = &cobra.Command{ + Use: "multica-runtime-prod-sim", + Short: "Run a real Multica issue-to-runtime acceptance check", + RunE: func(cmd *cobra.Command, args []string) error { + report, err := runMulticaRuntimeProdSimAcceptance(cmd.Context(), multicaRuntimeProdSimOptions{ + RunRoot: acceptanceRunRoot, + MulticaBin: acceptanceMulticaBin, + Profile: acceptanceMulticaProfile, + ServerURL: acceptanceMulticaServerURL, + WorkspaceID: acceptanceMulticaWorkspaceID, + RegistryPath: acceptanceMulticaRegistry, + AssigneePrincipal: acceptanceMulticaAssigneePrincipal, + IssueTitle: acceptanceMulticaIssueTitle, + IssueDescription: acceptanceMulticaIssueDescription, + Wait: acceptanceMulticaWait, + Poll: acceptanceMulticaPoll, + RequireIngest: acceptanceMulticaRequireIngest, + RequireManagedWake: acceptanceMulticaRequireManagedWake, + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), + }) + if report.ReportPath != "" { + fmt.Fprintf(cmd.OutOrStdout(), "acceptance report: %s\n", report.ReportPath) + } + if err != nil { + return err + } + if report.Status != "ok" { + return fmt.Errorf("Multica runtime prod-sim status: %s", report.Status) + } + return nil + }, +} + +func init() { + acceptanceMulticaRuntimeCmd.Flags().StringVar(&acceptanceRunRoot, "run-root", "", "acceptance run directory") + acceptanceMulticaRuntimeCmd.Flags().StringVar(&acceptanceMulticaBin, "multica-bin", multicaAcceptanceEnvDefault("MNEMON_MULTICA_BIN", ""), "Multica CLI path") + acceptanceMulticaRuntimeCmd.Flags().StringVar(&acceptanceMulticaProfile, "multica-profile", multicaAcceptanceEnvDefault("MNEMON_MULTICA_PROFILE", ""), "Multica CLI profile") + acceptanceMulticaRuntimeCmd.Flags().StringVar(&acceptanceMulticaServerURL, "multica-server-url", multicaAcceptanceEnvDefault("MNEMON_MULTICA_SERVER_URL", ""), "Multica server URL") + 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(&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") + acceptanceMulticaRuntimeCmd.Flags().DurationVar(&acceptanceMulticaPoll, "poll", 5*time.Second, "poll interval for Multica runs") + acceptanceMulticaRuntimeCmd.Flags().BoolVar(&acceptanceMulticaRequireIngest, "require-mnemon-ingest", true, "require run output to show recorded Mnemon ingest") + acceptanceMulticaRuntimeCmd.Flags().BoolVar(&acceptanceMulticaRequireManagedWake, "require-managed-wake", false, "require run output to show a completed managed wake") + rootCmd.AddCommand(acceptanceMulticaRuntimeCmd) +} + +type multicaRuntimeProdSimOptions struct { + RunRoot string + MulticaBin string + Profile string + ServerURL string + WorkspaceID string + RegistryPath string + AssigneePrincipal string + IssueTitle string + IssueDescription string + Wait time.Duration + Poll time.Duration + RequireIngest bool + RequireManagedWake bool + Stdout io.Writer + Stderr io.Writer +} + +type multicaRuntimeProdSimReport struct { + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` + StartedAt string `json:"started_at"` + FinishedAt string `json:"finished_at"` + RunRoot string `json:"run_root"` + ReportPath string `json:"report_path"` + WorkspaceID string `json:"workspace_id"` + RegistryPath string `json:"registry_path"` + Assignee driver.MulticaParticipantRecord `json:"assignee"` + Issue driver.MulticaIssue `json:"issue"` + Runs []driver.MulticaIssueRun `json:"runs,omitempty"` + RunMessages []driver.MulticaRunMessage `json:"run_messages,omitempty"` + Assertions []multicaRuntimeProdSimAssertion `json:"assertions"` + Errors []string `json:"errors,omitempty"` +} + +type multicaRuntimeProdSimAssertion struct { + Name string `json:"name"` + Passed bool `json:"passed"` + Detail string `json:"detail,omitempty"` +} + +func runMulticaRuntimeProdSimAcceptance(ctx context.Context, opts multicaRuntimeProdSimOptions) (multicaRuntimeProdSimReport, error) { + started := time.Now().UTC() + if opts.Stdout == nil { + opts.Stdout = io.Discard + } + if opts.Stderr == nil { + opts.Stderr = io.Discard + } + if opts.Wait <= 0 { + opts.Wait = 10 * time.Minute + } + if opts.Poll <= 0 { + opts.Poll = 5 * time.Second + } + runRoot := strings.TrimSpace(opts.RunRoot) + if runRoot == "" { + runRoot = filepath.Join(".testdata", "multica-runtime-prod-sim", started.Format("20060102T150405Z")) + } + absRunRoot, err := filepath.Abs(runRoot) + if err != nil { + return multicaRuntimeProdSimReport{}, err + } + runRoot = absRunRoot + report := multicaRuntimeProdSimReport{ + SchemaVersion: 1, + Status: "ok", + StartedAt: started.Format(time.RFC3339), + RunRoot: runRoot, + WorkspaceID: strings.TrimSpace(opts.WorkspaceID), + RegistryPath: strings.TrimSpace(opts.RegistryPath), + } + if err := prepareR1AcceptanceRunRoot(runRoot); err != nil { + return finishMulticaRuntimeProdSimReport(report, err) + } + 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) + } + if !ok { + return finishMulticaRuntimeProdSimReport(report, fmt.Errorf("Multica registry not found: %s", registryPath)) + } + report.RegistryPath = registryPath + assignee, err := selectMulticaAcceptanceAssignee(registry, opts.AssigneePrincipal) + if err != nil { + return finishMulticaRuntimeProdSimReport(report, err) + } + report.Assignee = assignee + cli := driver.MulticaCLI{ + Command: strings.TrimSpace(opts.MulticaBin), + Profile: strings.TrimSpace(opts.Profile), + ServerURL: strings.TrimSpace(opts.ServerURL), + WorkspaceID: multicaAcceptanceFirstNonEmpty(strings.TrimSpace(opts.WorkspaceID), strings.TrimSpace(registry.WorkspaceID)), + Env: os.Environ(), + Timeout: 30 * time.Second, + } + title := strings.TrimSpace(opts.IssueTitle) + if title == "" { + title = "Mnemon Multica runtime prod-sim " + started.Format("150405") + } + description := strings.TrimSpace(opts.IssueDescription) + if description == "" { + description = "Validate that a Multica issue assigned to a Mnemon participant enters the Mnemon runtime path and reports product-visible progress." + } + issue, err := cli.CreateIssue(ctx, driver.MulticaCreateIssueRequest{ + Title: title, + Description: description, + AssigneeID: assignee.AgentID, + AllowDuplicate: true, + }) + if err != nil { + return finishMulticaRuntimeProdSimReport(report, err) + } + 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) + report.Runs = runs + report.RunMessages = messages + if err != nil { + addMulticaProdSimAssertion(&report, "Multica runtime produced run evidence", false, err.Error()) + return finishMulticaRuntimeProdSimReport(report, 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 opts.RequireManagedWake { + addMulticaProdSimAssertion(&report, "runtime completed managed wake", strings.Contains(combined, "Managed wake: completed"), combined) + } + if !multicaProdSimAssertionsPassed(report) { + return finishMulticaRuntimeProdSimReport(report, fmt.Errorf("Multica runtime prod-sim assertions failed")) + } + return finishMulticaRuntimeProdSimReport(report, nil) +} + +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 { + if strings.TrimSpace(participant.AgentID) != "" { + return participant, nil + } + } + return driver.MulticaParticipantRecord{}, fmt.Errorf("registry has no participant with a Multica agent id") +} + +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 + for { + runs, err := cli.ListIssueRuns(ctx, issueID) + if err != nil { + return lastRuns, nil, err + } + lastRuns = runs + for _, run := range runs { + if !multicaRunTerminal(run) { + continue + } + messages, err := cli.ListRunMessages(ctx, run.ID, issueID) + if err != nil { + return runs, nil, err + } + if len(messages) > 0 { + return runs, messages, nil + } + } + if wait <= 0 || time.Now().After(deadline) { + return lastRuns, nil, fmt.Errorf("timed out waiting for Multica runtime evidence on issue %s", issueID) + } + select { + case <-ctx.Done(): + return lastRuns, nil, ctx.Err() + case <-time.After(poll): + } + } +} + +func multicaRunTerminal(run driver.MulticaIssueRun) bool { + if strings.TrimSpace(run.CompletedAt) != "" { + return true + } + switch strings.ToLower(strings.TrimSpace(run.Status)) { + case "completed", "complete", "succeeded", "success", "done", "failed", "error", "errored", "cancelled", "canceled": + return true + default: + return false + } +} + +func combinedMulticaRunMessages(messages []driver.MulticaRunMessage) string { + var parts []string + for _, message := range messages { + if strings.TrimSpace(message.Content) != "" { + parts = append(parts, strings.TrimSpace(message.Content)) + } + } + return strings.Join(parts, "\n") +} + +func addMulticaProdSimAssertion(report *multicaRuntimeProdSimReport, name string, passed bool, detail string) { + report.Assertions = append(report.Assertions, multicaRuntimeProdSimAssertion{Name: name, Passed: passed, Detail: detail}) +} + +func multicaProdSimAssertionsPassed(report multicaRuntimeProdSimReport) bool { + for _, assertion := range report.Assertions { + if !assertion.Passed { + return false + } + } + return true +} + +func finishMulticaRuntimeProdSimReport(report multicaRuntimeProdSimReport, err error) (multicaRuntimeProdSimReport, error) { + report.FinishedAt = time.Now().UTC().Format(time.RFC3339) + if err != nil { + report.Status = "failed" + report.Errors = append(report.Errors, err.Error()) + } else if !multicaProdSimAssertionsPassed(report) { + report.Status = "failed" + } else { + report.Status = "ok" + } + path := filepath.Join(report.RunRoot, "acceptance-report.json") + raw, marshalErr := json.MarshalIndent(report, "", " ") + if marshalErr != nil { + return report, marshalErr + } + if writeErr := os.WriteFile(path, append(raw, '\n'), 0o644); writeErr != nil { + return report, writeErr + } + report.ReportPath = path + raw, marshalErr = json.MarshalIndent(report, "", " ") + if marshalErr != nil { + return report, marshalErr + } + if writeErr := os.WriteFile(path, append(raw, '\n'), 0o644); writeErr != nil { + return report, writeErr + } + return report, err +} + +func multicaAcceptanceEnvDefault(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func multicaAcceptanceFirstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/harness/cmd/mnemon-acceptance/acceptance_multica_runtime_test.go b/harness/cmd/mnemon-acceptance/acceptance_multica_runtime_test.go new file mode 100644 index 00000000..c147543b --- /dev/null +++ b/harness/cmd/mnemon-acceptance/acceptance_multica_runtime_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/driver" +) + +func TestMulticaRuntimeProdSimAcceptanceObservesRunMessages(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-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' ;; + *) 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-runtime"), + MulticaBin: bin, + WorkspaceID: "ws-1", + RegistryPath: registryPath, + AssigneePrincipal: "planner@team", + IssueTitle: "Runtime prod sim", + IssueDescription: "Teamwork acceptance", + Wait: time.Millisecond, + Poll: time.Millisecond, + RequireIngest: true, + RequireManagedWake: true, + }) + if err != nil { + t.Fatalf("acceptance: %v", err) + } + if report.Status != "ok" || report.Issue.ID != "iss-9" || len(report.RunMessages) != 1 { + t.Fatalf("report mismatch: %+v", report) + } + 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 written.Status != "ok" || !multicaProdSimAssertionsPassed(written) { + t.Fatalf("written report mismatch: %+v", written) + } + args, err := os.ReadFile(argsPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(args), "issue create --title Runtime prod sim") || !strings.Contains(string(args), "--assignee-id agent-1") { + t.Fatalf("issue was not created through assigned Multica path:\n%s", args) + } + stdin, err := os.ReadFile(stdinPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(stdin), "Teamwork acceptance") { + t.Fatalf("issue description was not passed through stdin:\n%s", stdin) + } +} diff --git a/harness/cmd/mnemon-acceptance/root_test.go b/harness/cmd/mnemon-acceptance/root_test.go index 9d28d61d..9e0d92f8 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-runtime-prod-sim", } { if !commands[want] { t.Fatalf("mnemon-acceptance missing command %q; commands=%v", want, commands) From 7aa4a3ce49625ea5733fabbfe4f9986548f90f30 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 17:15:06 +0800 Subject: [PATCH 10/24] fix: harden multica managed wake setup Handle Multica's wrapped agent custom_env payload, let provisioning inject managed runtime settings, and select managed wake candidates that match the current Multica issue instead of stale rendered events. Validation: go test ./harness/cmd/mnemon-multica-runtime ./harness/cmd/mnemon-harness ./harness/internal/driver ./harness/cmd/mnemond ./harness/cmd/mnemon-acceptance; live Multica prod-sim passed with real Codex app-server managed wake at /tmp/mnemon-multica-live/acceptance-codex-wake-2/acceptance-report.json --- harness/cmd/mnemon-harness/multica.go | 14 +++++ harness/cmd/mnemon-harness/multica_test.go | 16 ++++++ harness/cmd/mnemon-multica-runtime/main.go | 55 ++++++++++++++++++- .../cmd/mnemon-multica-runtime/main_test.go | 15 +++++ harness/internal/driver/multica.go | 44 +++++++++++---- harness/internal/driver/multica_test.go | 4 +- 6 files changed, 133 insertions(+), 15 deletions(-) diff --git a/harness/cmd/mnemon-harness/multica.go b/harness/cmd/mnemon-harness/multica.go index 655bf3f2..68375d1d 100644 --- a/harness/cmd/mnemon-harness/multica.go +++ b/harness/cmd/mnemon-harness/multica.go @@ -54,6 +54,10 @@ var ( multicaProvisionControlAddr string multicaProvisionControlToken string multicaProvisionControlTokenFile string + multicaProvisionManagedRuntime string + multicaProvisionManagedCommand string + multicaProvisionManagedWorkspace string + multicaProvisionManagedTimeout time.Duration ) var multicaCmd = &cobra.Command{ @@ -473,6 +477,12 @@ func multicaParticipantRuntimeEnv(cli driver.MulticaCLI, participant driver.Mult addStringEnv(env, "MNEMON_CONTROL_TOKEN", multicaProvisionControlToken) addStringEnv(env, "MNEMON_CONTROL_TOKEN_FILE", multicaProvisionControlTokenFile) addStringEnv(env, "MNEMON_CONTROL_PRINCIPAL", participant.Principal) + addStringEnv(env, "MNEMON_MANAGED_RUNTIME", multicaProvisionManagedRuntime) + addStringEnv(env, "MNEMON_MANAGED_COMMAND", multicaProvisionManagedCommand) + addStringEnv(env, "MNEMON_MANAGED_WORKSPACE", multicaProvisionManagedWorkspace) + if multicaProvisionManagedTimeout > 0 { + env["MNEMON_MANAGED_TURN_TIMEOUT"] = multicaProvisionManagedTimeout.String() + } return env } @@ -658,6 +668,10 @@ func init() { multicaProvisionCmd.Flags().StringVar(&multicaProvisionControlAddr, "mnemon-control-addr", envDefault("MNEMON_CONTROL_ADDR", ""), "Local Mnemon URL injected into participant runtime env") multicaProvisionCmd.Flags().StringVar(&multicaProvisionControlToken, "mnemon-control-token", envDefault("MNEMON_CONTROL_TOKEN", ""), "Local Mnemon bearer token injected into participant runtime env") multicaProvisionCmd.Flags().StringVar(&multicaProvisionControlTokenFile, "mnemon-control-token-file", envDefault("MNEMON_CONTROL_TOKEN_FILE", ""), "Local Mnemon bearer token file injected into participant runtime env") + multicaProvisionCmd.Flags().StringVar(&multicaProvisionManagedRuntime, "managed-runtime", envDefault("MNEMON_MANAGED_RUNTIME", ""), "managed agent runtime injected into participant env (noop or codex-appserver)") + 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") multicaCmd.AddCommand(multicaProbeCmd, multicaProvisionCmd, multicaImportIssueCmd, multicaProjectCommentCmd) multicaCmd.GroupID = groupSpine diff --git a/harness/cmd/mnemon-harness/multica_test.go b/harness/cmd/mnemon-harness/multica_test.go index 04433c90..6d5e2f37 100644 --- a/harness/cmd/mnemon-harness/multica_test.go +++ b/harness/cmd/mnemon-harness/multica_test.go @@ -123,6 +123,8 @@ esac multicaProvisionRestartDaemon = false multicaProvisionWait = 0 multicaProvisionControlAddr = "http://127.0.0.1:8787" + multicaProvisionManagedRuntime = "noop" + multicaProvisionManagedWorkspace = tmp multicaJSON = true t.Setenv("MULTICA_ENV_STDIN_PATH", envStdinPath) @@ -168,6 +170,8 @@ esac `"MNEMON_MULTICA_WORKSPACE_ID":"ws-1"`, `"MNEMON_CONTROL_ADDR":"http://127.0.0.1:8787"`, `"MNEMON_CONTROL_PRINCIPAL":"planner@team"`, + `"MNEMON_MANAGED_RUNTIME":"noop"`, + `"MNEMON_MANAGED_WORKSPACE":"` + tmp + `"`, } { if !strings.Contains(string(envStdin), want) { t.Fatalf("agent env stdin missing %s:\n%s", want, envStdin) @@ -211,6 +215,10 @@ func restoreMulticaFlags(t *testing.T) { oldProvisionControlAddr := multicaProvisionControlAddr oldProvisionControlToken := multicaProvisionControlToken oldProvisionControlTokenFile := multicaProvisionControlTokenFile + oldProvisionManagedRuntime := multicaProvisionManagedRuntime + oldProvisionManagedCommand := multicaProvisionManagedCommand + oldProvisionManagedWorkspace := multicaProvisionManagedWorkspace + oldProvisionManagedTimeout := multicaProvisionManagedTimeout t.Cleanup(func() { multicaBin = oldBin multicaProfile = oldProfile @@ -246,6 +254,10 @@ func restoreMulticaFlags(t *testing.T) { multicaProvisionControlAddr = oldProvisionControlAddr multicaProvisionControlToken = oldProvisionControlToken multicaProvisionControlTokenFile = oldProvisionControlTokenFile + multicaProvisionManagedRuntime = oldProvisionManagedRuntime + multicaProvisionManagedCommand = oldProvisionManagedCommand + multicaProvisionManagedWorkspace = oldProvisionManagedWorkspace + multicaProvisionManagedTimeout = oldProvisionManagedTimeout }) multicaBin = "" multicaProfile = "" @@ -280,4 +292,8 @@ func restoreMulticaFlags(t *testing.T) { multicaProvisionControlAddr = "" multicaProvisionControlToken = "" multicaProvisionControlTokenFile = "" + multicaProvisionManagedRuntime = "" + multicaProvisionManagedCommand = "" + multicaProvisionManagedWorkspace = "" + multicaProvisionManagedTimeout = 0 } diff --git a/harness/cmd/mnemon-multica-runtime/main.go b/harness/cmd/mnemon-multica-runtime/main.go index 92f43dc9..0ffae3b6 100644 --- a/harness/cmd/mnemon-multica-runtime/main.go +++ b/harness/cmd/mnemon-multica-runtime/main.go @@ -17,6 +17,7 @@ import ( "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" "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation" ) @@ -56,6 +57,7 @@ type runtimeImportResult struct { IssueID string Identifier string Title string + Statement string Principal string TaskID string Status string @@ -256,6 +258,7 @@ func (s *runtimeRPCState) importIssue(input string) runtimeImportResult { result.IssueID = issue.ID result.Identifier = issue.Identifier result.Title = issue.Title + result.Statement = issue.Description externalID := "" if taskID != "" { externalID = "multica-task-" + taskID @@ -352,8 +355,8 @@ func (s *runtimeRPCState) wakeManagedAgent(result *runtimeImportResult) { result.WakeErr = fmt.Errorf("render managed wake candidates: %w", err) return } - candidates := driver.ManagedWakeCandidatesFromRender(result.Principal, resp) - if len(candidates) == 0 { + candidate, ok := managedWakeCandidateForResult(result.Principal, resp, *result) + if !ok { result.WakeStatus = "skipped" result.WakeErr = fmt.Errorf("no managed wake candidate in rendered context") return @@ -371,7 +374,7 @@ func (s *runtimeRPCState) wakeManagedAgent(result *runtimeImportResult) { Client: client, Ledger: driver.NewFileManagedWakeLedger(runtimeManagedLedgerPath(s.Env, workspace)), Now: func() time.Time { return s.now() }, - }).Wake(wakeCtx, candidates[0]) + }).Wake(wakeCtx, candidate) result.WakeTurnID = record.TurnID if err != nil { result.WakeStatus = "failed" @@ -384,6 +387,52 @@ func (s *runtimeRPCState) wakeManagedAgent(result *runtimeImportResult) { } } +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 { + raw := []string{result.IssueID, result.Identifier, result.Title, result.Statement, result.TaskID} + var out []string + for _, value := range raw { + value = strings.TrimSpace(value) + if 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(body) + 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) { if result == nil { return diff --git a/harness/cmd/mnemon-multica-runtime/main_test.go b/harness/cmd/mnemon-multica-runtime/main_test.go index db07bd62..b7d1269c 100644 --- a/harness/cmd/mnemon-multica-runtime/main_test.go +++ b/harness/cmd/mnemon-multica-runtime/main_test.go @@ -72,6 +72,21 @@ esac AuditID: "render-audit-1", BodyDigest: "sha256:render-body", Events: []eventmodel.EventEnvelope{{ + SchemaVersion: eventmodel.SchemaVersion, + Phase: eventmodel.PhaseDerived, + Event: eventmodel.Event{ + SchemaVersion: eventmodel.SchemaVersion, + ID: "derived-stale", + Type: "assignment.brief.derived", + Subject: "assignment/asg-stale", + Actor: "mnemond", + Audience: "planner@team", + Payload: eventmodel.BuildPayload(nil, map[string]any{ + "body": "assignment for an older issue", + }, nil), + }, + Meta: map[string]any{"presentation_hint": "work"}, + }, { SchemaVersion: eventmodel.SchemaVersion, Phase: eventmodel.PhaseDerived, Event: eventmodel.Event{ diff --git a/harness/internal/driver/multica.go b/harness/internal/driver/multica.go index 676cfb7e..41d32981 100644 --- a/harness/internal/driver/multica.go +++ b/harness/internal/driver/multica.go @@ -508,13 +508,10 @@ func (c MulticaCLI) GetAgentEnv(ctx context.Context, agentID string) (map[string if err != nil { return nil, err } - var env map[string]string - if err := json.Unmarshal([]byte(out.Stdout), &env); err != nil { + env, err := decodeMulticaAgentEnv([]byte(out.Stdout)) + if err != nil { return nil, fmt.Errorf("decode multica agent env: %w", err) } - if env == nil { - env = map[string]string{} - } return env, nil } @@ -537,13 +534,10 @@ func (c MulticaCLI) SetAgentEnv(ctx context.Context, agentID string, env map[str if strings.TrimSpace(out.Stdout) == "" { return env, nil } - var updated map[string]string - if err := json.Unmarshal([]byte(out.Stdout), &updated); err != nil { + updated, err := decodeMulticaAgentEnv([]byte(out.Stdout)) + if err != nil { return nil, fmt.Errorf("decode updated multica agent env: %w", err) } - if updated == nil { - updated = map[string]string{} - } return updated, nil } @@ -842,6 +836,36 @@ func DecodeMulticaIssue(r io.Reader) (MulticaIssue, error) { return issue, nil } +func decodeMulticaAgentEnv(data []byte) (map[string]string, error) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + if custom, ok := raw["custom_env"]; ok { + return decodeMulticaStringMap(custom) + } + return decodeMulticaStringMap(data) +} + +func decodeMulticaStringMap(data []byte) (map[string]string, error) { + var values map[string]any + if err := json.Unmarshal(data, &values); err != nil { + return nil, err + } + out := map[string]string{} + for key, value := range values { + switch v := value.(type) { + case string: + out[key] = v + case nil: + out[key] = "" + default: + out[key] = fmt.Sprint(v) + } + } + return out, nil +} + func multicaIssueStatement(issue MulticaIssue, fallback string) string { if strings.TrimSpace(issue.Description) != "" { return strings.TrimSpace(issue.Description) diff --git a/harness/internal/driver/multica_test.go b/harness/internal/driver/multica_test.go index b9c28bde..b7343803 100644 --- a/harness/internal/driver/multica_test.go +++ b/harness/internal/driver/multica_test.go @@ -127,8 +127,8 @@ func TestMulticaCLIAgentEnvUsesStdin(t *testing.T) { printf '%s\n' "$*" > "$MULTICA_ARGS_PATH" cat > "$MULTICA_STDIN_PATH" case "$*" in - *"agent env get agent-1"*) printf '{"EXISTING":"****"}\n' ;; - *"agent env set agent-1"*) cat "$MULTICA_STDIN_PATH" ;; + *"agent env get agent-1"*) printf '{"agent_id":"agent-1","custom_env":{"EXISTING":"****"}}\n' ;; + *"agent env set agent-1"*) printf '{"agent_id":"agent-1","custom_env":'; cat "$MULTICA_STDIN_PATH"; printf '}\n' ;; *) printf '{}\n' ;; esac ` From 12fe6a54a042485fa68a640a95f834bc97fa460a Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 19:29:11 +0800 Subject: [PATCH 11/24] feat: add multica hub driver primitives Add Multica hub metadata, assignment fingerprint, and file ledger helpers alongside CLI support for issue children and metadata access. These primitives give the runtime a deterministic way to identify assignment child-issue mailboxes and avoid duplicate projections. Validation: go test ./harness/internal/driver --- harness/internal/driver/multica.go | 132 +++++++ harness/internal/driver/multica_hub.go | 444 ++++++++++++++++++++++++ harness/internal/driver/multica_test.go | 163 +++++++++ 3 files changed, 739 insertions(+) create mode 100644 harness/internal/driver/multica_hub.go diff --git a/harness/internal/driver/multica.go b/harness/internal/driver/multica.go index 41d32981..058e8b80 100644 --- a/harness/internal/driver/multica.go +++ b/harness/internal/driver/multica.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "os/exec" + "sort" "strings" "time" @@ -639,6 +640,69 @@ func (c MulticaCLI) SetIssueMetadata(ctx context.Context, issueID, key, value, v return err } +func (c MulticaCLI) SetIssueMetadataMap(ctx context.Context, issueID string, values map[string]string) error { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + if err := c.SetIssueMetadata(ctx, issueID, key, values[key], "string"); err != nil { + return err + } + } + return nil +} + +func (c MulticaCLI) GetIssueMetadata(ctx context.Context, issueID, key string) (string, bool, error) { + issueID = strings.TrimSpace(issueID) + key = strings.TrimSpace(key) + if issueID == "" { + return "", false, fmt.Errorf("multica issue id is required") + } + if key == "" { + return "", false, fmt.Errorf("multica metadata key is required") + } + out, err := c.Run(ctx, []string{"issue", "metadata", "get", issueID, "--key", key, "--output", "json"}, "") + if err != nil { + return "", false, err + } + meta, err := decodeMulticaMetadataOutput([]byte(out.Stdout)) + if err != nil { + return "", false, err + } + value, ok := meta[key] + return value, ok, nil +} + +func (c MulticaCLI) ListIssueMetadata(ctx context.Context, issueID string) (map[string]string, error) { + issueID = strings.TrimSpace(issueID) + if issueID == "" { + return nil, fmt.Errorf("multica issue id is required") + } + out, err := c.Run(ctx, []string{"issue", "metadata", "list", issueID, "--output", "json"}, "") + if err != nil { + return nil, err + } + return decodeMulticaMetadataOutput([]byte(out.Stdout)) +} + +func (c MulticaCLI) ListIssueChildren(ctx context.Context, parentID string) ([]MulticaIssue, error) { + parentID = strings.TrimSpace(parentID) + if parentID == "" { + return nil, fmt.Errorf("multica parent issue id is required") + } + out, err := c.Run(ctx, []string{"issue", "children", parentID, "--output", "json"}, "") + if err != nil { + return nil, err + } + issues, err := decodeMulticaIssueListOutput([]byte(out.Stdout)) + if err != nil { + return nil, err + } + return issues, nil +} + func (c MulticaCLI) ListIssueRuns(ctx context.Context, issueID string) ([]MulticaIssueRun, error) { issueID = strings.TrimSpace(issueID) if issueID == "" { @@ -836,6 +900,74 @@ func DecodeMulticaIssue(r io.Reader) (MulticaIssue, error) { return issue, nil } +func decodeMulticaMetadataOutput(data []byte) (map[string]string, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var raw any + if err := dec.Decode(&raw); err != nil { + return nil, fmt.Errorf("decode multica metadata: %w", err) + } + return NormalizeMulticaMetadata(raw), nil +} + +func decodeMulticaIssueListOutput(data []byte) ([]MulticaIssue, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var raw any + if err := dec.Decode(&raw); err != nil { + return nil, fmt.Errorf("decode multica issue children: %w", err) + } + var out []MulticaIssue + seen := map[string]bool{} + var walk func(any) + walk = func(v any) { + switch value := v.(type) { + case []any: + for _, item := range value { + walk(item) + } + case map[string]any: + if issue, ok := multicaIssueFromRawMap(value); ok { + if !seen[issue.ID] { + seen[issue.ID] = true + out = append(out, issue) + } + return + } + for _, item := range value { + walk(item) + } + } + } + walk(raw) + return out, nil +} + +func multicaIssueFromRawMap(raw map[string]any) (MulticaIssue, bool) { + id, _ := raw["id"].(string) + if strings.TrimSpace(id) == "" { + return MulticaIssue{}, false + } + if _, ok := raw["title"]; !ok { + if _, ok := raw["identifier"]; !ok { + if _, ok := raw["description"]; !ok { + if _, ok := raw["status"]; !ok { + return MulticaIssue{}, false + } + } + } + } + data, err := json.Marshal(raw) + if err != nil { + return MulticaIssue{}, false + } + var issue MulticaIssue + if err := json.Unmarshal(data, &issue); err != nil { + return MulticaIssue{}, false + } + return issue, true +} + func decodeMulticaAgentEnv(data []byte) (map[string]string, error) { var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { diff --git a/harness/internal/driver/multica_hub.go b/harness/internal/driver/multica_hub.go new file mode 100644 index 00000000..23a5f9dd --- /dev/null +++ b/harness/internal/driver/multica_hub.go @@ -0,0 +1,444 @@ +package driver + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +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" +) + +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 +} + +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) +} + +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") +} + +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], + } +} + +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 MulticaIssueHubMetadata(issue MulticaIssue) MulticaHubMetadata { + return ParseMulticaHubMetadata(issue.Metadata) +} + +func IsMulticaAssignmentMailboxIssue(issue MulticaIssue) bool { + return MulticaIssueHubMetadata(issue).IsAssignmentMailbox() +} + +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 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/driver/multica_test.go b/harness/internal/driver/multica_test.go index b7343803..24e8881e 100644 --- a/harness/internal/driver/multica_test.go +++ b/harness/internal/driver/multica_test.go @@ -322,6 +322,169 @@ func TestFormatMulticaProjectionCommentCarriesStableMarkers(t *testing.T) { } } +func TestMulticaHubMetadataDetectsAssignmentMailbox(t *testing.T) { + issue := MulticaIssue{ + ID: "issue-2", + Metadata: 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 !IsMulticaAssignmentMailboxIssue(issue) { + t.Fatalf("issue was not detected as assignment mailbox: %+v", MulticaIssueHubMetadata(issue)) + } + meta := MulticaIssueHubMetadata(issue) + 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 TestMulticaAssignmentFingerprintStable(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 TestFileMulticaHubLedgerDedupesRecords(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 TestMulticaCLIChildrenAndMetadataCommands(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 children root-1"*) printf '{"children":[{"id":"child-1","identifier":"TEA-2","title":"Assignment","metadata":{"mnemon.kind":"assignment_mailbox"}}]}\n' ;; + *"issue metadata list child-1"*) printf '[{"key":"mnemon.kind","value":"assignment_mailbox"},{"key":"mnemon.assignment_id","value":"assignment-1"}]\n' ;; + *"issue metadata get child-1 --key mnemon.kind"*) printf '{"key":"mnemon.kind","value":"assignment_mailbox"}\n' ;; + *"issue metadata set child-1"*) printf '{}\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, + ), + } + children, err := cli.ListIssueChildren(context.Background(), "root-1") + if err != nil { + t.Fatal(err) + } + if len(children) != 1 || children[0].ID != "child-1" { + t.Fatalf("children = %+v", children) + } + meta, err := cli.ListIssueMetadata(context.Background(), "child-1") + if err != nil { + t.Fatal(err) + } + if meta[MulticaMetadataKind] != MulticaHubKindAssignmentMailbox || meta[MulticaMetadataAssignmentID] != "assignment-1" { + t.Fatalf("metadata = %+v", meta) + } + value, ok, err := cli.GetIssueMetadata(context.Background(), "child-1", MulticaMetadataKind) + if err != nil { + t.Fatal(err) + } + if !ok || value != MulticaHubKindAssignmentMailbox { + t.Fatalf("metadata get = %q ok=%v", value, ok) + } + if err := cli.SetIssueMetadataMap(context.Background(), "child-1", map[string]string{ + "b": "two", + "a": "one", + }); err != nil { + t.Fatal(err) + } + args := mustReadDriverTestFile(t, argsPath) + for _, want := range []string{ + "issue children root-1 --output json", + "issue metadata list child-1 --output json", + "issue metadata get child-1 --key mnemon.kind --output json", + "issue metadata set child-1 --key a --value one --type string --output json", + "issue metadata set child-1 --key b --value two --type string --output json", + } { + if !strings.Contains(args, want) { + t.Fatalf("args missing %q:\n%s", want, args) + } + } + if strings.Index(args, "--key a") > strings.Index(args, "--key b") { + t.Fatalf("metadata map should be written in deterministic key order:\n%s", args) + } +} + func mustReadDriverTestFile(t *testing.T, path string) string { t.Helper() data, err := os.ReadFile(path) From 1ff69be4a553aa4a813cf5b473eb90c38039054c Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 19:35:46 +0800 Subject: [PATCH 12/24] feat: classify multica hub runtime issues Teach the Multica runtime to initialize root session issues for hub mode and to correlate assignment child-issue mailboxes without importing them as new teamwork signals. Assignment dispatch now wakes the local managed agent through rendered Mnemon context while keeping the raw query as [mnemon:wake]. Validation: go test ./harness/internal/driver ./harness/cmd/mnemon-multica-runtime --- harness/cmd/mnemon-multica-runtime/main.go | 211 ++++++++++++++++-- .../cmd/mnemon-multica-runtime/main_test.go | 192 ++++++++++++++++ 2 files changed, 386 insertions(+), 17 deletions(-) diff --git a/harness/cmd/mnemon-multica-runtime/main.go b/harness/cmd/mnemon-multica-runtime/main.go index 0ffae3b6..80673758 100644 --- a/harness/cmd/mnemon-multica-runtime/main.go +++ b/harness/cmd/mnemon-multica-runtime/main.go @@ -54,21 +54,30 @@ type runtimeRPCState struct { } type runtimeImportResult struct { - IssueID string - Identifier string - Title string - Statement string - Principal string - TaskID string - Status string - Receipt *access.IngestReceipt - ProjectionStatus string - ProjectionCommentID string - ProjectionErr error - WakeStatus string - WakeTurnID string - WakeErr error - Err error + IssueID string + Identifier string + Title string + Statement string + Principal string + TaskID string + HubMetadata driver.MulticaHubMetadata + HubBackend string + HubKind string + SessionID string + CorrelationID string + RootIssueID string + AssignmentID string + AssignmentFingerprint string + MatchTerms []string + Status string + Receipt *access.IngestReceipt + ProjectionStatus string + ProjectionCommentID string + ProjectionErr error + WakeStatus string + WakeTurnID string + WakeErr error + Err error } func main() { @@ -259,6 +268,12 @@ func (s *runtimeRPCState) importIssue(input string) runtimeImportResult { 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")) + if result.HubMetadata.IsAssignmentMailbox() { + return s.correlateAssignmentMailbox(ctx, cli, issue, &result) + } externalID := "" if taskID != "" { externalID = "multica-task-" + taskID @@ -284,6 +299,18 @@ func (s *runtimeRPCState) importIssue(input string) runtimeImportResult { result.Err = err return result } + if strings.EqualFold(result.HubBackend, driver.MulticaHubBackend) { + ensureRootSessionHubFields(&result, issue) + 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(ctx, issue.ID, rootSessionMetadata(result, draft, s.now())); err != nil { + result.Status = "failed" + result.Err = fmt.Errorf("set Multica root session metadata: %w", err) + return result + } + } addr := strings.TrimSpace(envValue(s.Env, "MNEMON_CONTROL_ADDR")) if addr == "" { result.Status = "skipped" @@ -315,6 +342,122 @@ func (s *runtimeRPCState) importIssue(input string) runtimeImportResult { return result } +func (s *runtimeRPCState) correlateAssignmentMailbox(ctx context.Context, cli driver.MulticaCLI, issue driver.MulticaIssue, result *runtimeImportResult) runtimeImportResult { + ensureAssignmentHubFields(result, issue) + result.Status = "correlated" + result.MatchTerms = cleanRuntimeTerms( + result.AssignmentID, + result.AssignmentFingerprint, + result.HubMetadata.EventID, + string(eventmodel.Subject("assignment", result.AssignmentID)), + ) + addr := strings.TrimSpace(envValue(s.Env, "MNEMON_CONTROL_ADDR")) + if addr == "" { + result.WakeStatus = "skipped" + result.WakeErr = fmt.Errorf("MNEMON_CONTROL_ADDR is not set") + } else { + s.wakeManagedAgent(result) + } + s.projectImportComment(ctx, cli, issue, assignmentMailboxMarker(*result), result) + return *result +} + +func applyMulticaHubMetadata(result *runtimeImportResult, issue driver.MulticaIssue) { + 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) + result.CorrelationID = firstNonEmpty(meta.CorrelationID, result.CorrelationID) + 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) { + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if payload == nil || key == "" || value == "" { + return + } + rule, _ := payload[eventmodel.PayloadRuleKey].(map[string]any) + if rule == nil { + rule = map[string]any{} + payload[eventmodel.PayloadRuleKey] = rule + } + 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) + } + return out +} + +func assignmentMailboxMarker(result runtimeImportResult) string { + if result.HubMetadata.EventID != "" { + return result.HubMetadata.EventID + } + if result.AssignmentID != "" { + return "multica-assignment-" + result.AssignmentID + } + return "multica-issue-" + result.IssueID +} + func (s *runtimeRPCState) wakeManagedAgent(result *runtimeImportResult) { if result == nil { return @@ -411,6 +554,12 @@ func managedWakeCandidateForResult(principal string, resp presentation.Response, } 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.Statement, result.TaskID} var out []string for _, value := range raw { @@ -424,7 +573,7 @@ func managedWakeMatchTerms(result runtimeImportResult) []string { func eventNarrativeContainsAny(env eventmodel.EventEnvelope, terms []string) bool { body, _ := eventmodel.PayloadNarrative(env.Event.Payload)["body"].(string) - body = strings.ToLower(body) + 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 @@ -441,20 +590,37 @@ func (s *runtimeRPCState) projectImportComment(ctx context.Context, cli driver.M 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 } - commentBody := driver.FormatMulticaProjectionComment("issue admitted", body, []string{externalID}) + commentBody := driver.FormatMulticaProjectionComment(title, body, []string{externalID}) comment, err := cli.AddIssueComment(ctx, issue.ID, commentBody) if err != nil { result.ProjectionStatus = "failed" @@ -617,6 +783,17 @@ func formatRuntimeFinalAnswer(result runtimeImportResult) string { 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 { diff --git a/harness/cmd/mnemon-multica-runtime/main_test.go b/harness/cmd/mnemon-multica-runtime/main_test.go index b7d1269c..71614159 100644 --- a/harness/cmd/mnemon-multica-runtime/main_test.go +++ b/harness/cmd/mnemon-multica-runtime/main_test.go @@ -206,6 +206,189 @@ esac } } +func TestRuntimeInitializesMulticaRootSessionMetadata(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 root-1"*) printf '{"id":"root-1","identifier":"TEA-1","title":"Coordinate release validation","description":"Plan and split release validation across the team.","status":"todo","priority":"medium"}\n' ;; + *"issue metadata set root-1"*) printf '{}\n' ;; + *"issue comment add root-1"*) cat > "$MULTICA_COMMENT_PATH"; printf '{"id":"comment-root","issue_id":"root-1","content":"ok","type":"comment"}\n' ;; + *) printf '{}\n' ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + var got contract.ObservationEnvelope + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/ingest": + 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: 21, 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":"Your assigned issue ID is: root-1"}]}}` + "\n" + var out bytes.Buffer + err := runRuntime(runtimeConfig{ + Args: []string{"app-server", "--listen", "stdio://"}, + Env: append(os.Environ(), + "MNEMON_MULTICA_BIN="+bin, + "MNEMON_HUB_BACKEND=multica", + "MNEMON_CONTROL_ADDR="+srv.URL, + "MNEMON_CONTROL_PRINCIPAL=planner@team", + "MULTICA_ARGS_PATH="+argsPath, + "MULTICA_COMMENT_PATH="+commentPath, + "MULTICA_TASK_ID=task-root", + "MULTICA_AGENT_ID=agent-planner", + ), + CWD: tmp, + Stdin: strings.NewReader(input), + Stdout: &out, + Now: fixedRuntimeTime, + }) + if err != nil { + t.Fatal(err) + } + rule := got.Event.Payload["rule"].(map[string]any) + if rule["hub_backend"] != driver.MulticaHubBackend || rule["root_issue_id"] != "root-1" || rule["source_issue_id"] != "root-1" { + t.Fatalf("root hub rule mismatch: %+v", rule) + } + if rule["session_id"] != driver.MulticaSessionID("root-1") { + t.Fatalf("session id = %q", rule["session_id"]) + } + args := mustReadRuntimeTestFile(t, argsPath) + for _, want := range []string{ + "issue metadata set root-1 --key mnemon.hub_backend --value multica --type string --output json", + "issue metadata set root-1 --key mnemon.kind --value session_mailbox --type string --output json", + "issue metadata set root-1 --key mnemon.root_issue_id --value root-1 --type string --output json", + "issue comment add root-1 --content-stdin --output json", + } { + if !strings.Contains(args, want) { + t.Fatalf("args missing %q:\n%s", want, args) + } + } + comment := mustReadRuntimeTestFile(t, commentPath) + for _, want := range []string{"Mnemon update: issue admitted", "Hub backend: multica", "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") { + t.Fatalf("runtime output missing ingest evidence:\n%s", out.String()) + } +} + +func TestRuntimeCorrelatesAssignmentMailboxWithoutNewIngest(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 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 comment add child-1"*) cat > "$MULTICA_COMMENT_PATH"; printf '{"id":"comment-child","issue_id":"child-1","content":"ok","type":"comment"}\n' ;; + *) printf '{}\n' ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + var ingestCalled bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/ingest": + ingestCalled = true + http.Error(w, "assignment mailbox must not ingest a new signal", http.StatusInternalServerError) + case "/render": + if r.Header.Get(access.PrincipalHeader) != "worker@team" { + t.Fatalf("render principal header = %q", r.Header.Get(access.PrincipalHeader)) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(presentation.Response{ + SchemaVersion: 1, + Status: presentation.StatusOK, + AuditID: "render-audit-child", + BodyDigest: "sha256:render-child", + Events: []eventmodel.EventEnvelope{{ + SchemaVersion: eventmodel.SchemaVersion, + Phase: eventmodel.PhaseDerived, + Event: eventmodel.Event{ + SchemaVersion: eventmodel.SchemaVersion, + ID: "derived-work-asg-1", + Type: "assignment.work_available", + Subject: "assignment/asg-1", + Actor: "mnemond", + Audience: "worker@team", + Payload: eventmodel.BuildPayload(nil, map[string]any{ + "body": "Assignment asg-1 is yours: release validation. Expected work: check the edge cases.", + }, nil), + }, + Meta: map[string]any{"presentation_hint": "work"}, + }}, + }) + 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: child-1"}]}}` + "\n" + var out bytes.Buffer + err := runRuntime(runtimeConfig{ + Args: []string{"app-server", "--listen", "stdio://"}, + Env: append(os.Environ(), + "MNEMON_MULTICA_BIN="+bin, + "MNEMON_HUB_BACKEND=multica", + "MNEMON_CONTROL_ADDR="+srv.URL, + "MNEMON_CONTROL_PRINCIPAL=worker@team", + "MNEMON_MANAGED_RUNTIME=noop", + "MNEMON_MANAGED_LEDGER="+filepath.Join(tmp, "wake-ledger.jsonl"), + "MULTICA_ARGS_PATH="+argsPath, + "MULTICA_COMMENT_PATH="+commentPath, + "MULTICA_TASK_ID=task-child", + "MULTICA_AGENT_ID=agent-worker", + ), + CWD: tmp, + Stdin: strings.NewReader(input), + Stdout: &out, + Now: fixedRuntimeTime, + }) + if err != nil { + t.Fatal(err) + } + 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") { + t.Fatalf("runtime output missing correlation/wake evidence:\n%s", out.String()) + } + comment := mustReadRuntimeTestFile(t, commentPath) + for _, want := range []string{ + "Mnemon update: assignment mailbox correlated", + "Assignment: asg-1", + "mnemon:event=event-assignment-1", + "Managed wake: completed", + } { + if !strings.Contains(comment, want) { + t.Fatalf("comment missing %q:\n%s", want, comment) + } + } +} + func TestRuntimeSkipsIngestWhenControlAddressIsUnset(t *testing.T) { tmp := t.TempDir() argsPath := filepath.Join(tmp, "multica.args") @@ -243,3 +426,12 @@ printf '{"id":"iss-8","identifier":"TEA-8","title":"No local mnemond","descripti func fixedRuntimeTime() time.Time { return time.Date(2026, 6, 28, 9, 0, 0, 0, time.UTC) } + +func mustReadRuntimeTestFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return strings.TrimSpace(string(data)) +} From b7c3a489b5234bfe2db0221765a1e461ab7bdc30 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 19:41:33 +0800 Subject: [PATCH 13/24] feat: write multica hub assignment mailboxes Add the Multica hub writer path that pulls structured mnemond assignment/progress views, creates assignment child-issue mailboxes with correlation metadata, and records hub ledger entries to avoid duplicate projections. Provisioned Multica participants now explicitly run with MNEMON_HUB_BACKEND=multica. Validation: go test ./harness/internal/driver ./harness/cmd/mnemon-multica-runtime ./harness/cmd/mnemon-harness --- harness/cmd/mnemon-harness/multica.go | 1 + harness/cmd/mnemon-harness/multica_test.go | 1 + .../cmd/mnemon-multica-runtime/hub_writer.go | 538 ++++++++++++++++++ harness/cmd/mnemon-multica-runtime/main.go | 30 + .../cmd/mnemon-multica-runtime/main_test.go | 138 +++++ 5 files changed, 708 insertions(+) create mode 100644 harness/cmd/mnemon-multica-runtime/hub_writer.go diff --git a/harness/cmd/mnemon-harness/multica.go b/harness/cmd/mnemon-harness/multica.go index 68375d1d..899b50bf 100644 --- a/harness/cmd/mnemon-harness/multica.go +++ b/harness/cmd/mnemon-harness/multica.go @@ -468,6 +468,7 @@ func ensureMulticaParticipantEnv(ctx context.Context, cli driver.MulticaCLI, par func multicaParticipantRuntimeEnv(cli driver.MulticaCLI, participant driver.MulticaParticipantRecord, registryPath, workspaceID string) map[string]string { env := map[string]string{} + addStringEnv(env, "MNEMON_HUB_BACKEND", driver.MulticaHubBackend) addStringEnv(env, "MNEMON_MULTICA_REGISTRY", registryPath) addStringEnv(env, "MNEMON_MULTICA_WORKSPACE_ID", workspaceID) addStringEnv(env, "MNEMON_MULTICA_BIN", multicaCLICommand(cli)) diff --git a/harness/cmd/mnemon-harness/multica_test.go b/harness/cmd/mnemon-harness/multica_test.go index 6d5e2f37..829c4d88 100644 --- a/harness/cmd/mnemon-harness/multica_test.go +++ b/harness/cmd/mnemon-harness/multica_test.go @@ -166,6 +166,7 @@ esac t.Fatal(err) } for _, want := range []string{ + `"MNEMON_HUB_BACKEND":"multica"`, `"MNEMON_MULTICA_REGISTRY":"` + registryPath + `"`, `"MNEMON_MULTICA_WORKSPACE_ID":"ws-1"`, `"MNEMON_CONTROL_ADDR":"http://127.0.0.1:8787"`, diff --git a/harness/cmd/mnemon-multica-runtime/hub_writer.go b/harness/cmd/mnemon-multica-runtime/hub_writer.go new file mode 100644 index 00000000..cb6c67a0 --- /dev/null +++ b/harness/cmd/mnemon-multica-runtime/hub_writer.go @@ -0,0 +1,538 @@ +package main + +import ( + "context" + "fmt" + "strings" + "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" +) + +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) { + result.HubWriteStatus = "skipped" + return + } + if !strings.EqualFold(result.HubBackend, driver.MulticaHubBackend) || result.HubKind != driver.MulticaHubKindSession { + result.HubWriteStatus = "skipped" + return + } + if client == nil { + result.HubWriteStatus = "failed" + result.HubWriteErr = fmt.Errorf("mnemond client is required") + return + } + proj, err := client.PullPresentationView(contract.ActorID(result.Principal), contract.Subscription{ + Actor: contract.ActorID(result.Principal), + Refs: []contract.ResourceRef{ + {Kind: "assignment", ID: "project"}, + {Kind: "progress_digest", ID: "project"}, + }, + }) + if err != nil { + result.HubWriteStatus = "failed" + result.HubWriteErr = fmt.Errorf("pull teamwork view for Multica hub write: %w", err) + return + } + reg, ok, err := runtimeMulticaRegistry(s.Env, s.CWD) + if err != nil { + result.HubWriteStatus = "failed" + result.HubWriteErr = err + return + } + if !ok { + result.HubWriteStatus = "failed" + result.HubWriteErr = fmt.Errorf("Multica registry is required for assignment routing") + return + } + ledger := driver.NewFileMulticaHubLedger(driver.MulticaHubLedgerPath(s.CWD, envValue(s.Env, "MNEMON_MULTICA_HUB_LEDGER"))) + if err := s.writeAssignmentMailboxes(ctx, cli, ledger, reg, proj, rootIssue, result); err != nil { + result.HubWriteStatus = "failed" + result.HubWriteErr = err + return + } + if err := s.writeProgressComments(ctx, cli, ledger, proj, result); err != nil { + result.HubWriteStatus = "failed" + result.HubWriteErr = err + return + } + switch { + case result.HubChildIssues > 0 && result.HubFeedbackComments > 0: + result.HubWriteStatus = "updated" + case result.HubChildIssues > 0: + result.HubWriteStatus = "created" + case result.HubFeedbackComments > 0: + result.HubWriteStatus = "commented" + default: + result.HubWriteStatus = "noop" + } +} + +func (s *runtimeRPCState) writeAssignmentMailboxes(ctx context.Context, cli driver.MulticaCLI, ledger *driver.FileMulticaHubLedger, reg driver.MulticaRegistry, proj pview.View, rootIssue driver.MulticaIssue, result *runtimeImportResult) error { + for _, assignment := range hubViewItems(proj, "assignment") { + item := runtimeAssignmentItem(assignment) + if item.ID == "" || item.Assignee == "" { + continue + } + participant, ok := 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, + }) + source := driver.MulticaHubLedgerSource{ + SessionID: result.SessionID, + CorrelationID: result.CorrelationID, + EventID: item.EventID, + AssignmentID: item.ID, + AssignmentFingerprint: fingerprint, + Principal: item.Assignee, + ProjectionKind: "assignment", + } + if _, ok, err := ledger.Find(driver.MulticaHubKindAssignmentMailbox, source); err != nil { + return err + } else if ok { + continue + } + if existing, ok, err := findExistingMulticaAssignmentIssue(ctx, cli, result.RootIssueID, source); err != nil { + return err + } else if ok { + if err := ledger.Record(driver.MulticaHubLedgerRecord{ + Kind: driver.MulticaHubKindAssignmentMailbox, + Source: source, + Target: driver.MulticaHubLedgerTarget{ + RootIssueID: result.RootIssueID, + ChildIssueID: existing.ID, + Status: "existing", + }, + }); err != nil { + return err + } + continue + } + child, err := cli.CreateIssue(ctx, driver.MulticaCreateIssueRequest{ + Title: assignmentMailboxTitle(item), + Description: assignmentMailboxDescription(item, result), + AssigneeID: participant.AgentID, + ParentID: result.RootIssueID, + Status: "todo", + Priority: "medium", + }) + if err != nil { + return err + } + 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), + } + if err := cli.SetIssueMetadataMap(ctx, child.ID, meta.Map()); err != nil { + return err + } + if err := ledger.Record(driver.MulticaHubLedgerRecord{ + Kind: driver.MulticaHubKindAssignmentMailbox, + Source: source, + Target: driver.MulticaHubLedgerTarget{ + RootIssueID: result.RootIssueID, + ChildIssueID: child.ID, + Status: "created", + }, + }); err != nil { + return err + } + result.HubChildIssues++ + } + return nil +} + +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) + if item.ID == "" || item.AssignmentRef == "" { + 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 { + return err + } else if ok { + continue + } + child, ok, err := findAssignmentTargetFromLedger(ledger, result.SessionID, item.AssignmentRef) + 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 err != nil { + return err + } + if err := ledger.Record(driver.MulticaHubLedgerRecord{ + Kind: driver.MulticaHubKindFeedbackCarrier, + Source: source, + Target: driver.MulticaHubLedgerTarget{ + RootIssueID: result.RootIssueID, + ChildIssueID: child, + CommentID: comment.ID, + Status: "commented", + }, + }); err != nil { + return err + } + result.HubFeedbackComments++ + } + return nil +} + +type runtimeAssignment struct { + ID string + EventID string + 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 + 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"), + 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"), + 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 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 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 + } + } + 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 + } + } + } + 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) + } + } + case []map[string]any: + out = append(out, v...) + } + return out +} + +func hubItemFirstString(item map[string]any, keys ...string) string { + for _, key := range keys { + if value := hubItemString(item, key); value != "" { + return value + } + } + return "" +} + +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 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) + } + return out +} + +func assignmentMailboxTitle(item runtimeAssignment) string { + scope := strings.TrimSpace(item.Scope) + if scope == "" { + scope = strings.TrimSpace(item.ID) + } + if scope == "" { + scope = "assignment" + } + 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()) +} + +func findExistingMulticaAssignmentIssue(ctx context.Context, cli driver.MulticaCLI, rootIssueID string, source driver.MulticaHubLedgerSource) (driver.MulticaIssue, bool, error) { + children, err := cli.ListIssueChildren(ctx, rootIssueID) + if err != nil { + return driver.MulticaIssue{}, false, err + } + 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)) + } + if !meta.IsAssignmentMailbox() { + continue + } + if meta.SessionID == source.SessionID && + meta.AssignmentID == source.AssignmentID && + meta.AssignmentFingerprint == source.AssignmentFingerprint && + meta.Principal == source.Principal { + return child, true, nil + } + } + return driver.MulticaIssue{}, false, nil +} + +func findAssignmentTargetFromLedger(ledger *driver.FileMulticaHubLedger, sessionID, assignmentID 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 { + continue + } + if record.Source.SessionID == sessionID && record.Source.AssignmentID == assignmentID && strings.TrimSpace(record.Target.ChildIssueID) != "" { + return record.Target.ChildIssueID, true, nil + } + } + return "", false, 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 80673758..fae0e06a 100644 --- a/harness/cmd/mnemon-multica-runtime/main.go +++ b/harness/cmd/mnemon-multica-runtime/main.go @@ -77,6 +77,10 @@ type runtimeImportResult struct { WakeStatus string WakeTurnID string WakeErr error + HubWriteStatus string + HubChildIssues int + HubFeedbackComments int + HubWriteErr error Err error } @@ -338,6 +342,7 @@ func (s *runtimeRPCState) importIssue(input string) runtimeImportResult { result.Status = "recorded" result.Receipt = &rec s.wakeManagedAgent(&result) + s.writeMulticaHubArtifacts(ctx, cli, client, issue, &result) s.projectImportComment(ctx, cli, issue, draft.ExternalID, &result) return result } @@ -620,6 +625,9 @@ func (s *runtimeRPCState) projectImportComment(ctx context.Context, cli driver.M 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}) comment, err := cli.AddIssueComment(ctx, issue.ID, commentBody) if err != nil { @@ -869,6 +877,28 @@ func formatRuntimeFinalAnswer(result runtimeImportResult) string { b.WriteString(".") } } + 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(".") + } return strings.TrimSpace(b.String()) } diff --git a/harness/cmd/mnemon-multica-runtime/main_test.go b/harness/cmd/mnemon-multica-runtime/main_test.go index 71614159..cebd5c79 100644 --- a/harness/cmd/mnemon-multica-runtime/main_test.go +++ b/harness/cmd/mnemon-multica-runtime/main_test.go @@ -16,6 +16,7 @@ import ( 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" ) func TestRuntimeImportsAssignedIssueIntoMnemon(t *testing.T) { @@ -246,6 +247,7 @@ esac Env: append(os.Environ(), "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, @@ -290,6 +292,142 @@ esac } } +func TestRuntimeWritesAssignmentMailboxChildIssueFromView(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-2"*) printf '{"id":"root-2","identifier":"TEA-10","title":"Coordinate docs release","description":"Split docs release validation across the team.","status":"todo","priority":"medium"}\n' ;; + *"issue metadata set root-2"*) printf '{}\n' ;; + *"issue children root-2"*) printf '{"children":[]}\n' ;; + *"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' ;; + *) 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": + var sub contract.Subscription + if err := json.NewDecoder(r.Body).Decode(&sub); err != nil { + t.Fatal(err) + } + if sub.Actor != "planner@team" { + t.Fatalf("presentation actor = %q", sub.Actor) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(pview.View{ + Ref: "view-assignments", + Digest: "digest-assignments", + Content: []pview.ResourceContent{{ + Ref: contract.ResourceRef{Kind: "assignment", ID: "project"}, + Fields: map[string]any{"items": []any{map[string]any{ + "id": "asg-writer", + "actor": "planner@team", + "rule": map[string]any{ + "assignment_id": "asg-writer", + "assignee": "worker@team", + "scope": "check release notes", + "ttl": "30m", + "signal_ref": "teamwork_signal/root-2", + }, + "narrative": map[string]any{ + "expected_work": "check release notes against the public changelog", + "expected_feedback": "progress_digest with result or blocker", + }, + "refs": map[string]any{ + "context_refs": []any{"multica:issue:root-2"}, + "evidence_refs": []any{"multica:issue:root-2"}, + }, + }}}, + }}, + }) + 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-2"}]}}` + "\n" + var out bytes.Buffer + err := runRuntime(runtimeConfig{ + Args: []string{"app-server", "--listen", "stdio://"}, + Env: append(os.Environ(), + "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-2", + "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 hub write: created child_issues=1") { + 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 --assignee-id agent-worker --parent root-2 --status todo --priority medium", + "issue metadata set child-2 --key mnemon.kind --value assignment_mailbox --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", + } { + if !strings.Contains(args, want) { + t.Fatalf("args missing %q:\n%s", want, args) + } + } + if strings.Contains(args, "check release notes against") { + t.Fatalf("assignment description leaked into argv:\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"} { + if !strings.Contains(description, want) { + t.Fatalf("description missing %q:\n%s", want, 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) + } +} + func TestRuntimeCorrelatesAssignmentMailboxWithoutNewIngest(t *testing.T) { tmp := t.TempDir() argsPath := filepath.Join(tmp, "multica.args") From aef68483aba465d81c609d3f3ab043ecadba0055 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 19:47:02 +0800 Subject: [PATCH 14/24] test: add multica hub flow acceptance checks Extend the Multica prod-sim acceptance command with an optional hub-flow gate that verifies a five-participant registry, root session metadata, assignment child issue mailboxes, child runtime correlation messages, and distinct Multica agent run evidence. Validation: go test ./harness/internal/driver ./harness/cmd/mnemon-multica-runtime ./harness/cmd/mnemon-harness ./harness/cmd/mnemon-acceptance --- .../acceptance_multica_runtime.go | 237 ++++++++++++++++-- .../acceptance_multica_runtime_test.go | 92 ++++++- 2 files changed, 314 insertions(+), 15 deletions(-) diff --git a/harness/cmd/mnemon-acceptance/acceptance_multica_runtime.go b/harness/cmd/mnemon-acceptance/acceptance_multica_runtime.go index 8277750d..4ff6dbd9 100644 --- a/harness/cmd/mnemon-acceptance/acceptance_multica_runtime.go +++ b/harness/cmd/mnemon-acceptance/acceptance_multica_runtime.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "sort" "strings" "time" @@ -27,6 +28,9 @@ var ( acceptanceMulticaPoll time.Duration acceptanceMulticaRequireIngest bool acceptanceMulticaRequireManagedWake bool + acceptanceMulticaRequireHubFlow bool + acceptanceMulticaMinParticipants int + acceptanceMulticaMinActiveAgents int ) var acceptanceMulticaRuntimeCmd = &cobra.Command{ @@ -47,6 +51,9 @@ var acceptanceMulticaRuntimeCmd = &cobra.Command{ Poll: acceptanceMulticaPoll, RequireIngest: acceptanceMulticaRequireIngest, RequireManagedWake: acceptanceMulticaRequireManagedWake, + RequireHubFlow: acceptanceMulticaRequireHubFlow, + MinParticipants: acceptanceMulticaMinParticipants, + MinActiveAgents: acceptanceMulticaMinActiveAgents, Stdout: cmd.OutOrStdout(), Stderr: cmd.ErrOrStderr(), }) @@ -77,6 +84,9 @@ func init() { acceptanceMulticaRuntimeCmd.Flags().DurationVar(&acceptanceMulticaPoll, "poll", 5*time.Second, "poll interval for Multica runs") acceptanceMulticaRuntimeCmd.Flags().BoolVar(&acceptanceMulticaRequireIngest, "require-mnemon-ingest", true, "require run output to show recorded Mnemon ingest") acceptanceMulticaRuntimeCmd.Flags().BoolVar(&acceptanceMulticaRequireManagedWake, "require-managed-wake", false, "require run output to show a completed managed wake") + acceptanceMulticaRuntimeCmd.Flags().BoolVar(&acceptanceMulticaRequireHubFlow, "require-hub-flow", false, "require Multica hub backend evidence: root metadata, child issue mailboxes, and multi-agent runs") + acceptanceMulticaRuntimeCmd.Flags().IntVar(&acceptanceMulticaMinParticipants, "min-participants", 5, "minimum Multica participant agents required for hub-flow acceptance") + acceptanceMulticaRuntimeCmd.Flags().IntVar(&acceptanceMulticaMinActiveAgents, "min-active-agents", 3, "minimum distinct Multica agents with run evidence for hub-flow acceptance") rootCmd.AddCommand(acceptanceMulticaRuntimeCmd) } @@ -94,25 +104,35 @@ type multicaRuntimeProdSimOptions struct { Poll time.Duration RequireIngest bool RequireManagedWake bool + RequireHubFlow bool + MinParticipants int + MinActiveAgents int Stdout io.Writer Stderr io.Writer } type multicaRuntimeProdSimReport struct { - SchemaVersion int `json:"schema_version"` - Status string `json:"status"` - StartedAt string `json:"started_at"` - FinishedAt string `json:"finished_at"` - RunRoot string `json:"run_root"` - ReportPath string `json:"report_path"` - WorkspaceID string `json:"workspace_id"` - RegistryPath string `json:"registry_path"` - Assignee driver.MulticaParticipantRecord `json:"assignee"` - Issue driver.MulticaIssue `json:"issue"` - Runs []driver.MulticaIssueRun `json:"runs,omitempty"` - RunMessages []driver.MulticaRunMessage `json:"run_messages,omitempty"` - Assertions []multicaRuntimeProdSimAssertion `json:"assertions"` - Errors []string `json:"errors,omitempty"` + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` + StartedAt string `json:"started_at"` + FinishedAt string `json:"finished_at"` + RunRoot string `json:"run_root"` + ReportPath string `json:"report_path"` + WorkspaceID string `json:"workspace_id"` + RegistryPath string `json:"registry_path"` + Assignee driver.MulticaParticipantRecord `json:"assignee"` + Participants []driver.MulticaParticipantRecord `json:"participants,omitempty"` + Issue driver.MulticaIssue `json:"issue"` + RootMetadata map[string]string `json:"root_metadata,omitempty"` + ChildIssues []driver.MulticaIssue `json:"child_issues,omitempty"` + ChildMetadata map[string]map[string]string `json:"child_metadata,omitempty"` + Runs []driver.MulticaIssueRun `json:"runs,omitempty"` + RunMessages []driver.MulticaRunMessage `json:"run_messages,omitempty"` + ChildRuns map[string][]driver.MulticaIssueRun `json:"child_runs,omitempty"` + ChildMessages map[string][]driver.MulticaRunMessage `json:"child_messages,omitempty"` + ActiveAgents []string `json:"active_agents,omitempty"` + Assertions []multicaRuntimeProdSimAssertion `json:"assertions"` + Errors []string `json:"errors,omitempty"` } type multicaRuntimeProdSimAssertion struct { @@ -135,6 +155,12 @@ func runMulticaRuntimeProdSimAcceptance(ctx context.Context, opts multicaRuntime if opts.Poll <= 0 { opts.Poll = 5 * time.Second } + if opts.MinParticipants <= 0 { + opts.MinParticipants = 5 + } + if opts.MinActiveAgents <= 0 { + opts.MinActiveAgents = 3 + } runRoot := strings.TrimSpace(opts.RunRoot) if runRoot == "" { runRoot = filepath.Join(".testdata", "multica-runtime-prod-sim", started.Format("20060102T150405Z")) @@ -167,6 +193,13 @@ func runMulticaRuntimeProdSimAcceptance(ctx context.Context, opts multicaRuntime return finishMulticaRuntimeProdSimReport(report, fmt.Errorf("Multica registry not found: %s", registryPath)) } report.RegistryPath = registryPath + report.Participants = registry.Participants + if opts.RequireHubFlow { + addMulticaProdSimAssertion(&report, "hub-flow registry has participant team", len(registry.Participants) >= opts.MinParticipants, fmt.Sprintf("participants=%d min=%d", len(registry.Participants), opts.MinParticipants)) + if len(registry.Participants) < opts.MinParticipants { + return finishMulticaRuntimeProdSimReport(report, fmt.Errorf("Multica hub-flow requires at least %d participants", opts.MinParticipants)) + } + } assignee, err := selectMulticaAcceptanceAssignee(registry, opts.AssigneePrincipal) if err != nil { return finishMulticaRuntimeProdSimReport(report, err) @@ -216,6 +249,11 @@ func runMulticaRuntimeProdSimAcceptance(ctx context.Context, opts multicaRuntime if opts.RequireManagedWake { addMulticaProdSimAssertion(&report, "runtime completed managed wake", strings.Contains(combined, "Managed wake: completed"), combined) } + if opts.RequireHubFlow { + if err := collectMulticaHubFlowEvidence(ctx, cli, opts, &report); err != nil { + return finishMulticaRuntimeProdSimReport(report, err) + } + } if !multicaProdSimAssertionsPassed(report) { return finishMulticaRuntimeProdSimReport(report, fmt.Errorf("Multica runtime prod-sim assertions failed")) } @@ -243,6 +281,53 @@ func selectMulticaAcceptanceAssignee(reg driver.MulticaRegistry, principal strin return driver.MulticaParticipantRecord{}, fmt.Errorf("registry has no participant with a Multica agent id") } +func collectMulticaHubFlowEvidence(ctx context.Context, cli driver.MulticaCLI, opts multicaRuntimeProdSimOptions, report *multicaRuntimeProdSimReport) error { + rootMeta, err := cli.ListIssueMetadata(ctx, report.Issue.ID) + if err != nil { + addMulticaProdSimAssertion(report, "root issue metadata readable", false, err.Error()) + return err + } + report.RootMetadata = rootMeta + 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) + report.ChildIssues = children + report.ChildMetadata = childMeta + if err != nil { + addMulticaProdSimAssertion(report, "assignment child issue mailboxes created", false, err.Error()) + return err + } + addMulticaProdSimAssertion(report, "assignment child issue mailboxes created", len(children) > 0, fmt.Sprintf("children=%d", 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 + } + } + 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) + report.ChildRuns = childRuns + report.ChildMessages = childMessages + report.ActiveAgents = activeAgents + if err != nil { + addMulticaProdSimAssertion(report, "hub-flow activates multiple Multica agents", false, err.Error()) + return err + } + addMulticaProdSimAssertion(report, "hub-flow activates multiple Multica agents", len(activeAgents) >= opts.MinActiveAgents, fmt.Sprintf("active_agents=%v min=%d", activeAgents, opts.MinActiveAgents)) + 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) + } + return nil +} + 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 @@ -275,6 +360,99 @@ 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) { + deadline := time.Now().Add(wait) + for { + children, meta, err := listMulticaAssignmentChildren(ctx, cli, rootIssueID) + if err != nil { + return children, meta, err + } + if len(children) > 0 { + 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) + } + select { + case <-ctx.Done(): + return children, meta, ctx.Err() + case <-time.After(poll): + } + } +} + +func listMulticaAssignmentChildren(ctx context.Context, cli driver.MulticaCLI, rootIssueID string) ([]driver.MulticaIssue, map[string]map[string]string, error) { + rawChildren, err := cli.ListIssueChildren(ctx, rootIssueID) + if err != nil { + return nil, nil, err + } + metaByIssue := map[string]map[string]string{} + var children []driver.MulticaIssue + 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 + } + if meta[driver.MulticaMetadataKind] != driver.MulticaHubKindAssignmentMailbox { + continue + } + children = append(children, child) + metaByIssue[child.ID] = meta + } + return children, metaByIssue, nil +} + +func waitMulticaChildRunEvidence(ctx context.Context, cli driver.MulticaCLI, rootIssueID string, rootRuns []driver.MulticaIssueRun, children []driver.MulticaIssue, wait, poll time.Duration, minActive int) (map[string][]driver.MulticaIssueRun, map[string][]driver.MulticaRunMessage, []string, error) { + deadline := time.Now().Add(wait) + var lastRuns map[string][]driver.MulticaIssueRun + var lastMessages map[string][]driver.MulticaRunMessage + for { + childRuns := map[string][]driver.MulticaIssueRun{} + childMessages := map[string][]driver.MulticaRunMessage{} + active := multicaActiveAgentIDs(rootRuns) + for _, child := range children { + runs, err := cli.ListIssueRuns(ctx, child.ID) + if err != nil { + return childRuns, childMessages, sortedMulticaActiveAgents(active), err + } + childRuns[child.ID] = runs + for _, run := range runs { + if strings.TrimSpace(run.AgentID) != "" { + active[run.AgentID] = true + } + if !multicaRunTerminal(run) { + continue + } + messages, err := cli.ListRunMessages(ctx, run.ID, child.ID) + if err != nil { + return childRuns, childMessages, sortedMulticaActiveAgents(active), err + } + if len(messages) > 0 { + childMessages[child.ID] = append(childMessages[child.ID], messages...) + } + } + } + lastRuns = childRuns + lastMessages = childMessages + activeList := sortedMulticaActiveAgents(active) + if len(activeList) >= minActive && len(childMessages) > 0 { + return childRuns, childMessages, activeList, nil + } + if wait <= 0 || time.Now().After(deadline) { + return lastRuns, lastMessages, activeList, fmt.Errorf("timed out waiting for child run evidence on root %s active_agents=%v child_messages=%d", rootIssueID, activeList, len(childMessages)) + } + select { + case <-ctx.Done(): + return lastRuns, lastMessages, activeList, ctx.Err() + case <-time.After(poll): + } + } +} + func multicaRunTerminal(run driver.MulticaIssueRun) bool { if strings.TrimSpace(run.CompletedAt) != "" { return true @@ -297,6 +475,37 @@ func combinedMulticaRunMessages(messages []driver.MulticaRunMessage) string { return strings.Join(parts, "\n") } +func combinedMulticaChildMessages(messages map[string][]driver.MulticaRunMessage) string { + var parts []string + for _, items := range messages { + for _, message := range items { + if strings.TrimSpace(message.Content) != "" { + parts = append(parts, strings.TrimSpace(message.Content)) + } + } + } + return strings.Join(parts, "\n") +} + +func multicaActiveAgentIDs(runs []driver.MulticaIssueRun) map[string]bool { + out := map[string]bool{} + for _, run := range runs { + if strings.TrimSpace(run.AgentID) != "" { + out[run.AgentID] = true + } + } + return out +} + +func sortedMulticaActiveAgents(active map[string]bool) []string { + var out []string + for id := range active { + out = append(out, id) + } + sort.Strings(out) + return out +} + func addMulticaProdSimAssertion(report *multicaRuntimeProdSimReport, name string, passed bool, detail string) { report.Assertions = append(report.Assertions, multicaRuntimeProdSimAssertion{Name: name, Passed: passed, Detail: detail}) } diff --git a/harness/cmd/mnemon-acceptance/acceptance_multica_runtime_test.go b/harness/cmd/mnemon-acceptance/acceptance_multica_runtime_test.go index c147543b..475ea6b3 100644 --- a/harness/cmd/mnemon-acceptance/acceptance_multica_runtime_test.go +++ b/harness/cmd/mnemon-acceptance/acceptance_multica_runtime_test.go @@ -62,7 +62,7 @@ esac RequireManagedWake: true, }) if err != nil { - t.Fatalf("acceptance: %v", err) + t.Fatalf("acceptance: %v report=%+v", err, report) } if report.Status != "ok" || report.Issue.ID != "iss-9" || len(report.RunMessages) != 1 { t.Fatalf("report mismatch: %+v", report) @@ -93,3 +93,93 @@ esac t.Fatalf("issue description was not passed through stdin:\n%s", stdin) } } + +func TestMulticaRuntimeProdSimAcceptanceRequiresHubFlow(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 + *"issue create"*) printf '{"id":"root-9","identifier":"TEA-9","title":"Runtime hub flow","description":"Teamwork acceptance","status":"todo"}\n' ;; + *"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","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","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 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":"assistant","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"}]\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":"assistant","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"}]\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":"assistant","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"}]\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-runtime"), + MulticaBin: bin, + WorkspaceID: "ws-1", + RegistryPath: registryPath, + AssigneePrincipal: "planner@team", + IssueTitle: "Runtime hub flow", + 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.Participants) != 5 || len(report.ChildIssues) != 2 || len(report.ActiveAgents) != 3 { + t.Fatalf("hub report mismatch: %+v", report) + } + if report.RootMetadata[driver.MulticaMetadataKind] != driver.MulticaHubKindSession { + t.Fatalf("root metadata mismatch: %+v", report.RootMetadata) + } + if !multicaProdSimAssertionsPassed(report) { + t.Fatalf("hub assertions failed: %+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 children root-9 --output json", + "issue runs child-2 --output json", + "issue runs child-3 --output json", + } { + if !strings.Contains(string(args), want) { + t.Fatalf("args missing %q:\n%s", want, args) + } + } +} From 164443a1fe805907899ffa004840440a92bd95b6 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 20:05:11 +0800 Subject: [PATCH 15/24] fix: render wake context without token auth Avoid expanding an empty token argument array in the standard remind hook when trusted local access is used. Add an execution-level hook test that verifies a managed wake can render teamwork.events without a token file.\n\nValidation: go test ./... --- harness/internal/hostagent/thinshim.go | 6 +-- harness/internal/hostagent/thinshim_test.go | 55 +++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/harness/internal/hostagent/thinshim.go b/harness/internal/hostagent/thinshim.go index ee3a39f8..21b81822 100644 --- a/harness/internal/hostagent/thinshim.go +++ b/harness/internal/hostagent/thinshim.go @@ -122,11 +122,11 @@ func hookBodyBlock(timing string) string { `HOOK_BODY="[mnemon] Evaluate whether governed context should be read before responding."`, `if printf '%s' "${INPUT}" | grep -q '\[mnemon:wake\]'; then`, ` if [[ -n "${MNEMON_CONTROL_ADDR:-}" && -n "${MNEMON_CONTROL_PRINCIPAL:-}" ]]; then`, - ` TOKEN_ARGS=()`, + ` RENDER_ARGS=(control render --addr "${MNEMON_CONTROL_ADDR}" --principal "${MNEMON_CONTROL_PRINCIPAL}")`, ` if [[ -n "${MNEMON_CONTROL_TOKEN_FILE:-}" ]]; then`, - ` TOKEN_ARGS=(--token-file "${MNEMON_CONTROL_TOKEN_FILE}")`, + ` RENDER_ARGS+=(--token-file "${MNEMON_CONTROL_TOKEN_FILE}")`, ` fi`, - ` if RENDERED="$(cd "${PROJECT_ROOT}" && "${MNEMON_HARNESS_BIN:-mnemon-harness}" control render --addr "${MNEMON_CONTROL_ADDR}" --principal "${MNEMON_CONTROL_PRINCIPAL}" "${TOKEN_ARGS[@]}" --intent teamwork.events --lifecycle remind --surface hook 2>/dev/null)"; then`, + ` if RENDERED="$(cd "${PROJECT_ROOT}" && "${MNEMON_HARNESS_BIN:-mnemon-harness}" "${RENDER_ARGS[@]}" --intent teamwork.events --lifecycle remind --surface hook 2>/dev/null)"; then`, ` if [[ -n "${RENDERED}" ]]; then`, ` HOOK_BODY="$(printf '%s\n\n%s' "[mnemon] Wake signal received. Current governed context:" "${RENDERED}")"`, ` fi`, diff --git a/harness/internal/hostagent/thinshim_test.go b/harness/internal/hostagent/thinshim_test.go index 6cdd12c5..d5eaf1f0 100644 --- a/harness/internal/hostagent/thinshim_test.go +++ b/harness/internal/hostagent/thinshim_test.go @@ -1,6 +1,9 @@ package hostagent import ( + "os" + "os/exec" + "path/filepath" "strings" "testing" @@ -31,6 +34,7 @@ func TestRenderThinHookIsGenericLifecycleShim(t *testing.T) { "MEMORY.md", "control pull", "control observe", + "TOKEN_ARGS", "assignment", "progress_digest", "agent_profile", @@ -45,6 +49,57 @@ func TestRenderThinHookIsGenericLifecycleShim(t *testing.T) { } } +func TestRenderThinHookWakeRendersWithoutTokenFile(t *testing.T) { + root := t.TempDir() + hookDir := filepath.Join(root, ".codex", "hooks", "mnemon-r1") + if err := os.MkdirAll(hookDir, 0o755); err != nil { + t.Fatalf("mkdir hook dir: %v", err) + } + binDir := filepath.Join(root, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin dir: %v", err) + } + + hook, err := RenderStandardThinHook("codex", "remind") + if err != nil { + t.Fatalf("render codex remind hook: %v", err) + } + hookPath := filepath.Join(hookDir, "remind.sh") + if err := os.WriteFile(hookPath, []byte(hook), 0o755); err != nil { + t.Fatalf("write hook: %v", err) + } + + harnessPath := filepath.Join(binDir, "mnemon-harness") + stub := `#!/usr/bin/env bash +set -euo pipefail +if [[ "$*" == *"--token-file"* ]]; then + exit 7 +fi +if [[ "$*" != *"control render"* || "$*" != *"--intent teamwork.events"* ]]; then + exit 8 +fi +printf '[mnemon:work]\nassignment addressed to this principal\n' +` + if err := os.WriteFile(harnessPath, []byte(stub), 0o755); err != nil { + t.Fatalf("write harness stub: %v", err) + } + + cmd := exec.Command("bash", hookPath) + cmd.Stdin = strings.NewReader("[mnemon:wake]\n") + cmd.Env = append(os.Environ(), + "MNEMON_HARNESS_BIN="+harnessPath, + "MNEMON_CONTROL_ADDR=http://127.0.0.1:8791", + "MNEMON_CONTROL_PRINCIPAL=planner@team", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("run hook without token file: %v\n%s", err, out) + } + if !strings.Contains(string(out), "[mnemon:work]") { + t.Fatalf("wake hook must include rendered governed context, got:\n%s", out) + } +} + func TestRenderThinHookPrimeLoadsManagedGuide(t *testing.T) { body, err := RenderStandardThinHook("codex", "prime") if err != nil { From 9f7a3648b5093c2fd71176a5d2f20a2bc6315101 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 20:07:27 +0800 Subject: [PATCH 16/24] fix: preserve managed runtime principal defaults Make generated local env files provide defaults instead of overriding an explicit runtime environment. This lets mnemond-managed agents share a workspace while still preserving the per-agent principal passed by the managed runtime.\n\nValidation: go test ./... --- harness/internal/app/setup.go | 2 +- harness/internal/app/setup_test.go | 52 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/harness/internal/app/setup.go b/harness/internal/app/setup.go index 0169b79d..4cb370d6 100644 --- a/harness/internal/app/setup.go +++ b/harness/internal/app/setup.go @@ -364,7 +364,7 @@ func writeLocalEnv(path string, opts SetupOptions, tokenRel string, loops []stri } func exportLine(key, value string) string { - return fmt.Sprintf("export %s=%q\n", key, value) + return fmt.Sprintf("if [ -z \"${%s:-}\" ]; then\n export %s=%q\nelse\n export %s\nfi\n", key, key, value, key) } func unionLoops(a, b []string) []string { diff --git a/harness/internal/app/setup_test.go b/harness/internal/app/setup_test.go index 7d156ed9..d865bfd9 100644 --- a/harness/internal/app/setup_test.go +++ b/harness/internal/app/setup_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -161,6 +162,31 @@ func TestSetupDryRunWritesNothing(t *testing.T) { } } +func TestSetupLocalEnvPreservesExplicitRuntimePrincipal(t *testing.T) { + root := t.TempDir() + var out, errw bytes.Buffer + if _, err := New(root).Setup(context.Background(), &out, &errw, SetupOptions{ + Host: "codex", + ControlURL: "http://127.0.0.1:8787", + Principal: "setup@project", + UseToken: false, + }); err != nil { + t.Fatalf("setup: %v\nstderr=%s", err, errw.String()) + } + envPath := filepath.Join(root, ".mnemon", "harness", "local", "env.sh") + + defaultOut := sourceLocalEnv(t, envPath, envWithout("MNEMON_CONTROL_PRINCIPAL")) + if got, want := strings.TrimSpace(defaultOut), "setup@project"; got != want { + t.Fatalf("local env default principal = %q, want %q", got, want) + } + + explicitEnv := append(envWithout("MNEMON_CONTROL_PRINCIPAL"), "MNEMON_CONTROL_PRINCIPAL=runtime@project") + explicitOut := sourceLocalEnv(t, envPath, explicitEnv) + if got, want := strings.TrimSpace(explicitOut), "runtime@project"; got != want { + t.Fatalf("local env must preserve explicit runtime principal = %q, want %q", got, want) + } +} + func TestSetupRejectsUnsupportedEventPackage(t *testing.T) { root := t.TempDir() var out, errw bytes.Buffer @@ -211,6 +237,32 @@ func mustRead(t *testing.T, path string) []byte { return b } +func sourceLocalEnv(t *testing.T, envPath string, env []string) string { + t.Helper() + cmd := exec.Command("bash", "-c", `source "$1"; printf '%s\n' "$MNEMON_CONTROL_PRINCIPAL"`, "bash", envPath) + cmd.Env = env + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("source local env: %v\n%s", err, out) + } + return string(out) +} + +func envWithout(keys ...string) []string { + blocked := map[string]bool{} + for _, key := range keys { + blocked[key] = true + } + out := make([]string, 0, len(os.Environ())) + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + if !blocked[key] { + out = append(out, entry) + } + } + return out +} + func assertPublicSetupOutput(t *testing.T, output string) { t.Helper() for _, want := range []string{"Agent Integration:", "Local Mnemon:", "Remote Workspace:"} { From f95afb592d2d42389db2f308df95f2763be847c6 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 20:09:57 +0800 Subject: [PATCH 17/24] feat: inject harness cli into multica agents Allow Multica provision to pass an explicit mnemon-harness executable to participant agents. This keeps managed wake hooks on the same build as the registered runtime during local validation and packaged deployments.\n\nValidation: go test ./... --- harness/cmd/mnemon-harness/multica.go | 3 +++ harness/cmd/mnemon-harness/multica_test.go | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/harness/cmd/mnemon-harness/multica.go b/harness/cmd/mnemon-harness/multica.go index 899b50bf..f7291b8c 100644 --- a/harness/cmd/mnemon-harness/multica.go +++ b/harness/cmd/mnemon-harness/multica.go @@ -54,6 +54,7 @@ var ( multicaProvisionControlAddr string multicaProvisionControlToken string multicaProvisionControlTokenFile string + multicaProvisionHarnessBin string multicaProvisionManagedRuntime string multicaProvisionManagedCommand string multicaProvisionManagedWorkspace string @@ -478,6 +479,7 @@ func multicaParticipantRuntimeEnv(cli driver.MulticaCLI, participant driver.Mult addStringEnv(env, "MNEMON_CONTROL_TOKEN", multicaProvisionControlToken) addStringEnv(env, "MNEMON_CONTROL_TOKEN_FILE", multicaProvisionControlTokenFile) addStringEnv(env, "MNEMON_CONTROL_PRINCIPAL", participant.Principal) + addStringEnv(env, "MNEMON_HARNESS_BIN", multicaProvisionHarnessBin) addStringEnv(env, "MNEMON_MANAGED_RUNTIME", multicaProvisionManagedRuntime) addStringEnv(env, "MNEMON_MANAGED_COMMAND", multicaProvisionManagedCommand) addStringEnv(env, "MNEMON_MANAGED_WORKSPACE", multicaProvisionManagedWorkspace) @@ -669,6 +671,7 @@ func init() { multicaProvisionCmd.Flags().StringVar(&multicaProvisionControlAddr, "mnemon-control-addr", envDefault("MNEMON_CONTROL_ADDR", ""), "Local Mnemon URL injected into participant runtime env") multicaProvisionCmd.Flags().StringVar(&multicaProvisionControlToken, "mnemon-control-token", envDefault("MNEMON_CONTROL_TOKEN", ""), "Local Mnemon bearer token injected into participant runtime env") multicaProvisionCmd.Flags().StringVar(&multicaProvisionControlTokenFile, "mnemon-control-token-file", envDefault("MNEMON_CONTROL_TOKEN_FILE", ""), "Local Mnemon bearer token file injected into participant runtime env") + multicaProvisionCmd.Flags().StringVar(&multicaProvisionHarnessBin, "harness-bin", envDefault("MNEMON_HARNESS_BIN", ""), "mnemon-harness executable injected into participant runtime env") multicaProvisionCmd.Flags().StringVar(&multicaProvisionManagedRuntime, "managed-runtime", envDefault("MNEMON_MANAGED_RUNTIME", ""), "managed agent runtime injected into participant env (noop or codex-appserver)") 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") diff --git a/harness/cmd/mnemon-harness/multica_test.go b/harness/cmd/mnemon-harness/multica_test.go index 829c4d88..e4daf9b2 100644 --- a/harness/cmd/mnemon-harness/multica_test.go +++ b/harness/cmd/mnemon-harness/multica_test.go @@ -123,6 +123,7 @@ esac multicaProvisionRestartDaemon = false multicaProvisionWait = 0 multicaProvisionControlAddr = "http://127.0.0.1:8787" + multicaProvisionHarnessBin = "/abs/mnemon-harness" multicaProvisionManagedRuntime = "noop" multicaProvisionManagedWorkspace = tmp multicaJSON = true @@ -171,6 +172,7 @@ esac `"MNEMON_MULTICA_WORKSPACE_ID":"ws-1"`, `"MNEMON_CONTROL_ADDR":"http://127.0.0.1:8787"`, `"MNEMON_CONTROL_PRINCIPAL":"planner@team"`, + `"MNEMON_HARNESS_BIN":"/abs/mnemon-harness"`, `"MNEMON_MANAGED_RUNTIME":"noop"`, `"MNEMON_MANAGED_WORKSPACE":"` + tmp + `"`, } { @@ -216,6 +218,7 @@ func restoreMulticaFlags(t *testing.T) { oldProvisionControlAddr := multicaProvisionControlAddr oldProvisionControlToken := multicaProvisionControlToken oldProvisionControlTokenFile := multicaProvisionControlTokenFile + oldProvisionHarnessBin := multicaProvisionHarnessBin oldProvisionManagedRuntime := multicaProvisionManagedRuntime oldProvisionManagedCommand := multicaProvisionManagedCommand oldProvisionManagedWorkspace := multicaProvisionManagedWorkspace @@ -255,6 +258,7 @@ func restoreMulticaFlags(t *testing.T) { multicaProvisionControlAddr = oldProvisionControlAddr multicaProvisionControlToken = oldProvisionControlToken multicaProvisionControlTokenFile = oldProvisionControlTokenFile + multicaProvisionHarnessBin = oldProvisionHarnessBin multicaProvisionManagedRuntime = oldProvisionManagedRuntime multicaProvisionManagedCommand = oldProvisionManagedCommand multicaProvisionManagedWorkspace = oldProvisionManagedWorkspace From 7dbcf66458c8ada17c9741f01d9202c91ea685dd Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 20:26:06 +0800 Subject: [PATCH 18/24] feat: stream multica runtime progress Project Mnemon runtime progress into Multica agent run messages while keeping protocol JSON internal. Assignment mailbox projection now creates the child issue, writes mailbox metadata, and only then assigns the target agent to avoid child issues being misclassified as new sessions.\n\nValidation: go test ./... --- .../cmd/mnemon-multica-runtime/hub_writer.go | 4 +- harness/cmd/mnemon-multica-runtime/main.go | 228 +++++++++++++++--- .../cmd/mnemon-multica-runtime/main_test.go | 18 +- 3 files changed, 213 insertions(+), 37 deletions(-) diff --git a/harness/cmd/mnemon-multica-runtime/hub_writer.go b/harness/cmd/mnemon-multica-runtime/hub_writer.go index cb6c67a0..76041c79 100644 --- a/harness/cmd/mnemon-multica-runtime/hub_writer.go +++ b/harness/cmd/mnemon-multica-runtime/hub_writer.go @@ -130,7 +130,6 @@ func (s *runtimeRPCState) writeAssignmentMailboxes(ctx context.Context, cli driv child, err := cli.CreateIssue(ctx, driver.MulticaCreateIssueRequest{ Title: assignmentMailboxTitle(item), Description: assignmentMailboxDescription(item, result), - AssigneeID: participant.AgentID, ParentID: result.RootIssueID, Status: "todo", Priority: "medium", @@ -159,6 +158,9 @@ func (s *runtimeRPCState) writeAssignmentMailboxes(ctx context.Context, cli driv if err := cli.SetIssueMetadataMap(ctx, child.ID, meta.Map()); err != nil { return err } + if _, err := cli.AssignIssue(ctx, child.ID, participant.AgentID); err != nil { + return err + } if err := ledger.Record(driver.MulticaHubLedgerRecord{ Kind: driver.MulticaHubKindAssignmentMailbox, Source: source, diff --git a/harness/cmd/mnemon-multica-runtime/main.go b/harness/cmd/mnemon-multica-runtime/main.go index fae0e06a..fc8eb850 100644 --- a/harness/cmd/mnemon-multica-runtime/main.go +++ b/harness/cmd/mnemon-multica-runtime/main.go @@ -53,6 +53,8 @@ type runtimeRPCState struct { Now func() time.Time } +type runtimeProgressSink func(string) + type runtimeImportResult struct { IssueID string Identifier string @@ -140,20 +142,27 @@ func runRuntimeRPC(cfg runtimeConfig, cwd string) error { fmt.Fprintf(cfg.Stderr, "mnemon-multica-runtime: ignoring invalid rpc line: %v\n", err) continue } - responses := state.handle(msg) - for _, response := range responses { - if err := writeRuntimeRPC(cfg.Stdout, response); err != nil { - return err - } + if err := state.handle(msg, func(response rpcMessage) error { + return writeRuntimeRPC(cfg.Stdout, response) + }); err != nil { + return err } } return scanner.Err() } -func (s *runtimeRPCState) handle(msg rpcMessage) []rpcMessage { +func (s *runtimeRPCState) handle(msg rpcMessage, emit func(rpcMessage) error) error { + emitAll := func(messages ...rpcMessage) error { + for _, message := range messages { + if err := emit(message); err != nil { + return err + } + } + return nil + } switch msg.Method { case "initialize": - return []rpcMessage{{ + return emitAll(rpcMessage{ ID: msg.ID, Result: map[string]any{ "userAgent": "mnemon-multica-runtime/" + runtimeVersion, @@ -161,13 +170,13 @@ func (s *runtimeRPCState) handle(msg rpcMessage) []rpcMessage { "platformFamily": "unix", "platformOs": runtime.GOOS, }, - }} + }) case "initialized": return nil case "thread/start", "thread/resume": s.ThreadID = firstNonEmpty(stringParam(msg.Params, "threadId"), runtimeID("thread", s.now())) - return []rpcMessage{ - { + return emitAll( + rpcMessage{ Method: "remoteControl/status/changed", Params: map[string]any{ "status": "disabled", @@ -175,43 +184,61 @@ func (s *runtimeRPCState) handle(msg rpcMessage) []rpcMessage { "installationId": "mnemon-multica-runtime", }, }, - { + rpcMessage{ ID: msg.ID, Result: s.threadStartResult(msg.Params), }, - { + rpcMessage{ Method: "thread/started", Params: map[string]any{ "thread": s.threadObject(), }, }, - } + ) case "thread/name/set", "session/set_model": - return []rpcMessage{{ID: msg.ID, Result: map[string]any{}}} + return emitAll(rpcMessage{ID: msg.ID, Result: map[string]any{}}) case "turn/start": s.TurnID = runtimeID("turn", s.now()) input := extractRuntimeInput(msg.Params) - finalAnswer := s.runTurn(input) - return []rpcMessage{ - { + if err := emitAll( + rpcMessage{ ID: msg.ID, Result: map[string]any{ "turn": s.turnObject("inProgress"), }, }, - { + rpcMessage{ Method: "thread/status/changed", Params: map[string]any{"threadId": s.ThreadID, "status": map[string]any{"type": "active", "activeFlags": []any{}}}, }, - { + rpcMessage{ Method: "turn/started", Params: map[string]any{"threadId": s.ThreadID, "turn": s.turnObject("inProgress")}, }, - { + rpcMessage{ Method: "item/started", Params: map[string]any{"threadId": s.ThreadID, "turnId": s.TurnID, "item": runtimeAgentMessage("")}, }, - { + ); err != nil { + return err + } + var chunks []string + var progressErr error + progress := func(text string) { + text = strings.TrimSpace(text) + if text == "" || progressErr != nil { + return + } + chunks = append(chunks, text) + progressErr = emit(runtimeAgentDelta(s.ThreadID, s.TurnID, text+"\n\n")) + } + finalAnswer := s.runTurn(input, progress) + if progressErr != nil { + return progressErr + } + completedText := strings.Join(append(append([]string{}, chunks...), finalAnswer), "\n\n") + return emitAll( + rpcMessage{ Method: "item/agentMessage/delta", Params: map[string]any{ "threadId": s.ThreadID, @@ -220,33 +247,33 @@ func (s *runtimeRPCState) handle(msg rpcMessage) []rpcMessage { "delta": finalAnswer, }, }, - { + rpcMessage{ Method: "item/completed", - Params: map[string]any{"threadId": s.ThreadID, "turnId": s.TurnID, "item": runtimeAgentMessage(finalAnswer)}, + Params: map[string]any{"threadId": s.ThreadID, "turnId": s.TurnID, "item": runtimeAgentMessage(completedText)}, }, - { + rpcMessage{ Method: "thread/status/changed", Params: map[string]any{"threadId": s.ThreadID, "status": map[string]any{"type": "idle"}}, }, - { + rpcMessage{ Method: "turn/completed", Params: map[string]any{"threadId": s.ThreadID, "turn": s.turnObject("completed")}, }, - } + ) default: if msg.ID == nil { return nil } - return []rpcMessage{{ID: msg.ID, Result: map[string]any{}}} + return emitAll(rpcMessage{ID: msg.ID, Result: map[string]any{}}) } } -func (s *runtimeRPCState) runTurn(input string) string { - result := s.importIssue(input) +func (s *runtimeRPCState) runTurn(input string, progress runtimeProgressSink) string { + result := s.importIssue(input, progress) return formatRuntimeFinalAnswer(result) } -func (s *runtimeRPCState) importIssue(input string) runtimeImportResult { +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)) result := runtimeImportResult{ @@ -254,18 +281,22 @@ func (s *runtimeRPCState) importIssue(input string) runtimeImportResult { Principal: resolveRuntimePrincipal(s.Env, s.CWD), TaskID: taskID, } + emitRuntimeProgress(progress, "Mnemon runtime accepted the Multica task for "+displayRuntimePrincipal(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 } ctx, cancel := context.WithTimeout(context.Background(), runtimeTimeout(s.Env)) defer cancel() cli := runtimeMulticaCLI(s.Env) + emitRuntimeProgress(progress, "Loading Multica issue "+issueID+".") issue, err := cli.GetIssue(ctx, issueID) if err != nil { result.Status = "failed" result.Err = fmt.Errorf("fetch Multica issue %s: %w", issueID, err) + emitRuntimeProgress(progress, "Failed to load Multica issue "+issueID+".") return result } result.IssueID = issue.ID @@ -275,8 +306,10 @@ func (s *runtimeRPCState) importIssue(input string) runtimeImportResult { result.HubMetadata = driver.MulticaIssueHubMetadata(issue) applyMulticaHubMetadata(&result, issue) result.HubBackend = firstNonEmpty(result.HubBackend, envValue(s.Env, "MNEMON_HUB_BACKEND")) + emitRuntimeProgress(progress, "Loaded "+runtimeIssueLabel(issue)+"; classifying Mnemon hub metadata.") + markIssueInProgress(ctx, cli, issue.ID) if result.HubMetadata.IsAssignmentMailbox() { - return s.correlateAssignmentMailbox(ctx, cli, issue, &result) + return s.correlateAssignmentMailbox(ctx, cli, issue, &result, progress) } externalID := "" if taskID != "" { @@ -312,20 +345,25 @@ func (s *runtimeRPCState) importIssue(input string) runtimeImportResult { if err := cli.SetIssueMetadataMap(ctx, issue.ID, rootSessionMetadata(result, draft, s.now())); err != nil { result.Status = "failed" result.Err = fmt.Errorf("set Multica root session metadata: %w", err) + emitRuntimeProgress(progress, "Failed to write Mnemon root session metadata.") return result } + emitRuntimeProgress(progress, "Root session metadata written for "+runtimeIssueLabel(issue)+".") } addr := strings.TrimSpace(envValue(s.Env, "MNEMON_CONTROL_ADDR")) if addr == "" { result.Status = "skipped" + emitRuntimeProgress(progress, "Local Mnemon control address is not configured; skipping protocol ingest.") return result } client, err := runtimeControlClient(s.Env, addr, result.Principal) if err != nil { result.Status = "failed" result.Err = err + emitRuntimeProgress(progress, "Failed to create Local Mnemon control client.") return result } + emitRuntimeProgress(progress, "Submitting issue narrative to Mnemon teamwork.") rec, err := client.IngestObserve(contract.ActorID(result.Principal), contract.ObservationEnvelope{ ExternalID: draft.ExternalID, Event: contract.Event{ @@ -337,17 +375,23 @@ func (s *runtimeRPCState) importIssue(input string) runtimeImportResult { if err != nil { result.Status = "failed" result.Err = fmt.Errorf("ingest Mnemon observation: %w", err) + emitRuntimeProgress(progress, "Mnemon rejected or failed the issue observation.") return result } result.Status = "recorded" result.Receipt = &rec + emitRuntimeProgress(progress, fmt.Sprintf("Mnemon recorded the issue observation at seq=%d.", rec.Seq)) + emitRuntimeProgress(progress, "Waking the managed local agent with [mnemon:wake].") s.wakeManagedAgent(&result) + emitRuntimeProgress(progress, runtimeWakeProgress(result)) s.writeMulticaHubArtifacts(ctx, cli, client, issue, &result) + emitRuntimeProgress(progress, runtimeHubWriteProgress(result)) s.projectImportComment(ctx, cli, issue, draft.ExternalID, &result) + emitRuntimeProgress(progress, runtimeProjectionProgress(result)) return result } -func (s *runtimeRPCState) correlateAssignmentMailbox(ctx context.Context, cli driver.MulticaCLI, issue driver.MulticaIssue, result *runtimeImportResult) runtimeImportResult { +func (s *runtimeRPCState) correlateAssignmentMailbox(ctx context.Context, cli driver.MulticaCLI, issue driver.MulticaIssue, result *runtimeImportResult, progress runtimeProgressSink) runtimeImportResult { ensureAssignmentHubFields(result, issue) result.Status = "correlated" result.MatchTerms = cleanRuntimeTerms( @@ -356,14 +400,19 @@ func (s *runtimeRPCState) correlateAssignmentMailbox(ctx context.Context, cli dr result.HubMetadata.EventID, string(eventmodel.Subject("assignment", result.AssignmentID)), ) + emitRuntimeProgress(progress, "Assignment mailbox correlated: "+runtimeAssignmentLabel(*result)+".") addr := strings.TrimSpace(envValue(s.Env, "MNEMON_CONTROL_ADDR")) if addr == "" { result.WakeStatus = "skipped" result.WakeErr = fmt.Errorf("MNEMON_CONTROL_ADDR is not set") + emitRuntimeProgress(progress, "Local Mnemon control address is not configured; skipping managed wake.") } else { + emitRuntimeProgress(progress, "Waking assigned local agent with [mnemon:wake].") s.wakeManagedAgent(result) + emitRuntimeProgress(progress, runtimeWakeProgress(*result)) } s.projectImportComment(ctx, cli, issue, assignmentMailboxMarker(*result), result) + emitRuntimeProgress(progress, runtimeProjectionProgress(*result)) return *result } @@ -969,6 +1018,18 @@ func writeRuntimeRPC(w io.Writer, msg rpcMessage) error { return err } +func runtimeAgentDelta(threadID, turnID, delta string) rpcMessage { + return rpcMessage{ + Method: "item/agentMessage/delta", + Params: map[string]any{ + "threadId": threadID, + "turnId": turnID, + "itemId": "mnemon-runtime-message", + "delta": delta, + }, + } +} + func runtimeAgentMessage(text string) map[string]any { return map[string]any{ "type": "agentMessage", @@ -979,6 +1040,109 @@ func runtimeAgentMessage(text string) map[string]any { } } +func emitRuntimeProgress(progress runtimeProgressSink, text string) { + if progress != nil { + progress(text) + } +} + +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." + } +} + +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 { diff --git a/harness/cmd/mnemon-multica-runtime/main_test.go b/harness/cmd/mnemon-multica-runtime/main_test.go index cebd5c79..3a29cdcb 100644 --- a/harness/cmd/mnemon-multica-runtime/main_test.go +++ b/harness/cmd/mnemon-multica-runtime/main_test.go @@ -189,7 +189,7 @@ esac } lines := strings.Split(strings.TrimSpace(out.String()), "\n") - var sawComplete, sawAnswer bool + var sawComplete, sawProgress, sawAnswer bool for _, line := range lines { var msg map[string]any if err := json.Unmarshal([]byte(line), &msg); err != nil { @@ -198,12 +198,15 @@ esac if msg["method"] == "turn/completed" { sawComplete = true } + if msg["method"] == "item/agentMessage/delta" && strings.Contains(line, "Loading Multica issue iss-7") { + sawProgress = 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") { sawAnswer = true } } - if !sawComplete || !sawAnswer { - t.Fatalf("missing expected runtime response complete=%v answer=%v:\n%s", sawComplete, sawAnswer, out.String()) + if !sawComplete || !sawProgress || !sawAnswer { + t.Fatalf("missing expected runtime response complete=%v progress=%v answer=%v:\n%s", sawComplete, sawProgress, sawAnswer, out.String()) } } @@ -404,10 +407,11 @@ esac 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 --assignee-id agent-worker --parent root-2 --status todo --priority medium", + "issue create --title Mnemon assignment asg-writer: check release notes --output json --description-stdin --parent root-2 --status todo --priority medium", "issue metadata set child-2 --key mnemon.kind --value assignment_mailbox --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", } { if !strings.Contains(args, want) { t.Fatalf("args missing %q:\n%s", want, args) @@ -416,6 +420,12 @@ esac if strings.Contains(args, "check release notes against") { t.Fatalf("assignment description leaked into argv:\n%s", args) } + createIdx := strings.Index(args, "issue create --title Mnemon assignment asg-writer") + 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) + } 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"} { if !strings.Contains(description, want) { From dc51f1996d61a553b27de41da18e11765c7b0727 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 20:42:26 +0800 Subject: [PATCH 19/24] feat: align multica runtime status events Emit Codex-style runtime item lifecycle events, decouple managed wake duration from subsequent Multica projection calls, and project rule-based issue status transitions from Mnemon hub events. Assignment feedback now moves child issues and the root issue through Multica-visible states without using LLM judgment.\n\nValidation: go test ./... --- .../cmd/mnemon-multica-runtime/hub_writer.go | 23 ++++++ harness/cmd/mnemon-multica-runtime/main.go | 82 +++++++++++++++---- .../cmd/mnemon-multica-runtime/main_test.go | 51 +++++++++++- 3 files changed, 136 insertions(+), 20 deletions(-) diff --git a/harness/cmd/mnemon-multica-runtime/hub_writer.go b/harness/cmd/mnemon-multica-runtime/hub_writer.go index 76041c79..7007ed46 100644 --- a/harness/cmd/mnemon-multica-runtime/hub_writer.go +++ b/harness/cmd/mnemon-multica-runtime/hub_writer.go @@ -161,6 +161,7 @@ func (s *runtimeRPCState) writeAssignmentMailboxes(ctx context.Context, cli driv if _, err := cli.AssignIssue(ctx, child.ID, participant.AgentID); err != nil { return err } + markIssueInProgress(ctx, cli, child.ID) if err := ledger.Record(driver.MulticaHubLedgerRecord{ Kind: driver.MulticaHubKindAssignmentMailbox, Source: source, @@ -208,6 +209,10 @@ func (s *runtimeRPCState) writeProgressComments(ctx context.Context, cli driver. if err != nil { return err } + if status := multicaStatusForProgress(item); status != "" { + _, _ = cli.SetIssueStatus(ctx, child, status) + } + _, _ = cli.SetIssueStatus(ctx, result.RootIssueID, "in_review") if err := ledger.Record(driver.MulticaHubLedgerRecord{ Kind: driver.MulticaHubKindFeedbackCarrier, Source: source, @@ -225,6 +230,24 @@ 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 "in_review" + case "progress": + return "in_progress" + } + if strings.TrimSpace(item.Blocker) != "" { + return "blocked" + } + if strings.TrimSpace(item.Result) != "" { + return "in_review" + } + return "" +} + type runtimeAssignment struct { ID string EventID string diff --git a/harness/cmd/mnemon-multica-runtime/main.go b/harness/cmd/mnemon-multica-runtime/main.go index fc8eb850..e3bc4730 100644 --- a/harness/cmd/mnemon-multica-runtime/main.go +++ b/harness/cmd/mnemon-multica-runtime/main.go @@ -3,6 +3,7 @@ package main import ( "bufio" "context" + "crypto/sha256" "encoding/json" "flag" "fmt" @@ -200,6 +201,9 @@ func (s *runtimeRPCState) handle(msg rpcMessage, emit func(rpcMessage) error) er case "turn/start": s.TurnID = runtimeID("turn", s.now()) input := extractRuntimeInput(msg.Params) + nowMs := s.now().UnixMilli() + userItem := runtimeUserMessage(input) + agentItemID := runtimeID("msg", s.now()) if err := emitAll( rpcMessage{ ID: msg.ID, @@ -217,7 +221,15 @@ func (s *runtimeRPCState) handle(msg rpcMessage, emit func(rpcMessage) error) er }, rpcMessage{ Method: "item/started", - Params: map[string]any{"threadId": s.ThreadID, "turnId": s.TurnID, "item": runtimeAgentMessage("")}, + Params: runtimeItemParams(s.ThreadID, s.TurnID, userItem, "startedAtMs", nowMs), + }, + rpcMessage{ + Method: "item/completed", + Params: runtimeItemParams(s.ThreadID, s.TurnID, userItem, "completedAtMs", nowMs), + }, + rpcMessage{ + Method: "item/started", + Params: runtimeItemParams(s.ThreadID, s.TurnID, runtimeAgentMessage(agentItemID, ""), "startedAtMs", nowMs), }, ); err != nil { return err @@ -230,7 +242,7 @@ func (s *runtimeRPCState) handle(msg rpcMessage, emit func(rpcMessage) error) er return } chunks = append(chunks, text) - progressErr = emit(runtimeAgentDelta(s.ThreadID, s.TurnID, text+"\n\n")) + progressErr = emit(runtimeAgentDelta(s.ThreadID, s.TurnID, agentItemID, text+"\n\n")) } finalAnswer := s.runTurn(input, progress) if progressErr != nil { @@ -243,13 +255,13 @@ func (s *runtimeRPCState) handle(msg rpcMessage, emit func(rpcMessage) error) er Params: map[string]any{ "threadId": s.ThreadID, "turnId": s.TurnID, - "itemId": "mnemon-runtime-message", + "itemId": agentItemID, "delta": finalAnswer, }, }, rpcMessage{ Method: "item/completed", - Params: map[string]any{"threadId": s.ThreadID, "turnId": s.TurnID, "item": runtimeAgentMessage(completedText)}, + Params: runtimeItemParams(s.ThreadID, s.TurnID, runtimeAgentMessage(agentItemID, completedText), "completedAtMs", s.now().UnixMilli()), }, rpcMessage{ Method: "thread/status/changed", @@ -288,11 +300,10 @@ func (s *runtimeRPCState) importIssue(input string, progress runtimeProgressSink emitRuntimeProgress(progress, "No assigned Multica issue id was available; Mnemon skipped this turn.") return result } - ctx, cancel := context.WithTimeout(context.Background(), runtimeTimeout(s.Env)) - defer cancel() cli := runtimeMulticaCLI(s.Env) + multicaCtx := context.Background() emitRuntimeProgress(progress, "Loading Multica issue "+issueID+".") - issue, err := cli.GetIssue(ctx, issueID) + issue, err := cli.GetIssue(multicaCtx, issueID) if err != nil { result.Status = "failed" result.Err = fmt.Errorf("fetch Multica issue %s: %w", issueID, err) @@ -307,9 +318,9 @@ func (s *runtimeRPCState) importIssue(input string, progress runtimeProgressSink applyMulticaHubMetadata(&result, issue) result.HubBackend = firstNonEmpty(result.HubBackend, envValue(s.Env, "MNEMON_HUB_BACKEND")) emitRuntimeProgress(progress, "Loaded "+runtimeIssueLabel(issue)+"; classifying Mnemon hub metadata.") - markIssueInProgress(ctx, cli, issue.ID) + markIssueInProgress(multicaCtx, cli, issue.ID) if result.HubMetadata.IsAssignmentMailbox() { - return s.correlateAssignmentMailbox(ctx, cli, issue, &result, progress) + return s.correlateAssignmentMailbox(multicaCtx, cli, issue, &result, progress) } externalID := "" if taskID != "" { @@ -342,7 +353,7 @@ func (s *runtimeRPCState) importIssue(input string, progress runtimeProgressSink 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(ctx, issue.ID, rootSessionMetadata(result, draft, s.now())); err != nil { + 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) emitRuntimeProgress(progress, "Failed to write Mnemon root session metadata.") @@ -384,9 +395,9 @@ func (s *runtimeRPCState) importIssue(input string, progress runtimeProgressSink emitRuntimeProgress(progress, "Waking the managed local agent with [mnemon:wake].") s.wakeManagedAgent(&result) emitRuntimeProgress(progress, runtimeWakeProgress(result)) - s.writeMulticaHubArtifacts(ctx, cli, client, issue, &result) + s.writeMulticaHubArtifacts(multicaCtx, cli, client, issue, &result) emitRuntimeProgress(progress, runtimeHubWriteProgress(result)) - s.projectImportComment(ctx, cli, issue, draft.ExternalID, &result) + s.projectImportComment(multicaCtx, cli, issue, draft.ExternalID, &result) emitRuntimeProgress(progress, runtimeProjectionProgress(result)) return result } @@ -1018,28 +1029,67 @@ func writeRuntimeRPC(w io.Writer, msg rpcMessage) error { return err } -func runtimeAgentDelta(threadID, turnID, delta string) rpcMessage { +func runtimeAgentDelta(threadID, turnID, itemID, delta string) rpcMessage { return rpcMessage{ Method: "item/agentMessage/delta", Params: map[string]any{ "threadId": threadID, "turnId": turnID, - "itemId": "mnemon-runtime-message", + "itemId": itemID, "delta": delta, }, } } -func runtimeAgentMessage(text string) map[string]any { +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 string) map[string]any { + id = strings.TrimSpace(id) + if id == "" { + id = runtimeTextDigestID("msg", text) + } return map[string]any{ "type": "agentMessage", - "id": "mnemon-runtime-message", + "id": id, "text": text, "phase": "final_answer", "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(text) diff --git a/harness/cmd/mnemon-multica-runtime/main_test.go b/harness/cmd/mnemon-multica-runtime/main_test.go index 3a29cdcb..dab74c4b 100644 --- a/harness/cmd/mnemon-multica-runtime/main_test.go +++ b/harness/cmd/mnemon-multica-runtime/main_test.go @@ -189,7 +189,7 @@ esac } lines := strings.Split(strings.TrimSpace(out.String()), "\n") - var sawComplete, sawProgress, sawAnswer bool + var sawComplete, sawProgress, sawAnswer, sawUserItem, sawAgentStart, sawAgentCompletedAt bool for _, line := range lines { var msg map[string]any if err := json.Unmarshal([]byte(line), &msg); err != nil { @@ -198,6 +198,27 @@ esac if msg["method"] == "turn/completed" { sawComplete = true } + if msg["method"] == "item/started" { + params, _ := msg["params"].(map[string]any) + item, _ := params["item"].(map[string]any) + switch item["type"] { + case "userMessage": + _, sawUserItem = params["startedAtMs"] + case "agentMessage": + id, _ := item["id"].(string) + _, hasStarted := params["startedAtMs"] + if hasStarted && strings.HasPrefix(id, "msg-") { + sawAgentStart = true + } + } + } + if msg["method"] == "item/completed" { + params, _ := msg["params"].(map[string]any) + item, _ := params["item"].(map[string]any) + if item["type"] == "agentMessage" { + _, sawAgentCompletedAt = params["completedAtMs"] + } + } if msg["method"] == "item/agentMessage/delta" && strings.Contains(line, "Loading Multica issue iss-7") { sawProgress = true } @@ -205,8 +226,9 @@ esac sawAnswer = true } } - if !sawComplete || !sawProgress || !sawAnswer { - t.Fatalf("missing expected runtime response complete=%v progress=%v answer=%v:\n%s", sawComplete, sawProgress, sawAnswer, out.String()) + if !sawComplete || !sawProgress || !sawAnswer || !sawUserItem || !sawAgentStart || !sawAgentCompletedAt { + t.Fatalf("missing expected runtime response complete=%v progress=%v answer=%v user=%v agent_start=%v agent_completed_at=%v:\n%s", + sawComplete, sawProgress, sawAnswer, sawUserItem, sawAgentStart, sawAgentCompletedAt, out.String()) } } @@ -412,6 +434,7 @@ esac "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", + "issue status child-2 in_progress --output json", } { if !strings.Contains(args, want) { t.Fatalf("args missing %q:\n%s", want, args) @@ -423,7 +446,8 @@ esac createIdx := strings.Index(args, "issue create --title Mnemon assignment asg-writer") 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) { + statusIdx := strings.Index(args, "issue status child-2 in_progress") + if createIdx < 0 || metaIdx < 0 || assignIdx < 0 || statusIdx < 0 || !(createIdx < metaIdx && metaIdx < assignIdx && assignIdx < statusIdx) { t.Fatalf("assignment mailbox must be created, tagged, then assigned; args:\n%s", args) } description := mustReadRuntimeTestFile(t, createDescriptionPath) @@ -537,6 +561,25 @@ esac } } +func TestMulticaStatusForProgressIsRuleBased(t *testing.T) { + for _, tc := range []struct { + name string + item runtimeProgress + want string + }{ + {name: "progress", item: runtimeProgress{FeedbackKind: "progress"}, want: "in_progress"}, + {name: "result", item: runtimeProgress{FeedbackKind: "result"}, want: "in_review"}, + {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: "in_review"}, + {name: "unknown", item: runtimeProgress{Summary: "not enough signal"}, want: ""}, + } { + if got := multicaStatusForProgress(tc.item); got != tc.want { + t.Fatalf("%s: status = %q, want %q", tc.name, got, tc.want) + } + } +} + func TestRuntimeSkipsIngestWhenControlAddressIsUnset(t *testing.T) { tmp := t.TempDir() argsPath := filepath.Join(tmp, "multica.args") From f38fb2299993f44a79aff8bf3007d199c37d6338 Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 21:26:12 +0800 Subject: [PATCH 20/24] feat: project multica runtime activity events Emit Mnemon runtime progress as Codex-like commentary messages and commandExecution items so Multica can show incremental text, tool_use, and tool_result rows while a run is still active. Keep only the terminal summary as final_answer. Tests cover commandExecution projection, commentary/final phase separation, multiple message items, and deterministic env override behavior. Validated with go test ./... and a live Multica TEA-20 smoke run that produced 18 run messages across text/tool_use/tool_result. --- harness/cmd/mnemon-multica-runtime/main.go | 188 +++++++++++++++--- .../cmd/mnemon-multica-runtime/main_test.go | 82 +++++++- 2 files changed, 235 insertions(+), 35 deletions(-) diff --git a/harness/cmd/mnemon-multica-runtime/main.go b/harness/cmd/mnemon-multica-runtime/main.go index e3bc4730..c9b95813 100644 --- a/harness/cmd/mnemon-multica-runtime/main.go +++ b/harness/cmd/mnemon-multica-runtime/main.go @@ -50,11 +50,21 @@ type runtimeRPCState struct { CWD string ThreadID string TurnID string + ItemSeq int Env []string Now func() time.Time } -type runtimeProgressSink func(string) +type runtimeProgressSink func(runtimeProgressEvent) + +type runtimeProgressEvent struct { + Text string + Command string + CWD string + Output string + ExitCode int + DurationMs int64 +} type runtimeImportResult struct { IssueID string @@ -203,7 +213,6 @@ func (s *runtimeRPCState) handle(msg rpcMessage, emit func(rpcMessage) error) er input := extractRuntimeInput(msg.Params) nowMs := s.now().UnixMilli() userItem := runtimeUserMessage(input) - agentItemID := runtimeID("msg", s.now()) if err := emitAll( rpcMessage{ ID: msg.ID, @@ -227,42 +236,31 @@ func (s *runtimeRPCState) handle(msg rpcMessage, emit func(rpcMessage) error) er Method: "item/completed", Params: runtimeItemParams(s.ThreadID, s.TurnID, userItem, "completedAtMs", nowMs), }, - rpcMessage{ - Method: "item/started", - Params: runtimeItemParams(s.ThreadID, s.TurnID, runtimeAgentMessage(agentItemID, ""), "startedAtMs", nowMs), - }, ); err != nil { return err } - var chunks []string var progressErr error - progress := func(text string) { - text = strings.TrimSpace(text) - if text == "" || progressErr != nil { + progress := func(event runtimeProgressEvent) { + if progressErr != nil { + return + } + if strings.TrimSpace(event.Command) != "" { + progressErr = emitRuntimeCommandExecution(emit, s.ThreadID, s.TurnID, s.nextItemID("call"), s.CWD, event, s.now()) return } - chunks = append(chunks, text) - progressErr = emit(runtimeAgentDelta(s.ThreadID, s.TurnID, agentItemID, text+"\n\n")) + text := strings.TrimSpace(event.Text) + if text != "" { + progressErr = emitRuntimeAgentMessage(emit, s.ThreadID, s.TurnID, s.nextItemID("msg"), text, "commentary", s.now()) + } } finalAnswer := s.runTurn(input, progress) if progressErr != nil { return progressErr } - completedText := strings.Join(append(append([]string{}, chunks...), finalAnswer), "\n\n") + if err := emitRuntimeAgentMessage(emit, s.ThreadID, s.TurnID, s.nextItemID("msg"), finalAnswer, "final_answer", s.now()); err != nil { + return err + } return emitAll( - rpcMessage{ - Method: "item/agentMessage/delta", - Params: map[string]any{ - "threadId": s.ThreadID, - "turnId": s.TurnID, - "itemId": agentItemID, - "delta": finalAnswer, - }, - }, - rpcMessage{ - Method: "item/completed", - Params: runtimeItemParams(s.ThreadID, s.TurnID, runtimeAgentMessage(agentItemID, completedText), "completedAtMs", s.now().UnixMilli()), - }, rpcMessage{ Method: "thread/status/changed", Params: map[string]any{"threadId": s.ThreadID, "status": map[string]any{"type": "idle"}}, @@ -280,6 +278,14 @@ func (s *runtimeRPCState) handle(msg rpcMessage, emit func(rpcMessage) error) er } } +func (s *runtimeRPCState) nextItemID(prefix string) string { + s.ItemSeq++ + if strings.TrimSpace(prefix) == "" { + prefix = "item" + } + return fmt.Sprintf("%s-%d-%d", prefix, s.now().UTC().UnixNano(), s.ItemSeq) +} + func (s *runtimeRPCState) runTurn(input string, progress runtimeProgressSink) string { result := s.importIssue(input, progress) return formatRuntimeFinalAnswer(result) @@ -307,9 +313,11 @@ func (s *runtimeRPCState) importIssue(input string, progress runtimeProgressSink if err != nil { result.Status = "failed" result.Err = fmt.Errorf("fetch Multica issue %s: %w", issueID, err) + emitRuntimeCommand(progress, "multica issue get "+issueID, result.Err.Error(), 1) emitRuntimeProgress(progress, "Failed to load Multica issue "+issueID+".") return result } + emitRuntimeCommand(progress, "multica issue get "+issueID, "Loaded "+runtimeIssueLabel(issue)+".", 0) result.IssueID = issue.ID result.Identifier = issue.Identifier result.Title = issue.Title @@ -356,9 +364,11 @@ func (s *runtimeRPCState) importIssue(input string, progress runtimeProgressSink 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 } + 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")) @@ -386,18 +396,23 @@ func (s *runtimeRPCState) importIssue(input string, progress runtimeProgressSink if err != nil { result.Status = "failed" result.Err = fmt.Errorf("ingest Mnemon observation: %w", err) + emitRuntimeCommand(progress, "mnemond ingest observe --principal "+result.Principal, result.Err.Error(), 1) emitRuntimeProgress(progress, "Mnemon rejected or failed the issue observation.") return result } result.Status = "recorded" result.Receipt = &rec + emitRuntimeCommand(progress, "mnemond ingest observe --principal "+result.Principal, fmt.Sprintf("recorded seq=%d duplicate=%v ticked=%v", rec.Seq, rec.Dup, rec.Ticked), 0) emitRuntimeProgress(progress, fmt.Sprintf("Mnemon recorded the issue observation at seq=%d.", rec.Seq)) emitRuntimeProgress(progress, "Waking the managed local agent with [mnemon:wake].") s.wakeManagedAgent(&result) + emitRuntimeCommand(progress, "mnemond managed wake --principal "+result.Principal+" [mnemon:wake]", runtimeWakeProgress(result), runtimeExitCode(result.WakeErr)) emitRuntimeProgress(progress, runtimeWakeProgress(result)) s.writeMulticaHubArtifacts(multicaCtx, cli, client, issue, &result) + 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)) return result } @@ -411,6 +426,7 @@ func (s *runtimeRPCState) correlateAssignmentMailbox(ctx context.Context, cli dr 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")) if addr == "" { @@ -420,9 +436,11 @@ func (s *runtimeRPCState) correlateAssignmentMailbox(ctx context.Context, cli dr } else { emitRuntimeProgress(progress, "Waking assigned local agent with [mnemon:wake].") s.wakeManagedAgent(result) + emitRuntimeCommand(progress, "mnemond managed wake --principal "+result.Principal+" [mnemon:wake]", runtimeWakeProgress(*result), runtimeExitCode(result.WakeErr)) emitRuntimeProgress(progress, runtimeWakeProgress(*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)) return *result } @@ -1029,6 +1047,98 @@ func writeRuntimeRPC(w io.Writer, msg rpcMessage) error { return err } +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), + }, + } + for _, message := range messages { + if err := emit(message); err != nil { + return err + } + } + return nil +} + func runtimeAgentDelta(threadID, turnID, itemID, delta string) rpcMessage { return rpcMessage{ Method: "item/agentMessage/delta", @@ -1066,16 +1176,20 @@ func runtimeUserMessage(text string) map[string]any { } } -func runtimeAgentMessage(id, text string) map[string]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": "final_answer", + "phase": phase, "memoryCitation": nil, } } @@ -1092,8 +1206,21 @@ func runtimeTextDigestID(prefix, text string) string { func emitRuntimeProgress(progress runtimeProgressSink, text string) { if progress != nil { - progress(text) + progress(runtimeProgressEvent{Text: text}) + } +} + +func emitRuntimeCommand(progress runtimeProgressSink, command, output string, exitCode int) { + if progress != nil { + progress(runtimeProgressEvent{Command: command, Output: output, ExitCode: exitCode}) + } +} + +func runtimeExitCode(err error) int { + if err != nil { + return 1 } + return 0 } func displayRuntimePrincipal(principal string) string { @@ -1246,7 +1373,8 @@ func stringParam(params map[string]any, key string) string { func envValue(env []string, key string) string { prefix := key + "=" - for _, item := range env { + for i := len(env) - 1; i >= 0; i-- { + item := env[i] if strings.HasPrefix(item, prefix) { return strings.TrimSpace(strings.TrimPrefix(item, prefix)) } diff --git a/harness/cmd/mnemon-multica-runtime/main_test.go b/harness/cmd/mnemon-multica-runtime/main_test.go index dab74c4b..819071f0 100644 --- a/harness/cmd/mnemon-multica-runtime/main_test.go +++ b/harness/cmd/mnemon-multica-runtime/main_test.go @@ -19,6 +19,31 @@ import ( pview "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation/view" ) +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 runtimeTestEnv(values ...string) []string { + env := make([]string, 0, len(os.Environ())+len(values)) + for _, item := range os.Environ() { + key, _, ok := strings.Cut(item, "=") + if !ok { + continue + } + if strings.HasPrefix(key, "MNEMON_") || strings.HasPrefix(key, "MULTICA_") { + continue + } + env = append(env, item) + } + return append(env, values...) +} + func TestRuntimeImportsAssignedIssueIntoMnemon(t *testing.T) { tmp := t.TempDir() argsPath := filepath.Join(tmp, "multica.args") @@ -120,7 +145,7 @@ esac var out bytes.Buffer err := runRuntime(runtimeConfig{ Args: []string{"app-server", "--listen", "stdio://"}, - Env: append(os.Environ(), + Env: runtimeTestEnv( "MNEMON_MULTICA_BIN="+bin, "MNEMON_MULTICA_REGISTRY="+registryPath, "MNEMON_CONTROL_ADDR="+srv.URL, @@ -190,6 +215,11 @@ esac lines := strings.Split(strings.TrimSpace(out.String()), "\n") var sawComplete, sawProgress, sawAnswer, sawUserItem, sawAgentStart, sawAgentCompletedAt bool + var sawCommandStart, sawCommandComplete bool + var sawCommentaryProgress, sawFinalPhaseAnswer bool + agentCompleted := map[string]string{} + progressItemIDs := map[string]bool{} + answerItemID := "" for _, line := range lines { var msg map[string]any if err := json.Unmarshal([]byte(line), &msg); err != nil { @@ -210,6 +240,11 @@ esac if hasStarted && strings.HasPrefix(id, "msg-") { sawAgentStart = true } + case "commandExecution": + command, _ := item["command"].(string) + if strings.Contains(command, "multica issue get iss-7") && item["status"] == "inProgress" { + sawCommandStart = true + } } } if msg["method"] == "item/completed" { @@ -217,19 +252,56 @@ esac item, _ := params["item"].(map[string]any) if item["type"] == "agentMessage" { _, sawAgentCompletedAt = params["completedAtMs"] + id, _ := item["id"].(string) + text, _ := item["text"].(string) + if id != "" { + agentCompleted[id] = text + } + phase, _ := item["phase"].(string) + 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" { + sawFinalPhaseAnswer = true + } + } + if item["type"] == "commandExecution" { + command, _ := item["command"].(string) + output, _ := item["aggregatedOutput"].(string) + if strings.Contains(command, "multica issue get iss-7") && item["status"] == "completed" && strings.Contains(output, "Loaded TEA-7") { + sawCommandComplete = true + } } } if msg["method"] == "item/agentMessage/delta" && strings.Contains(line, "Loading Multica issue iss-7") { sawProgress = true + params, _ := msg["params"].(map[string]any) + if itemID, _ := params["itemId"].(string); itemID != "" { + 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") { sawAnswer = true + params, _ := msg["params"].(map[string]any) + answerItemID, _ = params["itemId"].(string) } } if !sawComplete || !sawProgress || !sawAnswer || !sawUserItem || !sawAgentStart || !sawAgentCompletedAt { t.Fatalf("missing expected runtime response complete=%v progress=%v answer=%v user=%v agent_start=%v agent_completed_at=%v:\n%s", sawComplete, sawProgress, sawAnswer, sawUserItem, sawAgentStart, sawAgentCompletedAt, out.String()) } + if !sawCommandStart || !sawCommandComplete { + t.Fatalf("missing commandExecution projection start=%v complete=%v:\n%s", sawCommandStart, sawCommandComplete, out.String()) + } + if !sawCommentaryProgress || !sawFinalPhaseAnswer { + t.Fatalf("unexpected agent message phases commentary=%v final=%v:\n%s", sawCommentaryProgress, sawFinalPhaseAnswer, out.String()) + } + if len(agentCompleted) < 4 { + t.Fatalf("runtime progress should complete multiple agent messages, got %d:\n%s", len(agentCompleted), out.String()) + } + if answerItemID == "" || progressItemIDs[answerItemID] { + t.Fatalf("final answer should be a separate agent message item from progress, answer=%q progress=%v", answerItemID, progressItemIDs) + } } func TestRuntimeInitializesMulticaRootSessionMetadata(t *testing.T) { @@ -269,7 +341,7 @@ esac var out bytes.Buffer err := runRuntime(runtimeConfig{ Args: []string{"app-server", "--listen", "stdio://"}, - Env: append(os.Environ(), + Env: runtimeTestEnv( "MNEMON_MULTICA_BIN="+bin, "MNEMON_HUB_BACKEND=multica", "MNEMON_MULTICA_HUB_WRITE=off", @@ -402,7 +474,7 @@ esac var out bytes.Buffer err := runRuntime(runtimeConfig{ Args: []string{"app-server", "--listen", "stdio://"}, - Env: append(os.Environ(), + Env: runtimeTestEnv( "MNEMON_MULTICA_BIN="+bin, "MNEMON_HUB_BACKEND=multica", "MNEMON_CONTROL_ADDR="+srv.URL, @@ -522,7 +594,7 @@ esac var out bytes.Buffer err := runRuntime(runtimeConfig{ Args: []string{"app-server", "--listen", "stdio://"}, - Env: append(os.Environ(), + Env: runtimeTestEnv( "MNEMON_MULTICA_BIN="+bin, "MNEMON_HUB_BACKEND=multica", "MNEMON_CONTROL_ADDR="+srv.URL, @@ -595,7 +667,7 @@ printf '{"id":"iss-8","identifier":"TEA-8","title":"No local mnemond","descripti var out bytes.Buffer err := runRuntime(runtimeConfig{ Args: []string{"app-server", "--listen", "stdio://"}, - Env: append(os.Environ(), + Env: runtimeTestEnv( "MNEMON_MULTICA_BIN="+bin, "MULTICA_ARGS_PATH="+argsPath, "MULTICA_TASK_ID=task-8", From 832ace3413db321287f624006fcae787ef0fd71a Mon Sep 17 00:00:00 2001 From: Grivn Date: Sun, 28 Jun 2026 23:03:30 +0800 Subject: [PATCH 21/24] feat: improve multica hub projection flow Project Multica hub artifacts from assignment mailbox turns, dispatch child mailboxes during root managed wakes, and reduce child assignment latency by separating dispatch metadata from supplemental metadata. Expand Multica acceptance visibility around run messages, comments, and final statuses. Validation: go test -count=1 ./...; live TEA-36 confirmed child runs started before the root run completed. --- .../acceptance_multica_runtime.go | 255 +++++++++++-- .../acceptance_multica_runtime_test.go | 16 +- .../cmd/mnemon-multica-runtime/hub_writer.go | 346 +++++++++++++++--- harness/cmd/mnemon-multica-runtime/main.go | 128 ++++++- .../cmd/mnemon-multica-runtime/main_test.go | 98 ++++- .../assets/guides/mnemon-harness-guide.md | 11 + harness/internal/driver/multica.go | 125 ++++++- harness/internal/driver/multica_test.go | 3 - .../mnemond/presentation/render_test.go | 27 ++ .../presentation/teamwork_presenter.go | 22 +- 10 files changed, 927 insertions(+), 104 deletions(-) diff --git a/harness/cmd/mnemon-acceptance/acceptance_multica_runtime.go b/harness/cmd/mnemon-acceptance/acceptance_multica_runtime.go index 4ff6dbd9..87a753ab 100644 --- a/harness/cmd/mnemon-acceptance/acceptance_multica_runtime.go +++ b/harness/cmd/mnemon-acceptance/acceptance_multica_runtime.go @@ -112,27 +112,33 @@ type multicaRuntimeProdSimOptions struct { } type multicaRuntimeProdSimReport struct { - SchemaVersion int `json:"schema_version"` - Status string `json:"status"` - StartedAt string `json:"started_at"` - FinishedAt string `json:"finished_at"` - RunRoot string `json:"run_root"` - ReportPath string `json:"report_path"` - WorkspaceID string `json:"workspace_id"` - RegistryPath string `json:"registry_path"` - Assignee driver.MulticaParticipantRecord `json:"assignee"` - Participants []driver.MulticaParticipantRecord `json:"participants,omitempty"` - Issue driver.MulticaIssue `json:"issue"` - RootMetadata map[string]string `json:"root_metadata,omitempty"` - ChildIssues []driver.MulticaIssue `json:"child_issues,omitempty"` - ChildMetadata map[string]map[string]string `json:"child_metadata,omitempty"` - Runs []driver.MulticaIssueRun `json:"runs,omitempty"` - RunMessages []driver.MulticaRunMessage `json:"run_messages,omitempty"` - ChildRuns map[string][]driver.MulticaIssueRun `json:"child_runs,omitempty"` - ChildMessages map[string][]driver.MulticaRunMessage `json:"child_messages,omitempty"` - ActiveAgents []string `json:"active_agents,omitempty"` - Assertions []multicaRuntimeProdSimAssertion `json:"assertions"` - Errors []string `json:"errors,omitempty"` + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` + StartedAt string `json:"started_at"` + FinishedAt string `json:"finished_at"` + RunRoot string `json:"run_root"` + ReportPath string `json:"report_path"` + WorkspaceID string `json:"workspace_id"` + RegistryPath string `json:"registry_path"` + Assignee driver.MulticaParticipantRecord `json:"assignee"` + Participants []driver.MulticaParticipantRecord `json:"participants,omitempty"` + Issue driver.MulticaIssue `json:"issue"` + RootMetadata map[string]string `json:"root_metadata,omitempty"` + ChildIssues []driver.MulticaIssue `json:"child_issues,omitempty"` + ChildMetadata map[string]map[string]string `json:"child_metadata,omitempty"` + RootComments []driver.MulticaComment `json:"root_comments,omitempty"` + ChildComments map[string][]driver.MulticaComment `json:"child_comments,omitempty"` + Runs []driver.MulticaIssueRun `json:"runs,omitempty"` + RunMessages []driver.MulticaRunMessage `json:"run_messages,omitempty"` + MessageTypes map[string]int `json:"message_types,omitempty"` + ChildRuns map[string][]driver.MulticaIssueRun `json:"child_runs,omitempty"` + ChildMessages map[string][]driver.MulticaRunMessage `json:"child_messages,omitempty"` + ChildMessageTypes map[string]int `json:"child_message_types,omitempty"` + ActiveAgents []string `json:"active_agents,omitempty"` + FinalRoot driver.MulticaIssue `json:"final_root,omitempty"` + FinalChildren []driver.MulticaIssue `json:"final_children,omitempty"` + Assertions []multicaRuntimeProdSimAssertion `json:"assertions"` + Errors []string `json:"errors,omitempty"` } type multicaRuntimeProdSimAssertion struct { @@ -219,7 +225,11 @@ func runMulticaRuntimeProdSimAcceptance(ctx context.Context, opts multicaRuntime } description := strings.TrimSpace(opts.IssueDescription) if description == "" { - description = "Validate that a Multica issue assigned to a Mnemon participant enters the Mnemon runtime path and reports product-visible progress." + 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.`) } issue, err := cli.CreateIssue(ctx, driver.MulticaCreateIssueRequest{ Title: title, @@ -236,6 +246,7 @@ func runMulticaRuntimeProdSimAcceptance(ctx context.Context, opts multicaRuntime runs, messages, err := waitMulticaRuntimeEvidence(ctx, cli, issue.ID, opts.Wait, 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) @@ -250,6 +261,7 @@ func runMulticaRuntimeProdSimAcceptance(ctx context.Context, opts multicaRuntime 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)) if err := collectMulticaHubFlowEvidence(ctx, cli, opts, &report); err != nil { return finishMulticaRuntimeProdSimReport(report, err) } @@ -299,6 +311,12 @@ func collectMulticaHubFlowEvidence(ctx context.Context, cli driver.MulticaCLI, o return err } addMulticaProdSimAssertion(report, "assignment child issue mailboxes created", len(children) > 0, fmt.Sprintf("children=%d", len(children))) + rawChildren, rawChildMeta, rawErr := listMulticaChildrenWithMetadata(ctx, cli, report.Issue.ID) + if rawErr != nil { + addMulticaProdSimAssertion(report, "root children are session-scoped assignment mailboxes", false, rawErr.Error()) + 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] @@ -314,17 +332,29 @@ func collectMulticaHubFlowEvidence(ctx context.Context, cli driver.MulticaCLI, o childRuns, childMessages, activeAgents, err := waitMulticaChildRunEvidence(ctx, cli, report.Issue.ID, report.Runs, children, opts.Wait, opts.Poll, opts.MinActiveAgents) report.ChildRuns = childRuns report.ChildMessages = childMessages + report.ChildMessageTypes = multicaChildRunMessageTypeCounts(childMessages) report.ActiveAgents = activeAgents if err != nil { addMulticaProdSimAssertion(report, "hub-flow activates multiple Multica agents", false, err.Error()) 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)) 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) } + finalRoot, finalChildren, rootComments, childComments, err := waitMulticaHubProjectionCompletion(ctx, cli, report.Issue.ID, children, opts.Wait, opts.Poll) + report.FinalRoot = finalRoot + report.FinalChildren = finalChildren + report.RootComments = rootComments + report.ChildComments = childComments + if err != nil { + addMulticaProdSimAssertion(report, "hub-flow projects feedback comments and completion statuses", false, err.Error()) + 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))) return nil } @@ -382,12 +412,27 @@ func waitMulticaAssignmentChildren(ctx context.Context, cli driver.MulticaCLI, r } func listMulticaAssignmentChildren(ctx context.Context, cli driver.MulticaCLI, rootIssueID string) ([]driver.MulticaIssue, map[string]map[string]string, error) { + rawChildren, metaByIssue, err := listMulticaChildrenWithMetadata(ctx, cli, rootIssueID) + if err != nil { + return nil, nil, err + } + var children []driver.MulticaIssue + for _, child := range rawChildren { + meta := metaByIssue[child.ID] + if meta[driver.MulticaMetadataKind] != driver.MulticaHubKindAssignmentMailbox { + continue + } + children = append(children, child) + } + return children, metaByIssue, nil +} + +func listMulticaChildrenWithMetadata(ctx context.Context, cli driver.MulticaCLI, rootIssueID string) ([]driver.MulticaIssue, map[string]map[string]string, error) { rawChildren, err := cli.ListIssueChildren(ctx, rootIssueID) if err != nil { return nil, nil, err } metaByIssue := map[string]map[string]string{} - var children []driver.MulticaIssue for _, child := range rawChildren { meta := driver.NormalizeMulticaMetadata(child.Metadata) if len(meta) == 0 || meta[driver.MulticaMetadataKind] == "" { @@ -397,13 +442,9 @@ func listMulticaAssignmentChildren(ctx context.Context, cli driver.MulticaCLI, r } meta = listed } - if meta[driver.MulticaMetadataKind] != driver.MulticaHubKindAssignmentMailbox { - continue - } - children = append(children, child) metaByIssue[child.ID] = meta } - return children, metaByIssue, nil + return rawChildren, metaByIssue, nil } func waitMulticaChildRunEvidence(ctx context.Context, cli driver.MulticaCLI, rootIssueID string, rootRuns []driver.MulticaIssueRun, children []driver.MulticaIssue, wait, poll time.Duration, minActive int) (map[string][]driver.MulticaIssueRun, map[string][]driver.MulticaRunMessage, []string, error) { @@ -487,6 +528,164 @@ func combinedMulticaChildMessages(messages map[string][]driver.MulticaRunMessage return strings.Join(parts, "\n") } +func multicaRunMessageTypeCounts(messages []driver.MulticaRunMessage) map[string]int { + out := map[string]int{} + for _, message := range messages { + typ := strings.TrimSpace(message.Type) + if typ == "" { + typ = "unknown" + } + out[typ]++ + } + return out +} + +func multicaChildRunMessageTypeCounts(messages map[string][]driver.MulticaRunMessage) map[string]int { + out := map[string]int{} + for _, items := range messages { + for _, message := range items { + typ := strings.TrimSpace(message.Type) + if typ == "" { + typ = "unknown" + } + out[typ]++ + } + } + return out +} + +func multicaMessagesExposeRuntimeActivity(types map[string]int) bool { + if len(types) == 0 { + return false + } + if types["tool_use"] > 0 && types["tool_result"] > 0 { + return true + } + return types["commandExecution"] > 0 || types["exec_command"] > 0 +} + +func multicaRawChildrenMatchSessionAssignments(children []driver.MulticaIssue, meta map[string]map[string]string, sessionID string) bool { + if len(children) == 0 { + return false + } + for _, child := range children { + childMeta := meta[child.ID] + if childMeta[driver.MulticaMetadataKind] != driver.MulticaHubKindAssignmentMailbox { + return false + } + if strings.TrimSpace(sessionID) != "" && childMeta[driver.MulticaMetadataSessionID] != sessionID { + return false + } + } + return true +} + +func waitMulticaHubProjectionCompletion(ctx context.Context, cli driver.MulticaCLI, rootIssueID string, children []driver.MulticaIssue, wait, poll time.Duration) (driver.MulticaIssue, []driver.MulticaIssue, []driver.MulticaComment, map[string][]driver.MulticaComment, error) { + deadline := time.Now().Add(wait) + var lastRoot driver.MulticaIssue + var lastChildren []driver.MulticaIssue + var lastRootComments []driver.MulticaComment + var lastChildComments map[string][]driver.MulticaComment + for { + root, err := cli.GetIssue(ctx, rootIssueID) + if err != nil { + return lastRoot, lastChildren, lastRootComments, lastChildComments, err + } + rootComments, err := cli.ListIssueComments(ctx, rootIssueID) + if err != nil { + return root, lastChildren, lastRootComments, lastChildComments, err + } + finalChildren := make([]driver.MulticaIssue, 0, len(children)) + childComments := map[string][]driver.MulticaComment{} + for _, child := range children { + latest, err := cli.GetIssue(ctx, child.ID) + if err != nil { + return root, finalChildren, rootComments, childComments, err + } + finalChildren = append(finalChildren, latest) + comments, err := cli.ListIssueComments(ctx, child.ID) + if err != nil { + return root, finalChildren, rootComments, childComments, err + } + childComments[child.ID] = comments + } + lastRoot = root + lastChildren = finalChildren + lastRootComments = rootComments + lastChildComments = childComments + if multicaHubProjectionComplete(root, finalChildren, childComments) { + return root, finalChildren, rootComments, childComments, nil + } + if wait <= 0 || time.Now().After(deadline) { + return root, finalChildren, rootComments, childComments, fmt.Errorf("timed out waiting for Multica feedback projection completion root=%s children=%v comments=%d", root.Status, multicaIssueStatuses(finalChildren), multicaCommentCount(childComments)) + } + select { + case <-ctx.Done(): + return root, finalChildren, rootComments, childComments, ctx.Err() + case <-time.After(poll): + } + } +} + +func multicaHubProjectionComplete(root driver.MulticaIssue, children []driver.MulticaIssue, childComments map[string][]driver.MulticaComment) bool { + if !multicaIssueStatusDone(root.Status) || len(children) == 0 { + return false + } + for _, child := range children { + if !multicaIssueStatusDone(child.Status) { + return false + } + if !multicaCommentsContainFeedbackMarker(childComments[child.ID]) { + return false + } + } + return true +} + +func multicaCommentsContainFeedbackMarker(comments []driver.MulticaComment) bool { + for _, comment := range comments { + content := strings.ToLower(comment.Content) + if strings.Contains(content, "mnemon update: assignment feedback") && strings.Contains(content, "mnemon:event=") { + return true + } + } + 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 { + out[firstNonEmptyString(issue.Identifier, issue.ID)] = issue.Status + } + return out +} + +func multicaCommentCount(comments map[string][]driver.MulticaComment) int { + n := 0 + for _, items := range comments { + n += len(items) + } + return n +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + func multicaActiveAgentIDs(runs []driver.MulticaIssueRun) map[string]bool { out := map[string]bool{} for _, run := range runs { diff --git a/harness/cmd/mnemon-acceptance/acceptance_multica_runtime_test.go b/harness/cmd/mnemon-acceptance/acceptance_multica_runtime_test.go index 475ea6b3..6cd7a8b0 100644 --- a/harness/cmd/mnemon-acceptance/acceptance_multica_runtime_test.go +++ b/harness/cmd/mnemon-acceptance/acceptance_multica_runtime_test.go @@ -123,14 +123,20 @@ printf '%s\n' "$*" >> "$MULTICA_ARGS_PATH" cat >> "$MULTICA_STDIN_PATH" case "$*" in *"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 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","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","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","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 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":"assistant","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"}]\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 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":"assistant","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"}]\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 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":"assistant","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"}]\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' ;; *) printf '{}\n' ;; esac ` @@ -159,7 +165,7 @@ esac if err != nil { t.Fatalf("acceptance: %v report=%+v", err, report) } - if report.Status != "ok" || len(report.Participants) != 5 || len(report.ChildIssues) != 2 || len(report.ActiveAgents) != 3 { + if report.Status != "ok" || len(report.Participants) != 5 || len(report.ChildIssues) != 2 || len(report.ActiveAgents) != 3 || report.FinalRoot.Status != "done" { t.Fatalf("hub report mismatch: %+v", report) } if report.RootMetadata[driver.MulticaMetadataKind] != driver.MulticaHubKindSession { diff --git a/harness/cmd/mnemon-multica-runtime/hub_writer.go b/harness/cmd/mnemon-multica-runtime/hub_writer.go index 7007ed46..847e05e7 100644 --- a/harness/cmd/mnemon-multica-runtime/hub_writer.go +++ b/harness/cmd/mnemon-multica-runtime/hub_writer.go @@ -2,8 +2,11 @@ package main import ( "context" + "encoding/json" "fmt" + "strconv" "strings" + "sync" "time" "github.com/mnemon-dev/mnemon/harness/internal/contract" @@ -21,7 +24,8 @@ func (s *runtimeRPCState) writeMulticaHubArtifacts(ctx context.Context, cli driv result.HubWriteStatus = "skipped" return } - if !strings.EqualFold(result.HubBackend, driver.MulticaHubBackend) || result.HubKind != driver.MulticaHubKindSession { + if !strings.EqualFold(result.HubBackend, driver.MulticaHubBackend) || + (result.HubKind != driver.MulticaHubKindSession && result.HubKind != driver.MulticaHubKindAssignmentMailbox) { result.HubWriteStatus = "skipped" return } @@ -42,22 +46,24 @@ func (s *runtimeRPCState) writeMulticaHubArtifacts(ctx context.Context, cli driv result.HubWriteErr = fmt.Errorf("pull teamwork view for Multica hub write: %w", err) return } - reg, ok, err := runtimeMulticaRegistry(s.Env, s.CWD) - if err != nil { - result.HubWriteStatus = "failed" - result.HubWriteErr = err - return - } - if !ok { - result.HubWriteStatus = "failed" - result.HubWriteErr = fmt.Errorf("Multica registry is required for assignment routing") - return - } - ledger := driver.NewFileMulticaHubLedger(driver.MulticaHubLedgerPath(s.CWD, envValue(s.Env, "MNEMON_MULTICA_HUB_LEDGER"))) - if err := s.writeAssignmentMailboxes(ctx, cli, ledger, reg, proj, rootIssue, result); err != nil { - result.HubWriteStatus = "failed" - result.HubWriteErr = err - return + ledger := driver.NewFileMulticaHubLedger(runtimeMulticaHubLedgerPath(s.Env, s.CWD)) + if result.HubKind == driver.MulticaHubKindSession { + reg, ok, err := runtimeMulticaRegistry(s.Env, s.CWD) + if err != nil { + result.HubWriteStatus = "failed" + result.HubWriteErr = err + return + } + if !ok { + result.HubWriteStatus = "failed" + result.HubWriteErr = fmt.Errorf("Multica registry is required for assignment routing") + return + } + if err := s.writeAssignmentMailboxes(ctx, cli, ledger, reg, proj, rootIssue, result); err != nil { + result.HubWriteStatus = "failed" + result.HubWriteErr = err + return + } } if err := s.writeProgressComments(ctx, cli, ledger, proj, result); err != nil { result.HubWriteStatus = "failed" @@ -77,11 +83,19 @@ func (s *runtimeRPCState) writeMulticaHubArtifacts(ctx context.Context, cli driv } func (s *runtimeRPCState) writeAssignmentMailboxes(ctx context.Context, cli driver.MulticaCLI, ledger *driver.FileMulticaHubLedger, reg driver.MulticaRegistry, proj pview.View, rootIssue driver.MulticaIssue, result *runtimeImportResult) error { + existingChildren, err := cli.ListIssueChildren(ctx, result.RootIssueID) + if err != nil { + return err + } + var projections []runtimeAssignmentProjection for _, assignment := range hubViewItems(proj, "assignment") { item := runtimeAssignmentItem(assignment) if item.ID == "" || item.Assignee == "" { continue } + if !hubItemAfterRootIngest(item.IngestSeq, result) { + continue + } participant, ok := multicaParticipantForPrincipal(reg, item.Assignee) if !ok || strings.TrimSpace(participant.AgentID) == "" { return fmt.Errorf("no Multica agent mapping for assignment assignee %q", item.Assignee) @@ -111,7 +125,7 @@ func (s *runtimeRPCState) writeAssignmentMailboxes(ctx context.Context, cli driv } else if ok { continue } - if existing, ok, err := findExistingMulticaAssignmentIssue(ctx, cli, result.RootIssueID, source); err != nil { + if existing, ok, err := findExistingMulticaAssignmentIssueInChildren(ctx, cli, existingChildren, source); err != nil { return err } else if ok { if err := ledger.Record(driver.MulticaHubLedgerRecord{ @@ -127,16 +141,6 @@ func (s *runtimeRPCState) writeAssignmentMailboxes(ctx context.Context, cli driv } continue } - child, err := cli.CreateIssue(ctx, driver.MulticaCreateIssueRequest{ - Title: assignmentMailboxTitle(item), - Description: assignmentMailboxDescription(item, result), - ParentID: result.RootIssueID, - Status: "todo", - Priority: "medium", - }) - if err != nil { - return err - } meta := driver.MulticaHubMetadata{ SchemaVersion: "1", HubBackend: driver.MulticaHubBackend, @@ -155,27 +159,122 @@ func (s *runtimeRPCState) writeAssignmentMailboxes(ctx context.Context, cli driv MulticaAgentID: participant.AgentID, ProjectedAt: s.now().UTC().Format(time.RFC3339), } - if err := cli.SetIssueMetadataMap(ctx, child.ID, meta.Map()); err != nil { - return err - } - if _, err := cli.AssignIssue(ctx, child.ID, participant.AgentID); err != nil { - return err + projections = append(projections, runtimeAssignmentProjection{ + Item: item, + Participant: participant, + Source: source, + Metadata: meta, + Result: result, + }) + } + created, err := s.projectAssignmentMailboxes(ctx, cli, ledger, projections) + result.HubChildIssues += created + if err != nil { + return err + } + return nil +} + +type runtimeAssignmentProjection struct { + Item runtimeAssignment + Participant driver.MulticaParticipantRecord + Source driver.MulticaHubLedgerSource + Metadata driver.MulticaHubMetadata + Result *runtimeImportResult +} + +func (s *runtimeRPCState) projectAssignmentMailboxes(ctx context.Context, cli driver.MulticaCLI, ledger *driver.FileMulticaHubLedger, projections []runtimeAssignmentProjection) (int, error) { + 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 } - markIssueInProgress(ctx, cli, child.ID) - if err := ledger.Record(driver.MulticaHubLedgerRecord{ - Kind: driver.MulticaHubKindAssignmentMailbox, - Source: source, - Target: driver.MulticaHubLedgerTarget{ - RootIssueID: result.RootIssueID, - ChildIssueID: child.ID, - Status: "created", - }, - }); err != nil { - return err + mu.Lock() + defer mu.Unlock() + if firstErr == nil { + firstErr = err + } + } + hasErr := func() bool { + mu.Lock() + defer mu.Unlock() + return firstErr != nil + } + 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) + } + } + }() + } + for _, projection := range projections { + if hasErr() { + break } - result.HubChildIssues++ + jobs <- projection } - return nil + close(jobs) + wg.Wait() + return created, firstErr } func (s *runtimeRPCState) writeProgressComments(ctx context.Context, cli driver.MulticaCLI, ledger *driver.FileMulticaHubLedger, proj pview.View, result *runtimeImportResult) error { @@ -184,6 +283,9 @@ func (s *runtimeRPCState) writeProgressComments(ctx context.Context, cli driver. if item.ID == "" || item.AssignmentRef == "" { continue } + if !hubItemAfterRootIngest(item.IngestSeq, result) { + continue + } source := driver.MulticaHubLedgerSource{ SessionID: result.SessionID, CorrelationID: result.CorrelationID, @@ -212,7 +314,13 @@ func (s *runtimeRPCState) writeProgressComments(ctx context.Context, cli driver. if status := multicaStatusForProgress(item); status != "" { _, _ = cli.SetIssueStatus(ctx, child, status) } - _, _ = cli.SetIssueStatus(ctx, result.RootIssueID, "in_review") + rootStatus := "in_review" + if multicaProgressCompletesAssignment(item) { + if done, _ := allMulticaAssignmentChildrenDone(ctx, cli, result.RootIssueID, result.SessionID, child); done { + rootStatus = "done" + } + } + _, _ = cli.SetIssueStatus(ctx, result.RootIssueID, rootStatus) if err := ledger.Record(driver.MulticaHubLedgerRecord{ Kind: driver.MulticaHubKindFeedbackCarrier, Source: source, @@ -235,7 +343,7 @@ func multicaStatusForProgress(item runtimeProgress) string { case "blocker": return "blocked" case "result": - return "in_review" + return "done" case "progress": return "in_progress" } @@ -243,14 +351,22 @@ func multicaStatusForProgress(item runtimeProgress) string { return "blocked" } if strings.TrimSpace(item.Result) != "" { - return "in_review" + 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 @@ -266,6 +382,7 @@ type runtimeAssignment struct { type runtimeProgress struct { ID string EventID string + IngestSeq int64 Actor string AssignmentRef string Scope string @@ -285,6 +402,7 @@ func runtimeAssignmentItem(item map[string]any) runtimeAssignment { 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"), @@ -303,6 +421,7 @@ func runtimeProgressItem(item map[string]any) runtimeProgress { 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"), @@ -333,6 +452,9 @@ func runtimeMulticaRegistry(env []string, cwd string) (driver.MulticaRegistry, b 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, "")) } @@ -345,6 +467,52 @@ func runtimeMulticaRegistry(env []string, cwd string) (driver.MulticaRegistry, b 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) { @@ -408,6 +576,50 @@ func hubItemString(item map[string]any, key string) string { return "" } +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 + } + } + } + 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 + } +} + +func hubItemAfterRootIngest(ingestSeq int64, result *runtimeImportResult) bool { + if result == nil || result.Receipt == nil || result.Receipt.Seq <= 0 || ingestSeq <= 0 { + return true + } + return ingestSeq > result.Receipt.Seq +} + func hubItemStringList(item map[string]any, key string) []string { if out := hubStringList(item[key]); len(out) > 0 { return out @@ -515,6 +727,10 @@ func findExistingMulticaAssignmentIssue(ctx context.Context, cli driver.MulticaC if err != nil { return driver.MulticaIssue{}, false, err } + return findExistingMulticaAssignmentIssueInChildren(ctx, cli, children, source) +} + +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() { @@ -554,6 +770,38 @@ func findAssignmentTargetFromLedger(ledger *driver.FileMulticaHubLedger, session return "", false, nil } +func allMulticaAssignmentChildrenDone(ctx context.Context, cli driver.MulticaCLI, rootIssueID, sessionID, justCompletedChildID string) (bool, error) { + children, err := cli.ListIssueChildren(ctx, rootIssueID) + if err != nil { + return false, err + } + 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)) + } + if !meta.IsAssignmentMailbox() || meta.SessionID != sessionID { + continue + } + seen = true + if child.ID == justCompletedChildID { + continue + } + switch strings.ToLower(strings.TrimSpace(child.Status)) { + case "done", "completed", "complete": + continue + default: + 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 { diff --git a/harness/cmd/mnemon-multica-runtime/main.go b/harness/cmd/mnemon-multica-runtime/main.go index c9b95813..c1e3b245 100644 --- a/harness/cmd/mnemon-multica-runtime/main.go +++ b/harness/cmd/mnemon-multica-runtime/main.go @@ -405,10 +405,11 @@ func (s *runtimeRPCState) importIssue(input string, progress runtimeProgressSink emitRuntimeCommand(progress, "mnemond ingest observe --principal "+result.Principal, fmt.Sprintf("recorded seq=%d duplicate=%v ticked=%v", rec.Seq, rec.Dup, rec.Ticked), 0) emitRuntimeProgress(progress, fmt.Sprintf("Mnemon recorded the issue observation at seq=%d.", rec.Seq)) emitRuntimeProgress(progress, "Waking the managed local agent with [mnemon:wake].") - s.wakeManagedAgent(&result) + earlyHubDeltas := s.wakeManagedAgentWithHubProjection(multicaCtx, cli, client, issue, &result) emitRuntimeCommand(progress, "mnemond managed wake --principal "+result.Principal+" [mnemon:wake]", runtimeWakeProgress(result), runtimeExitCode(result.WakeErr)) emitRuntimeProgress(progress, runtimeWakeProgress(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) @@ -429,15 +430,27 @@ func (s *runtimeRPCState) correlateAssignmentMailbox(ctx context.Context, cli dr 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")) + var client *access.Client if addr == "" { result.WakeStatus = "skipped" result.WakeErr = fmt.Errorf("MNEMON_CONTROL_ADDR is not set") emitRuntimeProgress(progress, "Local Mnemon control address is not configured; skipping managed wake.") } else { - emitRuntimeProgress(progress, "Waking assigned local agent with [mnemon:wake].") - s.wakeManagedAgent(result) - emitRuntimeCommand(progress, "mnemond managed wake --principal "+result.Principal+" [mnemon:wake]", runtimeWakeProgress(*result), runtimeExitCode(result.WakeErr)) - emitRuntimeProgress(progress, runtimeWakeProgress(*result)) + var err error + client, err = runtimeControlClient(s.Env, addr, result.Principal) + if err != nil { + result.WakeStatus = "failed" + result.WakeErr = err + emitRuntimeProgress(progress, "Failed to create Local Mnemon control client.") + } else { + emitRuntimeProgress(progress, "Waking assigned local agent with [mnemon:wake].") + s.wakeManagedAgent(result) + emitRuntimeCommand(progress, "mnemond managed wake --principal "+result.Principal+" [mnemon:wake]", runtimeWakeProgress(*result), runtimeExitCode(result.WakeErr)) + emitRuntimeProgress(progress, runtimeWakeProgress(*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)) + } } s.projectImportComment(ctx, cli, issue, assignmentMailboxMarker(*result), result) emitRuntimeCommand(progress, "multica issue comment add "+issue.ID, runtimeProjectionProgress(*result), runtimeExitCode(result.ProjectionErr)) @@ -613,6 +626,91 @@ func (s *runtimeRPCState) wakeManagedAgent(result *runtimeImportResult) { } } +type runtimeHubProjectionDelta struct { + ChildIssues int + FeedbackComments int + Status string + Err error +} + +func (d runtimeHubProjectionDelta) active() bool { + return d.ChildIssues > 0 || d.FeedbackComments > 0 +} + +func (s *runtimeRPCState) wakeManagedAgentWithHubProjection(ctx context.Context, cli driver.MulticaCLI, client *access.Client, rootIssue driver.MulticaIssue, result *runtimeImportResult) []runtimeHubProjectionDelta { + if result == nil || client == nil || + !strings.EqualFold(result.HubBackend, driver.MulticaHubBackend) || + result.HubKind != driver.MulticaHubKindSession || + !runtimeMulticaHubWriteEnabled(s.Env) { + s.wakeManagedAgent(result) + return nil + } + done := make(chan struct{}) + deltas := make(chan runtimeHubProjectionDelta, 16) + go func(snapshot runtimeImportResult) { + defer close(deltas) + ticker := time.NewTicker(runtimeHubProjectionInterval(s.Env)) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-ticker.C: + delta := s.writeMulticaHubArtifactsDelta(ctx, cli, client, rootIssue, snapshot) + if delta.active() { + select { + case deltas <- delta: + default: + } + } + } + } + }(*result) + s.wakeManagedAgent(result) + close(done) + var out []runtimeHubProjectionDelta + for delta := range deltas { + out = append(out, delta) + } + return out +} + +func (s *runtimeRPCState) writeMulticaHubArtifactsDelta(ctx context.Context, cli driver.MulticaCLI, client *access.Client, rootIssue driver.MulticaIssue, base runtimeImportResult) runtimeHubProjectionDelta { + snapshot := base + snapshot.HubChildIssues = 0 + snapshot.HubFeedbackComments = 0 + snapshot.HubWriteStatus = "" + snapshot.HubWriteErr = nil + s.writeMulticaHubArtifacts(ctx, cli, client, rootIssue, &snapshot) + return runtimeHubProjectionDelta{ + ChildIssues: snapshot.HubChildIssues, + FeedbackComments: snapshot.HubFeedbackComments, + Status: snapshot.HubWriteStatus, + Err: snapshot.HubWriteErr, + } +} + +func mergeRuntimeHubProjectionDeltas(result *runtimeImportResult, deltas []runtimeHubProjectionDelta) { + if result == nil || len(deltas) == 0 { + return + } + for _, delta := range deltas { + result.HubChildIssues += delta.ChildIssues + result.HubFeedbackComments += delta.FeedbackComments + } + if result.HubWriteErr != nil { + return + } + switch { + case result.HubChildIssues > 0 && result.HubFeedbackComments > 0: + result.HubWriteStatus = "updated" + case result.HubChildIssues > 0: + result.HubWriteStatus = "created" + case result.HubFeedbackComments > 0: + result.HubWriteStatus = "commented" + } +} + func managedWakeCandidateForResult(principal string, resp presentation.Response, result runtimeImportResult) (driver.ManagedWakeCandidate, bool) { terms := managedWakeMatchTerms(result) var fallback driver.ManagedWakeCandidate @@ -643,7 +741,7 @@ func managedWakeMatchTerms(result runtimeImportResult) []string { if result.AssignmentID != "" || result.AssignmentFingerprint != "" { return cleanRuntimeTerms(result.AssignmentID, result.AssignmentFingerprint) } - raw := []string{result.IssueID, result.Identifier, result.Title, result.Statement, result.TaskID} + raw := []string{result.IssueID, result.Identifier, result.Title, result.TaskID} var out []string for _, value := range raw { value = strings.TrimSpace(value) @@ -651,6 +749,12 @@ func managedWakeMatchTerms(result runtimeImportResult) []string { 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 } @@ -1435,6 +1539,18 @@ func runtimeManagedTurnTimeout(env []string) time.Duration { 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 diff --git a/harness/cmd/mnemon-multica-runtime/main_test.go b/harness/cmd/mnemon-multica-runtime/main_test.go index 819071f0..9e3c6d3e 100644 --- a/harness/cmd/mnemon-multica-runtime/main_test.go +++ b/harness/cmd/mnemon-multica-runtime/main_test.go @@ -29,6 +29,60 @@ func TestRuntimeEnvValueUsesLastValue(t *testing.T) { } } +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{ + {ChildIssues: 2}, + {FeedbackComments: 1}, + }) + if result.HubChildIssues != 2 || result.HubFeedbackComments != 1 || result.HubWriteStatus != "updated" { + t.Fatalf("merged projection result = %+v", result) + } + failed := runtimeImportResult{HubWriteStatus: "failed", HubWriteErr: os.ErrInvalid} + mergeRuntimeHubProjectionDeltas(&failed, []runtimeHubProjectionDelta{{ChildIssues: 1}}) + if failed.HubWriteStatus != "failed" || failed.HubChildIssues != 1 { + t.Fatalf("failed projection merge should preserve final failure while carrying counts: %+v", failed) + } +} + +func TestManagedWakeMatchTermsPreferStableIssueIdentity(t *testing.T) { + got := managedWakeMatchTerms(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) { + 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 runtimeTestEnv(values ...string) []string { env := make([]string, 0, len(os.Environ())+len(values)) for _, item := range os.Environ() { @@ -444,8 +498,22 @@ esac Content: []pview.ResourceContent{{ Ref: contract.ResourceRef{Kind: "assignment", ID: "project"}, Fields: map[string]any{"items": []any{map[string]any{ - "id": "asg-writer", - "actor": "planner@team", + "id": "asg-stale", + "ingest_seq": float64(30), + "actor": "planner@team", + "rule": map[string]any{ + "assignment_id": "asg-stale", + "assignee": "worker@team", + "scope": "old release notes", + }, + "narrative": map[string]any{ + "expected_work": "stale assignment from an older Multica session", + "expected_feedback": "progress_digest with result or blocker", + }, + }, map[string]any{ + "id": "asg-writer", + "ingest_seq": float64(32), + "actor": "planner@team", "rule": map[string]any{ "assignment_id": "asg-writer", "assignee": "worker@team", @@ -501,12 +569,11 @@ esac 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 todo --priority medium", + "issue create --title Mnemon assignment asg-writer: 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.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", - "issue status child-2 in_progress --output json", } { if !strings.Contains(args, want) { t.Fatalf("args missing %q:\n%s", want, args) @@ -515,13 +582,22 @@ esac if strings.Contains(args, "check release notes against") { t.Fatalf("assignment description leaked into argv:\n%s", args) } + 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") metaIdx := strings.Index(args, "issue metadata set child-2 --key mnemon.kind") assignIdx := strings.Index(args, "issue assign child-2 --to-id agent-worker") - statusIdx := strings.Index(args, "issue status child-2 in_progress") - if createIdx < 0 || metaIdx < 0 || assignIdx < 0 || statusIdx < 0 || !(createIdx < metaIdx && metaIdx < assignIdx && assignIdx < statusIdx) { + 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) } + 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) + } 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"} { if !strings.Contains(description, want) { @@ -584,6 +660,12 @@ esac Meta: map[string]any{"presentation_hint": "work"}, }}, }) + case "/presentation-view": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(pview.View{ + Ref: "view-empty", + Digest: "digest-empty", + }) default: t.Fatalf("unexpected path %s", r.URL.Path) } @@ -640,10 +722,10 @@ func TestMulticaStatusForProgressIsRuleBased(t *testing.T) { want string }{ {name: "progress", item: runtimeProgress{FeedbackKind: "progress"}, want: "in_progress"}, - {name: "result", item: runtimeProgress{FeedbackKind: "result"}, want: "in_review"}, + {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: "in_review"}, + {name: "result narrative fallback", item: runtimeProgress{Result: "validated"}, want: "done"}, {name: "unknown", item: runtimeProgress{Summary: "not enough signal"}, want: ""}, } { if got := multicaStatusForProgress(tc.item); got != tc.want { diff --git a/harness/internal/assets/guides/mnemon-harness-guide.md b/harness/internal/assets/guides/mnemon-harness-guide.md index 7ef49459..5a743ce9 100644 --- a/harness/internal/assets/guides/mnemon-harness-guide.md +++ b/harness/internal/assets/guides/mnemon-harness-guide.md @@ -48,7 +48,9 @@ Prefer the short teamwork/profile commands when they fit the event you need: "${MNEMON_HARNESS_BIN:-mnemon-harness}" control teamwork progress \ --assignment-ref \ + --feedback-kind result \ --summary "" \ + --result "" \ --external-id "${MNEMON_HARNESS_BIN:-mnemon-harness}" control profile update \ @@ -78,6 +80,15 @@ Check `"${MNEMON_HARNESS_BIN:-mnemon-harness}" loop schema --type ` before - Emit `assignment` when concrete work is delegated or self-assigned with expected feedback. - Emit `progress_digest` when a result, blocker, or important context change should be returned. +When a `teamwork_signal` requires coordination, record the minimal concrete `assignment` events as +soon as assignees and expected feedback are clear. Do not wait for local verification, integration, +or a final answer before recording delegation; those can continue after child agents have enough +durable state to start. + +When an assigned slice is complete, emit `progress_digest` with `feedback_kind=result` and a result +field. Reserve `feedback_kind=progress` for partial work and `feedback_kind=blocker` for work that +cannot proceed. + ## Guardrails - Do not record secrets, credentials, tokens, or transient scratch state. diff --git a/harness/internal/driver/multica.go b/harness/internal/driver/multica.go index 058e8b80..98937550 100644 --- a/harness/internal/driver/multica.go +++ b/harness/internal/driver/multica.go @@ -10,6 +10,7 @@ import ( "os/exec" "sort" "strings" + "sync" "time" eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" @@ -280,6 +281,22 @@ func (c MulticaCLI) AddIssueComment(ctx context.Context, issueID, content string return comment, nil } +func (c MulticaCLI) ListIssueComments(ctx context.Context, issueID string) ([]MulticaComment, error) { + issueID = strings.TrimSpace(issueID) + if issueID == "" { + return nil, fmt.Errorf("multica issue id is required") + } + out, err := c.Run(ctx, []string{"issue", "comment", "list", issueID, "--full", "--output", "json"}, "") + if err != nil { + return nil, err + } + comments, err := decodeMulticaCommentListOutput([]byte(out.Stdout)) + if err != nil { + return nil, err + } + return comments, nil +} + func (c MulticaCLI) ListRuntimeProfiles(ctx context.Context) ([]MulticaRuntimeProfile, error) { out, err := c.Run(ctx, []string{"runtime", "profile", "list", "--output", "json"}, "") if err != nil { @@ -646,12 +663,49 @@ func (c MulticaCLI) SetIssueMetadataMap(ctx context.Context, issueID string, val keys = append(keys, key) } sort.Strings(keys) - for _, key := range keys { - if err := c.SetIssueMetadata(ctx, issueID, key, values[key], "string"); err != nil { - return err + if len(keys) == 0 { + return nil + } + const maxMetadataWorkers = 6 + workers := maxMetadataWorkers + if len(keys) < workers { + workers = len(keys) + } + jobs := make(chan string) + var wg sync.WaitGroup + var mu sync.Mutex + var firstErr error + recordErr := func(err error) { + if err == nil { + return + } + mu.Lock() + defer mu.Unlock() + if firstErr == nil { + firstErr = err } } - return nil + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for key := range jobs { + mu.Lock() + stopped := firstErr != nil + mu.Unlock() + if stopped { + continue + } + recordErr(c.SetIssueMetadata(ctx, issueID, key, values[key], "string")) + } + }() + } + for _, key := range keys { + jobs <- key + } + close(jobs) + wg.Wait() + return firstErr } func (c MulticaCLI) GetIssueMetadata(ctx context.Context, issueID, key string) (string, bool, error) { @@ -943,6 +997,39 @@ func decodeMulticaIssueListOutput(data []byte) ([]MulticaIssue, error) { return out, nil } +func decodeMulticaCommentListOutput(data []byte) ([]MulticaComment, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var raw any + if err := dec.Decode(&raw); err != nil { + return nil, fmt.Errorf("decode multica comments: %w", err) + } + var out []MulticaComment + seen := map[string]bool{} + var walk func(any) + walk = func(v any) { + switch value := v.(type) { + case []any: + for _, item := range value { + walk(item) + } + case map[string]any: + if comment, ok := multicaCommentFromRawMap(value); ok { + if !seen[comment.ID] { + seen[comment.ID] = true + out = append(out, comment) + } + return + } + for _, item := range value { + walk(item) + } + } + } + walk(raw) + return out, nil +} + func multicaIssueFromRawMap(raw map[string]any) (MulticaIssue, bool) { id, _ := raw["id"].(string) if strings.TrimSpace(id) == "" { @@ -968,6 +1055,36 @@ func multicaIssueFromRawMap(raw map[string]any) (MulticaIssue, bool) { return issue, true } +func multicaCommentFromRawMap(raw map[string]any) (MulticaComment, bool) { + id, _ := raw["id"].(string) + if strings.TrimSpace(id) == "" { + return MulticaComment{}, false + } + if _, ok := raw["content"]; !ok { + if _, ok := raw["body"]; !ok { + if _, ok := raw["text"]; !ok { + return MulticaComment{}, false + } + } + } + comment := MulticaComment{ + ID: strings.TrimSpace(id), + IssueID: firstRawString(raw, "issue_id", "issueId"), + Content: firstRawString(raw, "content", "body", "text"), + Type: firstRawString(raw, "type", "kind"), + } + return comment, true +} + +func firstRawString(raw map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := raw[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + func decodeMulticaAgentEnv(data []byte) (map[string]string, error) { var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { diff --git a/harness/internal/driver/multica_test.go b/harness/internal/driver/multica_test.go index 24e8881e..945ec2e0 100644 --- a/harness/internal/driver/multica_test.go +++ b/harness/internal/driver/multica_test.go @@ -480,9 +480,6 @@ esac t.Fatalf("args missing %q:\n%s", want, args) } } - if strings.Index(args, "--key a") > strings.Index(args, "--key b") { - t.Fatalf("metadata map should be written in deterministic key order:\n%s", args) - } } func mustReadDriverTestFile(t *testing.T, path string) string { diff --git a/harness/internal/mnemond/presentation/render_test.go b/harness/internal/mnemond/presentation/render_test.go index 84dca534..5678b85d 100644 --- a/harness/internal/mnemond/presentation/render_test.go +++ b/harness/internal/mnemond/presentation/render_test.go @@ -58,6 +58,33 @@ func TestRenderPresentationDeterministicDigestAndAudit(t *testing.T) { } } +func TestTeamworkSignalPresentationCarriesContextRefs(t *testing.T) { + now := mustTime(t, "2026-06-24T10:00:00Z") + req := Request{Principal: "codex-a@project", Host: "codex", Lifecycle: "remind", RenderIntent: IntentTeamworkEvents} + proj := view.View{Ref: "proj_signal_refs", Digest: "digest_signal_refs", Content: []view.ResourceContent{ + content("agent_profile", "project", []any{map[string]any{"id": "p1", "actor": "codex-a@project", "freshness": "fresh", "summary": "A profile"}}), + content("teamwork_signal", "project", []any{map[string]any{ + "id": "sig1", + "statement": "Run the shared readiness drill", + "context_refs": []any{"multica:issue:issue-123"}, + }}), + }} + resp, err := (Renderer{Now: func() time.Time { return now }}).RenderPresentation(context.Background(), req, proj) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(resp.Body, "Context refs: multica:issue:issue-123") { + t.Fatalf("signal presentation must carry stable context refs for wake matching:\n%s", resp.Body) + } + if len(resp.Events) != 1 { + t.Fatalf("expected one signal event: %+v", resp.Events) + } + body, _ := eventmodel.PayloadNarrative(resp.Events[0].Event.Payload)["body"].(string) + if !strings.Contains(body, "multica:issue:issue-123") { + t.Fatalf("derived event body must carry stable context refs: %q", 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 b7b27dce..7004096b 100644 --- a/harness/internal/mnemond/presentation/teamwork_presenter.go +++ b/harness/internal/mnemond/presentation/teamwork_presenter.go @@ -77,11 +77,15 @@ 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 refs := signalContextRefs(signal); len(refs) > 0 { + body = fmt.Sprintf("%s Context refs: %s.", body, strings.Join(refs, ", ")) + } appendDerived( DerivedEventTeamworkSignalOpen, subject, []string{subject}, - fmt.Sprintf("Teamwork signal is open: %s. Assignment or self-assignment may be useful when you choose to act.", statement), + body, []string{"assignment.write_candidate.observed"}, ) } @@ -295,6 +299,22 @@ func progressRefs(items []map[string]any) []string { return refs } +func signalContextRefs(signal map[string]any) []string { + refs := append([]string{}, itemStringList(signal, "context_refs")...) + refs = append(refs, itemStringList(signal, "evidence_refs")...) + seen := map[string]bool{} + var out []string + for _, ref := range refs { + ref = strings.TrimSpace(ref) + if ref == "" || seen[ref] { + continue + } + seen[ref] = true + out = append(out, ref) + } + return out +} + func presentationHintForDerivedEventType(eventType string) string { switch eventType { case DerivedEventProfileUpdateRequested: From 6d481a732aa2a2ad9bd05156ea2c5f4ff36a362b Mon Sep 17 00:00:00 2001 From: Grivn Date: Mon, 29 Jun 2026 01:00:30 +0800 Subject: [PATCH 22/24] refactor: fold multica runtime probe into runtime Expose Multica runtime diagnostics through mnemon-multica-runtime probe/diagnose mode instead of a second runtime command. This keeps one product runtime identity while preserving the protocol/env recorder for debugging. Validation: go test -count=1 ./... --- harness/cmd/mnemon-multica-runtime/main.go | 13 +++ .../probe.go} | 94 ++----------------- .../probe_test.go} | 10 +- 3 files changed, 25 insertions(+), 92 deletions(-) rename harness/cmd/{mnemon-multica-runtime-probe/main.go => mnemon-multica-runtime/probe.go} (81%) rename harness/cmd/{mnemon-multica-runtime-probe/main_test.go => mnemon-multica-runtime/probe_test.go} (90%) diff --git a/harness/cmd/mnemon-multica-runtime/main.go b/harness/cmd/mnemon-multica-runtime/main.go index c1e3b245..1b310d0b 100644 --- a/harness/cmd/mnemon-multica-runtime/main.go +++ b/harness/cmd/mnemon-multica-runtime/main.go @@ -132,6 +132,9 @@ func runRuntime(cfg runtimeConfig) error { return err } } + if runtimeProbeModeEnabled(cfg.Args) { + return runRuntimeProbe(cfg) + } if wantsVersion(cfg.Args) { fmt.Fprintf(cfg.Stdout, "mnemon-multica-runtime %s\n", runtimeVersion) return nil @@ -1467,6 +1470,16 @@ func wantsVersion(args []string) bool { return false } +func runtimeProbeModeEnabled(args []string) bool { + for _, arg := range args { + switch strings.TrimSpace(arg) { + case "probe", "diagnose", "--probe", "-probe", "--diagnose", "-diagnose": + return true + } + } + return false +} + func stringParam(params map[string]any, key string) string { if params == nil { return "" diff --git a/harness/cmd/mnemon-multica-runtime-probe/main.go b/harness/cmd/mnemon-multica-runtime/probe.go similarity index 81% rename from harness/cmd/mnemon-multica-runtime-probe/main.go rename to harness/cmd/mnemon-multica-runtime/probe.go index 3360b836..b526f55a 100644 --- a/harness/cmd/mnemon-multica-runtime-probe/main.go +++ b/harness/cmd/mnemon-multica-runtime/probe.go @@ -3,7 +3,6 @@ package main import ( "bufio" "encoding/json" - "flag" "fmt" "io" "os" @@ -13,18 +12,6 @@ import ( "time" ) -const probeVersion = "dev" - -type probeConfig struct { - Args []string - Env []string - CWD string - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer - Now func() time.Time -} - type probeRecorder struct { path string now func() time.Time @@ -43,30 +30,7 @@ type probeEvent struct { Error string `json:"error,omitempty"` } -type rpcMessage struct { - JSONRPC string `json:"jsonrpc,omitempty"` - ID any `json:"id,omitempty"` - Method string `json:"method,omitempty"` - Params map[string]any `json:"params,omitempty"` - Result any `json:"result,omitempty"` - Error any `json:"error,omitempty"` -} - -func main() { - if err := runProbe(probeConfig{ - Args: os.Args[1:], - Env: os.Environ(), - Stdin: os.Stdin, - Stdout: os.Stdout, - Stderr: os.Stderr, - Now: time.Now, - }); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func runProbe(cfg probeConfig) error { +func runRuntimeProbe(cfg runtimeConfig) error { if cfg.Stdin == nil { cfg.Stdin = strings.NewReader("") } @@ -100,13 +64,13 @@ func runProbe(cfg probeConfig) error { return err } if wantsVersion(cfg.Args) { - fmt.Fprintf(cfg.Stdout, "mnemon-multica-runtime-probe %s\n", probeVersion) + fmt.Fprintf(cfg.Stdout, "mnemon-multica-runtime %s (probe mode)\n", runtimeVersion) return rec.record(probeEvent{Kind: "version"}) } return runProbeRPC(cfg, rec, cwd) } -func runProbeRPC(cfg probeConfig, rec *probeRecorder, cwd string) error { +func runProbeRPC(cfg runtimeConfig, rec *probeRecorder, cwd string) error { scanner := bufio.NewScanner(cfg.Stdin) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) state := probeRPCState{CWD: cwd, Now: cfg.Now} @@ -156,7 +120,7 @@ func (s *probeRPCState) handle(msg rpcMessage) []rpcMessage { return []rpcMessage{{ ID: msg.ID, Result: map[string]any{ - "userAgent": "mnemon-multica-runtime-probe/" + probeVersion, + "userAgent": "mnemon-multica-runtime/" + runtimeVersion + " probe", "codexHome": os.Getenv("CODEX_HOME"), "platformFamily": "unix", "platformOs": runtime.GOOS, @@ -169,8 +133,8 @@ func (s *probeRPCState) handle(msg rpcMessage) []rpcMessage { Method: "remoteControl/status/changed", Params: map[string]any{ "status": "disabled", - "serverName": "mnemon-multica-runtime-probe", - "installationId": "mnemon-probe", + "serverName": "mnemon-multica-runtime", + "installationId": "mnemon-runtime-probe", }, }, { @@ -267,7 +231,7 @@ func (s *probeRPCState) threadObject() map[string]any { "recencyAt": s.now().Unix(), "status": map[string]any{"type": "idle"}, "cwd": s.CWD, - "cliVersion": probeVersion, + "cliVersion": runtimeVersion, "source": "multica", "turns": []any{}, } @@ -384,23 +348,6 @@ func (r *probeRecorder) record(event probeEvent) error { return f.Close() } -func wantsVersion(args []string) bool { - fs := flag.NewFlagSet("mnemon-multica-runtime-probe", flag.ContinueOnError) - fs.SetOutput(io.Discard) - version := fs.Bool("version", false, "") - _ = fs.Parse(args) - if *version { - return true - } - for _, arg := range args { - switch arg { - case "version", "--version", "-version", "-v": - return true - } - } - return false -} - func redactProbeEnv(env []string) map[string]any { out := map[string]any{} for _, item := range env { @@ -431,33 +378,6 @@ func isSensitiveProbeEnv(key string) bool { return false } -func envValue(env []string, key string) string { - prefix := key + "=" - for _, item := range env { - if strings.HasPrefix(item, prefix) { - return strings.TrimSpace(strings.TrimPrefix(item, prefix)) - } - } - return "" -} - -func stringParam(params map[string]any, key string) string { - if params == nil { - return "" - } - value, _ := params[key].(string) - return strings.TrimSpace(value) -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} - func probeID(prefix string, ts time.Time) string { return fmt.Sprintf("%s-%d", prefix, ts.UTC().UnixNano()) } diff --git a/harness/cmd/mnemon-multica-runtime-probe/main_test.go b/harness/cmd/mnemon-multica-runtime/probe_test.go similarity index 90% rename from harness/cmd/mnemon-multica-runtime-probe/main_test.go rename to harness/cmd/mnemon-multica-runtime/probe_test.go index 647e0e2c..56a1dfe4 100644 --- a/harness/cmd/mnemon-multica-runtime-probe/main_test.go +++ b/harness/cmd/mnemon-multica-runtime/probe_test.go @@ -13,8 +13,8 @@ import ( func TestProbeVersionRedactsEnvironmentInLog(t *testing.T) { logPath := filepath.Join(t.TempDir(), "probe.jsonl") var out bytes.Buffer - err := runProbe(probeConfig{ - Args: []string{"--version"}, + err := runRuntime(runtimeConfig{ + Args: []string{"--probe", "--version"}, Env: []string{ "MNEMON_MULTICA_PROBE_LOG=" + logPath, "MULTICA_TASK_ID=task-1", @@ -28,7 +28,7 @@ func TestProbeVersionRedactsEnvironmentInLog(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(out.String(), "mnemon-multica-runtime-probe") { + if !strings.Contains(out.String(), "mnemon-multica-runtime") || !strings.Contains(out.String(), "probe mode") { t.Fatalf("version output = %q", out.String()) } logData, err := os.ReadFile(logPath) @@ -52,8 +52,8 @@ func TestProbeSpeaksMinimalCodexAppServerRPC(t *testing.T) { `{"jsonrpc":"2.0","id":3,"method":"turn/start","params":{"threadId":"thread-known","input":[{"type":"text","text":"hello"}]}}`, }, "\n") + "\n" var out bytes.Buffer - err := runProbe(probeConfig{ - Args: []string{"app-server", "--listen", "stdio://"}, + err := runRuntime(runtimeConfig{ + Args: []string{"--diagnose", "app-server", "--listen", "stdio://"}, Env: []string{"MNEMON_MULTICA_PROBE_LOG=" + logPath}, CWD: t.TempDir(), Stdin: strings.NewReader(input), From 305f5c71dc8fc24f2870583b9c9998c420b82e1d Mon Sep 17 00:00:00 2001 From: Grivn Date: Mon, 29 Jun 2026 01:40:28 +0800 Subject: [PATCH 23/24] feat: bridge managed codex activation traces Forward sanitized native Codex app-server item streams from mnemond-managed wake turns into the Multica runtime detail stream while keeping the wake query as the minimal [mnemon:wake] sentinel and avoiding Mnemon protocol event writes for trace data. Validation: go test -count=1 ./harness/internal/driver ./harness/internal/codexapp ./harness/cmd/mnemon-multica-runtime; go test -count=1 ./...; real Multica TEA-45 completed with managed Codex text/tool activity visible in run messages, governed.db trace marker count 0. --- harness/cmd/mnemon-multica-runtime/main.go | 103 ++++++- .../cmd/mnemon-multica-runtime/main_test.go | 77 ++++++ harness/internal/codexapp/codexapp.go | 10 + harness/internal/driver/managed_agent.go | 9 +- harness/internal/driver/managed_runtime.go | 14 +- .../internal/driver/managed_runtime_test.go | 61 +++++ harness/internal/driver/managed_trace.go | 252 ++++++++++++++++++ harness/internal/driver/managed_trace_test.go | 99 +++++++ 8 files changed, 616 insertions(+), 9 deletions(-) create mode 100644 harness/internal/driver/managed_trace.go create mode 100644 harness/internal/driver/managed_trace_test.go diff --git a/harness/cmd/mnemon-multica-runtime/main.go b/harness/cmd/mnemon-multica-runtime/main.go index 1b310d0b..e8c5147b 100644 --- a/harness/cmd/mnemon-multica-runtime/main.go +++ b/harness/cmd/mnemon-multica-runtime/main.go @@ -64,6 +64,7 @@ type runtimeProgressEvent struct { Output string ExitCode int DurationMs int64 + Trace *driver.ManagedTurnTraceEvent } type runtimeImportResult struct { @@ -247,6 +248,10 @@ func (s *runtimeRPCState) handle(msg rpcMessage, emit func(rpcMessage) error) er if progressErr != nil { return } + if event.Trace != nil { + progressErr = emitRuntimeManagedTraceEvent(emit, 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()) return @@ -408,7 +413,7 @@ func (s *runtimeRPCState) importIssue(input string, progress runtimeProgressSink emitRuntimeCommand(progress, "mnemond ingest observe --principal "+result.Principal, fmt.Sprintf("recorded seq=%d duplicate=%v ticked=%v", rec.Seq, rec.Dup, rec.Ticked), 0) 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) + 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)) s.writeMulticaHubArtifacts(multicaCtx, cli, client, issue, &result) @@ -447,7 +452,7 @@ func (s *runtimeRPCState) correlateAssignmentMailbox(ctx context.Context, cli dr emitRuntimeProgress(progress, "Failed to create Local Mnemon control client.") } else { emitRuntimeProgress(progress, "Waking assigned local agent with [mnemon:wake].") - s.wakeManagedAgent(result) + s.wakeManagedAgent(result, progress) emitRuntimeCommand(progress, "mnemond managed wake --principal "+result.Principal+" [mnemon:wake]", runtimeWakeProgress(*result), runtimeExitCode(result.WakeErr)) emitRuntimeProgress(progress, runtimeWakeProgress(*result)) s.writeMulticaHubArtifacts(ctx, cli, client, issue, result) @@ -557,7 +562,7 @@ func assignmentMailboxMarker(result runtimeImportResult) string { return "multica-issue-" + result.IssueID } -func (s *runtimeRPCState) wakeManagedAgent(result *runtimeImportResult) { +func (s *runtimeRPCState) wakeManagedAgent(result *runtimeImportResult, progress runtimeProgressSink) { if result == nil { return } @@ -615,7 +620,12 @@ func (s *runtimeRPCState) wakeManagedAgent(result *runtimeImportResult) { Principal: result.Principal, Client: client, Ledger: driver.NewFileManagedWakeLedger(runtimeManagedLedgerPath(s.Env, workspace)), - Now: func() time.Time { return s.now() }, + TraceSink: driver.ManagedTurnTraceSinkFunc(func(event driver.ManagedTurnTraceEvent) { + if progress != nil { + progress(runtimeProgressEvent{Trace: &event}) + } + }), + Now: func() time.Time { return s.now() }, }).Wake(wakeCtx, candidate) result.WakeTurnID = record.TurnID if err != nil { @@ -640,12 +650,12 @@ func (d runtimeHubProjectionDelta) active() bool { return d.ChildIssues > 0 || d.FeedbackComments > 0 } -func (s *runtimeRPCState) wakeManagedAgentWithHubProjection(ctx context.Context, cli driver.MulticaCLI, client *access.Client, rootIssue driver.MulticaIssue, result *runtimeImportResult) []runtimeHubProjectionDelta { +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) { - s.wakeManagedAgent(result) + s.wakeManagedAgent(result, progress) return nil } done := make(chan struct{}) @@ -669,7 +679,7 @@ func (s *runtimeRPCState) wakeManagedAgentWithHubProjection(ctx context.Context, } } }(*result) - s.wakeManagedAgent(result) + s.wakeManagedAgent(result, progress) close(done) var out []runtimeHubProjectionDelta for delta := range deltas { @@ -1154,6 +1164,85 @@ 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 == "" { diff --git a/harness/cmd/mnemon-multica-runtime/main_test.go b/harness/cmd/mnemon-multica-runtime/main_test.go index 9e3c6d3e..c1f4cd7d 100644 --- a/harness/cmd/mnemon-multica-runtime/main_test.go +++ b/harness/cmd/mnemon-multica-runtime/main_test.go @@ -358,6 +358,83 @@ esac } } +func TestRuntimeForwardsManagedCodexTraceItemsOnOuterTurn(t *testing.T) { + var messages []rpcMessage + emit := func(message rpcMessage) error { + messages = append(messages, message) + return nil + } + traceStarted := driver.ManagedTurnTraceEvent{ + SourceRuntime: driver.ManagedTurnTraceSourceCodexAppServer, + Principal: "planner@team", + TurnID: "inner-turn", + ItemID: "inner-msg", + Method: "item/started", + ItemType: "agentMessage", + Item: map[string]any{ + "type": "agentMessage", + "id": "inner-msg", + "text": "", + "phase": "commentary", + }, + } + traceDelta := driver.ManagedTurnTraceEvent{ + SourceRuntime: driver.ManagedTurnTraceSourceCodexAppServer, + Principal: "planner@team", + TurnID: "inner-turn", + ItemID: "inner-msg", + Method: "item/agentMessage/delta", + Text: "native Codex agent detail", + } + traceCompleted := traceStarted + traceCompleted.Method = "item/completed" + traceCompleted.Item = map[string]any{ + "type": "agentMessage", + "id": "inner-msg", + "text": "native Codex agent detail", + "phase": "commentary", + } + for _, event := range []driver.ManagedTurnTraceEvent{traceStarted, traceDelta, traceCompleted} { + if err := emitRuntimeManagedTraceEvent(emit, "outer-thread", "outer-turn", event, fixedRuntimeTime()); err != nil { + t.Fatal(err) + } + } + if len(messages) != 3 { + t.Fatalf("messages = %+v, want 3", messages) + } + var itemID string + for i, message := range messages { + params := message.Params + if params["threadId"] != "outer-thread" || params["turnId"] != "outer-turn" { + t.Fatalf("message %d not attached to outer turn: %+v", i, message) + } + switch message.Method { + case "item/started", "item/completed": + item, _ := params["item"].(map[string]any) + if item["id"] == "inner-msg" { + t.Fatalf("message %d leaked inner item id: %+v", i, message) + } + if item["mnemonManagedTurnId"] != "inner-turn" || item["mnemonPrincipal"] != "planner@team" { + t.Fatalf("message %d missing trace metadata: %+v", i, item) + } + if itemID == "" { + itemID, _ = item["id"].(string) + } else if item["id"] != itemID { + t.Fatalf("trace item id changed across lifecycle: first=%q got=%q", itemID, item["id"]) + } + case "item/agentMessage/delta": + if params["itemId"] != itemID { + t.Fatalf("delta item id = %q, want %q", params["itemId"], itemID) + } + if params["delta"] != "native Codex agent detail" { + t.Fatalf("delta = %+v", params) + } + default: + t.Fatalf("unexpected method %q", message.Method) + } + } +} + func TestRuntimeInitializesMulticaRootSessionMetadata(t *testing.T) { tmp := t.TempDir() argsPath := filepath.Join(tmp, "multica.args") diff --git a/harness/internal/codexapp/codexapp.go b/harness/internal/codexapp/codexapp.go index 6b14734a..4cc87080 100644 --- a/harness/internal/codexapp/codexapp.go +++ b/harness/internal/codexapp/codexapp.go @@ -205,6 +205,13 @@ func (s *AppServer) waitResponse(id int, timeout time.Duration) (map[string]any, // WaitNotification waits up to timeout for a notification with the given method, starting from // startIndex into the notification log (use NotificationCount before the action that triggers it). func (s *AppServer) WaitNotification(method string, timeout time.Duration, startIndex int) (map[string]any, error) { + return s.WaitNotificationWithCallback(method, timeout, startIndex, nil) +} + +// WaitNotificationWithCallback waits for a notification while calling onNotifications for every +// notification observed from startIndex onward. The callback runs on the AppServer driving goroutine; +// callers should keep it lightweight and must not call back into AppServer methods from it. +func (s *AppServer) WaitNotificationWithCallback(method string, timeout time.Duration, startIndex int, onNotifications func([]map[string]any)) (map[string]any, error) { deadline := time.After(timeout) cursor := startIndex if cursor < 0 || cursor > len(s.notifications) { @@ -214,6 +221,9 @@ func (s *AppServer) WaitNotification(method string, timeout time.Duration, start for cursor < len(s.notifications) { n := s.notifications[cursor] cursor++ + if onNotifications != nil { + onNotifications([]map[string]any{n}) + } if n["method"] == method { return n, nil } diff --git a/harness/internal/driver/managed_agent.go b/harness/internal/driver/managed_agent.go index 9e09548f..cc68cb9c 100644 --- a/harness/internal/driver/managed_agent.go +++ b/harness/internal/driver/managed_agent.go @@ -83,6 +83,7 @@ type ManagedAgentDriver struct { Principal string Client ManagedTurnClient Ledger ManagedWakeLedger + TraceSink ManagedTurnTraceSink Cooldown time.Duration Now func() time.Time @@ -137,7 +138,13 @@ func (d *ManagedAgentDriver) Wake(ctx context.Context, candidate ManagedWakeCand RenderAuditID: candidate.RenderAuditID, RenderBodyDigest: candidate.RenderBodyDigest, } - result, err := d.Client.StartTurn(ctx, ManagedWakeQuery) + 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() diff --git a/harness/internal/driver/managed_runtime.go b/harness/internal/driver/managed_runtime.go index 41ce2710..df2ccf4b 100644 --- a/harness/internal/driver/managed_runtime.go +++ b/harness/internal/driver/managed_runtime.go @@ -183,6 +183,10 @@ type CodexAppServerTurnClient struct { } 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) } @@ -256,7 +260,15 @@ func (c CodexAppServerTurnClient) StartTurn(ctx context.Context, query string) ( if _, err := server.Request("turn/start", turnParams, requestTimeout); err != nil { return ManagedTurnResult{}, fmt.Errorf("turn/start: %w", err) } - if _, err := server.WaitNotification("turn/completed", turnTimeout, before); err != nil { + 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) } diff --git a/harness/internal/driver/managed_runtime_test.go b/harness/internal/driver/managed_runtime_test.go index 56fa1ee2..165176b4 100644 --- a/harness/internal/driver/managed_runtime_test.go +++ b/harness/internal/driver/managed_runtime_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" "testing" + "time" eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation" @@ -77,6 +78,66 @@ func TestCodexAppServerTurnClientRejectsContextQuery(t *testing.T) { } } +func TestCodexAppServerTurnClientEmitsActivationTrace(t *testing.T) { + workspace := t.TempDir() + fakeCodex := filepath.Join(workspace, "fake-codex") + if err := os.WriteFile(fakeCodex, []byte(`#!/usr/bin/env bash +while IFS= read -r line; do + 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/started","params":{"threadId":"thread-1","turnId":"turn-1","item":{"type":"agentMessage","id":"msg-1","text":"","phase":"commentary"}}}\n' + printf '{"jsonrpc":"2.0","method":"item/agentMessage/delta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"msg-1","delta":"native trace detail"}}\n' + printf '{"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thread-1","turnId":"turn-1","item":{"type":"agentMessage","id":"msg-1","text":"native trace detail","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) + } + var traces []ManagedTurnTraceEvent + client := CodexAppServerTurnClient{ + Principal: "planner@team", + Command: fakeCodex, + Workspace: workspace, + TurnTimeout: 5 * time.Second, + RequestTimeout: 5 * time.Second, + } + result, err := client.StartTurnWithTrace(context.Background(), ManagedWakeQuery, ManagedTurnTraceSinkFunc(func(event ManagedTurnTraceEvent) { + traces = append(traces, event) + })) + if err != nil { + t.Fatal(err) + } + if result.Status != "completed" || !strings.Contains(result.FinalAnswer, "native trace detail") { + t.Fatalf("result = %+v", result) + } + var sawStart, sawDelta, sawComplete bool + for _, event := range traces { + if event.Principal != "planner@team" || event.SourceRuntime != ManagedTurnTraceSourceCodexAppServer { + t.Fatalf("unexpected trace identity: %+v", event) + } + switch event.Method { + case "item/started": + sawStart = event.ItemType == "agentMessage" + case "item/agentMessage/delta": + sawDelta = event.Text == "native trace detail" + case "item/completed": + sawComplete = event.Phase == "final_answer" + } + } + if !sawStart || !sawDelta || !sawComplete { + t.Fatalf("missing native trace events start=%v delta=%v complete=%v traces=%+v", sawStart, sawDelta, sawComplete, traces) + } +} + func TestCodexAppServerAdditionalContextRunsStandardHooks(t *testing.T) { workspace := t.TempDir() hookDir := filepath.Join(workspace, ".codex", "hooks", "mnemon-r1") diff --git a/harness/internal/driver/managed_trace.go b/harness/internal/driver/managed_trace.go new file mode 100644 index 00000000..259a3e1c --- /dev/null +++ b/harness/internal/driver/managed_trace.go @@ -0,0 +1,252 @@ +package driver + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +const ( + ManagedTurnTraceSourceCodexAppServer = "codex-appserver" + managedTurnTraceTextLimit = 12000 +) + +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 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 "" +} diff --git a/harness/internal/driver/managed_trace_test.go b/harness/internal/driver/managed_trace_test.go new file mode 100644 index 00000000..2c3e7cf3 --- /dev/null +++ b/harness/internal/driver/managed_trace_test.go @@ -0,0 +1,99 @@ +package driver + +import ( + "context" + "strings" + "testing" +) + +func TestManagedTurnTraceEventsFromCodexNotificationsRedactsAndBounds(t *testing.T) { + longOutput := strings.Repeat("x", managedTurnTraceTextLimit+20) + events := ManagedTurnTraceEventsFromCodexNotifications("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 multica issue get TEA-1", + "cwd": "/tmp/work", + "status": "completed", + "exitCode": 0, + "aggregatedOutput": "API_KEY=abc123 " + 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(traceTestString(got.Item["authToken"])) + for _, forbidden := range []string{"raw-secret", "abc123", "must-not-leak"} { + 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]") { + 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 TestManagedAgentDriverRoutesTraceWithoutChangingWakeQuery(t *testing.T) { + var gotQuery string + var traces []ManagedTurnTraceEvent + driver := &ManagedAgentDriver{ + Principal: "planner@team", + Client: traceManagedTurnClientFunc(func(ctx context.Context, query string, sink ManagedTurnTraceSink) (ManagedTurnResult, error) { + gotQuery = query + sink.OnManagedTurnTrace(ManagedTurnTraceEvent{Kind: "agent_message", Text: "native trace"}) + return ManagedTurnResult{TurnID: "turn-1", Status: "completed", FinalAnswer: "done"}, nil + }), + Ledger: NewMemoryManagedWakeLedger(), + TraceSink: ManagedTurnTraceSinkFunc(func(event ManagedTurnTraceEvent) { + traces = append(traces, event) + }), + } + record, err := driver.Wake(context.Background(), ManagedWakeCandidate{ + Principal: "planner@team", + DerivedEventID: "derived-1", + BodyDigest: "sha256:body", + }) + if err != nil { + t.Fatal(err) + } + if gotQuery != ManagedWakeQuery { + t.Fatalf("query = %q, want sentinel %q", gotQuery, ManagedWakeQuery) + } + if record.Status != "completed" || record.FinalAnswer != "done" { + t.Fatalf("wake record = %+v", record) + } + if len(traces) != 1 || traces[0].Text != "native trace" { + t.Fatalf("traces = %+v, want native trace", traces) + } +} + +type traceManagedTurnClientFunc func(context.Context, string, ManagedTurnTraceSink) (ManagedTurnResult, error) + +func (f traceManagedTurnClientFunc) StartTurn(ctx context.Context, query string) (ManagedTurnResult, error) { + return f(ctx, query, nil) +} + +func (f traceManagedTurnClientFunc) StartTurnWithTrace(ctx context.Context, query string, sink ManagedTurnTraceSink) (ManagedTurnResult, error) { + return f(ctx, query, sink) +} + +func traceTestString(value any) string { + if text, ok := value.(string); ok { + return text + } + return "" +} From abbdf0a2d820ccfa0a7d23d5648349425671146c Mon Sep 17 00:00:00 2001 From: Grivn Date: Mon, 29 Jun 2026 02:12:52 +0800 Subject: [PATCH 24/24] feat: add explicit multica participant registration Introduce a single-agent Multica participant registration path that adopts an existing UI-created agent by id or name, writes the Mnemon registry mapping, and injects the participant runtime environment. The old provision helper is hidden from the public Multica command surface and now composes the same registration primitive for its five-role test setup. Validation: go test -count=1 ./...; go run ./harness/cmd/mnemon-harness multica participant register --help; go run ./harness/cmd/mnemon-harness multica --help. --- harness/cmd/mnemon-harness/multica.go | 460 +++++++++++++++++---- harness/cmd/mnemon-harness/multica_test.go | 140 +++++++ 2 files changed, 509 insertions(+), 91 deletions(-) diff --git a/harness/cmd/mnemon-harness/multica.go b/harness/cmd/mnemon-harness/multica.go index f7291b8c..a55adcaf 100644 --- a/harness/cmd/mnemon-harness/multica.go +++ b/harness/cmd/mnemon-harness/multica.go @@ -59,6 +59,24 @@ var ( multicaProvisionManagedCommand string multicaProvisionManagedWorkspace string multicaProvisionManagedTimeout time.Duration + + multicaParticipantRegistry string + multicaParticipantProjectRoot string + multicaParticipantAgentID string + multicaParticipantAgentName string + multicaParticipantPrincipal string + multicaParticipantRole string + multicaParticipantRuntimeID string + multicaParticipantCreateIfMissing bool + multicaParticipantSyncAgent bool + multicaParticipantControlAddr string + multicaParticipantControlToken string + multicaParticipantControlTokenFile string + multicaParticipantHarnessBin string + multicaParticipantManagedRuntime string + multicaParticipantManagedCommand string + multicaParticipantManagedWorkspace string + multicaParticipantManagedTimeout time.Duration ) var multicaCmd = &cobra.Command{ @@ -85,9 +103,21 @@ var multicaProjectCommentCmd = &cobra.Command{ } var multicaProvisionCmd = &cobra.Command{ - Use: "provision", - Short: "Provision Multica runtime profile and Mnemon participant agents", - RunE: runMulticaProvision, + Use: "provision", + Short: "Provision Multica runtime profile and Mnemon participant agents", + Hidden: true, + RunE: runMulticaProvision, +} + +var multicaParticipantCmd = &cobra.Command{ + Use: "participant", + Short: "Manage explicit Mnemon participants backed by Multica agents", +} + +var multicaParticipantRegisterCmd = &cobra.Command{ + Use: "register", + Short: "Register one Multica agent as a Mnemon participant", + RunE: runMulticaParticipantRegister, } type multicaProbeReport struct { @@ -119,6 +149,43 @@ type multicaProvisionReport struct { Warnings []string `json:"warnings,omitempty"` } +type multicaParticipantRegisterReport struct { + WorkspaceID string `json:"workspace_id"` + RegistryPath string `json:"registry_path"` + Participant driver.MulticaParticipantRecord `json:"participant"` + Agent driver.MulticaAgent `json:"agent"` + AgentAction string `json:"agent_action"` + UpdatedEnv bool `json:"updated_env"` + Registry driver.MulticaRegistry `json:"registry"` + Warnings []string `json:"warnings,omitempty"` +} + +type multicaParticipantEnvOptions struct { + ControlAddr string + ControlToken string + ControlTokenFile string + HarnessBin string + ManagedRuntime string + ManagedCommand string + ManagedWorkspace string + ManagedTimeout time.Duration +} + +type multicaParticipantRegisterOptions struct { + RegistryPath string + WorkspaceID string + RuntimeProfileID string + RuntimeID string + AgentID string + AgentName string + Principal string + Role string + CreateIfMissing bool + RestoreArchived bool + SyncAgent bool + Env multicaParticipantEnvOptions +} + func runMulticaProbe(cmd *cobra.Command, args []string) error { ctx := multicaCommandContext(cmd) cli := multicaCLI() @@ -242,6 +309,52 @@ func runMulticaProjectComment(cmd *cobra.Command, args []string) error { return nil } +func runMulticaParticipantRegister(cmd *cobra.Command, args []string) error { + ctx := multicaCommandContext(cmd) + if strings.TrimSpace(multicaWorkspaceID) == "" { + return fmt.Errorf("--multica-workspace-id is required for participant register") + } + if strings.TrimSpace(multicaParticipantControlToken) != "" && strings.TrimSpace(multicaParticipantControlTokenFile) != "" { + return fmt.Errorf("--mnemon-control-token and --mnemon-control-token-file are mutually exclusive") + } + cli := multicaCLI() + if err := ensureMulticaReady(ctx, cli); err != nil { + return err + } + report, err := registerMulticaParticipant(ctx, cli, multicaParticipantRegisterOptions{ + RegistryPath: driver.MulticaRegistryPath(multicaParticipantProjectRoot, multicaParticipantRegistry), + WorkspaceID: strings.TrimSpace(multicaWorkspaceID), + RuntimeID: strings.TrimSpace(multicaParticipantRuntimeID), + AgentID: strings.TrimSpace(multicaParticipantAgentID), + AgentName: strings.TrimSpace(multicaParticipantAgentName), + Principal: strings.TrimSpace(multicaParticipantPrincipal), + Role: strings.TrimSpace(multicaParticipantRole), + CreateIfMissing: multicaParticipantCreateIfMissing, + SyncAgent: multicaParticipantSyncAgent, + Env: multicaParticipantEnvOptionsFromFlags(), + }) + if err != nil { + return err + } + if strings.TrimSpace(report.WorkspaceID) == "" { + report.Warnings = append(report.Warnings, "registry workspace id is empty; pass --multica-workspace-id to make runtime env explicit") + } + if strings.TrimSpace(multicaParticipantControlAddr) == "" { + report.Warnings = append(report.Warnings, "participant env does not include MNEMON_CONTROL_ADDR; runtime turns can complete but will skip local mnemond ingest") + } + if multicaJSON { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(report) + } + fmt.Fprintf(cmd.OutOrStdout(), "registered Multica agent %s as Mnemon participant %s\n", report.Participant.AgentID, report.Participant.Principal) + fmt.Fprintf(cmd.OutOrStdout(), "registry: %s\n", report.RegistryPath) + if report.UpdatedEnv { + fmt.Fprintf(cmd.OutOrStdout(), "agent env updated\n") + } + return nil +} + func runMulticaProvision(cmd *cobra.Command, args []string) error { ctx := multicaCommandContext(cmd) workspaceID := strings.TrimSpace(multicaWorkspaceID) @@ -288,43 +401,34 @@ func runMulticaProvision(cmd *cobra.Command, args []string) error { } report.Runtime = runtime participants := driver.DefaultMulticaParticipantRecords(multicaProvisionAgentPrefix) - agents, err := cli.ListAgents(ctx, true) - if err != nil { - return err - } for _, participant := range participants { - agent, action, err := ensureMulticaParticipantAgent(ctx, cli, agents, runtime.ID, participant) + registered, err := registerMulticaParticipant(ctx, cli, multicaParticipantRegisterOptions{ + RegistryPath: registryPath, + WorkspaceID: workspaceID, + RuntimeProfileID: profile.ID, + RuntimeID: runtime.ID, + AgentName: participant.AgentName, + Principal: participant.Principal, + Role: participant.Role, + CreateIfMissing: true, + RestoreArchived: true, + SyncAgent: true, + Env: multicaProvisionEnvOptionsFromFlags(), + }) if err != nil { return err } - participant.AgentID = agent.ID - report.Participants = driver.UpsertMulticaParticipantRecord(report.Participants, participant) - switch action { + report.Participants = driver.UpsertMulticaParticipantRecord(report.Participants, registered.Participant) + switch registered.AgentAction { case "created": - report.CreatedAgents = append(report.CreatedAgents, agent.ID) + report.CreatedAgents = append(report.CreatedAgents, registered.Participant.AgentID) case "restored": - report.RestoredAgents = append(report.RestoredAgents, agent.ID) + report.RestoredAgents = append(report.RestoredAgents, registered.Participant.AgentID) case "updated": - report.UpdatedAgents = append(report.UpdatedAgents, agent.ID) + report.UpdatedAgents = append(report.UpdatedAgents, registered.Participant.AgentID) } - agents = upsertMulticaAgent(agents, agent) - } - if err := driver.SaveMulticaRegistry(registryPath, driver.MulticaRegistry{ - SchemaVersion: 1, - WorkspaceID: workspaceID, - RuntimeProfileID: profile.ID, - RuntimeID: runtime.ID, - Participants: report.Participants, - }); err != nil { - return err - } - for _, participant := range report.Participants { - updated, err := ensureMulticaParticipantEnv(ctx, cli, participant, registryPath, workspaceID) - if err != nil { - return err - } - if updated { - report.UpdatedEnv = append(report.UpdatedEnv, participant.AgentID) + if registered.UpdatedEnv { + report.UpdatedEnv = append(report.UpdatedEnv, registered.Participant.AgentID) } } if !multicaProvisionRestartDaemon && strings.TrimSpace(multicaProvisionRuntimePath) != "" && created { @@ -343,6 +447,165 @@ func runMulticaProvision(cmd *cobra.Command, args []string) error { return nil } +func registerMulticaParticipant(ctx context.Context, cli driver.MulticaCLI, opts multicaParticipantRegisterOptions) (multicaParticipantRegisterReport, error) { + opts.RegistryPath = strings.TrimSpace(opts.RegistryPath) + if opts.RegistryPath == "" { + opts.RegistryPath = driver.MulticaRegistryPath(".", "") + } + opts.Principal = strings.TrimSpace(opts.Principal) + if opts.Principal == "" { + return multicaParticipantRegisterReport{}, fmt.Errorf("--principal is required") + } + if strings.TrimSpace(opts.AgentID) == "" && strings.TrimSpace(opts.AgentName) == "" { + return multicaParticipantRegisterReport{}, fmt.Errorf("one of --agent-id or --agent-name is required") + } + agent, action, err := resolveMulticaParticipantAgent(ctx, cli, opts) + if err != nil { + return multicaParticipantRegisterReport{}, err + } + participant := driver.MulticaParticipantRecord{ + Principal: opts.Principal, + AgentName: firstNonEmptyMultica(strings.TrimSpace(opts.AgentName), agent.Name), + AgentID: firstNonEmptyMultica(strings.TrimSpace(opts.AgentID), agent.ID), + Role: firstNonEmptyMultica(strings.TrimSpace(opts.Role), multicaRoleFromPrincipal(opts.Principal)), + } + if strings.TrimSpace(participant.AgentID) == "" { + return multicaParticipantRegisterReport{}, fmt.Errorf("participant %s has no Multica agent id", participant.Principal) + } + if strings.TrimSpace(participant.AgentName) == "" { + participant.AgentName = agent.Name + } + reg, _, err := driver.LoadMulticaRegistry(opts.RegistryPath) + if err != nil { + return multicaParticipantRegisterReport{}, err + } + reg.SchemaVersion = 1 + reg.WorkspaceID = firstNonEmptyMultica(strings.TrimSpace(opts.WorkspaceID), reg.WorkspaceID, agent.WorkspaceID) + reg.RuntimeProfileID = firstNonEmptyMultica(strings.TrimSpace(opts.RuntimeProfileID), reg.RuntimeProfileID) + reg.RuntimeID = firstNonEmptyMultica(strings.TrimSpace(opts.RuntimeID), reg.RuntimeID, agent.RuntimeID) + if strings.TrimSpace(reg.WorkspaceID) == "" { + return multicaParticipantRegisterReport{}, fmt.Errorf("--multica-workspace-id is required when the agent record does not expose workspace_id") + } + reg.Participants = driver.UpsertMulticaParticipantRecord(reg.Participants, participant) + if err := driver.SaveMulticaRegistry(opts.RegistryPath, reg); err != nil { + return multicaParticipantRegisterReport{}, err + } + updatedEnv, err := ensureMulticaParticipantEnv(ctx, cli, participant, opts.RegistryPath, reg.WorkspaceID, opts.Env) + if err != nil { + return multicaParticipantRegisterReport{}, err + } + return multicaParticipantRegisterReport{ + WorkspaceID: reg.WorkspaceID, + RegistryPath: opts.RegistryPath, + Participant: participant, + Agent: agent, + AgentAction: action, + UpdatedEnv: updatedEnv, + Registry: reg, + }, nil +} + +func resolveMulticaParticipantAgent(ctx context.Context, cli driver.MulticaCLI, opts multicaParticipantRegisterOptions) (driver.MulticaAgent, string, error) { + agents, err := cli.ListAgents(ctx, true) + if err != nil { + return driver.MulticaAgent{}, "", err + } + agentID := strings.TrimSpace(opts.AgentID) + agentName := strings.TrimSpace(opts.AgentName) + var matched driver.MulticaAgent + found := false + for _, agent := range agents { + if agentID != "" && agent.ID == agentID { + matched, found = agent, true + break + } + if agentID == "" && agentName != "" && agent.Name == agentName { + matched, found = agent, true + break + } + } + if found { + if agentName != "" && matched.Name != "" && matched.Name != agentName { + return driver.MulticaAgent{}, "", fmt.Errorf("agent id %s belongs to %q, not --agent-name %q", agentID, matched.Name, agentName) + } + return syncResolvedMulticaParticipantAgent(ctx, cli, matched, opts, "reused") + } + if agentID != "" { + return driver.MulticaAgent{}, "", fmt.Errorf("Multica agent id %s was not found", agentID) + } + if !opts.CreateIfMissing { + return driver.MulticaAgent{}, "", fmt.Errorf("Multica agent %q was not found; create it in Multica UI first or pass --create-if-missing", agentName) + } + if strings.TrimSpace(opts.RuntimeID) == "" { + return driver.MulticaAgent{}, "", fmt.Errorf("--runtime-id is required with --create-if-missing") + } + participant := driver.MulticaParticipantRecord{ + Principal: strings.TrimSpace(opts.Principal), + AgentName: agentName, + Role: firstNonEmptyMultica(strings.TrimSpace(opts.Role), multicaRoleFromPrincipal(opts.Principal)), + } + req := multicaParticipantAgentRequest(participant, strings.TrimSpace(opts.RuntimeID)) + created, err := cli.CreateAgent(ctx, req) + if err != nil { + return driver.MulticaAgent{}, "", err + } + return created, "created", nil +} + +func syncResolvedMulticaParticipantAgent(ctx context.Context, cli driver.MulticaCLI, agent driver.MulticaAgent, opts multicaParticipantRegisterOptions, action string) (driver.MulticaAgent, string, error) { + if strings.TrimSpace(agent.ArchivedAt) != "" { + if !opts.RestoreArchived { + return driver.MulticaAgent{}, "", fmt.Errorf("Multica agent %s is archived", agent.ID) + } + restored, err := cli.RestoreAgent(ctx, agent.ID) + if err != nil { + return driver.MulticaAgent{}, "", err + } + agent = restored + action = "restored" + } + if !opts.SyncAgent { + return agent, action, nil + } + runtimeID := firstNonEmptyMultica(strings.TrimSpace(opts.RuntimeID), agent.RuntimeID) + participant := driver.MulticaParticipantRecord{ + Principal: strings.TrimSpace(opts.Principal), + AgentName: firstNonEmptyMultica(strings.TrimSpace(opts.AgentName), agent.Name), + Role: firstNonEmptyMultica(strings.TrimSpace(opts.Role), multicaRoleFromPrincipal(opts.Principal)), + } + req := multicaParticipantAgentRequest(participant, runtimeID) + if !multicaAgentNeedsSync(agent, req) { + return agent, action, nil + } + updated, err := cli.UpdateAgent(ctx, agent.ID, req) + if err != nil { + return driver.MulticaAgent{}, "", err + } + if action != "restored" { + action = "updated" + } + return updated, action, nil +} + +func multicaParticipantAgentRequest(participant driver.MulticaParticipantRecord, runtimeID string) driver.MulticaCreateAgentRequest { + return driver.MulticaCreateAgentRequest{ + Name: participant.AgentName, + Description: "Mnemon teamwork participant: " + participant.Principal, + Instructions: multicaParticipantInstructions(participant), + RuntimeID: runtimeID, + Visibility: "private", + MaxConcurrentTasks: 1, + } +} + +func multicaAgentNeedsSync(agent driver.MulticaAgent, req driver.MulticaCreateAgentRequest) bool { + return (strings.TrimSpace(req.Name) != "" && agent.Name != req.Name) || + (strings.TrimSpace(req.RuntimeID) != "" && agent.RuntimeID != req.RuntimeID) || + (strings.TrimSpace(req.Description) != "" && agent.Description != req.Description) || + (strings.TrimSpace(req.Instructions) != "" && agent.Instructions != req.Instructions) || + (strings.TrimSpace(req.Visibility) != "" && agent.Visibility != req.Visibility) +} + func ensureMulticaReady(ctx context.Context, cli driver.MulticaCLI) error { if _, err := cli.Version(ctx); err != nil { return err @@ -406,47 +669,7 @@ func waitMulticaRuntimeForProfile(ctx context.Context, cli driver.MulticaCLI, pr } } -func ensureMulticaParticipantAgent(ctx context.Context, cli driver.MulticaCLI, agents []driver.MulticaAgent, runtimeID string, participant driver.MulticaParticipantRecord) (driver.MulticaAgent, string, error) { - req := driver.MulticaCreateAgentRequest{ - Name: participant.AgentName, - Description: "Mnemon teamwork participant: " + participant.Principal, - Instructions: multicaParticipantInstructions(participant), - RuntimeID: runtimeID, - Visibility: "private", - MaxConcurrentTasks: 1, - } - for _, agent := range agents { - if agent.Name != participant.AgentName { - continue - } - if strings.TrimSpace(agent.ArchivedAt) != "" { - restored, err := cli.RestoreAgent(ctx, agent.ID) - if err != nil { - return driver.MulticaAgent{}, "", err - } - updated, err := cli.UpdateAgent(ctx, restored.ID, req) - if err != nil { - return driver.MulticaAgent{}, "", err - } - return updated, "restored", nil - } - if agent.RuntimeID != runtimeID || agent.Description != req.Description || agent.Instructions != req.Instructions { - updated, err := cli.UpdateAgent(ctx, agent.ID, req) - if err != nil { - return driver.MulticaAgent{}, "", err - } - return updated, "updated", nil - } - return agent, "reused", nil - } - agent, err := cli.CreateAgent(ctx, req) - if err != nil { - return driver.MulticaAgent{}, "", err - } - return agent, "created", nil -} - -func ensureMulticaParticipantEnv(ctx context.Context, cli driver.MulticaCLI, participant driver.MulticaParticipantRecord, registryPath, workspaceID string) (bool, error) { +func ensureMulticaParticipantEnv(ctx context.Context, cli driver.MulticaCLI, participant driver.MulticaParticipantRecord, registryPath, workspaceID string, opts multicaParticipantEnvOptions) (bool, error) { if strings.TrimSpace(participant.AgentID) == "" { return false, fmt.Errorf("participant %s is missing a Multica agent id", participant.Principal) } @@ -455,7 +678,7 @@ func ensureMulticaParticipantEnv(ctx context.Context, cli driver.MulticaCLI, par 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) { + for key, value := range multicaParticipantRuntimeEnv(cli, participant, registryPath, workspaceID, opts) { merged[key] = value } if sameStringMap(existing, merged) { @@ -467,7 +690,7 @@ func ensureMulticaParticipantEnv(ctx context.Context, cli driver.MulticaCLI, par return true, nil } -func multicaParticipantRuntimeEnv(cli driver.MulticaCLI, participant driver.MulticaParticipantRecord, registryPath, workspaceID string) map[string]string { +func multicaParticipantRuntimeEnv(cli driver.MulticaCLI, participant driver.MulticaParticipantRecord, registryPath, workspaceID string, opts multicaParticipantEnvOptions) map[string]string { env := map[string]string{} addStringEnv(env, "MNEMON_HUB_BACKEND", driver.MulticaHubBackend) addStringEnv(env, "MNEMON_MULTICA_REGISTRY", registryPath) @@ -475,20 +698,46 @@ func multicaParticipantRuntimeEnv(cli driver.MulticaCLI, participant driver.Mult addStringEnv(env, "MNEMON_MULTICA_BIN", multicaCLICommand(cli)) addStringEnv(env, "MNEMON_MULTICA_PROFILE", multicaProfile) addStringEnv(env, "MNEMON_MULTICA_SERVER_URL", multicaServerURL) - addStringEnv(env, "MNEMON_CONTROL_ADDR", multicaProvisionControlAddr) - addStringEnv(env, "MNEMON_CONTROL_TOKEN", multicaProvisionControlToken) - addStringEnv(env, "MNEMON_CONTROL_TOKEN_FILE", multicaProvisionControlTokenFile) + 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_PRINCIPAL", participant.Principal) - addStringEnv(env, "MNEMON_HARNESS_BIN", multicaProvisionHarnessBin) - addStringEnv(env, "MNEMON_MANAGED_RUNTIME", multicaProvisionManagedRuntime) - addStringEnv(env, "MNEMON_MANAGED_COMMAND", multicaProvisionManagedCommand) - addStringEnv(env, "MNEMON_MANAGED_WORKSPACE", multicaProvisionManagedWorkspace) - if multicaProvisionManagedTimeout > 0 { - env["MNEMON_MANAGED_TURN_TIMEOUT"] = multicaProvisionManagedTimeout.String() + 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) + if opts.ManagedTimeout > 0 { + env["MNEMON_MANAGED_TURN_TIMEOUT"] = opts.ManagedTimeout.String() } return env } +func multicaProvisionEnvOptionsFromFlags() multicaParticipantEnvOptions { + return multicaParticipantEnvOptions{ + ControlAddr: multicaProvisionControlAddr, + ControlToken: multicaProvisionControlToken, + ControlTokenFile: multicaProvisionControlTokenFile, + HarnessBin: multicaProvisionHarnessBin, + ManagedRuntime: multicaProvisionManagedRuntime, + ManagedCommand: multicaProvisionManagedCommand, + ManagedWorkspace: multicaProvisionManagedWorkspace, + ManagedTimeout: multicaProvisionManagedTimeout, + } +} + +func multicaParticipantEnvOptionsFromFlags() multicaParticipantEnvOptions { + return multicaParticipantEnvOptions{ + ControlAddr: multicaParticipantControlAddr, + ControlToken: multicaParticipantControlToken, + ControlTokenFile: multicaParticipantControlTokenFile, + HarnessBin: multicaParticipantHarnessBin, + ManagedRuntime: multicaParticipantManagedRuntime, + ManagedCommand: multicaParticipantManagedCommand, + ManagedWorkspace: multicaParticipantManagedWorkspace, + ManagedTimeout: multicaParticipantManagedTimeout, + } +} + func addStringEnv(env map[string]string, key, value string) { value = strings.TrimSpace(value) if value != "" { @@ -524,14 +773,24 @@ func multicaParticipantInstructions(participant driver.MulticaParticipantRecord) }, "\n") } -func upsertMulticaAgent(agents []driver.MulticaAgent, next driver.MulticaAgent) []driver.MulticaAgent { - for i := range agents { - if agents[i].ID == next.ID { - agents[i] = next - return agents +func multicaRoleFromPrincipal(principal string) string { + principal = strings.TrimSpace(principal) + if principal == "" { + return "" + } + if before, _, ok := strings.Cut(principal, "@"); ok { + return strings.TrimSpace(before) + } + return principal +} + +func firstNonEmptyMultica(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) } } - return append(agents, next) + return "" } func loadMulticaIssue(ctx context.Context, stdin io.Reader) (driver.MulticaIssue, error) { @@ -677,7 +936,26 @@ func init() { 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") - multicaCmd.AddCommand(multicaProbeCmd, multicaProvisionCmd, multicaImportIssueCmd, multicaProjectCommentCmd) + multicaParticipantRegisterCmd.Flags().StringVar(&multicaParticipantRegistry, "registry", "", "Multica registry path") + multicaParticipantRegisterCmd.Flags().StringVar(&multicaParticipantProjectRoot, "project-root", ".", "project root for the default registry path") + multicaParticipantRegisterCmd.Flags().StringVar(&multicaParticipantAgentID, "agent-id", "", "existing Multica agent id to register") + multicaParticipantRegisterCmd.Flags().StringVar(&multicaParticipantAgentName, "agent-name", "", "Multica agent name to register or create") + multicaParticipantRegisterCmd.Flags().StringVar(&multicaParticipantPrincipal, "principal", envDefault("MNEMON_CONTROL_PRINCIPAL", ""), "Mnemon principal represented by the Multica agent") + multicaParticipantRegisterCmd.Flags().StringVar(&multicaParticipantRole, "role", "", "Mnemon team role; defaults to the principal prefix") + multicaParticipantRegisterCmd.Flags().StringVar(&multicaParticipantRuntimeID, "runtime-id", "", "Multica runtime id used when creating or syncing the agent") + multicaParticipantRegisterCmd.Flags().BoolVar(&multicaParticipantCreateIfMissing, "create-if-missing", false, "create --agent-name when no existing Multica agent matches") + multicaParticipantRegisterCmd.Flags().BoolVar(&multicaParticipantSyncAgent, "sync-agent", false, "update Multica agent runtime, description, instructions, and visibility to Mnemon defaults") + multicaParticipantRegisterCmd.Flags().StringVar(&multicaParticipantControlAddr, "mnemon-control-addr", envDefault("MNEMON_CONTROL_ADDR", ""), "Local Mnemon URL injected into participant runtime env") + multicaParticipantRegisterCmd.Flags().StringVar(&multicaParticipantControlToken, "mnemon-control-token", envDefault("MNEMON_CONTROL_TOKEN", ""), "Local Mnemon bearer token injected into participant runtime env") + multicaParticipantRegisterCmd.Flags().StringVar(&multicaParticipantControlTokenFile, "mnemon-control-token-file", envDefault("MNEMON_CONTROL_TOKEN_FILE", ""), "Local Mnemon bearer token file injected into participant runtime env") + multicaParticipantRegisterCmd.Flags().StringVar(&multicaParticipantHarnessBin, "harness-bin", envDefault("MNEMON_HARNESS_BIN", ""), "mnemon-harness executable injected into participant runtime env") + multicaParticipantRegisterCmd.Flags().StringVar(&multicaParticipantManagedRuntime, "managed-runtime", envDefault("MNEMON_MANAGED_RUNTIME", ""), "managed agent runtime injected into participant env (noop or codex-appserver)") + multicaParticipantRegisterCmd.Flags().StringVar(&multicaParticipantManagedCommand, "managed-command", envDefault("MNEMON_MANAGED_COMMAND", ""), "managed runtime command injected into participant env") + multicaParticipantRegisterCmd.Flags().StringVar(&multicaParticipantManagedWorkspace, "managed-workspace", envDefault("MNEMON_MANAGED_WORKSPACE", ""), "managed runtime workspace injected into participant env") + multicaParticipantRegisterCmd.Flags().DurationVar(&multicaParticipantManagedTimeout, "managed-turn-timeout", 0, "managed runtime turn timeout injected into participant env") + + multicaParticipantCmd.AddCommand(multicaParticipantRegisterCmd) + multicaCmd.AddCommand(multicaProbeCmd, multicaParticipantCmd, multicaProvisionCmd, multicaImportIssueCmd, multicaProjectCommentCmd) multicaCmd.GroupID = groupSpine rootCmd.AddCommand(multicaCmd) } diff --git a/harness/cmd/mnemon-harness/multica_test.go b/harness/cmd/mnemon-harness/multica_test.go index e4daf9b2..600031dd 100644 --- a/harness/cmd/mnemon-harness/multica_test.go +++ b/harness/cmd/mnemon-harness/multica_test.go @@ -85,6 +85,95 @@ func TestMulticaImportIssueWritesObservedDraftToLocalIngest(t *testing.T) { } } +func TestMulticaParticipantRegisterAdoptsExistingUIAgent(t *testing.T) { + restoreMulticaFlags(t) + + tmp := t.TempDir() + bin := filepath.Join(tmp, "multica") + envStdinPath := filepath.Join(tmp, "agent-env.jsonl") + argsPath := filepath.Join(tmp, "args.log") + script := `#!/usr/bin/env sh +printf '%s\n' "$*" >> "$MULTICA_ARGS_PATH" +case "$*" in + *"version --output json"*) printf '{"version":"v0.3.31","commit":"test","date":"now","os":"darwin","arch":"arm64","go":"go"}\n' ;; + *"auth status"*) printf 'Server: https://api.multica.ai\nUser: Test\n' >&2 ;; + *"daemon status --output json"*) printf '{"status":"running"}\n' ;; + *"agent list"*) printf '[{"id":"agent-ui-reviewer","name":"ui-reviewer","description":"Created in UI","runtime_id":"runtime-ui","status":"idle","visibility":"workspace","workspace_id":"ws-ui"}]\n' ;; + *"agent env get"*) printf '{}\n' ;; + *"agent env set"*) cat >> "$MULTICA_ENV_STDIN_PATH"; printf '\n' >> "$MULTICA_ENV_STDIN_PATH"; printf '{}\n' ;; + *"agent create"*|*"agent update"*|*"agent restore"*) printf 'unexpected mutation: %s\n' "$*" >&2; exit 42 ;; + *) printf '{}\n' ;; +esac +` + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + registryPath := filepath.Join(tmp, "registry.json") + multicaBin = bin + multicaProfile = "desktop-api.multica.ai" + multicaWorkspaceID = "ws-ui" + multicaParticipantRegistry = registryPath + multicaParticipantAgentName = "ui-reviewer" + multicaParticipantPrincipal = "reviewer@team" + multicaParticipantRole = "reviewer" + multicaParticipantControlAddr = "http://127.0.0.1:8791" + multicaParticipantHarnessBin = "/abs/mnemon-harness" + multicaParticipantManagedRuntime = "codex-appserver" + multicaParticipantManagedCommand = "codex" + multicaParticipantManagedWorkspace = tmp + multicaJSON = true + t.Setenv("MULTICA_ARGS_PATH", argsPath) + t.Setenv("MULTICA_ENV_STDIN_PATH", envStdinPath) + + var out bytes.Buffer + multicaParticipantRegisterCmd.SetOut(&out) + t.Cleanup(func() { + multicaParticipantRegisterCmd.SetOut(os.Stdout) + }) + if err := runMulticaParticipantRegister(multicaParticipantRegisterCmd, nil); err != nil { + t.Fatalf("participant register: %v", err) + } + var report multicaParticipantRegisterReport + if err := json.Unmarshal(out.Bytes(), &report); err != nil { + t.Fatalf("register output must be JSON: %v\n%s", err, out.String()) + } + if report.AgentAction != "reused" || !report.UpdatedEnv { + t.Fatalf("register report mismatch: %+v", report) + } + if report.Participant.Principal != "reviewer@team" || report.Participant.AgentID != "agent-ui-reviewer" || report.Participant.AgentName != "ui-reviewer" { + t.Fatalf("participant mismatch: %+v", report.Participant) + } + reg, ok, err := driver.LoadMulticaRegistry(registryPath) + if err != nil { + t.Fatal(err) + } + if !ok || reg.WorkspaceID != "ws-ui" || reg.RuntimeID != "runtime-ui" || len(reg.Participants) != 1 { + t.Fatalf("registry mismatch: ok=%v reg=%+v", ok, reg) + } + envStdin, err := os.ReadFile(envStdinPath) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `"MNEMON_CONTROL_PRINCIPAL":"reviewer@team"`, + `"MNEMON_HUB_BACKEND":"multica"`, + `"MNEMON_MULTICA_REGISTRY":"` + registryPath + `"`, + `"MNEMON_MANAGED_RUNTIME":"codex-appserver"`, + `"MNEMON_MANAGED_COMMAND":"codex"`, + } { + if !strings.Contains(string(envStdin), want) { + t.Fatalf("agent env stdin missing %s:\n%s", want, envStdin) + } + } + argsLog, err := os.ReadFile(argsPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(argsLog), "agent create") || strings.Contains(string(argsLog), "agent update") { + t.Fatalf("register must adopt without creating/updating the UI agent:\n%s", argsLog) + } +} + func TestMulticaProvisionCreatesParticipantRegistry(t *testing.T) { restoreMulticaFlags(t) @@ -223,6 +312,23 @@ func restoreMulticaFlags(t *testing.T) { oldProvisionManagedCommand := multicaProvisionManagedCommand oldProvisionManagedWorkspace := multicaProvisionManagedWorkspace oldProvisionManagedTimeout := multicaProvisionManagedTimeout + oldParticipantRegistry := multicaParticipantRegistry + oldParticipantProjectRoot := multicaParticipantProjectRoot + oldParticipantAgentID := multicaParticipantAgentID + oldParticipantAgentName := multicaParticipantAgentName + oldParticipantPrincipal := multicaParticipantPrincipal + oldParticipantRole := multicaParticipantRole + oldParticipantRuntimeID := multicaParticipantRuntimeID + oldParticipantCreateIfMissing := multicaParticipantCreateIfMissing + oldParticipantSyncAgent := multicaParticipantSyncAgent + oldParticipantControlAddr := multicaParticipantControlAddr + oldParticipantControlToken := multicaParticipantControlToken + oldParticipantControlTokenFile := multicaParticipantControlTokenFile + oldParticipantHarnessBin := multicaParticipantHarnessBin + oldParticipantManagedRuntime := multicaParticipantManagedRuntime + oldParticipantManagedCommand := multicaParticipantManagedCommand + oldParticipantManagedWorkspace := multicaParticipantManagedWorkspace + oldParticipantManagedTimeout := multicaParticipantManagedTimeout t.Cleanup(func() { multicaBin = oldBin multicaProfile = oldProfile @@ -263,6 +369,23 @@ func restoreMulticaFlags(t *testing.T) { multicaProvisionManagedCommand = oldProvisionManagedCommand multicaProvisionManagedWorkspace = oldProvisionManagedWorkspace multicaProvisionManagedTimeout = oldProvisionManagedTimeout + multicaParticipantRegistry = oldParticipantRegistry + multicaParticipantProjectRoot = oldParticipantProjectRoot + multicaParticipantAgentID = oldParticipantAgentID + multicaParticipantAgentName = oldParticipantAgentName + multicaParticipantPrincipal = oldParticipantPrincipal + multicaParticipantRole = oldParticipantRole + multicaParticipantRuntimeID = oldParticipantRuntimeID + multicaParticipantCreateIfMissing = oldParticipantCreateIfMissing + multicaParticipantSyncAgent = oldParticipantSyncAgent + multicaParticipantControlAddr = oldParticipantControlAddr + multicaParticipantControlToken = oldParticipantControlToken + multicaParticipantControlTokenFile = oldParticipantControlTokenFile + multicaParticipantHarnessBin = oldParticipantHarnessBin + multicaParticipantManagedRuntime = oldParticipantManagedRuntime + multicaParticipantManagedCommand = oldParticipantManagedCommand + multicaParticipantManagedWorkspace = oldParticipantManagedWorkspace + multicaParticipantManagedTimeout = oldParticipantManagedTimeout }) multicaBin = "" multicaProfile = "" @@ -301,4 +424,21 @@ func restoreMulticaFlags(t *testing.T) { multicaProvisionManagedCommand = "" multicaProvisionManagedWorkspace = "" multicaProvisionManagedTimeout = 0 + multicaParticipantRegistry = "" + multicaParticipantProjectRoot = "." + multicaParticipantAgentID = "" + multicaParticipantAgentName = "" + multicaParticipantPrincipal = "" + multicaParticipantRole = "" + multicaParticipantRuntimeID = "" + multicaParticipantCreateIfMissing = false + multicaParticipantSyncAgent = false + multicaParticipantControlAddr = "" + multicaParticipantControlToken = "" + multicaParticipantControlTokenFile = "" + multicaParticipantHarnessBin = "" + multicaParticipantManagedRuntime = "" + multicaParticipantManagedCommand = "" + multicaParticipantManagedWorkspace = "" + multicaParticipantManagedTimeout = 0 }