gentle-pi 2.6.2 → 2.6.3

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.
@@ -1,9 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
2
3
  import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
4
  import { tmpdir } from "node:os";
4
5
  import { join } from "node:path";
5
6
  import test, { after } from "node:test";
6
- import { historyDir, loadHistory, loadStoredTask, pruneHistory, saveTask } from "../lib/agents-history.ts";
7
+ import { acquireTaskLock, historyDir, loadHistory, loadStoredTask, pruneHistory, saveTask } from "../lib/agents-history.ts";
7
8
  import { applyTaskEvent, emptyThread, TASK_EVENT, TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
8
9
 
9
10
  // Gentle Agents history: JSON per task, async, lazy, pruned by count.
@@ -12,6 +13,13 @@ const root = mkdtempSync(join(tmpdir(), "gentle-agents-history-"));
12
13
  after(() => rmSync(root, { recursive: true, force: true }));
13
14
  const dir = join(root, "tasks");
14
15
 
16
+ function orphanLock(lockDir: string, id: string): void {
17
+ const moduleUrl = new URL("../lib/agents-history.ts", import.meta.url).href;
18
+ const source = `import { acquireTaskLock } from ${JSON.stringify(moduleUrl)}; const [dir, id] = process.argv.slice(-2); acquireTaskLock(dir, id);`;
19
+ const child = spawnSync(process.execPath, ["--experimental-strip-types", "--input-type=module", "-e", source, lockDir, id], { encoding: "utf8" });
20
+ assert.equal(child.status, 0, child.stderr || child.stdout);
21
+ }
22
+
15
23
  function task(id: string, createdAt: number): TaskRecord {
16
24
  return { id, agent: "explore", mode: "task", prompt: "p", label: "p", cwd: "/r", parentSessionId: "s", status: TASK_STATUS.COMPLETED, createdAt, startedAt: createdAt, endedAt: createdAt + 5, model: "m", thinking: undefined, sessionPath: null, error: null, result: "ok", lastStep: "done", lastActivityAt: createdAt, turns: 1, toolCalls: 0, tokens: 10, cost: 0.01 };
17
25
  }
@@ -33,6 +41,20 @@ test("saveTask writes a task with its thread and loadStoredTask reads it back",
33
41
  assert.deepEqual(readdirSync(dir), ["a1.json"], "no temp file is left behind");
34
42
  });
35
43
 
44
+ test("task reconciliation elections bypass dead candidates and fail closed for active or ambiguous candidates", () => {
45
+ const lockDir = join(root, "task-locks"), token = "11111111-1111-4111-8111-111111111111";
46
+ const held = acquireTaskLock(lockDir, "busy");
47
+ assert.match(held.path, /busy\.reconcile\.[0-9a-f-]+$/);
48
+ assert.throws(() => acquireTaskLock(lockDir, "busy"), /busy|active|ambiguous/i); held.release();
49
+ orphanLock(lockDir, "dead");
50
+ const deadName = readdirSync(lockDir).find(name => name.startsWith("dead.reconcile."))!;
51
+ const bypassed = acquireTaskLock(lockDir, "dead");
52
+ assert.notEqual(bypassed.path, join(lockDir, deadName)); assert.ok(!readdirSync(lockDir).includes(deadName)); bypassed.release();
53
+ const malformed = join(lockDir, `malformed.reconcile.${token}`); writeFileSync(malformed, "not-json");
54
+ assert.throws(() => acquireTaskLock(lockDir, "malformed"), /busy|active|ambiguous|malformed/i);
55
+ const foreign = join(lockDir, `foreign.reconcile.${token}`); writeFileSync(foreign, JSON.stringify({ schema: "gentle-pi.task-reconciliation-lock/v1", taskId: "foreign", token, pid: process.pid, host: "foreign-host" }));
56
+ assert.throws(() => acquireTaskLock(lockDir, "foreign"), /busy|active|ambiguous|foreign/i);
57
+ });
36
58
  test("loadHistory skips broken files, sorts newest first, and pruneHistory keeps the newest N", async () => {
37
59
  await saveTask(dir, task("b2", 3000), emptyThread());
38
60
  await saveTask(dir, task("c3", 2000), emptyThread());
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { PassThrough } from "node:stream";
3
4
  import { AGENT_MODE, parseAgentsConfig, resolveAgentProfile, type AgentDefinition } from "../lib/agents-config.ts";
4
5
  import { TASK_STATUS, TaskStore, type RemediationTaskState, type TaskRecord } from "../lib/agents-protocol.ts";
5
6
  import { AgentRunner, childArguments, JsonLines, piCommand, abortReasonText, type RemediationPlan, type RemediationTerminalFacts, type RunnerDeps, type RunnerHooks, type TaskRequest } from "../lib/agents-runner.ts";
@@ -25,7 +26,7 @@ interface Harness {
25
26
  spawnOptions: Array<{ env: NodeJS.ProcessEnv; stdio?: string[] }>;
26
27
  }
27
28
 
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
+ function harness(options: { pid?: number; maxConcurrency?: number; stallTimeoutMs?: number; answer?: Record<string, unknown>; exitOnKill?: boolean; state?: Record<string, unknown>; stateSuccess?: boolean; onNotification?: RunnerHooks["onNotification"]; onSuccessfulMutation?: RunnerHooks["onSuccessfulMutation"]; onFinish?: RunnerHooks["onFinish"] } = {}): Harness {
29
30
  const children: FakeChild[] = [];
30
31
  const timers: Harness["timers"] = [];
31
32
  const asks: Harness["asks"] = [];
@@ -59,7 +60,7 @@ function harness(options: { pid?: number; maxConcurrency?: number; answer?: Reco
59
60
  pi: { command: "pi", args: [] },
60
61
  };
61
62
  const store = new TaskStore();
62
- const runner = new AgentRunner(store, { maxConcurrency: options.maxConcurrency ?? 2, stallTimeoutMs: 10_000 }, deps, {
63
+ const runner = new AgentRunner(store, { maxConcurrency: options.maxConcurrency ?? 2, stallTimeoutMs: options.stallTimeoutMs ?? 10_000 }, deps, {
63
64
  askUser: async (taskId, ask) => {
64
65
  asks.push({ taskId, method: ask.method });
65
66
  return options.answer ?? { value: "yes" };
@@ -73,6 +74,157 @@ function harness(options: { pid?: number; maxConcurrency?: number; answer?: Reco
73
74
 
74
75
  const tick = () => new Promise((resolve) => setImmediate(resolve));
75
76
 
77
+ const FOUR_MIN_MS = 4 * 60_000;
78
+
79
+ // A child that never answers the launch RPC commands (get_state, prompt), so
80
+ // the task's lastStep never leaves its initial "starting" stage. Used to
81
+ // exercise the stall watchdog before any child response arrives.
82
+ function silentHarness(stallTimeoutMs: number): { store: TaskStore; runner: AgentRunner; timers: Array<{ fn: () => void; ms: number; cancelled: boolean }>; child: () => FakeChild } {
83
+ const timers: Array<{ fn: () => void; ms: number; cancelled: boolean }> = [];
84
+ let clock = 1000;
85
+ let created: FakeChild | undefined;
86
+ const store = new TaskStore();
87
+ const runner = new AgentRunner(store, { maxConcurrency: 1, stallTimeoutMs }, {
88
+ spawn: () => {
89
+ created = fakeChild();
90
+ created.child.stdin.removeAllListeners("data");
91
+ return created.child;
92
+ },
93
+ now: () => (clock += 1),
94
+ schedule: (fn, ms) => {
95
+ const timer = { fn, ms, cancelled: false };
96
+ timers.push(timer);
97
+ return () => {
98
+ timer.cancelled = true;
99
+ };
100
+ },
101
+ pi: { command: "pi", args: [] },
102
+ }, { askUser: async () => ({ cancelled: true }) });
103
+ return { store, runner, timers, child: () => created! };
104
+ }
105
+
106
+ test("stall before any child response records the last completed stage as starting, with the stderr tail", async () => {
107
+ const h = silentHarness(FOUR_MIN_MS);
108
+ const task = h.runner.run(request());
109
+ await tick();
110
+ (h.child().child.stderr as unknown as PassThrough).write("Error: cannot bind\nprovider socket\n");
111
+ await tick();
112
+ const stall = h.timers.filter((timer) => timer.ms === FOUR_MIN_MS && !timer.cancelled).at(-1);
113
+ assert.ok(stall);
114
+ stall!.fn();
115
+ await tick();
116
+ assert.equal(h.store.get(task.id)?.status, TASK_STATUS.TIMED_OUT);
117
+ assert.equal(h.store.get(task.id)?.error, "stalled for 4 min after: starting; stderr: Error: cannot bind provider socket");
118
+ });
119
+
120
+ test("stall after get_state and prompt responses records the prompt accepted stage", async () => {
121
+ const h = harness({ stallTimeoutMs: FOUR_MIN_MS });
122
+ const task = h.runner.run(request());
123
+ await tick();
124
+ assert.deepEqual(h.children[0].written.map((command) => command.type), ["get_state", "prompt"]);
125
+ assert.equal(h.store.get(task.id)?.lastStep, "prompt accepted");
126
+ const stall = h.timers.filter((timer) => timer.ms === FOUR_MIN_MS && !timer.cancelled).at(-1);
127
+ assert.ok(stall);
128
+ stall!.fn();
129
+ await tick();
130
+ assert.equal(h.store.get(task.id)?.error, "stalled for 4 min after: prompt accepted");
131
+ });
132
+
133
+ test("stderr tail bounds the child's raw output to 512 characters before stripping ANSI escapes", async () => {
134
+ const h = silentHarness(FOUR_MIN_MS);
135
+ const task = h.runner.run(request());
136
+ await tick();
137
+ const filler = "x".repeat(508);
138
+ (h.child().child.stderr as unknown as PassThrough).write(`${filler}OK`);
139
+ await tick();
140
+ const stall = h.timers.filter((timer) => timer.ms === FOUR_MIN_MS && !timer.cancelled).at(-1)!;
141
+ stall.fn();
142
+ await tick();
143
+ const expectedTail = `${"x".repeat(501)}OK`;
144
+ assert.equal(h.store.get(task.id)?.error, `stalled for 4 min after: starting; stderr: ${expectedTail}`);
145
+ });
146
+
147
+ test("child exit before agent_settled includes the stderr tail; a completed task never carries stderr", async () => {
148
+ const h = harness({ maxConcurrency: 2 });
149
+ const crashing = h.runner.run(request());
150
+ const completing = h.runner.run(request({ prompt: "finish clean" }));
151
+ await tick();
152
+ const crashChild = h.children[0];
153
+ const doneChild = h.children[1];
154
+ (crashChild.child.stderr as unknown as PassThrough).write("panic: provider unavailable");
155
+ await tick();
156
+ crashChild.exit(1);
157
+ await tick();
158
+ assert.equal(h.store.get(crashing.id)?.status, TASK_STATUS.FAILED);
159
+ assert.equal(h.store.get(crashing.id)?.error, "pi exited with code 1 before agent_settled; stderr: panic: provider unavailable");
160
+
161
+ (doneChild.child.stderr as unknown as PassThrough).write("noisy but irrelevant");
162
+ await tick();
163
+ doneChild.emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "final report" }], stopReason: "stop" }] });
164
+ doneChild.emit({ type: "agent_settled" });
165
+ await h.runner.waitFor(completing.id);
166
+ assert.equal(h.store.get(completing.id)?.status, TASK_STATUS.COMPLETED);
167
+ assert.equal(h.store.get(completing.id)?.error, null);
168
+ });
169
+
170
+ test("cancel(id, reason) records the given reason for a live and a queued task; cancelAll(reason) threads it", async () => {
171
+ const h = harness({ maxConcurrency: 1 });
172
+ const running = h.runner.run(request());
173
+ const queued = h.runner.run(request({ prompt: "queued work" }));
174
+ await tick();
175
+ assert.equal(h.store.get(queued.id)?.status, TASK_STATUS.QUEUED);
176
+ assert.equal(h.runner.cancel(queued.id, "stopped from the agents panel"), true);
177
+ assert.equal(h.store.get(queued.id)?.status, TASK_STATUS.CANCELLED);
178
+ assert.equal(h.store.get(queued.id)?.error, "stopped from the agents panel before start");
179
+ assert.equal(h.runner.cancel(running.id, "stopped from the agents panel"), true);
180
+ await tick();
181
+ assert.equal(h.store.get(running.id)?.status, TASK_STATUS.CANCELLED);
182
+ assert.equal(h.store.get(running.id)?.error, "stopped from the agents panel");
183
+
184
+ const h2 = harness({ maxConcurrency: 1 });
185
+ const runningTwo = h2.runner.run(request());
186
+ const queuedTwo = h2.runner.run(request({ prompt: "queued work" }));
187
+ await tick();
188
+ assert.equal(h2.runner.cancelAll("cancelled: parent session shut down"), 2);
189
+ await tick();
190
+ assert.equal(h2.store.get(runningTwo.id)?.error, "cancelled: parent session shut down");
191
+ assert.equal(h2.store.get(queuedTwo.id)?.error, "cancelled: parent session shut down before start");
192
+ });
193
+
194
+ test("earlier get_state and prompt responses cannot regress lastStep past a later child event", async () => {
195
+ const store = new TaskStore();
196
+ const timers: Array<{ fn: () => void; ms: number; cancelled: boolean }> = [];
197
+ let clock = 1000;
198
+ const fake = fakeChild();
199
+ fake.child.stdin.removeAllListeners("data"); // respond to get_state/prompt manually, out of order
200
+ const written: Array<Record<string, unknown>> = [];
201
+ fake.child.stdin.on("data", (chunk: Buffer) => written.push(JSON.parse(chunk.toString())));
202
+ const runner = new AgentRunner(store, { maxConcurrency: 1, stallTimeoutMs: FOUR_MIN_MS }, {
203
+ spawn: () => fake.child,
204
+ now: () => (clock += 1),
205
+ schedule: (fn, ms) => {
206
+ const timer = { fn, ms, cancelled: false };
207
+ timers.push(timer);
208
+ return () => {
209
+ timer.cancelled = true;
210
+ };
211
+ },
212
+ pi: { command: "pi", args: [] },
213
+ }, { askUser: async () => ({ cancelled: true }) });
214
+ const task = runner.run(request());
215
+ await tick();
216
+ assert.deepEqual(written.map((command) => command.type), ["get_state", "prompt"]);
217
+ // A later child event (a tool call) advances lastStep before either launch reply arrives.
218
+ fake.emit({ type: "tool_execution_start", toolCallId: "c1", toolName: "bash", args: {} });
219
+ await tick();
220
+ assert.equal(store.get(task.id)?.lastStep, "bash");
221
+ // The get_state and prompt responses arrive late; they must not regress the stage.
222
+ fake.emit({ type: "response", id: written[0]?.id, command: "get_state", success: true, data: { sessionFile: "/sessions/child.jsonl" } });
223
+ fake.emit({ type: "response", id: written[1]?.id, command: "prompt", success: true });
224
+ await tick();
225
+ assert.equal(store.get(task.id)?.lastStep, "bash", "a late get_state/prompt reply must not regress lastStep");
226
+ });
227
+
76
228
  test("synchronous cancellation before dequeue never invokes the policy callback", async () => {
77
229
  const h = harness(); let checks = 0;
78
230
  const task = h.runner.run(request({ prepareResponseObservations: async () => { checks++; return true; } }));
@@ -11,7 +11,7 @@ import { visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
11
11
  import gentleAgents, { agentRuntimePaths, agentsCollapseKey, agentsEnabled, agentsStopKey, agentsViewKey, answerThroughUi, completionText, legacySubagentsInstalled, type AgentsDeps } from "../extensions/gentle-agents.ts";
12
12
  import { historyDir, loadHistory, saveTask } from "../lib/agents-history.ts";
13
13
  import { STALE_COMPLETION_MS } from "../lib/agents-completion-delivery.ts";
14
- import { emptyThread, TASK_EVENT, TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
14
+ import { applyTaskEvent, emptyThread, TASK_EVENT, TASK_STATUS, TaskStore, type TaskRecord } from "../lib/agents-protocol.ts";
15
15
  import { NativePointerScope } from "../lib/native-pointer-region.ts";
16
16
  import { PresenceCursor, PresencePublisher, listPresence, readActivity } from "../lib/orchestrator-presence.ts";
17
17
  import { stripAnsi } from "../lib/terminal-theme.ts";
@@ -179,10 +179,11 @@ function deps(): { deps: Partial<AgentsDeps>; children: FakeChild[]; spawned: st
179
179
  };
180
180
  }
181
181
 
182
- test("all nine subagent registrations own their transcript shell", () => {
182
+ test("all ten subagent registrations own their transcript shell", () => {
183
183
  const { pi, tools } = fakePi();
184
184
  gentleAgents(pi, {}, deps().deps);
185
- assert.equal(tools.size, 9);
185
+ assert.equal(tools.size, 10);
186
+ assert.deepEqual(tools.get("subagent_reconcile")?.parameters, { type: "object", additionalProperties: false, required: ["task_id"], properties: { task_id: { type: "string" } } });
186
187
  for (const tool of tools.values()) assert.equal(tool.renderShell, "self", tool.name);
187
188
  });
188
189
 
@@ -1261,7 +1262,7 @@ test("subagent_list_agents and subagent_run in task mode launch a child with the
1261
1262
  gentleAgents(pi, {}, harness.deps);
1262
1263
  const { ctx, widget } = fakeContext();
1263
1264
  await fire("session_start", ctx);
1264
- assert.deepEqual([...tools.keys()].sort(), ["subagent_cancel", "subagent_continue", "subagent_list_agents", "subagent_list_tasks", "subagent_reply", "subagent_result", "subagent_run", "subagent_send_message", "subagent_status"]);
1265
+ assert.deepEqual([...tools.keys()].sort(), ["subagent_cancel", "subagent_continue", "subagent_list_agents", "subagent_list_tasks", "subagent_reconcile", "subagent_reply", "subagent_result", "subagent_run", "subagent_send_message", "subagent_status"]);
1265
1266
  const listed = await tools.get("subagent_list_agents")!.execute("c0", {}, undefined, undefined, ctx);
1266
1267
  assert.match(listed.content[0].text, /- explore \(global\): maps things/);
1267
1268
 
@@ -2141,6 +2142,81 @@ test("R1 malformed child grant denies tools even before/after failed session ini
2141
2142
  });
2142
2143
 
2143
2144
 
2145
+ test("public reconciliation replays retained authority, persists closure, and never exposes or starts the actor", async () => {
2146
+ const fixtureHome = join(root, "remediation-reconcile");
2147
+ const acquire = { workspaceRoot: cwd, changeName: "alpha", requestId: "retained-acquire", workUnit: "correct", evidenceGoal: "Observed correction", remediatesEvidenceRevision: `sha256:${"a".repeat(64)}` };
2148
+ await saveTask(historyDir(fixtureHome), { id: "retained", agent: "sdd-remediate", cwd, status: "failed", createdAt: 1, sddRemediation: { acquire, acquireUncertain: true } } as never, emptyThread());
2149
+ const h = fakePi(), runtime = deps(), calls = [];
2150
+ gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd: {
2151
+ sddAttemptAcquire: async input => { calls.push(["acquire", structuredClone(input)]); return { state: "proceed", token: "private-token" }; },
2152
+ sddAttemptSettle: async input => { calls.push(["settle", structuredClone(input)]); return { state: "complete" }; },
2153
+ } as unknown as NativeReviewCli });
2154
+ const { ctx } = fakeContext(); await h.fire("session_start", ctx);
2155
+ const output = await h.tools.get("subagent_reconcile").execute("reconcile", { task_id: "retained" }, undefined, undefined, ctx);
2156
+ assert.match(output.content[0].text, /reconciled/i);
2157
+ assert.equal(JSON.stringify(output).includes("private-token"), false);
2158
+ assert.deepEqual(calls[0], ["acquire", acquire]); assert.equal(calls[1][0], "settle");
2159
+ assert.equal(runtime.spawned.length, 0);
2160
+ const retained = (await loadHistory(historyDir(fixtureHome)))[0].task;
2161
+ assert.equal(retained.sddRemediation.acquireUncertain, undefined);
2162
+ assert.deepEqual(retained.sddRemediation.settlement, { state: "complete" });
2163
+ });
2164
+
2165
+ test("durable reconciliation locks serialize independent extension instances sharing one tasksDir", async () => {
2166
+ const fixtureHome = join(root, "remediation-reconcile-independent");
2167
+ const id = "retained-independent";
2168
+ const acquire = { workspaceRoot: cwd, changeName: "alpha", requestId: id, workUnit: "correct", evidenceGoal: "Observed correction" };
2169
+ await saveTask(historyDir(fixtureHome), { id, agent: "sdd-remediate", cwd, status: "failed", createdAt: 1, sddRemediation: { acquire, acquireUncertain: true } } as never, emptyThread());
2170
+ const first = fakePi(), second = fakePi(), runtime = deps();
2171
+ let calls = 0, release!: (result: { state: "blocked" }) => void;
2172
+ const pending = new Promise<{ state: "blocked" }>(resolve => { release = resolve; });
2173
+ const native = { sddAttemptAcquire: async () => { calls++; return calls === 1 ? pending : { state: "blocked" }; } } as unknown as NativeReviewCli;
2174
+ gentleAgents(first.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd: native });
2175
+ gentleAgents(second.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd: native });
2176
+ const firstContext = fakeContext(), secondContext = fakeContext();
2177
+ await first.fire("session_start", firstContext.ctx); await second.fire("session_start", secondContext.ctx);
2178
+ const running = first.tools.get("subagent_reconcile")!.execute("first", { task_id: id }, undefined, undefined, firstContext.ctx);
2179
+ await eventually(() => calls === 1, "the first instance must reach native acquire while holding the lock");
2180
+ await assert.rejects(second.tools.get("subagent_reconcile")!.execute("second", { task_id: id }, undefined, undefined, secondContext.ctx), /busy|active|already being reconciled/i);
2181
+ assert.equal(calls, 1, "a busy filesystem lock fails before native acquire");
2182
+ release({ state: "blocked" }); await running;
2183
+ await first.fire("session_shutdown", firstContext.ctx); await second.fire("session_shutdown", secondContext.ctx);
2184
+ });
2185
+
2186
+ test("reconciliation reloads a stale local task and preserves the retained disk thread", async () => {
2187
+ const fixtureHome = join(root, "remediation-reconcile-reload");
2188
+ const id = "retained-reload";
2189
+ const oldAcquire = { workspaceRoot: cwd, changeName: "alpha", requestId: "old", workUnit: "old", evidenceGoal: "old" };
2190
+ const freshAcquire = { workspaceRoot: cwd, changeName: "alpha", requestId: "fresh", workUnit: "fresh", evidenceGoal: "fresh" };
2191
+ await saveTask(historyDir(fixtureHome), { id, agent: "sdd-remediate", cwd, status: "failed", createdAt: 1, sddRemediation: { acquire: oldAcquire, acquireUncertain: true } } as never, applyTaskEvent(emptyThread(), { type: TASK_EVENT.NOTE, text: "old thread" }));
2192
+ const h = fakePi(), runtime = deps(), seen: unknown[] = [];
2193
+ gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd: { sddAttemptAcquire: async input => { seen.push(structuredClone(input)); return { state: "blocked" }; } } as unknown as NativeReviewCli });
2194
+ const { ctx } = fakeContext(); await h.fire("session_start", ctx);
2195
+ await h.tools.get("subagent_status")!.execute("status", { task_id: id }, undefined, undefined, ctx);
2196
+ const freshThread = applyTaskEvent(emptyThread(), { type: TASK_EVENT.NOTE, text: "fresh thread" });
2197
+ await saveTask(historyDir(fixtureHome), { id, agent: "sdd-remediate", cwd, status: "failed", createdAt: 1, sddRemediation: { acquire: freshAcquire, acquireUncertain: true } } as never, freshThread);
2198
+ await h.tools.get("subagent_reconcile")!.execute("reconcile", { task_id: id }, undefined, undefined, ctx);
2199
+ assert.deepEqual(seen, [freshAcquire], "native receives the force-reloaded retained request");
2200
+ const stored = (await loadHistory(historyDir(fixtureHome))).find(entry => entry.task.id === id)!;
2201
+ assert.deepEqual(stored.thread.items, freshThread.items, "persistence retains the exact disk thread, not the stale store thread");
2202
+ await h.fire("session_shutdown", ctx);
2203
+ });
2204
+
2205
+ test("reconciliation releases the durable lock after native failure", async () => {
2206
+ const fixtureHome = join(root, "remediation-reconcile-failure");
2207
+ const id = "retained-failure";
2208
+ const acquire = { workspaceRoot: cwd, changeName: "alpha", requestId: id, workUnit: "correct", evidenceGoal: "Observed correction" };
2209
+ await saveTask(historyDir(fixtureHome), { id, agent: "sdd-remediate", cwd, status: "failed", createdAt: 1, sddRemediation: { acquire, acquireUncertain: true } } as never, emptyThread());
2210
+ const h = fakePi(), runtime = deps(); let calls = 0;
2211
+ gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd: { sddAttemptAcquire: async () => { calls++; if (calls === 1) throw new TypeError("native failure"); return { state: "blocked" }; } } as unknown as NativeReviewCli });
2212
+ const { ctx } = fakeContext(); await h.fire("session_start", ctx);
2213
+ await assert.rejects(h.tools.get("subagent_reconcile")!.execute("failed", { task_id: id }, undefined, undefined, ctx), /native failure/);
2214
+ const recovered = await h.tools.get("subagent_reconcile")!.execute("retry", { task_id: id }, undefined, undefined, ctx);
2215
+ assert.match(recovered.content[0].text, /reconciled/i);
2216
+ assert.equal(calls, 2, "the second attempt acquires after finally released the first lock");
2217
+ await h.fire("session_shutdown", ctx);
2218
+ });
2219
+
2144
2220
  test("R3/R4 host reload refuses retained acquire/actor uncertainty without another launch", async () => {
2145
2221
  for (const actorClaimed of [false, true, "blocked", "complete"]) {
2146
2222
  const normal = typeof actorClaimed === "string";
@@ -97,7 +97,7 @@ async function writeWindowsSourceBinary(packageRoot: string): Promise<{ binaryPa
97
97
  method: "go-sumdb-source-build",
98
98
  package: "github.com/gentleman-programming/gentle-ai/v2/cmd/gentle-ai",
99
99
  module: "github.com/gentleman-programming/gentle-ai/v2",
100
- tag: "v2.8.2",
100
+ tag: "v2.9.0",
101
101
  architecture: process.arch === "x64" ? "x64" : "arm64",
102
102
  binarySha256: createHash("sha256").update(binary).digest("hex"),
103
103
  moduleChecksum: GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,