gentle-pi 2.2.0 → 2.3.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 +51 -80
- package/assets/agents/review-readability.md +0 -2
- package/assets/agents/review-reliability.md +0 -2
- package/assets/agents/review-resilience.md +0 -2
- package/assets/agents/review-risk.md +0 -2
- package/assets/agents/sdd-apply.md +5 -3
- package/assets/agents/sdd-proposal.md +2 -0
- package/assets/agents/sdd-research.md +54 -0
- package/assets/agents/sdd-status.md +4 -4
- package/assets/agents/sdd-tasks.md +2 -3
- package/assets/agents/sdd-verify.md +21 -1
- package/assets/chains/sdd-full.chain.md +1 -1
- package/assets/chains/sdd-verify.chain.md +1 -1
- package/assets/orchestrator-delegation.md +54 -248
- package/assets/orchestrator-memory.md +2 -0
- package/assets/orchestrator.md +22 -38
- package/assets/sdd-orchestrator-workflow.md +30 -37
- package/assets/support/sdd-status-contract.md +7 -7
- package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
- package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
- package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
- package/docs/native-authority-architecture.md +9 -11
- package/docs/review-integration.md +27 -373
- package/extensions/ask-user-choice.ts +151 -0
- package/extensions/gentle-ai.ts +1778 -3673
- package/extensions/quiet-tools.ts +515 -32
- package/extensions/sdd-init.ts +4 -8
- package/lib/gentle-ai-renderer.ts +70 -0
- package/lib/model-routing-authority.ts +133 -0
- package/lib/native-review-cli.ts +372 -940
- package/lib/opaque-pi-reviewer-adapter.ts +284 -0
- package/lib/review-candidate-view.ts +341 -132
- package/lib/review-host-relay.ts +210 -68
- package/lib/review-integration-v2.ts +839 -307
- package/lib/review-last-event-controller.ts +35 -0
- package/lib/sdd-preflight.ts +177 -66
- package/lib/sdd-status.ts +66 -111
- package/lib/terminal-theme.ts +1 -1
- package/package.json +83 -82
- package/runtime/gentle-ai-binary.mjs +1 -1
- package/runtime/native-review-cli.mjs +322 -890
- package/runtime/review-integration-v2.mjs +789 -257
- package/runtime/review-relay-contract.mjs +1 -1
- package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
- package/scripts/gentle-ai-installer.mjs +75 -22
- package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
- package/scripts/test-packed-runner.mjs +4 -7
- package/scripts/verify-package-files.mjs +12 -13
- package/skills/_shared/review-ledger-contract.md +8 -14
- package/skills/chained-pr/SKILL.md +3 -0
- package/skills/cognitive-doc-design/SKILL.md +1 -1
- package/skills/comment-writer/SKILL.md +1 -1
- package/skills/gentle-ai/SKILL.md +7 -74
- package/skills/judgment-day/SKILL.md +5 -7
- package/skills/rdd-defect-workflow/SKILL.md +3 -3
- package/skills/release/SKILL.md +3 -3
- package/skills/skill-registry/SKILL.md +1 -1
- package/skills/work-unit-commits/SKILL.md +3 -1
- package/tests/artifact-language.test.ts +24 -11
- package/tests/ask-user-choice.test.ts +264 -0
- package/tests/codegraph-tools.test.ts +3 -3
- package/tests/crosslane/cross-lane.mjs +15 -1168
- package/tests/delegated-key-learnings-contract.test.ts +8 -6
- package/tests/devbinary/native-review-parity.devtest.ts +167 -250
- package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
- package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
- package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
- package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
- package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
- package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
- package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
- package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
- package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
- package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
- package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
- package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
- package/tests/gentle-ai-binary.test.ts +83 -13
- package/tests/gentle-ai-dev-binary.test.ts +51 -228
- package/tests/gentle-ai-installer.test.ts +137 -47
- package/tests/gentle-ai.test.ts +820 -131
- package/tests/gentle-theme.test.ts +133 -0
- package/tests/maintainer/provider-relay.maintest.ts +340 -4
- package/tests/model-routing-authority.test.ts +257 -0
- package/tests/native-review-capability-contract.test.ts +28 -1
- package/tests/native-review-cli.test.ts +586 -962
- package/tests/native-review-consent.test.ts +150 -66
- package/tests/native-review-parity-runtime.test.ts +80 -359
- package/tests/native-review-parity.test.ts +674 -925
- package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
- package/tests/orchestrator-budget.test.ts +132 -88
- package/tests/orchestrator-rdd-ownership.test.ts +103 -0
- package/tests/package-manifest.test.ts +37 -35
- package/tests/provider-defect-handoff.test.ts +15 -118
- package/tests/quiet-tool-rendering.test.ts +1055 -28
- package/tests/review-authority-recovery-docs.test.ts +1 -2
- package/tests/review-candidate-view.test.ts +409 -3
- package/tests/review-compact-contract.test.ts +4 -30
- package/tests/review-controller-lock-status.test.ts +2 -2
- package/tests/review-controller-native-recovery.test.ts +363 -1450
- package/tests/review-controller-native-routing.test.ts +1251 -5642
- package/tests/review-controller-retired-ops.test.ts +1 -1
- package/tests/review-controller-workspace-root.test.ts +222 -71
- package/tests/review-controller.test.ts +26 -816
- package/tests/review-corrected-finalize-binding.test.ts +112 -153
- package/tests/review-dispatch-hydration-gap.test.ts +1 -53
- package/tests/review-gate.test.ts +0 -45
- package/tests/review-host-relay-restart-parity.test.ts +360 -0
- package/tests/review-host-relay-routing.test.ts +117 -82
- package/tests/review-host-relay.test.ts +242 -8
- package/tests/review-integration-v2-forward.test.ts +576 -553
- package/tests/review-integration-v2.test.ts +239 -165
- package/tests/review-last-event-closure.test.ts +408 -0
- package/tests/review-ledger-contract.test.ts +97 -35
- package/tests/review-recovered-lineage-routing.test.ts +0 -47
- package/tests/review-relay-transport-agent.test.ts +119 -46
- package/tests/review-snapshot.test.ts +3 -2
- package/tests/runtime-harness.mjs +361 -196
- package/tests/sdd-agent-tools.test.ts +36 -0
- package/tests/sdd-preflight.test.ts +81 -15
- package/tests/sdd-status.test.ts +109 -110
- package/tests/skill-collision-prefixes.test.ts +5 -8
- package/tests/writer-edit-surface-scope.test.ts +230 -0
- package/themes/Gentleman-Cute.json +94 -0
- package/themes/Gentleman-Sexy.json +92 -0
- package/lib/git-commit-transaction.ts +0 -861
- package/runtime/git-commit-transaction.mjs +0 -862
- package/scripts/run-git-commit-transaction.mjs +0 -35
- package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
- package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
- package/tests/git-commit-transaction.test.ts +0 -530
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
@@ -7,10 +7,15 @@ import { createNodeExecFileAdapter } from "../lib/native-review-cli.ts";
|
|
|
7
7
|
import {
|
|
8
8
|
REVIEW_HOST_RELAY_FAILURE,
|
|
9
9
|
REVIEW_HOST_RELAY_PI_ARGV,
|
|
10
|
+
REVIEW_HOST_RELAY_PI_TIMEOUT_ENV,
|
|
11
|
+
REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS,
|
|
12
|
+
REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS,
|
|
13
|
+
REVIEW_HOST_RELAY_PI_TIMEOUT_PER_MEBIBYTE_MS,
|
|
10
14
|
REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE,
|
|
11
15
|
REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE,
|
|
12
16
|
ReviewHostRelayError,
|
|
13
17
|
classifyReviewHostRelayRefusal,
|
|
18
|
+
resolveReviewHostRelayPiTimeoutMs,
|
|
14
19
|
resolveReviewHostRelaySubmission,
|
|
15
20
|
reviewHostRelaySlots,
|
|
16
21
|
runReviewHostRelaySlot,
|
|
@@ -20,15 +25,16 @@ import { decodeReviewNextTransitionV3, type ReviewCaptureSubmissionV1, type Revi
|
|
|
20
25
|
|
|
21
26
|
// ---------------------------------------------------------------------------
|
|
22
27
|
// Fake binaries. Following the repo's fake-executable idiom (shell/git
|
|
23
|
-
// wrappers in review
|
|
28
|
+
// wrappers in related review tests), these are
|
|
24
29
|
// shebang scripts written into a scratch directory; node scripts are used so
|
|
25
30
|
// binary-unsafe bytes survive verbatim.
|
|
26
31
|
// ---------------------------------------------------------------------------
|
|
27
32
|
|
|
28
33
|
const FAKE_GENTLE_AI = `#!/usr/bin/env node
|
|
29
34
|
const fs = require("node:fs");
|
|
35
|
+
const path = require("node:path");
|
|
30
36
|
const argv = process.argv.slice(2);
|
|
31
|
-
if (process.env.RELAY_FAKE_LOG) fs.appendFileSync(process.env.RELAY_FAKE_LOG, JSON.stringify({ argv, contract: process.env.${GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV} ?? null }) + "\\n");
|
|
37
|
+
if (process.env.RELAY_FAKE_LOG) fs.appendFileSync(process.env.RELAY_FAKE_LOG, JSON.stringify({ argv, cwd: process.cwd(), contract: process.env.${GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV} ?? null }) + "\\n");
|
|
32
38
|
if (argv.some((token) => token === "--materialize" || token.startsWith("--materialize="))) {
|
|
33
39
|
const mode = process.env.RELAY_FAKE_MATERIALIZE_MODE || "ok";
|
|
34
40
|
if (mode === "ok") { process.stdout.write(Buffer.from(process.env.RELAY_FAKE_PROMPT_B64 || "", "base64")); process.exit(0); }
|
|
@@ -40,10 +46,35 @@ if (argv.some((token) => token === "--materialize" || token.startsWith("--materi
|
|
|
40
46
|
const inputToken = argv.find((token) => token === "--input" || token.startsWith("--input="));
|
|
41
47
|
if (inputToken !== undefined) {
|
|
42
48
|
const mode = process.env.RELAY_FAKE_SUBMIT_MODE || "ok";
|
|
43
|
-
|
|
44
|
-
|
|
49
|
+
const inputPath = inputToken.startsWith("--input=") ? inputToken.slice("--input=".length) : argv[argv.indexOf("--input") + 1];
|
|
50
|
+
if (mode === "ok" || mode === "cleanup-fail") {
|
|
45
51
|
const bytes = fs.readFileSync(inputPath);
|
|
46
52
|
if (process.env.RELAY_FAKE_SUBMIT_CAPTURE) fs.writeFileSync(process.env.RELAY_FAKE_SUBMIT_CAPTURE, bytes);
|
|
53
|
+
const accepted = JSON.stringify({ schema: "gentle-ai.review-result-artifact/v2", admission_decision: "completed" });
|
|
54
|
+
if (mode === "cleanup-fail") {
|
|
55
|
+
process.stdout.write(accepted, () => {
|
|
56
|
+
fs.chmodSync(path.dirname(path.dirname(inputPath)), 0o500);
|
|
57
|
+
process.exit(0);
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
process.stdout.write(accepted);
|
|
62
|
+
process.exit(0);
|
|
63
|
+
}
|
|
64
|
+
if (mode === "refuse-cleanup-fail") fs.chmodSync(path.dirname(path.dirname(inputPath)), 0o500);
|
|
65
|
+
if (mode === "admit") {
|
|
66
|
+
// Go's admission refusal shape: a schema-bounded failure/v2 envelope on
|
|
67
|
+
// stdout, the operator line with the typed code suffix on stderr, exit 1.
|
|
68
|
+
const bytes = fs.readFileSync(inputPath);
|
|
69
|
+
let parsed;
|
|
70
|
+
try { parsed = JSON.parse(bytes.toString("utf8")); } catch { parsed = undefined; }
|
|
71
|
+
const refuse = (cause) => {
|
|
72
|
+
process.stdout.write(JSON.stringify({ schema: "gentle-ai.review-integration.failure/v2", contract: "gentle-ai.review-integration/v2", operation: "review.capture-result", phase: "preflight", code: "invalid_request", message: "The negotiated review request is invalid.", mutation_outcome: "not_started", authority_applicability: "not_evaluated", retry_safe: true, replayability: "not_replayable", required_inputs: [], next_action: "correct_request", cause }));
|
|
73
|
+
process.stderr.write("Error: " + cause + " [invalid_request]\\n");
|
|
74
|
+
process.exit(1);
|
|
75
|
+
};
|
|
76
|
+
if (parsed === undefined || typeof parsed !== "object") refuse("lens provider result admission incomplete: reviewer payload contains no complete JSON object: no object start was found in " + bytes.length + " bytes; the rejected reviewer payload was preserved at " + inputPath + ".rejected");
|
|
77
|
+
if (parsed.subject_hash !== process.env.RELAY_FAKE_EXPECTED_SUBJECT) refuse("reviewer artifact admission binding_mismatch: reviewer result echoed a different artifact subject: the rejected admission did not consume the lens slot, so re-run the lens and invoke gentle-ai review capture-result again on the same lineage with a result that echoes the binding's top-level subject_hash, which is " + process.env.RELAY_FAKE_EXPECTED_SUBJECT);
|
|
47
78
|
process.stdout.write(JSON.stringify({ schema: "gentle-ai.review-result-artifact/v2", admission_decision: "completed" }));
|
|
48
79
|
process.exit(0);
|
|
49
80
|
}
|
|
@@ -56,6 +87,7 @@ process.exit(9);
|
|
|
56
87
|
|
|
57
88
|
const FAKE_PI = `#!/usr/bin/env node
|
|
58
89
|
const fs = require("node:fs");
|
|
90
|
+
const path = require("node:path");
|
|
59
91
|
const argv = process.argv.slice(2);
|
|
60
92
|
if (process.env.RELAY_FAKE_PI_LOG) fs.appendFileSync(process.env.RELAY_FAKE_PI_LOG, JSON.stringify({ argv, cwd: process.cwd(), entries: fs.readdirSync(process.cwd()), contract: process.env.${GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV} ?? null }) + "\\n");
|
|
61
93
|
const chunks = [];
|
|
@@ -64,6 +96,7 @@ process.stdin.on("end", () => {
|
|
|
64
96
|
const stdin = Buffer.concat(chunks);
|
|
65
97
|
if (process.env.RELAY_FAKE_PI_STDIN_CAPTURE) fs.writeFileSync(process.env.RELAY_FAKE_PI_STDIN_CAPTURE, stdin);
|
|
66
98
|
const mode = process.env.RELAY_FAKE_PI_MODE || "ok";
|
|
99
|
+
if (process.env.RELAY_FAKE_PI_BREAK_CLEANUP === "true") fs.chmodSync(path.dirname(process.cwd()), 0o500);
|
|
67
100
|
if (mode === "ok") { process.stdout.write(Buffer.from(process.env.RELAY_FAKE_PI_OUTPUT_B64 || "", "base64")); process.exit(0); }
|
|
68
101
|
if (mode === "empty") process.exit(0);
|
|
69
102
|
if (mode === "hang") { setTimeout(() => process.exit(0), 10_000); return; }
|
|
@@ -80,11 +113,12 @@ interface RelayHarness {
|
|
|
80
113
|
piLogPath: string;
|
|
81
114
|
stdinCapturePath: string;
|
|
82
115
|
submitCapturePath: string;
|
|
116
|
+
targetCwd: string;
|
|
83
117
|
environment: NodeJS.ProcessEnv;
|
|
84
118
|
}
|
|
85
119
|
|
|
86
120
|
function harness(t: test.TestContext, overrides: Record<string, string> = {}): RelayHarness {
|
|
87
|
-
const directory = mkdtempSync(join(tmpdir(), "gentle-pi-relay-harness-"));
|
|
121
|
+
const directory = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-relay-harness-")));
|
|
88
122
|
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
|
89
123
|
const gentleAi = join(directory, "gentle-ai");
|
|
90
124
|
const pi = join(directory, "pi");
|
|
@@ -96,6 +130,8 @@ function harness(t: test.TestContext, overrides: Record<string, string> = {}): R
|
|
|
96
130
|
const piLogPath = join(directory, "pi.log");
|
|
97
131
|
const stdinCapturePath = join(directory, "pi-stdin.bin");
|
|
98
132
|
const submitCapturePath = join(directory, "submitted.bin");
|
|
133
|
+
const targetCwd = join(directory, "target-worktree");
|
|
134
|
+
mkdirSync(targetCwd);
|
|
99
135
|
const environment: NodeJS.ProcessEnv = {
|
|
100
136
|
...process.env,
|
|
101
137
|
RELAY_FAKE_LOG: logPath,
|
|
@@ -107,7 +143,7 @@ function harness(t: test.TestContext, overrides: Record<string, string> = {}): R
|
|
|
107
143
|
// The relay itself must add the handshake; the base environment never
|
|
108
144
|
// carries it, so the fake-binary log proves the injection.
|
|
109
145
|
delete environment[GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV];
|
|
110
|
-
return { directory, gentleAi, pi, logPath, piLogPath, stdinCapturePath, submitCapturePath, environment };
|
|
146
|
+
return { directory, gentleAi, pi, logPath, piLogPath, stdinCapturePath, submitCapturePath, targetCwd, environment };
|
|
111
147
|
}
|
|
112
148
|
|
|
113
149
|
function readLog(path: string): Array<{ argv: string[]; contract: string | null; cwd?: string; entries?: string[] }> {
|
|
@@ -152,6 +188,7 @@ function relayRequest(fixture: RelayHarness, overrides: Record<string, unknown>
|
|
|
152
188
|
submission: SUBMISSION,
|
|
153
189
|
gentleAiExecutable: fixture.gentleAi,
|
|
154
190
|
piExecutable: fixture.pi,
|
|
191
|
+
targetCwd: fixture.targetCwd,
|
|
155
192
|
environment: {
|
|
156
193
|
...fixture.environment,
|
|
157
194
|
RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
|
|
@@ -229,11 +266,13 @@ test("relay happy path moves prompt and result bytes verbatim through a fresh em
|
|
|
229
266
|
const substituted = gentleAiCalls[1]!.argv.at(-1)!;
|
|
230
267
|
assert.match(substituted, /^--input=\S+$/);
|
|
231
268
|
assert.equal(substituted.includes("{{value}}"), false);
|
|
269
|
+
assert.equal(existsSync(substituted.slice("--input=".length)), false, "the coordinator removes its temporary result file after provider submission");
|
|
232
270
|
assert.equal(gentleAiCalls[1]!.argv.length, 2 + SUBMISSION.argumentTokens.length);
|
|
233
271
|
assert.equal(gentleAiCalls[1]!.argv.some((token) => token.includes("--agent") || token.includes("--materialize")), false);
|
|
234
272
|
// Handshake declared on both gentle-ai invocations even though the base
|
|
235
273
|
// environment carried none.
|
|
236
274
|
assert.deepEqual(gentleAiCalls.map((call) => call.contract), [GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT]);
|
|
275
|
+
assert.deepEqual(gentleAiCalls.map((call) => call.cwd), [fixture.targetCwd, fixture.targetCwd]);
|
|
237
276
|
|
|
238
277
|
const piCalls = readLog(fixture.piLogPath);
|
|
239
278
|
assert.equal(piCalls.length, 1);
|
|
@@ -305,12 +344,108 @@ test("empty pi stdout fails closed with a typed error and no submission", async
|
|
|
305
344
|
|
|
306
345
|
test("pi timeout fails closed with a typed error and no submission", async (t) => {
|
|
307
346
|
const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang" });
|
|
308
|
-
const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 300 })), REVIEW_HOST_RELAY_FAILURE.
|
|
347
|
+
const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 300 })), REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi");
|
|
309
348
|
assert.equal(error.timedOut, true);
|
|
310
349
|
assert.equal(readLog(fixture.logPath).length, 1);
|
|
311
350
|
assert.equal(existsSync(fixture.submitCapturePath), false);
|
|
312
351
|
});
|
|
313
352
|
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// gentle-pi#367 — the reviewer bound is reachable from production, and a
|
|
355
|
+
// reviewer killed by it says so with both measurements.
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
test("the reviewer bound scales with materialized prompt bytes instead of one fixed number", () => {
|
|
359
|
+
const empty: NodeJS.ProcessEnv = {};
|
|
360
|
+
// A tiny prompt still gets the model-latency floor.
|
|
361
|
+
assert.equal(resolveReviewHostRelayPiTimeoutMs(0, empty), REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS);
|
|
362
|
+
assert.equal(resolveReviewHostRelayPiTimeoutMs(1, empty), REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS + 1);
|
|
363
|
+
// One mebibyte of prompt buys exactly one linear allowance.
|
|
364
|
+
assert.equal(
|
|
365
|
+
resolveReviewHostRelayPiTimeoutMs(1024 * 1024, empty),
|
|
366
|
+
REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS + REVIEW_HOST_RELAY_PI_TIMEOUT_PER_MEBIBYTE_MS,
|
|
367
|
+
);
|
|
368
|
+
// The reported field candidate: ~1.58 MB of prompt, whose reviewer needed
|
|
369
|
+
// 478s and was killed by the old fixed 600s bound. The derived bound must
|
|
370
|
+
// clear that measurement with real margin.
|
|
371
|
+
const reported = resolveReviewHostRelayPiTimeoutMs(1_580_000, empty);
|
|
372
|
+
assert.ok(reported > 600_000, `derived bound ${reported} must exceed the old fixed 600000ms bound`);
|
|
373
|
+
assert.ok(reported > 478_000 * 3, `derived bound ${reported} must keep real margin over the measured 478000ms reviewer run`);
|
|
374
|
+
// Never unbounded, however large the prompt gets.
|
|
375
|
+
assert.equal(resolveReviewHostRelayPiTimeoutMs(Number.MAX_SAFE_INTEGER, empty), REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("the reviewer bound honours the environment override and ignores malformed values", () => {
|
|
379
|
+
const bytes = 4 * 1024 * 1024;
|
|
380
|
+
assert.equal(resolveReviewHostRelayPiTimeoutMs(bytes, { [REVIEW_HOST_RELAY_PI_TIMEOUT_ENV]: "1234" }), 1234);
|
|
381
|
+
// The override is clamped by the same hard ceiling as the derived bound.
|
|
382
|
+
assert.equal(resolveReviewHostRelayPiTimeoutMs(bytes, { [REVIEW_HOST_RELAY_PI_TIMEOUT_ENV]: "999999999" }), REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS);
|
|
383
|
+
const derived = resolveReviewHostRelayPiTimeoutMs(bytes, {});
|
|
384
|
+
for (const malformed of ["", "0", "-1", "12.5", "abc", " 600000", "1e6"]) {
|
|
385
|
+
assert.equal(resolveReviewHostRelayPiTimeoutMs(bytes, { [REVIEW_HOST_RELAY_PI_TIMEOUT_ENV]: malformed }), derived, `malformed ${JSON.stringify(malformed)} must fall back to the derived bound`);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("the production relay path resolves the reviewer bound from the environment, with no injected timeout", async (t) => {
|
|
390
|
+
// The regression: piTimeoutMs was reachable only through the test seam, so
|
|
391
|
+
// production always ran against the fixed 600s bound. This request injects
|
|
392
|
+
// no timeout at all — the override must reach the real spawn.
|
|
393
|
+
const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang", [REVIEW_HOST_RELAY_PI_TIMEOUT_ENV]: "300" });
|
|
394
|
+
const error = await rejectsWithRelayError(
|
|
395
|
+
runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: undefined })),
|
|
396
|
+
REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT,
|
|
397
|
+
"pi",
|
|
398
|
+
);
|
|
399
|
+
assert.equal(error.timeoutMs, 300);
|
|
400
|
+
assert.equal(error.timedOut, true);
|
|
401
|
+
assert.equal(readLog(fixture.logPath).length, 1, "no submission after a reviewer timeout");
|
|
402
|
+
assert.equal(existsSync(fixture.submitCapturePath), false);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("a relay Pi timeout keeps its typed mapping and timing evidence when opaque scratch cleanup also fails", async (t) => {
|
|
406
|
+
const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang", RELAY_FAKE_PI_BREAK_CLEANUP: "true" });
|
|
407
|
+
const scratchParent = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-relay-pi-primary-failure-")));
|
|
408
|
+
const originalTmpdir = process.env.TMPDIR;
|
|
409
|
+
process.env.TMPDIR = scratchParent;
|
|
410
|
+
try {
|
|
411
|
+
const error = await rejectsWithRelayError(
|
|
412
|
+
runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 300 })),
|
|
413
|
+
REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT,
|
|
414
|
+
"pi",
|
|
415
|
+
);
|
|
416
|
+
assert.equal(error.timedOut, true);
|
|
417
|
+
assert.equal(error.timeoutMs, 300);
|
|
418
|
+
assert.ok(error.elapsedMs !== null && error.elapsedMs >= 250);
|
|
419
|
+
} finally {
|
|
420
|
+
if (originalTmpdir === undefined) delete process.env.TMPDIR;
|
|
421
|
+
else process.env.TMPDIR = originalTmpdir;
|
|
422
|
+
chmodSync(scratchParent, 0o700);
|
|
423
|
+
rmSync(scratchParent, { recursive: true, force: true });
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("a killed reviewer reports elapsed, limit, and what to change instead of an opaque transport failure", async (t) => {
|
|
428
|
+
const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang" });
|
|
429
|
+
const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 300 })), REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi");
|
|
430
|
+
assert.equal(error.timeoutMs, 300);
|
|
431
|
+
assert.ok(error.elapsedMs !== null && error.elapsedMs >= 250, `elapsed ${error.elapsedMs} must record the real wall time`);
|
|
432
|
+
assert.ok(error.elapsedMs! < 10_000, "the reviewer was killed at the bound, not left to finish");
|
|
433
|
+
assert.match(error.message, /exceeded the relay bound/);
|
|
434
|
+
assert.match(error.message, new RegExp(String(error.elapsedMs)));
|
|
435
|
+
assert.match(error.message, /limit for a \d+-byte materialized prompt/);
|
|
436
|
+
assert.match(error.message, new RegExp(REVIEW_HOST_RELAY_PI_TIMEOUT_ENV));
|
|
437
|
+
assert.equal(error.mutationOutcome, "none");
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("a crashed reviewer stays distinguishable from a killed one and still carries its measurements", async (t) => {
|
|
441
|
+
const fixture = harness(t, { RELAY_FAKE_PI_MODE: "fail" });
|
|
442
|
+
const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 30_000 })), REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi");
|
|
443
|
+
assert.equal(error.timedOut, false);
|
|
444
|
+
assert.equal(error.exitCode, 4);
|
|
445
|
+
assert.equal(error.timeoutMs, 30_000);
|
|
446
|
+
assert.ok(error.elapsedMs !== null && error.elapsedMs >= 0);
|
|
447
|
+
});
|
|
448
|
+
|
|
314
449
|
test("submission refusal is a typed error whose outcome is unknown pending STATUS", async (t) => {
|
|
315
450
|
const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "refuse" });
|
|
316
451
|
const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit");
|
|
@@ -319,6 +454,84 @@ test("submission refusal is a typed error whose outcome is unknown pending STATU
|
|
|
319
454
|
assert.equal(readLog(fixture.logPath).length, 2);
|
|
320
455
|
});
|
|
321
456
|
|
|
457
|
+
// gentle-pi#522 / #524: Go refuses a reviewer submission at admission with a
|
|
458
|
+
// typed [invalid_request] refusal and states that the lens slot was not
|
|
459
|
+
// consumed. That is a proven non-mutation, not an unknown outcome, and the
|
|
460
|
+
// refusal text is the only thing that tells the host what to change.
|
|
461
|
+
function admittingRequest(fixture: RelayHarness, reviewerOutput: Buffer) {
|
|
462
|
+
return relayRequest(fixture, {
|
|
463
|
+
environment: {
|
|
464
|
+
...fixture.environment,
|
|
465
|
+
RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
|
|
466
|
+
RELAY_FAKE_PI_OUTPUT_B64: reviewerOutput.toString("base64"),
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
test("a reviewer printing garbage is refused at admission as a proven non-mutation carrying Go's refusal", async (t) => {
|
|
472
|
+
const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "admit", RELAY_FAKE_EXPECTED_SUBJECT: `sha256:${"a".repeat(64)}` });
|
|
473
|
+
const error = await rejectsWithRelayError(
|
|
474
|
+
runReviewHostRelaySlot(admittingRequest(fixture, Buffer.from("not json at all", "utf8"))),
|
|
475
|
+
REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
|
|
476
|
+
"submit",
|
|
477
|
+
);
|
|
478
|
+
assert.equal(error.mutationOutcome, "none");
|
|
479
|
+
assert.equal(error.exitCode, 1);
|
|
480
|
+
assert.equal(error.timedOut, false);
|
|
481
|
+
assert.match(error.stderr, /reviewer payload contains no complete JSON object/);
|
|
482
|
+
assert.match(error.stderr, /\[invalid_request\]/);
|
|
483
|
+
assert.match(error.message, /reviewer payload contains no complete JSON object/);
|
|
484
|
+
assert.equal(readLog(fixture.logPath).length, 2, "the refusal must not be retried by the relay");
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("a reviewer echoing a different subject is refused at admission as a proven non-mutation carrying Go's continuation", async (t) => {
|
|
488
|
+
const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "admit", RELAY_FAKE_EXPECTED_SUBJECT: `sha256:${"a".repeat(64)}` });
|
|
489
|
+
const wrongSubject = Buffer.from(JSON.stringify({ subject_hash: `sha256:${"0".repeat(64)}`, inspection: { status: "completed", paths: [] }, findings: [], evidence: ["x"] }), "utf8");
|
|
490
|
+
const error = await rejectsWithRelayError(
|
|
491
|
+
runReviewHostRelaySlot(admittingRequest(fixture, wrongSubject)),
|
|
492
|
+
REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
|
|
493
|
+
"submit",
|
|
494
|
+
);
|
|
495
|
+
assert.equal(error.mutationOutcome, "none");
|
|
496
|
+
assert.equal(error.exitCode, 1);
|
|
497
|
+
assert.match(error.stderr, /binding_mismatch/);
|
|
498
|
+
assert.match(error.stderr, /did not consume the lens slot/);
|
|
499
|
+
assert.match(error.stderr, /\[invalid_request\]/);
|
|
500
|
+
assert.match(error.message, /did not consume the lens slot/);
|
|
501
|
+
assert.equal(readLog(fixture.logPath).length, 2, "the refusal must not be retried by the relay");
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("a submission the fake admits with the expected subject still completes", async (t) => {
|
|
505
|
+
const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "admit", RELAY_FAKE_EXPECTED_SUBJECT: `sha256:${"a".repeat(64)}` });
|
|
506
|
+
const result = await runReviewHostRelaySlot(admittingRequest(fixture, Buffer.from(JSON.stringify({ subject_hash: `sha256:${"a".repeat(64)}`, inspection: { status: "completed", paths: [] }, findings: [], evidence: ["x"] }), "utf8")));
|
|
507
|
+
assert.equal(JSON.parse(result.submission).admission_decision, "completed");
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("submission refusal preserves its primary evidence when result staging cleanup also fails", async (t) => {
|
|
511
|
+
const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "refuse-cleanup-fail" });
|
|
512
|
+
const scratchParent = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-relay-primary-failure-")));
|
|
513
|
+
const originalTmpdir = process.env.TMPDIR;
|
|
514
|
+
process.env.TMPDIR = scratchParent;
|
|
515
|
+
try {
|
|
516
|
+
const error = await rejectsWithRelayError(
|
|
517
|
+
runReviewHostRelaySlot(relayRequest(fixture)),
|
|
518
|
+
REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
|
|
519
|
+
"submit",
|
|
520
|
+
);
|
|
521
|
+
assert.equal(error.exitCode, 1);
|
|
522
|
+
assert.equal(error.timedOut, false);
|
|
523
|
+
assert.equal(error.timeoutMs, 30_000);
|
|
524
|
+
assert.ok(error.elapsedMs !== null && error.elapsedMs >= 0);
|
|
525
|
+
assert.match(error.stderr, /capture binding does not match/);
|
|
526
|
+
assert.doesNotMatch(error.message, /staging cleanup/i);
|
|
527
|
+
} finally {
|
|
528
|
+
if (originalTmpdir === undefined) delete process.env.TMPDIR;
|
|
529
|
+
else process.env.TMPDIR = originalTmpdir;
|
|
530
|
+
chmodSync(scratchParent, 0o700);
|
|
531
|
+
rmSync(scratchParent, { recursive: true, force: true });
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
|
|
322
535
|
test("relay scratch and staging directories are removed after failures too", async (t) => {
|
|
323
536
|
const fixture = harness(t, { RELAY_FAKE_PI_MODE: "fail" });
|
|
324
537
|
await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi");
|
|
@@ -327,6 +540,27 @@ test("relay scratch and staging directories are removed after failures too", asy
|
|
|
327
540
|
assert.equal(existsSync(piCalls[0]!.cwd!), false);
|
|
328
541
|
});
|
|
329
542
|
|
|
543
|
+
test("a result staging cleanup failure remains a typed submit failure", async (t) => {
|
|
544
|
+
const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "cleanup-fail" });
|
|
545
|
+
const scratchParent = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-relay-cleanup-")));
|
|
546
|
+
const originalTmpdir = process.env.TMPDIR;
|
|
547
|
+
process.env.TMPDIR = scratchParent;
|
|
548
|
+
try {
|
|
549
|
+
const error = await rejectsWithRelayError(
|
|
550
|
+
runReviewHostRelaySlot(relayRequest(fixture)),
|
|
551
|
+
REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
|
|
552
|
+
"submit",
|
|
553
|
+
);
|
|
554
|
+
assert.equal(error.mutationOutcome, "unknown");
|
|
555
|
+
assert.match(error.message, /staging cleanup/i);
|
|
556
|
+
} finally {
|
|
557
|
+
if (originalTmpdir === undefined) delete process.env.TMPDIR;
|
|
558
|
+
else process.env.TMPDIR = originalTmpdir;
|
|
559
|
+
chmodSync(scratchParent, 0o700);
|
|
560
|
+
rmSync(scratchParent, { recursive: true, force: true });
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
|
|
330
564
|
// ---------------------------------------------------------------------------
|
|
331
565
|
// Capability detection — typed refusal classes, no version sniffing.
|
|
332
566
|
// ---------------------------------------------------------------------------
|