gentle-pi 2.2.0 → 2.4.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 +76 -79
- 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-explore.md +1 -0
- 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 +56 -249
- package/assets/orchestrator-memory.md +2 -0
- package/assets/orchestrator.md +20 -40
- 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/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/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 +2349 -3785
- package/extensions/quiet-tools.ts +515 -32
- package/extensions/sdd-init.ts +4 -8
- package/lib/agent-home.ts +8 -0
- package/lib/gentle-ai-renderer.ts +70 -0
- package/lib/model-routing-authority.ts +133 -0
- package/lib/native-review-cli.ts +387 -940
- package/lib/opaque-pi-reviewer-adapter.ts +284 -0
- package/lib/provider-contract-bundle.ts +88 -6
- package/lib/review-candidate-view.ts +341 -132
- package/lib/review-host-relay.ts +324 -96
- package/lib/review-integration-v2.ts +868 -307
- package/lib/review-last-event-controller.ts +35 -0
- package/lib/sdd-preflight.ts +179 -68
- 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 +337 -890
- package/runtime/review-integration-v2.mjs +816 -255
- 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 +23 -23
- package/skills/_shared/review-ledger-contract.md +14 -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/issue-creation/SKILL.md +53 -93
- 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/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-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 +821 -131
- package/tests/gentle-theme.test.ts +133 -0
- package/tests/issue-creation-skill.test.ts +103 -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 +45 -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 +152 -36
- package/tests/provider-contract-bundle.test.ts +76 -0
- package/tests/provider-contract-mirror.test.ts +19 -0
- package/tests/provider-defect-handoff.test.ts +15 -118
- package/tests/quiet-tool-rendering.test.ts +1055 -28
- package/tests/review-agent-end-preflight.test.ts +408 -0
- 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-contract-prompt.test.ts +139 -0
- 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 +328 -91
- package/tests/review-host-relay.test.ts +436 -14
- package/tests/review-integration-v2-forward.test.ts +623 -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 +362 -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 +366 -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
- /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
|
@@ -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,28 +7,39 @@ 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,
|
|
21
|
+
prepareReviewHostRelaySlot,
|
|
22
|
+
runReviewHostRelayReviewerGroup,
|
|
16
23
|
runReviewHostRelaySlot,
|
|
24
|
+
submitReviewHostRelayPreparedResult,
|
|
25
|
+
type ReviewHostRelayPreparedResult,
|
|
26
|
+
type ReviewHostRelayRequest,
|
|
17
27
|
} from "../lib/review-host-relay.ts";
|
|
18
28
|
import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "../lib/review-relay-contract.ts";
|
|
19
29
|
import { decodeReviewNextTransitionV3, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3 } from "../lib/review-integration-v2.ts";
|
|
20
30
|
|
|
21
31
|
// ---------------------------------------------------------------------------
|
|
22
32
|
// Fake binaries. Following the repo's fake-executable idiom (shell/git
|
|
23
|
-
// wrappers in review
|
|
33
|
+
// wrappers in related review tests), these are
|
|
24
34
|
// shebang scripts written into a scratch directory; node scripts are used so
|
|
25
35
|
// binary-unsafe bytes survive verbatim.
|
|
26
36
|
// ---------------------------------------------------------------------------
|
|
27
37
|
|
|
28
38
|
const FAKE_GENTLE_AI = `#!/usr/bin/env node
|
|
29
39
|
const fs = require("node:fs");
|
|
40
|
+
const path = require("node:path");
|
|
30
41
|
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");
|
|
42
|
+
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
43
|
if (argv.some((token) => token === "--materialize" || token.startsWith("--materialize="))) {
|
|
33
44
|
const mode = process.env.RELAY_FAKE_MATERIALIZE_MODE || "ok";
|
|
34
45
|
if (mode === "ok") { process.stdout.write(Buffer.from(process.env.RELAY_FAKE_PROMPT_B64 || "", "base64")); process.exit(0); }
|
|
@@ -40,10 +51,37 @@ if (argv.some((token) => token === "--materialize" || token.startsWith("--materi
|
|
|
40
51
|
const inputToken = argv.find((token) => token === "--input" || token.startsWith("--input="));
|
|
41
52
|
if (inputToken !== undefined) {
|
|
42
53
|
const mode = process.env.RELAY_FAKE_SUBMIT_MODE || "ok";
|
|
43
|
-
|
|
44
|
-
|
|
54
|
+
const inputPath = inputToken.startsWith("--input=") ? inputToken.slice("--input=".length) : argv[argv.indexOf("--input") + 1];
|
|
55
|
+
const submitDelayMs = Number(process.env.RELAY_FAKE_SUBMIT_DELAY_MS || "0");
|
|
56
|
+
if (Number.isSafeInteger(submitDelayMs) && submitDelayMs > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, submitDelayMs);
|
|
57
|
+
if (mode === "ok" || mode === "cleanup-fail") {
|
|
45
58
|
const bytes = fs.readFileSync(inputPath);
|
|
46
59
|
if (process.env.RELAY_FAKE_SUBMIT_CAPTURE) fs.writeFileSync(process.env.RELAY_FAKE_SUBMIT_CAPTURE, bytes);
|
|
60
|
+
const accepted = JSON.stringify({ schema: "gentle-ai.review-result-artifact/v2", admission_decision: "completed" });
|
|
61
|
+
if (mode === "cleanup-fail") {
|
|
62
|
+
process.stdout.write(accepted, () => {
|
|
63
|
+
fs.chmodSync(path.dirname(path.dirname(inputPath)), 0o500);
|
|
64
|
+
process.exit(0);
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
process.stdout.write(accepted);
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
if (mode === "refuse-cleanup-fail") fs.chmodSync(path.dirname(path.dirname(inputPath)), 0o500);
|
|
72
|
+
if (mode === "admit") {
|
|
73
|
+
// Go's admission refusal shape: a schema-bounded failure/v2 envelope on
|
|
74
|
+
// stdout, the operator line with the typed code suffix on stderr, exit 1.
|
|
75
|
+
const bytes = fs.readFileSync(inputPath);
|
|
76
|
+
let parsed;
|
|
77
|
+
try { parsed = JSON.parse(bytes.toString("utf8")); } catch { parsed = undefined; }
|
|
78
|
+
const refuse = (cause) => {
|
|
79
|
+
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 }));
|
|
80
|
+
process.stderr.write("Error: " + cause + " [invalid_request]\\n");
|
|
81
|
+
process.exit(1);
|
|
82
|
+
};
|
|
83
|
+
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");
|
|
84
|
+
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
85
|
process.stdout.write(JSON.stringify({ schema: "gentle-ai.review-result-artifact/v2", admission_decision: "completed" }));
|
|
48
86
|
process.exit(0);
|
|
49
87
|
}
|
|
@@ -56,6 +94,7 @@ process.exit(9);
|
|
|
56
94
|
|
|
57
95
|
const FAKE_PI = `#!/usr/bin/env node
|
|
58
96
|
const fs = require("node:fs");
|
|
97
|
+
const path = require("node:path");
|
|
59
98
|
const argv = process.argv.slice(2);
|
|
60
99
|
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
100
|
const chunks = [];
|
|
@@ -63,12 +102,32 @@ process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
|
63
102
|
process.stdin.on("end", () => {
|
|
64
103
|
const stdin = Buffer.concat(chunks);
|
|
65
104
|
if (process.env.RELAY_FAKE_PI_STDIN_CAPTURE) fs.writeFileSync(process.env.RELAY_FAKE_PI_STDIN_CAPTURE, stdin);
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
105
|
+
const finish = () => {
|
|
106
|
+
const mode = process.env.RELAY_FAKE_PI_MODE || "ok";
|
|
107
|
+
if (process.env.RELAY_FAKE_PI_BREAK_CLEANUP === "true") fs.chmodSync(path.dirname(process.cwd()), 0o500);
|
|
108
|
+
if (mode === "ok") { process.stdout.write(Buffer.from(process.env.RELAY_FAKE_PI_OUTPUT_B64 || "", "base64")); process.exit(0); }
|
|
109
|
+
if (mode === "empty") process.exit(0);
|
|
110
|
+
if (mode === "hang") { setTimeout(() => process.exit(0), 10_000); return; }
|
|
111
|
+
process.stderr.write("pi exploded\\n");
|
|
112
|
+
process.exit(4);
|
|
113
|
+
};
|
|
114
|
+
const barrierDirectory = process.env.RELAY_FAKE_PI_BARRIER_DIR;
|
|
115
|
+
if (barrierDirectory === undefined) { finish(); return; }
|
|
116
|
+
fs.mkdirSync(barrierDirectory, { recursive: true });
|
|
117
|
+
fs.writeFileSync(path.join(barrierDirectory, String(process.pid)), "started");
|
|
118
|
+
const expected = Number(process.env.RELAY_FAKE_PI_BARRIER_COUNT || "4");
|
|
119
|
+
const deadline = Date.now() + 1_000;
|
|
120
|
+
const releaseFile = process.env.RELAY_FAKE_PI_RELEASE_FILE;
|
|
121
|
+
const waitForGroup = () => {
|
|
122
|
+
if (fs.readdirSync(barrierDirectory).length < expected) {
|
|
123
|
+
if (Date.now() >= deadline) { process.stderr.write("reviewer barrier timed out\\n"); process.exit(5); return; }
|
|
124
|
+
setTimeout(waitForGroup, 10);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (releaseFile !== undefined && !fs.existsSync(releaseFile)) { setTimeout(waitForGroup, 10); return; }
|
|
128
|
+
finish();
|
|
129
|
+
};
|
|
130
|
+
waitForGroup();
|
|
72
131
|
});
|
|
73
132
|
`;
|
|
74
133
|
|
|
@@ -80,11 +139,12 @@ interface RelayHarness {
|
|
|
80
139
|
piLogPath: string;
|
|
81
140
|
stdinCapturePath: string;
|
|
82
141
|
submitCapturePath: string;
|
|
142
|
+
targetCwd: string;
|
|
83
143
|
environment: NodeJS.ProcessEnv;
|
|
84
144
|
}
|
|
85
145
|
|
|
86
146
|
function harness(t: test.TestContext, overrides: Record<string, string> = {}): RelayHarness {
|
|
87
|
-
const directory = mkdtempSync(join(tmpdir(), "gentle-pi-relay-harness-"));
|
|
147
|
+
const directory = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-relay-harness-")));
|
|
88
148
|
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
|
89
149
|
const gentleAi = join(directory, "gentle-ai");
|
|
90
150
|
const pi = join(directory, "pi");
|
|
@@ -96,6 +156,8 @@ function harness(t: test.TestContext, overrides: Record<string, string> = {}): R
|
|
|
96
156
|
const piLogPath = join(directory, "pi.log");
|
|
97
157
|
const stdinCapturePath = join(directory, "pi-stdin.bin");
|
|
98
158
|
const submitCapturePath = join(directory, "submitted.bin");
|
|
159
|
+
const targetCwd = join(directory, "target-worktree");
|
|
160
|
+
mkdirSync(targetCwd);
|
|
99
161
|
const environment: NodeJS.ProcessEnv = {
|
|
100
162
|
...process.env,
|
|
101
163
|
RELAY_FAKE_LOG: logPath,
|
|
@@ -107,7 +169,7 @@ function harness(t: test.TestContext, overrides: Record<string, string> = {}): R
|
|
|
107
169
|
// The relay itself must add the handshake; the base environment never
|
|
108
170
|
// carries it, so the fake-binary log proves the injection.
|
|
109
171
|
delete environment[GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV];
|
|
110
|
-
return { directory, gentleAi, pi, logPath, piLogPath, stdinCapturePath, submitCapturePath, environment };
|
|
172
|
+
return { directory, gentleAi, pi, logPath, piLogPath, stdinCapturePath, submitCapturePath, targetCwd, environment };
|
|
111
173
|
}
|
|
112
174
|
|
|
113
175
|
function readLog(path: string): Array<{ argv: string[]; contract: string | null; cwd?: string; entries?: string[] }> {
|
|
@@ -115,6 +177,14 @@ function readLog(path: string): Array<{ argv: string[]; contract: string | null;
|
|
|
115
177
|
return readFileSync(path, "utf8").split("\n").filter((line) => line.length > 0).map((line) => JSON.parse(line));
|
|
116
178
|
}
|
|
117
179
|
|
|
180
|
+
async function waitFor(condition: () => boolean, message: string): Promise<void> {
|
|
181
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
182
|
+
if (condition()) return;
|
|
183
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
|
184
|
+
}
|
|
185
|
+
throw new Error(message);
|
|
186
|
+
}
|
|
187
|
+
|
|
118
188
|
const BINDING_TOKENS = Object.freeze([
|
|
119
189
|
"--lineage=review-1d5aadacc600e167",
|
|
120
190
|
`--expected-revision=sha256:${"c".repeat(64)}`,
|
|
@@ -125,6 +195,7 @@ const BINDING_TOKENS = Object.freeze([
|
|
|
125
195
|
`--subject-hash=sha256:${"a".repeat(64)}`,
|
|
126
196
|
]);
|
|
127
197
|
const CAPTURE_TOKENS = Object.freeze([...BINDING_TOKENS, "--agent=pi", "--materialize=true"]);
|
|
198
|
+
const REVIEWER_GROUP_LENSES = ["review-risk", "review-resilience", "review-readability", "review-reliability"] as const;
|
|
128
199
|
|
|
129
200
|
// The provider-owned completing form: exact operation and argument tokens
|
|
130
201
|
// with one declared {{value}} substitution slot for the artifact path.
|
|
@@ -152,6 +223,7 @@ function relayRequest(fixture: RelayHarness, overrides: Record<string, unknown>
|
|
|
152
223
|
submission: SUBMISSION,
|
|
153
224
|
gentleAiExecutable: fixture.gentleAi,
|
|
154
225
|
piExecutable: fixture.pi,
|
|
226
|
+
targetCwd: fixture.targetCwd,
|
|
155
227
|
environment: {
|
|
156
228
|
...fixture.environment,
|
|
157
229
|
RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
|
|
@@ -163,6 +235,20 @@ function relayRequest(fixture: RelayHarness, overrides: Record<string, unknown>
|
|
|
163
235
|
};
|
|
164
236
|
}
|
|
165
237
|
|
|
238
|
+
function reviewerGroupRequests(fixture: RelayHarness): ReviewHostRelayRequest[] {
|
|
239
|
+
return REVIEWER_GROUP_LENSES.map((lens, order) => relayRequest(fixture, {
|
|
240
|
+
captureArgumentTokens: CAPTURE_TOKENS.map((token) => token === "--lens=review-reliability"
|
|
241
|
+
? `--lens=${lens}`
|
|
242
|
+
: token === "--order=0" ? `--order=${order}` : token),
|
|
243
|
+
environment: {
|
|
244
|
+
...fixture.environment,
|
|
245
|
+
RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
|
|
246
|
+
RELAY_FAKE_PI_OUTPUT_B64: PI_OUTPUT_BYTES.toString("base64"),
|
|
247
|
+
},
|
|
248
|
+
}));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
166
252
|
async function rejectsWithRelayError(promise: Promise<unknown>, kind: string, stage: string): Promise<ReviewHostRelayError> {
|
|
167
253
|
let caught: ReviewHostRelayError | undefined;
|
|
168
254
|
await assert.rejects(promise, (error: unknown) => {
|
|
@@ -229,11 +315,13 @@ test("relay happy path moves prompt and result bytes verbatim through a fresh em
|
|
|
229
315
|
const substituted = gentleAiCalls[1]!.argv.at(-1)!;
|
|
230
316
|
assert.match(substituted, /^--input=\S+$/);
|
|
231
317
|
assert.equal(substituted.includes("{{value}}"), false);
|
|
318
|
+
assert.equal(existsSync(substituted.slice("--input=".length)), false, "the coordinator removes its temporary result file after provider submission");
|
|
232
319
|
assert.equal(gentleAiCalls[1]!.argv.length, 2 + SUBMISSION.argumentTokens.length);
|
|
233
320
|
assert.equal(gentleAiCalls[1]!.argv.some((token) => token.includes("--agent") || token.includes("--materialize")), false);
|
|
234
321
|
// Handshake declared on both gentle-ai invocations even though the base
|
|
235
322
|
// environment carried none.
|
|
236
323
|
assert.deepEqual(gentleAiCalls.map((call) => call.contract), [GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT]);
|
|
324
|
+
assert.deepEqual(gentleAiCalls.map((call) => call.cwd), [fixture.targetCwd, fixture.targetCwd]);
|
|
237
325
|
|
|
238
326
|
const piCalls = readLog(fixture.piLogPath);
|
|
239
327
|
assert.equal(piCalls.length, 1);
|
|
@@ -266,6 +354,145 @@ test("the pi lockdown argv is pinned exactly with no model or provider selection
|
|
|
266
354
|
assert.equal(piCalls[0]!.argv.some((token) => token.startsWith("--model") || token.startsWith("--provider") || token.startsWith("--profile")), false);
|
|
267
355
|
});
|
|
268
356
|
|
|
357
|
+
test("preparation snapshots mutable submission tokens and values before materialization", async (t) => {
|
|
358
|
+
const fixture = harness(t);
|
|
359
|
+
const barrierDirectory = join(fixture.directory, "submission-snapshot-barrier");
|
|
360
|
+
const releaseFile = join(fixture.directory, "submission-snapshot-release");
|
|
361
|
+
const submissionTokens = [...SUBMISSION.argumentTokens];
|
|
362
|
+
const submissionValues = SUBMISSION.values.map((value) => ({ ...value }));
|
|
363
|
+
const submission: ReviewCaptureSubmissionV1 = { ...SUBMISSION, argumentTokens: submissionTokens, values: submissionValues };
|
|
364
|
+
const prepared = prepareReviewHostRelaySlot(relayRequest(fixture, {
|
|
365
|
+
submission,
|
|
366
|
+
environment: {
|
|
367
|
+
...fixture.environment,
|
|
368
|
+
RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
|
|
369
|
+
RELAY_FAKE_PI_OUTPUT_B64: PI_OUTPUT_BYTES.toString("base64"),
|
|
370
|
+
RELAY_FAKE_PI_BARRIER_DIR: barrierDirectory,
|
|
371
|
+
RELAY_FAKE_PI_BARRIER_COUNT: "1",
|
|
372
|
+
RELAY_FAKE_PI_RELEASE_FILE: releaseFile,
|
|
373
|
+
},
|
|
374
|
+
}));
|
|
375
|
+
await waitFor(() => readLog(fixture.piLogPath).length === 1, "reviewer did not reach the snapshot barrier");
|
|
376
|
+
submissionTokens[submissionTokens.length - 1] = "--input=mutated";
|
|
377
|
+
submissionValues[0]!.slot = "mutated";
|
|
378
|
+
writeFileSync(releaseFile, "release");
|
|
379
|
+
|
|
380
|
+
const result = await prepared;
|
|
381
|
+
assert.deepEqual(result.request.submission, SUBMISSION);
|
|
382
|
+
await submitReviewHostRelayPreparedResult(result);
|
|
383
|
+
assert.deepEqual(readFileSync(fixture.submitCapturePath), PI_OUTPUT_BYTES);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("preparation keeps reviewer bytes private through deferred submission", async (t) => {
|
|
387
|
+
const fixture = harness(t);
|
|
388
|
+
const reviewerBytes = Buffer.from(PI_OUTPUT_BYTES);
|
|
389
|
+
const prepared = await prepareReviewHostRelaySlot(relayRequest(fixture), async () => ({
|
|
390
|
+
stdout: reviewerBytes,
|
|
391
|
+
promptByteLength: PROMPT_BYTES.length,
|
|
392
|
+
stdoutByteLength: reviewerBytes.length,
|
|
393
|
+
}));
|
|
394
|
+
reviewerBytes.fill(0);
|
|
395
|
+
const mutablePrepared = prepared as unknown as { resultBytes?: Buffer };
|
|
396
|
+
mutablePrepared.resultBytes?.fill(0);
|
|
397
|
+
assert.throws(() => { mutablePrepared.resultBytes = Buffer.from("fabricated"); }, TypeError);
|
|
398
|
+
await submitReviewHostRelayPreparedResult(prepared);
|
|
399
|
+
assert.deepEqual(readFileSync(fixture.submitCapturePath), PI_OUTPUT_BYTES);
|
|
400
|
+
|
|
401
|
+
const fabricated = { ...prepared, resultBytes: Buffer.from("fabricated") } as unknown as ReviewHostRelayPreparedResult;
|
|
402
|
+
await assert.rejects(submitReviewHostRelayPreparedResult(fabricated), /recognized prepared result/);
|
|
403
|
+
assert.equal(readLog(fixture.logPath).length, 2, "unrecognized results must not launch provider submission");
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("the supplied AbortSignal stays live through deferred submission", async (t) => {
|
|
407
|
+
const fixture = harness(t, { RELAY_FAKE_SUBMIT_DELAY_MS: "1000" });
|
|
408
|
+
const controller = new AbortController();
|
|
409
|
+
const prepared = await prepareReviewHostRelaySlot(relayRequest(fixture, { signal: controller.signal }));
|
|
410
|
+
const submission = submitReviewHostRelayPreparedResult(prepared);
|
|
411
|
+
await waitFor(() => readLog(fixture.logPath).length === 2, "submission did not start");
|
|
412
|
+
controller.abort();
|
|
413
|
+
await rejectsWithRelayError(submission, REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("four reviewers cross a shared barrier before any result submission can begin", async (t) => {
|
|
417
|
+
const fixture = harness(t);
|
|
418
|
+
const barrierDirectory = join(fixture.directory, "reviewer-barrier");
|
|
419
|
+
const requests = reviewerGroupRequests(fixture).map((request) => ({
|
|
420
|
+
...request,
|
|
421
|
+
environment: {
|
|
422
|
+
...request.environment,
|
|
423
|
+
RELAY_FAKE_PI_BARRIER_DIR: barrierDirectory,
|
|
424
|
+
RELAY_FAKE_PI_BARRIER_COUNT: String(REVIEWER_GROUP_LENSES.length),
|
|
425
|
+
},
|
|
426
|
+
}));
|
|
427
|
+
|
|
428
|
+
const prepared = await runReviewHostRelayReviewerGroup(requests);
|
|
429
|
+
|
|
430
|
+
assert.equal(prepared.length, REVIEWER_GROUP_LENSES.length);
|
|
431
|
+
assert.ok(prepared.every((result) => result.resultByteLength === PI_OUTPUT_BYTES.length));
|
|
432
|
+
assert.equal(readLog(fixture.piLogPath).length, REVIEWER_GROUP_LENSES.length);
|
|
433
|
+
assert.equal(readLog(fixture.logPath).length, REVIEWER_GROUP_LENSES.length, "preparation materializes only; it does not submit");
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("four reviewer results retain provider order when their preparation resolves in reverse", async (t) => {
|
|
437
|
+
const requests = reviewerGroupRequests(harness(t));
|
|
438
|
+
const started: number[] = [];
|
|
439
|
+
const complete: Array<() => void> = [];
|
|
440
|
+
const group = runReviewHostRelayReviewerGroup(requests, (request) => new Promise<ReviewHostRelayPreparedResult>((resolve) => {
|
|
441
|
+
const index = requests.indexOf(request);
|
|
442
|
+
assert.notEqual(index, -1);
|
|
443
|
+
started.push(index);
|
|
444
|
+
complete.push(() => resolve({
|
|
445
|
+
request,
|
|
446
|
+
promptByteLength: index + 1,
|
|
447
|
+
resultByteLength: index + 1,
|
|
448
|
+
}));
|
|
449
|
+
}));
|
|
450
|
+
|
|
451
|
+
assert.deepEqual(started, [0, 1, 2, 3], "every reviewer starts before the group waits");
|
|
452
|
+
for (const finish of [...complete].reverse()) finish();
|
|
453
|
+
const prepared = await group;
|
|
454
|
+
|
|
455
|
+
assert.deepEqual(prepared.map((result) => result.resultByteLength), [1, 2, 3, 4]);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("partial reviewer failures wait for a later pending transport and report the first provider-ordered error", async (t) => {
|
|
459
|
+
const requests = reviewerGroupRequests(harness(t));
|
|
460
|
+
const started: number[] = [];
|
|
461
|
+
let groupSettled = false;
|
|
462
|
+
let releaseLaterReviewer: (() => void) | undefined, rejectFirstProvider: ((reason?: unknown) => void) | undefined;
|
|
463
|
+
const firstProviderError = new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "first provider error");
|
|
464
|
+
|
|
465
|
+
const group = runReviewHostRelayReviewerGroup(requests, (request) => {
|
|
466
|
+
const index = requests.indexOf(request);
|
|
467
|
+
assert.notEqual(index, -1);
|
|
468
|
+
started.push(index);
|
|
469
|
+
if (index === 1) return new Promise<ReviewHostRelayPreparedResult>((_resolve, reject) => { rejectFirstProvider = reject; });
|
|
470
|
+
if (index === 2) return Promise.reject(new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "later provider error"));
|
|
471
|
+
if (index === 3) {
|
|
472
|
+
return new Promise<ReviewHostRelayPreparedResult>((resolve) => {
|
|
473
|
+
releaseLaterReviewer = () => resolve({
|
|
474
|
+
request,
|
|
475
|
+
promptByteLength: index + 1,
|
|
476
|
+
resultByteLength: index + 1,
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
return Promise.resolve({
|
|
481
|
+
request,
|
|
482
|
+
promptByteLength: index + 1,
|
|
483
|
+
resultByteLength: index + 1,
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
void group.then(() => { groupSettled = true; }, () => { groupSettled = true; });
|
|
487
|
+
|
|
488
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
489
|
+
assert.deepEqual(started, [0, 1, 2, 3], "one failed reviewer does not prevent later reviewers from starting");
|
|
490
|
+
rejectFirstProvider!(firstProviderError); await new Promise<void>((resolve) => setImmediate(resolve));
|
|
491
|
+
assert.equal(groupSettled, false, "the group must remain pending until the later reviewer settles");
|
|
492
|
+
releaseLaterReviewer!();
|
|
493
|
+
await assert.rejects(group, (error: unknown) => error === firstProviderError);
|
|
494
|
+
});
|
|
495
|
+
|
|
269
496
|
// ---------------------------------------------------------------------------
|
|
270
497
|
// Fail-closed legs — a typed transport error and NO submission.
|
|
271
498
|
// ---------------------------------------------------------------------------
|
|
@@ -305,12 +532,108 @@ test("empty pi stdout fails closed with a typed error and no submission", async
|
|
|
305
532
|
|
|
306
533
|
test("pi timeout fails closed with a typed error and no submission", async (t) => {
|
|
307
534
|
const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang" });
|
|
308
|
-
const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 300 })), REVIEW_HOST_RELAY_FAILURE.
|
|
535
|
+
const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 300 })), REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi");
|
|
309
536
|
assert.equal(error.timedOut, true);
|
|
310
537
|
assert.equal(readLog(fixture.logPath).length, 1);
|
|
311
538
|
assert.equal(existsSync(fixture.submitCapturePath), false);
|
|
312
539
|
});
|
|
313
540
|
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
// gentle-pi#367 — the reviewer bound is reachable from production, and a
|
|
543
|
+
// reviewer killed by it says so with both measurements.
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
test("the reviewer bound scales with materialized prompt bytes instead of one fixed number", () => {
|
|
547
|
+
const empty: NodeJS.ProcessEnv = {};
|
|
548
|
+
// A tiny prompt still gets the model-latency floor.
|
|
549
|
+
assert.equal(resolveReviewHostRelayPiTimeoutMs(0, empty), REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS);
|
|
550
|
+
assert.equal(resolveReviewHostRelayPiTimeoutMs(1, empty), REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS + 1);
|
|
551
|
+
// One mebibyte of prompt buys exactly one linear allowance.
|
|
552
|
+
assert.equal(
|
|
553
|
+
resolveReviewHostRelayPiTimeoutMs(1024 * 1024, empty),
|
|
554
|
+
REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS + REVIEW_HOST_RELAY_PI_TIMEOUT_PER_MEBIBYTE_MS,
|
|
555
|
+
);
|
|
556
|
+
// The reported field candidate: ~1.58 MB of prompt, whose reviewer needed
|
|
557
|
+
// 478s and was killed by the old fixed 600s bound. The derived bound must
|
|
558
|
+
// clear that measurement with real margin.
|
|
559
|
+
const reported = resolveReviewHostRelayPiTimeoutMs(1_580_000, empty);
|
|
560
|
+
assert.ok(reported > 600_000, `derived bound ${reported} must exceed the old fixed 600000ms bound`);
|
|
561
|
+
assert.ok(reported > 478_000 * 3, `derived bound ${reported} must keep real margin over the measured 478000ms reviewer run`);
|
|
562
|
+
// Never unbounded, however large the prompt gets.
|
|
563
|
+
assert.equal(resolveReviewHostRelayPiTimeoutMs(Number.MAX_SAFE_INTEGER, empty), REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("the reviewer bound honours the environment override and ignores malformed values", () => {
|
|
567
|
+
const bytes = 4 * 1024 * 1024;
|
|
568
|
+
assert.equal(resolveReviewHostRelayPiTimeoutMs(bytes, { [REVIEW_HOST_RELAY_PI_TIMEOUT_ENV]: "1234" }), 1234);
|
|
569
|
+
// The override is clamped by the same hard ceiling as the derived bound.
|
|
570
|
+
assert.equal(resolveReviewHostRelayPiTimeoutMs(bytes, { [REVIEW_HOST_RELAY_PI_TIMEOUT_ENV]: "999999999" }), REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS);
|
|
571
|
+
const derived = resolveReviewHostRelayPiTimeoutMs(bytes, {});
|
|
572
|
+
for (const malformed of ["", "0", "-1", "12.5", "abc", " 600000", "1e6"]) {
|
|
573
|
+
assert.equal(resolveReviewHostRelayPiTimeoutMs(bytes, { [REVIEW_HOST_RELAY_PI_TIMEOUT_ENV]: malformed }), derived, `malformed ${JSON.stringify(malformed)} must fall back to the derived bound`);
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("the production relay path resolves the reviewer bound from the environment, with no injected timeout", async (t) => {
|
|
578
|
+
// The regression: piTimeoutMs was reachable only through the test seam, so
|
|
579
|
+
// production always ran against the fixed 600s bound. This request injects
|
|
580
|
+
// no timeout at all — the override must reach the real spawn.
|
|
581
|
+
const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang", [REVIEW_HOST_RELAY_PI_TIMEOUT_ENV]: "300" });
|
|
582
|
+
const error = await rejectsWithRelayError(
|
|
583
|
+
runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: undefined })),
|
|
584
|
+
REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT,
|
|
585
|
+
"pi",
|
|
586
|
+
);
|
|
587
|
+
assert.equal(error.timeoutMs, 300);
|
|
588
|
+
assert.equal(error.timedOut, true);
|
|
589
|
+
assert.equal(readLog(fixture.logPath).length, 1, "no submission after a reviewer timeout");
|
|
590
|
+
assert.equal(existsSync(fixture.submitCapturePath), false);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("a relay Pi timeout keeps its typed mapping and timing evidence when opaque scratch cleanup also fails", async (t) => {
|
|
594
|
+
const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang", RELAY_FAKE_PI_BREAK_CLEANUP: "true" });
|
|
595
|
+
const scratchParent = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-relay-pi-primary-failure-")));
|
|
596
|
+
const originalTmpdir = process.env.TMPDIR;
|
|
597
|
+
process.env.TMPDIR = scratchParent;
|
|
598
|
+
try {
|
|
599
|
+
const error = await rejectsWithRelayError(
|
|
600
|
+
runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 300 })),
|
|
601
|
+
REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT,
|
|
602
|
+
"pi",
|
|
603
|
+
);
|
|
604
|
+
assert.equal(error.timedOut, true);
|
|
605
|
+
assert.equal(error.timeoutMs, 300);
|
|
606
|
+
assert.ok(error.elapsedMs !== null && error.elapsedMs >= 250);
|
|
607
|
+
} finally {
|
|
608
|
+
if (originalTmpdir === undefined) delete process.env.TMPDIR;
|
|
609
|
+
else process.env.TMPDIR = originalTmpdir;
|
|
610
|
+
chmodSync(scratchParent, 0o700);
|
|
611
|
+
rmSync(scratchParent, { recursive: true, force: true });
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("a killed reviewer reports elapsed, limit, and what to change instead of an opaque transport failure", async (t) => {
|
|
616
|
+
const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang" });
|
|
617
|
+
const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 300 })), REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi");
|
|
618
|
+
assert.equal(error.timeoutMs, 300);
|
|
619
|
+
assert.ok(error.elapsedMs !== null && error.elapsedMs >= 250, `elapsed ${error.elapsedMs} must record the real wall time`);
|
|
620
|
+
assert.ok(error.elapsedMs! < 10_000, "the reviewer was killed at the bound, not left to finish");
|
|
621
|
+
assert.match(error.message, /exceeded the relay bound/);
|
|
622
|
+
assert.match(error.message, new RegExp(String(error.elapsedMs)));
|
|
623
|
+
assert.match(error.message, /limit for a \d+-byte materialized prompt/);
|
|
624
|
+
assert.match(error.message, new RegExp(REVIEW_HOST_RELAY_PI_TIMEOUT_ENV));
|
|
625
|
+
assert.equal(error.mutationOutcome, "none");
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test("a crashed reviewer stays distinguishable from a killed one and still carries its measurements", async (t) => {
|
|
629
|
+
const fixture = harness(t, { RELAY_FAKE_PI_MODE: "fail" });
|
|
630
|
+
const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 30_000 })), REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi");
|
|
631
|
+
assert.equal(error.timedOut, false);
|
|
632
|
+
assert.equal(error.exitCode, 4);
|
|
633
|
+
assert.equal(error.timeoutMs, 30_000);
|
|
634
|
+
assert.ok(error.elapsedMs !== null && error.elapsedMs >= 0);
|
|
635
|
+
});
|
|
636
|
+
|
|
314
637
|
test("submission refusal is a typed error whose outcome is unknown pending STATUS", async (t) => {
|
|
315
638
|
const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "refuse" });
|
|
316
639
|
const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit");
|
|
@@ -319,6 +642,84 @@ test("submission refusal is a typed error whose outcome is unknown pending STATU
|
|
|
319
642
|
assert.equal(readLog(fixture.logPath).length, 2);
|
|
320
643
|
});
|
|
321
644
|
|
|
645
|
+
// gentle-pi#522 / #524: Go refuses a reviewer submission at admission with a
|
|
646
|
+
// typed [invalid_request] refusal and states that the lens slot was not
|
|
647
|
+
// consumed. That is a proven non-mutation, not an unknown outcome, and the
|
|
648
|
+
// refusal text is the only thing that tells the host what to change.
|
|
649
|
+
function admittingRequest(fixture: RelayHarness, reviewerOutput: Buffer) {
|
|
650
|
+
return relayRequest(fixture, {
|
|
651
|
+
environment: {
|
|
652
|
+
...fixture.environment,
|
|
653
|
+
RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
|
|
654
|
+
RELAY_FAKE_PI_OUTPUT_B64: reviewerOutput.toString("base64"),
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
test("a reviewer printing garbage is refused at admission as a proven non-mutation carrying Go's refusal", async (t) => {
|
|
660
|
+
const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "admit", RELAY_FAKE_EXPECTED_SUBJECT: `sha256:${"a".repeat(64)}` });
|
|
661
|
+
const error = await rejectsWithRelayError(
|
|
662
|
+
runReviewHostRelaySlot(admittingRequest(fixture, Buffer.from("not json at all", "utf8"))),
|
|
663
|
+
REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
|
|
664
|
+
"submit",
|
|
665
|
+
);
|
|
666
|
+
assert.equal(error.mutationOutcome, "none");
|
|
667
|
+
assert.equal(error.exitCode, 1);
|
|
668
|
+
assert.equal(error.timedOut, false);
|
|
669
|
+
assert.match(error.stderr, /reviewer payload contains no complete JSON object/);
|
|
670
|
+
assert.match(error.stderr, /\[invalid_request\]/);
|
|
671
|
+
assert.match(error.message, /reviewer payload contains no complete JSON object/);
|
|
672
|
+
assert.equal(readLog(fixture.logPath).length, 2, "the refusal must not be retried by the relay");
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test("a reviewer echoing a different subject is refused at admission as a proven non-mutation carrying Go's continuation", async (t) => {
|
|
676
|
+
const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "admit", RELAY_FAKE_EXPECTED_SUBJECT: `sha256:${"a".repeat(64)}` });
|
|
677
|
+
const wrongSubject = Buffer.from(JSON.stringify({ subject_hash: `sha256:${"0".repeat(64)}`, inspection: { status: "completed", paths: [] }, findings: [], evidence: ["x"] }), "utf8");
|
|
678
|
+
const error = await rejectsWithRelayError(
|
|
679
|
+
runReviewHostRelaySlot(admittingRequest(fixture, wrongSubject)),
|
|
680
|
+
REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
|
|
681
|
+
"submit",
|
|
682
|
+
);
|
|
683
|
+
assert.equal(error.mutationOutcome, "none");
|
|
684
|
+
assert.equal(error.exitCode, 1);
|
|
685
|
+
assert.match(error.stderr, /binding_mismatch/);
|
|
686
|
+
assert.match(error.stderr, /did not consume the lens slot/);
|
|
687
|
+
assert.match(error.stderr, /\[invalid_request\]/);
|
|
688
|
+
assert.match(error.message, /did not consume the lens slot/);
|
|
689
|
+
assert.equal(readLog(fixture.logPath).length, 2, "the refusal must not be retried by the relay");
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test("a submission the fake admits with the expected subject still completes", async (t) => {
|
|
693
|
+
const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "admit", RELAY_FAKE_EXPECTED_SUBJECT: `sha256:${"a".repeat(64)}` });
|
|
694
|
+
const result = await runReviewHostRelaySlot(admittingRequest(fixture, Buffer.from(JSON.stringify({ subject_hash: `sha256:${"a".repeat(64)}`, inspection: { status: "completed", paths: [] }, findings: [], evidence: ["x"] }), "utf8")));
|
|
695
|
+
assert.equal(JSON.parse(result.submission).admission_decision, "completed");
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test("submission refusal preserves its primary evidence when result staging cleanup also fails", async (t) => {
|
|
699
|
+
const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "refuse-cleanup-fail" });
|
|
700
|
+
const scratchParent = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-relay-primary-failure-")));
|
|
701
|
+
const originalTmpdir = process.env.TMPDIR;
|
|
702
|
+
process.env.TMPDIR = scratchParent;
|
|
703
|
+
try {
|
|
704
|
+
const error = await rejectsWithRelayError(
|
|
705
|
+
runReviewHostRelaySlot(relayRequest(fixture)),
|
|
706
|
+
REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
|
|
707
|
+
"submit",
|
|
708
|
+
);
|
|
709
|
+
assert.equal(error.exitCode, 1);
|
|
710
|
+
assert.equal(error.timedOut, false);
|
|
711
|
+
assert.equal(error.timeoutMs, 30_000);
|
|
712
|
+
assert.ok(error.elapsedMs !== null && error.elapsedMs >= 0);
|
|
713
|
+
assert.match(error.stderr, /capture binding does not match/);
|
|
714
|
+
assert.doesNotMatch(error.message, /staging cleanup/i);
|
|
715
|
+
} finally {
|
|
716
|
+
if (originalTmpdir === undefined) delete process.env.TMPDIR;
|
|
717
|
+
else process.env.TMPDIR = originalTmpdir;
|
|
718
|
+
chmodSync(scratchParent, 0o700);
|
|
719
|
+
rmSync(scratchParent, { recursive: true, force: true });
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
|
|
322
723
|
test("relay scratch and staging directories are removed after failures too", async (t) => {
|
|
323
724
|
const fixture = harness(t, { RELAY_FAKE_PI_MODE: "fail" });
|
|
324
725
|
await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi");
|
|
@@ -327,6 +728,27 @@ test("relay scratch and staging directories are removed after failures too", asy
|
|
|
327
728
|
assert.equal(existsSync(piCalls[0]!.cwd!), false);
|
|
328
729
|
});
|
|
329
730
|
|
|
731
|
+
test("a result staging cleanup failure remains a typed submit failure", async (t) => {
|
|
732
|
+
const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "cleanup-fail" });
|
|
733
|
+
const scratchParent = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-relay-cleanup-")));
|
|
734
|
+
const originalTmpdir = process.env.TMPDIR;
|
|
735
|
+
process.env.TMPDIR = scratchParent;
|
|
736
|
+
try {
|
|
737
|
+
const error = await rejectsWithRelayError(
|
|
738
|
+
runReviewHostRelaySlot(relayRequest(fixture)),
|
|
739
|
+
REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
|
|
740
|
+
"submit",
|
|
741
|
+
);
|
|
742
|
+
assert.equal(error.mutationOutcome, "unknown");
|
|
743
|
+
assert.match(error.message, /staging cleanup/i);
|
|
744
|
+
} finally {
|
|
745
|
+
if (originalTmpdir === undefined) delete process.env.TMPDIR;
|
|
746
|
+
else process.env.TMPDIR = originalTmpdir;
|
|
747
|
+
chmodSync(scratchParent, 0o700);
|
|
748
|
+
rmSync(scratchParent, { recursive: true, force: true });
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
|
|
330
752
|
// ---------------------------------------------------------------------------
|
|
331
753
|
// Capability detection — typed refusal classes, no version sniffing.
|
|
332
754
|
// ---------------------------------------------------------------------------
|