gentle-pi 2.1.2 → 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 +108 -79
- package/assets/agents/gentle-ai-worker.md +7 -3
- package/assets/agents/jd-fix-agent.md +1 -1
- package/assets/agents/jd-judge-a.md +3 -1
- package/assets/agents/jd-judge-b.md +3 -1
- package/assets/agents/review-readability.md +4 -3
- package/assets/agents/review-reliability.md +4 -3
- package/assets/agents/review-resilience.md +4 -3
- package/assets/agents/review-risk.md +4 -3
- package/assets/agents/sdd-apply.md +11 -4
- package/assets/agents/sdd-archive.md +6 -1
- package/assets/agents/sdd-design.md +6 -1
- package/assets/agents/sdd-explore.md +6 -2
- package/assets/agents/sdd-init.md +10 -2
- package/assets/agents/sdd-onboard.md +6 -1
- package/assets/agents/sdd-proposal.md +8 -1
- package/assets/agents/sdd-research.md +54 -0
- package/assets/agents/sdd-spec.md +6 -1
- package/assets/agents/sdd-status.md +10 -5
- package/assets/agents/sdd-sync.md +6 -1
- package/assets/agents/sdd-tasks.md +8 -4
- package/assets/agents/sdd-verify.md +27 -2
- package/assets/chains/4r-review.chain.md +2 -0
- package/assets/chains/sdd-full.chain.md +2 -2
- package/assets/chains/sdd-plan.chain.md +1 -1
- package/assets/chains/sdd-verify.chain.md +2 -2
- package/assets/orchestrator-delegation.md +145 -160
- package/assets/orchestrator-memory.md +2 -0
- package/assets/orchestrator.md +25 -48
- package/assets/sdd-orchestrator-workflow.md +163 -25
- package/assets/support/sdd-status-contract.md +24 -6
- package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
- package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
- package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
- package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
- package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
- package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
- package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
- package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
- package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
- package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
- package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
- package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
- package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
- package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
- 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-integration/v2/schemas/operation.schema.json +6 -1
- package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
- package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
- package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
- package/docs/native-authority-architecture.md +14 -16
- package/docs/review-integration.md +27 -353
- package/extensions/ask-user-choice.ts +151 -0
- package/extensions/gentle-ai.ts +2710 -3211
- package/extensions/quiet-tools.ts +515 -32
- package/extensions/sdd-init.ts +21 -12
- package/extensions/skill-registry.ts +10 -2
- package/extensions/startup-banner.ts +10 -4
- package/lib/gentle-ai-binary.ts +173 -2
- package/lib/gentle-ai-renderer.ts +70 -0
- package/lib/model-routing-authority.ts +133 -0
- package/lib/native-review-cli.ts +676 -781
- package/lib/opaque-pi-reviewer-adapter.ts +284 -0
- package/lib/provider-contract-bundle.ts +704 -0
- package/lib/review-candidate-view.ts +836 -118
- package/lib/review-compact-contract.ts +59 -248
- package/lib/review-host-relay.ts +578 -0
- package/lib/review-integration-v2.ts +1254 -221
- package/lib/review-last-event-controller.ts +35 -0
- package/lib/review-relay-contract.ts +16 -0
- package/lib/sdd-preflight.ts +230 -67
- package/lib/sdd-status.ts +66 -111
- package/lib/terminal-theme.ts +1 -1
- package/package.json +83 -79
- package/runtime/gentle-ai-binary.mjs +174 -3
- package/runtime/native-review-cli.mjs +610 -715
- package/runtime/review-integration-v2.mjs +1223 -190
- package/runtime/review-relay-contract.mjs +17 -0
- package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +5 -5
- package/scripts/check-provider-contract.mjs +138 -0
- package/scripts/gentle-ai-installer.mjs +85 -22
- package/scripts/maintainer/provider-relay-matrix.mjs +403 -0
- package/scripts/mirror-provider-contract.mjs +143 -0
- package/scripts/test-packed-runner.mjs +20 -9
- package/scripts/verify-package-files.mjs +119 -43
- package/skills/_shared/review-ledger-contract.md +11 -19
- 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 +94 -168
- package/skills/judgment-day/SKILL.md +9 -5
- package/skills/judgment-day/references/prompts-and-formats.md +2 -0
- package/skills/rdd-defect-workflow/SKILL.md +54 -0
- 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/background-subagents.test.ts +771 -0
- package/tests/codegraph-tools.test.ts +3 -3
- package/tests/crosslane/cross-lane.mjs +16 -0
- package/tests/delegated-key-learnings-contract.test.ts +240 -0
- package/tests/devbinary/native-review-parity.devtest.ts +167 -250
- package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
- package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
- package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
- package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
- package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -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/result-artifact-v2-path.captured.json +12 -0
- package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -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-consent-declined.captured.json +19 -0
- package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
- package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
- package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
- package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
- package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
- package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
- package/tests/gentle-ai-binary.test.ts +83 -13
- package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
- package/tests/gentle-ai-dev-binary.test.ts +159 -0
- package/tests/gentle-ai-installer.test.ts +138 -48
- package/tests/gentle-ai.test.ts +820 -131
- package/tests/gentle-theme.test.ts +133 -0
- package/tests/maintainer/provider-relay.maintest.ts +601 -0
- package/tests/model-routing-authority.test.ts +257 -0
- package/tests/native-review-capability-contract.test.ts +75 -2
- package/tests/native-review-cli.test.ts +588 -908
- package/tests/native-review-consent.test.ts +283 -38
- package/tests/native-review-parity-runtime.test.ts +108 -350
- package/tests/native-review-parity.test.ts +684 -732
- package/tests/native-sdd-attempt-authority.test.ts +235 -0
- package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
- package/tests/orchestrator-budget.test.ts +158 -89
- package/tests/orchestrator-rdd-ownership.test.ts +103 -0
- package/tests/package-manifest.test.ts +128 -100
- package/tests/provider-contract-bundle.test.ts +385 -0
- package/tests/provider-contract-mirror.test.ts +206 -0
- package/tests/provider-defect-handoff.test.ts +252 -0
- package/tests/quiet-tool-rendering.test.ts +1055 -28
- package/tests/review-actor-tool-deny.test.ts +12 -13
- package/tests/review-authority-recovery-docs.test.ts +1 -2
- package/tests/review-candidate-view.test.ts +898 -12
- package/tests/review-compact-contract.test.ts +29 -122
- package/tests/review-controller-lock-status.test.ts +2 -2
- package/tests/review-controller-native-recovery.test.ts +366 -857
- package/tests/review-controller-native-routing.test.ts +1253 -4199
- package/tests/review-controller-retired-ops.test.ts +1 -1
- package/tests/review-controller-workspace-root.test.ts +236 -70
- package/tests/review-controller.test.ts +26 -816
- package/tests/review-corrected-finalize-binding.test.ts +134 -0
- package/tests/review-dispatch-hydration-gap.test.ts +145 -0
- 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 +352 -0
- package/tests/review-host-relay.test.ts +754 -0
- package/tests/review-integration-v2-forward.test.ts +654 -0
- package/tests/review-integration-v2.test.ts +317 -129
- package/tests/review-last-event-closure.test.ts +408 -0
- package/tests/review-ledger-contract.test.ts +106 -60
- package/tests/review-recovered-lineage-routing.test.ts +199 -0
- package/tests/review-relay-transport-agent.test.ts +322 -0
- package/tests/review-snapshot.test.ts +3 -2
- package/tests/runtime-harness.mjs +573 -180
- package/tests/sdd-agent-tools.test.ts +53 -32
- package/tests/sdd-preflight.test.ts +81 -15
- package/tests/sdd-status.test.ts +109 -110
- package/tests/skill-collision-prefixes.test.ts +6 -8
- package/tests/skill-registry.test.ts +50 -1
- package/tests/verify-package-files.test.ts +62 -0
- 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/assets/agents/review-refuter.md +0 -40
- package/assets/agents/review-validator.md +0 -23
- package/lib/git-commit-transaction.ts +0 -801
- package/lib/native-review-remediation.ts +0 -49
- package/lib/review-compact.ts +0 -947
- package/lib/review-refuter-adapter.ts +0 -129
- package/lib/review-runtime-contract.ts +0 -68
- package/prompts/gcl.md +0 -54
- package/prompts/gis.md +0 -25
- package/prompts/gpr.md +0 -41
- package/prompts/gwr.md +0 -31
- package/runtime/git-commit-transaction.mjs +0 -802
- package/scripts/run-git-commit-transaction.mjs +0 -35
- package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
- package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
- package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
- package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
- package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
- package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
- package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
- package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
- 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 -302
- package/tests/review-compact.test.ts +0 -243
- package/tests/review-refuter-adapter.test.ts +0 -89
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
// The thin Pi host relay (gentle-pi#311 P4; provider contract gentle-ai#3249).
|
|
2
|
+
//
|
|
3
|
+
// gentle-ai owns prompt materialization, role and schema selection, byte
|
|
4
|
+
// budgets, parsing, admission, immutable capture, retry, correction
|
|
5
|
+
// accounting, and receipt state. This host boundary is
|
|
6
|
+
// intentionally narrow:
|
|
7
|
+
//
|
|
8
|
+
// 1. Run the exact provider-issued capture binding with `--agent pi
|
|
9
|
+
// --materialize` and take stdout as opaque prompt BYTES, verbatim.
|
|
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
|
+
// 3. Submit those bytes untouched through the provider-owned `submission`
|
|
15
|
+
// form carried by the collect input: execute its exact operation and
|
|
16
|
+
// argument tokens with only the tempfile path substituted into the
|
|
17
|
+
// declared {{value}} slot (BOM-less: the buffer is written
|
|
18
|
+
// byte-for-byte). The host never synthesizes or filters the completing
|
|
19
|
+
// form; a materialize slot without a provider submission is a typed
|
|
20
|
+
// contract mismatch, never a rebuilt invocation.
|
|
21
|
+
//
|
|
22
|
+
// On any failure the relay returns a TYPED transport error and submits
|
|
23
|
+
// nothing further. After a transport failure the caller re-queries negotiated
|
|
24
|
+
// STATUS and relaunches only if the exact same bound slot is reoffered —
|
|
25
|
+
// never from transcript inference. The relay never parses or rebuilds
|
|
26
|
+
// binding, evidence, prompt, schema, budgets, or admission.
|
|
27
|
+
|
|
28
|
+
import { spawn } from "node:child_process";
|
|
29
|
+
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
30
|
+
import { tmpdir } from "node:os";
|
|
31
|
+
import { isAbsolute, join } from "node:path";
|
|
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";
|
|
39
|
+
import { REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION, REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3 } from "./review-integration-v2.ts";
|
|
40
|
+
import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "./review-relay-contract.ts";
|
|
41
|
+
|
|
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";
|
|
45
|
+
|
|
46
|
+
export const REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE =
|
|
47
|
+
"provider relay requires a gentle-ai build with the pi host relay surface";
|
|
48
|
+
|
|
49
|
+
export const REVIEW_HOST_RELAY_FAILURE = {
|
|
50
|
+
RELAY_UNAVAILABLE: "relay-unavailable",
|
|
51
|
+
HANDSHAKE_REFUSED: "handshake-refused",
|
|
52
|
+
SUBMISSION_CONTRACT_MISMATCH: "submission-contract-mismatch",
|
|
53
|
+
MATERIALIZE_FAILED: "materialize-failed",
|
|
54
|
+
EMPTY_PROMPT: "empty-prompt",
|
|
55
|
+
PI_LAUNCH_FAILED: "pi-launch-failed",
|
|
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",
|
|
62
|
+
PI_EMPTY_OUTPUT: "pi-empty-output",
|
|
63
|
+
SUBMISSION_REFUSED: "submission-refused",
|
|
64
|
+
} as const;
|
|
65
|
+
export type ReviewHostRelayFailureKind = (typeof REVIEW_HOST_RELAY_FAILURE)[keyof typeof REVIEW_HOST_RELAY_FAILURE];
|
|
66
|
+
|
|
67
|
+
export type ReviewHostRelayStage = "binding" | "materialize" | "pi" | "submit";
|
|
68
|
+
|
|
69
|
+
export const REVIEW_HOST_RELAY_SUBMISSION_VALUE_SLOT = "{{value}}";
|
|
70
|
+
|
|
71
|
+
export const REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE =
|
|
72
|
+
"provider contract mismatch: the materialize capture input carries no provider-owned submission form; the host never synthesizes the completing form";
|
|
73
|
+
|
|
74
|
+
export class ReviewHostRelayError extends Error {
|
|
75
|
+
readonly kind: ReviewHostRelayFailureKind;
|
|
76
|
+
readonly stage: ReviewHostRelayStage;
|
|
77
|
+
readonly exitCode: number | null;
|
|
78
|
+
readonly stderr: string;
|
|
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;
|
|
86
|
+
// "none" until the submission invocation launches; a launched submission
|
|
87
|
+
// whose outcome could not be read is "unknown" and the caller reconciles
|
|
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).
|
|
92
|
+
readonly mutationOutcome: "none" | "unknown";
|
|
93
|
+
constructor(kind: ReviewHostRelayFailureKind, stage: ReviewHostRelayStage, message: string, details?: { exitCode?: number | null; stderr?: string; timedOut?: boolean; elapsedMs?: number; timeoutMs?: number; mutationOutcome?: "none" | "unknown" }) {
|
|
94
|
+
super(message);
|
|
95
|
+
this.name = "ReviewHostRelayError";
|
|
96
|
+
this.kind = kind;
|
|
97
|
+
this.stage = stage;
|
|
98
|
+
this.exitCode = details?.exitCode ?? null;
|
|
99
|
+
this.stderr = details?.stderr ?? "";
|
|
100
|
+
this.timedOut = details?.timedOut ?? false;
|
|
101
|
+
this.elapsedMs = details?.elapsedMs ?? null;
|
|
102
|
+
this.timeoutMs = details?.timeoutMs ?? null;
|
|
103
|
+
this.mutationOutcome = details?.mutationOutcome ?? (stage === "submit" ? "unknown" : "none");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
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
|
+
|
|
118
|
+
// Refusal classification for the materialize invocation. The installed
|
|
119
|
+
// gentle-ai is the only authority on whether the materialize form exists; Pi
|
|
120
|
+
// never version-sniffs. Two typed refusal classes are distinguished:
|
|
121
|
+
//
|
|
122
|
+
// unknown-flag the Go flag package's exact refusal for a flag the binary
|
|
123
|
+
// does not define (any binary older than v2.4.0) —
|
|
124
|
+
// the relay is unavailable and existing behavior stays
|
|
125
|
+
// untouched.
|
|
126
|
+
// handshake the provider's pre-authority pi admission refusal — always
|
|
127
|
+
// surfaced verbatim, never worked around.
|
|
128
|
+
const UNKNOWN_FLAG_REFUSAL = /flag provided but not defined: -{1,2}(?:materialize|agent)\b/;
|
|
129
|
+
const HANDSHAKE_REFUSAL = new RegExp(
|
|
130
|
+
[
|
|
131
|
+
GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV,
|
|
132
|
+
GENTLE_PI_REVIEW_RELAY_CONTRACT.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"),
|
|
133
|
+
"not eligible for immutable receipt review",
|
|
134
|
+
].join("|"),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
export function classifyReviewHostRelayRefusal(stderr: string): "unknown-flag" | "handshake" | "other" {
|
|
138
|
+
if (UNKNOWN_FLAG_REFUSAL.test(stderr)) return "unknown-flag";
|
|
139
|
+
if (HANDSHAKE_REFUSAL.test(stderr)) return "handshake";
|
|
140
|
+
return "other";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Slot detection — the provider decides. A collect input routes through the
|
|
145
|
+
// host relay ONLY when the provider itself issued the `--materialize` token
|
|
146
|
+
// (with the pi runtime identity) on a `review.capture-result` collection
|
|
147
|
+
// input. Nothing is ever inferred from state prose, risk, or transcript.
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
export interface ReviewHostRelaySlot {
|
|
151
|
+
/** Every provider-issued argument token, verbatim, in provider order. */
|
|
152
|
+
readonly captureArgumentTokens: readonly string[];
|
|
153
|
+
/**
|
|
154
|
+
* The provider-owned completing form, verbatim. Absent only when the
|
|
155
|
+
* provider violated its own contract; the relay then fails closed with a
|
|
156
|
+
* typed submission-contract-mismatch error instead of synthesizing one.
|
|
157
|
+
*/
|
|
158
|
+
readonly submission?: ReviewCaptureSubmissionV1;
|
|
159
|
+
readonly lens?: string;
|
|
160
|
+
readonly order?: string;
|
|
161
|
+
readonly subjectHash?: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function argumentValue(input: ReviewCollectInputV3, name: string): string | undefined {
|
|
165
|
+
const matches = input.arguments.filter((argument) => argument.name === name);
|
|
166
|
+
return matches.length === 1 ? matches[0]!.value : undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function renderToken(argument: ReviewCollectInputV3["arguments"][number]): string {
|
|
170
|
+
return argument.token ?? `--${argument.name}=${argument.value}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function isReviewHostRelayCollectInput(input: ReviewCollectInputV3): boolean {
|
|
174
|
+
return input.captureOperation === "review.capture-result"
|
|
175
|
+
&& argumentValue(input, "materialize") === "true"
|
|
176
|
+
&& argumentValue(input, "agent") === "pi";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function reviewHostRelaySlots(inputs: readonly ReviewCollectInputV3[]): readonly ReviewHostRelaySlot[] {
|
|
180
|
+
return inputs.filter((input) => isReviewHostRelayCollectInput(input)).map((input) => ({
|
|
181
|
+
captureArgumentTokens: input.arguments.map((argument) => renderToken(argument)),
|
|
182
|
+
...(input.submission === undefined ? {} : { submission: input.submission }),
|
|
183
|
+
...(argumentValue(input, "lens") === undefined ? {} : { lens: argumentValue(input, "lens") }),
|
|
184
|
+
...(argumentValue(input, "order") === undefined ? {} : { order: argumentValue(input, "order") }),
|
|
185
|
+
...(input.artifactSubject === undefined ? {} : { subjectHash: input.artifactSubject.subjectHash }),
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Provider role vectors (gentle-pi#311 P4-roles) — the two Go-owned non-lens
|
|
191
|
+
// adversarial role capture operations. Unlike the lens materialize slots
|
|
192
|
+
// above, these vectors are SELF-CONTAINED: the provider renders binding
|
|
193
|
+
// tokens plus `--agent=pi --execute=true`, and executing the exact rendered
|
|
194
|
+
// invocation makes Go materialize the role prompt, spawn its own locked-down
|
|
195
|
+
// pi subprocess, and admit the raw verdict into the compact slot. The host
|
|
196
|
+
// never materializes, launches pi, or submits anything for these slots — it
|
|
197
|
+
// runs one CLI invocation verbatim and re-queries negotiated STATUS.
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
export interface ReviewProviderRoleVectorSlot {
|
|
201
|
+
/** The provider-named capture operation, e.g. `review.capture-refuter`. */
|
|
202
|
+
readonly captureOperation: (typeof REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION)[keyof typeof REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION];
|
|
203
|
+
/** Every provider-issued argument token, verbatim, in provider order. */
|
|
204
|
+
readonly argumentTokens: readonly string[];
|
|
205
|
+
/** The provider-declared input name, e.g. `provider_refuter`. */
|
|
206
|
+
readonly name: string;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function isReviewProviderRoleVectorInput(input: ReviewCollectInputV3): boolean {
|
|
210
|
+
return (REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS as readonly string[]).includes(input.captureOperation)
|
|
211
|
+
&& argumentValue(input, "execute") === "true"
|
|
212
|
+
&& argumentValue(input, "agent") === "pi";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function reviewProviderRoleVectorSlots(inputs: readonly ReviewCollectInputV3[]): readonly ReviewProviderRoleVectorSlot[] {
|
|
216
|
+
return inputs.filter((input) => isReviewProviderRoleVectorInput(input)).map((input) => ({
|
|
217
|
+
captureOperation: input.captureOperation as ReviewProviderRoleVectorSlot["captureOperation"],
|
|
218
|
+
argumentTokens: input.arguments.map((argument) => renderToken(argument)),
|
|
219
|
+
name: input.name,
|
|
220
|
+
}));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Resolves the provider-owned submission form into an executable binding.
|
|
224
|
+
// Fails closed with a typed contract-mismatch error whenever the completing
|
|
225
|
+
// form is absent or cannot bind exactly one artifact value; the relay never
|
|
226
|
+
// repairs, filters, or synthesizes it.
|
|
227
|
+
export interface ReviewHostRelaySubmissionBinding {
|
|
228
|
+
readonly operationToken: string;
|
|
229
|
+
readonly argumentTokens: readonly string[];
|
|
230
|
+
readonly substitutionLocation: number;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function resolveReviewHostRelaySubmission(submission: ReviewCaptureSubmissionV1 | undefined): ReviewHostRelaySubmissionBinding {
|
|
234
|
+
if (submission === undefined) {
|
|
235
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE);
|
|
236
|
+
}
|
|
237
|
+
if (submission.operationToken.length === 0 || submission.argumentTokens.length === 0 || submission.argumentTokens.some((token) => typeof token !== "string" || token.length === 0)) {
|
|
238
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", "provider contract mismatch: the submission form carries an empty operation or argument token");
|
|
239
|
+
}
|
|
240
|
+
if (submission.values.length !== 1) {
|
|
241
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", `provider contract mismatch: the submission form must bind exactly one artifact value, received ${submission.values.length}`);
|
|
242
|
+
}
|
|
243
|
+
const value = submission.values[0]!;
|
|
244
|
+
const location = value.substitutionLocation;
|
|
245
|
+
if (!Number.isSafeInteger(location) || location < 0 || location >= submission.argumentTokens.length) {
|
|
246
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", "provider contract mismatch: the submission substitution location is outside its argument tokens");
|
|
247
|
+
}
|
|
248
|
+
if (!submission.argumentTokens[location]!.includes(REVIEW_HOST_RELAY_SUBMISSION_VALUE_SLOT)) {
|
|
249
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", `provider contract mismatch: the submission token at location ${location} carries no ${REVIEW_HOST_RELAY_SUBMISSION_VALUE_SLOT} slot`);
|
|
250
|
+
}
|
|
251
|
+
return { operationToken: submission.operationToken, argumentTokens: submission.argumentTokens, substitutionLocation: location };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Relay execution
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
export interface ReviewHostRelayRequest {
|
|
259
|
+
readonly captureArgumentTokens: readonly string[];
|
|
260
|
+
/** Canonical target worktree for coordinator-only native materialize/submit calls. */
|
|
261
|
+
readonly targetCwd?: string;
|
|
262
|
+
/** The provider-owned completing form; absent means contract mismatch. */
|
|
263
|
+
readonly submission?: ReviewCaptureSubmissionV1;
|
|
264
|
+
/** Absolute path; defaults to the verified package-local binary. */
|
|
265
|
+
readonly gentleAiExecutable?: string;
|
|
266
|
+
/** User-owned pi launcher; defaults to `pi` on PATH. */
|
|
267
|
+
readonly piExecutable?: string;
|
|
268
|
+
readonly environment?: NodeJS.ProcessEnv;
|
|
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
|
+
*/
|
|
276
|
+
readonly piTimeoutMs?: number;
|
|
277
|
+
readonly signal?: AbortSignal;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export interface ReviewHostRelayResult {
|
|
281
|
+
readonly promptByteLength: number;
|
|
282
|
+
readonly resultByteLength: number;
|
|
283
|
+
/** Raw submission stdout (the provider's admitted-manifest JSON), opaque. */
|
|
284
|
+
readonly submission: string;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export type ReviewHostRelayRunner = (request: ReviewHostRelayRequest) => Promise<ReviewHostRelayResult>;
|
|
288
|
+
|
|
289
|
+
const DEFAULT_GENTLE_AI_TIMEOUT_MS = 120_000;
|
|
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
|
+
}
|
|
341
|
+
|
|
342
|
+
interface ProcessCapture {
|
|
343
|
+
stdout: Buffer;
|
|
344
|
+
stderr: Buffer;
|
|
345
|
+
exitCode: number | null;
|
|
346
|
+
timedOut: boolean;
|
|
347
|
+
elapsedMs: number;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function collectGentleAiProcess(
|
|
351
|
+
file: string,
|
|
352
|
+
arguments_: readonly string[],
|
|
353
|
+
options: { cwd: string; env: NodeJS.ProcessEnv; stdin?: Buffer; timeoutMs: number; signal?: AbortSignal },
|
|
354
|
+
): Promise<ProcessCapture> {
|
|
355
|
+
return new Promise((resolve, reject) => {
|
|
356
|
+
const startedAt = Date.now();
|
|
357
|
+
const child = spawn(file, [...arguments_], {
|
|
358
|
+
cwd: options.cwd,
|
|
359
|
+
env: options.env,
|
|
360
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
361
|
+
shell: false,
|
|
362
|
+
windowsHide: true,
|
|
363
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
364
|
+
});
|
|
365
|
+
const stdout: Buffer[] = [];
|
|
366
|
+
const stderr: Buffer[] = [];
|
|
367
|
+
let timedOut = false;
|
|
368
|
+
let settled = false;
|
|
369
|
+
const timer = options.timeoutMs > 0
|
|
370
|
+
? setTimeout(() => {
|
|
371
|
+
timedOut = true;
|
|
372
|
+
child.kill("SIGKILL");
|
|
373
|
+
}, options.timeoutMs)
|
|
374
|
+
: undefined;
|
|
375
|
+
timer?.unref();
|
|
376
|
+
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
|
|
377
|
+
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
|
|
378
|
+
child.on("error", (error) => {
|
|
379
|
+
if (settled) return;
|
|
380
|
+
settled = true;
|
|
381
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
382
|
+
reject(error);
|
|
383
|
+
});
|
|
384
|
+
child.on("close", (code) => {
|
|
385
|
+
if (settled) return;
|
|
386
|
+
settled = true;
|
|
387
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
388
|
+
resolve({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), exitCode: code, timedOut, elapsedMs: Date.now() - startedAt });
|
|
389
|
+
});
|
|
390
|
+
if (options.stdin === undefined) {
|
|
391
|
+
child.stdin.end();
|
|
392
|
+
} else {
|
|
393
|
+
child.stdin.on("error", () => undefined);
|
|
394
|
+
child.stdin.end(options.stdin);
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
}
|
|
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
|
+
|
|
438
|
+
function assertTokens(name: string, tokens: readonly string[]): void {
|
|
439
|
+
if (tokens.length === 0) throw new TypeError(`Pi host relay requires the provider-issued ${name} tokens`);
|
|
440
|
+
if (tokens.some((token) => typeof token !== "string" || token.length === 0)) {
|
|
441
|
+
throw new TypeError(`Pi host relay ${name} tokens must all be non-empty strings`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Runs one complete host-relay capture for one provider-bound slot:
|
|
447
|
+
* materialize → opaque Pi adapter → submit. Throws a typed
|
|
448
|
+
* {@link ReviewHostRelayError} on every failure leg and submits nothing after
|
|
449
|
+
* a failure; the caller re-queries negotiated STATUS instead of retrying.
|
|
450
|
+
*/
|
|
451
|
+
export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): Promise<ReviewHostRelayResult> {
|
|
452
|
+
assertTokens("capture", request.captureArgumentTokens);
|
|
453
|
+
// The completing form is validated before any process launches: a
|
|
454
|
+
// materialize slot without a provider-owned submission is a typed
|
|
455
|
+
// contract mismatch, never a synthesized invocation.
|
|
456
|
+
const submissionBinding = resolveReviewHostRelaySubmission(request.submission);
|
|
457
|
+
const gentleAi = request.gentleAiExecutable ?? resolveGentleAiBinary();
|
|
458
|
+
if (!isAbsolute(gentleAi)) throw new TypeError("Pi host relay requires an absolute gentle-ai executable path");
|
|
459
|
+
const baseEnvironment = request.environment ?? process.env;
|
|
460
|
+
// Every gentle-ai invocation the relay makes carries the handshake; the
|
|
461
|
+
// pi subprocess environment stays exactly as the user configured it.
|
|
462
|
+
const gentleAiEnvironment = { ...baseEnvironment, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT };
|
|
463
|
+
const gentleAiTimeoutMs = request.gentleAiTimeoutMs ?? DEFAULT_GENTLE_AI_TIMEOUT_MS;
|
|
464
|
+
const targetCwd = request.targetCwd ?? process.cwd();
|
|
465
|
+
|
|
466
|
+
// (a) Materialize the Go-issued opaque prompt. This invocation is also the
|
|
467
|
+
// capability detection: an old binary's unknown-flag refusal proves the
|
|
468
|
+
// relay surface is absent, and the provider's handshake refusal surfaces
|
|
469
|
+
// verbatim. No version sniffing.
|
|
470
|
+
let materialized: ProcessCapture;
|
|
471
|
+
try {
|
|
472
|
+
materialized = await collectGentleAiProcess(gentleAi, ["review", "capture-result", ...request.captureArgumentTokens], {
|
|
473
|
+
cwd: targetCwd,
|
|
474
|
+
env: gentleAiEnvironment,
|
|
475
|
+
timeoutMs: gentleAiTimeoutMs,
|
|
476
|
+
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
477
|
+
});
|
|
478
|
+
} catch (error) {
|
|
479
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, "materialize", `gentle-ai prompt materialization could not start: ${error instanceof Error ? error.message : String(error)}`);
|
|
480
|
+
}
|
|
481
|
+
if (materialized.exitCode !== 0 || materialized.timedOut) {
|
|
482
|
+
const stderr = materialized.stderr.toString("utf8");
|
|
483
|
+
const refusal = classifyReviewHostRelayRefusal(stderr);
|
|
484
|
+
const timing = { elapsedMs: materialized.elapsedMs, timeoutMs: gentleAiTimeoutMs };
|
|
485
|
+
if (refusal === "unknown-flag") {
|
|
486
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, "materialize", REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut, ...timing });
|
|
487
|
+
}
|
|
488
|
+
if (refusal === "handshake") {
|
|
489
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, "materialize", stderr, { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut, ...timing });
|
|
490
|
+
}
|
|
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 });
|
|
494
|
+
}
|
|
495
|
+
const promptBytes = materialized.stdout;
|
|
496
|
+
if (promptBytes.length === 0) {
|
|
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 });
|
|
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;
|
|
519
|
+
|
|
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.
|
|
523
|
+
const stagingDirectory = await mkdtemp(join(tmpdir(), "gentle-pi-host-relay-result-"));
|
|
524
|
+
let primaryFailure = false;
|
|
525
|
+
try {
|
|
526
|
+
await chmod(stagingDirectory, 0o700);
|
|
527
|
+
const resultFile = join(stagingDirectory, "result.raw");
|
|
528
|
+
await writeFile(resultFile, resultBytes, { mode: 0o600 });
|
|
529
|
+
await chmod(resultFile, 0o600);
|
|
530
|
+
const submitTokens = submissionBinding.argumentTokens.map((token, index) =>
|
|
531
|
+
index === submissionBinding.substitutionLocation ? token.split(REVIEW_HOST_RELAY_SUBMISSION_VALUE_SLOT).join(resultFile) : token,
|
|
532
|
+
);
|
|
533
|
+
let submission: ProcessCapture;
|
|
534
|
+
try {
|
|
535
|
+
submission = await collectGentleAiProcess(gentleAi, ["review", submissionBinding.operationToken, ...submitTokens], {
|
|
536
|
+
cwd: targetCwd,
|
|
537
|
+
env: gentleAiEnvironment,
|
|
538
|
+
timeoutMs: gentleAiTimeoutMs,
|
|
539
|
+
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
540
|
+
});
|
|
541
|
+
} catch (error) {
|
|
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)}`);
|
|
543
|
+
}
|
|
544
|
+
if (submission.exitCode !== 0 || submission.timedOut || submission.stdout.length === 0) {
|
|
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);
|
|
556
|
+
}
|
|
557
|
+
return {
|
|
558
|
+
promptByteLength: promptBytes.length,
|
|
559
|
+
resultByteLength: piResult.stdoutByteLength,
|
|
560
|
+
submission: submission.stdout.toString("utf8"),
|
|
561
|
+
};
|
|
562
|
+
} catch (error) {
|
|
563
|
+
primaryFailure = true;
|
|
564
|
+
throw error;
|
|
565
|
+
} finally {
|
|
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
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|