gentle-pi 2.3.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +195 -11
- package/assets/agents/gentle-ai-worker.md +9 -0
- package/assets/agents/sdd-explore.md +1 -0
- package/assets/orchestrator-delegation.md +21 -10
- package/assets/orchestrator.md +8 -12
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
- package/docs/delegated-verification.md +25 -0
- package/docs/review-integration.md +1 -1
- package/docs/telemetry.md +38 -0
- package/extensions/ask-user-choice.ts +26 -20
- package/extensions/codegraph-tools.ts +94 -5
- package/extensions/gentle-agents.ts +588 -0
- package/extensions/gentle-ai.ts +1421 -143
- package/extensions/gentle-shell.ts +547 -0
- package/extensions/gentle-todo.ts +199 -0
- package/extensions/quiet-tools.ts +1 -1
- package/lib/agent-home.ts +8 -0
- package/lib/agents-config.ts +318 -0
- package/lib/agents-history.ts +80 -0
- package/lib/agents-protocol.ts +429 -0
- package/lib/agents-runner.ts +490 -0
- package/lib/agents-transcript.ts +87 -0
- package/lib/agents-view.ts +557 -0
- package/lib/agents-widget.ts +222 -0
- package/lib/gentle-ai-renderer.ts +142 -26
- package/lib/native-choice-list.ts +194 -0
- package/lib/native-fullscreen-interaction.ts +47 -0
- package/lib/native-pointer-region.ts +164 -0
- package/lib/native-review-cli.ts +103 -12
- package/lib/provider-contract-bundle.ts +88 -6
- package/lib/review-candidate-view-owner.ts +177 -0
- package/lib/review-candidate-view.ts +127 -35
- package/lib/review-consent-ui.ts +65 -0
- package/lib/review-host-relay.ts +146 -60
- package/lib/review-integration-v2.ts +92 -13
- package/lib/review-last-event-controller.ts +1 -0
- package/lib/review-relay-contract.ts +11 -0
- package/lib/review-repository.ts +2 -2
- package/lib/review-risk-assessment.ts +339 -0
- package/lib/review-session-standing-permission-ipc.ts +309 -0
- package/lib/review-session-standing-permission.ts +219 -0
- package/lib/sdd-preflight.ts +2 -2
- package/lib/shell-bar.ts +138 -0
- package/lib/shell-card.ts +136 -0
- package/lib/shell-changes-view.ts +205 -0
- package/lib/shell-changes.ts +210 -0
- package/lib/shell-gauge.ts +40 -0
- package/lib/shell-prompt.ts +119 -0
- package/lib/shell-todo.ts +280 -0
- package/lib/shell-usage-view.ts +76 -0
- package/lib/shell-usage.ts +246 -0
- package/lib/telemetry-trigger.ts +151 -0
- package/package.json +4 -4
- package/runtime/native-review-cli.mjs +102 -11
- package/runtime/review-integration-v2.mjs +92 -13
- package/runtime/review-relay-contract.mjs +11 -0
- package/runtime/review-risk-assessment.mjs +340 -0
- package/runtime/telemetry-trigger.mjs +152 -0
- package/scripts/build-runtime-modules.mjs +2 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/test-packed-runner.mjs +22 -0
- package/scripts/verify-package-files.mjs +18 -13
- package/skills/_shared/review-ledger-contract.md +9 -1
- package/skills/issue-creation/SKILL.md +53 -93
- package/tests/agents-config.test.ts +143 -0
- package/tests/agents-fake-child.ts +52 -0
- package/tests/agents-history.test.ts +54 -0
- package/tests/agents-protocol.test.ts +153 -0
- package/tests/agents-runner-process.test.ts +111 -0
- package/tests/agents-runner.test.ts +402 -0
- package/tests/agents-transcript.test.ts +30 -0
- package/tests/agents-view.test.ts +274 -0
- package/tests/agents-widget.test.ts +111 -0
- package/tests/ask-user-choice.test.ts +157 -3
- package/tests/codegraph-tools.test.ts +110 -1
- package/tests/devbinary/native-review-parity.devtest.ts +108 -0
- package/tests/fixtures/agents-process-child.mjs +23 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
- package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
- package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
- package/tests/gentle-agents.test.ts +741 -0
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +65 -0
- package/tests/gentle-ai.test.ts +31 -14
- package/tests/gentle-card-text.ts +35 -0
- package/tests/gentle-shell.test.ts +527 -0
- package/tests/gentle-todo.test.ts +182 -0
- package/tests/issue-creation-skill.test.ts +103 -0
- package/tests/native-choice-list.test.ts +202 -0
- package/tests/native-fullscreen-interaction.test.ts +125 -0
- package/tests/native-pointer-region.test.ts +245 -0
- package/tests/native-review-capability-contract.test.ts +33 -1
- package/tests/native-review-cli.test.ts +40 -0
- package/tests/native-review-consent.test.ts +91 -0
- package/tests/native-review-parity-runtime.test.ts +8 -2
- package/tests/native-review-parity.test.ts +29 -22
- package/tests/orchestrator-budget.test.ts +71 -2
- package/tests/orchestrator-rdd-ownership.test.ts +10 -1
- package/tests/package-manifest.test.ts +134 -9
- package/tests/provider-contract-bundle.test.ts +76 -0
- package/tests/provider-contract-mirror.test.ts +19 -0
- package/tests/quiet-tool-rendering.test.ts +96 -37
- package/tests/rdd-aware-verification-contract.test.ts +216 -0
- package/tests/rdd-status-line.test.ts +286 -0
- package/tests/review-agent-end-preflight.test.ts +408 -0
- package/tests/review-candidate-view.test.ts +452 -6
- package/tests/review-contract-prompt.test.ts +142 -0
- package/tests/review-controller-native-recovery.test.ts +29 -4
- package/tests/review-controller-native-routing.test.ts +321 -4
- package/tests/review-controller-workspace-root.test.ts +45 -2
- package/tests/review-controller.test.ts +26 -1
- package/tests/review-host-relay-routing.test.ts +229 -11
- package/tests/review-host-relay.test.ts +195 -7
- package/tests/review-integration-v2-forward.test.ts +47 -0
- package/tests/review-integration-v2.test.ts +112 -0
- package/tests/review-last-event-closure.test.ts +7 -2
- package/tests/review-ledger-contract.test.ts +1 -1
- package/tests/review-relay-contract.test.ts +26 -0
- package/tests/review-repository.test.ts +28 -1
- package/tests/review-risk-assessment.test.ts +626 -0
- package/tests/review-session-standing-permission-controller.test.ts +608 -0
- package/tests/review-session-standing-permission-ipc.test.ts +233 -0
- package/tests/review-session-standing-permission-runtime.test.ts +212 -0
- package/tests/review-session-standing-permission.test.ts +126 -0
- package/tests/runtime-harness.mjs +1 -0
- package/tests/shell-bar.test.ts +176 -0
- package/tests/shell-card.test.ts +118 -0
- package/tests/shell-changes-view.test.ts +146 -0
- package/tests/shell-changes.test.ts +182 -0
- package/tests/shell-prompt.test.ts +118 -0
- package/tests/shell-todo.test.ts +170 -0
- package/tests/shell-usage-view.test.ts +62 -0
- package/tests/shell-usage.test.ts +197 -0
- package/tests/telemetry-trigger.test.ts +349 -0
- package/tests/writer-edit-surface-scope.test.ts +153 -17
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
- /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
|
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
|
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import { syncBuiltinESMExports } from "node:module";
|
|
6
|
-
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import test from "node:test";
|
|
@@ -11,6 +11,7 @@ import { gzipSync } from "node:zlib";
|
|
|
11
11
|
import {
|
|
12
12
|
CandidateViewRegistry,
|
|
13
13
|
CandidateViewError,
|
|
14
|
+
hasExpectedExecutableBits,
|
|
14
15
|
type CandidateGitExecutor,
|
|
15
16
|
createCandidateView,
|
|
16
17
|
decodeCandidateContextManifest,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
digestChangedPathManifest,
|
|
19
20
|
injectReviewCandidateView,
|
|
20
21
|
readCandidateContextManifestPage,
|
|
22
|
+
type NativeCandidateProjectionDescriptor,
|
|
21
23
|
} from "../lib/review-candidate-view.ts";
|
|
22
24
|
|
|
23
25
|
function git(cwd: string, ...arguments_: string[]): string {
|
|
@@ -26,7 +28,10 @@ function git(cwd: string, ...arguments_: string[]): string {
|
|
|
26
28
|
|
|
27
29
|
function repository(t: test.TestContext): string {
|
|
28
30
|
const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-candidate-view-"));
|
|
29
|
-
t.after(() =>
|
|
31
|
+
t.after(() => {
|
|
32
|
+
execFileSync("chmod", ["-R", "u+rwx", cwd]);
|
|
33
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
34
|
+
});
|
|
30
35
|
git(cwd, "init", "-b", "main");
|
|
31
36
|
writeFileSync(join(cwd, "tracked.txt"), "base\n");
|
|
32
37
|
git(cwd, "add", "tracked.txt");
|
|
@@ -34,6 +39,233 @@ function repository(t: test.TestContext): string {
|
|
|
34
39
|
return cwd;
|
|
35
40
|
}
|
|
36
41
|
|
|
42
|
+
function ownerMarker(root: string): string { return `${root}.owner.json`; }
|
|
43
|
+
|
|
44
|
+
function orphanFixture(t: test.TestContext) {
|
|
45
|
+
const cwd = repository(t);
|
|
46
|
+
const registry = new CandidateViewRegistry();
|
|
47
|
+
const view = registry.create({ contributorRoot: cwd });
|
|
48
|
+
const marker = ownerMarker(view.root);
|
|
49
|
+
const owner = JSON.parse(readFileSync(marker, "utf8"));
|
|
50
|
+
owner.pid = 2147483000;
|
|
51
|
+
writeFileSync(marker, JSON.stringify(owner));
|
|
52
|
+
return { cwd, registry, view, marker, owner };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function mockOwnerProbe(t: test.TestContext, code?: string): void {
|
|
56
|
+
t.mock.method(process, "kill", () => {
|
|
57
|
+
if (code) throw Object.assign(new Error("fixture probe"), { code });
|
|
58
|
+
return true;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test("candidate ownership is private and durable before worktree add; ordinary cleanup removes its marker", (t) => {
|
|
63
|
+
const cwd = repository(t);
|
|
64
|
+
let observed = false;
|
|
65
|
+
let syncs = 0;
|
|
66
|
+
const fsync = fs.fsyncSync;
|
|
67
|
+
t.mock.method(fs, "fsyncSync", (fd: number) => { syncs++; fsync(fd); });
|
|
68
|
+
syncBuiltinESMExports();
|
|
69
|
+
t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
|
|
70
|
+
const registry = new CandidateViewRegistry((file, args, options) => {
|
|
71
|
+
if (args[0] === "worktree" && args[1] === "add") {
|
|
72
|
+
const root = args[args.length - 2]!;
|
|
73
|
+
const marker = ownerMarker(root);
|
|
74
|
+
const owner = JSON.parse(readFileSync(marker, "utf8"));
|
|
75
|
+
assert.equal(owner.root, root);
|
|
76
|
+
assert.equal(owner.pid, process.pid);
|
|
77
|
+
assert.match(owner.token, /^[0-9a-f-]{36}$/);
|
|
78
|
+
assert.equal(lstatSync(marker).mode & 0o777, 0o600);
|
|
79
|
+
assert.equal(existsSync(root), false);
|
|
80
|
+
assert.ok(syncs >= 4, "marker and every newly created ancestor must be durable before add");
|
|
81
|
+
observed = true;
|
|
82
|
+
}
|
|
83
|
+
return execFileSync(file, args, options);
|
|
84
|
+
});
|
|
85
|
+
const view = registry.create({ contributorRoot: cwd });
|
|
86
|
+
assert.equal(observed, true);
|
|
87
|
+
view.cleanup();
|
|
88
|
+
assert.equal(existsSync(view.root), false);
|
|
89
|
+
assert.equal(existsSync(ownerMarker(view.root)), false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("marked registered definitively dead candidate is reclaimed before another materialization", (t) => {
|
|
93
|
+
const { cwd, view, marker } = orphanFixture(t);
|
|
94
|
+
mockOwnerProbe(t, "ESRCH");
|
|
95
|
+
const next = new CandidateViewRegistry().create({ contributorRoot: cwd });
|
|
96
|
+
assert.equal(existsSync(view.root), false);
|
|
97
|
+
assert.equal(existsSync(marker), false);
|
|
98
|
+
assert.ok(!git(cwd, "worktree", "list", "--porcelain").includes(view.root));
|
|
99
|
+
next.cleanup();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
for (const scenario of ["self", "live", "EPERM", "unknown", "malformed", "foreign-host", "unknown-host", "invalid-pid", "extra-key", "legacy", "unregistered", "root-symlink", "marker-symlink", "parent-symlink", "escape", "unknown-lock", "public-marker", "locked", "backlink", "admin-symlink", "missing-root"] as const) {
|
|
103
|
+
test(`orphan sweep preserves ${scenario} ownership`, (t) => {
|
|
104
|
+
const { cwd, view, marker, owner } = orphanFixture(t);
|
|
105
|
+
mockOwnerProbe(t, scenario === "live" ? undefined : scenario === "EPERM" ? "EPERM" : scenario === "unknown" ? "EIO" : "ESRCH");
|
|
106
|
+
if (scenario === "self") owner.pid = process.pid;
|
|
107
|
+
if (scenario === "foreign-host") owner.host = "another-host";
|
|
108
|
+
if (scenario === "unknown-host") owner.host = null;
|
|
109
|
+
if (scenario === "invalid-pid") owner.pid = -1;
|
|
110
|
+
if (scenario === "extra-key") owner.extra = true;
|
|
111
|
+
if (scenario === "escape") owner.root = cwd;
|
|
112
|
+
writeFileSync(marker, scenario === "malformed" ? "{" : JSON.stringify(owner));
|
|
113
|
+
if (scenario === "legacy") rmSync(marker);
|
|
114
|
+
if (scenario === "public-marker") chmodSync(marker, 0o644);
|
|
115
|
+
if (scenario === "locked") git(cwd, "worktree", "lock", view.root);
|
|
116
|
+
if (scenario === "backlink" || scenario === "admin-symlink") {
|
|
117
|
+
const admin = readFileSync(join(view.root, ".git"), "utf8").slice(8).trim();
|
|
118
|
+
if (scenario === "backlink") writeFileSync(join(admin, "gitdir"), `${cwd}/.git\n`);
|
|
119
|
+
else { renameSync(admin, `${admin}.saved`); symlinkSync(`${admin}.saved`, admin); }
|
|
120
|
+
}
|
|
121
|
+
if (scenario === "missing-root") renameSync(view.root, `${view.root}.saved`);
|
|
122
|
+
if (scenario === "unknown-lock") writeFileSync(`${view.root}.reaper-lock`, "unknown");
|
|
123
|
+
if (scenario === "unregistered") {
|
|
124
|
+
execFileSync("chmod", ["-R", "u+rwx", view.root]);
|
|
125
|
+
git(cwd, "worktree", "remove", "--force", view.root);
|
|
126
|
+
mkdirSync(view.root);
|
|
127
|
+
writeFileSync(join(view.root, "preserve.txt"), "unregistered");
|
|
128
|
+
}
|
|
129
|
+
if (scenario === "root-symlink") { renameSync(view.root, `${view.root}.saved`); symlinkSync(`${view.root}.saved`, view.root); }
|
|
130
|
+
if (scenario === "marker-symlink") { renameSync(marker, `${marker}.saved`); symlinkSync(`${marker}.saved`, marker); }
|
|
131
|
+
if (scenario === "parent-symlink") {
|
|
132
|
+
const parent = join(view.root, "..");
|
|
133
|
+
renameSync(parent, `${parent}.saved`);
|
|
134
|
+
symlinkSync(`${parent}.saved`, parent);
|
|
135
|
+
}
|
|
136
|
+
new CandidateViewRegistry().sweepOrphans(cwd);
|
|
137
|
+
assert.equal(existsSync(view.root), scenario !== "missing-root");
|
|
138
|
+
assert.equal(existsSync(marker), scenario !== "legacy");
|
|
139
|
+
assert.equal(readFileSync(join(cwd, "tracked.txt"), "utf8"), "base\n");
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
test("reaper lock excludes concurrent sweeps and rechecks marker identity before removal", (t) => {
|
|
144
|
+
const { cwd, view, marker, owner } = orphanFixture(t);
|
|
145
|
+
mockOwnerProbe(t, "ESRCH");
|
|
146
|
+
let lists = 0;
|
|
147
|
+
const racing = new CandidateViewRegistry((file, args, options) => {
|
|
148
|
+
if (args.includes("list")) {
|
|
149
|
+
lists++;
|
|
150
|
+
new CandidateViewRegistry().sweepOrphans(cwd);
|
|
151
|
+
if (lists === 2) writeFileSync(marker, JSON.stringify({ ...owner, token: "00000000-0000-4000-8000-000000000000" }));
|
|
152
|
+
}
|
|
153
|
+
return execFileSync(file, args, options);
|
|
154
|
+
});
|
|
155
|
+
racing.sweepOrphans(cwd);
|
|
156
|
+
assert.ok(lists >= 2);
|
|
157
|
+
assert.equal(existsSync(view.root), true);
|
|
158
|
+
assert.equal(existsSync(`${view.root}.reaper-lock`), false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("failed Git removal preserves owned candidate and marker without recursive fallback", (t) => {
|
|
162
|
+
const cwd = repository(t);
|
|
163
|
+
const registry = new CandidateViewRegistry((file, args, options) => {
|
|
164
|
+
if (args.includes("remove")) throw new Error("fixture removal failure");
|
|
165
|
+
return execFileSync(file, args, options);
|
|
166
|
+
});
|
|
167
|
+
const view = registry.create({ contributorRoot: cwd });
|
|
168
|
+
assert.throws(() => view.cleanup());
|
|
169
|
+
assert.equal(existsSync(view.root), true);
|
|
170
|
+
assert.equal(existsSync(ownerMarker(view.root)), true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("failed creation retains a recoverable marker when Git removal also fails", (t) => {
|
|
174
|
+
const cwd = repository(t);
|
|
175
|
+
let root = "";
|
|
176
|
+
const registry = new CandidateViewRegistry((file, args, options) => {
|
|
177
|
+
if (args[0] === "worktree" && args[1] === "add") root = args[args.length - 2]!;
|
|
178
|
+
if (args.includes("remove") || (args[0] === "read-tree" && options.cwd === root)) throw new Error("fixture failure");
|
|
179
|
+
return execFileSync(file, args, options);
|
|
180
|
+
});
|
|
181
|
+
assert.throws(() => registry.create({ contributorRoot: cwd }));
|
|
182
|
+
assert.equal(existsSync(root), true);
|
|
183
|
+
const owner = JSON.parse(readFileSync(ownerMarker(root), "utf8"));
|
|
184
|
+
writeFileSync(ownerMarker(root), JSON.stringify({ ...owner, pid: 2147483000 }));
|
|
185
|
+
mockOwnerProbe(t, "ESRCH");
|
|
186
|
+
new CandidateViewRegistry().sweepOrphans(cwd);
|
|
187
|
+
assert.equal(existsSync(root), false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("materialization rejects a symlinked owner ancestor without creating an external store", (t) => {
|
|
191
|
+
const cwd = repository(t);
|
|
192
|
+
const outside = join(cwd, "outside");
|
|
193
|
+
mkdirSync(outside);
|
|
194
|
+
symlinkSync(outside, join(cwd, ".git", "gentle-ai"));
|
|
195
|
+
assert.throws(() => new CandidateViewRegistry().create({ contributorRoot: cwd }));
|
|
196
|
+
assert.equal(existsSync(join(outside, "candidate-views")), false);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("owner fsync failure prevents worktree registration", (t) => {
|
|
200
|
+
const cwd = repository(t);
|
|
201
|
+
let adds = 0;
|
|
202
|
+
t.mock.method(fs, "fsyncSync", () => { throw new Error("fixture fsync failure"); });
|
|
203
|
+
syncBuiltinESMExports();
|
|
204
|
+
t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
|
|
205
|
+
const registry = new CandidateViewRegistry((file, args, options) => {
|
|
206
|
+
if (args[0] === "worktree" && args[1] === "add") adds++;
|
|
207
|
+
return execFileSync(file, args, options);
|
|
208
|
+
});
|
|
209
|
+
assert.throws(() => registry.create({ contributorRoot: cwd }), /fsync/);
|
|
210
|
+
assert.equal(adds, 0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("ordinary cleanup preserves mismatched owner and cleanupAll continues other records", (t) => {
|
|
214
|
+
const cwd = repository(t);
|
|
215
|
+
const registry = new CandidateViewRegistry();
|
|
216
|
+
const first = registry.create({ contributorRoot: cwd });
|
|
217
|
+
const second = registry.create({ contributorRoot: cwd });
|
|
218
|
+
const marker = ownerMarker(first.root);
|
|
219
|
+
const bytes = readFileSync(marker, "utf8");
|
|
220
|
+
writeFileSync(marker, JSON.stringify({ ...JSON.parse(bytes), token: "00000000-0000-4000-8000-000000000000" }));
|
|
221
|
+
registry.cleanupAll();
|
|
222
|
+
assert.equal(existsSync(first.root), true);
|
|
223
|
+
assert.equal(existsSync(marker), true);
|
|
224
|
+
assert.equal(existsSync(second.root), false);
|
|
225
|
+
writeFileSync(marker, bytes);
|
|
226
|
+
registry.cleanupAll();
|
|
227
|
+
assert.equal(existsSync(first.root), false);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
for (const race of ["root", "registration", "lock"] as const) {
|
|
231
|
+
test(`orphan reaper preserves a ${race} replacement at the Git race boundary`, (t) => {
|
|
232
|
+
const { cwd, view, marker } = orphanFixture(t);
|
|
233
|
+
mockOwnerProbe(t, "ESRCH");
|
|
234
|
+
let lists = 0;
|
|
235
|
+
const registry = new CandidateViewRegistry((file, args, options) => {
|
|
236
|
+
const result = execFileSync(file, args, options);
|
|
237
|
+
if (args.includes("list") && ++lists === 2) {
|
|
238
|
+
if (race === "root") { renameSync(view.root, `${view.root}.saved`); mkdirSync(view.root); }
|
|
239
|
+
if (race === "registration") return String(result).replace(`worktree ${view.root}`, `worktree ${view.root}.other`);
|
|
240
|
+
if (race === "lock") writeFileSync(`${view.root}.reaper-lock`, "replacement");
|
|
241
|
+
}
|
|
242
|
+
return result;
|
|
243
|
+
});
|
|
244
|
+
registry.sweepOrphans(cwd);
|
|
245
|
+
assert.equal(existsSync(view.root), true);
|
|
246
|
+
assert.equal(existsSync(marker), true);
|
|
247
|
+
if (race === "lock") assert.equal(readFileSync(`${view.root}.reaper-lock`, "utf8"), "replacement");
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
test("ordinary cleanup never unlinks a replaced sidecar after Git removal", (t) => {
|
|
252
|
+
const cwd = repository(t);
|
|
253
|
+
let marker = "";
|
|
254
|
+
const registry = new CandidateViewRegistry((file, args, options) => {
|
|
255
|
+
const result = execFileSync(file, args, options);
|
|
256
|
+
if (args[0] === "worktree" && args[1] === "remove") {
|
|
257
|
+
const bytes = readFileSync(marker);
|
|
258
|
+
renameSync(marker, `${marker}.saved`);
|
|
259
|
+
writeFileSync(marker, bytes, { mode: 0o600 });
|
|
260
|
+
}
|
|
261
|
+
return result;
|
|
262
|
+
});
|
|
263
|
+
const view = registry.create({ contributorRoot: cwd });
|
|
264
|
+
marker = ownerMarker(view.root);
|
|
265
|
+
assert.throws(() => view.cleanup(), /ownership changed/i);
|
|
266
|
+
assert.equal(existsSync(marker), true);
|
|
267
|
+
});
|
|
268
|
+
|
|
37
269
|
function compactCandidateContextManifest(task: string): { encoded: string; sha256: string } {
|
|
38
270
|
const match = /Frozen changed scope manifest \(gzip\+base64url\): `([A-Za-z0-9_-]+)`\.\nFrozen changed scope manifest SHA-256: `([0-9a-f]{64})`\./.exec(task);
|
|
39
271
|
assert.ok(match, "expected a compact candidate context manifest");
|
|
@@ -379,6 +611,50 @@ test("candidate view preserves staged additions with explicit intended-untracked
|
|
|
379
611
|
}
|
|
380
612
|
});
|
|
381
613
|
|
|
614
|
+
test("candidate view detects a same-stat tracked rewrite with selected untracked content when trustctime is disabled", (t) => {
|
|
615
|
+
const contributorRoot = repository(t);
|
|
616
|
+
const tracked = join(contributorRoot, "tracked.txt");
|
|
617
|
+
const index = join(contributorRoot, ".git", "index");
|
|
618
|
+
// A fractional timestamp exposes Date-based timestamp restoration, which can
|
|
619
|
+
// collapse the private index onto the live index's racy-clean boundary.
|
|
620
|
+
const fixedTimestampSeconds = 946_684_800.654_321;
|
|
621
|
+
git(contributorRoot, "config", "core.trustctime", "false");
|
|
622
|
+
writeFileSync(tracked, "fixed\n");
|
|
623
|
+
utimesSync(tracked, fixedTimestampSeconds, fixedTimestampSeconds);
|
|
624
|
+
git(contributorRoot, "add", "tracked.txt");
|
|
625
|
+
git(contributorRoot, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "fixed timestamp base");
|
|
626
|
+
writeFileSync(tracked, "other\n");
|
|
627
|
+
utimesSync(tracked, fixedTimestampSeconds, fixedTimestampSeconds);
|
|
628
|
+
utimesSync(index, fixedTimestampSeconds, fixedTimestampSeconds);
|
|
629
|
+
const liveIndexMtimeNs = lstatSync(index, { bigint: true }).mtimeNs;
|
|
630
|
+
if (liveIndexMtimeNs % 1_000_000n === 0n) t.diagnostic("filesystem does not retain sub-millisecond index mtimes; retaining the same-stat rewrite assertion");
|
|
631
|
+
else assert.notEqual(liveIndexMtimeNs % 1_000_000_000n, 0n, "sub-millisecond filesystem must retain the requested non-whole-second index mtime");
|
|
632
|
+
writeFileSync(join(contributorRoot, "selected.txt"), "selected\n");
|
|
633
|
+
|
|
634
|
+
let privateIndexMtimeNs: bigint | undefined;
|
|
635
|
+
const registry = new CandidateViewRegistry((file, arguments_, options) => {
|
|
636
|
+
if (arguments_[0] === "add" && privateIndexMtimeNs === undefined) {
|
|
637
|
+
const privateIndexPath = options.env?.GIT_INDEX_FILE;
|
|
638
|
+
assert.equal(typeof privateIndexPath, "string", "private candidate index must be set before Git refreshes tracked entries");
|
|
639
|
+
privateIndexMtimeNs = lstatSync(privateIndexPath, { bigint: true }).mtimeNs;
|
|
640
|
+
}
|
|
641
|
+
return execFileSync(file, arguments_, options);
|
|
642
|
+
});
|
|
643
|
+
const view = registry.create({ contributorRoot, intendedUntracked: ["selected.txt"] });
|
|
644
|
+
try {
|
|
645
|
+
assert.notEqual(privateIndexMtimeNs, undefined, "private index timestamp must be observed before Git add");
|
|
646
|
+
assert.ok(privateIndexMtimeNs! < liveIndexMtimeNs, "private index must be strictly earlier than the live index");
|
|
647
|
+
assert.ok(liveIndexMtimeNs - privateIndexMtimeNs! >= 1_000_000_000n, "private index must leave a bounded racy-clean safety interval");
|
|
648
|
+
assert.deepEqual(view.intendedUntracked, ["selected.txt"]);
|
|
649
|
+
assert.deepEqual(view.paths, ["selected.txt", "tracked.txt"]);
|
|
650
|
+
assert.notEqual(view.candidateTree, view.baseTree);
|
|
651
|
+
assert.equal(readFileSync(join(view.root, "tracked.txt"), "utf8"), "other\n");
|
|
652
|
+
assert.equal(readFileSync(join(view.root, "selected.txt"), "utf8"), "selected\n");
|
|
653
|
+
} finally {
|
|
654
|
+
view.cleanup();
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
|
|
382
658
|
test("candidate view skips a shared index that disappears during stat or copy", (t) => {
|
|
383
659
|
const contributorRoot = repository(t);
|
|
384
660
|
writeFileSync(join(contributorRoot, "tracked.txt"), "tracked selection\n");
|
|
@@ -490,6 +766,76 @@ test("candidate registry isolates replay, projection, current, and cleanup state
|
|
|
490
766
|
registry.cleanupTerminal("same-lineage", "approved", rootA);
|
|
491
767
|
});
|
|
492
768
|
|
|
769
|
+
// ga#4085 / ga#4050: after a valid `granted` consent, a retried native START
|
|
770
|
+
// for a lineage this controller already bound (native reports it "resumed")
|
|
771
|
+
// re-materializes a fresh, content-identical candidate view under a new
|
|
772
|
+
// token -- for example after a transport hiccup between START and the
|
|
773
|
+
// follow-up STATUS call. Rebinding that duplicate to the same lineage used to
|
|
774
|
+
// fail closed with "candidate view lineage binding is missing or ambiguous"
|
|
775
|
+
// even though nothing was actually ambiguous. It must reuse the already-bound
|
|
776
|
+
// view instead of failing the retry.
|
|
777
|
+
test("candidate registry rebinds a retried native START for an already-bound lineage instead of failing closed", (t) => {
|
|
778
|
+
const contributorRoot = repository(t);
|
|
779
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), "candidate\n");
|
|
780
|
+
const registry = new CandidateViewRegistry();
|
|
781
|
+
t.after(() => registry.cleanupAll());
|
|
782
|
+
const first = registry.create({ contributorRoot });
|
|
783
|
+
registry.bindCurrent({ token: first.token, lineageId: "retry-lineage", selectedLenses: ["review-risk"] });
|
|
784
|
+
assert.equal(registry.hasCurrentBinding(contributorRoot), true);
|
|
785
|
+
|
|
786
|
+
const retry = registry.create({ contributorRoot });
|
|
787
|
+
assert.notEqual(retry.token, first.token, "the retry must materialize a distinct physical worktree/token");
|
|
788
|
+
assert.doesNotThrow(() => registry.bindCurrent({ token: retry.token, lineageId: "retry-lineage", selectedLenses: ["review-risk"] }));
|
|
789
|
+
|
|
790
|
+
// The already-bound view stays current; the redundant duplicate is discarded.
|
|
791
|
+
assert.equal(registry.resolveForLens("retry-lineage", "review-risk", contributorRoot).root, first.root);
|
|
792
|
+
assert.equal(registry.resolveCurrentForLens("review-risk", contributorRoot).root, first.root);
|
|
793
|
+
assert.equal(existsSync(retry.root), false, "the redundant duplicate candidate view must be cleaned up");
|
|
794
|
+
registry.cleanupTerminal("retry-lineage", "approved", contributorRoot);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test("candidate registry still fails closed rebinding the same lineage to genuinely different content", (t) => {
|
|
798
|
+
const contributorRoot = repository(t);
|
|
799
|
+
const registry = new CandidateViewRegistry();
|
|
800
|
+
t.after(() => registry.cleanupAll());
|
|
801
|
+
const first = registry.create({ contributorRoot });
|
|
802
|
+
registry.bindCurrent({ token: first.token, lineageId: "conflict-lineage", selectedLenses: ["review-risk"] });
|
|
803
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), "different content\n");
|
|
804
|
+
const conflicting = registry.create({ contributorRoot });
|
|
805
|
+
assert.notEqual(conflicting.candidateTree, first.candidateTree);
|
|
806
|
+
assert.throws(
|
|
807
|
+
() => registry.bindCurrent({ token: conflicting.token, lineageId: "conflict-lineage", selectedLenses: ["review-risk"] }),
|
|
808
|
+
CandidateViewError,
|
|
809
|
+
);
|
|
810
|
+
registry.cleanup(conflicting.token);
|
|
811
|
+
registry.cleanupTerminal("conflict-lineage", "approved", contributorRoot);
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
// gentle-pi#323: `createOrReuse` reuses whatever view a replay key maps to,
|
|
815
|
+
// with no awareness of live candidate content -- a content-independent key
|
|
816
|
+
// reuses a stale view even after the candidate content it was frozen from
|
|
817
|
+
// has changed. The fix lives at the START call site (extensions/gentle-ai.ts),
|
|
818
|
+
// which now folds the current candidate tree into the replay key; this test
|
|
819
|
+
// documents both the registry's plain-key reuse contract and that folding
|
|
820
|
+
// content identity into the key produces a fresh view once content changes.
|
|
821
|
+
test("candidate registry reuses a replay-keyed view verbatim regardless of live content, so callers must fold content identity into the key", (t) => {
|
|
822
|
+
const registry = new CandidateViewRegistry();
|
|
823
|
+
t.after(() => registry.cleanupAll());
|
|
824
|
+
const contributorRoot = repository(t);
|
|
825
|
+
const contentIndependentKey = "cwd-and-lineage-only";
|
|
826
|
+
const first = registry.createOrReuse({ contributorRoot, replayKey: contentIndependentKey });
|
|
827
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), "candidate two\n");
|
|
828
|
+
const staleReuse = registry.createOrReuse({ contributorRoot, replayKey: contentIndependentKey });
|
|
829
|
+
assert.equal(staleReuse.token, first.token, "a content-independent replay key reuses the stale view");
|
|
830
|
+
const freshForCurrentContent = registry.create({ contributorRoot });
|
|
831
|
+
assert.notEqual(staleReuse.candidateTree, freshForCurrentContent.candidateTree, "the stale reused view no longer reflects live candidate content");
|
|
832
|
+
const contentScopedKey = `${contentIndependentKey}${freshForCurrentContent.candidateTree}`;
|
|
833
|
+
const rekeyed = registry.createOrReuse({ contributorRoot, replayKey: contentScopedKey });
|
|
834
|
+
assert.notEqual(rekeyed.token, staleReuse.token, "folding candidate content into the replay key mints a fresh view once content changes");
|
|
835
|
+
assert.equal(rekeyed.candidateTree, freshForCurrentContent.candidateTree);
|
|
836
|
+
registry.cleanup(freshForCurrentContent.token);
|
|
837
|
+
});
|
|
838
|
+
|
|
493
839
|
test("candidate registry rejects a lineage target whose authorized symlink was replaced", (t) => {
|
|
494
840
|
const root = repository(t);
|
|
495
841
|
const replacement = repository(t);
|
|
@@ -546,14 +892,15 @@ test("candidate view cleanup is confined and idempotent", (t) => {
|
|
|
546
892
|
assert.equal(lstatSync(view.root, { throwIfNoEntry: false }), undefined);
|
|
547
893
|
});
|
|
548
894
|
|
|
549
|
-
test("candidate cleanup
|
|
895
|
+
test("candidate cleanup preserves a root when Git reports success without deleting it", (t) => {
|
|
550
896
|
const registry = new CandidateViewRegistry((file, arguments_, options) =>
|
|
551
897
|
arguments_[0] === "worktree" && arguments_[1] === "remove" ? "" : execFileSync(file, arguments_, options));
|
|
552
898
|
const view = registry.create({ contributorRoot: repository(t) });
|
|
553
899
|
assert.equal(lstatSync(view.root).mode & 0o222, 0);
|
|
554
|
-
view.cleanup();
|
|
555
|
-
view.cleanup();
|
|
556
|
-
assert.equal(
|
|
900
|
+
assert.throws(() => view.cleanup(), /incomplete/);
|
|
901
|
+
assert.throws(() => view.cleanup(), /incomplete/);
|
|
902
|
+
assert.equal(existsSync(view.root), true);
|
|
903
|
+
assert.equal(existsSync(ownerMarker(view.root)), true);
|
|
557
904
|
});
|
|
558
905
|
|
|
559
906
|
test("corrected views stay within frozen scope and replace projections only when promoted", (t) => {
|
|
@@ -711,6 +1058,32 @@ test("candidate view verifies unchanged tree entries even when they are absent f
|
|
|
711
1058
|
}
|
|
712
1059
|
});
|
|
713
1060
|
|
|
1061
|
+
test("candidate executable-mode validation accepts a readonly Git executable on Windows and rejects it on POSIX", () => {
|
|
1062
|
+
assert.equal(hasExpectedExecutableBits(0o444, "100755", "win32"), true);
|
|
1063
|
+
assert.equal(hasExpectedExecutableBits(0o444, "100755", "linux"), false);
|
|
1064
|
+
assert.equal(hasExpectedExecutableBits(0o555, "100755", "linux"), true);
|
|
1065
|
+
assert.equal(hasExpectedExecutableBits(0o555, "100644", "win32"), false);
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
test("candidate registry forwards explicit Windows mode validation to view verification", (t) => {
|
|
1069
|
+
if (process.platform === "win32") return t.skip("requires POSIX candidate-owner directory permissions unavailable on Windows");
|
|
1070
|
+
const contributorRoot = repository(t);
|
|
1071
|
+
writeFileSync(join(contributorRoot, "unchanged-executable.sh"), "#!/bin/sh\necho base\n");
|
|
1072
|
+
git(contributorRoot, "add", "unchanged-executable.sh");
|
|
1073
|
+
git(contributorRoot, "update-index", "--chmod=+x", "unchanged-executable.sh");
|
|
1074
|
+
chmodSync(join(contributorRoot, "unchanged-executable.sh"), 0o755);
|
|
1075
|
+
git(contributorRoot, "-c", "user.name=Candidate Test", "-c", "user.email=candidate@example.invalid", "commit", "-m", "executable base");
|
|
1076
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), "changed\n");
|
|
1077
|
+
const view = new CandidateViewRegistry(undefined, "win32").createOrReuse({ contributorRoot, baseRef: "HEAD" });
|
|
1078
|
+
try {
|
|
1079
|
+
assert.deepEqual(view.paths, ["tracked.txt"]);
|
|
1080
|
+
chmodSync(view.root, 0o755); chmodSync(join(view.root, "unchanged-executable.sh"), 0o444); chmodSync(view.root, 0o555);
|
|
1081
|
+
assert.doesNotThrow(() => view.verify());
|
|
1082
|
+
} finally {
|
|
1083
|
+
view.cleanup();
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
|
|
714
1087
|
test("candidate view compacts an oversized non-ASCII scope losslessly and deterministically", (t) => {
|
|
715
1088
|
const contributorRoot = repository(t);
|
|
716
1089
|
writeFileSync(join(contributorRoot, "deleted.txt"), "delete me\n");
|
|
@@ -1925,3 +2298,76 @@ test("candidate view cleans up a partially registered unborn fallback worktree w
|
|
|
1925
2298
|
const leftover = existsSync(parent) ? readdirSync(parent).filter((entry) => lstatSync(join(parent, entry)).isDirectory()) : [];
|
|
1926
2299
|
assert.deepEqual(leftover, [], "no candidate directory remains after a partial unborn fallback failure");
|
|
1927
2300
|
});
|
|
2301
|
+
|
|
2302
|
+
function stagedFinalizeDescriptor(cwd: string, path: string, content: string): NativeCandidateProjectionDescriptor {
|
|
2303
|
+
writeFileSync(join(cwd, path), content);
|
|
2304
|
+
git(cwd, "add", path);
|
|
2305
|
+
const baseTree = git(cwd, "rev-parse", "HEAD^{tree}");
|
|
2306
|
+
const currentCandidateTree = git(cwd, "write-tree");
|
|
2307
|
+
return { baseTree, currentCandidateTree, paths: [path], intendedUntracked: [], projection: "staged" };
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
function candidateViewWorktreeCount(cwd: string): number {
|
|
2311
|
+
return git(cwd, "worktree", "list").split("\n").filter((line) => line.includes("candidate-views")).length;
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
test("gentle-pi#185: a forecast-restored FINALIZE worktree does not leak across the next restore for the same lineage", (t) => {
|
|
2315
|
+
// gentle-pi#185: `restoreForFinalizeFromNative` materializes a real Git
|
|
2316
|
+
// worktree to restore a lineage's FINALIZE binding from the native frozen
|
|
2317
|
+
// projection. A forecast-only FINALIZE after a process restart uses this
|
|
2318
|
+
// exact path, but its outcome is non-terminal, so `cleanupTerminal` (which
|
|
2319
|
+
// only fires for "approved"/"escalated") never runs for it. Before the fix,
|
|
2320
|
+
// `restoreForFinalizeFromNative` never cleared the stale binding it had
|
|
2321
|
+
// just created, so restoring the same lineage's FINALIZE binding again
|
|
2322
|
+
// (the lineage's later approved FINALIZE, or another restart-time
|
|
2323
|
+
// forecast) threw "native frozen projection is invalid or already
|
|
2324
|
+
// restored" from `restoreProjectionFromNative` — leaving the first
|
|
2325
|
+
// worktree registered forever and breaking the very finalize that should
|
|
2326
|
+
// have succeeded.
|
|
2327
|
+
const cwd = repository(t);
|
|
2328
|
+
const registry = new CandidateViewRegistry();
|
|
2329
|
+
const lineageId = "forecast-lineage-185";
|
|
2330
|
+
const descriptor = stagedFinalizeDescriptor(cwd, "tracked.txt", "forecast change\n");
|
|
2331
|
+
|
|
2332
|
+
assert.equal(candidateViewWorktreeCount(cwd), 0, "no candidate worktree exists before any restore");
|
|
2333
|
+
|
|
2334
|
+
// Reproduces the leak symptom: a forecast-only FINALIZE restore
|
|
2335
|
+
// materializes a worktree that is still registered with Git once the
|
|
2336
|
+
// (non-terminal) forecast finalize returns.
|
|
2337
|
+
registry.restoreForFinalizeFromNative(lineageId, cwd, descriptor);
|
|
2338
|
+
assert.equal(candidateViewWorktreeCount(cwd), 1, "the forecast restore leaves its worktree registered with Git after it returns");
|
|
2339
|
+
|
|
2340
|
+
// A later restore for the same lineage (its approved FINALIZE, or another
|
|
2341
|
+
// restart-time forecast) must clean up the stale worktree and replace it
|
|
2342
|
+
// deterministically instead of throwing or leaking a parallel sibling.
|
|
2343
|
+
assert.doesNotThrow(
|
|
2344
|
+
() => registry.restoreForFinalizeFromNative(lineageId, cwd, descriptor),
|
|
2345
|
+
"restoring the same lineage's FINALIZE binding again must not fail on the stale binding it left behind",
|
|
2346
|
+
);
|
|
2347
|
+
assert.equal(candidateViewWorktreeCount(cwd), 1, "the second restore must replace the first worktree, never accumulate a parallel sibling");
|
|
2348
|
+
|
|
2349
|
+
// The subsequent approved FINALIZE's terminal cleanup must still work for
|
|
2350
|
+
// the worktree that is actually registered after the restore.
|
|
2351
|
+
registry.cleanupTerminal(lineageId, "approved", cwd);
|
|
2352
|
+
assert.equal(candidateViewWorktreeCount(cwd), 0, "approved cleanup removes the restored worktree, leaving no candidate worktree registered");
|
|
2353
|
+
});
|
|
2354
|
+
|
|
2355
|
+
test("gentle-pi#185 review correction: a restore whose materialization fails leaves the previous binding intact", (t) => {
|
|
2356
|
+
const cwd = repository(t);
|
|
2357
|
+
const lineageId = "forecast-lineage-185-rollback";
|
|
2358
|
+
const descriptor = stagedFinalizeDescriptor(cwd, "tracked.txt", "forecast change\n");
|
|
2359
|
+
let worktreeAdds = 0;
|
|
2360
|
+
const executor: CandidateGitExecutor = (file, args, options) => {
|
|
2361
|
+
if (args[0] === "worktree" && args[1] === "add" && ++worktreeAdds === 2) throw Object.assign(new Error("simulated worktree add failure"), { status: 128 });
|
|
2362
|
+
return execFileSync(file, args, options);
|
|
2363
|
+
};
|
|
2364
|
+
const registry = new CandidateViewRegistry(executor);
|
|
2365
|
+
const first = registry.restoreForFinalizeFromNative(lineageId, cwd, descriptor);
|
|
2366
|
+
assert.equal(candidateViewWorktreeCount(cwd), 1);
|
|
2367
|
+
let failure: unknown;
|
|
2368
|
+
try { registry.restoreForFinalizeFromNative(lineageId, cwd, descriptor); } catch (error) { failure = error; }
|
|
2369
|
+
assert.equal((failure as CandidateViewError)?.reason, "candidate-view-git-failure", "a failed materialization must propagate, not silently succeed");
|
|
2370
|
+
assert.equal(candidateViewWorktreeCount(cwd), 1, "the previously registered worktree must survive the failed restore");
|
|
2371
|
+
assert.equal(registry.resolveForFinalize(lineageId, cwd).token, first.token, "the lineage must still resolve to its original worktree");
|
|
2372
|
+
registry.cleanupTerminal(lineageId, "approved", cwd);
|
|
2373
|
+
});
|