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.
- package/README.md +1 -1
- package/docs/gentle-shell.md +1 -1
- package/docs/readme-reference.md +8 -8
- package/extensions/gentle-agents.ts +118 -8
- package/extensions/gentle-ai.ts +174 -8
- package/lib/agents-history.ts +82 -1
- package/lib/agents-runner.ts +44 -11
- package/lib/native-review-cli.ts +13 -0
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +13 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/verify-package-files.mjs +2 -2
- package/tests/agents-history.test.ts +23 -1
- package/tests/agents-runner.test.ts +154 -2
- package/tests/gentle-agents.test.ts +80 -4
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +97 -1
- package/tests/native-review-capability-contract.test.ts +17 -1
- package/tests/package-manifest.test.ts +6 -6
- package/tests/sdd-managed-runtime-settlement.test.ts +75 -0
package/lib/agents-runner.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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?.
|
|
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)
|
|
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(() =>
|
|
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 {
|
package/lib/native-review-cli.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
51
|
-
export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:
|
|
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.
|
|
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.
|
|
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.
|
|
118
|
-
"darwin/arm64": asset("gentle-ai_2.
|
|
119
|
-
"linux/amd64": asset("gentle-ai_2.
|
|
120
|
-
"linux/arm64": asset("gentle-ai_2.
|
|
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.
|
|
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.
|
|
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}[31mOK[0m`);
|
|
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
|
|
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,
|
|
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.
|
|
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,
|