gentle-pi 2.3.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 +30 -4
- package/assets/agents/sdd-explore.md +1 -0
- package/assets/orchestrator-delegation.md +2 -1
- package/assets/orchestrator.md +6 -10
- 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/review-integration.md +1 -1
- package/extensions/gentle-ai.ts +530 -71
- package/lib/agent-home.ts +8 -0
- package/lib/native-review-cli.ts +15 -0
- package/lib/provider-contract-bundle.ts +88 -6
- package/lib/review-host-relay.ts +146 -60
- package/lib/review-integration-v2.ts +34 -5
- package/lib/sdd-preflight.ts +2 -2
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +15 -0
- package/runtime/review-integration-v2.mjs +34 -5
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/verify-package-files.mjs +14 -13
- package/skills/_shared/review-ledger-contract.md +6 -0
- package/skills/issue-creation/SKILL.md +53 -93
- 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 +1 -1
- package/tests/gentle-ai-installer.test.ts +46 -46
- package/tests/gentle-ai.test.ts +3 -2
- package/tests/issue-creation-skill.test.ts +103 -0
- package/tests/native-review-capability-contract.test.ts +18 -1
- package/tests/orchestrator-budget.test.ts +2 -2
- package/tests/orchestrator-rdd-ownership.test.ts +1 -1
- package/tests/package-manifest.test.ts +123 -9
- package/tests/provider-contract-bundle.test.ts +76 -0
- package/tests/provider-contract-mirror.test.ts +19 -0
- package/tests/review-agent-end-preflight.test.ts +408 -0
- package/tests/review-contract-prompt.test.ts +139 -0
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +211 -9
- package/tests/review-host-relay.test.ts +195 -7
- package/tests/review-integration-v2-forward.test.ts +47 -0
- package/tests/review-ledger-contract.test.ts +1 -1
- package/tests/runtime-harness.mjs +1 -0
- package/tests/writer-edit-surface-scope.test.ts +153 -17
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
- /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdtempSync, readFileSync, 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
8
|
import type { NativeReviewCli } 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 type
|
|
10
|
+
import { 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
|
|
14
14
|
// other capture form stays untouched.
|
|
15
15
|
|
|
16
16
|
const SHA = `sha256:${"1".repeat(64)}`;
|
|
17
|
+
const PHASE_REVISION = `sha256:${"3".repeat(64)}`;
|
|
17
18
|
const TREE = "2".repeat(40);
|
|
18
19
|
|
|
19
20
|
function repository(t: test.TestContext): string {
|
|
@@ -26,10 +27,10 @@ function repository(t: test.TestContext): string {
|
|
|
26
27
|
return cwd;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
function bindingArguments(lineageId: string, lens:
|
|
30
|
+
function bindingArguments(lineageId: string, lens: ReviewArtifactSubjectV2["lens"], order: number, revision = SHA): ReviewCollectInputV3["arguments"] {
|
|
30
31
|
return [
|
|
31
32
|
{ name: "lineage", value: lineageId, token: `--lineage=${lineageId}` },
|
|
32
|
-
{ name: "expected-revision", value:
|
|
33
|
+
{ name: "expected-revision", value: revision, token: `--expected-revision=${revision}` },
|
|
33
34
|
{ name: "target", value: SHA, token: `--target=${SHA}` },
|
|
34
35
|
{ name: "repository-context", value: `rctx1_${"e".repeat(64)}`, token: `--repository-context=rctx1_${"e".repeat(64)}` },
|
|
35
36
|
{ name: "lens", value: lens, token: `--lens=${lens}` },
|
|
@@ -38,8 +39,8 @@ function bindingArguments(lineageId: string, lens: string, order: number): Revie
|
|
|
38
39
|
];
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
function providerSubmission(lineageId: string, lens:
|
|
42
|
-
const bindingTokens = bindingArguments(lineageId, lens, order).map((argument) => argument.token!);
|
|
42
|
+
function providerSubmission(lineageId: string, lens: ReviewArtifactSubjectV2["lens"], order: number, revision = SHA): ReviewCaptureSubmissionV1 {
|
|
43
|
+
const bindingTokens = bindingArguments(lineageId, lens, order, revision).map((argument) => argument.token!);
|
|
43
44
|
return {
|
|
44
45
|
operationToken: "capture-result",
|
|
45
46
|
argumentTokens: [...bindingTokens, "--input={{value}}"],
|
|
@@ -47,22 +48,71 @@ function providerSubmission(lineageId: string, lens: string, order: number): Rev
|
|
|
47
48
|
};
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
function relayCollectInput(lineageId: string, lens:
|
|
51
|
+
function relayCollectInput(lineageId: string, lens: ReviewArtifactSubjectV2["lens"], order: number, materialize = true, submission: ReviewCaptureSubmissionV1 | "provider" | "absent" = "provider", revision = SHA): ReviewCollectInputV3 {
|
|
51
52
|
return {
|
|
52
53
|
name: "reviewer_result",
|
|
53
54
|
schema: "https://gentle-ai.dev/schema/review/reviewer/v1",
|
|
54
55
|
captureOperation: "review.capture-result",
|
|
55
56
|
arguments: [
|
|
56
|
-
...bindingArguments(lineageId, lens, order),
|
|
57
|
+
...bindingArguments(lineageId, lens, order, revision),
|
|
57
58
|
...(materialize ? [
|
|
58
59
|
{ name: "agent", value: "pi", token: "--agent=pi" },
|
|
59
60
|
{ name: "materialize", value: "true", token: "--materialize=true" },
|
|
60
61
|
] : []),
|
|
61
62
|
],
|
|
62
|
-
|
|
63
|
+
artifactSubject: {
|
|
64
|
+
schema: "gentle-ai.review-artifact-subject/v2", subjectHash: `sha256:${String(order).repeat(64)}`,
|
|
65
|
+
lineageId, authorityRevision: revision, targetIdentity: SHA, baseTree: TREE, candidateTree: TREE,
|
|
66
|
+
changedPathManifestSha256: SHA, lens, selectedOrder: order,
|
|
67
|
+
},
|
|
68
|
+
baseTree: TREE, candidateTree: TREE, changedPathManifest: [],
|
|
69
|
+
...(materialize && submission !== "absent" ? { submission: submission === "provider" ? providerSubmission(lineageId, lens, order, revision) : submission } : {}),
|
|
63
70
|
};
|
|
64
71
|
}
|
|
65
72
|
|
|
73
|
+
function capturedGroupedStatus(lineageId: string, authorityRevision = SHA): ReviewStatusV3 {
|
|
74
|
+
const raw = JSON.parse(readFileSync(new URL("./fixtures/devbinary/status-v5-capture-result-submission.captured.json", import.meta.url), "utf8")) as Record<string, unknown>;
|
|
75
|
+
// The captured binary's forward-only `finalize` action is not accepted by
|
|
76
|
+
// this checked-out decoder; retain the v5 capture and normalize only that
|
|
77
|
+
// unrelated routing action before decoding its provider-owned group.
|
|
78
|
+
raw.action = "stop";
|
|
79
|
+
const authority = raw.authority as Record<string, unknown>, repositoryContext = raw.repository_context as Record<string, unknown>;
|
|
80
|
+
authority.lineage_id = lineageId;
|
|
81
|
+
authority.revision = authorityRevision;
|
|
82
|
+
const phaseRevision = String(repositoryContext.revision), targetIdentity = String(raw.target_identity);
|
|
83
|
+
const source = ((((raw.next_transition as Record<string, unknown>).collect as Record<string, unknown>).inputs as Array<Record<string, unknown>>)[0]!);
|
|
84
|
+
const lenses = ["review-risk", "review-resilience", "review-readability", "review-reliability"];
|
|
85
|
+
((raw.next_transition as Record<string, unknown>).collect as Record<string, unknown>).inputs = lenses.map((lens, order) => {
|
|
86
|
+
const input = JSON.parse(JSON.stringify(source)) as Record<string, unknown>;
|
|
87
|
+
const subjectHash = `sha256:${String(order + 1).repeat(64)}`;
|
|
88
|
+
const arguments_ = input.arguments as Array<Record<string, unknown>>;
|
|
89
|
+
for (const argument of arguments_) {
|
|
90
|
+
const value = argument.name === "lineage" ? lineageId
|
|
91
|
+
: argument.name === "lens" ? lens
|
|
92
|
+
: argument.name === "order" ? String(order)
|
|
93
|
+
: argument.name === "subject-hash" ? subjectHash
|
|
94
|
+
: argument.value;
|
|
95
|
+
argument.value = value;
|
|
96
|
+
argument.token = `--${String(argument.name)}=${String(value)}`;
|
|
97
|
+
}
|
|
98
|
+
const submission = input.submission as Record<string, unknown>;
|
|
99
|
+
submission.argument_tokens = [...arguments_.filter((argument) => argument.name !== "agent" && argument.name !== "materialize").map((argument) => argument.token), "--input={{value}}"];
|
|
100
|
+
const artifactSubject = input.artifact_subject as Record<string, unknown>;
|
|
101
|
+
artifactSubject.subject_hash = subjectHash;
|
|
102
|
+
artifactSubject.lineage_id = lineageId;
|
|
103
|
+
artifactSubject.authority_revision = phaseRevision;
|
|
104
|
+
artifactSubject.target_identity = targetIdentity;
|
|
105
|
+
artifactSubject.lens = lens;
|
|
106
|
+
artifactSubject.selected_order = order;
|
|
107
|
+
return input;
|
|
108
|
+
});
|
|
109
|
+
return decodeReviewStatusV3(raw);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function replaceArgument(input: ReviewCollectInputV3, name: string, value: string): void {
|
|
113
|
+
input.arguments = input.arguments.map((argument) => argument.name === name ? { ...argument, value, token: `--${name}=${value}` } : argument);
|
|
114
|
+
}
|
|
115
|
+
|
|
66
116
|
function finalizeStatus(lineageId: string, inputs?: readonly ReviewCollectInputV3[]): ReviewStatusV3 {
|
|
67
117
|
return {
|
|
68
118
|
contract: "gentle-ai.review-integration/v2",
|
|
@@ -86,6 +136,7 @@ function finalizeStatus(lineageId: string, inputs?: readonly ReviewCollectInputV
|
|
|
86
136
|
currentSnapshotIdentity: SHA,
|
|
87
137
|
},
|
|
88
138
|
candidates: [],
|
|
139
|
+
repositoryContext: { capability: "review.opaque_repository_context", handle: `rctx1_${"e".repeat(64)}`, revision: SHA, targetIdentity: SHA },
|
|
89
140
|
...(inputs === undefined ? {} : { nextTransition: { kind: "collect", reasonCode: "reviewer_results_required", collect: { inputs: [...inputs] } } }),
|
|
90
141
|
raw: { schema: "gentle-ai.review-integration.status/v3", action: "stop", lineage_id: lineageId },
|
|
91
142
|
} as unknown as ReviewStatusV3;
|
|
@@ -166,6 +217,157 @@ test("Pi-authored review documents are rejected at the capture input boundary",
|
|
|
166
217
|
assert.equal(relayCalls, 0);
|
|
167
218
|
});
|
|
168
219
|
|
|
220
|
+
function groupInputs(lineageId: string, revision = SHA): ReviewCollectInputV3[] { return ["review-risk", "review-resilience", "review-readability", "review-reliability"].map((lens, order) => relayCollectInput(lineageId, lens, order, true, "provider", revision)); }
|
|
221
|
+
|
|
222
|
+
async function runCaptureGroup(cwd: string, harness: RoutingHarness, lineageId: string, inputs: readonly ReviewCollectInputV3[], reviewerRunAcknowledged = true): Promise<Record<string, unknown>> { return await __testing.executeReviewCaptureGroupOperation({ lineageId, collectBindings: inputs.map((input) => JSON.stringify(input)), reviewerRunAcknowledged }, cwd, harness.native) as Record<string, unknown>; }
|
|
223
|
+
|
|
224
|
+
function prepared(request: ReviewHostRelayRequest) { return { request, promptByteLength: 64, resultByteLength: 32 }; }
|
|
225
|
+
|
|
226
|
+
test("grouped capture forecasts once, reaches a four-reviewer barrier, and reconciles unclosed submissions", async (t) => {
|
|
227
|
+
t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
|
|
228
|
+
const cwd = repository(t), lineageId = "relay-group", inputs = groupInputs(lineageId), lenses = inputs.map((input) => input.arguments.find((argument) => argument.name === "lens")!.value);
|
|
229
|
+
const harness = nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), ...inputs.map((_input, index) => finalizeStatus(lineageId, index === 1 ? [...inputs.slice(index), { name: "unrelated", schema: "example", captureOperation: "external.example", arguments: [] }] : inputs.slice(index))), finalizeStatus(lineageId)]);
|
|
230
|
+
let ready!: () => void;
|
|
231
|
+
const allStarted = new Promise<void>((resolve) => { ready = resolve; });
|
|
232
|
+
const releases = new Map<string, () => void>(), completed: string[] = [], submitted: string[] = [];
|
|
233
|
+
const reviewerGroup = async (requests: readonly ReviewHostRelayRequest[]) => await Promise.all(requests.map(async (request) => {
|
|
234
|
+
const lens = request.captureArgumentTokens.find((token) => token.startsWith("--lens="))!.slice(7);
|
|
235
|
+
await new Promise<void>((resolve) => { releases.set(lens, resolve); if (releases.size === requests.length) ready(); });
|
|
236
|
+
completed.push(lens); return prepared(request);
|
|
237
|
+
}));
|
|
238
|
+
__testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async (result) => {
|
|
239
|
+
submitted.push(result.request.captureArgumentTokens.find((token) => token.startsWith("--lens="))!.slice(7));
|
|
240
|
+
return { promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submission: "{}" };
|
|
241
|
+
});
|
|
242
|
+
const forecast = await runCaptureGroup(cwd, harness, lineageId, inputs, false);
|
|
243
|
+
assert.deepEqual(forecast.cost_forecast, { transport: "pi_host_relay", model_runs: 4, lenses });
|
|
244
|
+
const run = runCaptureGroup(cwd, harness, lineageId, inputs);
|
|
245
|
+
await allStarted;
|
|
246
|
+
assert.deepEqual([...releases.keys()], lenses, "all reviewers start before the group waits");
|
|
247
|
+
for (const lens of [...lenses].reverse()) releases.get(lens!)!();
|
|
248
|
+
const result = await run;
|
|
249
|
+
assert.deepEqual(completed, [...lenses].reverse(), "reviewer completion order is not submission order");
|
|
250
|
+
assert.deepEqual(submitted, lenses);
|
|
251
|
+
assert.deepEqual({ outcome: result.outcome, statusCalls: harness.statusCalls.length, providerAction: result.provider_action }, { outcome: "native-reviewer-group-status-reconciled", statusCalls: 7, providerAction: "stop" });
|
|
252
|
+
assert.equal(result.next_transition, undefined);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("captured v5 STATUS accepts capture-phase Pn when authority has advanced to Rn at the forecast boundary", async (t) => {
|
|
256
|
+
t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
|
|
257
|
+
const cwd = repository(t), lineageId = "relay-group-phase-revision", status = capturedGroupedStatus(lineageId, SHA), inputs = status.nextTransition!.collect!.inputs!;
|
|
258
|
+
let launches = 0;
|
|
259
|
+
__testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); });
|
|
260
|
+
|
|
261
|
+
assert.notEqual(status.authority!.revision, status.repositoryContext!.revision, "captured v5 group keeps capture-phase Pn distinct from authority Rn");
|
|
262
|
+
const forecast = await runCaptureGroup(cwd, nativeHarness([status]), lineageId, inputs, false);
|
|
263
|
+
|
|
264
|
+
assert.equal(forecast.outcome, "reviewer-model-run-forecast");
|
|
265
|
+
assert.deepEqual(forecast.cost_forecast, { transport: "pi_host_relay", model_runs: 4, lenses: ["review-risk", "review-resilience", "review-readability", "review-reliability"] });
|
|
266
|
+
assert.equal(launches, 0, "decoder-valid capture-phase Pn must pass admission before any reviewer model launch");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("group rejects STATUS repository target mismatch and per-slot revision, context, or target drift before launch", async (t) => {
|
|
270
|
+
t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
|
|
271
|
+
const cwd = repository(t), lineageId = "relay-group-slot-consistency";
|
|
272
|
+
const repositoryTargetMismatch = capturedGroupedStatus(lineageId, SHA);
|
|
273
|
+
repositoryTargetMismatch.repositoryContext!.targetIdentity = SHA;
|
|
274
|
+
const revisionMismatch = capturedGroupedStatus(lineageId, SHA);
|
|
275
|
+
replaceArgument(revisionMismatch.nextTransition!.collect!.inputs![1]!, "expected-revision", SHA);
|
|
276
|
+
const contextMismatch = capturedGroupedStatus(lineageId, SHA);
|
|
277
|
+
replaceArgument(contextMismatch.nextTransition!.collect!.inputs![1]!, "repository-context", `rctx1_${"f".repeat(64)}`);
|
|
278
|
+
const targetMismatch = capturedGroupedStatus(lineageId, SHA);
|
|
279
|
+
replaceArgument(targetMismatch.nextTransition!.collect!.inputs![1]!, "target", SHA);
|
|
280
|
+
let launches = 0;
|
|
281
|
+
__testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); });
|
|
282
|
+
|
|
283
|
+
// Each call forwards this STATUS's own current set, so rejection proves an
|
|
284
|
+
// internal STATUS guard rather than stale or caller/canonical mismatch.
|
|
285
|
+
const repositoryTargetResult = await runCaptureGroup(cwd, nativeHarness([repositoryTargetMismatch]), lineageId, repositoryTargetMismatch.nextTransition!.collect!.inputs!);
|
|
286
|
+
assert.deepEqual({ outcome: repositoryTargetResult.outcome, reason: repositoryTargetResult.reason }, {
|
|
287
|
+
outcome: "capture-group-rejected",
|
|
288
|
+
reason: "current STATUS repository context does not match the reviewer group binding",
|
|
289
|
+
});
|
|
290
|
+
const revisionResult = await runCaptureGroup(cwd, nativeHarness([revisionMismatch]), lineageId, revisionMismatch.nextTransition!.collect!.inputs!);
|
|
291
|
+
assert.deepEqual({ outcome: revisionResult.outcome, reason: revisionResult.reason }, {
|
|
292
|
+
outcome: "capture-group-rejected",
|
|
293
|
+
reason: "current STATUS carries an incomplete or mismatched materialize reviewer binding",
|
|
294
|
+
});
|
|
295
|
+
const contextResult = await runCaptureGroup(cwd, nativeHarness([contextMismatch]), lineageId, contextMismatch.nextTransition!.collect!.inputs!);
|
|
296
|
+
assert.deepEqual({ outcome: contextResult.outcome, reason: contextResult.reason }, {
|
|
297
|
+
outcome: "capture-group-rejected",
|
|
298
|
+
reason: "current STATUS carries an incomplete or mismatched materialize reviewer binding",
|
|
299
|
+
});
|
|
300
|
+
const targetResult = await runCaptureGroup(cwd, nativeHarness([targetMismatch]), lineageId, targetMismatch.nextTransition!.collect!.inputs!);
|
|
301
|
+
assert.deepEqual({ outcome: targetResult.outcome, reason: targetResult.reason }, {
|
|
302
|
+
outcome: "capture-group-rejected",
|
|
303
|
+
reason: "current STATUS carries an incomplete or mismatched materialize reviewer binding",
|
|
304
|
+
});
|
|
305
|
+
assert.equal(launches, 0, "current STATUS consistency checks reject before reviewer model launch");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("group rejects absent or mismatched current repository context before launch", async (t) => {
|
|
309
|
+
t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
|
|
310
|
+
const cwd = repository(t), lineageId = "relay-group-context-reject", inputs = groupInputs(lineageId, PHASE_REVISION);
|
|
311
|
+
const revisionMismatch = finalizeStatus(lineageId, inputs);
|
|
312
|
+
const handleMismatch = finalizeStatus(lineageId, inputs);
|
|
313
|
+
const absent = finalizeStatus(lineageId, inputs);
|
|
314
|
+
handleMismatch.repositoryContext = { capability: "review.opaque_repository_context", handle: `rctx1_${"f".repeat(64)}`, revision: PHASE_REVISION, targetIdentity: SHA };
|
|
315
|
+
delete absent.repositoryContext;
|
|
316
|
+
let launches = 0;
|
|
317
|
+
__testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); });
|
|
318
|
+
|
|
319
|
+
for (const status of [revisionMismatch, handleMismatch, absent]) {
|
|
320
|
+
const result = await runCaptureGroup(cwd, nativeHarness([status]), lineageId, inputs);
|
|
321
|
+
assert.equal(result.outcome, "capture-group-rejected");
|
|
322
|
+
}
|
|
323
|
+
assert.equal(launches, 0, "repository-context validation must reject before reviewer model launch");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("group rejects partial, duplicate, reordered, stale, mixed, and late-invalid bindings before launch", async (t) => {
|
|
327
|
+
t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
|
|
328
|
+
const cwd = repository(t), lineageId = "relay-group-reject", inputs = groupInputs(lineageId);
|
|
329
|
+
const stale = JSON.parse(JSON.stringify(inputs[3]!)) as ReviewCollectInputV3;
|
|
330
|
+
stale.arguments = [...stale.arguments, { name: "replacement", value: "stale", token: "--replacement=stale" }];
|
|
331
|
+
const mixed = [...inputs.slice(0, 3), relayCollectInput(lineageId, "review-reliability", 3, false)];
|
|
332
|
+
const malformed = JSON.parse(JSON.stringify(inputs)) as ReviewCollectInputV3[];
|
|
333
|
+
malformed[3]!.submission!.argumentTokens = ["--input={{value}}"];
|
|
334
|
+
const cases = [
|
|
335
|
+
{ bindings: inputs.slice(0, 3), current: inputs }, { bindings: [...inputs].reverse(), current: inputs },
|
|
336
|
+
{ bindings: [...inputs.slice(0, 3), inputs[2]!], current: inputs }, { bindings: [...inputs.slice(0, 3), stale], current: inputs },
|
|
337
|
+
{ bindings: mixed, current: mixed }, { bindings: malformed, current: malformed },
|
|
338
|
+
];
|
|
339
|
+
let launches = 0;
|
|
340
|
+
__testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); });
|
|
341
|
+
for (const entry of cases) {
|
|
342
|
+
const result = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, entry.current)]), lineageId, entry.bindings);
|
|
343
|
+
assert.equal(result.outcome, "capture-group-rejected");
|
|
344
|
+
}
|
|
345
|
+
assert.equal(launches, 0);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("group preserves earlier admission when a later STATUS drifts, and stops on closure or unknown outcome", async (t) => {
|
|
349
|
+
t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
|
|
350
|
+
const cwd = repository(t), lineageId = "relay-group-stop", inputs = groupInputs(lineageId);
|
|
351
|
+
const reviewerGroup = async (requests: readonly ReviewHostRelayRequest[]) => requests.map(prepared);
|
|
352
|
+
let submissions = 0;
|
|
353
|
+
__testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async (result) => { submissions += 1; return { promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submission: "{}" }; });
|
|
354
|
+
const staleStatus = finalizeStatus(lineageId, inputs), stale = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), staleStatus]), lineageId, inputs);
|
|
355
|
+
assert.deepEqual({ outcome: stale.outcome, submissions, mutation: stale.mutation_performed, mutationOutcome: stale.mutation_outcome }, { outcome: "capture-group-authority-drift", submissions: 1, mutation: true, mutationOutcome: "partial" });
|
|
356
|
+
assert.equal(stale.reconciliation, staleStatus.raw);
|
|
357
|
+
submissions = 0;
|
|
358
|
+
const drift = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs.slice(2))]), lineageId, inputs);
|
|
359
|
+
assert.deepEqual({ outcome: drift.outcome, submissions, mutation: drift.mutation_performed, mutationOutcome: drift.mutation_outcome }, { outcome: "capture-group-authority-drift", submissions: 1, mutation: true, mutationOutcome: "partial" });
|
|
360
|
+
assert.deepEqual(((drift.host_relay as { reviewers: Array<{ lens: string }> }).reviewers).map((reviewer) => reviewer.lens), ["review-risk"]);
|
|
361
|
+
submissions = 0;
|
|
362
|
+
__testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async (result) => { submissions += 1; return { promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submission: JSON.stringify({ schema: "gentle-ai.review-last-event-closure/v1", operation: "review/capture-result", lineage_id: lineageId, state: "approved", store_revision: SHA, action: "native last event closed the review" }) }; });
|
|
363
|
+
const terminal = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs)]), lineageId, inputs);
|
|
364
|
+
assert.deepEqual({ status: terminal.status, submissions }, { status: "closed", submissions: 1 });
|
|
365
|
+
submissions = 0;
|
|
366
|
+
__testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async () => { submissions += 1; throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", "unknown", { timedOut: true }); });
|
|
367
|
+
const unknown = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs)]), lineageId, inputs);
|
|
368
|
+
assert.deepEqual({ status: unknown.status, submissions }, { status: "reconciled", submissions: 1 });
|
|
369
|
+
});
|
|
370
|
+
|
|
169
371
|
test("an old binary reports the relay as unavailable without touching existing behavior", async (t) => {
|
|
170
372
|
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
171
373
|
const cwd = repository(t);
|
|
@@ -18,7 +18,12 @@ import {
|
|
|
18
18
|
resolveReviewHostRelayPiTimeoutMs,
|
|
19
19
|
resolveReviewHostRelaySubmission,
|
|
20
20
|
reviewHostRelaySlots,
|
|
21
|
+
prepareReviewHostRelaySlot,
|
|
22
|
+
runReviewHostRelayReviewerGroup,
|
|
21
23
|
runReviewHostRelaySlot,
|
|
24
|
+
submitReviewHostRelayPreparedResult,
|
|
25
|
+
type ReviewHostRelayPreparedResult,
|
|
26
|
+
type ReviewHostRelayRequest,
|
|
22
27
|
} from "../lib/review-host-relay.ts";
|
|
23
28
|
import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "../lib/review-relay-contract.ts";
|
|
24
29
|
import { decodeReviewNextTransitionV3, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3 } from "../lib/review-integration-v2.ts";
|
|
@@ -47,6 +52,8 @@ const inputToken = argv.find((token) => token === "--input" || token.startsWith(
|
|
|
47
52
|
if (inputToken !== undefined) {
|
|
48
53
|
const mode = process.env.RELAY_FAKE_SUBMIT_MODE || "ok";
|
|
49
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);
|
|
50
57
|
if (mode === "ok" || mode === "cleanup-fail") {
|
|
51
58
|
const bytes = fs.readFileSync(inputPath);
|
|
52
59
|
if (process.env.RELAY_FAKE_SUBMIT_CAPTURE) fs.writeFileSync(process.env.RELAY_FAKE_SUBMIT_CAPTURE, bytes);
|
|
@@ -95,13 +102,32 @@ process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
|
95
102
|
process.stdin.on("end", () => {
|
|
96
103
|
const stdin = Buffer.concat(chunks);
|
|
97
104
|
if (process.env.RELAY_FAKE_PI_STDIN_CAPTURE) fs.writeFileSync(process.env.RELAY_FAKE_PI_STDIN_CAPTURE, stdin);
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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();
|
|
105
131
|
});
|
|
106
132
|
`;
|
|
107
133
|
|
|
@@ -151,6 +177,14 @@ function readLog(path: string): Array<{ argv: string[]; contract: string | null;
|
|
|
151
177
|
return readFileSync(path, "utf8").split("\n").filter((line) => line.length > 0).map((line) => JSON.parse(line));
|
|
152
178
|
}
|
|
153
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
|
+
|
|
154
188
|
const BINDING_TOKENS = Object.freeze([
|
|
155
189
|
"--lineage=review-1d5aadacc600e167",
|
|
156
190
|
`--expected-revision=sha256:${"c".repeat(64)}`,
|
|
@@ -161,6 +195,7 @@ const BINDING_TOKENS = Object.freeze([
|
|
|
161
195
|
`--subject-hash=sha256:${"a".repeat(64)}`,
|
|
162
196
|
]);
|
|
163
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;
|
|
164
199
|
|
|
165
200
|
// The provider-owned completing form: exact operation and argument tokens
|
|
166
201
|
// with one declared {{value}} substitution slot for the artifact path.
|
|
@@ -200,6 +235,20 @@ function relayRequest(fixture: RelayHarness, overrides: Record<string, unknown>
|
|
|
200
235
|
};
|
|
201
236
|
}
|
|
202
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
|
+
|
|
203
252
|
async function rejectsWithRelayError(promise: Promise<unknown>, kind: string, stage: string): Promise<ReviewHostRelayError> {
|
|
204
253
|
let caught: ReviewHostRelayError | undefined;
|
|
205
254
|
await assert.rejects(promise, (error: unknown) => {
|
|
@@ -305,6 +354,145 @@ test("the pi lockdown argv is pinned exactly with no model or provider selection
|
|
|
305
354
|
assert.equal(piCalls[0]!.argv.some((token) => token.startsWith("--model") || token.startsWith("--provider") || token.startsWith("--profile")), false);
|
|
306
355
|
});
|
|
307
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
|
+
|
|
308
496
|
// ---------------------------------------------------------------------------
|
|
309
497
|
// Fail-closed legs — a typed transport error and NO submission.
|
|
310
498
|
// ---------------------------------------------------------------------------
|
|
@@ -393,6 +393,53 @@ test("capabilities/v2.3 retains status/v5 while v2.4 requires status/v6", () =>
|
|
|
393
393
|
assert.throws(() => decodeReviewCapabilitiesV2(v22Retired, CAPTURED_DIGEST), /schema/);
|
|
394
394
|
});
|
|
395
395
|
|
|
396
|
+
test("capabilities/v2.5 negotiates the v2.6.0 advertisement and status/v7 decodes the eligible untracked inventory", () => {
|
|
397
|
+
// gentle-ai v2.6.0 advertises capabilities/v2.5: the v2.4 surface plus
|
|
398
|
+
// status/v7, with status/v6 still advertised for compatibility.
|
|
399
|
+
const v25 = clone(fixture(DEV_FIXTURES, "capabilities-v2.2.captured.json") as JsonObject);
|
|
400
|
+
v25.schema = "gentle-ai.review-integration.capabilities/v2.5";
|
|
401
|
+
(v25.protocol as JsonObject).minor = 5;
|
|
402
|
+
const features = v25.features as JsonObject;
|
|
403
|
+
features.mandatory = (features.mandatory as JsonObject[]).filter((feature) =>
|
|
404
|
+
!["exact_receipt_replay", "five_delivery_gates", "sdd_receipt_binding"].includes(feature.name as string));
|
|
405
|
+
v25.schemas = [
|
|
406
|
+
...(v25.schemas as string[]).map((schema) => schema
|
|
407
|
+
.replace("capabilities/v2.2", "capabilities/v2.5")
|
|
408
|
+
.replace("start/v3", "start/v4")
|
|
409
|
+
.replace("status/v5", "status/v6")),
|
|
410
|
+
"gentle-ai.review-intended-untracked-selection/v1",
|
|
411
|
+
"gentle-ai.review-integration.status/v7",
|
|
412
|
+
];
|
|
413
|
+
const decoded = decodeReviewCapabilitiesV2(v25, CAPTURED_DIGEST);
|
|
414
|
+
assert.equal(decoded.schemas.has("gentle-ai.review-integration.status/v6"), true);
|
|
415
|
+
// The negotiated set is the v2.5 requirement floor; status/v7 is additive
|
|
416
|
+
// and never required, so an advertisement without it still negotiates.
|
|
417
|
+
assert.equal(decoded.schemas.has("gentle-ai.review-integration.status/v7"), false);
|
|
418
|
+
const withoutStatusV7 = clone(v25);
|
|
419
|
+
withoutStatusV7.schemas = (withoutStatusV7.schemas as string[]).filter((schema) => schema !== "gentle-ai.review-integration.status/v7");
|
|
420
|
+
assert.equal(decodeReviewCapabilitiesV2(withoutStatusV7, CAPTURED_DIGEST).schemas.has("gentle-ai.review-integration.status/v6"), true);
|
|
421
|
+
|
|
422
|
+
// status/v6 stays required: v7 is an additive extension, not a replacement.
|
|
423
|
+
const missingStatusV6 = clone(v25);
|
|
424
|
+
missingStatusV6.schemas = (missingStatusV6.schemas as string[]).filter((schema) => schema !== "gentle-ai.review-integration.status/v6");
|
|
425
|
+
assert.throws(() => decodeReviewCapabilitiesV2(missingStatusV6, CAPTURED_DIGEST), /status\/v6/);
|
|
426
|
+
|
|
427
|
+
// status/v7 adds only the optional top-level eligible untracked inventory digest.
|
|
428
|
+
const v7 = initialIntendedUntrackedStatusV6();
|
|
429
|
+
v7.schema = "gentle-ai.review-integration.status/v7";
|
|
430
|
+
v7.eligible_untracked_inventory = sha("e");
|
|
431
|
+
assert.equal(decodeReviewStatusV3(v7).eligibleUntrackedInventory, sha("e"));
|
|
432
|
+
|
|
433
|
+
const v7Staged = initialIntendedUntrackedStatusV6();
|
|
434
|
+
v7Staged.schema = "gentle-ai.review-integration.status/v7";
|
|
435
|
+
assert.equal(decodeReviewStatusV3(v7Staged).eligibleUntrackedInventory, undefined);
|
|
436
|
+
|
|
437
|
+
// The digest is v7-only: a v6 envelope carrying it is still rejected.
|
|
438
|
+
const v6WithDigest = initialIntendedUntrackedStatusV6();
|
|
439
|
+
v6WithDigest.eligible_untracked_inventory = sha("e");
|
|
440
|
+
assert.throws(() => decodeReviewStatusV3(v6WithDigest), /eligible_untracked_inventory/);
|
|
441
|
+
});
|
|
442
|
+
|
|
396
443
|
test("status/v6 decodes and enforces the intended-untracked selection submission", () => {
|
|
397
444
|
const decoded = decodeReviewStatusV3(initialIntendedUntrackedStatusV6());
|
|
398
445
|
const input = decoded.nextTransition?.collect?.inputs[0];
|
|
@@ -311,7 +311,7 @@ test("Judgment Day skill and prompts preserve bounded fix and re-judgment author
|
|
|
311
311
|
});
|
|
312
312
|
|
|
313
313
|
test("orchestrator, injected skill, and README defer RDD lifecycle ownership to Gentle AI", () => {
|
|
314
|
-
const boundary = "
|
|
314
|
+
const boundary = "This package injects the mirrored provider-bundle review execution contract into this session's system prompt at start; Gentle AI writes nothing into the Pi system prompt, and this package owns everything else here. Absent that mirrored contract, this package invents no lifecycle instructions.";
|
|
315
315
|
const orchestrator = union(ORCHESTRATOR);
|
|
316
316
|
assert.ok(orchestrator.includes(boundary), "orchestrator must carry the sole static ownership boundary");
|
|
317
317
|
|
|
@@ -314,6 +314,7 @@ async function run() {
|
|
|
314
314
|
const capture = lastEventPi.tools.get("gentle_review_capture");
|
|
315
315
|
assert.ok(controller, "runtime must register the public status controller");
|
|
316
316
|
assert.ok(capture, "runtime must register the one-slot capture tool");
|
|
317
|
+
assert.equal(lastEventPi.tools.get("gentle_review_capture_group")?.executionMode, "sequential", "runtime must register grouped capture with bounded foreground concurrency");
|
|
317
318
|
assert.equal(controller.parameters.properties.operation.enum.includes("finalize"), false);
|
|
318
319
|
assert.equal(controller.parameters.properties.operation.enum.includes("validate"), false);
|
|
319
320
|
|