gentle-pi 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -80
- package/assets/agents/review-readability.md +0 -2
- package/assets/agents/review-reliability.md +0 -2
- package/assets/agents/review-resilience.md +0 -2
- package/assets/agents/review-risk.md +0 -2
- package/assets/agents/sdd-apply.md +5 -3
- package/assets/agents/sdd-proposal.md +2 -0
- package/assets/agents/sdd-research.md +54 -0
- package/assets/agents/sdd-status.md +4 -4
- package/assets/agents/sdd-tasks.md +2 -3
- package/assets/agents/sdd-verify.md +21 -1
- package/assets/chains/sdd-full.chain.md +1 -1
- package/assets/chains/sdd-verify.chain.md +1 -1
- package/assets/orchestrator-delegation.md +54 -248
- package/assets/orchestrator-memory.md +2 -0
- package/assets/orchestrator.md +22 -38
- package/assets/sdd-orchestrator-workflow.md +30 -37
- package/assets/support/sdd-status-contract.md +7 -7
- package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
- package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
- package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
- package/docs/native-authority-architecture.md +9 -11
- package/docs/review-integration.md +27 -373
- package/extensions/ask-user-choice.ts +151 -0
- package/extensions/gentle-ai.ts +1778 -3673
- package/extensions/quiet-tools.ts +515 -32
- package/extensions/sdd-init.ts +4 -8
- package/lib/gentle-ai-renderer.ts +70 -0
- package/lib/model-routing-authority.ts +133 -0
- package/lib/native-review-cli.ts +372 -940
- package/lib/opaque-pi-reviewer-adapter.ts +284 -0
- package/lib/review-candidate-view.ts +341 -132
- package/lib/review-host-relay.ts +210 -68
- package/lib/review-integration-v2.ts +839 -307
- package/lib/review-last-event-controller.ts +35 -0
- package/lib/sdd-preflight.ts +177 -66
- package/lib/sdd-status.ts +66 -111
- package/lib/terminal-theme.ts +1 -1
- package/package.json +83 -82
- package/runtime/gentle-ai-binary.mjs +1 -1
- package/runtime/native-review-cli.mjs +322 -890
- package/runtime/review-integration-v2.mjs +789 -257
- package/runtime/review-relay-contract.mjs +1 -1
- package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
- package/scripts/gentle-ai-installer.mjs +75 -22
- package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
- package/scripts/test-packed-runner.mjs +4 -7
- package/scripts/verify-package-files.mjs +12 -13
- package/skills/_shared/review-ledger-contract.md +8 -14
- package/skills/chained-pr/SKILL.md +3 -0
- package/skills/cognitive-doc-design/SKILL.md +1 -1
- package/skills/comment-writer/SKILL.md +1 -1
- package/skills/gentle-ai/SKILL.md +7 -74
- package/skills/judgment-day/SKILL.md +5 -7
- package/skills/rdd-defect-workflow/SKILL.md +3 -3
- package/skills/release/SKILL.md +3 -3
- package/skills/skill-registry/SKILL.md +1 -1
- package/skills/work-unit-commits/SKILL.md +3 -1
- package/tests/artifact-language.test.ts +24 -11
- package/tests/ask-user-choice.test.ts +264 -0
- package/tests/codegraph-tools.test.ts +3 -3
- package/tests/crosslane/cross-lane.mjs +15 -1168
- package/tests/delegated-key-learnings-contract.test.ts +8 -6
- package/tests/devbinary/native-review-parity.devtest.ts +167 -250
- package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
- package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
- package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
- package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
- package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
- package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
- package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
- package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
- package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
- package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
- package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
- package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
- package/tests/gentle-ai-binary.test.ts +83 -13
- package/tests/gentle-ai-dev-binary.test.ts +51 -228
- package/tests/gentle-ai-installer.test.ts +137 -47
- package/tests/gentle-ai.test.ts +820 -131
- package/tests/gentle-theme.test.ts +133 -0
- package/tests/maintainer/provider-relay.maintest.ts +340 -4
- package/tests/model-routing-authority.test.ts +257 -0
- package/tests/native-review-capability-contract.test.ts +28 -1
- package/tests/native-review-cli.test.ts +586 -962
- package/tests/native-review-consent.test.ts +150 -66
- package/tests/native-review-parity-runtime.test.ts +80 -359
- package/tests/native-review-parity.test.ts +674 -925
- package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
- package/tests/orchestrator-budget.test.ts +132 -88
- package/tests/orchestrator-rdd-ownership.test.ts +103 -0
- package/tests/package-manifest.test.ts +37 -35
- package/tests/provider-defect-handoff.test.ts +15 -118
- package/tests/quiet-tool-rendering.test.ts +1055 -28
- package/tests/review-authority-recovery-docs.test.ts +1 -2
- package/tests/review-candidate-view.test.ts +409 -3
- package/tests/review-compact-contract.test.ts +4 -30
- package/tests/review-controller-lock-status.test.ts +2 -2
- package/tests/review-controller-native-recovery.test.ts +363 -1450
- package/tests/review-controller-native-routing.test.ts +1251 -5642
- package/tests/review-controller-retired-ops.test.ts +1 -1
- package/tests/review-controller-workspace-root.test.ts +222 -71
- package/tests/review-controller.test.ts +26 -816
- package/tests/review-corrected-finalize-binding.test.ts +112 -153
- package/tests/review-dispatch-hydration-gap.test.ts +1 -53
- package/tests/review-gate.test.ts +0 -45
- package/tests/review-host-relay-restart-parity.test.ts +360 -0
- package/tests/review-host-relay-routing.test.ts +117 -82
- package/tests/review-host-relay.test.ts +242 -8
- package/tests/review-integration-v2-forward.test.ts +576 -553
- package/tests/review-integration-v2.test.ts +239 -165
- package/tests/review-last-event-closure.test.ts +408 -0
- package/tests/review-ledger-contract.test.ts +97 -35
- package/tests/review-recovered-lineage-routing.test.ts +0 -47
- package/tests/review-relay-transport-agent.test.ts +119 -46
- package/tests/review-snapshot.test.ts +3 -2
- package/tests/runtime-harness.mjs +361 -196
- package/tests/sdd-agent-tools.test.ts +36 -0
- package/tests/sdd-preflight.test.ts +81 -15
- package/tests/sdd-status.test.ts +109 -110
- package/tests/skill-collision-prefixes.test.ts +5 -8
- package/tests/writer-edit-surface-scope.test.ts +230 -0
- package/themes/Gentleman-Cute.json +94 -0
- package/themes/Gentleman-Sexy.json +92 -0
- package/lib/git-commit-transaction.ts +0 -861
- package/runtime/git-commit-transaction.mjs +0 -862
- package/scripts/run-git-commit-transaction.mjs +0 -35
- package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
- package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
- package/tests/git-commit-transaction.test.ts +0 -530
package/lib/review-host-relay.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
// The thin Pi host relay (gentle-pi#311 P4; provider contract gentle-ai#3249).
|
|
2
2
|
//
|
|
3
3
|
// gentle-ai owns prompt materialization, role and schema selection, byte
|
|
4
|
-
// budgets, parsing, admission, immutable capture, retry
|
|
5
|
-
// accounting,
|
|
4
|
+
// budgets, parsing, admission, immutable capture, retry, correction
|
|
5
|
+
// accounting, and receipt state. This host boundary is
|
|
6
6
|
// intentionally narrow:
|
|
7
7
|
//
|
|
8
8
|
// 1. Run the exact provider-issued capture binding with `--agent pi
|
|
9
9
|
// --materialize` and take stdout as opaque prompt BYTES, verbatim.
|
|
10
|
-
// 2.
|
|
11
|
-
//
|
|
12
|
-
// stdout as raw final bytes. Model/provider/profile selection
|
|
13
|
-
// user-owned: no --model, no --provider, environment untouched.
|
|
10
|
+
// 2. Pass those prompt bytes to the pure opaque Pi adapter, which owns its
|
|
11
|
+
// locked-down print-mode subprocess and fresh empty scratch directory;
|
|
12
|
+
// take its stdout as raw final bytes. Model/provider/profile selection
|
|
13
|
+
// stays user-owned: no --model, no --provider, environment untouched.
|
|
14
14
|
// 3. Submit those bytes untouched through the provider-owned `submission`
|
|
15
15
|
// form carried by the collect input: execute its exact operation and
|
|
16
16
|
// argument tokens with only the tempfile path substituted into the
|
|
@@ -30,25 +30,18 @@ import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
|
30
30
|
import { tmpdir } from "node:os";
|
|
31
31
|
import { isAbsolute, join } from "node:path";
|
|
32
32
|
import { resolveGentleAiBinary } from "./gentle-ai-binary.ts";
|
|
33
|
+
import {
|
|
34
|
+
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE,
|
|
35
|
+
OpaquePiReviewerTransportError,
|
|
36
|
+
runOpaquePiReviewer,
|
|
37
|
+
type OpaquePiReviewerResult,
|
|
38
|
+
} from "./opaque-pi-reviewer-adapter.ts";
|
|
33
39
|
import { REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION, REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3 } from "./review-integration-v2.ts";
|
|
34
40
|
import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "./review-relay-contract.ts";
|
|
35
41
|
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
// --model/--provider/--profile, which remain user-owned.
|
|
40
|
-
export const REVIEW_HOST_RELAY_PI_ARGV = Object.freeze([
|
|
41
|
-
"--print",
|
|
42
|
-
"--mode", "text",
|
|
43
|
-
"--no-session",
|
|
44
|
-
"--no-tools",
|
|
45
|
-
"--no-extensions",
|
|
46
|
-
"--no-skills",
|
|
47
|
-
"--no-prompt-templates",
|
|
48
|
-
"--no-themes",
|
|
49
|
-
"--no-context-files",
|
|
50
|
-
"--no-approve",
|
|
51
|
-
] as const);
|
|
42
|
+
// Compatibility export for existing relay consumers. The pure adapter owns the
|
|
43
|
+
// fixed Pi process boundary and its locked-down argv.
|
|
44
|
+
export { OPAQUE_PI_REVIEWER_ARGV as REVIEW_HOST_RELAY_PI_ARGV } from "./opaque-pi-reviewer-adapter.ts";
|
|
52
45
|
|
|
53
46
|
export const REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE =
|
|
54
47
|
"provider relay requires a gentle-ai build with the pi host relay surface";
|
|
@@ -61,6 +54,11 @@ export const REVIEW_HOST_RELAY_FAILURE = {
|
|
|
61
54
|
EMPTY_PROMPT: "empty-prompt",
|
|
62
55
|
PI_LAUNCH_FAILED: "pi-launch-failed",
|
|
63
56
|
PI_FAILED: "pi-failed",
|
|
57
|
+
// gentle-pi#367: a reviewer killed by the relay bound is not a crash. It
|
|
58
|
+
// is the one failure class that a byte-identical relaunch cannot survive,
|
|
59
|
+
// so it carries its own kind, its own elapsed/limit evidence, and its own
|
|
60
|
+
// continuation instead of hiding inside `pi-failed`.
|
|
61
|
+
PI_TIMED_OUT: "pi-timed-out",
|
|
64
62
|
PI_EMPTY_OUTPUT: "pi-empty-output",
|
|
65
63
|
SUBMISSION_REFUSED: "submission-refused",
|
|
66
64
|
} as const;
|
|
@@ -79,11 +77,20 @@ export class ReviewHostRelayError extends Error {
|
|
|
79
77
|
readonly exitCode: number | null;
|
|
80
78
|
readonly stderr: string;
|
|
81
79
|
readonly timedOut: boolean;
|
|
80
|
+
// Wall time the killed or failed child actually consumed, and the bound it
|
|
81
|
+
// was measured against. Both are null only when no child process ran.
|
|
82
|
+
// Without them a transport failure cannot be told apart from a crash, which
|
|
83
|
+
// is what forced the gentle-pi#367 reporter to measure the relay by hand.
|
|
84
|
+
readonly elapsedMs: number | null;
|
|
85
|
+
readonly timeoutMs: number | null;
|
|
82
86
|
// "none" until the submission invocation launches; a launched submission
|
|
83
87
|
// whose outcome could not be read is "unknown" and the caller reconciles
|
|
84
|
-
// through negotiated STATUS, never through a blind retry.
|
|
88
|
+
// through negotiated STATUS, never through a blind retry. A launched
|
|
89
|
+
// submission that gentle-ai refused with its typed admission refusal is
|
|
90
|
+
// "none" again: the provider states that the lens slot was not consumed
|
|
91
|
+
// (gentle-pi#522 / #524).
|
|
85
92
|
readonly mutationOutcome: "none" | "unknown";
|
|
86
|
-
constructor(kind: ReviewHostRelayFailureKind, stage: ReviewHostRelayStage, message: string, details?: { exitCode?: number | null; stderr?: string; timedOut?: boolean }) {
|
|
93
|
+
constructor(kind: ReviewHostRelayFailureKind, stage: ReviewHostRelayStage, message: string, details?: { exitCode?: number | null; stderr?: string; timedOut?: boolean; elapsedMs?: number; timeoutMs?: number; mutationOutcome?: "none" | "unknown" }) {
|
|
87
94
|
super(message);
|
|
88
95
|
this.name = "ReviewHostRelayError";
|
|
89
96
|
this.kind = kind;
|
|
@@ -91,10 +98,23 @@ export class ReviewHostRelayError extends Error {
|
|
|
91
98
|
this.exitCode = details?.exitCode ?? null;
|
|
92
99
|
this.stderr = details?.stderr ?? "";
|
|
93
100
|
this.timedOut = details?.timedOut ?? false;
|
|
94
|
-
this.
|
|
101
|
+
this.elapsedMs = details?.elapsedMs ?? null;
|
|
102
|
+
this.timeoutMs = details?.timeoutMs ?? null;
|
|
103
|
+
this.mutationOutcome = details?.mutationOutcome ?? (stage === "submit" ? "unknown" : "none");
|
|
95
104
|
}
|
|
96
105
|
}
|
|
97
106
|
|
|
107
|
+
// gentle-pi#522 / #524: gentle-ai refuses a reviewer submission before any
|
|
108
|
+
// admission with exit 1 and its typed operator line, `<reason> [invalid_request]`.
|
|
109
|
+
// That code is the provider's preflight class: the request was refused as
|
|
110
|
+
// sent and the lens slot was not consumed. The relay recognises only that
|
|
111
|
+
// typed shape; it never parses the reason, and it never retries.
|
|
112
|
+
const ADMISSION_REFUSAL = /\[invalid_request\]/;
|
|
113
|
+
|
|
114
|
+
export function isReviewHostRelayAdmissionRefusal(capture: { exitCode: number | null; timedOut: boolean }, stderr: string): boolean {
|
|
115
|
+
return capture.exitCode === 1 && !capture.timedOut && ADMISSION_REFUSAL.test(stderr);
|
|
116
|
+
}
|
|
117
|
+
|
|
98
118
|
// Refusal classification for the materialize invocation. The installed
|
|
99
119
|
// gentle-ai is the only authority on whether the materialize form exists; Pi
|
|
100
120
|
// never version-sniffs. Two typed refusal classes are distinguished:
|
|
@@ -237,6 +257,8 @@ export function resolveReviewHostRelaySubmission(submission: ReviewCaptureSubmis
|
|
|
237
257
|
|
|
238
258
|
export interface ReviewHostRelayRequest {
|
|
239
259
|
readonly captureArgumentTokens: readonly string[];
|
|
260
|
+
/** Canonical target worktree for coordinator-only native materialize/submit calls. */
|
|
261
|
+
readonly targetCwd?: string;
|
|
240
262
|
/** The provider-owned completing form; absent means contract mismatch. */
|
|
241
263
|
readonly submission?: ReviewCaptureSubmissionV1;
|
|
242
264
|
/** Absolute path; defaults to the verified package-local binary. */
|
|
@@ -245,6 +267,12 @@ export interface ReviewHostRelayRequest {
|
|
|
245
267
|
readonly piExecutable?: string;
|
|
246
268
|
readonly environment?: NodeJS.ProcessEnv;
|
|
247
269
|
readonly gentleAiTimeoutMs?: number;
|
|
270
|
+
/**
|
|
271
|
+
* Overrides the reviewer bound entirely. Production leaves it unset and the
|
|
272
|
+
* relay derives the bound from the materialized prompt bytes and
|
|
273
|
+
* {@link REVIEW_HOST_RELAY_PI_TIMEOUT_ENV}; this seam exists so tests can
|
|
274
|
+
* exercise the timeout leg without a wall-clock wait.
|
|
275
|
+
*/
|
|
248
276
|
readonly piTimeoutMs?: number;
|
|
249
277
|
readonly signal?: AbortSignal;
|
|
250
278
|
}
|
|
@@ -259,21 +287,73 @@ export interface ReviewHostRelayResult {
|
|
|
259
287
|
export type ReviewHostRelayRunner = (request: ReviewHostRelayRequest) => Promise<ReviewHostRelayResult>;
|
|
260
288
|
|
|
261
289
|
const DEFAULT_GENTLE_AI_TIMEOUT_MS = 120_000;
|
|
262
|
-
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// The reviewer subprocess bound (gentle-pi#367).
|
|
293
|
+
//
|
|
294
|
+
// The previous bound was a single hardcoded 600_000 ms reachable only through
|
|
295
|
+
// the test-injectable runner. A field-measured lens legitimately needed 478s
|
|
296
|
+
// against a ~1.58 MB materialized prompt: it survived by hand and was killed
|
|
297
|
+
// under the relay, and the sanctioned continuation then re-spent every lens to
|
|
298
|
+
// reach the same wall. One fixed number cannot serve a prompt class that
|
|
299
|
+
// varies by orders of magnitude, so the bound is derived instead:
|
|
300
|
+
//
|
|
301
|
+
// floor + ceil(promptBytes / MiB * perMebibyte), clamped to the ceiling
|
|
302
|
+
//
|
|
303
|
+
// The floor covers model latency that does not depend on prompt size; the
|
|
304
|
+
// linear term covers the part that does. At the measured 1.58 MB the derived
|
|
305
|
+
// bound is ~37 minutes, roughly a 4.7x margin over the 478s the reviewer
|
|
306
|
+
// actually needed — deliberately generous, because the reviewer model and
|
|
307
|
+
// provider are user-owned and the relay cannot know their throughput.
|
|
308
|
+
//
|
|
309
|
+
// GENTLE_PI_REVIEW_RELAY_PI_TIMEOUT_MS replaces the derived bound entirely for
|
|
310
|
+
// callers who know their own configuration. It follows the repository's
|
|
311
|
+
// established numeric-override shape (GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS,
|
|
312
|
+
// GENTLE_PI_REVIEW_MAX_BUFFER_BYTES): a positive decimal, silently ignored
|
|
313
|
+
// when malformed, and clamped to the same hard ceiling so no configuration can
|
|
314
|
+
// turn a foreground FINALIZE into an unbounded child process.
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
export const REVIEW_HOST_RELAY_PI_TIMEOUT_ENV = "GENTLE_PI_REVIEW_RELAY_PI_TIMEOUT_MS";
|
|
318
|
+
export const REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS = 900_000;
|
|
319
|
+
export const REVIEW_HOST_RELAY_PI_TIMEOUT_PER_MEBIBYTE_MS = 900_000;
|
|
320
|
+
export const REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS = 7_200_000;
|
|
321
|
+
const BYTES_PER_MEBIBYTE = 1024 * 1024;
|
|
322
|
+
|
|
323
|
+
export function resolveReviewHostRelayPiTimeoutMs(promptByteLength: number, environment: NodeJS.ProcessEnv = process.env): number {
|
|
324
|
+
const configured = environment[REVIEW_HOST_RELAY_PI_TIMEOUT_ENV];
|
|
325
|
+
if (configured !== undefined && /^[1-9]\d*$/.test(configured)) {
|
|
326
|
+
const parsed = Number(configured);
|
|
327
|
+
if (Number.isSafeInteger(parsed)) return Math.min(parsed, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS);
|
|
328
|
+
}
|
|
329
|
+
const bytes = Number.isSafeInteger(promptByteLength) && promptByteLength > 0 ? promptByteLength : 0;
|
|
330
|
+
const scaled = REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS + Math.ceil((bytes / BYTES_PER_MEBIBYTE) * REVIEW_HOST_RELAY_PI_TIMEOUT_PER_MEBIBYTE_MS);
|
|
331
|
+
return Math.min(scaled, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// The reviewer ran out of time; it did not crash. The message states both
|
|
335
|
+
// measurements and names the two things that can change the outcome, because
|
|
336
|
+
// the one thing that cannot is relaunching the identical slot.
|
|
337
|
+
export function reviewHostRelayPiTimeoutMessage(elapsedMs: number, timeoutMs: number, promptByteLength: number): string {
|
|
338
|
+
return `pi reviewer subprocess exceeded the relay bound: killed after ${elapsedMs}ms against a ${timeoutMs}ms limit for a ${promptByteLength}-byte materialized prompt. `
|
|
339
|
+
+ `Relaunching the same slot unchanged reaches the same wall. Raise ${REVIEW_HOST_RELAY_PI_TIMEOUT_ENV} above the reviewer's real wall time (ceiling ${REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS}ms) or reduce the candidate scope so the materialized prompt is smaller.`;
|
|
340
|
+
}
|
|
263
341
|
|
|
264
342
|
interface ProcessCapture {
|
|
265
343
|
stdout: Buffer;
|
|
266
344
|
stderr: Buffer;
|
|
267
345
|
exitCode: number | null;
|
|
268
346
|
timedOut: boolean;
|
|
347
|
+
elapsedMs: number;
|
|
269
348
|
}
|
|
270
349
|
|
|
271
|
-
function
|
|
350
|
+
function collectGentleAiProcess(
|
|
272
351
|
file: string,
|
|
273
352
|
arguments_: readonly string[],
|
|
274
353
|
options: { cwd: string; env: NodeJS.ProcessEnv; stdin?: Buffer; timeoutMs: number; signal?: AbortSignal },
|
|
275
354
|
): Promise<ProcessCapture> {
|
|
276
355
|
return new Promise((resolve, reject) => {
|
|
356
|
+
const startedAt = Date.now();
|
|
277
357
|
const child = spawn(file, [...arguments_], {
|
|
278
358
|
cwd: options.cwd,
|
|
279
359
|
env: options.env,
|
|
@@ -305,7 +385,7 @@ function collectProcess(
|
|
|
305
385
|
if (settled) return;
|
|
306
386
|
settled = true;
|
|
307
387
|
if (timer !== undefined) clearTimeout(timer);
|
|
308
|
-
resolve({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), exitCode: code, timedOut });
|
|
388
|
+
resolve({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), exitCode: code, timedOut, elapsedMs: Date.now() - startedAt });
|
|
309
389
|
});
|
|
310
390
|
if (options.stdin === undefined) {
|
|
311
391
|
child.stdin.end();
|
|
@@ -316,6 +396,45 @@ function collectProcess(
|
|
|
316
396
|
});
|
|
317
397
|
}
|
|
318
398
|
|
|
399
|
+
function relayPiTransportError(error: unknown, promptByteLength: number, piTimeoutMs: number): ReviewHostRelayError {
|
|
400
|
+
if (!(error instanceof OpaquePiReviewerTransportError)) {
|
|
401
|
+
return new ReviewHostRelayError(
|
|
402
|
+
REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED,
|
|
403
|
+
"pi",
|
|
404
|
+
`pi subprocess could not start: ${error instanceof Error ? error.message : String(error)}`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
const details = {
|
|
408
|
+
exitCode: error.exitCode,
|
|
409
|
+
stderr: error.stderr.toString("utf8"),
|
|
410
|
+
timedOut: error.timedOut,
|
|
411
|
+
...(error.elapsedMs === null ? {} : { elapsedMs: error.elapsedMs }),
|
|
412
|
+
...(error.timeoutMs === null ? {} : { timeoutMs: error.timeoutMs }),
|
|
413
|
+
};
|
|
414
|
+
if (
|
|
415
|
+
error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.TIMED_OUT
|
|
416
|
+
&& error.elapsedMs !== null
|
|
417
|
+
&& error.timeoutMs !== null
|
|
418
|
+
) {
|
|
419
|
+
return new ReviewHostRelayError(
|
|
420
|
+
REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT,
|
|
421
|
+
"pi",
|
|
422
|
+
reviewHostRelayPiTimeoutMessage(error.elapsedMs, error.timeoutMs, promptByteLength),
|
|
423
|
+
{ ...details, timedOut: true, elapsedMs: error.elapsedMs, timeoutMs: error.timeoutMs },
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
if (error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT) {
|
|
427
|
+
return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, "pi", "pi subprocess produced no output bytes", details);
|
|
428
|
+
}
|
|
429
|
+
if (
|
|
430
|
+
error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.LAUNCH_FAILED
|
|
431
|
+
|| error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.SCRATCH_FAILED
|
|
432
|
+
) {
|
|
433
|
+
return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED, "pi", `pi subprocess could not start: ${error.message}`, details);
|
|
434
|
+
}
|
|
435
|
+
return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "pi subprocess failed", details);
|
|
436
|
+
}
|
|
437
|
+
|
|
319
438
|
function assertTokens(name: string, tokens: readonly string[]): void {
|
|
320
439
|
if (tokens.length === 0) throw new TypeError(`Pi host relay requires the provider-issued ${name} tokens`);
|
|
321
440
|
if (tokens.some((token) => typeof token !== "string" || token.length === 0)) {
|
|
@@ -325,7 +444,7 @@ function assertTokens(name: string, tokens: readonly string[]): void {
|
|
|
325
444
|
|
|
326
445
|
/**
|
|
327
446
|
* Runs one complete host-relay capture for one provider-bound slot:
|
|
328
|
-
* materialize →
|
|
447
|
+
* materialize → opaque Pi adapter → submit. Throws a typed
|
|
329
448
|
* {@link ReviewHostRelayError} on every failure leg and submits nothing after
|
|
330
449
|
* a failure; the caller re-queries negotiated STATUS instead of retrying.
|
|
331
450
|
*/
|
|
@@ -342,7 +461,7 @@ export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): P
|
|
|
342
461
|
// pi subprocess environment stays exactly as the user configured it.
|
|
343
462
|
const gentleAiEnvironment = { ...baseEnvironment, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT };
|
|
344
463
|
const gentleAiTimeoutMs = request.gentleAiTimeoutMs ?? DEFAULT_GENTLE_AI_TIMEOUT_MS;
|
|
345
|
-
const
|
|
464
|
+
const targetCwd = request.targetCwd ?? process.cwd();
|
|
346
465
|
|
|
347
466
|
// (a) Materialize the Go-issued opaque prompt. This invocation is also the
|
|
348
467
|
// capability detection: an old binary's unknown-flag refusal proves the
|
|
@@ -350,8 +469,8 @@ export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): P
|
|
|
350
469
|
// verbatim. No version sniffing.
|
|
351
470
|
let materialized: ProcessCapture;
|
|
352
471
|
try {
|
|
353
|
-
materialized = await
|
|
354
|
-
cwd:
|
|
472
|
+
materialized = await collectGentleAiProcess(gentleAi, ["review", "capture-result", ...request.captureArgumentTokens], {
|
|
473
|
+
cwd: targetCwd,
|
|
355
474
|
env: gentleAiEnvironment,
|
|
356
475
|
timeoutMs: gentleAiTimeoutMs,
|
|
357
476
|
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
@@ -362,48 +481,49 @@ export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): P
|
|
|
362
481
|
if (materialized.exitCode !== 0 || materialized.timedOut) {
|
|
363
482
|
const stderr = materialized.stderr.toString("utf8");
|
|
364
483
|
const refusal = classifyReviewHostRelayRefusal(stderr);
|
|
484
|
+
const timing = { elapsedMs: materialized.elapsedMs, timeoutMs: gentleAiTimeoutMs };
|
|
365
485
|
if (refusal === "unknown-flag") {
|
|
366
|
-
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, "materialize", REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut });
|
|
486
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, "materialize", REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut, ...timing });
|
|
367
487
|
}
|
|
368
488
|
if (refusal === "handshake") {
|
|
369
|
-
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, "materialize", stderr, { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut });
|
|
489
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, "materialize", stderr, { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut, ...timing });
|
|
370
490
|
}
|
|
371
|
-
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, "materialize",
|
|
491
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, "materialize", materialized.timedOut
|
|
492
|
+
? `gentle-ai prompt materialization exceeded its ${gentleAiTimeoutMs}ms bound after ${materialized.elapsedMs}ms`
|
|
493
|
+
: "gentle-ai prompt materialization failed", { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut, ...timing });
|
|
372
494
|
}
|
|
373
495
|
const promptBytes = materialized.stdout;
|
|
374
496
|
if (promptBytes.length === 0) {
|
|
375
|
-
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.EMPTY_PROMPT, "materialize", "gentle-ai prompt materialization produced no bytes", { exitCode: 0, stderr: materialized.stderr.toString("utf8") });
|
|
497
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.EMPTY_PROMPT, "materialize", "gentle-ai prompt materialization produced no bytes", { exitCode: 0, stderr: materialized.stderr.toString("utf8"), elapsedMs: materialized.elapsedMs, timeoutMs: gentleAiTimeoutMs });
|
|
376
498
|
}
|
|
499
|
+
// The reviewer bound is derived from the prompt the provider actually
|
|
500
|
+
// materialized, so it can only be resolved here. An explicit request
|
|
501
|
+
// timeout (the test seam) still wins over both the override and the scale.
|
|
502
|
+
const piTimeoutMs = request.piTimeoutMs ?? resolveReviewHostRelayPiTimeoutMs(promptBytes.length, baseEnvironment);
|
|
503
|
+
|
|
504
|
+
// (b) The pure adapter owns the fresh isolated Pi process. Its input and
|
|
505
|
+
// output are opaque bytes; this coordinator only maps transport failures to
|
|
506
|
+
// the established relay boundary.
|
|
507
|
+
let piResult: OpaquePiReviewerResult;
|
|
508
|
+
try {
|
|
509
|
+
piResult = await runOpaquePiReviewer(promptBytes, {
|
|
510
|
+
...(request.piExecutable === undefined ? {} : { piExecutable: request.piExecutable }),
|
|
511
|
+
environment: baseEnvironment,
|
|
512
|
+
timeoutMs: piTimeoutMs,
|
|
513
|
+
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
514
|
+
});
|
|
515
|
+
} catch (error) {
|
|
516
|
+
throw relayPiTransportError(error, promptBytes.length, piTimeoutMs);
|
|
517
|
+
}
|
|
518
|
+
const resultBytes = piResult.stdout;
|
|
377
519
|
|
|
378
|
-
// (
|
|
379
|
-
|
|
520
|
+
// (c) Submit the raw final bytes untouched through the provider-owned
|
|
521
|
+
// completing form: its exact operation and argument tokens, with only the
|
|
522
|
+
// artifact path substituted into the declared {{value}} slot.
|
|
380
523
|
const stagingDirectory = await mkdtemp(join(tmpdir(), "gentle-pi-host-relay-result-"));
|
|
524
|
+
let primaryFailure = false;
|
|
381
525
|
try {
|
|
382
|
-
await chmod(scratchDirectory, 0o700);
|
|
383
526
|
await chmod(stagingDirectory, 0o700);
|
|
384
|
-
let piRun: ProcessCapture;
|
|
385
|
-
try {
|
|
386
|
-
piRun = await collectProcess(request.piExecutable ?? "pi", REVIEW_HOST_RELAY_PI_ARGV, {
|
|
387
|
-
cwd: scratchDirectory,
|
|
388
|
-
env: baseEnvironment,
|
|
389
|
-
stdin: promptBytes,
|
|
390
|
-
timeoutMs: piTimeoutMs,
|
|
391
|
-
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
392
|
-
});
|
|
393
|
-
} catch (error) {
|
|
394
|
-
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED, "pi", `pi subprocess could not start: ${error instanceof Error ? error.message : String(error)}`);
|
|
395
|
-
}
|
|
396
|
-
if (piRun.exitCode !== 0 || piRun.timedOut) {
|
|
397
|
-
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "pi subprocess failed", { exitCode: piRun.exitCode, stderr: piRun.stderr.toString("utf8"), timedOut: piRun.timedOut });
|
|
398
|
-
}
|
|
399
|
-
const resultBytes = piRun.stdout;
|
|
400
|
-
if (resultBytes.length === 0) {
|
|
401
|
-
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, "pi", "pi subprocess produced no output bytes", { exitCode: 0, stderr: piRun.stderr.toString("utf8") });
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// (d) Submit the raw final bytes untouched through the provider-owned
|
|
405
|
-
// completing form: its exact operation and argument tokens, with only
|
|
406
|
-
// the artifact path substituted into the declared {{value}} slot.
|
|
407
527
|
const resultFile = join(stagingDirectory, "result.raw");
|
|
408
528
|
await writeFile(resultFile, resultBytes, { mode: 0o600 });
|
|
409
529
|
await chmod(resultFile, 0o600);
|
|
@@ -412,8 +532,8 @@ export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): P
|
|
|
412
532
|
);
|
|
413
533
|
let submission: ProcessCapture;
|
|
414
534
|
try {
|
|
415
|
-
submission = await
|
|
416
|
-
cwd:
|
|
535
|
+
submission = await collectGentleAiProcess(gentleAi, ["review", submissionBinding.operationToken, ...submitTokens], {
|
|
536
|
+
cwd: targetCwd,
|
|
417
537
|
env: gentleAiEnvironment,
|
|
418
538
|
timeoutMs: gentleAiTimeoutMs,
|
|
419
539
|
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
@@ -422,15 +542,37 @@ export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): P
|
|
|
422
542
|
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", `gentle-ai capture submission could not start: ${error instanceof Error ? error.message : String(error)}`);
|
|
423
543
|
}
|
|
424
544
|
if (submission.exitCode !== 0 || submission.timedOut || submission.stdout.length === 0) {
|
|
425
|
-
|
|
545
|
+
const stderr = submission.stderr.toString("utf8");
|
|
546
|
+
const details = { exitCode: submission.exitCode, stderr, timedOut: submission.timedOut, elapsedMs: submission.elapsedMs, timeoutMs: gentleAiTimeoutMs };
|
|
547
|
+
// A typed admission refusal is a proven non-mutation whose reason is
|
|
548
|
+
// the refusal text itself; everything else that launched (timeout,
|
|
549
|
+
// signal, untyped exit) stays unknown pending STATUS.
|
|
550
|
+
if (isReviewHostRelayAdmissionRefusal(submission, stderr)) {
|
|
551
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", stderr.trim(), { ...details, mutationOutcome: "none" });
|
|
552
|
+
}
|
|
553
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", submission.timedOut
|
|
554
|
+
? `gentle-ai capture submission exceeded its ${gentleAiTimeoutMs}ms bound after ${submission.elapsedMs}ms`
|
|
555
|
+
: "gentle-ai refused the relayed capture submission", details);
|
|
426
556
|
}
|
|
427
557
|
return {
|
|
428
558
|
promptByteLength: promptBytes.length,
|
|
429
|
-
resultByteLength:
|
|
559
|
+
resultByteLength: piResult.stdoutByteLength,
|
|
430
560
|
submission: submission.stdout.toString("utf8"),
|
|
431
561
|
};
|
|
562
|
+
} catch (error) {
|
|
563
|
+
primaryFailure = true;
|
|
564
|
+
throw error;
|
|
432
565
|
} finally {
|
|
433
|
-
|
|
434
|
-
|
|
566
|
+
try {
|
|
567
|
+
await rm(stagingDirectory, { recursive: true, force: true });
|
|
568
|
+
} catch (error) {
|
|
569
|
+
if (!primaryFailure) {
|
|
570
|
+
throw new ReviewHostRelayError(
|
|
571
|
+
REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
|
|
572
|
+
"submit",
|
|
573
|
+
`Pi host relay result staging cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
435
577
|
}
|
|
436
578
|
}
|