macro-agent 0.2.4 → 0.2.5

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.
Files changed (40) hide show
  1. package/dist/agent/agent-manager-v2.d.ts.map +1 -1
  2. package/dist/agent/agent-manager-v2.js +182 -43
  3. package/dist/agent/agent-manager-v2.js.map +1 -1
  4. package/dist/agent/agent-manager.d.ts +17 -0
  5. package/dist/agent/agent-manager.d.ts.map +1 -1
  6. package/dist/agent/agent-manager.js.map +1 -1
  7. package/dist/agent/types.d.ts +21 -2
  8. package/dist/agent/types.d.ts.map +1 -1
  9. package/dist/agent/types.js.map +1 -1
  10. package/dist/cognitive/macro-agent-backend.d.ts.map +1 -1
  11. package/dist/cognitive/macro-agent-backend.js +37 -8
  12. package/dist/cognitive/macro-agent-backend.js.map +1 -1
  13. package/dist/cognitive/types.d.ts +24 -0
  14. package/dist/cognitive/types.d.ts.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/map/mail-bridge.d.ts +7 -0
  19. package/dist/map/mail-bridge.d.ts.map +1 -1
  20. package/dist/map/mail-bridge.js +39 -9
  21. package/dist/map/mail-bridge.js.map +1 -1
  22. package/dist/teams/team-runtime-v2.d.ts.map +1 -1
  23. package/dist/teams/team-runtime-v2.js +23 -6
  24. package/dist/teams/team-runtime-v2.js.map +1 -1
  25. package/package.json +1 -1
  26. package/src/agent/__tests__/agent-manager-v2.completion-signal.test.ts +284 -0
  27. package/src/agent/agent-manager-v2.ts +216 -50
  28. package/src/agent/agent-manager.ts +17 -0
  29. package/src/agent/types.ts +23 -2
  30. package/src/cognitive/__tests__/macro-agent-backend.test.ts +158 -5
  31. package/src/cognitive/macro-agent-backend.ts +39 -7
  32. package/src/cognitive/types.ts +24 -0
  33. package/src/index.ts +2 -0
  34. package/src/map/__tests__/mail-bridge.test.ts +39 -2
  35. package/src/map/mail-bridge.ts +61 -13
  36. package/src/teams/team-runtime-v2.ts +29 -6
  37. package/dist/workspace/dataplane-adapter.d.ts +0 -260
  38. package/dist/workspace/dataplane-adapter.d.ts.map +0 -1
  39. package/dist/workspace/dataplane-adapter.js +0 -416
  40. package/dist/workspace/dataplane-adapter.js.map +0 -1
@@ -0,0 +1,284 @@
1
+ /**
2
+ * S1 (completion-detection) unit tests for promptUntilDone's optional
3
+ * `isComplete` predicate.
4
+ *
5
+ * The macro `promptUntilDone` normally completes ONLY when the agent calls the
6
+ * macro done() tool; otherwise it re-prompts "call done()" up to maxFollowUps
7
+ * times (each a slow full turn). The τ agent calls τ's `finish`, not macro
8
+ * done(), so without an external completion predicate the loop exhausts
9
+ * maxFollowUps and Atlas.solve() hangs until the rollout backstop.
10
+ *
11
+ * These tests drive promptUntilDone with a MOCKED acp session `prompt` stream
12
+ * (no live agent) and assert:
13
+ * - an `isComplete` that flips true after the first streamed update breaks the
14
+ * loop on attempt 0, calls terminate(agentId, "completed"), returns
15
+ * completedExternally:true, and does NOT exhaust maxFollowUps;
16
+ * - back-compat: with NO predicate the existing done()-detection is unchanged.
17
+ */
18
+
19
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
20
+ import { createAgentManagerV2 } from "../agent-manager-v2.js";
21
+ import { AgentStore } from "../agent-store.js";
22
+ import type { AgentManager } from "../agent-manager.js";
23
+ import type { InboxAdapter, TasksAdapter } from "../../adapters/types.js";
24
+
25
+ // ── Mock acp-factory: a controllable per-spawn session.prompt stream ───────
26
+ // `promptStreamFactory` is reassigned per test to control what each prompt
27
+ // attempt yields. `promptCallCount` lets us assert how many attempts ran.
28
+ let promptStreamFactory: () => AsyncIterable<unknown>;
29
+ let promptCallCount = 0;
30
+ // Invoked when the agent handle is closed (i.e. terminate() tears down the
31
+ // session). The silent-stream repro registers a hook here so that ONLY a real
32
+ // terminate() can end its otherwise-forever-blocked stream — faithfully
33
+ // modelling the live "stream ends/throws on teardown" behavior. On the old
34
+ // reactive-only code terminate() is never called mid-stream, so the stream
35
+ // never ends → the for-await hangs (red).
36
+ let onHandleClose: (() => void) | null = null;
37
+
38
+ vi.mock("acp-factory", () => ({
39
+ AgentFactory: {
40
+ spawn: vi.fn().mockResolvedValue({
41
+ createSession: vi.fn().mockResolvedValue({
42
+ id: "provider-session-1",
43
+ prompt: vi.fn().mockImplementation(() => {
44
+ promptCallCount++;
45
+ return promptStreamFactory();
46
+ }),
47
+ forkWithFlush: vi.fn().mockResolvedValue({ id: "forked-session-1" }),
48
+ }),
49
+ loadSession: vi.fn().mockResolvedValue({ id: "loaded-session-1" }),
50
+ close: vi.fn().mockImplementation(async () => {
51
+ onHandleClose?.();
52
+ }),
53
+ isRunning: vi.fn().mockReturnValue(true),
54
+ }),
55
+ },
56
+ }));
57
+
58
+ function createMockInboxAdapter(): InboxAdapter {
59
+ return {
60
+ registerAgent: vi.fn().mockResolvedValue(undefined),
61
+ deregisterAgent: vi.fn().mockResolvedValue(undefined),
62
+ send: vi.fn().mockResolvedValue("msg-1"),
63
+ onDelivery: vi.fn(),
64
+ offDelivery: vi.fn(),
65
+ checkInbox: vi.fn().mockResolvedValue([]),
66
+ readThread: vi.fn().mockResolvedValue([]),
67
+ setSignalFilter: vi.fn(),
68
+ setEmissionValidator: vi.fn(),
69
+ socketPath: "/tmp/test-inbox.sock",
70
+ stop: vi.fn().mockResolvedValue(undefined),
71
+ } as unknown as InboxAdapter;
72
+ }
73
+
74
+ // A single benign streamed update (an agent message chunk — NOT a done() tool
75
+ // call), used to trigger the isComplete check mid-stream.
76
+ function* oneMessageUpdate(): Generator<unknown> {
77
+ yield {
78
+ sessionUpdate: "agent_message_chunk",
79
+ content: { type: "text", text: "working" },
80
+ };
81
+ }
82
+
83
+ // A stream that emits a macro done() tool-call update (title ends with __done),
84
+ // for the back-compat path.
85
+ function* doneToolUpdate(): Generator<unknown> {
86
+ yield {
87
+ sessionUpdate: "tool_call",
88
+ title: "mcp__macro-agent__done",
89
+ rawInput: { status: "completed" },
90
+ };
91
+ }
92
+
93
+ async function* asAsync(gen: Iterable<unknown>): AsyncIterable<unknown> {
94
+ for (const u of gen) yield u;
95
+ }
96
+
97
+ // Reproduces the live τ hang: the agent runs the whole episode in ONE prompt()
98
+ // call, yields a couple of updates, then the stream goes SILENT but does NOT
99
+ // close — the async iterator just blocks forever. With only the reactive
100
+ // per-update / per-attempt checks, isComplete is never re-evaluated and the
101
+ // for-await never unblocks.
102
+ //
103
+ // The ONLY way out is terminate(): it tears down the session (calls
104
+ // handle.close()), which we wire to release the dangling await — faithfully
105
+ // modelling the live "stream ends on teardown" behavior. So on the old
106
+ // reactive-only code (terminate never called mid-stream) the stream NEVER ends
107
+ // → hang (red); the concurrent poller calls terminate → close → release →
108
+ // stream ends → completion (green).
109
+ function silentThenHangStream(): () => AsyncIterable<unknown> {
110
+ let release: () => void = () => {};
111
+ const hang = new Promise<void>((resolve) => {
112
+ release = resolve;
113
+ });
114
+ // terminate() → handle.close() → release the blocked stream.
115
+ onHandleClose = () => release();
116
+ async function* gen(): AsyncIterable<unknown> {
117
+ yield {
118
+ sessionUpdate: "agent_message_chunk",
119
+ content: { type: "text", text: "working 1" },
120
+ };
121
+ yield {
122
+ sessionUpdate: "agent_message_chunk",
123
+ content: { type: "text", text: "working 2" },
124
+ };
125
+ // Silent-but-open: block forever until terminate() releases it — exactly
126
+ // the live failure mode (silent, non-closing iterator).
127
+ await hang;
128
+ }
129
+ return gen;
130
+ }
131
+
132
+ function createMockTasksAdapter(): TasksAdapter {
133
+ return {
134
+ createTask: vi.fn().mockResolvedValue("ot-task-1"),
135
+ assignTask: vi.fn().mockResolvedValue(undefined),
136
+ transitionTask: vi.fn().mockResolvedValue(undefined),
137
+ getTask: vi.fn().mockResolvedValue(null),
138
+ queryReady: vi.fn().mockResolvedValue([]),
139
+ listTasks: vi.fn().mockResolvedValue([]),
140
+ addBlocker: vi.fn().mockResolvedValue(undefined),
141
+ removeBlocker: vi.fn().mockResolvedValue(undefined),
142
+ claimTask: vi.fn().mockResolvedValue(null),
143
+ unclaimTask: vi.fn().mockResolvedValue(undefined),
144
+ listClaimable: vi.fn().mockResolvedValue([]),
145
+ connect: vi.fn().mockResolvedValue(undefined),
146
+ disconnect: vi.fn(),
147
+ connected: true,
148
+ } as unknown as TasksAdapter;
149
+ }
150
+
151
+ describe("promptUntilDone — S1 external completion predicate", () => {
152
+ let agentStore: AgentStore;
153
+ let inboxAdapter: InboxAdapter;
154
+ let manager: AgentManager;
155
+
156
+ beforeEach(() => {
157
+ promptCallCount = 0;
158
+ onHandleClose = null;
159
+ agentStore = new AgentStore(":memory:");
160
+ inboxAdapter = createMockInboxAdapter();
161
+ manager = createAgentManagerV2(agentStore, inboxAdapter, createMockTasksAdapter(), {
162
+ defaultCwd: "/tmp/test",
163
+ });
164
+ });
165
+
166
+ afterEach(async () => {
167
+ await manager.close();
168
+ agentStore.close();
169
+ });
170
+
171
+ it("breaks on attempt 0 when isComplete flips true after the first update; terminates 'completed'; completedExternally:true; does NOT exhaust maxFollowUps", async () => {
172
+ promptStreamFactory = () => asAsync(oneMessageUpdate());
173
+
174
+ const spawned = await manager.spawn({ task: "tau episode", role: "worker" });
175
+ expect(manager.hasActiveSession(spawned.id)).toBe(true);
176
+
177
+ // Flip true after the first update is observed.
178
+ let updates = 0;
179
+ const isComplete = vi.fn(() => {
180
+ updates++;
181
+ return updates >= 1; // true from the first check on
182
+ });
183
+
184
+ const maxFollowUps = 16;
185
+ const result = await manager.promptUntilDone(spawned.id, "go", {
186
+ maxFollowUps,
187
+ isComplete,
188
+ });
189
+
190
+ expect(result.completedExternally).toBe(true);
191
+ expect(result.doneCalled).toBe(false);
192
+ // Only the initial attempt ran — no "please call done()" re-prompts.
193
+ expect(promptCallCount).toBe(1);
194
+ expect(isComplete).toHaveBeenCalled();
195
+ // Mirrors the done() path: the agent is terminated (its active session is
196
+ // torn down + the store record transitions to "stopped"). terminate() is an
197
+ // internal closure call, so we observe its EFFECT rather than spy on it.
198
+ expect(manager.hasActiveSession(spawned.id)).toBe(false);
199
+ expect(agentStore.getAgent(spawned.id)!.state).toBe("stopped");
200
+ });
201
+
202
+ it("REPRO (live hang): silent-but-open stream + poll-only isComplete → concurrent poller terminates and returns bounded (completedExternally:true, promptCallCount===1)", async () => {
203
+ // The stream yields 2 updates then blocks forever without closing. The
204
+ // predicate is true from the start but can ONLY be observed via the
205
+ // concurrent poller, because no further updates arrive to drive the
206
+ // reactive per-update check, and the per-attempt check never runs (the
207
+ // for-await never ends on its own). On the OLD reactive-only code this
208
+ // test HANGS until the vitest timeout (red). With the concurrent poller it
209
+ // returns within a few poll intervals (green).
210
+ promptStreamFactory = silentThenHangStream();
211
+
212
+ const spawned = await manager.spawn({ task: "tau episode (silent)", role: "worker" });
213
+ expect(manager.hasActiveSession(spawned.id)).toBe(true);
214
+
215
+ // FALSE during the streamed updates (so the reactive per-update check
216
+ // (a) does NOT catch it), then TRUE once the episode has externally
217
+ // completed. After the 2 updates the stream goes silent forever, so the
218
+ // reactive checks never run again — ONLY the concurrent poller can observe
219
+ // the flip. (The 2 per-update reactive checks consume the first 2 false
220
+ // returns; everything after — i.e. the poller — sees true.)
221
+ let checks = 0;
222
+ const isComplete = vi.fn(() => {
223
+ checks++;
224
+ return checks > 2;
225
+ });
226
+
227
+ const pollIntervalMs = 20;
228
+ const maxFollowUps = 16;
229
+
230
+ const start = Date.now();
231
+ const result = await manager.promptUntilDone(spawned.id, "go", {
232
+ maxFollowUps,
233
+ isComplete,
234
+ pollIntervalMs,
235
+ });
236
+ const elapsed = Date.now() - start;
237
+
238
+ // Bounded return — NOT a hang, NOT maxFollowUps exhaustion.
239
+ expect(elapsed).toBeLessThan(2000);
240
+ expect(result.completedExternally).toBe(true);
241
+ expect(result.doneCalled).toBe(false);
242
+ // The whole episode is one prompt() call; no re-prompts.
243
+ expect(promptCallCount).toBe(1);
244
+ expect(isComplete).toHaveBeenCalled();
245
+ // terminate('completed') was effected: session torn down + store stopped.
246
+ expect(manager.hasActiveSession(spawned.id)).toBe(false);
247
+ expect(agentStore.getAgent(spawned.id)!.state).toBe("stopped");
248
+ });
249
+
250
+ it("back-compat: with NO predicate, done()-detection is unchanged (doneCalled true, completedExternally false, terminate 'completed')", async () => {
251
+ promptStreamFactory = () => asAsync(doneToolUpdate());
252
+
253
+ const spawned = await manager.spawn({ task: "normal agent", role: "worker" });
254
+
255
+ const result = await manager.promptUntilDone(spawned.id, "go", {
256
+ maxFollowUps: 2,
257
+ });
258
+
259
+ expect(result.doneCalled).toBe(true);
260
+ expect(result.doneStatus).toBe("completed");
261
+ expect(result.completedExternally).toBe(false);
262
+ expect(promptCallCount).toBe(1);
263
+ // done()-detection still terminates the agent (unchanged behavior).
264
+ expect(manager.hasActiveSession(spawned.id)).toBe(false);
265
+ expect(agentStore.getAgent(spawned.id)!.state).toBe("stopped");
266
+ });
267
+
268
+ it("no predicate + no done(): exhausts maxFollowUps (unchanged re-prompt behavior)", async () => {
269
+ // Empty stream each attempt — agent never calls done(), no predicate.
270
+ promptStreamFactory = () => asAsync([]);
271
+
272
+ const spawned = await manager.spawn({ task: "silent agent", role: "worker" });
273
+
274
+ const maxFollowUps = 3;
275
+ const result = await manager.promptUntilDone(spawned.id, "go", {
276
+ maxFollowUps,
277
+ });
278
+
279
+ expect(result.doneCalled).toBe(false);
280
+ expect(result.completedExternally).toBe(false);
281
+ // Initial attempt + maxFollowUps re-prompts = maxFollowUps + 1 prompt calls.
282
+ expect(promptCallCount).toBe(maxFollowUps + 1);
283
+ });
284
+ });
@@ -48,7 +48,7 @@ import type {
48
48
  AgentLifecycleEvent,
49
49
  AgentConfig,
50
50
  ContinueAgentOptions,
51
- MCPServerConfig,
51
+ MCPServerStdioConfig,
52
52
  } from "./types.js";
53
53
  import { AgentManagerError } from "./types.js";
54
54
  import type { RoleRegistry, Capability } from "../roles/types.js";
@@ -220,6 +220,12 @@ export function createAgentManagerV2(
220
220
  const activeSessions = new Map<AgentId, ActiveSession>();
221
221
  const agentWorkspaces = new Map<AgentId, Workspace>();
222
222
  const lifecycleListeners = new Set<AgentLifecycleCallback>();
223
+ // In-flight fire-and-forget terminate() promises (e.g. the promptUntilDone
224
+ // completion poller tears the agent down without awaiting it, to keep
225
+ // completion latency low). close() awaits these so shutdown cannot return
226
+ // while a group-kill is still racing — which would let process.exit orphan
227
+ // the subtree.
228
+ const pendingTerminations = new Set<Promise<unknown>>();
223
229
  let spawnInterceptor: SpawnInterceptor | null = null;
224
230
  let isShuttingDown = false;
225
231
  let mapServerUrl: string | undefined;
@@ -285,7 +291,7 @@ export function createAgentManagerV2(
285
291
  lineage?: string[];
286
292
  sessionId?: string;
287
293
  streamId?: string;
288
- }): MCPServerConfig {
294
+ }): MCPServerStdioConfig {
289
295
  const env: Record<string, string> = {
290
296
  MACRO_AGENT_ID: opts.agentId,
291
297
  MACRO_PARENT_ID: opts.parentId,
@@ -759,15 +765,28 @@ export function createAgentManagerV2(
759
765
  value: v,
760
766
  })),
761
767
  },
762
- ...(agentConfig?.mcpServers?.map((s) => ({
763
- name: s.name,
764
- command: s.command,
765
- args: s.args ?? [],
766
- env: Object.entries(s.env ?? {}).map(([k, v]) => ({
767
- name: k,
768
- value: v,
769
- })),
770
- })) ?? []),
768
+ ...(agentConfig?.mcpServers?.map((s) => {
769
+ if ("command" in s) {
770
+ return {
771
+ name: s.name,
772
+ command: s.command,
773
+ args: s.args ?? [],
774
+ env: Object.entries(s.env ?? {}).map(([k, v]) => ({
775
+ name: k,
776
+ value: v,
777
+ })),
778
+ };
779
+ }
780
+ return {
781
+ name: s.name,
782
+ type: s.type,
783
+ url: s.url,
784
+ headers: Object.entries(s.headers ?? {}).map(([k, v]) => ({
785
+ name: k,
786
+ value: v,
787
+ })),
788
+ };
789
+ }) ?? []),
771
790
  ];
772
791
 
773
792
  // Always-on subsystem MCP servers (the "trinity"). The macro-agent
@@ -1753,63 +1772,189 @@ export function createAgentManagerV2(
1753
1772
  options?: {
1754
1773
  maxFollowUps?: number;
1755
1774
  onUpdate?: (update: ExtendedSessionUpdate) => void;
1775
+ /**
1776
+ * Optional caller-supplied completion predicate. When it returns true the
1777
+ * loop stops consuming, terminates the agent as "completed", and returns
1778
+ * `completedExternally: true` — WITHOUT requiring the macro `done()` tool.
1779
+ *
1780
+ * Checked three ways (all fire terminate + return completedExternally):
1781
+ * (a) reactively after each streamed update (fast path),
1782
+ * (b) reactively after each attempt's stream ends (fast path),
1783
+ * (c) on a CONCURRENT ~2s timer while consuming the stream. (c) is the
1784
+ * critical path for the live τ rollout: the agent runs the whole
1785
+ * episode in ONE prompt() call, calls τ's finish, the env writes its
1786
+ * reward sink (done:true), and then the prompt() stream goes SILENT
1787
+ * and does NOT close — the async iterator just blocks. With only the
1788
+ * reactive checks (a)/(b) the predicate is never re-evaluated and
1789
+ * solve() hangs until the rollout backstop. The concurrent poller
1790
+ * observes done:true independent of update arrival, flips
1791
+ * completedExternally, and terminate()s the agent — which ends the
1792
+ * in-flight prompt() generator (via return or throw, both handled).
1793
+ *
1794
+ * Generic by design: this module has NO knowledge of what the predicate
1795
+ * reads (e.g. an external reward sink). Back-compat: when absent, NO poller
1796
+ * is started and behavior is unchanged — only `done()` detection drives
1797
+ * completion.
1798
+ *
1799
+ * @param pollIntervalMs Override the concurrent poll cadence (default
1800
+ * 2000ms). Primarily a test seam.
1801
+ */
1802
+ isComplete?: () => boolean | Promise<boolean>;
1803
+ pollIntervalMs?: number;
1756
1804
  }
1757
1805
  ): Promise<{
1758
1806
  doneCalled: boolean;
1759
1807
  doneStatus?: string;
1808
+ completedExternally: boolean;
1760
1809
  updates: ExtendedSessionUpdate[];
1761
1810
  }> {
1762
1811
  const maxFollowUps = options?.maxFollowUps ?? 2;
1812
+ const pollIntervalMs = options?.pollIntervalMs ?? 2000;
1763
1813
  const allUpdates: ExtendedSessionUpdate[] = [];
1764
1814
  let doneCalled = false;
1765
1815
  let doneStatus: string | undefined;
1816
+ let completedExternally = false;
1766
1817
 
1767
1818
  let currentMessage = message;
1768
1819
 
1769
1820
  for (let attempt = 0; attempt <= maxFollowUps; attempt++) {
1770
- for await (const update of prompt(agentId, currentMessage)) {
1771
- allUpdates.push(update);
1772
- options?.onUpdate?.(update);
1821
+ // ── Concurrent completion poller ─────────────────────────────────────
1822
+ // Only armed when the caller supplies a predicate (back-compat: no
1823
+ // predicate → no poller, identical behavior). The live failure mode is a
1824
+ // prompt() stream that has gone SILENT but not CLOSED after the episode
1825
+ // externally completed; the reactive per-update check never fires again
1826
+ // because no updates arrive. This timer re-evaluates isComplete()
1827
+ // independent of update arrival. On true it flips completedExternally and
1828
+ // terminate()s the agent, which ends the in-flight prompt() generator
1829
+ // (return or throw) so the for-await below unblocks. The interval is
1830
+ // always cleared in `finally` so no timer leaks across attempts.
1831
+ let pollTimer: ReturnType<typeof setInterval> | undefined;
1832
+ let polling = false;
1833
+
1834
+ // Resolves the instant completion is detected (external predicate OR the
1835
+ // macro done() tool). The attempt is RACED against this signal so it
1836
+ // returns WITHOUT waiting for the prompt() generator to drain or for
1837
+ // terminate() to finish. The prior design relied on terminate() ending
1838
+ // the silent in-flight stream to unblock the for-await; when that teardown
1839
+ // stalled (e.g. a long, busy real episode whose subprocess is slow to die),
1840
+ // promptUntilDone never returned and the macro session stayed "running"
1841
+ // until the multi-hour hard-timer — the refinement "finalization hang".
1842
+ let signalDone: () => void = () => {};
1843
+ const doneSignal = new Promise<void>((resolve) => {
1844
+ signalDone = resolve;
1845
+ });
1773
1846
 
1774
- // Detect done() tool call from session updates.
1775
- // acp-factory uses { sessionUpdate: "tool_call", title: "mcp__macro-agent__done" }
1776
- const uAny = update as any;
1847
+ const startPoller = () => {
1848
+ if (!options?.isComplete) return;
1849
+ pollTimer = setInterval(() => {
1850
+ if (polling || completedExternally) return;
1851
+ polling = true;
1852
+ void (async () => {
1853
+ try {
1854
+ if (!completedExternally && options.isComplete && (await options.isComplete())) {
1855
+ completedExternally = true;
1856
+ signalDone();
1857
+ // Tear down the agent/session in the BACKGROUND — never await it
1858
+ // here, so a slow teardown cannot gate the return. Tracked in
1859
+ // pendingTerminations so close() awaits it before shutdown
1860
+ // returns (else a still-racing group-kill is abandoned by a
1861
+ // subsequent process.exit, orphaning the subtree). Best effort.
1862
+ {
1863
+ const t = terminate(agentId, "completed" as any).catch(() => {});
1864
+ pendingTerminations.add(t);
1865
+ void t.finally(() => pendingTerminations.delete(t));
1866
+ }
1867
+ }
1868
+ } catch {
1869
+ // Predicate errored — leave completion to the reactive checks.
1870
+ } finally {
1871
+ polling = false;
1872
+ }
1873
+ })();
1874
+ }, pollIntervalMs);
1875
+ };
1777
1876
 
1778
- // Check title field (primary detection)
1779
- if (
1780
- (uAny.sessionUpdate === "tool_call" || uAny.sessionUpdate === "tool_call_update") &&
1781
- typeof uAny.title === "string" &&
1782
- uAny.title.endsWith("__done")
1783
- ) {
1784
- doneCalled = true;
1785
- // Extract status from rawInput (may arrive across multiple updates —
1786
- // first update has rawInput={}, subsequent has full input)
1787
- try {
1788
- const raw = uAny.rawInput;
1789
- const input =
1790
- typeof raw === "string" ? JSON.parse(raw) :
1791
- typeof raw === "object" ? raw :
1792
- uAny.input;
1793
- if (input?.status) {
1794
- doneStatus = input.status;
1877
+ // Consume the stream as a background task so a silent-but-open generator
1878
+ // can be raced against the completion signal below.
1879
+ const consume = (async () => {
1880
+ for await (const update of prompt(agentId, currentMessage)) {
1881
+ allUpdates.push(update);
1882
+ options?.onUpdate?.(update);
1883
+
1884
+ // External completion predicate (reactive fast path).
1885
+ if (options?.isComplete && (await options.isComplete())) {
1886
+ completedExternally = true;
1887
+ signalDone();
1888
+ break;
1889
+ }
1890
+ if (completedExternally) break;
1891
+
1892
+ // Detect done() tool call from session updates.
1893
+ // acp-factory uses { sessionUpdate: "tool_call", title: "mcp__macro-agent__done" }
1894
+ const uAny = update as any;
1895
+
1896
+ // Check title field (primary detection)
1897
+ if (
1898
+ (uAny.sessionUpdate === "tool_call" || uAny.sessionUpdate === "tool_call_update") &&
1899
+ typeof uAny.title === "string" &&
1900
+ uAny.title.endsWith("__done")
1901
+ ) {
1902
+ doneCalled = true;
1903
+ // Extract status from rawInput (may arrive across multiple updates —
1904
+ // first update has rawInput={}, subsequent has full input)
1905
+ try {
1906
+ const raw = uAny.rawInput;
1907
+ const input =
1908
+ typeof raw === "string" ? JSON.parse(raw) :
1909
+ typeof raw === "object" ? raw :
1910
+ uAny.input;
1911
+ if (input?.status) {
1912
+ doneStatus = input.status;
1913
+ }
1914
+ } catch {
1915
+ // Best effort — rawInput may not be parseable yet
1795
1916
  }
1796
- } catch {
1797
- // Best effort — rawInput may not be parseable yet
1917
+ signalDone();
1798
1918
  }
1799
- }
1800
1919
 
1801
- // Fallback: check older format
1802
- if (
1803
- uAny.type === "result" &&
1804
- uAny.subtype === "tool_result" &&
1805
- uAny.toolName === "done"
1806
- ) {
1807
- doneCalled = true;
1808
- doneStatus = uAny.result?.status;
1920
+ // Fallback: check older format
1921
+ if (
1922
+ uAny.type === "result" &&
1923
+ uAny.subtype === "tool_result" &&
1924
+ uAny.toolName === "done"
1925
+ ) {
1926
+ doneCalled = true;
1927
+ doneStatus = uAny.result?.status;
1928
+ signalDone();
1929
+ }
1809
1930
  }
1931
+ })();
1932
+ // Prevent an unhandled rejection if we return via doneSignal before the
1933
+ // now-orphaned stream throws from a background teardown.
1934
+ consume.catch(() => {});
1935
+
1936
+ try {
1937
+ startPoller();
1938
+ // Return as soon as EITHER the stream drains naturally OR completion is
1939
+ // signalled — whichever comes first. This is the key change: completion
1940
+ // no longer depends on the generator unblocking.
1941
+ await Promise.race([consume, doneSignal]);
1942
+ } catch (err) {
1943
+ // A genuine stream error (not a post-completion teardown) re-throws.
1944
+ if (!completedExternally && !doneCalled) throw err;
1945
+ } finally {
1946
+ // Always clear the concurrent poller so no timer leaks across attempts.
1947
+ if (pollTimer) clearInterval(pollTimer);
1948
+ }
1949
+
1950
+ // Re-check after the stream ends in case completion landed exactly as the
1951
+ // attempt's stream drained (the inner break / poller already set the flag
1952
+ // when it fired mid-stream).
1953
+ if (!completedExternally && options?.isComplete && (await options.isComplete())) {
1954
+ completedExternally = true;
1810
1955
  }
1811
1956
 
1812
- if (doneCalled) break;
1957
+ if (doneCalled || completedExternally) break;
1813
1958
 
1814
1959
  if (attempt < maxFollowUps) {
1815
1960
  currentMessage =
@@ -1817,9 +1962,11 @@ export function createAgentManagerV2(
1817
1962
  }
1818
1963
  }
1819
1964
 
1820
- // Auto-terminate when done() was called and the handler signaled shouldTerminate.
1821
- // This closes the lifecycle gap: without this, agents stay in "running" state
1822
- // after calling done() because nothing triggers terminate().
1965
+ // Auto-terminate when the agent completed either via the macro done() tool
1966
+ // or via the caller's external completion predicate. This closes the
1967
+ // lifecycle gap: without this, agents stay in "running" state because
1968
+ // nothing triggers terminate(). The external path mirrors the done() path
1969
+ // (terminate as "completed").
1823
1970
  if (doneCalled) {
1824
1971
  const reason = doneStatus === "completed" ? "completed" : (doneStatus ?? "failed");
1825
1972
  try {
@@ -1827,9 +1974,15 @@ export function createAgentManagerV2(
1827
1974
  } catch {
1828
1975
  // Best effort — agent may already be stopping
1829
1976
  }
1977
+ } else if (completedExternally) {
1978
+ try {
1979
+ await terminate(agentId, "completed" as any);
1980
+ } catch {
1981
+ // Best effort — agent may already be stopping
1982
+ }
1830
1983
  }
1831
1984
 
1832
- return { doneCalled, doneStatus, updates: allUpdates };
1985
+ return { doneCalled, doneStatus, completedExternally, updates: allUpdates };
1833
1986
  }
1834
1987
 
1835
1988
  function getSession(agentId: AgentId): Session | null {
@@ -1974,6 +2127,19 @@ export function createAgentManagerV2(
1974
2127
  });
1975
2128
  }
1976
2129
  activeSessions.clear();
2130
+
2131
+ // Await any in-flight fire-and-forget terminations (e.g. the completion
2132
+ // poller's background teardown) so shutdown does not return while a
2133
+ // group-kill is still racing — otherwise a follow-on process.exit abandons
2134
+ // it and orphans the acp/claude/mcp_env subtree. Bounded by a backstop so a
2135
+ // genuinely stuck close() cannot wedge shutdown.
2136
+ if (pendingTerminations.size > 0) {
2137
+ await Promise.race([
2138
+ Promise.allSettled([...pendingTerminations]),
2139
+ new Promise((resolve) => setTimeout(resolve, 6000)),
2140
+ ]);
2141
+ }
2142
+
1977
2143
  agentWorkspaces.clear();
1978
2144
  lifecycleListeners.clear();
1979
2145
 
@@ -164,10 +164,27 @@ export interface AgentManager {
164
164
  maxFollowUps?: number;
165
165
  /** Callback for each update during prompting */
166
166
  onUpdate?: (update: ExtendedSessionUpdate) => void;
167
+ /**
168
+ * Optional caller-supplied completion predicate. When it returns true the
169
+ * loop stops early and terminates the agent as "completed" WITHOUT
170
+ * requiring the macro done() tool. Back-compat: absent → unchanged.
171
+ *
172
+ * Evaluated reactively per-update/per-attempt AND on a concurrent timer
173
+ * (see implementation) so a silent-but-open prompt() stream still
174
+ * completes. No predicate → no poller, unchanged behavior.
175
+ */
176
+ isComplete?: () => boolean | Promise<boolean>;
177
+ /**
178
+ * Override the concurrent isComplete() poll cadence in ms (default 2000).
179
+ * Only meaningful alongside `isComplete`; primarily a test seam.
180
+ */
181
+ pollIntervalMs?: number;
167
182
  },
168
183
  ): Promise<{
169
184
  doneCalled: boolean;
170
185
  doneStatus?: string;
186
+ /** True when the caller's isComplete() predicate ended the loop. */
187
+ completedExternally: boolean;
171
188
  updates: ExtendedSessionUpdate[];
172
189
  }>;
173
190