gentle-pi 2.6.2 → 2.6.4

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,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { Duplex, Readable, Writable } from "node:stream";
3
+ import { stripVTControlCharacters } from "node:util";
3
4
  import { RESEARCH_SELECTION_ENV, RESEARCH_ARTIFACT_ENV, type ResearchArtifactIntent } from "./sdd-research-capabilities.ts";
4
5
  import { AGENT_MODE, formatModelRef, type AgentDefinition, type AgentMode, type ModelRef } from "./agents-config.ts";
5
6
  import { CHILD_QUERY_MAX_INFLIGHT, CHILD_QUERY_TIMEOUT_MS, parseChildFrame, validChildMessage, validChildQueryId } from "./agents-messaging.ts";
@@ -275,8 +276,13 @@ interface LiveTask {
275
276
  acknowledgedIpcIds: Set<string>;
276
277
  acknowledgedIpcOrder: string[];
277
278
  mutationStarts: Map<string, { toolName: "write" | "edit"; toolCallId: string; path: string }>;
279
+ // Bounded ring buffer of the child's raw stderr output, capped to the last
280
+ // STDERR_TAIL_MAX characters. Only surfaced on the stall and pre-settle exit
281
+ // terminal paths, never on completed, cancelled, or other failure reasons.
282
+ stderrTail: string;
278
283
  }
279
284
 
285
+ const STDERR_TAIL_MAX = 512;
280
286
  const CHILD_MARKER = "GENTLE_PI_AGENTS_CHILD";
281
287
  const IPC_MARKER = "GENTLE_PI_AGENTS_OWNED_IPC";
282
288
  const PARENT_NOTIFICATION_TOOL = "subagent_parent_message";
@@ -487,15 +493,15 @@ export class AgentRunner {
487
493
  return accepted;
488
494
  }
489
495
 
490
- cancel(id: string): boolean {
496
+ cancel(id: string, reason = "cancelled"): boolean {
491
497
  const queued = this.queue.findIndex((entry) => entry.task.id === id);
492
498
  if (queued >= 0) {
493
499
  this.queue.splice(queued, 1);
494
- this.finish(id, TASK_STATUS.CANCELLED, "cancelled before start");
500
+ this.finish(id, TASK_STATUS.CANCELLED, `${reason} before start`);
495
501
  return true;
496
502
  }
497
503
  if (!this.live.has(id)) return false;
498
- this.requestStop(id, TASK_STATUS.CANCELLED, "cancelled", true);
504
+ this.requestStop(id, TASK_STATUS.CANCELLED, reason, true);
499
505
  return true;
500
506
  }
501
507
 
@@ -506,9 +512,9 @@ export class AgentRunner {
506
512
  if (live) { live.observations = undefined; live.observationGuard = undefined; }
507
513
  }
508
514
 
509
- cancelAll(): number {
515
+ cancelAll(reason = "cancelled"): number {
510
516
  const ids = [...this.queue.map((entry) => entry.task.id), ...this.live.keys()];
511
- return ids.filter((id) => this.cancel(id)).length;
517
+ return ids.filter((id) => this.cancel(id, reason)).length;
512
518
  }
513
519
 
514
520
  steer(id: string, message: string): boolean {
@@ -555,7 +561,7 @@ export class AgentRunner {
555
561
  return;
556
562
  }
557
563
  const processGroup = detached && typeof child.pid === "number" && child.pid > 0 ? child.pid : undefined;
558
- const live: LiveTask = { child, mutationStarts: new Map(), pending: new Map(), queries: new Map(), replies: new Map(), cancelStall: () => {}, cancelGrace: () => {}, processGroup, terminal: undefined, childExit: undefined, cleanupDeadlineAt: undefined, quarantined: false, nextId: 0, ipcClosed: false, acknowledgedIpcIds: new Set(), acknowledgedIpcOrder: [] };
564
+ const live: LiveTask = { child, mutationStarts: new Map(), pending: new Map(), queries: new Map(), replies: new Map(), cancelStall: () => {}, cancelGrace: () => {}, processGroup, terminal: undefined, childExit: undefined, cleanupDeadlineAt: undefined, quarantined: false, nextId: 0, ipcClosed: false, acknowledgedIpcIds: new Set(), acknowledgedIpcOrder: [], stderrTail: "" };
559
565
  if (request.prepareResponseObservations) {
560
566
  let ready = false;
561
567
  live.observationPreparation = () => ready;
@@ -592,7 +598,11 @@ export class AgentRunner {
592
598
  const lines = new JsonLines((value) => this.receive(id, request, value));
593
599
  child.stdout.setEncoding("utf8");
594
600
  child.stdout.on("data", (chunk: string) => lines.push(chunk));
595
- child.stderr?.on("data", () => {});
601
+ child.stderr?.setEncoding("utf8");
602
+ child.stderr?.on("data", (chunk: string) => {
603
+ const tail = live.stderrTail + chunk;
604
+ live.stderrTail = tail.length > STDERR_TAIL_MAX ? tail.slice(-STDERR_TAIL_MAX) : tail;
605
+ });
596
606
  child.on("exit", (code) => this.exited(id, code));
597
607
  if (request.sddRemediation && child.pid === undefined) {
598
608
  this.childError(id, new Error("remediation child has no process ID"));
@@ -602,6 +612,7 @@ export class AgentRunner {
602
612
  const data = response.data as { sessionFile?: unknown; model?: { provider?: unknown; id?: unknown } | null; thinkingLevel?: unknown } | undefined;
603
613
  if (response.success !== true || live.terminal || this.live.get(id) !== live || !data) return;
604
614
  const resolved: Partial<TaskRecord> = {};
615
+ if (this.canAdvanceLastStep(id, ["starting"])) resolved.lastStep = "pi ready";
605
616
  if (typeof data.sessionFile === "string" && data.sessionFile) resolved.sessionPath = data.sessionFile;
606
617
  if (data.model === null) resolved.model = "default";
607
618
  else if (typeof data.model?.provider === "string" && data.model.provider && typeof data.model.id === "string" && data.model.id) {
@@ -611,13 +622,35 @@ export class AgentRunner {
611
622
  this.store.update(id, resolved);
612
623
  });
613
624
  void this.send(id, { type: "prompt", message: promptText(request) }).then((response) => {
614
- if (response.success === false) this.requestStop(id, TASK_STATUS.FAILED, String(response.error ?? "prompt rejected"));
625
+ if (response.success === false) {
626
+ this.requestStop(id, TASK_STATUS.FAILED, String(response.error ?? "prompt rejected"));
627
+ return;
628
+ }
629
+ if (!live.terminal && this.live.get(id) === live && this.canAdvanceLastStep(id, ["starting", "pi ready"])) this.store.update(id, { lastStep: "prompt accepted" });
615
630
  });
616
631
  }
617
632
 
633
+ // A late get_state/prompt reply must never overwrite a stage that a child
634
+ // event (or the other reply) has already advanced lastStep past.
635
+ private canAdvanceLastStep(id: string, from: readonly string[]): boolean {
636
+ const current = this.store.get(id)?.lastStep;
637
+ return current !== undefined && from.includes(current);
638
+ }
639
+
640
+ // Cleaned for display only: raw bytes stay in live.stderrTail so later
641
+ // appends keep working from the unstripped ring buffer.
642
+ private stderrSuffix(live: LiveTask): string {
643
+ const cleaned = stripVTControlCharacters(live.stderrTail).replace(/\s+/g, " ").trim();
644
+ return cleaned ? `; stderr: ${cleaned}` : "";
645
+ }
646
+
618
647
  private armStall(id: string, live: LiveTask): void {
619
648
  live.cancelStall();
620
- live.cancelStall = this.deps.schedule(() => this.requestStop(id, TASK_STATUS.TIMED_OUT, `stalled for ${Math.round(this.limits.stallTimeoutMs / 60_000)} min`), this.limits.stallTimeoutMs);
649
+ live.cancelStall = this.deps.schedule(() => {
650
+ const lastStep = this.store.get(id)?.lastStep ?? "starting";
651
+ const minutes = Math.round(this.limits.stallTimeoutMs / 60_000);
652
+ this.requestStop(id, TASK_STATUS.TIMED_OUT, `stalled for ${minutes} min after: ${lastStep}${this.stderrSuffix(live)}`);
653
+ }, this.limits.stallTimeoutMs);
621
654
  }
622
655
 
623
656
  private send(id: string, command: Record<string, unknown>): Promise<Record<string, unknown>> {
@@ -933,7 +966,7 @@ export class AgentRunner {
933
966
  if (!live) return;
934
967
  live.childExit = code;
935
968
  if (this.groupExists(live)) {
936
- if (!live.terminal) this.requestStop(id, TASK_STATUS.FAILED, `pi exited with code ${code ?? "unknown"} before agent_settled`);
969
+ if (!live.terminal) this.requestStop(id, TASK_STATUS.FAILED, `pi exited with code ${code ?? "unknown"} before agent_settled${this.stderrSuffix(live)}`);
937
970
  return;
938
971
  }
939
972
  this.completeExit(id, live);
@@ -951,7 +984,7 @@ export class AgentRunner {
951
984
  return;
952
985
  }
953
986
  const terminal = live.terminal;
954
- this.finish(id, terminal ? terminal.status : TASK_STATUS.FAILED, terminal ? terminal.error : `pi exited with code ${live.childExit ?? "unknown"} before agent_settled`, live);
987
+ this.finish(id, terminal ? terminal.status : TASK_STATUS.FAILED, terminal ? terminal.error : `pi exited with code ${live.childExit ?? "unknown"} before agent_settled${this.stderrSuffix(live)}`, live);
955
988
  }
956
989
 
957
990
  private finish(id: string, status: TaskRecord["status"], error: string | null, live?: LiveTask): void {
@@ -980,6 +980,19 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
980
980
  // repeats 2.8.1 exactly. riskEvidence and hint remain dark because neither
981
981
  // is proven to reach the negotiated START path Pi consumes.
982
982
  "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 }),
983
+ // v2.9.0 shipped RTK opt-in Community Tool integration (#4560, installer/
984
+ // sync/TUI only), SDD attempt-ledger fixes (#4564, #4567, #4569 — the
985
+ // remediation pointer is now decided by chain equality before shape, and
986
+ // the refusal wording changed), sync telemetry-runtime symlinked root
987
+ // (#4565), OpenCode reviewer Task wrapper decoding (#4545), and Engram
988
+ // protocol asset wording (#4179). Ground-truthed by diffing
989
+ // contracts/review-integration/v2 and contracts/review-provider-contract
990
+ // between the v2.8.2 and v2.9.0 tags in the gentle-ai source tree: zero
991
+ // bytes changed. None of the above touch the closed START/STATUS fields
992
+ // this row negotiates, so it repeats 2.8.2 exactly. riskEvidence and hint
993
+ // remain dark because neither is proven to reach the negotiated START
994
+ // path Pi consumes.
995
+ "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 }),
983
996
  });
984
997
 
985
998
  export interface NativeReviewProcessDiagnostics {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "2.6.2",
3
+ "version": "2.6.4",
4
4
  "description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -981,6 +981,19 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
981
981
  // repeats 2.8.1 exactly. riskEvidence and hint remain dark because neither
982
982
  // is proven to reach the negotiated START path Pi consumes.
983
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 }),
984
997
  });
985
998
 
986
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.2";
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.2`
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 e3f53de6, the published v2.8.2 release head.
51
- export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:54VJ0ruRiPc6MbNw4iFmlcBkQFOyF5D8iHY0A54Ivug=";
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.2 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.2 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.2_darwin_amd64.tar.gz", "0daa28897e6e54ce584f12ccefebf0d0df84e4ea8fbbd3a07e596ca82b079fa2", "17069156869ceda8e23eaa4fe5d7565cd57c1d78528f121dabba7301b82a0c14", "gentle-ai"),
118
- "darwin/arm64": asset("gentle-ai_2.8.2_darwin_arm64.tar.gz", "12265017e0fb6d5dd1ddb1751f188fa8c47d95a95d7c7515ae174394da5f6e97", "491542e4b60e432048d4074f21c1b04417b980cf77eb34cd0a2e7447ca57dd77", "gentle-ai"),
119
- "linux/amd64": asset("gentle-ai_2.8.2_linux_amd64.tar.gz", "5b95b184606168685a4ff576c103b051ceb23346a7f81ba557e047fa4d744146", "a55f4d2e114128866810c25195e5efecdded4502660e9a9c35c0b7a41c909dd8", "gentle-ai"),
120
- "linux/arm64": asset("gentle-ai_2.8.2_linux_arm64.tar.gz", "a33c91ca0f5c5c85a4fd69f5169138089eb53e0a92c6ab80de5094d0688debb7", "e2759aca09ffe97638e984491319e43c9477d1ae117726ecbdf20d29d44c8950", "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.2 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.2 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";
@@ -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,