@staix/agent-hub 0.12.36 → 0.12.38
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/CHANGELOG.md +22 -0
- package/README.md +18 -11
- package/docs/agent-notes/adapters.md +19 -6
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +3 -2
- package/docs/agent-notes/daemon.md +9 -3
- package/docs/agent-notes/tasks.md +8 -4
- package/docs/agent-notes/tests.md +2 -2
- package/docs/memory.md +3 -1
- package/docs/operations.md +57 -10
- package/docs/quickstart.md +15 -7
- package/docs/sdlc.md +29 -3
- package/docs/security.md +10 -2
- package/docs/smoke.md +38 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +58 -4
- package/docs/verification/2026-10-19-0.12.37.md +60 -0
- package/docs/verification/2026-10-19-agy-p1-t0.md +55 -0
- package/docs/verification/2026-10-19-bus-defer-reset.md +9 -0
- package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
- package/docs/verification/2026-10-19-kimi-agy-reviewer-isolation.md +220 -0
- package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
- package/docs/verification/2026-10-20-0.12.38.md +56 -0
- package/docs/verified.json +27 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5627 -3670
- package/src/adapters/acp.ts +3 -2
- package/src/adapters/agy/definition.ts +61 -0
- package/src/adapters/agy/entry.ts +76 -0
- package/src/adapters/agy/hooks.ts +81 -0
- package/src/adapters/claude-channel.ts +1 -0
- package/src/adapters/claude-print.ts +18 -156
- package/src/adapters/codex-review-isolation.ts +290 -0
- package/src/adapters/definitions/builtin-metadata.ts +9 -6
- package/src/adapters/definitions/native-cli-entry-claude.ts +3 -0
- package/src/adapters/definitions/native-cli-entry-codex.ts +5 -1
- package/src/adapters/definitions/native-cli-entry-jcode.ts +2 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +12 -4
- package/src/adapters/definitions/native-cli-entry-local.ts +3 -0
- package/src/adapters/definitions/native-cli-entry-pi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -0
- package/src/adapters/definitions/native-cli-entry.ts +2 -0
- package/src/adapters/definitions/native-cli-retained.ts +15 -0
- package/src/adapters/definitions/native-factories.ts +17 -0
- package/src/adapters/definitions/native-policy-aux.ts +1 -1
- package/src/adapters/definitions/qwen.ts +1 -1
- package/src/adapters/definitions/reviewer-variants.ts +33 -0
- package/src/adapters/jcode.ts +20 -5
- package/src/adapters/kimi-review.ts +161 -0
- package/src/adapters/registry-types.ts +36 -4
- package/src/adapters/registry.ts +28 -7
- package/src/adapters/reviewer-mcp.ts +85 -0
- package/src/adapters/reviewer-print.ts +382 -0
- package/src/cli/agy-mcp.ts +370 -0
- package/src/cli/console-state.ts +6 -0
- package/src/cli/console.ts +2 -0
- package/src/cli/facts-hook.ts +15 -2
- package/src/cli/help.ts +2 -0
- package/src/cli/identity.ts +1 -1
- package/src/cli/init.ts +7 -0
- package/src/cli/launch.ts +3 -1
- package/src/cli/main.ts +20 -0
- package/src/cli/memory.ts +13 -12
- package/src/cli/output.ts +5 -0
- package/src/hub/board.ts +34 -6
- package/src/hub/bus.ts +17 -6
- package/src/hub/config-apply.ts +3 -2
- package/src/hub/control-client.ts +7 -1
- package/src/hub/daemon.ts +266 -46
- package/src/hub/envelope.ts +1 -1
- package/src/hub/host-executable.ts +1 -1
- package/src/hub/hub-tools.ts +5 -0
- package/src/hub/instances.ts +13 -3
- package/src/hub/memory-profile.ts +67 -11
- package/src/hub/native-hook-launch.ts +37 -0
- package/src/hub/peer-catalog.ts +2 -3
- package/src/hub/planning.ts +2 -0
- package/src/hub/release-admission.ts +64 -0
- package/src/hub/reviewer-pool-config.ts +11 -0
- package/src/hub/reviewer-pool.ts +76 -0
- package/src/hub/routing.ts +45 -3
- package/src/hub/sdlc-release.ts +113 -0
- package/src/hub/tasks.ts +261 -36
- package/src/ui/index.html +6 -1
- package/templates/AGENTS.block.md +1 -1
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
4
|
+
import { HUB, frame } from "../hub/envelope.ts";
|
|
5
|
+
import { BasePeer } from "../hub/peers.ts";
|
|
6
|
+
import type { Envelope } from "../hub/envelope.ts";
|
|
7
|
+
import type { ReviewerMcpSession } from "./reviewer-mcp.ts";
|
|
8
|
+
|
|
9
|
+
export interface ReviewerPrintUsage { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number }
|
|
10
|
+
export interface ReviewerIsolationReceipt {
|
|
11
|
+
backend: string;
|
|
12
|
+
version: string;
|
|
13
|
+
binary: string;
|
|
14
|
+
profile: string;
|
|
15
|
+
readOnly: true;
|
|
16
|
+
strictMcp: boolean;
|
|
17
|
+
ephemeral: boolean;
|
|
18
|
+
enforcement?: "kernel" | "native-policy";
|
|
19
|
+
freshSession?: true;
|
|
20
|
+
hubToolsOnly?: true;
|
|
21
|
+
evidence: readonly string[];
|
|
22
|
+
}
|
|
23
|
+
export interface ReviewerPrepareContext {
|
|
24
|
+
peer: string;
|
|
25
|
+
cwd: string;
|
|
26
|
+
sessionId: string;
|
|
27
|
+
mcpConfig: string;
|
|
28
|
+
systemPrompt: string;
|
|
29
|
+
privatePaths: readonly string[];
|
|
30
|
+
command?: readonly string[];
|
|
31
|
+
signal: AbortSignal;
|
|
32
|
+
/** Host-framed public review evidence, never configuration-selected argv. */
|
|
33
|
+
input?: string;
|
|
34
|
+
task?: number;
|
|
35
|
+
}
|
|
36
|
+
export interface ReviewerLaunch {
|
|
37
|
+
command: readonly string[];
|
|
38
|
+
args: readonly string[];
|
|
39
|
+
isolation: ReviewerIsolationReceipt;
|
|
40
|
+
/** Recheck the fixed producer's binary/config binding immediately before the model child is spawned. */
|
|
41
|
+
current?: () => boolean;
|
|
42
|
+
env?: NodeJS.ProcessEnv;
|
|
43
|
+
cleanup?: () => void | Promise<void>;
|
|
44
|
+
cwd?: string;
|
|
45
|
+
envMode?: "replace";
|
|
46
|
+
stop?: (child: ChildProcess) => Promise<void>;
|
|
47
|
+
/** Latch a change in native policy for the running review, not just at spawn. */
|
|
48
|
+
watchCurrent?: true;
|
|
49
|
+
}
|
|
50
|
+
export interface ReviewerBackend {
|
|
51
|
+
id: string;
|
|
52
|
+
taskMcp?: true;
|
|
53
|
+
nativePolicy?: true;
|
|
54
|
+
prepare(context: ReviewerPrepareContext): Promise<ReviewerLaunch | { unavailable: string }>;
|
|
55
|
+
usage?(event: unknown): ReviewerPrintUsage | undefined;
|
|
56
|
+
failure?(event: unknown): "quota" | undefined;
|
|
57
|
+
}
|
|
58
|
+
export interface ReviewerFailure {
|
|
59
|
+
task: number;
|
|
60
|
+
head?: string;
|
|
61
|
+
flowRevision?: number;
|
|
62
|
+
cause: "start_failed" | "quota" | "no_verdict";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ReviewerReviewBinding { readonly head?: string; readonly flowRevision?: number }
|
|
66
|
+
export interface ReviewerPrintOptions {
|
|
67
|
+
cwd: string;
|
|
68
|
+
command?: readonly string[];
|
|
69
|
+
mcpConfig: string;
|
|
70
|
+
systemPrompt: string;
|
|
71
|
+
privatePaths?: readonly string[];
|
|
72
|
+
watchdogMs: number;
|
|
73
|
+
backend: ReviewerBackend;
|
|
74
|
+
facts?(env: Envelope): string | undefined;
|
|
75
|
+
verdictSince(task: number, since: number): boolean;
|
|
76
|
+
onUsage?(usage: ReviewerPrintUsage, task: number): void;
|
|
77
|
+
onUnavailable?(failure: ReviewerFailure, current: () => boolean): void | Promise<void>;
|
|
78
|
+
log?(line: string): void;
|
|
79
|
+
taskMcp?(task: number, current: () => boolean): ReviewerMcpSession;
|
|
80
|
+
/** Synchronous host task/privacy/policy fence, checked again after awaited preparation. */
|
|
81
|
+
admit?(env: Envelope): boolean;
|
|
82
|
+
bindingCurrent?(task: number, binding: ReviewerReviewBinding): boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ponytail: 256 KiB per NDJSON frame and 4 MiB stdout per review child. Raise only with measured native receipts.
|
|
86
|
+
const MAX_REVIEW_FRAME_BYTES = 256 * 1024, MAX_REVIEW_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
87
|
+
class GroupCleanupError extends Error {}
|
|
88
|
+
type ReviewTarget = Omit<ReviewerFailure, "cause">;
|
|
89
|
+
interface ReviewBatch { targets: (ReviewTarget | undefined)[]; next: number; failures: ReviewerFailure[] }
|
|
90
|
+
const reviewTarget = (env: Envelope): ReviewTarget | undefined => {
|
|
91
|
+
const task = reviewTask(env);
|
|
92
|
+
if (task === undefined || env.private) return undefined;
|
|
93
|
+
const revision = Number(env.refs?.reviewRevision);
|
|
94
|
+
return { task, ...(typeof env.refs?.commit === "string" ? { head: env.refs.commit } : {}), ...(Number.isSafeInteger(revision) && revision > 0 ? { flowRevision: revision } : {}) };
|
|
95
|
+
};
|
|
96
|
+
const remainingFailures = (batch: ReviewBatch, start: number, cause: ReviewerFailure["cause"]): ReviewerFailure[] =>
|
|
97
|
+
batch.targets.slice(start).flatMap(target => target ? [{ ...target, cause }] : []);
|
|
98
|
+
const reviewTask = (env: Envelope): number | undefined => {
|
|
99
|
+
if (env.from !== HUB || env.kind !== "review") return undefined;
|
|
100
|
+
const id = Number(env.refs?.task);
|
|
101
|
+
return Number.isSafeInteger(id) && id > 0 ? id : undefined;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export class ReviewerPrintPeer extends BasePeer {
|
|
105
|
+
readonly hubNative = true;
|
|
106
|
+
private generation = 0;
|
|
107
|
+
private child: ChildProcess | undefined;
|
|
108
|
+
private childStop: ((child: ChildProcess) => Promise<void>) | undefined;
|
|
109
|
+
private childCleanup: (() => Promise<void>) | undefined;
|
|
110
|
+
private childCurrent: (() => boolean) | undefined;
|
|
111
|
+
private reviewMcpClose: (() => void) | undefined;
|
|
112
|
+
private activeDeliveryId: string | undefined;
|
|
113
|
+
private batch: ReviewBatch | undefined;
|
|
114
|
+
private preparation: { abort: AbortController; done: Promise<ReviewerLaunch> } | undefined;
|
|
115
|
+
private receipt: ReviewerIsolationReceipt | undefined;
|
|
116
|
+
get isolationReceipt(): ReviewerIsolationReceipt | undefined { return this.receipt; }
|
|
117
|
+
/** The task whose review the running child was started for (R4: the hub records `fresh` for its verdict). */
|
|
118
|
+
reviewing: number | undefined;
|
|
119
|
+
private reviewSeq = 0;
|
|
120
|
+
private runningBinding: { task: number; binding: ReviewerReviewBinding } | undefined;
|
|
121
|
+
/** Immutable envelope identity of this running child, never the board's later head. */
|
|
122
|
+
reviewBinding(task: number): ReviewerReviewBinding | undefined {
|
|
123
|
+
return this.reviewing === task && this.runningBinding?.task === task ? this.runningBinding.binding : undefined;
|
|
124
|
+
}
|
|
125
|
+
/** Identifies the running child's authority over `task`; a replacement child for the same task gets a new token. */
|
|
126
|
+
reviewToken(task: number): number | undefined {
|
|
127
|
+
if (this.reviewing !== task) return undefined;
|
|
128
|
+
try { if (this.childCurrent?.() === false) { this.abortReview("review policy changed"); return undefined; } }
|
|
129
|
+
catch { this.abortReview("review policy changed"); return undefined; }
|
|
130
|
+
return this.reviewSeq;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
constructor(id: string, private readonly opts: ReviewerPrintOptions) { super(id, opts.watchdogMs); }
|
|
134
|
+
|
|
135
|
+
recoveryMetadata(): Record<string, unknown> { return { launch: { kind: "reviewer-print", peer: this.id } }; }
|
|
136
|
+
|
|
137
|
+
async start(): Promise<void> {
|
|
138
|
+
// A retained child means a review's process group was never confirmed gone: no new review beside it.
|
|
139
|
+
if (this.child) throw new Error(`${this.id}: an earlier review's process group is still running; stop it first`);
|
|
140
|
+
if (this.preparation) throw new Error(`${this.id}: reviewer preparation is still running`);
|
|
141
|
+
this.receipt = undefined;
|
|
142
|
+
const generation = ++this.generation;
|
|
143
|
+
try {
|
|
144
|
+
const launch = await this.prepare(randomUUID());
|
|
145
|
+
try {
|
|
146
|
+
if (generation !== this.generation || launch.current?.() === false) throw new Error("reviewer isolation changed before startup");
|
|
147
|
+
this.receipt = launch.isolation;
|
|
148
|
+
} finally { await launch.cleanup?.(); }
|
|
149
|
+
} catch (error) { if (generation === this.generation) this.setState("offline"); throw error; }
|
|
150
|
+
if (generation === this.generation) this.setState("idle");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async stop(reason?: string): Promise<void> {
|
|
154
|
+
this.generation++; // fence before the first await: a late exit settles nothing
|
|
155
|
+
this.reviewMcpClose?.();
|
|
156
|
+
this.receipt = undefined;
|
|
157
|
+
this.batch = undefined;
|
|
158
|
+
this.preparation?.abort.abort();
|
|
159
|
+
await this.preparation?.done.catch(() => {});
|
|
160
|
+
this.settle("needs_review", reason ?? "review stopped before settlement");
|
|
161
|
+
if (this.child) this.setState("busy"); // unavailable, not offline, until the group is confirmed gone
|
|
162
|
+
await this.kill(); // throws when the child could not be stopped; its handle is kept and the peer stays busy
|
|
163
|
+
this.setState("offline");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
protected override onWatchdog(): void { this.abortReview("review watchdog timeout"); }
|
|
167
|
+
|
|
168
|
+
private abortReview(reason: "review watchdog timeout" | "review output limit exceeded" | "review policy changed"): void {
|
|
169
|
+
const generation = ++this.generation, batch = this.batch, preparation = this.preparation;
|
|
170
|
+
this.reviewing = undefined;
|
|
171
|
+
this.reviewMcpClose?.();
|
|
172
|
+
this.receipt = undefined;
|
|
173
|
+
this.clearWatchdog();
|
|
174
|
+
preparation?.abort.abort();
|
|
175
|
+
this.settle("needs_review", reason);
|
|
176
|
+
// Revoke first, then confirm both preparation and the model's whole group are gone before fallback.
|
|
177
|
+
void (async () => {
|
|
178
|
+
if (preparation) await preparation.done.catch(() => {});
|
|
179
|
+
if (generation !== this.generation) return;
|
|
180
|
+
await this.kill();
|
|
181
|
+
if (generation !== this.generation) return; // a person's stop owns the hold; never reroute it
|
|
182
|
+
if (batch) await this.reportFailures([...batch.failures, ...remainingFailures(batch, batch.next, "no_verdict")], generation);
|
|
183
|
+
if (generation === this.generation && this.state === "busy") { this.batch = undefined; super.onWatchdog(); }
|
|
184
|
+
})().catch((error: Error) => this.opts.log?.(`[${this.id}] review cleanup failed, staying busy`));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
|
|
188
|
+
if (this.state !== "idle") {
|
|
189
|
+
if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is ${this.state}` });
|
|
190
|
+
throw new Error(`${this.id} is ${this.state}`);
|
|
191
|
+
}
|
|
192
|
+
const generation = ++this.generation;
|
|
193
|
+
this.activeDeliveryId = deliveryId;
|
|
194
|
+
this.batch = { targets: envs.map(reviewTarget), next: 0, failures: [] };
|
|
195
|
+
this.setState("busy");
|
|
196
|
+
if (deliveryId) this.delivery({ id: deliveryId, state: "accepted" });
|
|
197
|
+
void this.run(envs, generation).catch((error: Error) => {
|
|
198
|
+
if (generation !== this.generation) return;
|
|
199
|
+
this.settle("needs_review", `reviewer failed: ${error.message}`);
|
|
200
|
+
void this.kill().then(() => { if (generation === this.generation) { this.batch = undefined; this.setState("idle"); } }, () => undefined);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private async run(envs: Envelope[], generation: number): Promise<void> {
|
|
205
|
+
const missing: number[] = [];
|
|
206
|
+
let started = false;
|
|
207
|
+
const batch = this.batch!;
|
|
208
|
+
const reroute = batch.failures;
|
|
209
|
+
let failure: { state: "failed_safe" | "needs_review"; reason: string } | undefined;
|
|
210
|
+
for (const [index, env] of envs.entries()) {
|
|
211
|
+
batch.next = index;
|
|
212
|
+
const task = reviewTask(env);
|
|
213
|
+
if (task === undefined) continue; // a withdrawal or other notice: nothing to review
|
|
214
|
+
if (env.private) { failure = { state: "needs_review", reason: "a private review is the person's, never a cloud reviewer's" }; break; }
|
|
215
|
+
const since = Date.now();
|
|
216
|
+
let result: { code: number | null; quota: boolean };
|
|
217
|
+
try { result = await this.review(env, task, generation); }
|
|
218
|
+
catch (error) {
|
|
219
|
+
if (generation !== this.generation) return;
|
|
220
|
+
if (error instanceof GroupCleanupError) {
|
|
221
|
+
// Unavailable until a stop confirms the group gone: no new review runs beside the survivors.
|
|
222
|
+
this.opts.log?.(`[${this.id}] review of task #${task}: ${error.message}`);
|
|
223
|
+
this.settle("needs_review", `review process group still running: ${error.message}`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
// Once an earlier review in this batch ran, the batch had effects: hold it for a person, never requeue it.
|
|
227
|
+
failure = { state: started ? "needs_review" : "failed_safe", reason: `reviewer could not start: ${(error as Error).message}` };
|
|
228
|
+
reroute.push(...remainingFailures(batch, index, "start_failed"));
|
|
229
|
+
batch.next = envs.length;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
started = true;
|
|
233
|
+
if (generation !== this.generation) return;
|
|
234
|
+
let recorded = false;
|
|
235
|
+
try { recorded = this.opts.verdictSince(task, since); } catch (error) { this.opts.log?.(`[${this.id}] verdict check for task #${task} failed: ${(error as Error).message}`); }
|
|
236
|
+
if (!recorded) {
|
|
237
|
+
missing.push(task);
|
|
238
|
+
const target = batch.targets[index];
|
|
239
|
+
if (target) reroute.push({ ...target, cause: result.quota ? "quota" : "no_verdict" });
|
|
240
|
+
if (result.quota) {
|
|
241
|
+
reroute.push(...remainingFailures(batch, index + 1, "quota"));
|
|
242
|
+
batch.next = envs.length;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
batch.next = index + 1;
|
|
247
|
+
if (result.code !== 0) this.opts.log?.(`[${this.id}] review of task #${task} exited ${result.code}`);
|
|
248
|
+
}
|
|
249
|
+
if (generation !== this.generation) return;
|
|
250
|
+
this.clearWatchdog(); // the child is gone; keep busy while the host settles the remaining walk
|
|
251
|
+
this.settle(failure?.state ?? (missing.length ? "needs_review" : "completed"), failure?.reason ?? (missing.length ? `no verdict recorded for task ${missing.map(id => `#${id}`).join(", ")}` : undefined));
|
|
252
|
+
await this.reportFailures(reroute, generation);
|
|
253
|
+
if (generation === this.generation) { this.batch = undefined; this.setState("idle"); }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private async reportFailures(failures: readonly ReviewerFailure[], generation: number): Promise<void> {
|
|
257
|
+
const seen = new Set<string>();
|
|
258
|
+
for (const failed of failures) {
|
|
259
|
+
if (generation !== this.generation) return;
|
|
260
|
+
const identity = JSON.stringify([failed.task, failed.head, failed.flowRevision]);
|
|
261
|
+
if (seen.has(identity)) continue;
|
|
262
|
+
seen.add(identity);
|
|
263
|
+
try { await this.opts.onUnavailable?.(failed, () => generation === this.generation); }
|
|
264
|
+
catch { this.opts.log?.(`[${this.id}] reviewer fallback could not be recorded; inspect the task`); }
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private prepare(sessionId: string, input?: string, task?: number, mcpConfig?: string): Promise<ReviewerLaunch> {
|
|
269
|
+
const abort = new AbortController();
|
|
270
|
+
const done = (async () => {
|
|
271
|
+
if (this.opts.backend.taskMcp && !this.opts.taskMcp) throw new Error("task-bound reviewer MCP is unavailable");
|
|
272
|
+
const launch = await this.opts.backend.prepare({ peer: this.id, cwd: this.opts.cwd, sessionId,
|
|
273
|
+
mcpConfig: this.opts.backend.taskMcp ? (mcpConfig ?? '{"mcpServers":{}}') : this.opts.mcpConfig,
|
|
274
|
+
systemPrompt: this.opts.systemPrompt, privatePaths: this.opts.privatePaths ?? [], command: this.opts.command, signal: abort.signal,
|
|
275
|
+
...(input === undefined ? {} : { input }), ...(task === undefined ? {} : { task }) });
|
|
276
|
+
if ("unavailable" in launch) throw new Error(launch.unavailable);
|
|
277
|
+
const proof = launch.isolation;
|
|
278
|
+
const policy = this.opts.backend.nativePolicy
|
|
279
|
+
? proof?.enforcement === "native-policy" && proof.freshSession === true && proof.hubToolsOnly === true
|
|
280
|
+
: proof?.strictMcp === true && proof.ephemeral === true;
|
|
281
|
+
if (abort.signal.aborted || !proof || proof.backend !== this.opts.backend.id || proof.readOnly !== true || !policy || !proof.binary || !proof.version || !proof.profile || !proof.evidence.length || !launch.command.length) {
|
|
282
|
+
await launch.cleanup?.();
|
|
283
|
+
throw new Error("reviewer isolation is unavailable or preparation was cancelled");
|
|
284
|
+
}
|
|
285
|
+
return launch;
|
|
286
|
+
})();
|
|
287
|
+
const pending = { abort, done };
|
|
288
|
+
this.preparation = pending;
|
|
289
|
+
void done.finally(() => { if (this.preparation === pending) this.preparation = undefined; }).catch(() => {});
|
|
290
|
+
return done;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private async review(env: Envelope, task: number, generation: number): Promise<{ code: number | null; quota: boolean }> {
|
|
294
|
+
if (this.opts.admit?.(env) === false) throw new Error("review admission changed");
|
|
295
|
+
let facts: string | undefined;
|
|
296
|
+
try { facts = this.opts.facts?.(env); } catch (error) { this.opts.log?.(`[${this.id}] review facts for task #${task} failed: ${(error as Error).message}`); }
|
|
297
|
+
const input = `${frame(env)}${facts ? `\n\n${facts}` : ""}`;
|
|
298
|
+
let launch: ReviewerLaunch | undefined;
|
|
299
|
+
const revision=Number(env.refs?.reviewRevision);
|
|
300
|
+
const binding: ReviewerReviewBinding=Object.freeze({head:env.refs?.commit,flowRevision:Number.isSafeInteger(revision)&&revision>0?revision:undefined});
|
|
301
|
+
const mcp = this.opts.backend.taskMcp ? this.opts.taskMcp?.(task, () => generation === this.generation && this.reviewToken(task) !== undefined && this.opts.bindingCurrent?.(task,binding) !== false) : undefined;
|
|
302
|
+
let mcpClosed=false;
|
|
303
|
+
const closeMcp=()=>{ if(mcpClosed)return; mcpClosed=true; mcp?.close(); if(this.reviewMcpClose===closeMcp)this.reviewMcpClose=undefined; };
|
|
304
|
+
this.reviewMcpClose=closeMcp;
|
|
305
|
+
let ownedChild: ChildProcess | undefined, releasing: Promise<void> | undefined;
|
|
306
|
+
let policyTimer: ReturnType<typeof setInterval> | undefined;
|
|
307
|
+
const release = () => releasing ??= Promise.resolve().then(() => launch?.cleanup?.()).then(() => {});
|
|
308
|
+
try {
|
|
309
|
+
launch = await this.prepare(randomUUID(), input, task, mcp?.config);
|
|
310
|
+
if (generation !== this.generation || this.opts.admit?.(env) === false || launch.current?.() === false) throw new Error("reviewer isolation or admission changed before child spawn");
|
|
311
|
+
this.receipt = launch.isolation;
|
|
312
|
+
const [cmd, ...prefix] = launch.command;
|
|
313
|
+
let quota = false;
|
|
314
|
+
return await new Promise<{ code: number | null; quota: boolean }>((resolve, reject) => {
|
|
315
|
+
const child = spawn(cmd!, [...prefix, ...launch!.args], { cwd: launch!.cwd ?? this.opts.cwd, detached: true,
|
|
316
|
+
env: peerChildEnv(this.id, launch!.envMode === "replace" ? launch!.env : { ...process.env, ...launch!.env }), stdio: ["pipe", "pipe", "ignore"] });
|
|
317
|
+
// Owned before any await, so a stop that lands before "spawn" still finds and kills it.
|
|
318
|
+
this.child = child;
|
|
319
|
+
ownedChild = child;
|
|
320
|
+
this.childStop = launch!.stop;
|
|
321
|
+
this.childCleanup = release;
|
|
322
|
+
this.childCurrent = launch!.watchCurrent ? launch!.current : undefined;
|
|
323
|
+
child.once("error", error => { if (this.child === child) { this.child = undefined; this.childStop = undefined; } reject(error); });
|
|
324
|
+
child.stdin!.on("error", () => { /* the child exited before reading its prompt; its exit settles the review */ });
|
|
325
|
+
child.once("spawn", () => {
|
|
326
|
+
trackGroup(child);
|
|
327
|
+
if (generation !== this.generation) { child.stdin!.destroy(); return; } // stopped meanwhile: no prompt
|
|
328
|
+
this.runningBinding={task,binding};
|
|
329
|
+
this.reviewing = task; this.reviewSeq++;
|
|
330
|
+
if (this.childCurrent) policyTimer = setInterval(() => { this.reviewToken(task); }, 100);
|
|
331
|
+
child.stdin!.end(input);
|
|
332
|
+
});
|
|
333
|
+
let buffer = "", outputBytes = 0;
|
|
334
|
+
child.stdout!.on("data", (chunk: Buffer) => {
|
|
335
|
+
if (generation !== this.generation) return;
|
|
336
|
+
outputBytes += chunk.length;
|
|
337
|
+
if (outputBytes > MAX_REVIEW_OUTPUT_BYTES) { this.abortReview("review output limit exceeded"); return; }
|
|
338
|
+
buffer += chunk.toString("utf8");
|
|
339
|
+
for (let i; (i = buffer.indexOf("\n")) >= 0;) {
|
|
340
|
+
const line = buffer.slice(0, i); buffer = buffer.slice(i + 1);
|
|
341
|
+
if (Buffer.byteLength(line) > MAX_REVIEW_FRAME_BYTES) { this.abortReview("review output limit exceeded"); return; }
|
|
342
|
+
try {
|
|
343
|
+
const event: unknown = JSON.parse(line);
|
|
344
|
+
const usage = this.opts.backend.usage?.(event);
|
|
345
|
+
if (usage) this.opts.onUsage?.(usage, task);
|
|
346
|
+
if (this.opts.backend.failure?.(event) === "quota") quota = true;
|
|
347
|
+
} catch { /* not a stream-json line */ }
|
|
348
|
+
}
|
|
349
|
+
if (Buffer.byteLength(buffer) > MAX_REVIEW_FRAME_BYTES) { this.abortReview("review output limit exceeded"); return; }
|
|
350
|
+
this.touch();
|
|
351
|
+
});
|
|
352
|
+
child.once("exit", code => {
|
|
353
|
+
if (this.child === child) { this.reviewing = undefined; this.runningBinding=undefined; } // the verdict authority ends with the leader
|
|
354
|
+
// The review ends when its whole process group is gone (an MCP server or other descendant may outlive the leader).
|
|
355
|
+
(launch!.stop ? launch!.stop(child) : stopOwnedProcess(child, { group: true })).then(() => { if (this.child === child) { this.child = undefined; this.childStop = undefined; } resolve({ code, quota }); },
|
|
356
|
+
(error: Error) => reject(new GroupCleanupError(error.message)));
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
} finally {
|
|
360
|
+
closeMcp();
|
|
361
|
+
if (policyTimer) clearInterval(policyTimer);
|
|
362
|
+
try { if (!ownedChild || this.child !== ownedChild) { await release(); if (this.childCleanup === release) this.childCleanup = undefined; } }
|
|
363
|
+
catch { this.opts.log?.(`[${this.id}] reviewer isolation cleanup incomplete`); }
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private settle(state: "completed" | "failed_safe" | "needs_review", reason?: string): void {
|
|
368
|
+
const id = this.activeDeliveryId;
|
|
369
|
+
this.activeDeliveryId = undefined;
|
|
370
|
+
if (id) this.delivery({ id, state, ...(reason ? { reason } : {}) });
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Revokes the verdict authority at once, then stops the child; on failure the handle is kept and the error thrown. */
|
|
374
|
+
private async kill(): Promise<void> {
|
|
375
|
+
this.reviewing = undefined;
|
|
376
|
+
this.runningBinding=undefined;
|
|
377
|
+
const child = this.child;
|
|
378
|
+
if (!child) return;
|
|
379
|
+
await (this.childStop ? this.childStop(child) : stopOwnedProcess(child, { group: true }));
|
|
380
|
+
if (this.child === child) { await this.childCleanup?.(); this.child = undefined; this.childStop = undefined; this.childCleanup = undefined; this.childCurrent = undefined; }
|
|
381
|
+
}
|
|
382
|
+
}
|