gentle-pi 2.5.0 → 2.6.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 +139 -30
- package/assets/agents/gentle-ai-worker.md +4 -0
- package/assets/agents/jd-fix-agent.md +18 -0
- package/assets/agents/jd-judge-a.md +1 -1
- package/assets/agents/jd-judge-b.md +1 -1
- package/assets/agents/sdd-apply.md +7 -5
- package/assets/agents/sdd-archive.md +5 -3
- package/assets/agents/sdd-design.md +4 -0
- package/assets/agents/sdd-explore.md +4 -0
- package/assets/agents/sdd-init.md +4 -0
- package/assets/agents/sdd-onboard.md +4 -0
- package/assets/agents/sdd-proposal.md +4 -0
- package/assets/agents/sdd-remediate.md +37 -0
- package/assets/agents/sdd-research.md +26 -3
- package/assets/agents/sdd-spec.md +4 -0
- package/assets/agents/sdd-status.md +9 -75
- package/assets/agents/sdd-sync.md +4 -0
- package/assets/agents/sdd-tasks.md +4 -0
- package/assets/agents/sdd-verify.md +5 -3
- package/assets/chains/sdd-full.chain.md +4 -0
- package/assets/chains/sdd-plan.chain.md +4 -0
- package/assets/chains/sdd-verify.chain.md +4 -0
- package/assets/migrations/managed-assets-v2.5.0.json +7 -0
- package/assets/orchestrator-delegation.md +21 -3
- package/assets/sdd-orchestrator-workflow.md +54 -21
- package/assets/support/sdd-status-contract.md +34 -90
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
- package/docs/telemetry.md +57 -1
- package/docs/windows-startup-console-visibility.md +18 -0
- package/extensions/ask-user-choice.ts +143 -15
- package/extensions/codegraph-tools.ts +1 -0
- package/extensions/gentle-agents.ts +799 -50
- package/extensions/gentle-ai.ts +2033 -322
- package/extensions/gentle-shell.ts +145 -42
- package/extensions/gentle-todo.ts +47 -12
- package/extensions/quiet-tools.ts +1 -0
- package/extensions/runtime-metrics.ts +130 -0
- package/extensions/sdd-init.ts +2 -2
- package/extensions/startup-banner.ts +52 -75
- package/lib/agent-profiles.ts +550 -0
- package/lib/agents-completion-delivery.ts +72 -0
- package/lib/agents-config.ts +7 -10
- package/lib/agents-history.ts +9 -1
- package/lib/agents-messaging.ts +187 -0
- package/lib/agents-protocol.ts +77 -5
- package/lib/agents-runner.ts +548 -26
- package/lib/agents-thread-view.ts +57 -0
- package/lib/agents-view-layout.ts +40 -0
- package/lib/agents-view.ts +548 -191
- package/lib/agents-widget.ts +33 -14
- package/lib/gentle-ai-binary.ts +3 -1
- package/lib/gentle-ai-renderer.ts +8 -6
- package/lib/native-review-cli.ts +283 -1
- package/lib/orchestrator-presence.ts +337 -0
- package/lib/profiles-orchestrator.ts +203 -0
- package/lib/review-candidate-view-owner.ts +296 -46
- package/lib/review-candidate-view.ts +30 -20
- package/lib/review-consent-component.ts +247 -0
- package/lib/review-consent-ui.ts +53 -8
- package/lib/review-host-relay.ts +28 -0
- package/lib/review-integration-v2.ts +187 -5
- package/lib/review-last-event-controller.ts +7 -4
- package/lib/review-reminder-receipt.ts +74 -0
- package/lib/review-session-standing-permission.ts +27 -6
- package/lib/runtime-metrics-children.ts +199 -0
- package/lib/runtime-metrics-delivery.ts +68 -0
- package/lib/runtime-metrics-native.ts +166 -0
- package/lib/runtime-metrics-pi-identity.ts +113 -0
- package/lib/runtime-metrics-policy.ts +51 -0
- package/lib/runtime-metrics.ts +255 -0
- package/lib/sdd-preflight.ts +362 -81
- package/lib/sdd-research-capabilities.ts +228 -0
- package/lib/sdd-status.ts +29 -7
- package/lib/session-worktree-registry.ts +118 -0
- package/lib/shell-bar.ts +47 -1
- package/lib/shell-card.ts +1 -4
- package/lib/shell-changes-view.ts +362 -37
- package/lib/shell-changes.ts +81 -1
- package/lib/shell-prompt.ts +11 -15
- package/lib/shell-sidebar-banner.ts +11 -0
- package/lib/shell-sidebar-layout.ts +213 -0
- package/lib/shell-sidebar.ts +41 -0
- package/lib/shell-todo.ts +28 -11
- package/lib/telemetry-trigger.ts +2 -0
- package/package.json +6 -3
- package/runtime/gentle-ai-binary.mjs +3 -1
- package/runtime/native-review-cli.mjs +283 -1
- package/runtime/review-integration-v2.mjs +187 -5
- package/runtime/telemetry-trigger.mjs +2 -0
- package/scripts/build-runtime-modules.mjs +9 -1
- package/scripts/check-types.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +12 -0
- package/scripts/install-tui-mode-setting.mjs +114 -0
- package/scripts/test-packed-runner.mjs +16 -2
- package/scripts/types-baseline.json +99 -0
- package/scripts/verify-package-files.mjs +4 -2
- package/skills/_shared/review-ledger-contract.md +17 -1
- package/skills/issue-creation/SKILL.md +3 -3
- package/skills/judgment-day/SKILL.md +17 -3
- package/skills/judgment-day/references/prompts-and-formats.md +14 -3
- package/tests/agent-profiles.test.ts +722 -0
- package/tests/agents-completion-delivery.test.ts +94 -0
- package/tests/agents-config.test.ts +62 -0
- package/tests/agents-fake-child.ts +15 -1
- package/tests/agents-grouping.test.ts +179 -0
- package/tests/agents-integration.test.ts +100 -0
- package/tests/agents-messaging.test.ts +94 -0
- package/tests/agents-protocol.test.ts +45 -0
- package/tests/agents-queries.test.ts +190 -0
- package/tests/agents-responsive.test.ts +43 -0
- package/tests/agents-runner.test.ts +571 -14
- package/tests/agents-thread-view.test.ts +45 -0
- package/tests/agents-view.test.ts +476 -65
- package/tests/agents-widget.test.ts +31 -1
- package/tests/artifact-language.test.ts +25 -2
- package/tests/ask-user-choice.test.ts +169 -3
- package/tests/asset-installation-runtime.test.ts +108 -0
- package/tests/autonomous-guard.test.ts +116 -1
- package/tests/codegraph-tools.test.ts +2 -1
- package/tests/delegated-key-learnings-contract.test.ts +1 -1
- package/tests/devbinary/native-review-parity.devtest.ts +2 -0
- package/tests/feature-request-form.test.ts +67 -0
- package/tests/fixtures/agents-messaging-child.mjs +5 -0
- package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
- package/tests/gentle-agents.test.ts +1460 -33
- package/tests/gentle-ai-binary.test.ts +7 -2
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai-renderer.test.ts +38 -0
- package/tests/gentle-ai.test.ts +944 -4
- package/tests/gentle-shell.test.ts +303 -12
- package/tests/gentle-todo.test.ts +54 -10
- package/tests/install-tui-mode-setting.test.ts +324 -0
- package/tests/issue-creation-skill.test.ts +22 -0
- package/tests/model-routing-authority.test.ts +12 -0
- package/tests/native-review-capability-contract.test.ts +12 -1
- package/tests/native-review-cli.test.ts +277 -3
- package/tests/native-review-parity.test.ts +14 -7
- package/tests/native-sdd-attempt-authority.test.ts +7 -2
- package/tests/orchestrator-presence.test.ts +389 -0
- package/tests/package-manifest.test.ts +232 -7
- package/tests/profiles-orchestrator.test.ts +208 -0
- package/tests/quiet-tool-rendering.test.ts +1 -0
- package/tests/rdd-aware-verification-contract.test.ts +10 -0
- package/tests/review-agent-end-preflight.test.ts +332 -24
- package/tests/review-candidate-view.test.ts +304 -6
- package/tests/review-consent-ui.test.ts +352 -0
- package/tests/review-contract-prompt.test.ts +14 -0
- package/tests/review-controller-native-routing.test.ts +563 -3
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-restart-parity.test.ts +142 -1
- package/tests/review-host-relay-routing.test.ts +364 -4
- package/tests/review-host-relay.test.ts +29 -0
- package/tests/review-integration-v2-forward.test.ts +44 -0
- package/tests/review-integration-v2.test.ts +164 -0
- package/tests/review-last-event-closure.test.ts +105 -1
- package/tests/review-ledger-contract.test.ts +61 -6
- package/tests/review-reminder-receipt.test.ts +62 -0
- package/tests/review-session-standing-permission-controller.test.ts +52 -4
- package/tests/review-session-standing-permission.test.ts +30 -0
- package/tests/runtime-harness.mjs +447 -39
- package/tests/runtime-metrics-children.test.ts +206 -0
- package/tests/runtime-metrics-delivery.test.ts +85 -0
- package/tests/runtime-metrics-extension.test.ts +187 -0
- package/tests/runtime-metrics-native.test.ts +209 -0
- package/tests/runtime-metrics-pi-identity.test.ts +113 -0
- package/tests/runtime-metrics-policy.test.ts +62 -0
- package/tests/runtime-metrics.test.ts +184 -0
- package/tests/sdd-agent-tools.test.ts +10 -1
- package/tests/sdd-execution-routing-contract.test.ts +28 -0
- package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
- package/tests/sdd-native-managed-uptake.test.ts +253 -0
- package/tests/sdd-planning-routing-contract.test.ts +45 -0
- package/tests/sdd-preflight.test.ts +252 -8
- package/tests/sdd-research-capabilities.test.ts +256 -0
- package/tests/sdd-research-live.test.ts +241 -0
- package/tests/sdd-selection-transport.test.ts +504 -0
- package/tests/sdd-status.test.ts +51 -0
- package/tests/session-worktree-registry.test.ts +135 -0
- package/tests/shell-card.test.ts +24 -3
- package/tests/shell-changes-view.test.ts +471 -8
- package/tests/shell-changes.test.ts +168 -0
- package/tests/shell-prompt.test.ts +28 -6
- package/tests/shell-sidebar-banner.test.ts +23 -0
- package/tests/shell-sidebar-layout.test.ts +387 -0
- package/tests/shell-sidebar.test.ts +50 -0
- package/tests/shell-todo.test.ts +100 -11
- package/tests/startup-banner.test.ts +126 -0
- package/tests/telemetry-trigger.test.ts +3 -1
|
@@ -72,6 +72,21 @@ function relayCollectInput(lineageId, lens, order) {
|
|
|
72
72
|
{ name: "agent", value: "pi", token: "--agent=pi" },
|
|
73
73
|
{ name: "materialize", value: "true", token: "--materialize=true" },
|
|
74
74
|
],
|
|
75
|
+
// gentle-pi#638: the artifact subject is what makes the slot declarable —
|
|
76
|
+
// its subject_hash is the --request-hash the capture-unachievable verb
|
|
77
|
+
// binds to. A slot without one cannot be declared, only retried.
|
|
78
|
+
artifactSubject: {
|
|
79
|
+
schema: "gentle-ai.review-artifact-subject/v2",
|
|
80
|
+
subjectHash: `sha256:${String(order).repeat(64)}`,
|
|
81
|
+
lineageId,
|
|
82
|
+
authorityRevision: SHA,
|
|
83
|
+
targetIdentity: SHA,
|
|
84
|
+
baseTree: TREE,
|
|
85
|
+
candidateTree: TREE,
|
|
86
|
+
changedPathManifestSha256: SHA,
|
|
87
|
+
lens,
|
|
88
|
+
selectedOrder: order,
|
|
89
|
+
},
|
|
75
90
|
submission: providerSubmission(lineageId, lens, order),
|
|
76
91
|
};
|
|
77
92
|
}
|
|
@@ -146,6 +161,7 @@ const { ReviewHostRelayError, REVIEW_HOST_RELAY_FAILURE } = await import(${JSON.
|
|
|
146
161
|
const statusQueue = JSON.parse(await readFile(statusFile, "utf8"));
|
|
147
162
|
const relayRequests = [];
|
|
148
163
|
const statusCalls = [];
|
|
164
|
+
const unachievableCalls = [];
|
|
149
165
|
let captureCalls = 0;
|
|
150
166
|
|
|
151
167
|
__testing.setReviewHostRelayRunnerForTesting(async (request) => {
|
|
@@ -156,6 +172,9 @@ __testing.setReviewHostRelayRunnerForTesting(async (request) => {
|
|
|
156
172
|
if (mode === "capture-fail") {
|
|
157
173
|
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "pi subprocess failed", { exitCode: 4 });
|
|
158
174
|
}
|
|
175
|
+
if (mode === "capture-timeout") {
|
|
176
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { exitCode: null, timedOut: true, elapsedMs: 2256004, timeoutMs: 2256000 });
|
|
177
|
+
}
|
|
159
178
|
if (mode === "capture-ambiguous") {
|
|
160
179
|
throw Object.assign(new Error("capture response lost"), { mutationOutcome: "unknown", nextAction: "review.status" });
|
|
161
180
|
}
|
|
@@ -172,6 +191,27 @@ const nativeReviewCli = {
|
|
|
172
191
|
if (next === undefined) throw new Error("status queue exhausted");
|
|
173
192
|
return next;
|
|
174
193
|
},
|
|
194
|
+
captureUnachievableLens: async (request) => {
|
|
195
|
+
unachievableCalls.push({
|
|
196
|
+
cwd: request.cwd,
|
|
197
|
+
lineageId: request.lineageId,
|
|
198
|
+
targetIdentity: request.targetIdentity,
|
|
199
|
+
expectedRevision: request.expectedRevision,
|
|
200
|
+
requestHash: request.requestHash,
|
|
201
|
+
reason: request.reason,
|
|
202
|
+
...(request.detail === undefined ? {} : { detail: request.detail }),
|
|
203
|
+
...(request.repositoryContext === undefined ? {} : { repositoryContext: request.repositoryContext }),
|
|
204
|
+
});
|
|
205
|
+
return {
|
|
206
|
+
schema: "gentle-ai.review-capture-unachievable/v1",
|
|
207
|
+
lineageId: request.lineageId,
|
|
208
|
+
targetIdentity: request.targetIdentity,
|
|
209
|
+
lens: ${JSON.stringify(LENS)},
|
|
210
|
+
selectedOrder: ${JSON.stringify(ORDER)},
|
|
211
|
+
reason: request.reason,
|
|
212
|
+
recorded: true,
|
|
213
|
+
};
|
|
214
|
+
},
|
|
175
215
|
};
|
|
176
216
|
|
|
177
217
|
let result;
|
|
@@ -199,7 +239,7 @@ try {
|
|
|
199
239
|
error = caught instanceof Error ? { name: caught.name, message: caught.message } : String(caught);
|
|
200
240
|
}
|
|
201
241
|
|
|
202
|
-
await writeFile(outFile, JSON.stringify({ result, inspectResult, captureBinding, error, relayRequests, statusCalls, captureCalls }, null, 2));
|
|
242
|
+
await writeFile(outFile, JSON.stringify({ result, inspectResult, captureBinding, error, relayRequests, statusCalls, captureCalls, unachievableCalls }, null, 2));
|
|
203
243
|
`;
|
|
204
244
|
}
|
|
205
245
|
|
|
@@ -225,6 +265,107 @@ function inspectCollectBinding(inspectResult) {
|
|
|
225
265
|
const PENDING = collectStatus(LINEAGE, [relayCollectInput(LINEAGE, LENS, ORDER)]);
|
|
226
266
|
const CONVERGED = collectStatus(LINEAGE, []);
|
|
227
267
|
|
|
268
|
+
// gentle-pi#638: the typed stop the provider renders once the slot is declared
|
|
269
|
+
// unachievable — no collect inputs, one withdraw binding per declared slot.
|
|
270
|
+
// Built in the same plain-object style as collectStatus: typed fields for the
|
|
271
|
+
// controller, raw fields for the envelope passthrough.
|
|
272
|
+
function unachievableStopStatus(lineageId, lens, order) {
|
|
273
|
+
const subjectHash = `sha256:${String(order).repeat(64)}`;
|
|
274
|
+
const repositoryContext = `rctx1_${"e".repeat(64)}`;
|
|
275
|
+
const withdrawArguments = [
|
|
276
|
+
{ name: "lineage", value: lineageId, token: `--lineage=${lineageId}` },
|
|
277
|
+
{ name: "expected-revision", value: SHA, token: `--expected-revision=${SHA}` },
|
|
278
|
+
{ name: "target", value: SHA, token: `--target=${SHA}` },
|
|
279
|
+
{ name: "repository-context", value: repositoryContext, token: `--repository-context=${repositoryContext}` },
|
|
280
|
+
{ name: "request-hash", value: subjectHash, token: `--request-hash=${subjectHash}` },
|
|
281
|
+
{ name: "withdraw", value: "true", token: "--withdraw=true" },
|
|
282
|
+
];
|
|
283
|
+
const withdrawCommand = `gentle-ai review capture-unachievable --lineage=${lineageId} --expected-revision=${SHA} --target=${SHA} --repository-context=${repositoryContext} --request-hash=${subjectHash} --withdraw=true`;
|
|
284
|
+
return {
|
|
285
|
+
...collectStatus(lineageId, []),
|
|
286
|
+
nextTransition: {
|
|
287
|
+
kind: "stop",
|
|
288
|
+
reasonCode: "unachievable_lens_slot",
|
|
289
|
+
unachievableLensSlots: [
|
|
290
|
+
{
|
|
291
|
+
lens,
|
|
292
|
+
selectedOrder: order,
|
|
293
|
+
subjectHash,
|
|
294
|
+
reason: "relay_transport_bound_exceeded",
|
|
295
|
+
withdraw: {
|
|
296
|
+
operation: "review.capture-unachievable",
|
|
297
|
+
command: withdrawCommand,
|
|
298
|
+
arguments: withdrawArguments,
|
|
299
|
+
binding: { targetIdentity: SHA, lineageId, revision: SHA },
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
},
|
|
304
|
+
raw: {
|
|
305
|
+
schema: "gentle-ai.review-integration.status/v5",
|
|
306
|
+
action: "stop",
|
|
307
|
+
lineage_id: lineageId,
|
|
308
|
+
target_identity: SHA,
|
|
309
|
+
next_transition: {
|
|
310
|
+
kind: "stop",
|
|
311
|
+
reason_code: "unachievable_lens_slot",
|
|
312
|
+
unachievable_lens_slots: [
|
|
313
|
+
{
|
|
314
|
+
lens,
|
|
315
|
+
selected_order: order,
|
|
316
|
+
subject_hash: subjectHash,
|
|
317
|
+
reason: "relay_transport_bound_exceeded",
|
|
318
|
+
withdraw: {
|
|
319
|
+
operation: "review.capture-unachievable",
|
|
320
|
+
command: withdrawCommand,
|
|
321
|
+
arguments: withdrawArguments,
|
|
322
|
+
binding: { lineage_id: lineageId, revision: SHA, target_identity: SHA, repository_context: repositoryContext },
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const UNACHIEVABLE_STOP = unachievableStopStatus(LINEAGE, LENS, ORDER);
|
|
331
|
+
|
|
332
|
+
// gentle-pi#638: the declared slot must not survive the restart as a reoffer.
|
|
333
|
+
// Process A declares after the deterministic bound kill; fresh Process B sees
|
|
334
|
+
// the provider's typed stop with the withdraw hint instead of the public
|
|
335
|
+
// collectBinding it would have seen before the declaration.
|
|
336
|
+
test("an unachievable declaration survives the controller restart as a typed stop, not a reoffer", async (t) => {
|
|
337
|
+
const cwd = repository(t);
|
|
338
|
+
|
|
339
|
+
// Process A: the deterministic relay bound kills the reviewer; the capture
|
|
340
|
+
// path declares the slot unachievable and renders the provider's stop. The
|
|
341
|
+
// worker always runs INSPECT first, so the queue carries the pending collect
|
|
342
|
+
// twice (inspect + capture selection) before the post-declaration stop.
|
|
343
|
+
const declared = runWorker(t, cwd, [PENDING, PENDING, UNACHIEVABLE_STOP], "capture-timeout");
|
|
344
|
+
assert.equal(declared.error, undefined, `declared run threw: ${JSON.stringify(declared.error)}`);
|
|
345
|
+
assert.equal(declared.captureCalls, 1, "Process A invokes one capture");
|
|
346
|
+
assert.equal(declared.relayRequests.length, 1, "Process A launches one relay transport");
|
|
347
|
+
assert.equal(declared.unachievableCalls.length, 1, "the declaration is recorded exactly once");
|
|
348
|
+
assert.deepEqual(declared.unachievableCalls, [{ cwd, lineageId: LINEAGE, targetIdentity: SHA, expectedRevision: SHA, requestHash: `sha256:${String(ORDER).repeat(64)}`, reason: "relay_transport_bound_exceeded", detail: "killed after 2256004ms against a 2256000ms relay bound", repositoryContext: `rctx1_${"e".repeat(64)}` }]);
|
|
349
|
+
assert.equal(declared.statusCalls.length, 3, "inspect and selection STATUS plus exactly one bound re-query");
|
|
350
|
+
assert.equal(declared.result.tool, "gentle_review_capture");
|
|
351
|
+
assert.equal(declared.result.status, "blocked");
|
|
352
|
+
assert.equal(declared.result.outcome, "unachievable-lens-slot-declared");
|
|
353
|
+
assert.equal(declared.result.mutation_performed, true);
|
|
354
|
+
assert.match(String(declared.result.next_action), /withdraw/);
|
|
355
|
+
|
|
356
|
+
// Process B (fresh): the provider's STATUS no longer reoffers the binding;
|
|
357
|
+
// INSPECT renders the typed stop whose hint is the withdraw command.
|
|
358
|
+
const inspect = runWorker(t, cwd, [UNACHIEVABLE_STOP], "inspect");
|
|
359
|
+
assert.equal(inspect.error, undefined, `inspect run threw: ${JSON.stringify(inspect.error)}`);
|
|
360
|
+
assert.equal(inspect.captureCalls, 0, "no capture runs without a reoffered binding");
|
|
361
|
+
assert.equal(inspect.relayRequests.length, 0, "INSPECT never launches the relay");
|
|
362
|
+
assert.equal(inspect.unachievableCalls.length, 0, "INSPECT never declares");
|
|
363
|
+
assert.equal(inspectCollectBinding(inspect.result), undefined, "the declared slot is not reoffered after restart");
|
|
364
|
+
assert.equal(inspect.result.status, "blocked");
|
|
365
|
+
assert.match(String(inspect.result.hint), /capture-unachievable/);
|
|
366
|
+
assert.match(String(inspect.result.hint), /--withdraw=true/);
|
|
367
|
+
});
|
|
368
|
+
|
|
228
369
|
test("a fresh controller process reoffers the exact public binding before one capture closes", async (t) => {
|
|
229
370
|
const cwd = repository(t);
|
|
230
371
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
7
7
|
import { __testing } from "../extensions/gentle-ai.ts";
|
|
8
|
-
import type { NativeReviewCli } from "../lib/native-review-cli.ts";
|
|
8
|
+
import type { NativeReviewCli, NativeReviewUnachievableLensCaptureArtifact, NativeReviewUnachievableLensCaptureRequest } from "../lib/native-review-cli.ts";
|
|
9
9
|
import { REVIEW_HOST_RELAY_FAILURE, REVIEW_HOST_RELAY_PI_TIMEOUT_ENV, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS, REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE, REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, ReviewHostRelayError, type ReviewHostRelayRequest } from "../lib/review-host-relay.ts";
|
|
10
|
-
import { decodeReviewStatusV3, type ReviewArtifactSubjectV2, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3, type ReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
10
|
+
import { decodeReviewNextTransitionV3, decodeReviewStatusV3, type ReviewArtifactSubjectV2, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3, type ReviewStatusV3 } from "../lib/review-integration-v2.ts";
|
|
11
11
|
|
|
12
12
|
// One-slot capture routing: the host relay runs only when the selected
|
|
13
13
|
// provider-returned collect input carries the --materialize token. Every
|
|
@@ -158,13 +158,18 @@ function providerRefuterRequiredStatus(lineageId: string): ReviewStatusV3 {
|
|
|
158
158
|
interface RoutingHarness {
|
|
159
159
|
statusQueue: ReviewStatusV3[];
|
|
160
160
|
statusCalls: Array<{ cwd: string; lineageId?: string; agent?: "pi" }>;
|
|
161
|
+
unachievableCalls: NativeReviewUnachievableLensCaptureRequest[];
|
|
161
162
|
native: NativeReviewCli;
|
|
162
163
|
}
|
|
163
164
|
|
|
164
|
-
|
|
165
|
+
type UnachievableResponder = (request: NativeReviewUnachievableLensCaptureRequest) => Promise<NativeReviewUnachievableLensCaptureArtifact>;
|
|
166
|
+
|
|
167
|
+
// The declaration verb stays OPT-IN: an absent captureUnachievableLens is the invocation-adjacent capability gate, so every pre-existing failure test keeps its exact envelope through the unchanged fall-back.
|
|
168
|
+
function nativeHarness(statuses: readonly ReviewStatusV3[], unachievableResponder?: UnachievableResponder): RoutingHarness {
|
|
165
169
|
const harness: RoutingHarness = {
|
|
166
170
|
statusQueue: [...statuses],
|
|
167
171
|
statusCalls: [],
|
|
172
|
+
unachievableCalls: [],
|
|
168
173
|
native: undefined as unknown as NativeReviewCli,
|
|
169
174
|
};
|
|
170
175
|
harness.native = {
|
|
@@ -174,6 +179,7 @@ function nativeHarness(statuses: readonly ReviewStatusV3[]): RoutingHarness {
|
|
|
174
179
|
if (next === undefined) throw new Error("status queue exhausted");
|
|
175
180
|
return next;
|
|
176
181
|
},
|
|
182
|
+
...(unachievableResponder === undefined ? {} : { captureUnachievableLens: async (request: NativeReviewUnachievableLensCaptureRequest) => { harness.unachievableCalls.push(request); return await unachievableResponder(request); } }),
|
|
177
183
|
};
|
|
178
184
|
return harness;
|
|
179
185
|
}
|
|
@@ -506,6 +512,360 @@ test("a relay timeout reports its one-slot measurements and no auto-follow", asy
|
|
|
506
512
|
assert.equal(harness.statusCalls.length, 1);
|
|
507
513
|
});
|
|
508
514
|
|
|
515
|
+
// gentle-pi#638: the typed stop a provider renders after a host declared a
|
|
516
|
+
// slot unachievable, built through the production decoder so the routing
|
|
517
|
+
// tests exercise the same typed shape the extension consumes.
|
|
518
|
+
function unachievableStopStatus(lineageId: string, lens: ReviewArtifactSubjectV2["lens"], order: number, reason = "relay_transport_bound_exceeded"): ReviewStatusV3 {
|
|
519
|
+
const subjectHash = `sha256:${String(order).repeat(64)}`;
|
|
520
|
+
const repositoryContext = `rctx1_${"e".repeat(64)}`;
|
|
521
|
+
const rawTransition = {
|
|
522
|
+
kind: "stop",
|
|
523
|
+
reason_code: "unachievable_lens_slot",
|
|
524
|
+
unachievable_lens_slots: [
|
|
525
|
+
{
|
|
526
|
+
lens,
|
|
527
|
+
selected_order: order,
|
|
528
|
+
subject_hash: subjectHash,
|
|
529
|
+
reason,
|
|
530
|
+
withdraw: {
|
|
531
|
+
operation: "review.capture-unachievable",
|
|
532
|
+
command: `gentle-ai review capture-unachievable --lineage=${lineageId} --expected-revision=${SHA} --target=${SHA} --repository-context=${repositoryContext} --request-hash=${subjectHash} --withdraw=true`,
|
|
533
|
+
arguments: [
|
|
534
|
+
{ name: "lineage", value: lineageId, token: `--lineage=${lineageId}` },
|
|
535
|
+
{ name: "expected-revision", value: SHA, token: `--expected-revision=${SHA}` },
|
|
536
|
+
{ name: "target", value: SHA, token: `--target=${SHA}` },
|
|
537
|
+
{ name: "repository-context", value: repositoryContext, token: `--repository-context=${repositoryContext}` },
|
|
538
|
+
{ name: "request-hash", value: subjectHash, token: `--request-hash=${subjectHash}` },
|
|
539
|
+
{ name: "withdraw", value: "true", token: "--withdraw=true" },
|
|
540
|
+
],
|
|
541
|
+
binding: { lineage_id: lineageId, revision: SHA, target_identity: SHA, repository_context: repositoryContext },
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
],
|
|
545
|
+
};
|
|
546
|
+
return {
|
|
547
|
+
...finalizeStatus(lineageId),
|
|
548
|
+
nextTransition: decodeReviewNextTransitionV3(rawTransition),
|
|
549
|
+
raw: { schema: "gentle-ai.review-integration.status/v5", action: "stop", lineage_id: lineageId, target_identity: SHA, next_transition: rawTransition },
|
|
550
|
+
} as unknown as ReviewStatusV3;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function unachievableArtifact(lineageId: string, lens: ReviewArtifactSubjectV2["lens"], order: number, reason = "relay_transport_bound_exceeded"): NativeReviewUnachievableLensCaptureArtifact {
|
|
554
|
+
return { schema: "gentle-ai.review-capture-unachievable/v1", lineageId, targetIdentity: SHA, lens, selectedOrder: order, reason, recorded: true };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// gentle-pi#638: a deterministic pi timeout is declared unachievable through
|
|
558
|
+
// the native verb, and one bound STATUS re-query renders the typed stop with
|
|
559
|
+
// its withdraw binding instead of reoffering the same slot.
|
|
560
|
+
test("a deterministic pi timeout declares the slot unachievable and renders the typed stop", async (t) => {
|
|
561
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
562
|
+
const cwd = repository(t);
|
|
563
|
+
const lineageId = "relay-lineage";
|
|
564
|
+
const input = relayCollectInput(lineageId, "review-risk", 0);
|
|
565
|
+
const stop = unachievableStopStatus(lineageId, "review-risk", 0);
|
|
566
|
+
const harness = nativeHarness([finalizeStatus(lineageId, [input]), stop], () => Promise.resolve(unachievableArtifact(lineageId, "review-risk", 0)));
|
|
567
|
+
__testing.setReviewHostRelayRunnerForTesting(async () => {
|
|
568
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { exitCode: null, timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const result = await runCapture(cwd, harness, lineageId);
|
|
572
|
+
|
|
573
|
+
assert.equal(result.status, "blocked");
|
|
574
|
+
assert.equal(result.outcome, "unachievable-lens-slot-declared");
|
|
575
|
+
assert.deepEqual(result.failure, { kind: "pi-timed-out", stage: "pi", exit_code: null, timed_out: true, elapsed_ms: 2_256_004, timeout_ms: 2_256_000 });
|
|
576
|
+
assert.deepEqual(harness.unachievableCalls, [{ cwd: realpathSync(cwd), lineageId, targetIdentity: SHA, expectedRevision: SHA, requestHash: `sha256:${"0".repeat(64)}`, reason: "relay_transport_bound_exceeded", detail: "killed after 2256004ms against a 2256000ms relay bound", repositoryContext: `rctx1_${"e".repeat(64)}` }]);
|
|
577
|
+
assert.deepEqual(result.declaration, { lens: "review-risk", selected_order: 0, subject_hash: `sha256:${"0".repeat(64)}`, reason: "relay_transport_bound_exceeded" });
|
|
578
|
+
assert.deepEqual(result.result, stop.raw);
|
|
579
|
+
const slots = result.unachievable_lens_slots as Array<{ lens: string; subject_hash: string; withdraw: string }>;
|
|
580
|
+
assert.equal(slots.length, 1);
|
|
581
|
+
assert.equal(slots[0]!.lens, "review-risk");
|
|
582
|
+
assert.equal(slots[0]!.subject_hash, `sha256:${"0".repeat(64)}`);
|
|
583
|
+
assert.match(slots[0]!.withdraw, /capture-unachievable.*--withdraw=true/);
|
|
584
|
+
assert.match(String(result.next_action), /withdraw/);
|
|
585
|
+
assert.equal(result.mutation_performed, true);
|
|
586
|
+
// gentle-pi#822: the native declaration was recorded, so the flow reports the mutation as committed, not none.
|
|
587
|
+
assert.equal(result.mutation_outcome, "committed");
|
|
588
|
+
assert.equal(harness.statusCalls.length, 2, "one selection STATUS plus exactly one bound re-query");
|
|
589
|
+
assert.equal(harness.statusCalls.at(-1)?.agent, "pi", "the bound re-query preserves the Pi host runtime");
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
// gentle-pi#638: generic admission rejections describe the submitted reviewer
|
|
593
|
+
// bytes, not a deterministic failure of the provider-bound slot. A fresh
|
|
594
|
+
// reviewer can repair malformed JSON or a binding mismatch, so both retain the
|
|
595
|
+
// ordinary exact-reoffer path and must never declare the slot unachievable.
|
|
596
|
+
test("repairable submission refusals retain the exact-slot retry path", async (t) => {
|
|
597
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
598
|
+
const cwd = repository(t);
|
|
599
|
+
const lineageId = "relay-lineage";
|
|
600
|
+
for (const refusal of [
|
|
601
|
+
"Error: reviewer payload contains no complete JSON object [invalid_request]\n",
|
|
602
|
+
"Error: reviewer artifact admission binding_mismatch [invalid_request]\n",
|
|
603
|
+
]) {
|
|
604
|
+
const input = relayCollectInput(lineageId, "review-reliability", 0);
|
|
605
|
+
const harness = nativeHarness([finalizeStatus(lineageId, [input])], async () => {
|
|
606
|
+
throw new Error("a repairable refusal must not call capture-unachievable");
|
|
607
|
+
});
|
|
608
|
+
__testing.setReviewHostRelayRunnerForTesting(async () => {
|
|
609
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", refusal.trim(), { exitCode: 1, stderr: refusal, mutationOutcome: "none" });
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
const result = await runCapture(cwd, harness, lineageId);
|
|
613
|
+
|
|
614
|
+
assert.equal(result.outcome, "pi-host-relay-transport-failure");
|
|
615
|
+
assert.equal(result.mutation_performed, false);
|
|
616
|
+
assert.equal(result.mutation_outcome, "none");
|
|
617
|
+
assert.match(String(result.next_action), /fresh STATUS/);
|
|
618
|
+
assert.deepEqual(harness.unachievableCalls, []);
|
|
619
|
+
assert.equal(harness.statusCalls.length, 1, "a repairable non-mutation needs no reconciliation before the provider reoffers it");
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// gentle-pi#822: a stop carrying slots that do not match the identity this
|
|
624
|
+
// session declared is a reconciliation failure — never a success rendering
|
|
625
|
+
// someone else's withdraw command.
|
|
626
|
+
test("a stop whose slots do not match the declared identity reports a reconciliation failure", async (t) => {
|
|
627
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
628
|
+
const cwd = repository(t);
|
|
629
|
+
const lineageId = "relay-lineage";
|
|
630
|
+
const input = relayCollectInput(lineageId, "review-risk", 0);
|
|
631
|
+
const foreignStop = unachievableStopStatus(lineageId, "review-risk", 1);
|
|
632
|
+
const harness = nativeHarness([finalizeStatus(lineageId, [input]), foreignStop], () => Promise.resolve(unachievableArtifact(lineageId, "review-risk", 0)));
|
|
633
|
+
__testing.setReviewHostRelayRunnerForTesting(async () => {
|
|
634
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { exitCode: null, timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
const result = await runCapture(cwd, harness, lineageId);
|
|
638
|
+
|
|
639
|
+
assert.equal(result.status, "blocked");
|
|
640
|
+
assert.equal(result.outcome, "unachievable-lens-declaration-reconciliation-failed");
|
|
641
|
+
assert.deepEqual(result.declaration, { lens: "review-risk", selected_order: 0, subject_hash: `sha256:${"0".repeat(64)}`, reason: "relay_transport_bound_exceeded" });
|
|
642
|
+
assert.equal(result.unachievable_lens_slots, undefined, "a foreign slot is never exposed as this session's withdraw command");
|
|
643
|
+
const reconciliationFailure = result.reconciliation_failure as { outcome?: string; reason?: string } | undefined;
|
|
644
|
+
assert.equal(reconciliationFailure?.outcome, "unachievable-lens-slot-declaration-unmatched");
|
|
645
|
+
assert.match(String(reconciliationFailure?.reason), /no unachievable_lens_slots entry matches the declared slot identity/);
|
|
646
|
+
assert.equal(result.mutation_performed, true);
|
|
647
|
+
assert.equal(result.mutation_outcome, "committed");
|
|
648
|
+
assert.match(String(result.next_action), /refused the unachievable declaration/);
|
|
649
|
+
assert.equal(harness.statusCalls.length, 2, "the bound re-query still runs exactly once");
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
// gentle-pi#822: with several declared entries on the stop, only the matching
|
|
653
|
+
// identity is exposed — never the withdraw commands of other runs.
|
|
654
|
+
test("a stop carrying several declared slots exposes only the one this session declared", async (t) => {
|
|
655
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
656
|
+
const cwd = repository(t);
|
|
657
|
+
const lineageId = "relay-lineage";
|
|
658
|
+
const input = relayCollectInput(lineageId, "review-risk", 0);
|
|
659
|
+
const stop = unachievableStopStatus(lineageId, "review-risk", 0);
|
|
660
|
+
const foreign = unachievableStopStatus(lineageId, "review-reliability", 2).nextTransition?.unachievableLensSlots?.[0];
|
|
661
|
+
assert.ok(foreign !== undefined);
|
|
662
|
+
stop.nextTransition = { ...stop.nextTransition!, unachievableLensSlots: [...stop.nextTransition!.unachievableLensSlots!, foreign] };
|
|
663
|
+
const harness = nativeHarness([finalizeStatus(lineageId, [input]), stop], () => Promise.resolve(unachievableArtifact(lineageId, "review-risk", 0)));
|
|
664
|
+
__testing.setReviewHostRelayRunnerForTesting(async () => {
|
|
665
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { exitCode: null, timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
const result = await runCapture(cwd, harness, lineageId);
|
|
669
|
+
|
|
670
|
+
assert.equal(result.outcome, "unachievable-lens-slot-declared");
|
|
671
|
+
const slots = result.unachievable_lens_slots as Array<{ lens: string; selected_order: number; subject_hash: string }>;
|
|
672
|
+
assert.equal(slots.length, 1, "only the declared entry is exposed");
|
|
673
|
+
assert.equal(slots[0]!.lens, "review-risk");
|
|
674
|
+
assert.equal(slots[0]!.selected_order, 0);
|
|
675
|
+
assert.equal(slots[0]!.subject_hash, `sha256:${"0".repeat(64)}`);
|
|
676
|
+
assert.equal(result.mutation_outcome, "committed");
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// gentle-pi#822 (CodeRabbit finding): success must be proven by the unachievable_lens_slot stop itself — a bound STATUS that comes back with a collect reoffer instead of the stop is a reconciliation failure, never a silent success with no withdraw command.
|
|
680
|
+
test("a bound STATUS that reoffers the collect transition instead of the stop reports a reconciliation failure", async (t) => {
|
|
681
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
682
|
+
const cwd = repository(t);
|
|
683
|
+
const lineageId = "relay-lineage";
|
|
684
|
+
const input = relayCollectInput(lineageId, "review-risk", 0);
|
|
685
|
+
const harness = nativeHarness([finalizeStatus(lineageId, [input]), finalizeStatus(lineageId, [input])], () => Promise.resolve(unachievableArtifact(lineageId, "review-risk", 0)));
|
|
686
|
+
__testing.setReviewHostRelayRunnerForTesting(async () => {
|
|
687
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { exitCode: null, timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
const result = await runCapture(cwd, harness, lineageId);
|
|
691
|
+
|
|
692
|
+
assert.equal(result.status, "blocked");
|
|
693
|
+
assert.equal(result.outcome, "unachievable-lens-declaration-reconciliation-failed");
|
|
694
|
+
assert.equal(result.unachievable_lens_slots, undefined, "no stop means no withdraw command to expose");
|
|
695
|
+
const reconciliationFailure = result.reconciliation_failure as { outcome?: string; reason?: string };
|
|
696
|
+
assert.equal(reconciliationFailure?.outcome, "unachievable-lens-slot-declaration-unmatched");
|
|
697
|
+
assert.match(String(reconciliationFailure?.reason), /did not return the unachievable_lens_slot stop/);
|
|
698
|
+
assert.equal(result.mutation_performed, true, "the declaration was recorded");
|
|
699
|
+
assert.equal(result.mutation_outcome, "committed");
|
|
700
|
+
assert.equal(harness.statusCalls.length, 2, "the bound re-query still runs exactly once");
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
// gentle-pi#822 (CodeRabbit finding): the same proof requirement covers every STATUS shape that is not the stop — a foreign stop reason code or no transition at all.
|
|
704
|
+
test("a bound STATUS stop with a foreign reason code or no transition reports a reconciliation failure", async (t) => {
|
|
705
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
706
|
+
const cwd = repository(t);
|
|
707
|
+
const lineageId = "relay-lineage";
|
|
708
|
+
const input = relayCollectInput(lineageId, "review-risk", 0);
|
|
709
|
+
const responder = () => Promise.resolve(unachievableArtifact(lineageId, "review-risk", 0));
|
|
710
|
+
const relayFailure = async () => {
|
|
711
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { exitCode: null, timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
const foreignStop = { ...finalizeStatus(lineageId), nextTransition: { kind: "stop", reasonCode: "rdd_disabled" } } as unknown as ReviewStatusV3;
|
|
715
|
+
const foreignHarness = nativeHarness([finalizeStatus(lineageId, [input]), foreignStop], responder);
|
|
716
|
+
__testing.setReviewHostRelayRunnerForTesting(relayFailure);
|
|
717
|
+
const foreign = await runCapture(cwd, foreignHarness, lineageId);
|
|
718
|
+
|
|
719
|
+
assert.equal(foreign.outcome, "unachievable-lens-declaration-reconciliation-failed");
|
|
720
|
+
assert.equal(foreign.unachievable_lens_slots, undefined);
|
|
721
|
+
const foreignFailure = foreign.reconciliation_failure as { outcome?: string; reason?: string };
|
|
722
|
+
assert.match(String(foreignFailure?.reason), /did not return the unachievable_lens_slot stop/);
|
|
723
|
+
assert.equal(foreign.mutation_performed, true);
|
|
724
|
+
assert.equal(foreign.mutation_outcome, "committed");
|
|
725
|
+
|
|
726
|
+
const bareHarness = nativeHarness([finalizeStatus(lineageId, [input]), finalizeStatus(lineageId)], responder);
|
|
727
|
+
__testing.setReviewHostRelayRunnerForTesting(relayFailure);
|
|
728
|
+
const bare = await runCapture(cwd, bareHarness, lineageId);
|
|
729
|
+
|
|
730
|
+
assert.equal(bare.outcome, "unachievable-lens-declaration-reconciliation-failed");
|
|
731
|
+
assert.equal(bare.unachievable_lens_slots, undefined);
|
|
732
|
+
const bareFailure = bare.reconciliation_failure as { outcome?: string; reason?: string };
|
|
733
|
+
assert.match(String(bareFailure?.reason), /did not return the unachievable_lens_slot stop/);
|
|
734
|
+
assert.equal(bare.mutation_outcome, "committed");
|
|
735
|
+
assert.equal(bareHarness.statusCalls.length, 2);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
// gentle-pi#822: STATUS for one lineage renders only that lineage's withdraw
|
|
739
|
+
// command as the hint; a request for an unlisted lineage omits the hint rather
|
|
740
|
+
// than surfacing a potentially unrelated withdraw command.
|
|
741
|
+
test("an unachievable stop renders the withdraw hint only for the requested lineage", async (t) => {
|
|
742
|
+
const cwd = repository(t);
|
|
743
|
+
const matchedHarness = nativeHarness([unachievableStopStatus("other-lineage", "review-risk", 0)]);
|
|
744
|
+
const matched = (await __testing.executeReviewControllerOperation({ operation: "status", lineageId: "other-lineage" }, cwd, matchedHarness.native)) as Record<string, unknown>;
|
|
745
|
+
assert.match(String(matched.hint), /capture-unachievable.*--withdraw=true/);
|
|
746
|
+
assert.equal(matched.requested_lineage_id, "other-lineage");
|
|
747
|
+
|
|
748
|
+
const unmatchedHarness = nativeHarness([unachievableStopStatus("other-lineage", "review-risk", 0)]);
|
|
749
|
+
const unmatched = (await __testing.executeReviewControllerOperation({ operation: "status", lineageId: "relay-lineage" }, cwd, unmatchedHarness.native)) as Record<string, unknown>;
|
|
750
|
+
assert.equal(unmatched.hint, undefined, "no hint when no slot matches the requested lineage");
|
|
751
|
+
assert.equal(unmatched.requested_lineage_id, "relay-lineage");
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
// The fail-open capability gate: an older binary that refuses the whole verb
|
|
755
|
+
// keeps today's transport-failure envelope untouched.
|
|
756
|
+
test("an unknown-verb refusal falls back to today's transport-failure behavior", async (t) => {
|
|
757
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
758
|
+
const cwd = repository(t);
|
|
759
|
+
const lineageId = "relay-lineage";
|
|
760
|
+
const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)])], async () => {
|
|
761
|
+
throw Object.assign(new Error("native command returned empty output"), { diagnostics: { stderr: 'Error: unknown review command "capture-unachievable"\n' } });
|
|
762
|
+
});
|
|
763
|
+
__testing.setReviewHostRelayRunnerForTesting(async () => {
|
|
764
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { exitCode: null, timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
const result = await runCapture(cwd, harness, lineageId);
|
|
768
|
+
|
|
769
|
+
assert.equal(result.status, "blocked");
|
|
770
|
+
assert.equal(result.outcome, "pi-host-relay-timeout");
|
|
771
|
+
assert.equal(harness.unachievableCalls.length, 1, "the verb was attempted exactly once");
|
|
772
|
+
assert.equal(harness.statusCalls.length, 1, "the fall-back performs no bound STATUS re-query");
|
|
773
|
+
assert.match(String(result.next_action), new RegExp(`${REVIEW_HOST_RELAY_PI_TIMEOUT_ENV}=<milliseconds>`));
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
// Every non-capability declaration failure — here a typed binding-mismatch
|
|
777
|
+
// refusal — is surfaced with both failures, never hidden behind the relay
|
|
778
|
+
// failure it followed.
|
|
779
|
+
test("a refused declaration surfaces both failures instead of the transport fall-back", async (t) => {
|
|
780
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
781
|
+
const cwd = repository(t);
|
|
782
|
+
const lineageId = "relay-lineage";
|
|
783
|
+
const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)])], async () => {
|
|
784
|
+
throw Object.assign(new Error("review capture-unachievable binding does not match the current reviewing authority"), { diagnostics: { stderr: "Error: review capture-unachievable binding does not match [invalid_request]" } });
|
|
785
|
+
});
|
|
786
|
+
__testing.setReviewHostRelayRunnerForTesting(async () => {
|
|
787
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { exitCode: null, timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
const result = await runCapture(cwd, harness, lineageId);
|
|
791
|
+
|
|
792
|
+
assert.equal(result.status, "blocked");
|
|
793
|
+
assert.equal(result.outcome, "unachievable-lens-declaration-failed");
|
|
794
|
+
assert.equal(result.mutation_performed, false);
|
|
795
|
+
assert.equal(result.mutation_outcome, "none");
|
|
796
|
+
assert.deepEqual(result.failure, { kind: "pi-timed-out", stage: "pi", exit_code: null, timed_out: true, elapsed_ms: 2_256_004, timeout_ms: 2_256_000 });
|
|
797
|
+
assert.ok(result.declaration_failure, "the declaration refusal rides the envelope");
|
|
798
|
+
assert.equal(harness.statusCalls.length, 1, "a refused declaration performs no bound STATUS re-query");
|
|
799
|
+
assert.match(String(result.next_action), /fresh STATUS/);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
// gentle-pi#822 (outside-diff finding): the declaration failure's own envelope carries the mutation truth — a failure AFTER the provider recorded the declaration reports committed, never a hardcoded none.
|
|
803
|
+
test("a declaration failure whose envelope already committed propagates its mutation state", async (t) => {
|
|
804
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
805
|
+
const cwd = repository(t);
|
|
806
|
+
const lineageId = "relay-lineage";
|
|
807
|
+
const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)])], async () => {
|
|
808
|
+
throw Object.assign(new Error("review capture-unachievable failed after recording"), { failureEnvelope: { raw: { code: "invalid_request", message: "review capture-unachievable failed after recording" }, mutationOutcome: "committed" } });
|
|
809
|
+
});
|
|
810
|
+
__testing.setReviewHostRelayRunnerForTesting(async () => {
|
|
811
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { exitCode: null, timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
const result = await runCapture(cwd, harness, lineageId);
|
|
815
|
+
|
|
816
|
+
assert.equal(result.status, "blocked");
|
|
817
|
+
assert.equal(result.outcome, "unachievable-lens-declaration-failed");
|
|
818
|
+
assert.equal(result.mutation_performed, true);
|
|
819
|
+
assert.equal(result.mutation_outcome, "committed");
|
|
820
|
+
assert.ok(result.declaration_failure, "the declaration failure still rides the envelope");
|
|
821
|
+
assert.equal(harness.statusCalls.length, 1, "a committed outcome needs no reconciliation re-query");
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
// gentle-pi#822 (outside-diff finding): an unknown declaration outcome is proven or disproven by one bound STATUS re-query without ever changing the failure outcome.
|
|
825
|
+
test("an unknown declaration outcome is proven committed by one bound STATUS re-query", async (t) => {
|
|
826
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
827
|
+
const cwd = repository(t);
|
|
828
|
+
const lineageId = "relay-lineage";
|
|
829
|
+
const input = relayCollectInput(lineageId, "review-risk", 0);
|
|
830
|
+
const stop = unachievableStopStatus(lineageId, "review-risk", 0);
|
|
831
|
+
const harness = nativeHarness([finalizeStatus(lineageId, [input]), stop], async () => {
|
|
832
|
+
throw Object.assign(new Error("native command timed out while recording the declaration"), { failureEnvelope: { raw: { code: "deadline_exceeded", message: "native command timed out" }, mutationOutcome: "unknown" } });
|
|
833
|
+
});
|
|
834
|
+
__testing.setReviewHostRelayRunnerForTesting(async () => {
|
|
835
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { exitCode: null, timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
const result = await runCapture(cwd, harness, lineageId);
|
|
839
|
+
|
|
840
|
+
assert.equal(result.status, "blocked");
|
|
841
|
+
assert.equal(result.outcome, "unachievable-lens-declaration-failed", "the failure outcome never changes");
|
|
842
|
+
assert.equal(result.mutation_performed, true);
|
|
843
|
+
assert.equal(result.mutation_outcome, "committed");
|
|
844
|
+
assert.equal(harness.statusCalls.length, 2, "one selection STATUS plus exactly one bound reconciliation re-query");
|
|
845
|
+
assert.equal(harness.statusCalls.at(-1)?.agent, "pi", "the reconciliation re-query preserves the Pi host runtime");
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
// gentle-pi#822 (outside-diff finding): a malformed declaration error with no envelope at all keeps the conservative none outcome and performs no re-query.
|
|
849
|
+
test("a malformed declaration error without an envelope keeps mutation none", async (t) => {
|
|
850
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
851
|
+
const cwd = repository(t);
|
|
852
|
+
const lineageId = "relay-lineage";
|
|
853
|
+
const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)])], async () => {
|
|
854
|
+
throw new TypeError("cannot read properties of undefined (reading 'detail')");
|
|
855
|
+
});
|
|
856
|
+
__testing.setReviewHostRelayRunnerForTesting(async () => {
|
|
857
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { exitCode: null, timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
const result = await runCapture(cwd, harness, lineageId);
|
|
861
|
+
|
|
862
|
+
assert.equal(result.status, "blocked");
|
|
863
|
+
assert.equal(result.outcome, "unachievable-lens-declaration-failed");
|
|
864
|
+
assert.equal(result.mutation_performed, false);
|
|
865
|
+
assert.equal(result.mutation_outcome, "none");
|
|
866
|
+
assert.equal(harness.statusCalls.length, 1, "no envelope means no unknown outcome and no re-query");
|
|
867
|
+
});
|
|
868
|
+
|
|
509
869
|
test("a non-timeout transport failure keeps its generic continuation and now carries its measurements", async (t) => {
|
|
510
870
|
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
511
871
|
const cwd = repository(t);
|
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
resolveReviewHostRelayPiTimeoutMs,
|
|
19
19
|
resolveReviewHostRelaySubmission,
|
|
20
20
|
reviewHostRelaySlots,
|
|
21
|
+
reviewHostRelayUnachievableDetail,
|
|
22
|
+
reviewHostRelayUnachievableReason,
|
|
21
23
|
prepareReviewHostRelaySlot,
|
|
22
24
|
runReviewHostRelayReviewerGroup,
|
|
23
25
|
runReviewHostRelaySlot,
|
|
@@ -779,6 +781,33 @@ test("refusal classification distinguishes unknown-flag, handshake, and other",
|
|
|
779
781
|
assert.equal(classifyReviewHostRelayRefusal("some unrelated explosion"), "other");
|
|
780
782
|
});
|
|
781
783
|
|
|
784
|
+
// gentle-pi#638: only a reviewer killed by the scaled relay bound is proven
|
|
785
|
+
// deterministic for this exact slot. Generic admission refusals are repairable
|
|
786
|
+
// by a fresh reviewer and retain the ordinary exact-reoffer behavior.
|
|
787
|
+
test("the unachievable predicate names only deterministic slot failures", () => {
|
|
788
|
+
const killed = new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
|
|
789
|
+
assert.equal(reviewHostRelayUnachievableReason(killed), "relay_transport_bound_exceeded");
|
|
790
|
+
assert.equal(reviewHostRelayUnachievableDetail(killed), "killed after 2256004ms against a 2256000ms relay bound");
|
|
791
|
+
|
|
792
|
+
for (const refusal of ["reviewer payload contains no complete JSON object", "reviewer artifact admission binding_mismatch"]) {
|
|
793
|
+
const admitted = new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", `${refusal} [invalid_request]`, { exitCode: 1, mutationOutcome: "none" });
|
|
794
|
+
assert.equal(reviewHostRelayUnachievableReason(admitted), undefined, `${refusal} is repairable by a fresh reviewer`);
|
|
795
|
+
assert.equal(reviewHostRelayUnachievableDetail(admitted), undefined);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const unknownOutcome = new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", "gentle-ai capture submission exceeded its bound", { timedOut: true });
|
|
799
|
+
assert.equal(reviewHostRelayUnachievableReason(unknownOutcome), undefined, "an unknown mutation outcome is never deterministic");
|
|
800
|
+
assert.equal(reviewHostRelayUnachievableDetail(unknownOutcome), undefined);
|
|
801
|
+
|
|
802
|
+
for (const kind of [REVIEW_HOST_RELAY_FAILURE.PI_FAILED, REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED, REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, REVIEW_HOST_RELAY_FAILURE.EMPTY_PROMPT, REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH]) {
|
|
803
|
+
assert.equal(reviewHostRelayUnachievableReason(new ReviewHostRelayError(kind, "pi", "transient")), undefined, `${kind} stays transient`);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const unmeasured = new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "killed at the bound");
|
|
807
|
+
assert.equal(reviewHostRelayUnachievableReason(unmeasured), "relay_transport_bound_exceeded");
|
|
808
|
+
assert.equal(reviewHostRelayUnachievableDetail(unmeasured), undefined, "no measurements means no detail, never a fabricated one");
|
|
809
|
+
});
|
|
810
|
+
|
|
782
811
|
// ---------------------------------------------------------------------------
|
|
783
812
|
// Slot detection — the provider decides; nothing is inferred.
|
|
784
813
|
// ---------------------------------------------------------------------------
|