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..87a753ab --- /dev/null +++ b/harness/cmd/mnemon-acceptance/acceptance_multica_runtime.go @@ -0,0 +1,764 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "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 + acceptanceMulticaRequireHubFlow bool + acceptanceMulticaMinParticipants int + acceptanceMulticaMinActiveAgents int +) + +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, + RequireHubFlow: acceptanceMulticaRequireHubFlow, + MinParticipants: acceptanceMulticaMinParticipants, + MinActiveAgents: acceptanceMulticaMinActiveAgents, + 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") + 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) +} + +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 + 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"` + 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 { + 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 + } + 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")) + } + 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 + 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) + } + 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 = 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, + 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 + report.MessageTypes = multicaRunMessageTypeCounts(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 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) + } + } + 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 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))) + 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] + 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.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 +} + +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 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, 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{} + 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 + } + metaByIssue[child.ID] = meta + } + 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) { + 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 + } + 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 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 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 { + 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}) +} + +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..6cd7a8b0 --- /dev/null +++ b/harness/cmd/mnemon-acceptance/acceptance_multica_runtime_test.go @@ -0,0 +1,191 @@ +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 report=%+v", err, report) + } + 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) + } +} + +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 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","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":"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":"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":"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 +` + 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 || report.FinalRoot.Status != "done" { + 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) + } + } +} 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) diff --git a/harness/cmd/mnemon-harness/multica.go b/harness/cmd/mnemon-harness/multica.go new file mode 100644 index 00000000..a55adcaf --- /dev/null +++ b/harness/cmd/mnemon-harness/multica.go @@ -0,0 +1,961 @@ +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 + + multicaProvisionRegistry string + multicaProvisionProjectRoot string + multicaProvisionProfileName string + multicaProvisionRuntimeCommand string + multicaProvisionRuntimePath string + multicaProvisionAgentPrefix string + multicaProvisionRestartDaemon bool + multicaProvisionWait time.Duration + multicaProvisionControlAddr string + multicaProvisionControlToken string + multicaProvisionControlTokenFile string + multicaProvisionHarnessBin string + multicaProvisionManagedRuntime string + 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{ + 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, +} + +var multicaProvisionCmd = &cobra.Command{ + 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 { + 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"` +} + +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"` + UpdatedEnv []string `json:"updated_env,omitempty"` + 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() + report := multicaProbeReport{ + Command: multicaCLICommand(cli), + Profile: strings.TrimSpace(multicaProfile), + ServerURL: strings.TrimSpace(multicaServerURL), + WorkspaceID: strings.TrimSpace(multicaWorkspaceID), + } + if version, err := cli.Version(ctx); err != nil { + report.VersionError = err.Error() + } else { + report.Version = &version + } + if status, err := cli.AuthStatus(ctx); err != nil { + report.AuthStatus = status + report.AuthError = err.Error() + } else { + report.AuthStatus = status + } + if daemon, err := cli.DaemonStatus(ctx); 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 { + ctx := multicaCommandContext(cmd) + issue, err := loadMulticaIssue(ctx, 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(multicaCommandContext(cmd), 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 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) + 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 + } + profileName := strings.TrimSpace(multicaProvisionProfileName) + if profileName == "" { + profileName = "mnemon-runtime" + } + runtimeCommand := strings.TrimSpace(multicaProvisionRuntimeCommand) + if runtimeCommand == "" { + runtimeCommand = "mnemon-multica-runtime" + } + registryPath := driver.MulticaRegistryPath(multicaProvisionProjectRoot, multicaProvisionRegistry) + report := multicaProvisionReport{WorkspaceID: workspaceID, RegistryPath: registryPath} + 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) + for _, participant := range participants { + 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 + } + report.Participants = driver.UpsertMulticaParticipantRecord(report.Participants, registered.Participant) + switch registered.AgentAction { + case "created": + report.CreatedAgents = append(report.CreatedAgents, registered.Participant.AgentID) + case "restored": + report.RestoredAgents = append(report.RestoredAgents, registered.Participant.AgentID) + case "updated": + report.UpdatedAgents = append(report.UpdatedAgents, registered.Participant.AgentID) + } + if registered.UpdatedEnv { + report.UpdatedEnv = append(report.UpdatedEnv, registered.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("", " ") + 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 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 + } + 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 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) + } + 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, opts) { + 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, opts multicaParticipantEnvOptions) 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)) + addStringEnv(env, "MNEMON_MULTICA_PROFILE", multicaProfile) + addStringEnv(env, "MNEMON_MULTICA_SERVER_URL", multicaServerURL) + addStringEnv(env, "MNEMON_CONTROL_ADDR", opts.ControlAddr) + addStringEnv(env, "MNEMON_CONTROL_TOKEN", opts.ControlToken) + addStringEnv(env, "MNEMON_CONTROL_TOKEN_FILE", opts.ControlTokenFile) + addStringEnv(env, "MNEMON_CONTROL_PRINCIPAL", participant.Principal) + addStringEnv(env, "MNEMON_HARNESS_BIN", opts.HarnessBin) + addStringEnv(env, "MNEMON_MANAGED_RUNTIME", opts.ManagedRuntime) + addStringEnv(env, "MNEMON_MANAGED_COMMAND", opts.ManagedCommand) + addStringEnv(env, "MNEMON_MANAGED_WORKSPACE", opts.ManagedWorkspace) + 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 != "" { + 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 + ".", + "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 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 "" +} + +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 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 == "" { + 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") + + 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") + 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") + multicaProvisionCmd.Flags().DurationVar(&multicaProvisionManagedTimeout, "managed-turn-timeout", 0, "managed runtime turn timeout injected into participant env") + + 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 new file mode 100644 index 00000000..600031dd --- /dev/null +++ b/harness/cmd/mnemon-harness/multica_test.go @@ -0,0 +1,444 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "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) { + 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 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) + + 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' ;; + *"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" ;; + *"agent env get"*) printf '{}\n' ;; + *"agent env set"*) cat >> "$MULTICA_ENV_STDIN_PATH"; printf '\n' >> "$MULTICA_ENV_STDIN_PATH"; printf '{}\n' ;; + *) 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 + multicaProvisionControlAddr = "http://127.0.0.1:8787" + multicaProvisionHarnessBin = "/abs/mnemon-harness" + multicaProvisionManagedRuntime = "noop" + multicaProvisionManagedWorkspace = tmp + multicaJSON = true + t.Setenv("MULTICA_ENV_STDIN_PATH", envStdinPath) + + 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) + } + 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) + } + 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) + } + } + envStdin, err := os.ReadFile(envStdinPath) + if err != nil { + 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"`, + `"MNEMON_CONTROL_PRINCIPAL":"planner@team"`, + `"MNEMON_HARNESS_BIN":"/abs/mnemon-harness"`, + `"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) + } + } +} + +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 + oldProvisionRegistry := multicaProvisionRegistry + oldProvisionProjectRoot := multicaProvisionProjectRoot + oldProvisionProfileName := multicaProvisionProfileName + oldProvisionRuntimeCommand := multicaProvisionRuntimeCommand + oldProvisionRuntimePath := multicaProvisionRuntimePath + oldProvisionAgentPrefix := multicaProvisionAgentPrefix + oldProvisionRestartDaemon := multicaProvisionRestartDaemon + oldProvisionWait := multicaProvisionWait + oldProvisionControlAddr := multicaProvisionControlAddr + oldProvisionControlToken := multicaProvisionControlToken + oldProvisionControlTokenFile := multicaProvisionControlTokenFile + oldProvisionHarnessBin := multicaProvisionHarnessBin + oldProvisionManagedRuntime := multicaProvisionManagedRuntime + 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 + 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 + multicaProvisionRegistry = oldProvisionRegistry + multicaProvisionProjectRoot = oldProvisionProjectRoot + multicaProvisionProfileName = oldProvisionProfileName + multicaProvisionRuntimeCommand = oldProvisionRuntimeCommand + multicaProvisionRuntimePath = oldProvisionRuntimePath + multicaProvisionAgentPrefix = oldProvisionAgentPrefix + multicaProvisionRestartDaemon = oldProvisionRestartDaemon + multicaProvisionWait = oldProvisionWait + multicaProvisionControlAddr = oldProvisionControlAddr + multicaProvisionControlToken = oldProvisionControlToken + multicaProvisionControlTokenFile = oldProvisionControlTokenFile + multicaProvisionHarnessBin = oldProvisionHarnessBin + multicaProvisionManagedRuntime = oldProvisionManagedRuntime + 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 = "" + 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 + multicaProvisionRegistry = "" + multicaProvisionProjectRoot = "." + multicaProvisionProfileName = "mnemon-runtime" + multicaProvisionRuntimeCommand = "mnemon-multica-runtime" + multicaProvisionRuntimePath = "" + multicaProvisionAgentPrefix = "mnemon" + multicaProvisionRestartDaemon = false + multicaProvisionWait = 30 * 1_000_000_000 + multicaProvisionControlAddr = "" + multicaProvisionControlToken = "" + multicaProvisionControlTokenFile = "" + multicaProvisionManagedRuntime = "" + multicaProvisionManagedCommand = "" + multicaProvisionManagedWorkspace = "" + multicaProvisionManagedTimeout = 0 + multicaParticipantRegistry = "" + multicaParticipantProjectRoot = "." + multicaParticipantAgentID = "" + multicaParticipantAgentName = "" + multicaParticipantPrincipal = "" + multicaParticipantRole = "" + multicaParticipantRuntimeID = "" + multicaParticipantCreateIfMissing = false + multicaParticipantSyncAgent = false + multicaParticipantControlAddr = "" + multicaParticipantControlToken = "" + multicaParticipantControlTokenFile = "" + multicaParticipantHarnessBin = "" + multicaParticipantManagedRuntime = "" + multicaParticipantManagedCommand = "" + multicaParticipantManagedWorkspace = "" + multicaParticipantManagedTimeout = 0 +} 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..847e05e7 --- /dev/null +++ b/harness/cmd/mnemon-multica-runtime/hub_writer.go @@ -0,0 +1,811 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + "github.com/mnemon-dev/mnemon/harness/internal/driver" + eventmodel "github.com/mnemon-dev/mnemon/harness/internal/event" + "github.com/mnemon-dev/mnemon/harness/internal/mnemond/access" + pview "github.com/mnemon-dev/mnemon/harness/internal/mnemond/presentation/view" +) + +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.HubKind != driver.MulticaHubKindAssignmentMailbox) { + 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 + } + 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" + 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 { + 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) + } + 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 := findExistingMulticaAssignmentIssueInChildren(ctx, cli, existingChildren, 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 + } + meta := driver.MulticaHubMetadata{ + SchemaVersion: "1", + HubBackend: driver.MulticaHubBackend, + Kind: driver.MulticaHubKindAssignmentMailbox, + SessionID: result.SessionID, + CorrelationID: result.CorrelationID, + EventID: item.EventID, + EventType: "assignment.accepted", + EventPhase: string(eventmodel.PhaseAccepted), + AssignmentID: item.ID, + AssignmentFingerprint: fingerprint, + Principal: item.Assignee, + SourceIssueID: rootIssue.ID, + RootIssueID: result.RootIssueID, + ProjectionOwner: result.Principal, + MulticaAgentID: participant.AgentID, + ProjectedAt: s.now().UTC().Format(time.RFC3339), + } + projections = append(projections, runtimeAssignmentProjection{ + Item: item, + Participant: participant, + Source: source, + Metadata: meta, + 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 + } + 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 + } + jobs <- projection + } + 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 { + for _, progress := range hubViewItems(proj, "progress_digest") { + item := runtimeProgressItem(progress) + if item.ID == "" || item.AssignmentRef == "" { + continue + } + if !hubItemAfterRootIngest(item.IngestSeq, result) { + continue + } + source := driver.MulticaHubLedgerSource{ + SessionID: result.SessionID, + CorrelationID: result.CorrelationID, + EventID: item.EventID, + AssignmentID: item.AssignmentRef, + Principal: item.Actor, + ProjectionKind: "progress", + } + if _, ok, err := ledger.Find(driver.MulticaHubKindFeedbackCarrier, source); err != nil { + 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 status := multicaStatusForProgress(item); status != "" { + _, _ = cli.SetIssueStatus(ctx, child, status) + } + rootStatus := "in_review" + if multicaProgressCompletesAssignment(item) { + if done, _ := allMulticaAssignmentChildrenDone(ctx, cli, result.RootIssueID, result.SessionID, child); done { + rootStatus = "done" + } + } + _, _ = cli.SetIssueStatus(ctx, result.RootIssueID, rootStatus) + 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 +} + +func multicaStatusForProgress(item runtimeProgress) string { + switch strings.ToLower(strings.TrimSpace(item.FeedbackKind)) { + case "blocker": + return "blocked" + case "result": + return "done" + case "progress": + return "in_progress" + } + if strings.TrimSpace(item.Blocker) != "" { + return "blocked" + } + if strings.TrimSpace(item.Result) != "" { + return "done" + } + return "" +} + +func multicaProgressCompletesAssignment(item runtimeProgress) bool { + if strings.EqualFold(strings.TrimSpace(item.FeedbackKind), "result") { + return true + } + return strings.TrimSpace(item.Result) != "" +} + +type runtimeAssignment struct { + ID string + EventID string + IngestSeq int64 + Actor string + Assignee string + Scope string + TTL string + SignalRef string + ExpectedWork string + ExpectedFeedback string + Rationale string + ContextRefs []string + EvidenceRefs []string +} + +type runtimeProgress struct { + ID string + EventID string + IngestSeq int64 + Actor string + AssignmentRef string + Scope string + FeedbackKind string + Summary string + Result string + Blocker string + ArtifactRefs []string + EvidenceRefs []string +} + +func runtimeAssignmentItem(item map[string]any) runtimeAssignment { + id := hubItemFirstString(item, "assignment_id", "id", "declaration_id") + if id == "" { + id = hubItemString(item, "event_id") + } + return runtimeAssignment{ + ID: id, + EventID: hubItemFirstString(item, "event_id", "id", "declaration_id", "assignment_id"), + IngestSeq: hubItemInt64(item, "ingest_seq"), + Actor: hubItemString(item, "actor"), + Assignee: hubItemString(item, "assignee"), + Scope: hubItemString(item, "scope"), + TTL: hubItemString(item, "ttl"), + SignalRef: hubItemString(item, "signal_ref"), + ExpectedWork: hubItemString(item, "expected_work"), + ExpectedFeedback: hubItemString(item, "expected_feedback"), + Rationale: hubItemString(item, "rationale"), + ContextRefs: hubItemStringList(item, "context_refs"), + EvidenceRefs: hubItemStringList(item, "evidence_refs"), + } +} + +func runtimeProgressItem(item map[string]any) runtimeProgress { + id := hubItemFirstString(item, "id", "declaration_id", "event_id") + return runtimeProgress{ + ID: id, + EventID: hubItemFirstString(item, "event_id", "id", "declaration_id"), + IngestSeq: hubItemInt64(item, "ingest_seq"), + Actor: hubItemString(item, "actor"), + AssignmentRef: hubItemString(item, "assignment_ref"), + Scope: hubItemString(item, "scope"), + FeedbackKind: hubItemString(item, "feedback_kind"), + Summary: hubItemString(item, "summary"), + Result: hubItemString(item, "result"), + Blocker: hubItemString(item, "blocker"), + ArtifactRefs: hubItemStringList(item, "artifact_refs"), + EvidenceRefs: hubItemStringList(item, "evidence_refs"), + } +} + +func runtimeMulticaHubWriteEnabled(env []string) bool { + value := strings.TrimSpace(envValue(env, "MNEMON_MULTICA_HUB_WRITE")) + if value == "" { + return true + } + switch strings.ToLower(value) { + case "0", "false", "off", "disabled", "no": + return false + default: + return true + } +} + +func runtimeMulticaRegistry(env []string, cwd string) (driver.MulticaRegistry, bool, error) { + paths := []string{} + if explicit := envValue(env, "MNEMON_MULTICA_REGISTRY"); explicit != "" { + paths = append(paths, explicit) + } + if workspace := envValue(env, "MNEMON_MANAGED_WORKSPACE"); workspace != "" { + paths = append(paths, driver.MulticaRegistryPath(workspace, "")) + } + if strings.TrimSpace(cwd) != "" { + paths = append(paths, driver.MulticaRegistryPath(cwd, "")) + } + for _, path := range paths { + reg, ok, err := driver.LoadMulticaRegistry(path) + if err != nil || ok { + return reg, ok, err + } + } + return driver.MulticaRegistry{}, false, nil +} + +func runtimeMulticaHubLedgerPath(env []string, cwd string) string { + if explicit := envValue(env, "MNEMON_MULTICA_HUB_LEDGER"); explicit != "" { + return driver.MulticaHubLedgerPath("", explicit) + } + if workspace := envValue(env, "MNEMON_MANAGED_WORKSPACE"); workspace != "" { + return driver.MulticaHubLedgerPath(workspace, "") + } + return driver.MulticaHubLedgerPath(cwd, "") +} + +func assignmentMailboxDispatchMetadata(full map[string]string) map[string]string { + keys := []string{ + driver.MulticaMetadataSchemaVersion, + driver.MulticaMetadataHubBackend, + driver.MulticaMetadataKind, + driver.MulticaMetadataSessionID, + driver.MulticaMetadataCorrelationID, + driver.MulticaMetadataEventID, + driver.MulticaMetadataAssignmentID, + driver.MulticaMetadataAssignmentFingerprint, + driver.MulticaMetadataPrincipal, + driver.MulticaMetadataSourceIssueID, + driver.MulticaMetadataRootIssueID, + } + out := map[string]string{} + for _, key := range keys { + if value := strings.TrimSpace(full[key]); value != "" { + out[key] = value + } + } + return out +} + +func assignmentMailboxSupplementalMetadata(full, dispatch map[string]string) map[string]string { + out := map[string]string{} + for key, value := range full { + if _, ok := dispatch[key]; ok { + continue + } + if value = strings.TrimSpace(value); value != "" { + out[key] = value + } + } + return out +} + +func multicaParticipantForPrincipal(reg driver.MulticaRegistry, principal string) (driver.MulticaParticipantRecord, bool) { + for _, participant := range reg.Participants { + if strings.TrimSpace(participant.Principal) == strings.TrimSpace(principal) { + return participant, true + } + } + 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 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 + } + 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 + } + 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() { + 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 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 { + out[key] = value + } + return out +} diff --git a/harness/cmd/mnemon-multica-runtime/main.go b/harness/cmd/mnemon-multica-runtime/main.go new file mode 100644 index 00000000..e8c5147b --- /dev/null +++ b/harness/cmd/mnemon-multica-runtime/main.go @@ -0,0 +1,1698 @@ +package main + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" + "unicode" + + "github.com/mnemon-dev/mnemon/harness/internal/contract" + "github.com/mnemon-dev/mnemon/harness/internal/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" +) + +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 + ItemSeq int + Env []string + Now func() time.Time +} + +type runtimeProgressSink func(runtimeProgressEvent) + +type runtimeProgressEvent struct { + Text string + Command string + CWD string + Output string + ExitCode int + DurationMs int64 + Trace *driver.ManagedTurnTraceEvent +} + +type runtimeImportResult struct { + 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 + HubWriteStatus string + HubChildIssues int + HubFeedbackComments int + HubWriteErr error + 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 runtimeProbeModeEnabled(cfg.Args) { + return runRuntimeProbe(cfg) + } + 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 + } + 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, 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 emitAll(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 emitAll( + rpcMessage{ + Method: "remoteControl/status/changed", + Params: map[string]any{ + "status": "disabled", + "serverName": "mnemon-multica-runtime", + "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 emitAll(rpcMessage{ID: msg.ID, Result: map[string]any{}}) + case "turn/start": + s.TurnID = runtimeID("turn", s.now()) + input := extractRuntimeInput(msg.Params) + nowMs := s.now().UnixMilli() + userItem := runtimeUserMessage(input) + 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: runtimeItemParams(s.ThreadID, s.TurnID, userItem, "startedAtMs", nowMs), + }, + rpcMessage{ + Method: "item/completed", + Params: runtimeItemParams(s.ThreadID, s.TurnID, userItem, "completedAtMs", nowMs), + }, + ); err != nil { + return err + } + var progressErr error + progress := func(event runtimeProgressEvent) { + 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 + } + 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 + } + if err := emitRuntimeAgentMessage(emit, s.ThreadID, s.TurnID, s.nextItemID("msg"), finalAnswer, "final_answer", s.now()); err != nil { + return err + } + return emitAll( + 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 emitAll(rpcMessage{ID: msg.ID, Result: map[string]any{}}) + } +} + +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) +} + +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{ + IssueID: issueID, + 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 + } + cli := runtimeMulticaCLI(s.Env) + multicaCtx := context.Background() + emitRuntimeProgress(progress, "Loading Multica issue "+issueID+".") + issue, err := cli.GetIssue(multicaCtx, issueID) + 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 + result.Statement = issue.Description + 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(multicaCtx, cli, issue.ID) + if result.HubMetadata.IsAssignmentMailbox() { + return s.correlateAssignmentMailbox(multicaCtx, cli, issue, &result, progress) + } + externalID := "" + if taskID != "" { + externalID = "multica-task-" + taskID + } + draft, err := driver.BuildMulticaIssueTeamworkSignal(issue, driver.MulticaIssueSignalOptions{ + Scope: envDefault(s.Env, "MNEMON_MULTICA_SCOPE", "multica/teamwork"), + TTL: envDefault(s.Env, "MNEMON_MULTICA_TTL", "30m"), + 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 + } + 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(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")) + 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{ + 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) + 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].") + 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) + mergeRuntimeHubProjectionDeltas(&result, earlyHubDeltas) + emitRuntimeCommand(progress, "mnemon multica hub project --issue "+issue.ID, runtimeHubWriteProgress(result), runtimeExitCode(result.HubWriteErr)) + emitRuntimeProgress(progress, runtimeHubWriteProgress(result)) + s.projectImportComment(multicaCtx, cli, issue, draft.ExternalID, &result) + emitRuntimeCommand(progress, "multica issue comment add "+issue.ID, runtimeProjectionProgress(result), runtimeExitCode(result.ProjectionErr)) + emitRuntimeProgress(progress, runtimeProjectionProgress(result)) + return result +} + +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( + result.AssignmentID, + result.AssignmentFingerprint, + result.HubMetadata.EventID, + string(eventmodel.Subject("assignment", result.AssignmentID)), + ) + emitRuntimeCommand(progress, "mnemon multica assignment correlate --issue "+issue.ID, "Assignment mailbox correlated: "+runtimeAssignmentLabel(*result)+".", 0) + emitRuntimeProgress(progress, "Assignment mailbox correlated: "+runtimeAssignmentLabel(*result)+".") + addr := strings.TrimSpace(envValue(s.Env, "MNEMON_CONTROL_ADDR")) + 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 { + 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, 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) + 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)) + emitRuntimeProgress(progress, runtimeProjectionProgress(*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, progress runtimeProgressSink) { + 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 + } + candidate, ok := managedWakeCandidateForResult(result.Principal, resp, *result) + if !ok { + result.WakeStatus = "skipped" + result.WakeErr = fmt.Errorf("no managed wake candidate in rendered context") + return + } + client, workspace, err := runtimeManagedTurnClient(s.Env, s.CWD, runtimeName) + 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)), + 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 { + result.WakeStatus = "failed" + result.WakeErr = err + return + } + result.WakeStatus = record.Status + if result.WakeStatus == "" { + result.WakeStatus = "completed" + } +} + +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, progress runtimeProgressSink) []runtimeHubProjectionDelta { + if result == nil || client == nil || + !strings.EqualFold(result.HubBackend, driver.MulticaHubBackend) || + result.HubKind != driver.MulticaHubKindSession || + !runtimeMulticaHubWriteEnabled(s.Env) { + s.wakeManagedAgent(result, progress) + 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, progress) + 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 + for _, env := range resp.Events { + candidates := driver.ManagedWakeCandidatesFromEvents(principal, []eventmodel.EventEnvelope{env}) + if len(candidates) == 0 { + continue + } + candidates[0].RenderAuditID = resp.AuditID + candidates[0].RenderBodyDigest = resp.BodyDigest + if fallback.Principal == "" { + fallback = candidates[0] + } + if len(terms) == 0 || eventNarrativeContainsAny(env, terms) { + return candidates[0], true + } + } + if len(terms) == 0 && fallback.Principal != "" { + return fallback, true + } + return driver.ManagedWakeCandidate{}, false +} + +func managedWakeMatchTerms(result runtimeImportResult) []string { + if len(result.MatchTerms) > 0 { + return cleanRuntimeTerms(result.MatchTerms...) + } + if result.AssignmentID != "" || result.AssignmentFingerprint != "" { + return cleanRuntimeTerms(result.AssignmentID, result.AssignmentFingerprint) + } + raw := []string{result.IssueID, result.Identifier, result.Title, result.TaskID} + var out []string + for _, value := range raw { + value = strings.TrimSpace(value) + if len(value) >= 3 { + out = append(out, value) + } + } + if len(out) > 0 { + return out + } + if value := strings.TrimSpace(result.Statement); len(value) >= 3 { + out = append(out, value) + } + return out +} + +func eventNarrativeContainsAny(env eventmodel.EventEnvelope, terms []string) bool { + body, _ := eventmodel.PayloadNarrative(env.Event.Payload)["body"].(string) + body = strings.ToLower(strings.Join([]string{body, string(env.Event.Subject), env.Event.ID, env.Event.Type}, "\n")) + for _, term := range terms { + if strings.Contains(body, strings.ToLower(term)) { + return true + } + } + return false +} + +func (s *runtimeRPCState) projectImportComment(ctx context.Context, cli driver.MulticaCLI, issue driver.MulticaIssue, externalID string, result *runtimeImportResult) { + if result == nil { + return + } + if !runtimeProjectionEnabled(s.Env) { + result.ProjectionStatus = "skipped" + return + } + title := "issue admitted" + body := "Issue admitted into Mnemon teamwork." + if result.HubKind == driver.MulticaHubKindAssignmentMailbox || result.Status == "correlated" { + title = "assignment mailbox correlated" + body = "Assignment mailbox correlated with Mnemon teamwork." + } + if result.Principal != "" { + body += "\nPrincipal: " + result.Principal + } + if result.TaskID != "" { + body += "\nMultica task: " + result.TaskID + } + if result.HubBackend != "" { + body += "\nHub backend: " + result.HubBackend + } + if result.SessionID != "" { + body += "\nSession: " + result.SessionID + } + if result.RootIssueID != "" { + body += "\nRoot issue: " + result.RootIssueID + } + if result.AssignmentID != "" { + body += "\nAssignment: " + result.AssignmentID + } + if result.Receipt != nil { + body += fmt.Sprintf("\nMnemon ingest: seq=%d duplicate=%v ticked=%v", result.Receipt.Seq, result.Receipt.Dup, result.Receipt.Ticked) + } + if result.WakeStatus != "" { + body += "\nManaged wake: " + result.WakeStatus + } + if result.HubWriteStatus != "" { + body += fmt.Sprintf("\nMultica hub write: %s child_issues=%d feedback_comments=%d", result.HubWriteStatus, result.HubChildIssues, result.HubFeedbackComments) + } + commentBody := driver.FormatMulticaProjectionComment(title, body, []string{externalID}) + 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"), + 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, 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 "", fmt.Errorf("read MNEMON_CONTROL_TOKEN_FILE: %w", err) + } + token = strings.TrimSpace(string(data)) + } + 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) + } +} + +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 driver.ManagedTurnResult{TurnID: "noop-turn", Status: "completed"}, 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 "correlated": + b.WriteString(" Mnemon assignment mailbox: correlated") + if result.AssignmentID != "" { + b.WriteString(" assignment=") + b.WriteString(result.AssignmentID) + } + if result.SessionID != "" { + b.WriteString(" session=") + b.WriteString(result.SessionID) + } + b.WriteString(".") + case "skipped": + b.WriteString(" Mnemon ingest: skipped") + if result.Err != nil { + b.WriteString(" (") + b.WriteString(result.Err.Error()) + b.WriteString(")") + } else { + b.WriteString(" because MNEMON_CONTROL_ADDR is not set") + } + b.WriteString(".") + case "failed": + b.WriteString(" Mnemon ingest: failed") + if result.Err != nil { + b.WriteString(" (") + b.WriteString(result.Err.Error()) + b.WriteString(")") + } + b.WriteString(".") + default: + if result.Err != nil { + b.WriteString(" Mnemon ingest: failed (") + b.WriteString(result.Err.Error()) + b.WriteString(").") + } + } + switch result.ProjectionStatus { + case "commented": + b.WriteString(" Multica projection: comment") + if result.ProjectionCommentID != "" { + b.WriteString("=") + b.WriteString(result.ProjectionCommentID) + } + b.WriteString(".") + case "skipped": + b.WriteString(" Multica projection: skipped.") + case "failed": + b.WriteString(" Multica projection: failed") + if result.ProjectionErr != nil { + b.WriteString(" (") + b.WriteString(result.ProjectionErr.Error()) + b.WriteString(")") + } + b.WriteString(".") + } + switch result.WakeStatus { + case "completed": + b.WriteString(" Managed wake: completed") + if result.WakeTurnID != "" { + b.WriteString(" turn=") + b.WriteString(result.WakeTurnID) + } + b.WriteString(".") + case "skipped": + b.WriteString(" Managed wake: skipped") + if result.WakeErr != nil { + b.WriteString(" (") + b.WriteString(result.WakeErr.Error()) + b.WriteString(")") + } + b.WriteString(".") + case "failed": + b.WriteString(" Managed wake: failed") + if result.WakeErr != nil { + b.WriteString(" (") + b.WriteString(result.WakeErr.Error()) + b.WriteString(")") + } + b.WriteString(".") + default: + if result.WakeStatus != "" { + b.WriteString(" Managed wake: ") + b.WriteString(result.WakeStatus) + b.WriteString(".") + } + } + 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()) +} + +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 emitRuntimeManagedTraceEvent(emit func(rpcMessage) error, threadID, turnID string, event driver.ManagedTurnTraceEvent, now time.Time) error { + switch event.Method { + case "item/started": + item := runtimeManagedTraceItem(event) + if len(item) == 0 { + return nil + } + return emit(rpcMessage{ + Method: "item/started", + Params: runtimeItemParams(threadID, turnID, item, "startedAtMs", now.UTC().UnixMilli()), + }) + case "item/completed": + item := runtimeManagedTraceItem(event) + if len(item) == 0 { + return nil + } + return emit(rpcMessage{ + Method: "item/completed", + Params: runtimeItemParams(threadID, turnID, item, "completedAtMs", now.UTC().UnixMilli()), + }) + case "item/agentMessage/delta": + text := strings.TrimSpace(event.Text) + if text == "" { + return nil + } + return emit(runtimeAgentDelta(threadID, turnID, runtimeManagedTraceItemID(event), text)) + default: + return nil + } +} + +func runtimeManagedTraceItem(event driver.ManagedTurnTraceEvent) map[string]any { + if len(event.Item) == 0 { + return nil + } + item, _ := cloneRuntimeAny(event.Item).(map[string]any) + if item == nil { + return nil + } + item["id"] = runtimeManagedTraceItemID(event) + if _, ok := item["source"]; !ok { + item["source"] = "managedCodexAppServer" + } + if strings.TrimSpace(event.SourceRuntime) != "" { + item["mnemonSourceRuntime"] = event.SourceRuntime + } + if strings.TrimSpace(event.Principal) != "" { + item["mnemonPrincipal"] = event.Principal + } + if strings.TrimSpace(event.TurnID) != "" { + item["mnemonManagedTurnId"] = event.TurnID + } + return item +} + +func runtimeManagedTraceItemID(event driver.ManagedTurnTraceEvent) string { + key := firstNonEmpty(event.ItemID, event.Command, event.Text, event.Kind, event.Method) + return runtimeTextDigestID("managed", event.SourceRuntime+"\n"+event.TurnID+"\n"+key) +} + +func cloneRuntimeAny(value any) any { + switch typed := value.(type) { + case map[string]any: + out := map[string]any{} + for key, item := range typed { + out[key] = cloneRuntimeAny(item) + } + return out + case []any: + out := make([]any, 0, len(typed)) + for _, item := range typed { + out = append(out, cloneRuntimeAny(item)) + } + return out + default: + return typed + } +} + +func emitRuntimeCommandExecution(emit func(rpcMessage) error, threadID, turnID, itemID, fallbackCWD string, event runtimeProgressEvent, now time.Time) error { + command := strings.TrimSpace(event.Command) + if command == "" { + return nil + } + if strings.TrimSpace(itemID) == "" { + itemID = runtimeTextDigestID("call", command+"\n"+event.Output) + } + cwd := strings.TrimSpace(event.CWD) + if cwd == "" { + cwd = fallbackCWD + } + output := strings.TrimSpace(event.Output) + durationMs := event.DurationMs + if durationMs < 0 { + durationMs = 0 + } + nowMs := now.UTC().UnixMilli() + started := runtimeCommandExecution(itemID, command, cwd, "inProgress", "", nil, nil) + completed := runtimeCommandExecution(itemID, command, cwd, "completed", output, event.ExitCode, durationMs) + messages := []rpcMessage{ + { + Method: "item/started", + Params: runtimeItemParams(threadID, turnID, started, "startedAtMs", nowMs), + }, + { + Method: "item/completed", + Params: runtimeItemParams(threadID, turnID, completed, "completedAtMs", nowMs), + }, + } + for _, message := range messages { + if err := emit(message); err != nil { + return err + } + } + return nil +} + +func runtimeCommandExecution(id, command, cwd, status, output string, exitCode any, durationMs any) map[string]any { + item := map[string]any{ + "type": "commandExecution", + "id": id, + "command": command, + "cwd": cwd, + "processId": "mnemon-runtime", + "source": "mnemonRuntime", + "status": status, + "commandActions": []any{map[string]any{"type": "unknown", "command": command}}, + "aggregatedOutput": nil, + "exitCode": nil, + "durationMs": nil, + } + if status == "completed" { + item["aggregatedOutput"] = output + item["exitCode"] = exitCode + item["durationMs"] = durationMs + } + return item +} + +func emitRuntimeAgentMessage(emit func(rpcMessage) error, threadID, turnID, itemID, text, phase string, now time.Time) error { + text = strings.TrimSpace(text) + if text == "" { + return nil + } + phase = strings.TrimSpace(phase) + if phase == "" { + phase = "commentary" + } + if strings.TrimSpace(itemID) == "" { + itemID = runtimeTextDigestID("msg", text) + } + nowMs := now.UTC().UnixMilli() + messages := []rpcMessage{ + { + Method: "item/started", + Params: runtimeItemParams(threadID, turnID, runtimeAgentMessage(itemID, "", phase), "startedAtMs", nowMs), + }, + runtimeAgentDelta(threadID, turnID, itemID, text), + { + Method: "item/completed", + Params: runtimeItemParams(threadID, turnID, runtimeAgentMessage(itemID, text, phase), "completedAtMs", nowMs), + }, + } + 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", + Params: map[string]any{ + "threadId": threadID, + "turnId": turnID, + "itemId": itemID, + "delta": delta, + }, + } +} + +func runtimeItemParams(threadID, turnID string, item map[string]any, timeKey string, timestampMs int64) map[string]any { + params := map[string]any{ + "threadId": threadID, + "turnId": turnID, + "item": item, + } + if timeKey != "" && timestampMs > 0 { + params[timeKey] = timestampMs + } + return params +} + +func runtimeUserMessage(text string) map[string]any { + return map[string]any{ + "type": "userMessage", + "id": runtimeTextDigestID("user", text), + "clientId": nil, + "content": []any{map[string]any{ + "type": "text", + "text": text, + "text_elements": []any{}, + }}, + } +} + +func runtimeAgentMessage(id, text, phase string) map[string]any { + id = strings.TrimSpace(id) + if id == "" { + id = runtimeTextDigestID("msg", text) + } + phase = strings.TrimSpace(phase) + if phase == "" { + phase = "commentary" + } + return map[string]any{ + "type": "agentMessage", + "id": id, + "text": text, + "phase": phase, + "memoryCitation": nil, + } +} + +func runtimeTextDigestID(prefix, text string) string { + prefix = strings.TrimSpace(prefix) + if prefix == "" { + prefix = "item" + } + sum := sha256.Sum256([]byte(text)) + digest := fmt.Sprintf("%x", sum[:])[:24] + return prefix + "_" + digest +} + +func emitRuntimeProgress(progress runtimeProgressSink, text string) { + if progress != nil { + progress(runtimeProgressEvent{Text: text}) + } +} + +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 { + 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 { + 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 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 "" + } + value, _ := params[key].(string) + return strings.TrimSpace(value) +} + +func envValue(env []string, key string) string { + prefix := key + "=" + for i := len(env) - 1; i >= 0; i-- { + item := env[i] + if strings.HasPrefix(item, prefix) { + return strings.TrimSpace(strings.TrimPrefix(item, prefix)) + } + } + return "" +} + +func envDefault(env []string, key, fallback string) string { + if value := envValue(env, key); value != "" { + return value + } + return fallback +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + 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 runtimeManagedTurnTimeout(env []string) time.Duration { + raw := envValue(env, "MNEMON_MANAGED_TURN_TIMEOUT") + if raw == "" { + return 5 * time.Minute + } + d, err := time.ParseDuration(raw) + if err != nil || d <= 0 { + return 5 * time.Minute + } + return d +} + +func runtimeHubProjectionInterval(env []string) time.Duration { + raw := envValue(env, "MNEMON_MULTICA_HUB_PROJECT_INTERVAL") + if raw == "" { + return 5 * time.Second + } + d, err := time.ParseDuration(raw) + if err != nil || d <= 0 { + return 5 * time.Second + } + return d +} + +func runtimeManagedLedgerPath(env []string, workspace string) string { + if explicit := envValue(env, "MNEMON_MANAGED_LEDGER"); explicit != "" { + return explicit + } + root := strings.TrimSpace(workspace) + if root == "" { + root = "." + } + return filepath.Join(root, ".mnemon", "harness", "local", "managed-agent", "wake-ledger.jsonl") +} + +func runtimeProjectionEnabled(env []string) bool { + value := strings.ToLower(envDefault(env, "MNEMON_MULTICA_PROJECT_COMMENTS", "true")) + switch value { + case "0", "false", "no", "off": + return false + default: + return true + } +} + +func sanitizePrincipal(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + var b strings.Builder + 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..c1f4cd7d --- /dev/null +++ b/harness/cmd/mnemon-multica-runtime/main_test.go @@ -0,0 +1,859 @@ +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" + 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 TestRuntimeEnvValueUsesLastValue(t *testing.T) { + env := []string{ + "MNEMON_MANAGED_RUNTIME=codex-appserver", + "MNEMON_MANAGED_RUNTIME=off", + } + if got := envValue(env, "MNEMON_MANAGED_RUNTIME"); got != "off" { + t.Fatalf("envValue = %q, want off", got) + } +} + +func TestRuntimeMulticaHubLedgerPathDefaultsToManagedWorkspace(t *testing.T) { + tmp := t.TempDir() + workspace := filepath.Join(tmp, "managed-workspace") + cwd := filepath.Join(tmp, "task-workdir") + got := runtimeMulticaHubLedgerPath([]string{"MNEMON_MANAGED_WORKSPACE=" + workspace}, cwd) + want := filepath.Join(workspace, driver.MulticaDefaultHubLedgerRelPath) + if got != want { + t.Fatalf("hub ledger path = %q, want %q", got, want) + } + explicit := filepath.Join(tmp, "explicit.jsonl") + got = runtimeMulticaHubLedgerPath([]string{ + "MNEMON_MANAGED_WORKSPACE=" + workspace, + "MNEMON_MULTICA_HUB_LEDGER=" + explicit, + }, cwd) + if got != explicit { + t.Fatalf("explicit hub ledger path = %q, want %q", got, explicit) + } +} + +func TestMergeRuntimeHubProjectionDeltasPreservesEarlyDispatchCounts(t *testing.T) { + result := runtimeImportResult{HubWriteStatus: "noop"} + mergeRuntimeHubProjectionDeltas(&result, []runtimeHubProjectionDelta{ + {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() { + 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") + 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 +` + 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) { + 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-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{ + 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) + } + })) + 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: runtimeTestEnv( + "MNEMON_MULTICA_BIN="+bin, + "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", + "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 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", "Managed wake: completed", "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, 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 { + t.Fatalf("invalid rpc line %q: %v", line, err) + } + 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 + } + 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" { + params, _ := msg["params"].(map[string]any) + 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 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") + 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: runtimeTestEnv( + "MNEMON_MULTICA_BIN="+bin, + "MNEMON_HUB_BACKEND=multica", + "MNEMON_MULTICA_HUB_WRITE=off", + "MNEMON_CONTROL_ADDR="+srv.URL, + "MNEMON_CONTROL_PRINCIPAL=planner@team", + "MULTICA_ARGS_PATH="+argsPath, + "MULTICA_COMMENT_PATH="+commentPath, + "MULTICA_TASK_ID=task-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 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-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", + "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: runtimeTestEnv( + "MNEMON_MULTICA_BIN="+bin, + "MNEMON_HUB_BACKEND=multica", + "MNEMON_CONTROL_ADDR="+srv.URL, + "MNEMON_CONTROL_PRINCIPAL=planner@team", + "MNEMON_MULTICA_REGISTRY="+registryPath, + "MNEMON_MULTICA_HUB_LEDGER="+filepath.Join(tmp, "hub-ledger.jsonl"), + "MULTICA_ARGS_PATH="+argsPath, + "MULTICA_COMMENT_PATH="+commentPath, + "MULTICA_CREATE_DESCRIPTION_PATH="+createDescriptionPath, + "MULTICA_TASK_ID=task-root-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 --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", + } { + 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) + } + 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") + 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) { + 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") + 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"}, + }}, + }) + 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) + } + })) + 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: runtimeTestEnv( + "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 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: "done"}, + {name: "blocker", item: runtimeProgress{FeedbackKind: "blocker"}, want: "blocked"}, + {name: "blocker narrative fallback", item: runtimeProgress{Blocker: "waiting on access"}, want: "blocked"}, + {name: "result narrative fallback", item: runtimeProgress{Result: "validated"}, want: "done"}, + {name: "unknown", item: runtimeProgress{Summary: "not enough signal"}, want: ""}, + } { + 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") + 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: runtimeTestEnv( + "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) +} + +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)) +} diff --git a/harness/cmd/mnemon-multica-runtime/probe.go b/harness/cmd/mnemon-multica-runtime/probe.go new file mode 100644 index 00000000..b526f55a --- /dev/null +++ b/harness/cmd/mnemon-multica-runtime/probe.go @@ -0,0 +1,383 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "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"` +} + +func runRuntimeProbe(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 + } + } + 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 %s (probe mode)\n", runtimeVersion) + return rec.record(probeEvent{Kind: "version"}) + } + return runProbeRPC(cfg, rec, cwd) +} + +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} + 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/" + runtimeVersion + " probe", + "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", + "installationId": "mnemon-runtime-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": runtimeVersion, + "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 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 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_test.go b/harness/cmd/mnemon-multica-runtime/probe_test.go new file mode 100644 index 00000000..56a1dfe4 --- /dev/null +++ b/harness/cmd/mnemon-multica-runtime/probe_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 := runRuntime(runtimeConfig{ + Args: []string{"--probe", "--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") || !strings.Contains(out.String(), "probe mode") { + 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 := runRuntime(runtimeConfig{ + Args: []string{"--diagnose", "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) +} 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:"} { 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/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 "" +} diff --git a/harness/internal/driver/multica.go b/harness/internal/driver/multica.go new file mode 100644 index 00000000..98937550 --- /dev/null +++ b/harness/internal/driver/multica.go @@ -0,0 +1,1144 @@ +package driver + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os/exec" + "sort" + "strings" + "sync" + "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 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"` + Content string `json:"content"` + 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 +} + +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) 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 { + 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) 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) 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 + } + env, err := decodeMulticaAgentEnv([]byte(out.Stdout)) + if err != nil { + return nil, fmt.Errorf("decode multica agent env: %w", err) + } + 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 + } + updated, err := decodeMulticaAgentEnv([]byte(out.Stdout)) + if err != nil { + return nil, fmt.Errorf("decode updated multica agent env: %w", err) + } + 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") + } + 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) 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) + 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 + } + } + 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) { + 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 == "" { + 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 == "" { + 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 + WorkspaceID string + TaskID string + AgentID string + Principal string + EvidenceRefs []string + ContextRefs []string + ExternalID string +} + +type MulticaObservedDraft struct { + EventType string `json:"event_type"` + ExternalID string `json:"external_id"` + Payload map[string]any `json:"payload"` +} + +func BuildMulticaIssueTeamworkSignal(issue MulticaIssue, opts MulticaIssueSignalOptions) (MulticaObservedDraft, error) { + if strings.TrimSpace(issue.ID) == "" { + return MulticaObservedDraft{}, fmt.Errorf("multica issue id is required") + } + title := strings.TrimSpace(issue.Title) + if title == "" { + title = strings.TrimSpace(issue.Identifier) + } + if title == "" { + title = issue.ID + } + scope := strings.TrimSpace(opts.Scope) + if scope == "" { + scope = "multica/teamwork" + } + ttl := strings.TrimSpace(opts.TTL) + if ttl == "" { + ttl = "30m" + } + correlation := "multica:issue:" + issue.ID + rule := map[string]any{ + "external_source": MulticaExternalSource, + "external_issue_id": issue.ID, + "external_issue_identifier": strings.TrimSpace(issue.Identifier), + "correlation_id": correlation, + "scope": scope, + "ttl": ttl, + } + addMulticaRuleString(rule, "external_workspace_id", opts.WorkspaceID) + addMulticaRuleString(rule, "external_task_id", opts.TaskID) + addMulticaRuleString(rule, "external_agent_id", opts.AgentID) + addMulticaRuleString(rule, "principal", opts.Principal) + narrative := map[string]any{ + "title": title, + "statement": multicaIssueStatement(issue, title), + "why_teamwork": strings.TrimSpace(opts.WhyTeamwork), + } + if narrative["why_teamwork"] == "" { + narrative["why_teamwork"] = "The Multica issue is being bridged into Mnemon so local agents can decide whether and how to coordinate." + } + refs := map[string]any{ + "context_refs": append([]string{correlation}, cleanMulticaRefs(opts.ContextRefs)...), + } + if evidence := cleanMulticaRefs(opts.EvidenceRefs); len(evidence) > 0 { + refs["evidence_refs"] = evidence + } else { + refs["evidence_refs"] = []string{correlation} + } + externalID := strings.TrimSpace(opts.ExternalID) + if externalID == "" { + externalID = "multica-issue-" + issue.ID + } + return MulticaObservedDraft{ + EventType: "teamwork_signal.write_candidate.observed", + ExternalID: externalID, + Payload: eventmodel.BuildPayload(rule, narrative, refs), + }, nil +} + +func FormatMulticaProjectionComment(title string, body string, eventIDs []string) string { + title = strings.TrimSpace(title) + body = strings.TrimSpace(body) + var b strings.Builder + if title != "" { + b.WriteString("Mnemon update: ") + b.WriteString(title) + b.WriteString("\n\n") + } else { + b.WriteString("Mnemon update\n\n") + } + if body != "" { + b.WriteString(body) + b.WriteString("\n") + } + if ids := cleanMulticaRefs(eventIDs); len(ids) > 0 { + b.WriteString("\n") + for _, id := range ids { + b.WriteString("mnemon:event=") + b.WriteString(id) + b.WriteString("\n") + } + } + return strings.TrimSpace(b.String()) +} + +func DecodeMulticaIssue(r io.Reader) (MulticaIssue, error) { + var issue MulticaIssue + if err := json.NewDecoder(r).Decode(&issue); err != nil { + return MulticaIssue{}, err + } + 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 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) == "" { + 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 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 { + 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) + } + return fallback +} + +func cleanMulticaRefs(values []string) []string { + var out []string + seen := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + return out +} + +func addMulticaRuleString(rule map[string]any, key, value string) { + value = strings.TrimSpace(value) + if value != "" { + rule[key] = value + } +} diff --git a/harness/internal/driver/multica_hub.go b/harness/internal/driver/multica_hub.go 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_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) + } +} diff --git a/harness/internal/driver/multica_test.go b/harness/internal/driver/multica_test.go new file mode 100644 index 00000000..945ec2e0 --- /dev/null +++ b/harness/internal/driver/multica_test.go @@ -0,0 +1,492 @@ +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.", + WorkspaceID: "workspace-1", + TaskID: "task-1", + AgentID: "agent-1", + Principal: "planner@team", + EvidenceRefs: []string{"multica:issue/iss-123"}, + ExternalID: "multica-task-task-1", + }) + if err != nil { + t.Fatal(err) + } + if draft.EventType != "teamwork_signal.write_candidate.observed" { + t.Fatalf("event type = %q", draft.EventType) + } + rule := draft.Payload["rule"].(map[string]any) + if rule["external_source"] != MulticaExternalSource || rule["external_issue_id"] != "iss-123" || rule["scope"] != "multica/poc" { + t.Fatalf("rule mapping mismatch: %+v", rule) + } + if rule["external_workspace_id"] != "workspace-1" || rule["external_task_id"] != "task-1" || rule["external_agent_id"] != "agent-1" || rule["principal"] != "planner@team" { + t.Fatalf("runtime rule mapping mismatch: %+v", rule) + } + if draft.ExternalID != "multica-task-task-1" { + t.Fatalf("external id = %q", draft.ExternalID) + } + narrative := draft.Payload["narrative"].(map[string]any) + if narrative["statement"] != "Check that Multica issue context can start Mnemon teamwork." { + t.Fatalf("narrative statement mismatch: %+v", narrative) + } + if _, ok := narrative["external_issue_id"]; ok { + t.Fatalf("narrative must not carry rule ids: %+v", narrative) + } + if _, ok := narrative["external_task_id"]; ok { + t.Fatalf("narrative must not carry runtime ids: %+v", narrative) + } +} + +func TestMulticaCLIAddCommentUsesStdin(t *testing.T) { + tmp := t.TempDir() + argsPath := filepath.Join(tmp, "args.txt") + 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 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 '{"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 +` + 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") + 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{ + "Mnemon update: assignment finished", + "Worker reported passing evidence.", + "mnemon:event=ev-1", + "mnemon:event=ev-2", + } { + if !strings.Contains(got, want) { + t.Fatalf("projection comment missing %q:\n%s", want, got) + } + } + if strings.Count(got, "mnemon:event=ev-1") != 1 { + t.Fatalf("projection comment should dedupe markers:\n%s", got) + } +} + +func TestMulticaHubMetadataDetectsAssignmentMailbox(t *testing.T) { + issue := MulticaIssue{ + ID: "issue-2", + 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) + } + } +} + +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)) +} 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 { 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: