gentle-pi 2.6.1 → 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.
Files changed (41) hide show
  1. package/README.md +183 -943
  2. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +9 -8
  3. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +3 -3
  4. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +7 -2
  5. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/lens.schema.json +2 -2
  6. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -1
  7. package/contracts/review-provider-contract-mirror/v1.2.0/generated/provider-capabilities.baseline.json +1 -1
  8. package/contracts/review-provider-contract-mirror/v1.2.0/generated/provider-roles.baseline.json +2 -2
  9. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  10. package/docs/assets/brand/gentle-pi-banner.svg +33 -0
  11. package/docs/assets/brand/terminal-divider.svg +17 -0
  12. package/docs/assets/diagrams/agent-orchestration.svg +19 -0
  13. package/docs/assets/diagrams/gentleman-workflow.svg +15 -0
  14. package/docs/assets/diagrams/native-review.svg +16 -0
  15. package/docs/assets/diagrams/sdd-cycle.svg +14 -0
  16. package/docs/assets/features/gentle-shell.png +0 -0
  17. package/docs/gentle-shell.md +151 -0
  18. package/docs/readme-reference.md +868 -0
  19. package/extensions/gentle-agents.ts +122 -9
  20. package/extensions/gentle-ai.ts +65 -22
  21. package/lib/agents-history.ts +89 -2
  22. package/lib/agents-runner.ts +44 -11
  23. package/lib/native-review-cli.ts +22 -0
  24. package/package.json +1 -1
  25. package/runtime/native-review-cli.mjs +22 -0
  26. package/scripts/gentle-ai-installer.mjs +10 -10
  27. package/scripts/verify-package-files.mjs +2 -2
  28. package/tests/agents-history.test.ts +23 -1
  29. package/tests/agents-runner.test.ts +154 -2
  30. package/tests/gentle-agents.test.ts +102 -4
  31. package/tests/gentle-ai-binary.test.ts +1 -1
  32. package/tests/gentle-ai-installer.test.ts +47 -47
  33. package/tests/gentle-ai.test.ts +3 -2
  34. package/tests/native-review-capability-contract.test.ts +29 -1
  35. package/tests/package-manifest.test.ts +19 -18
  36. package/tests/review-authority-recovery-docs.test.ts +13 -13
  37. package/tests/review-controller-native-routing.test.ts +49 -0
  38. package/tests/review-ledger-contract.test.ts +7 -5
  39. package/tests/sdd-managed-runtime-settlement.test.ts +112 -0
  40. package/tests/sdd-selection-transport.test.ts +57 -0
  41. package/tests/skill-collision-prefixes.test.ts +2 -2
@@ -972,6 +972,28 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
972
972
  // this row repeats 2.8.0 exactly. riskEvidence and hint remain dark
973
973
  // because neither is proven to reach the negotiated START path Pi consumes.
974
974
  "2.8.1": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
975
+ // v2.8.2 shipped OpenCode SDD preflight plugin fixes, community-tools RTK
976
+ // acquisition, and Claude Code Stop telemetry. The provider contract semver
977
+ // stays 1.2.0; the same pin re-mirrors bundle bytes that had drifted under
978
+ // that semver (lens inspection.status "unavailable", targeted-validator
979
+ // regressions/inspection members, seven Pi stop reason codes). None of
980
+ // those touch the closed START/STATUS fields this row negotiates, so it
981
+ // repeats 2.8.1 exactly. riskEvidence and hint remain dark because neither
982
+ // is proven to reach the negotiated START path Pi consumes.
983
+ "2.8.2": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
984
+ // v2.9.0 shipped RTK opt-in Community Tool integration (#4560, installer/
985
+ // sync/TUI only), SDD attempt-ledger fixes (#4564, #4567, #4569 — the
986
+ // remediation pointer is now decided by chain equality before shape, and
987
+ // the refusal wording changed), sync telemetry-runtime symlinked root
988
+ // (#4565), OpenCode reviewer Task wrapper decoding (#4545), and Engram
989
+ // protocol asset wording (#4179). Ground-truthed by diffing
990
+ // contracts/review-integration/v2 and contracts/review-provider-contract
991
+ // between the v2.8.2 and v2.9.0 tags in the gentle-ai source tree: zero
992
+ // bytes changed. None of the above touch the closed START/STATUS fields
993
+ // this row negotiates, so it repeats 2.8.2 exactly. riskEvidence and hint
994
+ // remain dark because neither is proven to reach the negotiated START
995
+ // path Pi consumes.
996
+ "2.9.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
975
997
  });
976
998
 
977
999
 
@@ -36,7 +36,7 @@ const WINDOWS_SYSTEM_ROOT = "C:\\Windows";
36
36
  // version check below) derives from this constant instead of repeating the
37
37
  // literal, so a pin bump cannot leave a stale copy behind. See
38
38
  // scripts/install-gentle-ai.mjs for the incident that motivated this.
39
- export const INSTALLER_VERSION = "2.8.1";
39
+ export const INSTALLER_VERSION = "2.9.0";
40
40
  export const RELEASE_BASE_URL = `https://github.com/Gentleman-Programming/gentle-ai/releases/download/v${INSTALLER_VERSION}/`;
41
41
  export const GENTLE_AI_INSTALL_METHOD = Object.freeze({
42
42
  SIGNED_RELEASE_ASSET: "signed-release-asset",
@@ -45,10 +45,10 @@ export const GENTLE_AI_INSTALL_METHOD = Object.freeze({
45
45
  export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH = "github.com/gentleman-programming/gentle-ai/v2/cmd/gentle-ai";
46
46
  export const GENTLE_AI_WINDOWS_SOURCE_MODULE = "github.com/gentleman-programming/gentle-ai/v2";
47
47
  export const GENTLE_AI_WINDOWS_SOURCE_TAG = `v${INSTALLER_VERSION}`;
48
- // `go mod download -json github.com/gentleman-programming/gentle-ai/v2@v2.8.1`
48
+ // `go mod download -json github.com/gentleman-programming/gentle-ai/v2@v2.9.0`
49
49
  // with GOSUMDB=sum.golang.org reports this exact module SumDB checksum, and the
50
- // tag resolves to commit 7aad6a51, the published v2.8.1 release head.
51
- export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:EixFRy7P3JLVi1ovr18pMxQIrtjDib9i7VEH9g4XRVE=";
50
+ // tag resolves to commit be495547, the published v2.9.0 release head.
51
+ export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:WqmYw1UfVYCbtOxU1uTuiYq9+ROCUIDrmKIOjyYggQI=";
52
52
  export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE = `${GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH}@${GENTLE_AI_WINDOWS_SOURCE_TAG}`;
53
53
  export const GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION = "1.25.10";
54
54
  export const GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE_CODE = "GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE";
@@ -67,7 +67,7 @@ export class GentleAiInstallerError extends Error {
67
67
  // Sentinel used while a re-pinned gentle-ai release is not yet published. A
68
68
  // sentinel digest can never match a real SHA-256, so installation fails closed,
69
69
  // and verify-package-files.mjs refuses to pack/publish while any digest below
70
- // still holds it. The v2.8.1 digests are pinned from the published release:
70
+ // still holds it. The v2.9.0 digests are pinned from the published release:
71
71
  // archive sha256 values verified against the minisign-signed checksums.txt and
72
72
  // freshly computed hashes; binary sha256 values computed from the extracted
73
73
  // executables.
@@ -109,15 +109,15 @@ async function downloadPinnedGentleAiAsset(asset, destination, options) {
109
109
  }
110
110
 
111
111
  // Windows is absent from signed release archives on purpose. gentle-ai stopped
112
- // distributing unsigned Windows builds in c4b764d0, so v2.8.1 publishes signed
112
+ // distributing unsigned Windows builds in c4b764d0, so v2.9.0 publishes signed
113
113
  // Darwin/Linux archives only. Windows x64/arm64 uses the separately verified
114
114
  // exact-tag Go SumDB source-build path below; restore archive rows only when
115
115
  // upstream ships signed Windows assets.
116
116
  export const GENTLE_AI_RELEASE_ASSETS = Object.freeze({
117
- "darwin/amd64": asset("gentle-ai_2.8.1_darwin_amd64.tar.gz", "cbfba54a72ec0a28ce5e42c3057a03cc3e809c28b1ff38d080ef42a242027897", "77784169c58b7f84d21c2df538031de2a9582cc1ea43242103d9487f3278c85f", "gentle-ai"),
118
- "darwin/arm64": asset("gentle-ai_2.8.1_darwin_arm64.tar.gz", "a5aca61d4e98fed0e0a8d1f2d6e1ae5d48746bd6baa541c91f629ade25f47839", "1c4b63db9aba22f02b6cf53b4b61cb750c2a7d8bf429c5aae82993227ca1e613", "gentle-ai"),
119
- "linux/amd64": asset("gentle-ai_2.8.1_linux_amd64.tar.gz", "609190fa9e8ef1896a8b4fcbb5a44e1ad5ac64aae1d17eb0459735c5bbef199b", "c11a6d627a81741d00abc7bc9071522e9279de6f95219d810469a448615e618e", "gentle-ai"),
120
- "linux/arm64": asset("gentle-ai_2.8.1_linux_arm64.tar.gz", "919232dcf7ba1b0cd228f808166146cce8f2ea5d691a59bda9cd42a294432ea4", "f4eb76ea6f466ba991e530b7fc6dce57b39da273d8b3013570edeb17dfe6908d", "gentle-ai"),
117
+ "darwin/amd64": asset("gentle-ai_2.9.0_darwin_amd64.tar.gz", "0e1ce0b117e6f15b56e05defecb33a33825c2e25eb8306df09401d41e0a176fb", "0dc22de552e0403f318770067adccd82783b1f654ed4d0410d7d969e28e39602", "gentle-ai"),
118
+ "darwin/arm64": asset("gentle-ai_2.9.0_darwin_arm64.tar.gz", "0a58d81cd7d76315e1d11ecf6b38abb27ee8e7e709a04c9a786e89636fb559bb", "5e72f1f34667a858fc179504e794847a1407142240361f99effd252d6ad49ee9", "gentle-ai"),
119
+ "linux/amd64": asset("gentle-ai_2.9.0_linux_amd64.tar.gz", "7d414cd8cba8ddc0ab9fa4bc309932638c533217b2f999b2a52e7a4fc0bf6f14", "5c47029fb6520f99b7df1b6841766b09d7184efed2e18694d83795bf4d4a5ad4", "gentle-ai"),
120
+ "linux/arm64": asset("gentle-ai_2.9.0_linux_arm64.tar.gz", "2bdab4684b5d415df9c9423c2022b017c7c1c1d9056f56acc08633fa0d821cfc", "56501a4d91f4e3255726b63c59b96a6d42c6952af2d265f54c8e99b361cda0c9", "gentle-ai"),
121
121
  });
122
122
 
123
123
  // A pinned asset is either a signed archive or, for a prerelease pin only,
@@ -340,7 +340,7 @@ async function main() {
340
340
  });
341
341
 
342
342
  if (driftedContracts.length > 0) {
343
- console.error("gentle-pi packaged review-integration/v1 and review-integration/v2 contract bytes drifted from the pinned v2.8.1 runtime's vendored Gentle AI contract artifacts:");
343
+ console.error("gentle-pi packaged review-integration/v1 and review-integration/v2 contract bytes drifted from the pinned v2.9.0 runtime's vendored Gentle AI contract artifacts:");
344
344
  for (const drift of driftedContracts) console.error(`- ${drift.relativePath}: expected ${drift.expected}, got ${drift.actual}`);
345
345
  process.exit(1);
346
346
  }
@@ -385,7 +385,7 @@ async function main() {
385
385
  process.exit(1);
386
386
  }
387
387
 
388
- console.log(`gentle-pi package resource check passed (${requiredPaths.length} files; ${Object.keys(contractHashes).length} exact byte-pinned contract artifacts for the v2.8.1 runtime).`);
388
+ console.log(`gentle-pi package resource check passed (${requiredPaths.length} files; ${Object.keys(contractHashes).length} exact byte-pinned contract artifacts for the v2.9.0 runtime).`);
389
389
  }
390
390
 
391
391
  const isMainModule = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
@@ -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";
@@ -2160,3 +2236,25 @@ test("R3/R4 host reload refuses retained acquire/actor uncertainty without anoth
2160
2236
  await h.fire("session_shutdown", ctx);
2161
2237
  }
2162
2238
  });
2239
+
2240
+ test("R3/R4 a known native-blocked settlement lets native admission decide the next attempt; an uncertain one still refuses locally", async () => {
2241
+ for (const uncertain of [false, true]) {
2242
+ const h = fakePi(), runtime = deps(), fixtureHome = join(root, `remediation-settlement-${uncertain}`);
2243
+ mkdirSync(join(fixtureHome, ".pi", "agent", "agents"), { recursive: true });
2244
+ writeFileSync(join(fixtureHome, ".pi", "agent", "agents", "sdd-remediate.md"), readFileSync("assets/agents/sdd-remediate.md"));
2245
+ const revision = `sha256:${"a".repeat(64)}`;
2246
+ const acquire = { workspaceRoot: cwd, changeName: "alpha", requestId: "retained", workUnit: "correct", evidenceGoal: "Observed correction", remediatesEvidenceRevision: revision };
2247
+ await saveTask(historyDir(fixtureHome), { id: "retained", agent: "sdd-remediate", cwd, status: "failed", createdAt: 1, sddRemediation: uncertain
2248
+ ? { acquire, settlementUncertain: true, settle: { requestId: "exact" } }
2249
+ : { acquire, settlement: { state: "blocked", reason: "maintainer_decision" } } } as never, emptyThread());
2250
+ let acquisitions = 0, confirmations = 0;
2251
+ gentleAgents(h.pi, {}, { ...runtime.deps, home: fixtureHome, nativeSdd: { sddStatus: async () => ({ schemaName: "gentle-ai.sdd-status", schemaVersion: 2, changeName: "alpha", artifactStore: "openspec", planningHome: { mode: "repo-local", path: join(cwd, "openspec") }, changeRoot: join(cwd, "openspec/changes/alpha"), actionContext: { mode: "repo-local", workspaceRoot: cwd, allowedEditRoots: [cwd] }, dependencies: Object.fromEntries(["proposal", "specs", "design", "tasks", "apply", "verify", "archive"].map(key => [key, "ready"])), phaseInstructions: { apply: [], verify: [], remediate: ["Correct evidence"], archive: [] }, blockedReasons: [], nextRecommended: "remediate", remediationState: { required: true, complete: false, failedEvidenceRevision: revision } }), sddAttemptSettle: async () => ({ state: "proceed" as const }), sddAttemptAcquire: async () => { acquisitions++; return { state: "blocked" }; } } as unknown as NativeReviewCli });
2252
+ const { ctx } = fakeContext(fakeTui, async () => { confirmations++; return true; });
2253
+ await h.fire("session_start", ctx);
2254
+ await assert.rejects(h.tools.get("subagent_run").execute("again", { agent: "sdd-remediate", task: "Correct alpha", context: PARENT_CONFIRMED_SDD_CONTEXT, mode: "background", sdd_change: { changeName: "alpha", workspaceRoot: cwd, phase: "remediate", failedEvidenceRevision: revision }, remediation: { attempt: { ...acquire, requestId: "different" }, plan: { cwd, commands: ["pnpm test"], runtimeHarness: { naReason: "Not applicable because this fixture has no runtime boundary." }, rollback: { boundary: "Revert fixture", command: "git diff --check" } } } }, undefined, undefined, ctx), uncertain ? /reconcile exact history without actor replay/ : /no actor started/);
2255
+ assert.equal(acquisitions, uncertain ? 0 : 1, "native admission is consulted once local unresolved history is not uncertain");
2256
+ assert.equal(confirmations, uncertain ? 0 : 1);
2257
+ assert.equal(runtime.spawned.length, 0);
2258
+ await h.fire("session_shutdown", ctx);
2259
+ }
2260
+ });
@@ -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.1",
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,