gentle-pi 2.5.0 → 2.6.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 (189) hide show
  1. package/README.md +139 -30
  2. package/assets/agents/gentle-ai-worker.md +4 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +21 -3
  25. package/assets/sdd-orchestrator-workflow.md +54 -21
  26. package/assets/support/sdd-status-contract.md +34 -90
  27. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  28. package/docs/telemetry.md +57 -1
  29. package/docs/windows-startup-console-visibility.md +18 -0
  30. package/extensions/ask-user-choice.ts +143 -15
  31. package/extensions/codegraph-tools.ts +1 -0
  32. package/extensions/gentle-agents.ts +799 -50
  33. package/extensions/gentle-ai.ts +2033 -322
  34. package/extensions/gentle-shell.ts +145 -42
  35. package/extensions/gentle-todo.ts +47 -12
  36. package/extensions/quiet-tools.ts +1 -0
  37. package/extensions/runtime-metrics.ts +130 -0
  38. package/extensions/sdd-init.ts +2 -2
  39. package/extensions/startup-banner.ts +52 -75
  40. package/lib/agent-profiles.ts +550 -0
  41. package/lib/agents-completion-delivery.ts +72 -0
  42. package/lib/agents-config.ts +7 -10
  43. package/lib/agents-history.ts +9 -1
  44. package/lib/agents-messaging.ts +187 -0
  45. package/lib/agents-protocol.ts +77 -5
  46. package/lib/agents-runner.ts +548 -26
  47. package/lib/agents-thread-view.ts +57 -0
  48. package/lib/agents-view-layout.ts +40 -0
  49. package/lib/agents-view.ts +548 -191
  50. package/lib/agents-widget.ts +33 -14
  51. package/lib/gentle-ai-binary.ts +3 -1
  52. package/lib/gentle-ai-renderer.ts +8 -6
  53. package/lib/native-review-cli.ts +283 -1
  54. package/lib/orchestrator-presence.ts +337 -0
  55. package/lib/profiles-orchestrator.ts +203 -0
  56. package/lib/review-candidate-view-owner.ts +296 -46
  57. package/lib/review-candidate-view.ts +30 -20
  58. package/lib/review-consent-component.ts +247 -0
  59. package/lib/review-consent-ui.ts +53 -8
  60. package/lib/review-host-relay.ts +28 -0
  61. package/lib/review-integration-v2.ts +187 -5
  62. package/lib/review-last-event-controller.ts +7 -4
  63. package/lib/review-reminder-receipt.ts +74 -0
  64. package/lib/review-session-standing-permission.ts +27 -6
  65. package/lib/runtime-metrics-children.ts +199 -0
  66. package/lib/runtime-metrics-delivery.ts +68 -0
  67. package/lib/runtime-metrics-native.ts +166 -0
  68. package/lib/runtime-metrics-pi-identity.ts +113 -0
  69. package/lib/runtime-metrics-policy.ts +51 -0
  70. package/lib/runtime-metrics.ts +255 -0
  71. package/lib/sdd-preflight.ts +362 -81
  72. package/lib/sdd-research-capabilities.ts +228 -0
  73. package/lib/sdd-status.ts +29 -7
  74. package/lib/session-worktree-registry.ts +118 -0
  75. package/lib/shell-bar.ts +47 -1
  76. package/lib/shell-card.ts +1 -4
  77. package/lib/shell-changes-view.ts +362 -37
  78. package/lib/shell-changes.ts +81 -1
  79. package/lib/shell-prompt.ts +11 -15
  80. package/lib/shell-sidebar-banner.ts +11 -0
  81. package/lib/shell-sidebar-layout.ts +213 -0
  82. package/lib/shell-sidebar.ts +41 -0
  83. package/lib/shell-todo.ts +28 -11
  84. package/lib/telemetry-trigger.ts +2 -0
  85. package/package.json +6 -3
  86. package/runtime/gentle-ai-binary.mjs +3 -1
  87. package/runtime/native-review-cli.mjs +283 -1
  88. package/runtime/review-integration-v2.mjs +187 -5
  89. package/runtime/telemetry-trigger.mjs +2 -0
  90. package/scripts/build-runtime-modules.mjs +9 -1
  91. package/scripts/check-types.mjs +125 -0
  92. package/scripts/gentle-ai-installer.mjs +10 -10
  93. package/scripts/install-gentle-ai.mjs +12 -0
  94. package/scripts/install-tui-mode-setting.mjs +114 -0
  95. package/scripts/test-packed-runner.mjs +16 -2
  96. package/scripts/types-baseline.json +99 -0
  97. package/scripts/verify-package-files.mjs +4 -2
  98. package/skills/_shared/review-ledger-contract.md +17 -1
  99. package/skills/issue-creation/SKILL.md +3 -3
  100. package/skills/judgment-day/SKILL.md +17 -3
  101. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  102. package/tests/agent-profiles.test.ts +722 -0
  103. package/tests/agents-completion-delivery.test.ts +94 -0
  104. package/tests/agents-config.test.ts +62 -0
  105. package/tests/agents-fake-child.ts +15 -1
  106. package/tests/agents-grouping.test.ts +179 -0
  107. package/tests/agents-integration.test.ts +100 -0
  108. package/tests/agents-messaging.test.ts +94 -0
  109. package/tests/agents-protocol.test.ts +45 -0
  110. package/tests/agents-queries.test.ts +190 -0
  111. package/tests/agents-responsive.test.ts +43 -0
  112. package/tests/agents-runner.test.ts +571 -14
  113. package/tests/agents-thread-view.test.ts +45 -0
  114. package/tests/agents-view.test.ts +476 -65
  115. package/tests/agents-widget.test.ts +31 -1
  116. package/tests/artifact-language.test.ts +25 -2
  117. package/tests/ask-user-choice.test.ts +169 -3
  118. package/tests/asset-installation-runtime.test.ts +108 -0
  119. package/tests/autonomous-guard.test.ts +116 -1
  120. package/tests/codegraph-tools.test.ts +2 -1
  121. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  122. package/tests/devbinary/native-review-parity.devtest.ts +2 -0
  123. package/tests/feature-request-form.test.ts +67 -0
  124. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  125. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  126. package/tests/gentle-agents.test.ts +1460 -33
  127. package/tests/gentle-ai-binary.test.ts +7 -2
  128. package/tests/gentle-ai-installer.test.ts +47 -47
  129. package/tests/gentle-ai-renderer.test.ts +38 -0
  130. package/tests/gentle-ai.test.ts +944 -4
  131. package/tests/gentle-shell.test.ts +303 -12
  132. package/tests/gentle-todo.test.ts +54 -10
  133. package/tests/install-tui-mode-setting.test.ts +324 -0
  134. package/tests/issue-creation-skill.test.ts +22 -0
  135. package/tests/model-routing-authority.test.ts +12 -0
  136. package/tests/native-review-capability-contract.test.ts +12 -1
  137. package/tests/native-review-cli.test.ts +277 -3
  138. package/tests/native-review-parity.test.ts +14 -7
  139. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  140. package/tests/orchestrator-presence.test.ts +389 -0
  141. package/tests/package-manifest.test.ts +232 -7
  142. package/tests/profiles-orchestrator.test.ts +208 -0
  143. package/tests/quiet-tool-rendering.test.ts +1 -0
  144. package/tests/rdd-aware-verification-contract.test.ts +10 -0
  145. package/tests/review-agent-end-preflight.test.ts +332 -24
  146. package/tests/review-candidate-view.test.ts +304 -6
  147. package/tests/review-consent-ui.test.ts +352 -0
  148. package/tests/review-contract-prompt.test.ts +14 -0
  149. package/tests/review-controller-native-routing.test.ts +563 -3
  150. package/tests/review-controller.test.ts +1 -1
  151. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  152. package/tests/review-host-relay-routing.test.ts +364 -4
  153. package/tests/review-host-relay.test.ts +29 -0
  154. package/tests/review-integration-v2-forward.test.ts +44 -0
  155. package/tests/review-integration-v2.test.ts +164 -0
  156. package/tests/review-last-event-closure.test.ts +105 -1
  157. package/tests/review-ledger-contract.test.ts +61 -6
  158. package/tests/review-reminder-receipt.test.ts +62 -0
  159. package/tests/review-session-standing-permission-controller.test.ts +52 -4
  160. package/tests/review-session-standing-permission.test.ts +30 -0
  161. package/tests/runtime-harness.mjs +447 -39
  162. package/tests/runtime-metrics-children.test.ts +206 -0
  163. package/tests/runtime-metrics-delivery.test.ts +85 -0
  164. package/tests/runtime-metrics-extension.test.ts +187 -0
  165. package/tests/runtime-metrics-native.test.ts +209 -0
  166. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  167. package/tests/runtime-metrics-policy.test.ts +62 -0
  168. package/tests/runtime-metrics.test.ts +184 -0
  169. package/tests/sdd-agent-tools.test.ts +10 -1
  170. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  171. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  172. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  173. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  174. package/tests/sdd-preflight.test.ts +252 -8
  175. package/tests/sdd-research-capabilities.test.ts +256 -0
  176. package/tests/sdd-research-live.test.ts +241 -0
  177. package/tests/sdd-selection-transport.test.ts +504 -0
  178. package/tests/sdd-status.test.ts +51 -0
  179. package/tests/session-worktree-registry.test.ts +135 -0
  180. package/tests/shell-card.test.ts +24 -3
  181. package/tests/shell-changes-view.test.ts +471 -8
  182. package/tests/shell-changes.test.ts +168 -0
  183. package/tests/shell-prompt.test.ts +28 -6
  184. package/tests/shell-sidebar-banner.test.ts +23 -0
  185. package/tests/shell-sidebar-layout.test.ts +387 -0
  186. package/tests/shell-sidebar.test.ts +50 -0
  187. package/tests/shell-todo.test.ts +100 -11
  188. package/tests/startup-banner.test.ts +126 -0
  189. package/tests/telemetry-trigger.test.ts +3 -1
@@ -1,8 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { AGENT_MODE, type AgentDefinition } from "../lib/agents-config.ts";
4
- import { TASK_STATUS, TaskStore } from "../lib/agents-protocol.ts";
5
- import { AgentRunner, childArguments, JsonLines, piCommand, type RunnerDeps, type TaskRequest } from "../lib/agents-runner.ts";
3
+ import { AGENT_MODE, parseAgentsConfig, resolveAgentProfile, type AgentDefinition } from "../lib/agents-config.ts";
4
+ import { TASK_STATUS, TaskStore, type RemediationTaskState, type TaskRecord } from "../lib/agents-protocol.ts";
5
+ import { AgentRunner, childArguments, JsonLines, piCommand, abortReasonText, type RemediationPlan, type RemediationTerminalFacts, type RunnerDeps, type RunnerHooks, type TaskRequest } from "../lib/agents-runner.ts";
6
6
  import { fakeChild, type FakeChild } from "./agents-fake-child.ts";
7
7
 
8
8
  // Gentle Agents runner: every subagent is a child `pi --mode rpc` process.
@@ -22,10 +22,10 @@ interface Harness {
22
22
  timers: Array<{ fn: () => void; ms: number; cancelled: boolean }>;
23
23
  asks: Array<{ taskId: string; method: string }>;
24
24
  finishes: string[];
25
- spawnOptions: Array<{ stdio?: string[] }>;
25
+ spawnOptions: Array<{ env: NodeJS.ProcessEnv; stdio?: string[] }>;
26
26
  }
27
27
 
28
- function harness(options: { maxConcurrency?: number; answer?: Record<string, unknown>; exitOnKill?: boolean } = {}): Harness {
28
+ function harness(options: { pid?: number; maxConcurrency?: number; answer?: Record<string, unknown>; exitOnKill?: boolean; state?: Record<string, unknown>; stateSuccess?: boolean; onNotification?: RunnerHooks["onNotification"]; onSuccessfulMutation?: RunnerHooks["onSuccessfulMutation"]; onFinish?: RunnerHooks["onFinish"] } = {}): Harness {
29
29
  const children: FakeChild[] = [];
30
30
  const timers: Harness["timers"] = [];
31
31
  const asks: Harness["asks"] = [];
@@ -34,8 +34,17 @@ function harness(options: { maxConcurrency?: number; answer?: Record<string, unk
34
34
  let clock = 1000;
35
35
  const deps: RunnerDeps = {
36
36
  spawn: (_command, _args, launchOptions) => {
37
- spawnOptions.push({ stdio: launchOptions.stdio });
38
- const fake = fakeChild({ exitOnKill: options.exitOnKill });
37
+ spawnOptions.push({ env: launchOptions.env, stdio: launchOptions.stdio });
38
+ const fake = fakeChild({ exitOnKill: options.exitOnKill, pid: options.pid });
39
+ if (options.state !== undefined) {
40
+ fake.child.stdin.removeAllListeners("data");
41
+ fake.child.stdin.on("data", (chunk) => {
42
+ const command = JSON.parse(String(chunk));
43
+ fake.written.push(command);
44
+ fake.emit({ type: "response", id: command.id, success: command.type !== "get_state" || options.stateSuccess !== false,
45
+ data: command.type === "get_state" ? options.state : undefined });
46
+ });
47
+ }
39
48
  children.push(fake);
40
49
  return fake.child;
41
50
  },
@@ -55,19 +64,300 @@ function harness(options: { maxConcurrency?: number; answer?: Record<string, unk
55
64
  asks.push({ taskId, method: ask.method });
56
65
  return options.answer ?? { value: "yes" };
57
66
  },
58
- onFinish: (task) => finishes.push(task.id),
67
+ onFinish: (task, observations) => { finishes.push(task.id); options.onFinish?.(task, observations); },
68
+ onNotification: options.onNotification,
69
+ onSuccessfulMutation: options.onSuccessfulMutation,
59
70
  });
60
71
  return { store, runner, children, timers, asks, finishes, spawnOptions };
61
72
  }
62
73
 
63
74
  const tick = () => new Promise((resolve) => setImmediate(resolve));
64
75
 
76
+ test("synchronous cancellation before dequeue never invokes the policy callback", async () => {
77
+ const h = harness(); let checks = 0;
78
+ const task = h.runner.run(request({ prepareResponseObservations: async () => { checks++; return true; } }));
79
+ h.runner.cancel(task.id);
80
+ await tick();
81
+ assert.equal(checks, 0);
82
+ assert.equal(h.children.length, 0);
83
+ });
84
+
85
+ test("hanging preparation never blocks spawn or queued core work; late grants are dropped", async () => {
86
+ const h = harness({ maxConcurrency: 1 });
87
+ let grant!: (value: boolean) => void;
88
+ let checks = 0;
89
+ const first = h.runner.run(request({ prepareResponseObservations: () => { checks++; return new Promise(resolve => { grant = resolve; }); } }));
90
+ const second = h.runner.run(request());
91
+ await tick();
92
+ assert.equal(checks, 1);
93
+ assert.equal(h.children.length, 1);
94
+ assert.equal(h.store.get(second.id)?.status, TASK_STATUS.QUEUED);
95
+ assert.equal(h.runner.cancel(first.id), true);
96
+ await tick();
97
+ assert.equal(h.children.length, 2);
98
+ grant(true);
99
+ await tick();
100
+ assert.equal(h.children.length, 2);
101
+ assert.equal((await h.runner.waitFor(first.id)).status, TASK_STATUS.CANCELLED);
102
+ h.runner.cancel(second.id);
103
+ });
104
+
105
+ for (const outcome of ["ready", "late", "reject", "throw"] as const) {
106
+ test(`parallel preparation ${outcome} cannot delay execution or revive dropped observations`, async () => {
107
+ const snapshots: Parameters<NonNullable<RunnerHooks["onFinish"]>>[1][] = [];
108
+ const h = harness({ onFinish: (_task, snapshot) => snapshots.push(snapshot) });
109
+ let grant!: (value: boolean) => void;
110
+ const task = h.runner.run(request({ prepareResponseObservations: () => {
111
+ if (outcome === "throw") throw new Error("preparation failed");
112
+ if (outcome === "reject") return Promise.reject(new Error("preparation failed"));
113
+ return new Promise(resolve => { grant = resolve; });
114
+ } }));
115
+ await tick();
116
+ assert.equal(h.children.length, 1);
117
+ if (outcome === "ready") { grant(true); await tick(); }
118
+ const message = { type: "message_end", message: { role: "assistant", provider: "openai", model: "gpt-4o", stopReason: "stop", content: [{ type: "text", text: "done" }] } };
119
+ h.children[0].emit(message);
120
+ if (outcome === "late") { grant(true); await tick(); }
121
+ h.children[0].emit(message);
122
+ h.children[0].emit({ type: "agent_end" });
123
+ h.children[0].emit({ type: "agent_settled" });
124
+ await h.runner.waitFor(task.id);
125
+ assert.equal(snapshots.length, 1);
126
+ assert.equal(snapshots[0]?.responses.length, outcome === "ready" ? 2 : undefined);
127
+ });
128
+ }
129
+
130
+ for (const checkpoint of ["launch", "stream", "finish", "throw"] as const) {
131
+ test(`child observation guard discards permanently at ${checkpoint} without changing task execution`, async () => {
132
+ let allowed = checkpoint !== "launch";
133
+ let calls = 0;
134
+ const snapshots: Parameters<NonNullable<RunnerHooks["onFinish"]>>[1][] = [];
135
+ const h = harness({ onFinish: (_task, snapshot) => snapshots.push(snapshot) });
136
+ const task = h.runner.run(request({ collectResponseObservations: true,
137
+ canCollectResponseObservations: () => {
138
+ calls++;
139
+ if (checkpoint === "throw") throw new Error("private policy failure");
140
+ return allowed;
141
+ } }));
142
+ await tick();
143
+ const child = h.children[0];
144
+ const response = { type: "message_end", message: { role: "assistant", stopReason: "stop", usage: { input: 3 } } };
145
+ child.emit(response);
146
+ if (checkpoint === "stream") {
147
+ allowed = false;
148
+ child.emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "progress" } });
149
+ allowed = true;
150
+ child.emit(response);
151
+ }
152
+ if (checkpoint === "finish") allowed = false;
153
+ h.runner.cancel(task.id);
154
+ await tick();
155
+ assert.equal((await h.runner.waitFor(task.id)).status, TASK_STATUS.CANCELLED);
156
+ assert.deepEqual(snapshots, [undefined]);
157
+ assert.ok(calls > 0);
158
+ });
159
+ }
160
+
161
+ test("child observation guard is never consulted when collection is default-off", async () => {
162
+ let calls = 0;
163
+ const h = harness();
164
+ const task = h.runner.run(request({ canCollectResponseObservations: () => { calls++; return true; } }));
165
+ await tick();
166
+ h.children[0].emit({ type: "message_end", message: { role: "assistant", stopReason: "stop" } });
167
+ h.runner.cancel(task.id);
168
+ await tick();
169
+ assert.equal(calls, 0);
170
+ });
171
+
172
+ for (const ending of ["cancel", "failure", "hook-error", "hook-async-error"] as const) {
173
+ test(`successful child mutations require paired RPC events and survive ${ending}`, async () => {
174
+ const mutations: unknown[] = [];
175
+ const h = harness({ onSuccessfulMutation: (task, tool) => {
176
+ mutations.push({ taskId: task.id, parent: task.parentSessionId, ...tool });
177
+ if (ending === "hook-error") throw new Error("receipt append unavailable");
178
+ if (ending === "hook-async-error") return Promise.reject(new Error("async receipt append unavailable"));
179
+ } });
180
+ const task = h.runner.run(request());
181
+ await tick();
182
+ const child = h.children[0];
183
+ const start = (id: string, toolName: string) => child.emit({ type: "tool_execution_start", toolCallId: id, toolName, args: { path: "src/file.ts" } });
184
+ const end = (id: string, isError: unknown = false) => child.emit({ type: "tool_execution_end", toolCallId: id, isError, result: { content: [] } });
185
+ assert.deepEqual(mutations, [], "spawn is not mutation evidence");
186
+ end("missing");
187
+ for (const name of ["read", "bash", "subagent_run"]) { start(name, name); end(name); }
188
+ start("failed", "write"); end("failed", true);
189
+ start("unknown", "edit"); end("unknown", null);
190
+ child.emit({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "I edited files" } });
191
+ assert.deepEqual(mutations, []);
192
+ for (const name of ["write", "edit"]) { start(name, name); end(name); end(name); }
193
+ assert.deepEqual(mutations, ["write", "edit"].map((toolName) => ({ taskId: task.id, parent: "s1", toolName, toolCallId: toolName, path: "src/file.ts" })));
194
+ start("unfinished", "write");
195
+ if (ending === "failure") child.fail("later failure");
196
+ else h.runner.cancel(task.id);
197
+ await tick();
198
+ end("unfinished"); start("late", "write"); end("late");
199
+ assert.equal(mutations.length, 2, "terminal cleanup rejects late events without retracting successful writes");
200
+ });
201
+ }
202
+
203
+ test("launch registration waits for actual spawn, including queued launches, and ignores failed spawns", async () => {
204
+ const launches: string[] = [];
205
+ const spawns: Array<() => void> = [];
206
+ const children: FakeChild[] = [];
207
+ const cwds: string[] = [];
208
+ const store = new TaskStore();
209
+ const runner = new AgentRunner(store, { maxConcurrency: 1, stallTimeoutMs: 1000 }, {
210
+ spawn: (_command, _args, options) => {
211
+ cwds.push(options.cwd);
212
+ if (options.cwd === "/throws") throw new Error("missing executable");
213
+ const fake = fakeChild();
214
+ const on = fake.child.on.bind(fake.child);
215
+ fake.child.on = ((event: string, listener: () => void) => {
216
+ if (event === "spawn") spawns.push(listener);
217
+ else on(event as "exit", listener);
218
+ return fake.child;
219
+ }) as typeof fake.child.on;
220
+ children.push(fake);
221
+ return fake.child;
222
+ },
223
+ now: () => 1000, schedule: () => () => {}, pi: { command: "pi", args: [] },
224
+ }, { askUser: async () => ({ cancelled: true }) });
225
+ const first = runner.run(request({ cwd: "/child", onLaunch: () => launches.push("s1:/child") }));
226
+ const second = runner.run(request({ cwd: "/queued", onLaunch: () => launches.push("s1:/queued") }));
227
+ assert.deepEqual(launches, []);
228
+ await tick();
229
+ assert.deepEqual(launches, [], "returning a child handle is not successful spawn");
230
+ assert.equal(typeof spawns[0], "function");
231
+ spawns[0]();
232
+ assert.deepEqual(launches, ["s1:/child"]);
233
+ runner.cancel(first.id);
234
+ await tick();
235
+ assert.equal(store.get(second.id)?.cwd, "/queued");
236
+ spawns[1]();
237
+ assert.deepEqual(launches, ["s1:/child", "s1:/queued"]);
238
+ runner.cancel(second.id);
239
+ await tick();
240
+ const failed = runner.run(request({ cwd: "/missing", onLaunch: () => launches.push("bad") }));
241
+ await tick();
242
+ children[2].fail("ENOENT");
243
+ await tick();
244
+ assert.equal(store.get(failed.id)?.status, TASK_STATUS.FAILED);
245
+ const thrown = runner.run(request({ cwd: "/throws", onLaunch: () => launches.push("bad") }));
246
+ await tick();
247
+ assert.equal(store.get(thrown.id)?.status, TASK_STATUS.FAILED);
248
+ assert.deepEqual(launches, ["s1:/child", "s1:/queued"]);
249
+ assert.deepEqual(cwds, ["/child", "/queued", "/missing", "/throws"]);
250
+ });
251
+
252
+ test("runner captures resolved model and effort, retaining omitted launch values", async () => {
253
+ for (const scenario of [
254
+ { state: { model: { provider: "anthropic", id: "resolved-model" }, thinkingLevel: "off" }, model: "anthropic/resolved-model", thinking: "off" },
255
+ { state: { thinkingLevel: "max" }, model: "openai-codex/gpt-5.6-terra", thinking: "max" },
256
+ { state: {}, model: "openai-codex/gpt-5.6-terra", thinking: "high" },
257
+ { state: { model: null }, model: "default", thinking: "high" },
258
+ { state: { model: { id: 7 }, thinkingLevel: 7 }, model: "openai-codex/gpt-5.6-terra", thinking: "high" },
259
+ ]) {
260
+ const h = harness({ state: scenario.state });
261
+ const task = h.runner.run(request());
262
+ await tick();
263
+ assert.equal(h.store.get(task.id)?.model, scenario.model);
264
+ assert.equal(h.store.get(task.id)?.thinking, scenario.thinking);
265
+ h.runner.cancel(task.id);
266
+ }
267
+ const h = harness({ state: { model: { provider: "wrong", id: "wrong" }, thinkingLevel: "low" }, stateSuccess: false });
268
+ const task = h.runner.run(request({ model: undefined, thinking: undefined }));
269
+ await tick();
270
+ assert.equal(h.store.get(task.id)?.model, "default");
271
+ assert.equal(h.store.get(task.id)?.thinking, undefined);
272
+ h.runner.cancel(task.id);
273
+ });
274
+
275
+ test("runner delivers each response combination once at finish, never attributing launch selection", async () => {
276
+ const snapshots: NonNullable<Parameters<NonNullable<RunnerHooks["onFinish"]>>[1]>[] = [];
277
+ const h = harness({ exitOnKill: false, state: { model: { provider: "anthropic", id: "launch" }, thinkingLevel: "max" },
278
+ onFinish: (_task, snapshot) => { assert.ok(snapshot); snapshots.push(snapshot); } });
279
+ const task = h.runner.run(request({ collectResponseObservations: true }));
280
+ await tick();
281
+ const child = h.children[0];
282
+ const responses = [
283
+ { provider: "openai", model: "gpt-4o", providerThinkingLevel: "low", stopReason: "error" },
284
+ { provider: "anthropic", model: "claude-sonnet-4", providerThinkingLevel: "high", stopReason: "toolUse" },
285
+ { provider: "openai", model: "gpt-4o", providerThinkingLevel: "high", stopReason: "stop" },
286
+ ];
287
+ for (const response of responses) {
288
+ const message = { role: "assistant", ...response, usage: { input: 10, totalTokens: 10, cost: { total: 0.1 } }, content: [{ type: "text", text: "private report" }] };
289
+ child.emit({ type: "message_start", message });
290
+ child.emit({ type: "message_end", message });
291
+ child.emit({ type: "turn_end", message });
292
+ child.emit({ type: "agent_end", messages: [message] });
293
+ }
294
+ assert.deepEqual(snapshots, [], "agent_end is not settlement");
295
+ child.emit({ type: "agent_settled" });
296
+ assert.deepEqual(snapshots, [], "settlement still waits for process cleanup");
297
+ child.exit(0);
298
+ await tick();
299
+ assert.equal(snapshots.length, 1);
300
+ const snapshot = snapshots[0];
301
+ assert.equal(snapshot.agentSettled, true);
302
+ assert.equal(snapshot.droppedResponses, 0);
303
+ assert.equal(snapshot.coverage, "final_assistant_messages_only");
304
+ assert.deepEqual(snapshot.responses.map((response) => [response.provider, response.model, response.providerThinkingLevel]),
305
+ responses.map((response) => [response.provider, response.model, response.providerThinkingLevel].map((value) => ({ state: "observed", value }))));
306
+ assert.ok(snapshot.responses.every((response) => Object.values(response.selected).every((field) => field.state === "unavailable")));
307
+ assert.equal(h.store.get(task.id)?.tokens, 30);
308
+ assert.equal(h.store.get(task.id)?.cost, 0.1 + 0.1 + 0.1);
309
+ assert.equal(h.store.get(task.id)?.model, "anthropic/launch");
310
+ assert.deepEqual(child.written.map((command) => command.type), ["get_state", "prompt"]);
311
+ assert.doesNotMatch(JSON.stringify(snapshot), /private|launch|s1|modelVersion/);
312
+ assert.ok(Object.isFrozen(snapshot) && Object.isFrozen(snapshot.responses) && Object.isFrozen(snapshot.responses[0].tokens.input));
313
+ child.emit({ type: "agent_settled" }); child.exit(0);
314
+ assert.equal(snapshots.length, 1);
315
+ });
316
+
317
+ for (const ending of ["cancel", "exit", "error", "timeout", "settled-error"] as const) test(`bounded response coverage survives ${ending} honestly`, async () => {
318
+ let snapshot: Parameters<NonNullable<RunnerHooks["onFinish"]>>[1];
319
+ const h = harness({ onFinish: (_task, observations) => { snapshot = observations; } });
320
+ const task = h.runner.run(request({ collectResponseObservations: true }));
321
+ await tick();
322
+ const child = h.children[0];
323
+ for (let index = 0; index < 130; index++) child.emit({ type: "message_end", message: {
324
+ role: "assistant", model: `model-${index}`, stopReason: "error", usage: { totalTokens: 1 } } });
325
+ if (ending === "cancel") h.runner.cancel(task.id);
326
+ else if (ending === "exit") child.exit(1);
327
+ else if (ending === "error") child.fail("private process error");
328
+ else if (ending === "timeout") h.timers.filter((timer) => !timer.cancelled && timer.ms === 10_000).at(-1)!.fn();
329
+ else { child.emit({ type: "agent_end", messages: [{ role: "assistant", stopReason: "error" }] }); child.emit({ type: "agent_settled" }); }
330
+ await h.runner.waitFor(task.id);
331
+ assert.ok(snapshot);
332
+ assert.equal(snapshot.responses.length, 128);
333
+ assert.equal(snapshot.droppedResponses, 2);
334
+ assert.equal(snapshot.agentSettled, ending === "settled-error");
335
+ assert.equal(h.store.get(task.id)?.tokens, 130, "buffer cap never caps existing UI totals");
336
+ assert.notEqual(h.store.get(task.id)?.status, TASK_STATUS.COMPLETED);
337
+ child.emit({ type: "message_end", message: { role: "assistant", stopReason: "stop" } });
338
+ assert.equal(snapshot.responses.length, 128);
339
+ assert.equal(h.finishes.length, 1);
340
+ });
341
+
342
+ test("response buffering is disabled by default and absent for tasks cancelled before launch", async () => {
343
+ const snapshots: unknown[] = [];
344
+ const h = harness({ maxConcurrency: 1, onFinish: (_task, snapshot) => snapshots.push(snapshot) });
345
+ const task = h.runner.run(request());
346
+ const queued = h.runner.run(request({ collectResponseObservations: true }));
347
+ await tick();
348
+ h.children[0].emit({ type: "message_end", message: { role: "assistant", stopReason: "stop", usage: { totalTokens: 7 } } });
349
+ h.runner.cancel(queued.id); h.runner.cancel(task.id);
350
+ await tick();
351
+ assert.deepEqual(snapshots, [undefined, undefined]);
352
+ assert.equal(h.store.get(task.id)?.tokens, 7);
353
+ });
354
+
65
355
  test("childArguments builds an rpc launch with model, thinking, tools, session dir, and instructions", () => {
66
356
  const args = childArguments(request());
67
357
  assert.deepEqual(args.slice(0, 2), ["--mode", "rpc"]);
68
358
  assert.ok(args.includes("--session-dir") && args[args.indexOf("--session-dir") + 1] === "/sessions");
69
359
  assert.equal(args[args.indexOf("--model") + 1], "openai-codex/gpt-5.6-terra:high");
70
- assert.equal(args[args.indexOf("--tools") + 1], "read,grep");
360
+ assert.equal(args[args.indexOf("--tools") + 1], "read,grep,subagent_parent_message");
71
361
  assert.equal(args[args.indexOf("--append-system-prompt") + 1], "You map things.");
72
362
  assert.ok(!args.includes("--session"));
73
363
  const resumed = childArguments(request({ resumeSessionPath: "/sessions/old.jsonl", model: undefined, thinking: undefined, agent: { ...explorer, tools: [] } }));
@@ -75,6 +365,76 @@ test("childArguments builds an rpc launch with model, thinking, tools, session d
75
365
  assert.ok(!resumed.includes("--model") && !resumed.includes("--tools"));
76
366
  });
77
367
 
368
+ test("childArguments preserves a max profile instead of the definition's medium effort", () => {
369
+ const agent: AgentDefinition = { ...explorer, name: "worker", thinking: "medium" };
370
+ const config = parseAgentsConfig({ model_profiles: { worker: { model: "openai-codex/gpt-5.6-luna", effort: "max" } } }, undefined);
371
+ const profile = resolveAgentProfile(agent, config);
372
+ const args = childArguments(request({ agent, model: profile.model, thinking: profile.thinking }));
373
+ assert.equal(args[args.indexOf("--model") + 1], "openai-codex/gpt-5.6-luna:max");
374
+ });
375
+
376
+ test("childArguments preserves a max default without a selected model", () => {
377
+ const profile = resolveAgentProfile(explorer, parseAgentsConfig({ default_effort: "max" }, undefined));
378
+ const args = childArguments(request({ model: profile.model, thinking: profile.thinking }));
379
+ assert.ok(!args.includes("--model"));
380
+ assert.equal(args[args.indexOf("--thinking") + 1], "max");
381
+ });
382
+
383
+ test("childArguments grants every child the notification-only parent message tool", () => {
384
+ const args = childArguments(request());
385
+ assert.equal(args[args.indexOf("--tools") + 1], "read,grep,subagent_parent_message");
386
+ });
387
+
388
+ test("AgentRunner admits strict live notifications once and closes IPC before Stop", async () => {
389
+ const notifications: string[] = [];
390
+ const { runner, children, spawnOptions } = harness({ onNotification: (task, message) => task.parentSessionId === "s1" && (notifications.push(message), true) });
391
+ const task = runner.run(request());
392
+ await tick();
393
+ children[0].message({ id: "n1", kind: "notification", message: "checkpoint" });
394
+ children[0].message({ id: "n1", kind: "notification", message: "checkpoint" });
395
+ children[0].message({ id: "n2", kind: "notification", message: "x".repeat(8 * 1024 + 1) });
396
+ children[0].message({ id: "q3", kind: "query", message: "unsupported" });
397
+ children[0].message({ id: "n4", kind: "notification", message: "\uD800" });
398
+ children[0].message({ id: "n5", kind: "notification", message: "forged field", sender: "forged" });
399
+ children[0].message({ id: "n0", kind: "notification", message: "invalid correlation" });
400
+ children[0].message({ id: `n${"1".repeat(1_000)}`, kind: "notification", message: "invalid correlation" });
401
+ await tick();
402
+ assert.deepEqual(spawnOptions[0]?.stdio, ["pipe", "pipe", "pipe", "ipc"]);
403
+ assert.deepEqual(notifications, ["checkpoint"]);
404
+ assert.deepEqual(children[0].sent, [
405
+ { id: "n1", kind: "ack", accepted: true },
406
+ { id: "n2", kind: "ack", accepted: false, error: "invalid child IPC message" },
407
+ { id: "q3", kind: "reply", error: "task parent cannot accept queries" },
408
+ { id: "n4", kind: "ack", accepted: false, error: "invalid child IPC message" },
409
+ { id: "n5", kind: "ack", accepted: false, error: "invalid child IPC frame" },
410
+ ]);
411
+ runner.cancel(task.id);
412
+ children[0].message({ id: "after-stop", kind: "notification", message: "ignored" });
413
+ await tick();
414
+ assert.equal(children[0].sent.length, 5);
415
+ assert.ok(children[0].disconnects > 0);
416
+ });
417
+
418
+ test("AgentRunner rejects notifications from an inactive parent session with a static acknowledgement", async () => {
419
+ const { runner, children } = harness({ onNotification: () => false });
420
+ runner.run(request());
421
+ await tick();
422
+ children[0].message({ id: "n1", kind: "notification", message: "not active" });
423
+ await tick();
424
+ assert.deepEqual(children[0].sent, [{ id: "n1", kind: "ack", accepted: false, error: "task parent is not the active host session" }]);
425
+ });
426
+
427
+ test("AgentRunner retains only a 64-notification duplicate window", async () => {
428
+ const notifications: string[] = [];
429
+ const { runner, children } = harness({ onNotification: (_task, message) => { notifications.push(message); } });
430
+ runner.run(request());
431
+ await tick();
432
+ for (let index = 1; index <= 65; index += 1) children[0].message({ id: `n${index}`, kind: "notification", message: `message ${index}` });
433
+ children[0].message({ id: "n1", kind: "notification", message: "message 1 again" });
434
+ await tick();
435
+ assert.equal(notifications.length, 66, "an ID evicted from the recent 64-ack window can be admitted again");
436
+ });
437
+
78
438
  test("piCommand reuses the running pi entry point and honors the override", () => {
79
439
  assert.deepEqual(piCommand({ execPath: "/bin/node", argv: ["/bin/node", "/x/dist/cli.js"], env: {} }), { command: "/bin/node", args: ["/x/dist/cli.js"] });
80
440
  assert.deepEqual(piCommand({ execPath: "/bin/node", argv: ["/bin/node", "/x/other.js"], env: {} }), { command: "pi", args: [] });
@@ -219,12 +579,43 @@ test("AgentRunner fails if the child exits after agent_end but before agent_sett
219
579
  assert.equal(store.get(task.id)?.result, "partial answer", "the final observed answer remains available for diagnostics");
220
580
  });
221
581
 
222
- test("AgentRunner reserves a parent-owned fourth stdio fd only for package-child authorization", async () => {
582
+ for (const [platform, detached] of [["win32", false], ["linux", true]] as const) test(`AgentRunner selects detached=${detached} for ${platform} without changing the launch contract`, async () => {
583
+ const store = new TaskStore();
584
+ const launches: Array<{ command: string; args: string[]; options: Parameters<RunnerDeps["spawn"]>[2] }> = [];
585
+ const child = fakeChild();
586
+ const runner = new AgentRunner(store, { maxConcurrency: 1, stallTimeoutMs: 1_000 }, {
587
+ spawn: (command, args, options) => {
588
+ launches.push({ command, args, options });
589
+ return child.child;
590
+ },
591
+ now: () => 1,
592
+ schedule: () => () => {},
593
+ pi: { command: "pi-fixture", args: ["--from-host"] },
594
+ process: { platform, kill: () => {} },
595
+ }, { askUser: async () => ({ cancelled: true }) });
596
+ const task = runner.run(request({ env: { PATH: "/fixture", KEEP: "yes" } }));
597
+ await tick();
598
+ const ownedIpc = launches[0]?.options.env.GENTLE_PI_AGENTS_OWNED_IPC;
599
+ assert.match(ownedIpc ?? "", /^\d+-[a-z0-9]+$/, "the runner creates an opaque owned-IPC marker");
600
+ assert.deepEqual(launches, [{
601
+ command: "pi-fixture",
602
+ args: ["--from-host", "--mode", "rpc", "--session-dir", "/sessions", "--model", "openai-codex/gpt-5.6-terra:high", "--tools", "read,grep,subagent_parent_message", "--append-system-prompt", "You map things."],
603
+ options: { cwd: "/repo", env: { PATH: "/fixture", KEEP: "yes", GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: ownedIpc }, detached, stdio: ["pipe", "pipe", "pipe", "ipc"] },
604
+ }]);
605
+ child.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "platform checked" }], stopReason: "stop" }] });
606
+ child.emit({ type: "agent_settled" });
607
+ assert.equal((await runner.waitFor(task.id)).status, TASK_STATUS.COMPLETED);
608
+ });
609
+
610
+ test("AgentRunner retains permission broker fd3 and assigns messaging IPC to fd4", async () => {
223
611
  const { runner, children, spawnOptions } = harness();
224
612
  const task = runner.run(request({ authorizeParentStandingReviewPermission: () => true }));
225
613
  await tick();
226
- assert.deepEqual(spawnOptions[0]?.stdio, ["pipe", "pipe", "pipe", "pipe"]);
227
- assert.equal((spawnOptions[0] as { stdio?: string[] } | undefined)?.stdio?.length, 4);
614
+ const launch = spawnOptions[0];
615
+ assert.match(launch?.env.GENTLE_PI_AGENTS_OWNED_IPC ?? "", /^\d+-[a-z0-9]+$/, "the owned-IPC marker has the runner's opaque shape");
616
+ assert.deepEqual(launch?.env, { GENTLE_PI_AGENTS_CHILD: "1", GENTLE_PI_AGENTS_OWNED_IPC: launch?.env.GENTLE_PI_AGENTS_OWNED_IPC, GENTLE_PI_AGENTS_PARENT_PERMISSION_FD: "3" });
617
+ assert.deepEqual(launch?.stdio, ["pipe", "pipe", "pipe", "pipe", "ipc"]);
618
+ assert.equal(launch?.stdio?.length, 5);
228
619
  children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "channel checked" }], stopReason: "stop" }] });
229
620
  children[0].emit({ type: "agent_settled" });
230
621
  assert.equal((await runner.waitFor(task.id)).status, TASK_STATUS.COMPLETED);
@@ -336,6 +727,7 @@ for (const lateEvents of [false, true]) test(`AgentRunner releases quarantined c
336
727
  let launches = 0;
337
728
  let asks = 0;
338
729
  const finishes: string[] = [];
730
+ const observations: Parameters<NonNullable<RunnerHooks["onFinish"]>>[1][] = [];
339
731
  let resolveAnswer!: (answer: { value: string }) => void;
340
732
  const answer = new Promise<{ value: string }>((resolve) => { resolveAnswer = resolve; });
341
733
  const child = fakeChild({ exitOnKill: false, pid: 71 });
@@ -351,11 +743,12 @@ for (const lateEvents of [false, true]) test(`AgentRunner releases quarantined c
351
743
  process: { platform: "linux", kill: (_pid, signal) => {
352
744
  if (signal === 0) throw Object.assign(new Error("group probe"), { code: groupGone ? "ESRCH" : "EPERM" });
353
745
  } },
354
- }, { askUser: async () => { asks += 1; return answer; }, onFinish: (task) => finishes.push(task.id) });
355
- const first = runner.run(request());
746
+ }, { askUser: async () => { asks += 1; return answer; }, onFinish: (task, snapshot) => { finishes.push(task.id); observations.push(snapshot); } });
747
+ const first = runner.run(request({ collectResponseObservations: true }));
356
748
  const second = runner.run(request({ prompt: "queued" }));
357
749
  await tick();
358
750
  const waiter = runner.waitFor(first.id);
751
+ child.emit({ type: "message_end", message: { role: "assistant", stopReason: "aborted", usage: { input: 3 } } });
359
752
  if (lateEvents) child.emit({ type: "extension_ui_request", id: "early", method: "input", title: "Pending?" });
360
753
  runner.cancel(first.id);
361
754
  const grace = timers.find((timer) => timer.ms === 250);
@@ -369,6 +762,9 @@ for (const lateEvents of [false, true]) test(`AgentRunner releases quarantined c
369
762
  assert.equal(store.get(first.id)?.status, TASK_STATUS.FAILED);
370
763
  assert.equal((await waiter).status, TASK_STATUS.FAILED);
371
764
  assert.match(store.get(first.id)?.error ?? "", /cleanup unconfirmed/);
765
+ assert.equal(observations.length, 1);
766
+ assert.equal(observations[0]?.agentSettled, false);
767
+ assert.equal(observations[0]?.responses.length, 1);
372
768
  assert.equal(store.get(second.id)?.status, TASK_STATUS.QUEUED, "the unconfirmed group retains its capacity");
373
769
  assert.equal(timers.filter((timer) => timer.ms === 25 && !timer.cancelled).length, 0, "confirmation polling stops at its deadline");
374
770
  const finished = structuredClone(store.get(first.id));
@@ -398,5 +794,166 @@ for (const lateEvents of [false, true]) test(`AgentRunner releases quarantined c
398
794
  child.exit(0);
399
795
  await tick();
400
796
  assert.deepEqual(finishes, [first.id], "cleanup must not finish the quarantined task twice");
797
+ assert.equal(observations.length, 1, "late cleanup does not redeliver observations");
401
798
  assert.deepEqual(store.get(first.id), finished);
402
799
  });
800
+
801
+ test("a confirmed-gone group completes the exit and frees its slot without an observed exit", async () => {
802
+ // The group probe reports ESRCH (the group is gone) while the child never emits
803
+ // its exit event. Returning without finishing left the task terminal in memory
804
+ // with no record and no retry; finishing without releasing the live entry would
805
+ // keep the concurrency slot occupied and never pump queued work.
806
+ const store = new TaskStore();
807
+ const timers: Array<{ fn: () => void; ms: number; cancelled: boolean }> = [];
808
+ const finishes: string[] = [];
809
+ let now = 1_000;
810
+ let launches = 0;
811
+ const runner = new AgentRunner(store, { maxConcurrency: 1, stallTimeoutMs: 10_000 }, {
812
+ spawn: () => fakeChild({ exitOnKill: false, pid: 90 + (launches += 1) }).child,
813
+ now: () => now,
814
+ schedule: (fn, ms) => {
815
+ const timer = { fn, ms, cancelled: false };
816
+ timers.push(timer);
817
+ return () => { timer.cancelled = true; };
818
+ },
819
+ pi: { command: "pi", args: [] },
820
+ process: { platform: "linux", kill: (_pid, signal) => {
821
+ if (signal === 0) throw Object.assign(new Error("group probe"), { code: "ESRCH" });
822
+ } },
823
+ }, { askUser: async () => ({ value: "yes" }), onFinish: (task) => { finishes.push(task.id); } });
824
+ const first = runner.run(request());
825
+ const second = runner.run(request({ prompt: "queued" }));
826
+ await tick();
827
+ const waiter = runner.waitFor(first.id);
828
+ runner.cancel(first.id);
829
+ const grace = timers.find((timer) => timer.ms === 250);
830
+ assert.ok(grace, "termination grace is scheduled");
831
+ grace.fn();
832
+ await tick();
833
+ assert.equal(store.get(first.id)?.status, TASK_STATUS.CANCELLED);
834
+ assert.equal((await waiter).status, TASK_STATUS.CANCELLED, "the waiter receives the recorded outcome");
835
+ assert.equal(finishes.length, 1, "the run is recorded exactly once");
836
+ assert.equal(store.get(second.id)?.status, TASK_STATUS.RUNNING, "the freed slot starts queued work");
837
+ });
838
+
839
+ test("an unprobeable process group quarantines at its deadline and still records the run", async () => {
840
+ // On win32 the child is not detached, so there is no process group to probe and
841
+ // an observed exit is the only confirmation available. With no exit event the
842
+ // run must still be recorded at the deadline, and its slot must be retained
843
+ // rather than freed on an unproven assumption.
844
+ const store = new TaskStore();
845
+ const timers: Array<{ fn: () => void; ms: number; cancelled: boolean }> = [];
846
+ const finishes: string[] = [];
847
+ let now = 1_000;
848
+ let launches = 0;
849
+ const runner = new AgentRunner(store, { maxConcurrency: 1, stallTimeoutMs: 10_000 }, {
850
+ spawn: () => fakeChild({ exitOnKill: false, pid: 90 + (launches += 1) }).child, now: () => now,
851
+ schedule: (fn, ms) => {
852
+ const timer = { fn, ms, cancelled: false };
853
+ timers.push(timer);
854
+ return () => { timer.cancelled = true; };
855
+ },
856
+ pi: { command: "pi", args: [] },
857
+ process: { platform: "win32", kill: () => {} },
858
+ }, { askUser: async () => ({ value: "yes" }), onFinish: (task) => { finishes.push(task.id); } });
859
+ const first = runner.run(request());
860
+ const second = runner.run(request({ prompt: "queued" }));
861
+ await tick();
862
+ runner.cancel(first.id);
863
+ const grace = timers.find((timer) => timer.ms === 250);
864
+ assert.ok(grace, "termination grace is scheduled");
865
+ grace.fn();
866
+ now = 5_000;
867
+ const check = timers.filter((timer) => timer.ms === 25).at(-1);
868
+ assert.ok(check, "an unprobeable group must keep polling instead of stopping silently");
869
+ check.fn();
870
+ await tick();
871
+ assert.equal(store.get(first.id)?.status, TASK_STATUS.FAILED);
872
+ assert.match(store.get(first.id)?.error ?? "", /capacity quarantined/);
873
+ assert.equal(finishes.length, 1, "the run is recorded exactly once");
874
+ assert.equal(store.get(second.id)?.status, TASK_STATUS.QUEUED, "an unconfirmed exit retains its capacity");
875
+ assert.equal(launches, 1, "no further launch happens while the slot is quarantined");
876
+ });
877
+
878
+ test("abortReasonText renders an Error, a string, and nothing for unknown reasons", () => {
879
+ assert.equal(abortReasonText(undefined), "");
880
+ assert.equal(abortReasonText(new Error("interrupted by user")), " (interrupted by user)");
881
+ assert.equal(abortReasonText("host timeout"), " (host timeout)");
882
+ assert.equal(abortReasonText(new Error("")), "");
883
+ assert.equal(abortReasonText(42), "");
884
+ });
885
+
886
+ test("research narrowing transport keeps exact argv paths and replaces inherited selection", async () => {
887
+ const h = harness();
888
+ const selection = { documentation: { tools: ["fetch_content"], extensions: { fetch_content: "/installed/docs tools.ts" } } };
889
+ for (const researchSelection of [selection, undefined]) {
890
+ const artifact = { store: "none" as const, worktree: "/work", changeName: "demo", retainedIntent: "denied questions", locators: [] };
891
+ const expected = structuredClone(artifact);
892
+ const launch = request({ researchSelection, researchArtifact: artifact, extensionPaths: researchSelection ? ["/installed/docs tools.ts"] : [],
893
+ env: { PATH: "/bin", GENTLE_PI_RESEARCH_SELECTION: "stale broad selection", GENTLE_PI_RESEARCH_ARTIFACT: "stale broader scope" } });
894
+ const argv = childArguments(launch);
895
+ assert.deepEqual(argv.filter((_, i) => argv[i - 1] === "--extension"), launch.extensionPaths);
896
+ const task = h.runner.run(launch);
897
+ artifact.worktree = "/wrong";
898
+ await tick();
899
+ assert.deepEqual(JSON.parse(h.spawnOptions.at(-1)!.env.GENTLE_PI_RESEARCH_ARTIFACT!), expected);
900
+ assert.deepEqual("researchArtifact" in task ? task.researchArtifact : undefined, expected);
901
+ assert.deepEqual(JSON.parse(h.spawnOptions.at(-1)!.env.GENTLE_PI_RESEARCH_SELECTION!), researchSelection ?? null);
902
+ assert.equal(h.spawnOptions.at(-1)!.env.PATH, "/bin");
903
+ h.runner.cancel(task.id);
904
+ assert.equal((await h.runner.waitFor(task.id)).status, TASK_STATUS.CANCELLED);
905
+ }
906
+ });
907
+
908
+
909
+ test("runner retains remediation observations and awaits terminal settlement", async () => {
910
+ const h = harness({ pid: 123 });
911
+ let finalized: { record: TaskRecord; facts: RemediationTerminalFacts } | undefined;
912
+ let release: () => void;
913
+ const gate = new Promise<void>(resolve => { release = resolve; });
914
+ const remediationPlan: RemediationPlan = { cwd: "/repo", commands: ["pnpm test"], runtimeHarness: { naReason: "Not applicable because the fixture has no runtime boundary." }, rollback: { boundary: "Revert fixture", command: "git diff --check" } };
915
+ const remediation: RemediationTaskState = { failedEvidenceRevision: `sha256:${"a".repeat(64)}`, plan: remediationPlan, pending: {}, observations: [], invalid: false, token: "opaque", acquire: { workspaceRoot: "/repo", changeName: "demo", requestId: "fixture", workUnit: "correct", evidenceGoal: "Observed correction" } };
916
+ const task = h.runner.run(request({ sddRemediation: remediation, finalizeRemediation: async (record, facts) => { finalized = { record, facts }; await gate; } }));
917
+ await tick();
918
+ for (const command of ["pnpm test", "git diff --check"]) {
919
+ h.children[0].emit({ type: "tool_execution_start", toolName: "bash", toolCallId: command, args: { command } });
920
+ h.children[0].emit({ type: "tool_execution_end", toolName: "bash", toolCallId: command, isError: false, result: { content: [{ type: "text", text: "command output" }], details: { remediationCommand: { command, toolCallId: command, cwd: "/repo", exitCode: 0 } } } });
921
+ }
922
+ h.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "done" }], stopReason: "stop" }] });
923
+ h.children[0].emit({ type: "agent_settled" });
924
+ await tick();
925
+ assert.ok(finalized);
926
+ assert.equal(finalized.record.sddRemediation?.observations.length, 2);
927
+ const prompt = h.children[0].written.find(value => value.type === "prompt").message;
928
+ assert.ok(typeof prompt === "string");
929
+ assert.match(prompt, /pnpm test/);
930
+ assert.doesNotMatch(prompt, /opaque/);
931
+ assert.equal(finalized.facts.cleanupConfirmed, true);
932
+ assert.equal(h.finishes.length, 0);
933
+ release(); await h.runner.waitFor(task.id);
934
+ assert.equal(h.finishes.length, 1);
935
+ assert.equal(remediation.observations.length, 0);
936
+ });
937
+
938
+
939
+ test("admitted no-PID failure settles interrupted before sending a prompt", async () => {
940
+ const h = harness(); let facts;
941
+ const task = h.runner.run(request({ sddRemediation: { plan: {} } as unknown as RemediationTaskState, finalizeRemediation: async (_task, observed) => { facts = observed; } }));
942
+ await tick();
943
+ assert.equal(h.children[0].written.some(value => value.type === "prompt"), false);
944
+ await h.runner.waitFor(task.id);
945
+ assert.equal(facts.spawned, false);
946
+ assert.equal(facts.exited, false);
947
+ assert.equal(facts.cleanupConfirmed, false);
948
+ });
949
+
950
+
951
+ test("admitted synchronous spawn failure finalizes without launching another actor", async () => {
952
+ let spawns = 0, facts;
953
+ const store = new TaskStore();
954
+ const runner = new AgentRunner(store, { maxConcurrency: 1, stallTimeoutMs: 100 }, { spawn: () => { spawns++; throw new Error("spawn refused"); }, pi: { command: "pi", args: [] }, now: () => 1, schedule: () => () => {} }, { askUser: async () => ({}) });
955
+ const task = runner.run(request({ sddRemediation: { plan: {} } as unknown as RemediationTaskState, finalizeRemediation: async (_task, value) => { facts = value; } }));
956
+ await runner.waitFor(task.id);
957
+ assert.deepEqual(facts, { spawned: false, exited: false, cleanupConfirmed: true });
958
+ assert.equal(spawns, 1);
959
+ });