macro-agent 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +166 -33
- package/README.md +781 -131
- package/dist/acp/claude-code-replay.d.ts +11 -0
- package/dist/acp/claude-code-replay.d.ts.map +1 -0
- package/dist/acp/claude-code-replay.js +190 -0
- package/dist/acp/claude-code-replay.js.map +1 -0
- package/dist/acp/macro-agent.d.ts.map +1 -1
- package/dist/acp/macro-agent.js +155 -6
- package/dist/acp/macro-agent.js.map +1 -1
- package/dist/acp/types.d.ts +9 -0
- package/dist/acp/types.d.ts.map +1 -1
- package/dist/acp/types.js.map +1 -1
- package/dist/agent/agent-manager-v2.d.ts +21 -0
- package/dist/agent/agent-manager-v2.d.ts.map +1 -1
- package/dist/agent/agent-manager-v2.js +234 -43
- package/dist/agent/agent-manager-v2.js.map +1 -1
- package/dist/agent/agent-manager.d.ts +12 -0
- package/dist/agent/agent-manager.d.ts.map +1 -1
- package/dist/agent/agent-manager.js.map +1 -1
- package/dist/agent/types.d.ts +15 -2
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/boot-v2.d.ts +41 -0
- package/dist/boot-v2.d.ts.map +1 -1
- package/dist/boot-v2.js +16 -1
- package/dist/boot-v2.js.map +1 -1
- package/dist/cli/index.js +56 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cognitive/macro-agent-backend.d.ts.map +1 -1
- package/dist/cognitive/macro-agent-backend.js +40 -22
- package/dist/cognitive/macro-agent-backend.js.map +1 -1
- package/dist/integrations/skilltree.d.ts.map +1 -1
- package/dist/integrations/skilltree.js +1 -0
- package/dist/integrations/skilltree.js.map +1 -1
- package/dist/lifecycle/cleanup.d.ts +33 -2
- package/dist/lifecycle/cleanup.d.ts.map +1 -1
- package/dist/lifecycle/cleanup.js +28 -6
- package/dist/lifecycle/cleanup.js.map +1 -1
- package/dist/lifecycle/handlers-v2.d.ts +7 -0
- package/dist/lifecycle/handlers-v2.d.ts.map +1 -1
- package/dist/lifecycle/handlers-v2.js +28 -2
- package/dist/lifecycle/handlers-v2.js.map +1 -1
- package/dist/lifecycle/types.d.ts +11 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/lifecycle/types.js.map +1 -1
- package/dist/map/acp-bridge.d.ts +9 -0
- package/dist/map/acp-bridge.d.ts.map +1 -1
- package/dist/map/acp-bridge.js +15 -2
- package/dist/map/acp-bridge.js.map +1 -1
- package/dist/map/cascade-bridge.d.ts +44 -0
- package/dist/map/cascade-bridge.d.ts.map +1 -0
- package/dist/map/cascade-bridge.js +257 -0
- package/dist/map/cascade-bridge.js.map +1 -0
- package/dist/map/lifecycle-bridge.d.ts +1 -1
- package/dist/map/lifecycle-bridge.d.ts.map +1 -1
- package/dist/map/lifecycle-bridge.js +58 -23
- package/dist/map/lifecycle-bridge.js.map +1 -1
- package/dist/map/server.d.ts.map +1 -1
- package/dist/map/server.js +47 -6
- package/dist/map/server.js.map +1 -1
- package/dist/map/sidecar.d.ts.map +1 -1
- package/dist/map/sidecar.js +33 -2
- package/dist/map/sidecar.js.map +1 -1
- package/dist/map/types.d.ts +20 -0
- package/dist/map/types.d.ts.map +1 -1
- package/dist/mcp/tools/done-v2.d.ts.map +1 -1
- package/dist/mcp/tools/done-v2.js +8 -0
- package/dist/mcp/tools/done-v2.js.map +1 -1
- package/dist/teams/team-manager-v2.d.ts.map +1 -1
- package/dist/teams/team-manager-v2.js +26 -0
- package/dist/teams/team-manager-v2.js.map +1 -1
- package/dist/teams/team-runtime-v2.d.ts.map +1 -1
- package/dist/teams/team-runtime-v2.js +16 -3
- package/dist/teams/team-runtime-v2.js.map +1 -1
- package/dist/workspace/config.d.ts +10 -10
- package/dist/workspace/config.d.ts.map +1 -1
- package/dist/workspace/config.js +4 -4
- package/dist/workspace/config.js.map +1 -1
- package/dist/workspace/git-cascade-adapter.d.ts +510 -0
- package/dist/workspace/git-cascade-adapter.d.ts.map +1 -0
- package/dist/workspace/git-cascade-adapter.js +908 -0
- package/dist/workspace/git-cascade-adapter.js.map +1 -0
- package/dist/workspace/index.d.ts +3 -3
- package/dist/workspace/index.d.ts.map +1 -1
- package/dist/workspace/index.js +4 -4
- package/dist/workspace/index.js.map +1 -1
- package/dist/workspace/landing/direct-push.d.ts +20 -0
- package/dist/workspace/landing/direct-push.d.ts.map +1 -0
- package/dist/workspace/landing/direct-push.js +74 -0
- package/dist/workspace/landing/direct-push.js.map +1 -0
- package/dist/workspace/landing/index.d.ts +29 -0
- package/dist/workspace/landing/index.d.ts.map +1 -0
- package/dist/workspace/landing/index.js +37 -0
- package/dist/workspace/landing/index.js.map +1 -0
- package/dist/workspace/landing/merge-to-parent.d.ts +41 -0
- package/dist/workspace/landing/merge-to-parent.d.ts.map +1 -0
- package/dist/workspace/landing/merge-to-parent.js +185 -0
- package/dist/workspace/landing/merge-to-parent.js.map +1 -0
- package/dist/workspace/landing/optimistic-push.d.ts +16 -0
- package/dist/workspace/landing/optimistic-push.d.ts.map +1 -0
- package/dist/workspace/landing/optimistic-push.js +27 -0
- package/dist/workspace/landing/optimistic-push.js.map +1 -0
- package/dist/workspace/landing/queue-to-branch.d.ts +24 -0
- package/dist/workspace/landing/queue-to-branch.d.ts.map +1 -0
- package/dist/workspace/landing/queue-to-branch.js +79 -0
- package/dist/workspace/landing/queue-to-branch.js.map +1 -0
- package/dist/workspace/merge-queue/merge-queue.d.ts +10 -0
- package/dist/workspace/merge-queue/merge-queue.d.ts.map +1 -1
- package/dist/workspace/merge-queue/merge-queue.js +10 -0
- package/dist/workspace/merge-queue/merge-queue.js.map +1 -1
- package/dist/workspace/merge-queue/types.d.ts +16 -2
- package/dist/workspace/merge-queue/types.d.ts.map +1 -1
- package/dist/workspace/merge-queue/types.js +9 -0
- package/dist/workspace/merge-queue/types.js.map +1 -1
- package/dist/workspace/pool/types.d.ts +1 -0
- package/dist/workspace/pool/types.d.ts.map +1 -1
- package/dist/workspace/pool/worktree-pool.d.ts.map +1 -1
- package/dist/workspace/pool/worktree-pool.js +1 -0
- package/dist/workspace/pool/worktree-pool.js.map +1 -1
- package/dist/workspace/recovery/abandon.d.ts +15 -0
- package/dist/workspace/recovery/abandon.d.ts.map +1 -0
- package/dist/workspace/recovery/abandon.js +45 -0
- package/dist/workspace/recovery/abandon.js.map +1 -0
- package/dist/workspace/recovery/auto-resolve.d.ts +27 -0
- package/dist/workspace/recovery/auto-resolve.d.ts.map +1 -0
- package/dist/workspace/recovery/auto-resolve.js +99 -0
- package/dist/workspace/recovery/auto-resolve.js.map +1 -0
- package/dist/workspace/recovery/defer.d.ts +15 -0
- package/dist/workspace/recovery/defer.d.ts.map +1 -0
- package/dist/workspace/recovery/defer.js +16 -0
- package/dist/workspace/recovery/defer.js.map +1 -0
- package/dist/workspace/recovery/escalate.d.ts +16 -0
- package/dist/workspace/recovery/escalate.d.ts.map +1 -0
- package/dist/workspace/recovery/escalate.js +24 -0
- package/dist/workspace/recovery/escalate.js.map +1 -0
- package/dist/workspace/recovery/index.d.ts +32 -0
- package/dist/workspace/recovery/index.d.ts.map +1 -0
- package/dist/workspace/recovery/index.js +45 -0
- package/dist/workspace/recovery/index.js.map +1 -0
- package/dist/workspace/recovery/spawn-resolver.d.ts +45 -0
- package/dist/workspace/recovery/spawn-resolver.d.ts.map +1 -0
- package/dist/workspace/recovery/spawn-resolver.js +111 -0
- package/dist/workspace/recovery/spawn-resolver.js.map +1 -0
- package/dist/workspace/recovery/types.d.ts +63 -0
- package/dist/workspace/recovery/types.d.ts.map +1 -0
- package/dist/workspace/recovery/types.js +12 -0
- package/dist/workspace/recovery/types.js.map +1 -0
- package/dist/workspace/topology/index.d.ts +9 -0
- package/dist/workspace/topology/index.d.ts.map +1 -0
- package/dist/workspace/topology/index.js +8 -0
- package/dist/workspace/topology/index.js.map +1 -0
- package/dist/workspace/topology/no-workspace.d.ts +18 -0
- package/dist/workspace/topology/no-workspace.d.ts.map +1 -0
- package/dist/workspace/topology/no-workspace.js +25 -0
- package/dist/workspace/topology/no-workspace.js.map +1 -0
- package/dist/workspace/topology/types.d.ts +97 -0
- package/dist/workspace/topology/types.d.ts.map +1 -0
- package/dist/workspace/topology/types.js +20 -0
- package/dist/workspace/topology/types.js.map +1 -0
- package/dist/workspace/topology/yaml-driven.d.ts +69 -0
- package/dist/workspace/topology/yaml-driven.d.ts.map +1 -0
- package/dist/workspace/topology/yaml-driven.js +273 -0
- package/dist/workspace/topology/yaml-driven.js.map +1 -0
- package/dist/workspace/types-v3.d.ts +110 -0
- package/dist/workspace/types-v3.d.ts.map +1 -0
- package/dist/workspace/types-v3.js +20 -0
- package/dist/workspace/types-v3.js.map +1 -0
- package/dist/workspace/types.d.ts +145 -17
- package/dist/workspace/types.d.ts.map +1 -1
- package/dist/workspace/workspace-manager.d.ts +92 -13
- package/dist/workspace/workspace-manager.d.ts.map +1 -1
- package/dist/workspace/workspace-manager.js +373 -13
- package/dist/workspace/workspace-manager.js.map +1 -1
- package/dist/workspace/yaml-schema.d.ts +254 -0
- package/dist/workspace/yaml-schema.d.ts.map +1 -0
- package/dist/workspace/yaml-schema.js +170 -0
- package/dist/workspace/yaml-schema.js.map +1 -0
- package/docs/conflict-recovery.md +472 -0
- package/docs/git-cascade-integration-gaps.md +678 -0
- package/docs/workspace-interfaces.md +731 -0
- package/docs/workspace-redesign-plan.md +302 -0
- package/package.json +4 -4
- package/src/__tests__/e2e/auto-sync.e2e.test.ts +257 -0
- package/src/__tests__/e2e/cascade-rebase.e2e.test.ts +254 -0
- package/src/__tests__/e2e/cli-run.e2e.test.ts +167 -0
- package/src/__tests__/e2e/self-driving-v3.e2e.test.ts +197 -0
- package/src/__tests__/e2e/spawn-resolver.e2e.test.ts +200 -0
- package/src/__tests__/e2e/workspace-lifecycle.e2e.test.ts +30 -22
- package/src/__tests__/e2e/workspace-v3.e2e.test.ts +413 -0
- package/src/acp/__tests__/claude-code-replay.test.ts +225 -0
- package/src/acp/__tests__/macro-agent.test.ts +39 -1
- package/src/acp/claude-code-replay.ts +208 -0
- package/src/acp/macro-agent.ts +167 -9
- package/src/acp/types.ts +10 -0
- package/src/agent/__tests__/agent-manager-topology.test.ts +73 -0
- package/src/agent/__tests__/agent-manager-v2.test.ts +66 -0
- package/src/agent/__tests__/task-ref-resolution.test.ts +231 -0
- package/src/agent/agent-manager-v2.ts +293 -48
- package/src/agent/agent-manager.ts +14 -0
- package/src/agent/types.ts +16 -2
- package/src/boot-v2.ts +68 -1
- package/src/cli/index.ts +61 -0
- package/src/cognitive/macro-agent-backend.ts +45 -29
- package/src/integrations/skilltree.ts +1 -0
- package/src/lifecycle/cleanup.ts +52 -3
- package/src/lifecycle/handlers-v2.ts +40 -3
- package/src/lifecycle/types.ts +12 -0
- package/src/map/__tests__/cascade-bridge.test.ts +229 -0
- package/src/map/__tests__/lifecycle-bridge.test.ts +86 -10
- package/src/map/acp-bridge.ts +26 -3
- package/src/map/cascade-bridge.ts +301 -0
- package/src/map/lifecycle-bridge.ts +52 -17
- package/src/map/server.ts +47 -6
- package/src/map/sidecar.ts +31 -1
- package/src/map/types.ts +20 -0
- package/src/mcp/tools/done-v2.ts +9 -0
- package/src/teams/team-manager-v2.ts +37 -0
- package/src/teams/team-runtime-v2.ts +23 -3
- package/src/workspace/__tests__/{dataplane-adapter.test.ts → git-cascade-adapter.test.ts} +209 -14
- package/src/workspace/__tests__/self-driving-yaml.test.ts +114 -0
- package/src/workspace/__tests__/shared-worktree-refcount.test.ts +154 -0
- package/src/workspace/__tests__/standalone-mode.test.ts +118 -0
- package/src/workspace/__tests__/workspace-manager-v3.test.ts +245 -0
- package/src/workspace/__tests__/yaml-schema.test.ts +210 -0
- package/src/workspace/config.ts +11 -11
- package/src/workspace/git-cascade-adapter.ts +1186 -0
- package/src/workspace/index.ts +11 -11
- package/src/workspace/landing/__tests__/strategies.test.ts +142 -0
- package/src/workspace/landing/direct-push.ts +91 -0
- package/src/workspace/landing/index.ts +40 -0
- package/src/workspace/landing/merge-to-parent.ts +228 -0
- package/src/workspace/landing/optimistic-push.ts +36 -0
- package/src/workspace/landing/queue-to-branch.ts +108 -0
- package/src/workspace/merge-queue/merge-queue.ts +10 -0
- package/src/workspace/merge-queue/types.ts +16 -2
- package/src/workspace/pool/__tests__/worktree-pool.integration.test.ts +5 -5
- package/src/workspace/pool/types.ts +1 -0
- package/src/workspace/pool/worktree-pool.ts +1 -0
- package/src/workspace/recovery/__tests__/auto-resolve-integration.test.ts +127 -0
- package/src/workspace/recovery/__tests__/spawn-resolver.test.ts +139 -0
- package/src/workspace/recovery/__tests__/strategies.test.ts +145 -0
- package/src/workspace/recovery/abandon.ts +51 -0
- package/src/workspace/recovery/auto-resolve.ts +119 -0
- package/src/workspace/recovery/defer.ts +23 -0
- package/src/workspace/recovery/escalate.ts +30 -0
- package/src/workspace/recovery/index.ts +58 -0
- package/src/workspace/recovery/spawn-resolver.ts +145 -0
- package/src/workspace/recovery/types.ts +54 -0
- package/src/workspace/topology/__tests__/yaml-driven.test.ts +345 -0
- package/src/workspace/topology/index.ts +18 -0
- package/src/workspace/topology/no-workspace.ts +39 -0
- package/src/workspace/topology/types.ts +116 -0
- package/src/workspace/topology/yaml-driven.ts +316 -0
- package/src/workspace/types-v3.ts +155 -0
- package/src/workspace/types.ts +191 -20
- package/src/workspace/workspace-manager.ts +474 -19
- package/src/workspace/yaml-schema.ts +216 -0
- package/src/workspace/dataplane-adapter.ts +0 -546
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code JSONL → ACP SessionUpdate replay
|
|
3
|
+
*
|
|
4
|
+
* Works around a gap in claude-code-acp where `loadSession` does NOT emit
|
|
5
|
+
* `session/update` notifications for historical conversation content — it only
|
|
6
|
+
* tells the Claude Code subprocess to restore context internally. That means
|
|
7
|
+
* clients (like OpenHive) calling ACP's `session/load` get back an empty
|
|
8
|
+
* session.
|
|
9
|
+
*
|
|
10
|
+
* Per the ACP spec (https://agentclientprotocol.com/protocol/session-setup#loading-sessions),
|
|
11
|
+
* loadSession SHOULD stream the conversation history back via session/update
|
|
12
|
+
* notifications. This module reads Claude Code's native JSONL transcript (which
|
|
13
|
+
* Claude Code always persists, no hooks required) and converts each entry to
|
|
14
|
+
* ACP SessionUpdate events so the client can reconstruct the conversation.
|
|
15
|
+
*
|
|
16
|
+
* TODO(upstream): fix claude-code-acp so this workaround becomes unnecessary.
|
|
17
|
+
* See node_modules/@sudocode-ai/claude-code-acp/dist/acp-agent.js:344-353.
|
|
18
|
+
*
|
|
19
|
+
* @module acp/claude-code-replay
|
|
20
|
+
*/
|
|
21
|
+
import * as fs from "node:fs/promises";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import * as os from "node:os";
|
|
24
|
+
import type { SessionUpdate } from "@agentclientprotocol/sdk";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Locate Claude Code's JSONL transcript for the given session.
|
|
28
|
+
*
|
|
29
|
+
* Claude Code writes transcripts to `~/.claude/projects/{encoded-cwd}/{session-id}.jsonl`.
|
|
30
|
+
* The cwd encoding replaces `/` with `-` (e.g., `/tmp/x` → `-private-tmp-x` on macOS
|
|
31
|
+
* where /tmp is a symlink). Rather than replicating the encoding exactly, we scan
|
|
32
|
+
* project directories for a file matching the session ID — the ID is a UUID so
|
|
33
|
+
* collisions are not a concern.
|
|
34
|
+
*/
|
|
35
|
+
async function locateTranscript(providerSessionId: string): Promise<string | null> {
|
|
36
|
+
const projectsRoot = path.join(os.homedir(), ".claude", "projects");
|
|
37
|
+
let dirents: Awaited<ReturnType<typeof fs.readdir>>;
|
|
38
|
+
try {
|
|
39
|
+
// @ts-expect-error — withFileTypes overload returns Dirent[]
|
|
40
|
+
dirents = await fs.readdir(projectsRoot, { withFileTypes: true });
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
for (const d of dirents as unknown as Array<{ isDirectory(): boolean; name: string }>) {
|
|
45
|
+
if (!d.isDirectory()) continue;
|
|
46
|
+
const candidate = path.join(projectsRoot, d.name, `${providerSessionId}.jsonl`);
|
|
47
|
+
try {
|
|
48
|
+
await fs.access(candidate);
|
|
49
|
+
return candidate;
|
|
50
|
+
} catch {
|
|
51
|
+
// Not in this dir — keep scanning
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type ContentBlock =
|
|
58
|
+
| { type: "text"; text: string }
|
|
59
|
+
| { type: "tool_use"; id: string; name: string; input: unknown }
|
|
60
|
+
| { type: "tool_result"; tool_use_id: string; content: unknown; is_error?: boolean }
|
|
61
|
+
| { type: "thinking"; thinking?: string; text?: string };
|
|
62
|
+
|
|
63
|
+
interface JsonlEntry {
|
|
64
|
+
type?: string;
|
|
65
|
+
isMeta?: boolean;
|
|
66
|
+
message?: {
|
|
67
|
+
role?: string;
|
|
68
|
+
content?: string | ContentBlock[];
|
|
69
|
+
};
|
|
70
|
+
uuid?: string;
|
|
71
|
+
timestamp?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Convert a user message content block into an ACP SessionUpdate.
|
|
76
|
+
*/
|
|
77
|
+
function userBlockToUpdate(block: ContentBlock): SessionUpdate | null {
|
|
78
|
+
if (block.type === "text" && block.text) {
|
|
79
|
+
return {
|
|
80
|
+
sessionUpdate: "user_message_chunk",
|
|
81
|
+
content: { type: "text", text: block.text },
|
|
82
|
+
} as unknown as SessionUpdate;
|
|
83
|
+
}
|
|
84
|
+
if (block.type === "tool_result") {
|
|
85
|
+
const output =
|
|
86
|
+
typeof block.content === "string"
|
|
87
|
+
? block.content
|
|
88
|
+
: JSON.stringify(block.content);
|
|
89
|
+
return {
|
|
90
|
+
sessionUpdate: "tool_call_update",
|
|
91
|
+
toolCallId: block.tool_use_id,
|
|
92
|
+
output,
|
|
93
|
+
status: block.is_error ? "failed" : "completed",
|
|
94
|
+
} as unknown as SessionUpdate;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Convert an assistant message content block into an ACP SessionUpdate.
|
|
101
|
+
*/
|
|
102
|
+
function assistantBlockToUpdate(block: ContentBlock): SessionUpdate | null {
|
|
103
|
+
if (block.type === "text" && block.text) {
|
|
104
|
+
return {
|
|
105
|
+
sessionUpdate: "agent_message_chunk",
|
|
106
|
+
content: { type: "text", text: block.text },
|
|
107
|
+
} as unknown as SessionUpdate;
|
|
108
|
+
}
|
|
109
|
+
if (block.type === "tool_use") {
|
|
110
|
+
return {
|
|
111
|
+
sessionUpdate: "tool_call",
|
|
112
|
+
toolCallId: block.id,
|
|
113
|
+
title: block.name,
|
|
114
|
+
rawInput: block.input,
|
|
115
|
+
status: "pending",
|
|
116
|
+
} as unknown as SessionUpdate;
|
|
117
|
+
}
|
|
118
|
+
if (block.type === "thinking") {
|
|
119
|
+
const text = block.thinking ?? block.text ?? "";
|
|
120
|
+
if (!text) return null;
|
|
121
|
+
return {
|
|
122
|
+
sessionUpdate: "agent_thought_chunk",
|
|
123
|
+
content: { type: "text", text },
|
|
124
|
+
} as unknown as SessionUpdate;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Check if a string user-message looks like a Claude Code internal command
|
|
131
|
+
* (e.g., `<command-name>/model</command-name>`, `<local-command-stdout>...`).
|
|
132
|
+
* These get recorded in the JSONL but aren't part of the conversation UX.
|
|
133
|
+
*/
|
|
134
|
+
function isInternalCommand(text: string): boolean {
|
|
135
|
+
const t = text.trimStart();
|
|
136
|
+
return (
|
|
137
|
+
t.startsWith("<command-") ||
|
|
138
|
+
t.startsWith("<local-command-") ||
|
|
139
|
+
t.startsWith("<system-reminder") ||
|
|
140
|
+
t.startsWith("Caveat:")
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Read Claude Code's JSONL transcript for a session and yield ACP SessionUpdate
|
|
146
|
+
* events suitable for emitting via `connection.sessionUpdate({ sessionId, update })`.
|
|
147
|
+
*
|
|
148
|
+
* Yields events in chronological order. Returns early (yields nothing) if the
|
|
149
|
+
* transcript file doesn't exist — e.g., the agent is running on a different
|
|
150
|
+
* machine.
|
|
151
|
+
*/
|
|
152
|
+
export async function* replayClaudeCodeTranscript(
|
|
153
|
+
providerSessionId: string,
|
|
154
|
+
): AsyncGenerator<SessionUpdate> {
|
|
155
|
+
const jsonlPath = await locateTranscript(providerSessionId);
|
|
156
|
+
if (!jsonlPath) return;
|
|
157
|
+
|
|
158
|
+
let raw: string;
|
|
159
|
+
try {
|
|
160
|
+
raw = await fs.readFile(jsonlPath, "utf-8");
|
|
161
|
+
} catch {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const line of raw.split("\n")) {
|
|
166
|
+
const trimmed = line.trim();
|
|
167
|
+
if (!trimmed) continue;
|
|
168
|
+
|
|
169
|
+
let entry: JsonlEntry;
|
|
170
|
+
try {
|
|
171
|
+
entry = JSON.parse(trimmed);
|
|
172
|
+
} catch {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Skip non-conversation entries (queue-operations, summaries, etc.)
|
|
177
|
+
if (entry.type !== "user" && entry.type !== "assistant") continue;
|
|
178
|
+
|
|
179
|
+
// Skip meta messages (local command output, system injections)
|
|
180
|
+
if (entry.isMeta) continue;
|
|
181
|
+
|
|
182
|
+
const message = entry.message;
|
|
183
|
+
if (!message) continue;
|
|
184
|
+
const content = message.content;
|
|
185
|
+
|
|
186
|
+
if (entry.type === "user") {
|
|
187
|
+
if (typeof content === "string") {
|
|
188
|
+
if (isInternalCommand(content)) continue;
|
|
189
|
+
yield {
|
|
190
|
+
sessionUpdate: "user_message_chunk",
|
|
191
|
+
content: { type: "text", text: content },
|
|
192
|
+
} as unknown as SessionUpdate;
|
|
193
|
+
} else if (Array.isArray(content)) {
|
|
194
|
+
for (const block of content) {
|
|
195
|
+
const upd = userBlockToUpdate(block);
|
|
196
|
+
if (upd) yield upd;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} else if (entry.type === "assistant") {
|
|
200
|
+
if (Array.isArray(content)) {
|
|
201
|
+
for (const block of content) {
|
|
202
|
+
const upd = assistantBlockToUpdate(block);
|
|
203
|
+
if (upd) yield upd;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
package/src/acp/macro-agent.ts
CHANGED
|
@@ -154,6 +154,20 @@ export function createMacroAgent(
|
|
|
154
154
|
let firstPrompt: string | null = null;
|
|
155
155
|
const sessionStartedAt = new Date().toISOString();
|
|
156
156
|
|
|
157
|
+
// Per-session update history for ACP session/load replay.
|
|
158
|
+
// Per the ACP spec, loadSession should re-emit all session/update notifications
|
|
159
|
+
// previously sent so the client can reconstruct state. We buffer them here
|
|
160
|
+
// keyed by ACP session ID.
|
|
161
|
+
const sessionUpdateHistory = new Map<string, SessionUpdate[]>();
|
|
162
|
+
function appendSessionUpdate(acpSessionId: string, update: SessionUpdate): void {
|
|
163
|
+
const arr = sessionUpdateHistory.get(acpSessionId);
|
|
164
|
+
if (arr) {
|
|
165
|
+
arr.push(update);
|
|
166
|
+
} else {
|
|
167
|
+
sessionUpdateHistory.set(acpSessionId, [update]);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
157
171
|
// Git info (cached — read once per session)
|
|
158
172
|
let gitInfo: { branch: string | null; commitHash: string | null; remoteUrl: string | null } | null = null;
|
|
159
173
|
function getGitInfo(cwd: string): { branch: string | null; commitHash: string | null; remoteUrl: string | null } {
|
|
@@ -414,20 +428,46 @@ export function createMacroAgent(
|
|
|
414
428
|
params: NewSessionRequest,
|
|
415
429
|
): Promise<NewSessionResponse> {
|
|
416
430
|
const cwd = params.cwd ?? defaultCwd;
|
|
417
|
-
|
|
418
|
-
|
|
431
|
+
|
|
432
|
+
// Two paths into newSession:
|
|
433
|
+
//
|
|
434
|
+
// 1. MAP-bound stream — initConfig.targetAgentId is set by the ACP
|
|
435
|
+
// bridge to the local agent ID this stream was opened against.
|
|
436
|
+
// Bind the session to that specific agent (any role), preserving
|
|
437
|
+
// the routing intent that brought the stream here. This matters
|
|
438
|
+
// when multiple coordinators share a cwd: cwd-based lookup would
|
|
439
|
+
// pick whichever the store returned first, which may not be the
|
|
440
|
+
// one the client actually wanted to talk to.
|
|
441
|
+
//
|
|
442
|
+
// 2. Fallback — pure ACP client with no agent context. Use
|
|
443
|
+
// cwd-based head-manager lookup (spawning one if needed). This
|
|
444
|
+
// keeps stock ACP clients working without protocol changes.
|
|
445
|
+
let target: { id: string; session_id: string };
|
|
446
|
+
if (initConfig?.targetAgentId) {
|
|
447
|
+
const bound = agentManager.getActiveAgentSession(
|
|
448
|
+
initConfig.targetAgentId as any,
|
|
449
|
+
);
|
|
450
|
+
if (!bound) {
|
|
451
|
+
throw new ACPError(
|
|
452
|
+
`Agent ${initConfig.targetAgentId} is not running or has no active session`,
|
|
453
|
+
"AGENT_NOT_FOUND",
|
|
454
|
+
{ agentId: initConfig.targetAgentId },
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
target = { id: bound.id, session_id: bound.session_id };
|
|
458
|
+
} else {
|
|
459
|
+
const headManager = await agentManager.getOrCreateHeadManager({ cwd });
|
|
460
|
+
target = { id: headManager.id, session_id: headManager.session_id };
|
|
461
|
+
}
|
|
419
462
|
|
|
420
463
|
// Create session mapping
|
|
421
|
-
const mapping = sessionMapper.createMapping(
|
|
422
|
-
headManager.session_id,
|
|
423
|
-
headManager.id,
|
|
424
|
-
);
|
|
464
|
+
const mapping = sessionMapper.createMapping(target.session_id, target.id);
|
|
425
465
|
|
|
426
466
|
// Annotate sessionlog with swarm metadata (best effort)
|
|
427
467
|
try {
|
|
428
468
|
const { annotateSession } = await import("../integrations/sessionlog.js");
|
|
429
469
|
annotateSession(cwd, {
|
|
430
|
-
swarmId:
|
|
470
|
+
swarmId: target.id,
|
|
431
471
|
scope: "macro-agent",
|
|
432
472
|
});
|
|
433
473
|
} catch {
|
|
@@ -444,9 +484,93 @@ export function createMacroAgent(
|
|
|
444
484
|
): Promise<LoadSessionResponse> {
|
|
445
485
|
const sessionId = params.sessionId;
|
|
446
486
|
|
|
487
|
+
/**
|
|
488
|
+
* Resolve the provider_session_id (Claude Code's UUID for the session)
|
|
489
|
+
* for a given macro-agent acp session. Needed to locate the JSONL
|
|
490
|
+
* transcript on disk as a fallback when the in-memory buffer is empty.
|
|
491
|
+
*/
|
|
492
|
+
const resolveProviderSessionId = (agentId: string): string | undefined => {
|
|
493
|
+
const store = (system as any).agentStore;
|
|
494
|
+
const rec = store?.getAgent?.(agentId);
|
|
495
|
+
const meta = rec?.metadata as Record<string, unknown> | undefined;
|
|
496
|
+
const psid = meta?.provider_session_id;
|
|
497
|
+
return typeof psid === "string" ? psid : undefined;
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
// Replay helper: re-emit session/update notifications so the client can
|
|
501
|
+
// rebuild its view of the conversation. Per the ACP spec, loadSession
|
|
502
|
+
// SHOULD emit all previously-sent session/update events.
|
|
503
|
+
//
|
|
504
|
+
// Two sources in priority order:
|
|
505
|
+
// 1. In-memory buffer (populated by prior prompts in this process).
|
|
506
|
+
// Fast and accurate — exactly what was streamed to the client.
|
|
507
|
+
// 2. Claude Code's JSONL transcript on disk (durable across restarts).
|
|
508
|
+
// Used when the buffer is empty — e.g., we got a loadSession for a
|
|
509
|
+
// session that was prompted but whose updates were never buffered,
|
|
510
|
+
// or after a process restart within the same session's lifetime.
|
|
511
|
+
//
|
|
512
|
+
// This is a workaround: the ACP spec expects the underlying agent
|
|
513
|
+
// (claude-code-acp) to emit session/update during loadSession, but its
|
|
514
|
+
// current implementation only passes `resume: sessionId` to Claude Code
|
|
515
|
+
// SDK for internal context restoration without surfacing history to the
|
|
516
|
+
// client. See claude-code-replay.ts for details.
|
|
517
|
+
const replayHistory = async (agentIdForLookup?: string, explicitProviderSessionId?: string): Promise<void> => {
|
|
518
|
+
const buffered = sessionUpdateHistory.get(sessionId);
|
|
519
|
+
if (buffered && buffered.length > 0) {
|
|
520
|
+
for (const update of buffered) {
|
|
521
|
+
try {
|
|
522
|
+
await connection.sessionUpdate({ sessionId, update });
|
|
523
|
+
} catch {
|
|
524
|
+
// Best effort — continue replaying remaining updates
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Buffer empty — fall back to Claude Code JSONL if we can locate it.
|
|
531
|
+
// Provider session ID resolution priority:
|
|
532
|
+
// 1. Explicit — passed via ACP LoadSessionRequest._meta.provider_session_id
|
|
533
|
+
// (survives macro-agent restart since it comes from the client)
|
|
534
|
+
// 2. From agent metadata — when sessionMapper still has the mapping
|
|
535
|
+
const providerSessionId =
|
|
536
|
+
explicitProviderSessionId ??
|
|
537
|
+
(agentIdForLookup ? resolveProviderSessionId(agentIdForLookup) : undefined);
|
|
538
|
+
if (!providerSessionId) return;
|
|
539
|
+
|
|
540
|
+
try {
|
|
541
|
+
const { replayClaudeCodeTranscript } = await import("./claude-code-replay.js");
|
|
542
|
+
const bufferForFuture: SessionUpdate[] = [];
|
|
543
|
+
for await (const update of replayClaudeCodeTranscript(providerSessionId)) {
|
|
544
|
+
try {
|
|
545
|
+
await connection.sessionUpdate({ sessionId, update });
|
|
546
|
+
bufferForFuture.push(update);
|
|
547
|
+
} catch {
|
|
548
|
+
// Best effort — continue
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
// Populate the in-memory buffer so subsequent loadSession calls use
|
|
552
|
+
// the fast path. Only if we actually read something.
|
|
553
|
+
if (bufferForFuture.length > 0) {
|
|
554
|
+
sessionUpdateHistory.set(sessionId, bufferForFuture);
|
|
555
|
+
}
|
|
556
|
+
} catch {
|
|
557
|
+
// Transcript read failed — no history to emit
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
// Extract optional provider_session_id from ACP LoadSessionRequest._meta.
|
|
562
|
+
// Clients (like OpenHive) that know the underlying Claude Code session ID
|
|
563
|
+
// can pass it here to enable history recovery across macro-agent restarts
|
|
564
|
+
// (when sessionMapper is empty because it's in-memory only).
|
|
565
|
+
const metaProviderSessionId =
|
|
566
|
+
typeof (params as any)._meta?.provider_session_id === "string"
|
|
567
|
+
? ((params as any)._meta.provider_session_id as string)
|
|
568
|
+
: undefined;
|
|
569
|
+
|
|
447
570
|
// Check if we already have a mapping for this session
|
|
448
571
|
let mapping = sessionMapper.getMapping(sessionId);
|
|
449
572
|
if (mapping) {
|
|
573
|
+
await replayHistory(mapping.agentId, metaProviderSessionId);
|
|
450
574
|
return {};
|
|
451
575
|
}
|
|
452
576
|
|
|
@@ -459,6 +583,17 @@ export function createMacroAgent(
|
|
|
459
583
|
resumed.session_id,
|
|
460
584
|
resumed.id,
|
|
461
585
|
);
|
|
586
|
+
await replayHistory(resumed.id, metaProviderSessionId);
|
|
587
|
+
return {};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// No mapping — but if the client supplied provider_session_id via _meta,
|
|
591
|
+
// we can still replay history from the JSONL on disk. This is the
|
|
592
|
+
// cross-restart recovery path. We don't create a session mapping because
|
|
593
|
+
// there's no live agent to bind to — the client should create a fresh
|
|
594
|
+
// session if it wants to continue the conversation.
|
|
595
|
+
if (metaProviderSessionId) {
|
|
596
|
+
await replayHistory(undefined, metaProviderSessionId);
|
|
462
597
|
return {};
|
|
463
598
|
}
|
|
464
599
|
|
|
@@ -488,6 +623,20 @@ export function createMacroAgent(
|
|
|
488
623
|
}
|
|
489
624
|
const message = textParts.join("\n") || "";
|
|
490
625
|
|
|
626
|
+
// Record the user prompt as a user_message_chunk update in the replay
|
|
627
|
+
// buffer so session/load can reconstruct the full conversation. The
|
|
628
|
+
// agent itself doesn't emit this — the client sent it — but it IS part
|
|
629
|
+
// of the session's logical state.
|
|
630
|
+
for (const block of params.prompt) {
|
|
631
|
+
if ("text" in block && typeof block.text === "string" && block.text) {
|
|
632
|
+
const userChunk = {
|
|
633
|
+
sessionUpdate: "user_message_chunk",
|
|
634
|
+
content: { type: "text", text: block.text },
|
|
635
|
+
} as unknown as SessionUpdate;
|
|
636
|
+
appendSessionUpdate(params.sessionId, userChunk);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
491
640
|
sessionMapper.setProcessing(params.sessionId, true);
|
|
492
641
|
|
|
493
642
|
// Capture first prompt for trajectory metadata (used as session description in OpenHive UI)
|
|
@@ -507,7 +656,7 @@ export function createMacroAgent(
|
|
|
507
656
|
if (mapServer) {
|
|
508
657
|
const agents = mapServer.agents?.list?.() ?? [];
|
|
509
658
|
const mapAgent = agents.find(
|
|
510
|
-
(a: any) => a.metadata?.
|
|
659
|
+
(a: any) => a.metadata?.peerAgentId === agentId,
|
|
511
660
|
);
|
|
512
661
|
if (mapAgent) {
|
|
513
662
|
mapServer.agents.updateState(mapAgent.id, "busy");
|
|
@@ -703,6 +852,10 @@ export function createMacroAgent(
|
|
|
703
852
|
sessionId: params.sessionId,
|
|
704
853
|
update,
|
|
705
854
|
};
|
|
855
|
+
// Buffer for replay on session/load. Per the ACP spec, loadSession
|
|
856
|
+
// should re-emit all previously sent session/update notifications
|
|
857
|
+
// so the client can reconstruct state after reconnect.
|
|
858
|
+
appendSessionUpdate(params.sessionId, update);
|
|
706
859
|
await connection.sessionUpdate(notification);
|
|
707
860
|
}
|
|
708
861
|
}
|
|
@@ -742,6 +895,11 @@ export function createMacroAgent(
|
|
|
742
895
|
phase: "active",
|
|
743
896
|
startedAt: sessionStartedAt,
|
|
744
897
|
label: `Step ${checkpointCounter} (${toolCallCount} tool calls)`,
|
|
898
|
+
// Underlying Claude Code session ID — lets OpenHive find the
|
|
899
|
+
// JSONL transcript on disk for history recovery even if the
|
|
900
|
+
// macro-agent process dies.
|
|
901
|
+
provider_session_id:
|
|
902
|
+
(agentRecord as any)?.metadata?.provider_session_id ?? undefined,
|
|
745
903
|
// metrics fields
|
|
746
904
|
...(isMetrics ? {
|
|
747
905
|
duration_ms: Date.now() - promptStartTime,
|
|
@@ -785,7 +943,7 @@ export function createMacroAgent(
|
|
|
785
943
|
if (mapServer) {
|
|
786
944
|
const agents = mapServer.agents?.list?.() ?? [];
|
|
787
945
|
const mapAgent = agents.find(
|
|
788
|
-
(a: any) => a.metadata?.
|
|
946
|
+
(a: any) => a.metadata?.peerAgentId === agentId,
|
|
789
947
|
);
|
|
790
948
|
if (mapAgent) {
|
|
791
949
|
mapServer.agents.updateState(mapAgent.id, "idle");
|
package/src/acp/types.ts
CHANGED
|
@@ -54,6 +54,16 @@ export interface MacroAgentInitConfig {
|
|
|
54
54
|
|
|
55
55
|
/** Suffix appended to system prompts */
|
|
56
56
|
systemPromptSuffix?: string;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Local agent ID this ACP stream is bound to. When set, `session/new` binds
|
|
60
|
+
* the new session to this specific agent (any role) instead of falling back
|
|
61
|
+
* to cwd-based head-manager lookup. Set by the ACP-over-MAP bridge so that
|
|
62
|
+
* MAP-level routing (which already targets a specific agent) is preserved
|
|
63
|
+
* end-to-end through the ACP layer — important when multiple coordinators
|
|
64
|
+
* share the same cwd.
|
|
65
|
+
*/
|
|
66
|
+
targetAgentId?: string;
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
// ─────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentManagerV2 + TopologyPolicy integration (Phase 4).
|
|
3
|
+
*
|
|
4
|
+
* Verifies that when a TopologyPolicy is set, workspace allocation goes
|
|
5
|
+
* through the V3 path (YamlDrivenTopology → WorkspaceDecision → V3 methods).
|
|
6
|
+
* When unset, legacy role-name dispatch is preserved (regression guard).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
10
|
+
import { YamlDrivenTopology } from '../../workspace/topology/yaml-driven.js';
|
|
11
|
+
import { parseTeamWorkspaceConfig } from '../../workspace/yaml-schema.js';
|
|
12
|
+
|
|
13
|
+
// Isolated test: verify the TopologyPolicy → WorkspaceDecision compilation
|
|
14
|
+
// happens when policy is set. Full AgentManagerV2 spawn integration is
|
|
15
|
+
// covered by E2E tests; here we validate the delegation logic in isolation.
|
|
16
|
+
|
|
17
|
+
describe('AgentManagerV2 + TopologyPolicy (Phase 4)', () => {
|
|
18
|
+
describe('policy presence', () => {
|
|
19
|
+
it('YamlDrivenTopology is the primary policy for declarative teams', async () => {
|
|
20
|
+
const config = parseTeamWorkspaceConfig({
|
|
21
|
+
roles: {
|
|
22
|
+
peer: {
|
|
23
|
+
workspace: 'new_stream',
|
|
24
|
+
stream_lineage: 'fork_from_team_root',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const topology = new YamlDrivenTopology(config!);
|
|
29
|
+
|
|
30
|
+
// Policy name is stable (used for introspection)
|
|
31
|
+
expect(topology.name).toBe('yaml-driven');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('onAgentSpawn is invocable via policy reference', async () => {
|
|
35
|
+
const config = parseTeamWorkspaceConfig({
|
|
36
|
+
roles: {
|
|
37
|
+
peer: { workspace: 'none' },
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
const topology = new YamlDrivenTopology(config!);
|
|
41
|
+
|
|
42
|
+
const mockWs = {
|
|
43
|
+
createStreamV3: vi.fn(() => 'stream-1'),
|
|
44
|
+
allocateWorktree: vi.fn(),
|
|
45
|
+
} as unknown as import('../../workspace/types.js').WorkspaceManager;
|
|
46
|
+
|
|
47
|
+
await topology.onTeamStart({
|
|
48
|
+
teamName: 't',
|
|
49
|
+
teamInstanceId: 't-1',
|
|
50
|
+
workspaceConfig: config,
|
|
51
|
+
workspaceManager: mockWs,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const decision = await topology.onAgentSpawn({
|
|
55
|
+
agentId: 'a1',
|
|
56
|
+
role: 'peer',
|
|
57
|
+
workspaceManager: mockWs,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(decision.kind).toBe('none');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('policy absence (legacy path)', () => {
|
|
65
|
+
it('policy=null means AgentManagerV2 uses role-name dispatch (regression guard)', () => {
|
|
66
|
+
// This test documents the behavior contract: when
|
|
67
|
+
// setTopologyPolicy(null) or never called, the legacy switch(role)
|
|
68
|
+
// path in agent-manager-v2.ts:createWorkspaceForRole is the active
|
|
69
|
+
// code path. Validated by existing E2E tests for self-driving.
|
|
70
|
+
expect(true).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -452,6 +452,72 @@ describe("AgentManagerV2", () => {
|
|
|
452
452
|
});
|
|
453
453
|
});
|
|
454
454
|
|
|
455
|
+
// ── getOrCreateHeadManager() ───────────────────────────────
|
|
456
|
+
|
|
457
|
+
describe("getOrCreateHeadManager()", () => {
|
|
458
|
+
it("reuses an existing head manager matching the requested cwd", async () => {
|
|
459
|
+
const first = await manager.getOrCreateHeadManager({ cwd: "/tmp/proj-a" });
|
|
460
|
+
const second = await manager.getOrCreateHeadManager({ cwd: "/tmp/proj-a" });
|
|
461
|
+
expect(second.id).toBe(first.id);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("spawns a distinct head manager for a different cwd", async () => {
|
|
465
|
+
const a = await manager.getOrCreateHeadManager({ cwd: "/tmp/proj-a" });
|
|
466
|
+
const b = await manager.getOrCreateHeadManager({ cwd: "/tmp/proj-b" });
|
|
467
|
+
expect(b.id).not.toBe(a.id);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("getActiveAgentSession returns null for unknown agents", () => {
|
|
471
|
+
expect(manager.getActiveAgentSession("never-spawned" as AgentId)).toBeNull();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("getActiveAgentSession returns the spawned shape for any role with a live session", async () => {
|
|
475
|
+
// Spawn a non-coordinator (worker) so we exercise the role-agnostic path
|
|
476
|
+
const worker = await manager.spawn({ task: "Test work", role: "worker" });
|
|
477
|
+
|
|
478
|
+
const session = manager.getActiveAgentSession(worker.id as AgentId);
|
|
479
|
+
expect(session).not.toBeNull();
|
|
480
|
+
expect(session!.id).toBe(worker.id);
|
|
481
|
+
expect(session!.agent.role).toBe("worker");
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("getActiveAgentSession returns null after the agent is terminated", async () => {
|
|
485
|
+
const worker = await manager.spawn({ task: "Test work", role: "worker" });
|
|
486
|
+
await manager.terminate(worker.id as AgentId, "completed");
|
|
487
|
+
expect(manager.getActiveAgentSession(worker.id as AgentId)).toBeNull();
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("ignores stale 'running' store records whose session isn't live in this process", async () => {
|
|
491
|
+
// Simulate a crashed prior process: a coordinator record exists in the
|
|
492
|
+
// store with state='running' and parent_id=null, but has no matching
|
|
493
|
+
// activeSessions entry (the session was lost when the process died).
|
|
494
|
+
// Without the activeSessions filter in the predicate, getOrCreate would
|
|
495
|
+
// erroneously reuse this stale record and skip spawning.
|
|
496
|
+
agentStore.putAgent({
|
|
497
|
+
id: "stale-coord" as AgentId,
|
|
498
|
+
name: "stale-coord",
|
|
499
|
+
role: "coordinator",
|
|
500
|
+
state: "running",
|
|
501
|
+
parent_id: null,
|
|
502
|
+
lineage: [],
|
|
503
|
+
team: null,
|
|
504
|
+
scope: "default",
|
|
505
|
+
task: "",
|
|
506
|
+
task_id: "",
|
|
507
|
+
cwd: "/tmp/stale-proj",
|
|
508
|
+
capabilities: [],
|
|
509
|
+
created_at: Date.now() as any,
|
|
510
|
+
started_at: Date.now() as any,
|
|
511
|
+
config: {},
|
|
512
|
+
metadata: {},
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const result = await manager.getOrCreateHeadManager({ cwd: "/tmp/stale-proj" });
|
|
516
|
+
expect(result.id).not.toBe("stale-coord");
|
|
517
|
+
expect(manager.hasActiveSession(result.id as AgentId)).toBe(true);
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
455
521
|
// ── Lifecycle Callbacks ────────────────────────────────────
|
|
456
522
|
|
|
457
523
|
describe("lifecycle callbacks", () => {
|