gentle-pi 2.3.0 → 2.5.0

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 (153) hide show
  1. package/README.md +195 -11
  2. package/assets/agents/gentle-ai-worker.md +9 -0
  3. package/assets/agents/sdd-explore.md +1 -0
  4. package/assets/orchestrator-delegation.md +21 -10
  5. package/assets/orchestrator.md +8 -12
  6. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  7. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
  8. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
  9. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
  10. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
  11. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
  12. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
  13. package/docs/delegated-verification.md +25 -0
  14. package/docs/review-integration.md +1 -1
  15. package/docs/telemetry.md +38 -0
  16. package/extensions/ask-user-choice.ts +26 -20
  17. package/extensions/codegraph-tools.ts +94 -5
  18. package/extensions/gentle-agents.ts +588 -0
  19. package/extensions/gentle-ai.ts +1421 -143
  20. package/extensions/gentle-shell.ts +547 -0
  21. package/extensions/gentle-todo.ts +199 -0
  22. package/extensions/quiet-tools.ts +1 -1
  23. package/lib/agent-home.ts +8 -0
  24. package/lib/agents-config.ts +318 -0
  25. package/lib/agents-history.ts +80 -0
  26. package/lib/agents-protocol.ts +429 -0
  27. package/lib/agents-runner.ts +490 -0
  28. package/lib/agents-transcript.ts +87 -0
  29. package/lib/agents-view.ts +557 -0
  30. package/lib/agents-widget.ts +222 -0
  31. package/lib/gentle-ai-renderer.ts +142 -26
  32. package/lib/native-choice-list.ts +194 -0
  33. package/lib/native-fullscreen-interaction.ts +47 -0
  34. package/lib/native-pointer-region.ts +164 -0
  35. package/lib/native-review-cli.ts +103 -12
  36. package/lib/provider-contract-bundle.ts +88 -6
  37. package/lib/review-candidate-view-owner.ts +177 -0
  38. package/lib/review-candidate-view.ts +127 -35
  39. package/lib/review-consent-ui.ts +65 -0
  40. package/lib/review-host-relay.ts +146 -60
  41. package/lib/review-integration-v2.ts +92 -13
  42. package/lib/review-last-event-controller.ts +1 -0
  43. package/lib/review-relay-contract.ts +11 -0
  44. package/lib/review-repository.ts +2 -2
  45. package/lib/review-risk-assessment.ts +339 -0
  46. package/lib/review-session-standing-permission-ipc.ts +309 -0
  47. package/lib/review-session-standing-permission.ts +219 -0
  48. package/lib/sdd-preflight.ts +2 -2
  49. package/lib/shell-bar.ts +138 -0
  50. package/lib/shell-card.ts +136 -0
  51. package/lib/shell-changes-view.ts +205 -0
  52. package/lib/shell-changes.ts +210 -0
  53. package/lib/shell-gauge.ts +40 -0
  54. package/lib/shell-prompt.ts +119 -0
  55. package/lib/shell-todo.ts +280 -0
  56. package/lib/shell-usage-view.ts +76 -0
  57. package/lib/shell-usage.ts +246 -0
  58. package/lib/telemetry-trigger.ts +151 -0
  59. package/package.json +4 -4
  60. package/runtime/native-review-cli.mjs +102 -11
  61. package/runtime/review-integration-v2.mjs +92 -13
  62. package/runtime/review-relay-contract.mjs +11 -0
  63. package/runtime/review-risk-assessment.mjs +340 -0
  64. package/runtime/telemetry-trigger.mjs +152 -0
  65. package/scripts/build-runtime-modules.mjs +2 -0
  66. package/scripts/gentle-ai-installer.mjs +10 -10
  67. package/scripts/test-packed-runner.mjs +22 -0
  68. package/scripts/verify-package-files.mjs +18 -13
  69. package/skills/_shared/review-ledger-contract.md +9 -1
  70. package/skills/issue-creation/SKILL.md +53 -93
  71. package/tests/agents-config.test.ts +143 -0
  72. package/tests/agents-fake-child.ts +52 -0
  73. package/tests/agents-history.test.ts +54 -0
  74. package/tests/agents-protocol.test.ts +153 -0
  75. package/tests/agents-runner-process.test.ts +111 -0
  76. package/tests/agents-runner.test.ts +402 -0
  77. package/tests/agents-transcript.test.ts +30 -0
  78. package/tests/agents-view.test.ts +274 -0
  79. package/tests/agents-widget.test.ts +111 -0
  80. package/tests/ask-user-choice.test.ts +157 -3
  81. package/tests/codegraph-tools.test.ts +110 -1
  82. package/tests/devbinary/native-review-parity.devtest.ts +108 -0
  83. package/tests/fixtures/agents-process-child.mjs +23 -0
  84. package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
  85. package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
  86. package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
  87. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
  88. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
  89. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
  90. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
  91. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
  92. package/tests/gentle-agents.test.ts +741 -0
  93. package/tests/gentle-ai-binary.test.ts +1 -1
  94. package/tests/gentle-ai-installer.test.ts +47 -47
  95. package/tests/gentle-ai-renderer.test.ts +65 -0
  96. package/tests/gentle-ai.test.ts +31 -14
  97. package/tests/gentle-card-text.ts +35 -0
  98. package/tests/gentle-shell.test.ts +527 -0
  99. package/tests/gentle-todo.test.ts +182 -0
  100. package/tests/issue-creation-skill.test.ts +103 -0
  101. package/tests/native-choice-list.test.ts +202 -0
  102. package/tests/native-fullscreen-interaction.test.ts +125 -0
  103. package/tests/native-pointer-region.test.ts +245 -0
  104. package/tests/native-review-capability-contract.test.ts +33 -1
  105. package/tests/native-review-cli.test.ts +40 -0
  106. package/tests/native-review-consent.test.ts +91 -0
  107. package/tests/native-review-parity-runtime.test.ts +8 -2
  108. package/tests/native-review-parity.test.ts +29 -22
  109. package/tests/orchestrator-budget.test.ts +71 -2
  110. package/tests/orchestrator-rdd-ownership.test.ts +10 -1
  111. package/tests/package-manifest.test.ts +134 -9
  112. package/tests/provider-contract-bundle.test.ts +76 -0
  113. package/tests/provider-contract-mirror.test.ts +19 -0
  114. package/tests/quiet-tool-rendering.test.ts +96 -37
  115. package/tests/rdd-aware-verification-contract.test.ts +216 -0
  116. package/tests/rdd-status-line.test.ts +286 -0
  117. package/tests/review-agent-end-preflight.test.ts +408 -0
  118. package/tests/review-candidate-view.test.ts +452 -6
  119. package/tests/review-contract-prompt.test.ts +142 -0
  120. package/tests/review-controller-native-recovery.test.ts +29 -4
  121. package/tests/review-controller-native-routing.test.ts +321 -4
  122. package/tests/review-controller-workspace-root.test.ts +45 -2
  123. package/tests/review-controller.test.ts +26 -1
  124. package/tests/review-host-relay-routing.test.ts +229 -11
  125. package/tests/review-host-relay.test.ts +195 -7
  126. package/tests/review-integration-v2-forward.test.ts +47 -0
  127. package/tests/review-integration-v2.test.ts +112 -0
  128. package/tests/review-last-event-closure.test.ts +7 -2
  129. package/tests/review-ledger-contract.test.ts +1 -1
  130. package/tests/review-relay-contract.test.ts +26 -0
  131. package/tests/review-repository.test.ts +28 -1
  132. package/tests/review-risk-assessment.test.ts +626 -0
  133. package/tests/review-session-standing-permission-controller.test.ts +608 -0
  134. package/tests/review-session-standing-permission-ipc.test.ts +233 -0
  135. package/tests/review-session-standing-permission-runtime.test.ts +212 -0
  136. package/tests/review-session-standing-permission.test.ts +126 -0
  137. package/tests/runtime-harness.mjs +1 -0
  138. package/tests/shell-bar.test.ts +176 -0
  139. package/tests/shell-card.test.ts +118 -0
  140. package/tests/shell-changes-view.test.ts +146 -0
  141. package/tests/shell-changes.test.ts +182 -0
  142. package/tests/shell-prompt.test.ts +118 -0
  143. package/tests/shell-todo.test.ts +170 -0
  144. package/tests/shell-usage-view.test.ts +62 -0
  145. package/tests/shell-usage.test.ts +197 -0
  146. package/tests/telemetry-trigger.test.ts +349 -0
  147. package/tests/writer-edit-surface-scope.test.ts +153 -17
  148. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
  149. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
  150. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
  151. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
  152. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
  153. /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
@@ -0,0 +1,153 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ applyTaskEvent,
5
+ emptyThread,
6
+ normalizeRpcEvent,
7
+ TASK_EVENT,
8
+ TASK_STATUS,
9
+ taskLabel,
10
+ TaskStore,
11
+ THREAD_ITEM,
12
+ type TaskRecord,
13
+ } from "../lib/agents-protocol.ts";
14
+
15
+ // Gentle Agents protocol: the child pi process streams RPC events; the host
16
+ // normalizes them into small typed deltas, applies them to an append-only
17
+ // thread, and notifies only the listeners of the task that changed.
18
+
19
+ function record(overrides: Partial<TaskRecord> = {}): TaskRecord {
20
+ return {
21
+ id: "t1",
22
+ agent: "gentle-ai-explore",
23
+ mode: "task",
24
+ prompt: "Map the repo",
25
+ label: "map the repo",
26
+ cwd: "/repo",
27
+ parentSessionId: "s1",
28
+ status: TASK_STATUS.QUEUED,
29
+ createdAt: 1000,
30
+ startedAt: null,
31
+ endedAt: null,
32
+ model: "openai-codex/gpt-5.6-terra",
33
+ thinking: "high",
34
+ sessionPath: null,
35
+ error: null,
36
+ result: null,
37
+ lastStep: "queued",
38
+ lastActivityAt: 1000,
39
+ turns: 0,
40
+ toolCalls: 0,
41
+ tokens: 0,
42
+ cost: 0,
43
+ ...overrides,
44
+ };
45
+ }
46
+
47
+ test("normalizeRpcEvent maps pi RPC events to task deltas and ignores the rest", () => {
48
+ assert.deepEqual(normalizeRpcEvent({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "Hi" } }), [{ type: TASK_EVENT.TEXT, text: "Hi" }]);
49
+ assert.deepEqual(normalizeRpcEvent({ type: "message_update", assistantMessageEvent: { type: "thinking_delta", delta: "hmm" } }), [{ type: TASK_EVENT.THINKING, text: "hmm" }]);
50
+ assert.deepEqual(normalizeRpcEvent({ type: "tool_execution_start", toolCallId: "c1", toolName: "bash", args: { command: "ls" } }), [{ type: TASK_EVENT.TOOL_START, callId: "c1", name: "bash", args: { command: "ls" } }]);
51
+ assert.deepEqual(normalizeRpcEvent({ type: "tool_execution_update", toolCallId: "c1", toolName: "bash", partialResult: { content: [{ type: "text", text: "a\nb" }] } }), [{ type: TASK_EVENT.TOOL_UPDATE, callId: "c1", output: "a\nb" }]);
52
+ assert.deepEqual(normalizeRpcEvent({ type: "tool_execution_end", toolCallId: "c1", toolName: "bash", isError: true, result: { content: [{ type: "text", text: "boom" }] } }), [{ type: TASK_EVENT.TOOL_END, callId: "c1", output: "boom", isError: true }]);
53
+ assert.deepEqual(normalizeRpcEvent({ type: "turn_end" }), [{ type: TASK_EVENT.TURN_END }]);
54
+ assert.deepEqual(normalizeRpcEvent({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done." }], stopReason: "stop" }] }), [{ type: TASK_EVENT.AGENT_END, text: "done.", outcome: "success" }]);
55
+ assert.deepEqual(normalizeRpcEvent({ type: "agent_end", messages: [{ role: "assistant", content: [], stopReason: "error", errorMessage: "WebSocket error with untrusted provider payload" }] }), [{ type: TASK_EVENT.AGENT_END, text: "", outcome: "error", diagnostic: "assistant reported an error" }]);
56
+ assert.deepEqual(normalizeRpcEvent({ type: "agent_end", messages: [{ role: "assistant", content: [], stopReason: "aborted" }] }), [{ type: TASK_EVENT.AGENT_END, text: "", outcome: "aborted", diagnostic: "assistant aborted" }]);
57
+ assert.deepEqual(normalizeRpcEvent({ type: "agent_end", messages: [{ role: "assistant", content: [], stopReason: "stop" }] }), [{ type: TASK_EVENT.AGENT_END, text: "", outcome: "empty", diagnostic: "assistant returned no final report" }]);
58
+ assert.deepEqual(normalizeRpcEvent({ type: "agent_settled" }), [{ type: TASK_EVENT.AGENT_SETTLED }]);
59
+ assert.deepEqual(normalizeRpcEvent({ type: "message_update", assistantMessageEvent: { type: "error", reason: "error", error: { message: "rate limited" } } }), [{ type: TASK_EVENT.ERROR, message: "rate limited" }]);
60
+ assert.deepEqual(normalizeRpcEvent({ type: "extension_ui_request", id: "u1", method: "confirm", title: "Delete?" }), [{ type: TASK_EVENT.ASK, request: { id: "u1", method: "confirm", title: "Delete?" } }]);
61
+ assert.deepEqual(normalizeRpcEvent({ type: "extension_ui_request", id: "u2", method: "setStatus", statusKey: "mcp" }), [], "fire-and-forget UI requests never count as questions");
62
+ assert.deepEqual(normalizeRpcEvent({ type: "extension_ui_request", id: "u3", method: "notify", message: "hi" }), []);
63
+ assert.deepEqual(normalizeRpcEvent({ type: "auto_retry_start", attempt: 1, maxAttempts: 3 }), [{ type: TASK_EVENT.NOTE, text: "retrying (1/3)" }]);
64
+ assert.deepEqual(normalizeRpcEvent({ type: "message_end", message: { role: "assistant", usage: { totalTokens: 9621, cost: { total: 0.0193 } } } }), [{ type: TASK_EVENT.USAGE, tokens: 9621, cost: 0.0193 }]);
65
+ assert.deepEqual(normalizeRpcEvent({ type: "message_end", message: { role: "user" } }), []);
66
+ assert.deepEqual(normalizeRpcEvent({ type: "queue_update" }), []);
67
+ assert.deepEqual(normalizeRpcEvent("garbage"), []);
68
+ });
69
+
70
+ test("taskLabel prefers an explicit label and otherwise takes the prompt's first sentence", () => {
71
+ assert.equal(taskLabel("Map the repo. Then report.", " map the repo "), "map the repo");
72
+ assert.equal(taskLabel("Repeat a fresh read-only exploration of lib. Own runtime architecture: extensions, hooks."), "Repeat a fresh read-only exploration of lib");
73
+ assert.equal(taskLabel("\n\nFirst line here:\nsecond"), "First line here");
74
+ assert.equal(taskLabel(`${"x".repeat(100)} tail`).length, 72);
75
+ assert.equal(taskLabel("\x1b[31mred\x1b[0m"), "red");
76
+ });
77
+
78
+ test("applyTaskEvent appends incrementally: text deltas merge, tool output is replaced, items stay bounded", () => {
79
+ let thread = emptyThread({ maxItems: 3, maxOutputChars: 9 });
80
+ thread = applyTaskEvent(thread, { type: TASK_EVENT.TEXT, text: "Hel" });
81
+ thread = applyTaskEvent(thread, { type: TASK_EVENT.TEXT, text: "lo" });
82
+ assert.deepEqual(thread.items, [{ kind: THREAD_ITEM.TEXT, text: "Hello" }]);
83
+ thread = applyTaskEvent(thread, { type: TASK_EVENT.TOOL_START, callId: "c1", name: "bash", args: { command: "ls" } });
84
+ thread = applyTaskEvent(thread, { type: TASK_EVENT.TOOL_UPDATE, callId: "c1", output: "line one\nline two" });
85
+ assert.equal(thread.items[1].kind, THREAD_ITEM.TOOL);
86
+ assert.equal((thread.items[1] as { output: string }).output, "…line two", "tool output keeps the tail");
87
+ thread = applyTaskEvent(thread, { type: TASK_EVENT.TOOL_END, callId: "c1", output: "ok", isError: false });
88
+ assert.deepEqual(thread.items[1], { kind: THREAD_ITEM.TOOL, callId: "c1", name: "bash", args: { command: "ls" }, output: "ok", running: false, isError: false });
89
+ thread = applyTaskEvent(thread, { type: TASK_EVENT.NOTE, text: "retrying (1/3)" });
90
+ thread = applyTaskEvent(thread, { type: TASK_EVENT.TEXT, text: "After" });
91
+ assert.equal(thread.items.length, 3);
92
+ assert.equal(thread.dropped, 1, "the oldest item made room");
93
+ assert.equal(thread.items[0].kind, THREAD_ITEM.TOOL);
94
+ assert.equal(thread.version, 7);
95
+ });
96
+
97
+ test("applyTaskEvent sanitizes text, ignores updates for unknown tools, and records asks as notes", () => {
98
+ let thread = emptyThread();
99
+ thread = applyTaskEvent(thread, { type: TASK_EVENT.TEXT, text: "\x1b[31mred\x1b[0m" });
100
+ assert.deepEqual(thread.items, [{ kind: THREAD_ITEM.TEXT, text: "red" }]);
101
+ const before = thread;
102
+ thread = applyTaskEvent(thread, { type: TASK_EVENT.TOOL_UPDATE, callId: "missing", output: "x" });
103
+ assert.strictEqual(thread, before);
104
+ thread = applyTaskEvent(thread, { type: TASK_EVENT.ASK, request: { id: "u1", method: "confirm", title: "Delete?" } });
105
+ assert.deepEqual(thread.items[1], { kind: THREAD_ITEM.NOTE, text: "asked: Delete?" });
106
+ });
107
+
108
+ test("TaskStore notifies only the listeners of the task that changed and keeps summaries cheap", () => {
109
+ const store = new TaskStore();
110
+ store.add(record({ id: "a" }));
111
+ store.add(record({ id: "b", agent: "worker" }));
112
+ const seenA: string[] = [];
113
+ const seenB: string[] = [];
114
+ const summaries: string[] = [];
115
+ const offA = store.subscribe("a", (task) => seenA.push(`${task.status}:${task.lastStep}`));
116
+ store.subscribe("b", (task) => seenB.push(task.status));
117
+ store.subscribeSummary((summary) => summaries.push(`${summary.running}/${summary.queued}/${summary.finished}`));
118
+ store.update("a", { status: TASK_STATUS.RUNNING, startedAt: 2000, lastStep: "starting" });
119
+ store.apply("a", { type: TASK_EVENT.TOOL_START, callId: "c1", name: "bash", args: {} }, 2500);
120
+ assert.deepEqual(seenA, ["running:starting", "running:bash"]);
121
+ assert.deepEqual(seenB, []);
122
+ assert.deepEqual(summaries, ["1/1/0"], "an event inside a task never touches the summary");
123
+ assert.equal(store.get("a")?.toolCalls, 1);
124
+ assert.equal(store.get("a")?.lastActivityAt, 2500);
125
+ assert.equal(store.thread("a").items.length, 1);
126
+ offA();
127
+ store.update("a", { status: TASK_STATUS.COMPLETED, endedAt: 3000 });
128
+ assert.equal(seenA.length, 2, "unsubscribed listener stays quiet");
129
+ assert.deepEqual(summaries, ["1/1/0", "0/1/1"]);
130
+ assert.deepEqual(store.list("s1").map((task) => task.id), ["a", "b"], "newest activity first");
131
+ assert.equal(store.get("missing"), undefined);
132
+ assert.deepEqual(store.thread("missing"), emptyThread());
133
+ });
134
+
135
+ test("TaskStore.apply moves the task to waiting on ask, back to running on any later event, and counts turns", () => {
136
+ const store = new TaskStore();
137
+ store.add(record({ id: "a", status: TASK_STATUS.RUNNING }));
138
+ store.apply("a", { type: TASK_EVENT.ASK, request: { id: "u1", method: "input", title: "Name?" } }, 1);
139
+ assert.equal(store.get("a")?.status, TASK_STATUS.WAITING);
140
+ assert.equal(store.get("a")?.lastStep, "asked: Name?");
141
+ store.apply("a", { type: TASK_EVENT.TEXT, text: "thanks" }, 2);
142
+ assert.equal(store.get("a")?.status, TASK_STATUS.RUNNING);
143
+ store.apply("a", { type: TASK_EVENT.TURN_END }, 3);
144
+ store.apply("a", { type: TASK_EVENT.TURN_END }, 4);
145
+ assert.equal(store.get("a")?.turns, 2);
146
+ store.apply("a", { type: TASK_EVENT.USAGE, tokens: 100, cost: 0.5 }, 4);
147
+ store.apply("a", { type: TASK_EVENT.USAGE, tokens: 50, cost: 0.25 }, 4);
148
+ assert.equal(store.get("a")?.tokens, 150);
149
+ assert.equal(store.get("a")?.cost, 0.75);
150
+ store.apply("a", { type: TASK_EVENT.AGENT_END, text: "final answer", outcome: "success" }, 5);
151
+ assert.equal(store.get("a")?.result, "final answer");
152
+ assert.equal(store.get("a")?.status, TASK_STATUS.RUNNING, "agent_end alone does not finish: the runner decides");
153
+ });
@@ -0,0 +1,111 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn as nodeSpawn } from "node:child_process";
3
+ import { fileURLToPath } from "node:url";
4
+ import test from "node:test";
5
+ import { AGENT_MODE, type AgentDefinition } from "../lib/agents-config.ts";
6
+ import { AgentRunner, type ChildLike, type RunnerDeps, type TaskRequest } from "../lib/agents-runner.ts";
7
+ import { TASK_STATUS, TaskStore } from "../lib/agents-protocol.ts";
8
+
9
+ const fixture = fileURLToPath(new URL("./fixtures/agents-process-child.mjs", import.meta.url));
10
+ const agent: AgentDefinition = { name: "process", description: "test", filePath: "/test.md", scope: "global", instructions: "", model: undefined, thinking: undefined, mode: undefined, tools: [] };
11
+ const request = (prompt: string): TaskRequest => ({ agent, prompt, label: undefined, context: undefined, mode: AGENT_MODE.BACKGROUND, cwd: process.cwd(), parentSessionId: "test", model: undefined, thinking: undefined, sessionDir: "/tmp", resumeSessionPath: undefined, env: {} });
12
+
13
+ const waitFor = async (predicate: () => boolean, timeoutMs = 10_000): Promise<void> => {
14
+ const deadline = Date.now() + timeoutMs;
15
+ while (!predicate()) {
16
+ if (Date.now() >= deadline) throw new Error(`condition was not met within ${timeoutMs}ms`);
17
+ await new Promise((resolve) => setTimeout(resolve, 10));
18
+ }
19
+ };
20
+
21
+ test("POSIX cleanup retains queue slots when a leader exits but its TERM-resisting descendant remains", { skip: process.platform === "win32" }, async () => {
22
+ const store = new TaskStore();
23
+ let launches = 0;
24
+ let firstPid: number | undefined;
25
+ const descendantPids: Array<number | undefined> = [];
26
+ let firstDetached = false;
27
+ const ownedPids: number[] = [];
28
+ const runtimeTimers: Array<{ fn: () => void; timer: ReturnType<typeof setTimeout> | undefined; cancelled: boolean }> = [];
29
+ const armRuntimeTimeout = (index: number) => {
30
+ const timer = runtimeTimers[index];
31
+ if (timer && !timer.cancelled && timer.timer === undefined) timer.timer = setTimeout(timer.fn, 500);
32
+ };
33
+ const deps: RunnerDeps = {
34
+ spawn: (_command, _args, options): ChildLike => {
35
+ launches += 1;
36
+ const env = launches === 1 ? { ...options.env, AGENTS_PROCESS_CHILD_EXIT_ON_TERM: "1" } : launches === 3 ? { ...options.env, AGENTS_PROCESS_CHILD_EXIT_AFTER_READY: "1" } : options.env;
37
+ const child = nodeSpawn(process.execPath, [fixture], { cwd: options.cwd, env, detached: options.detached, stdio: ["pipe", "pipe", "pipe"] });
38
+ const launchIndex = launches - 1;
39
+ ownedPids.push(child.pid!);
40
+ if (launches === 1) {
41
+ firstPid = child.pid;
42
+ firstDetached = options.detached === true;
43
+ }
44
+ let output = "";
45
+ child.stdout.on("data", (chunk: Buffer) => {
46
+ output += chunk.toString();
47
+ const match = output.match(/DESCENDANT:(\d+)/);
48
+ if (match) {
49
+ descendantPids[launchIndex] = Number(match[1]);
50
+ armRuntimeTimeout(launchIndex);
51
+ }
52
+ });
53
+ return child;
54
+ },
55
+ now: Date.now,
56
+ schedule: (fn, ms) => {
57
+ if (ms === 500) {
58
+ const timer = { fn, timer: undefined, cancelled: false };
59
+ const index = launches - 1;
60
+ runtimeTimers[index] = timer;
61
+ if (descendantPids[index] !== undefined) armRuntimeTimeout(index);
62
+ return () => {
63
+ timer.cancelled = true;
64
+ if (timer.timer) clearTimeout(timer.timer);
65
+ };
66
+ }
67
+ const timer = setTimeout(fn, ms);
68
+ return () => clearTimeout(timer);
69
+ },
70
+ pi: { command: process.execPath, args: [fixture] },
71
+ };
72
+ const runner = new AgentRunner(store, { maxConcurrency: 1, stallTimeoutMs: 500 }, deps, { askUser: async () => ({ cancelled: true }) });
73
+ const first = runner.run(request("first"));
74
+ const second = runner.run(request("second"));
75
+ const third = runner.run(request("third"));
76
+ const fourth = runner.run(request("fourth"));
77
+ try {
78
+ await waitFor(() => launches === 1 && descendantPids[0] !== undefined);
79
+ assert.equal(firstDetached, true, "the first child owns a POSIX process group");
80
+ assert.equal(runner.cancel(first.id), true);
81
+ assert.equal(store.get(first.id)?.status, TASK_STATUS.RUNNING, "leader exit does not release its live descendant group");
82
+ await new Promise((resolve) => setTimeout(resolve, 40));
83
+ assert.doesNotThrow(() => process.kill(descendantPids[0]!, 0), "the exact TERM-resisting descendant remains alive");
84
+ assert.equal(launches, 1, "the queued task cannot use the slot during SIGTERM grace");
85
+ await waitFor(() => store.get(first.id)?.status === TASK_STATUS.CANCELLED);
86
+ assert.equal(launches, 2, "the slot opens only after the owned group exits");
87
+ assert.throws(() => process.kill(-firstPid!, 0), { code: "ESRCH" }, "SIGKILL cleaned the owned child group, including its descendant");
88
+ await waitFor(() => store.get(second.id)?.status === TASK_STATUS.TIMED_OUT);
89
+ assert.throws(() => process.kill(-ownedPids[1], 0), { code: "ESRCH" }, "timeout also bounds cleanup of its owned group");
90
+ await waitFor(() => launches === 3 && descendantPids[2] !== undefined);
91
+ await new Promise((resolve) => setTimeout(resolve, 40));
92
+ assert.doesNotThrow(() => process.kill(descendantPids[2]!, 0), "the natural-exit descendant remains alive");
93
+ assert.equal(launches, 3, "natural leader exit does not release the queue slot");
94
+ await waitFor(() => store.get(third.id)?.status === TASK_STATUS.FAILED);
95
+ assert.equal(launches, 4, "the queue resumes after natural-exit group cleanup");
96
+ assert.throws(() => process.kill(-ownedPids[2], 0), { code: "ESRCH" }, "natural exit also cleans its owned group");
97
+ } finally {
98
+ if (firstDetached) {
99
+ for (const pid of ownedPids) {
100
+ try { process.kill(-pid, "SIGKILL"); } catch {}
101
+ }
102
+ } else {
103
+ for (const pid of [firstPid, ...descendantPids, ...ownedPids]) {
104
+ if (pid) try { process.kill(pid, "SIGKILL"); } catch {}
105
+ }
106
+ }
107
+ runner.cancel(second.id);
108
+ runner.cancel(third.id);
109
+ runner.cancel(fourth.id);
110
+ }
111
+ });