pi-crew 0.11.44 → 0.11.46
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 +67 -1
- package/dist/index.mjs +510 -510
- package/package.json +1 -1
- package/src/config/env-allowlist-registry.ts +4 -0
- package/src/config/env-vars.ts +4 -0
- package/src/extension/cross-extension-rpc.ts +15 -6
- package/src/prompt/prompt-runtime.ts +16 -3
- package/src/runtime/async-runner.ts +5 -7
- package/src/runtime/broker/crew-broker-child.ts +58 -0
- package/src/runtime/broker/crew-broker-delegate-request.ts +381 -0
- package/src/runtime/broker/crew-broker.ts +57 -327
- package/src/runtime/broker/socket-stop.ts +147 -0
- package/src/runtime/broker/stdin-handshake.ts +11 -0
- package/src/runtime/child-pi/child-pi-kill.ts +44 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +53 -4
- package/src/runtime/child-pi/child-pi.ts +32 -1
- package/src/runtime/child-pi/inference-broker.ts +40 -8
- package/src/runtime/goal-workflow/goal-achievement.ts +10 -1
- package/src/runtime/pi-spawn.ts +10 -1
- package/src/runtime/task-runner/post-execution.ts +36 -2
- package/src/utils/socket-path.ts +54 -0
- package/src/worktree/branch-freshness.ts +9 -22
- package/src/worktree/cleanup.ts +78 -65
- package/src/worktree/git-hardening.ts +507 -0
- package/src/worktree/worktree-manager.ts +176 -129
package/package.json
CHANGED
|
@@ -198,6 +198,10 @@ const CREW_ENV_FORWARDING_DECL: Record<string, CrewEnvForwardDecision> = {
|
|
|
198
198
|
reason: "per-task compound broker token via built.env/pipe (F4) — secret, never allowlisted",
|
|
199
199
|
},
|
|
200
200
|
PI_CREW_BROKER_TASK_ID: { kind: "child-write", reason: "child-pi-spawn broker handshake" },
|
|
201
|
+
PI_CREW_BROKER_STDIN: {
|
|
202
|
+
kind: "host-only",
|
|
203
|
+
reason: "LOW-1 closure broker-credential transport gate — read by the host spawner (child-pi-spawn .resolveBrokerStdinHandshakeEnabled) to pick env vs stdin route; the worker-side mirror read (crew-broker-child .resolveBrokerStdinFallbackEnabled) is defense-in-depth for the rollback case and needs NO ambient forwarding (parent decision fully determines the route; forwarding would churn the pinned BASE_ALLOWLIST)",
|
|
204
|
+
},
|
|
201
205
|
PI_CREW_INFERENCE_BROKER: {
|
|
202
206
|
kind: "gap",
|
|
203
207
|
reason: "v0.11.31 inference broker toggle — resolved in the PARENT only (ensureInferenceBroker); deliberately never forwarded to children (a child re-starting its own broker would be nonsensical)",
|
package/src/config/env-vars.ts
CHANGED
|
@@ -666,6 +666,10 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
|
|
|
666
666
|
name: "PI_CREW_BROKER_TASK_ID",
|
|
667
667
|
doc: "broker task id for child handshake (crew-broker-child.ts:51)",
|
|
668
668
|
},
|
|
669
|
+
PI_CREW_BROKER_STDIN: {
|
|
670
|
+
name: "PI_CREW_BROKER_STDIN",
|
|
671
|
+
doc: "LOW-1 closure (2026-10-27) broker-credential transport gate: unset/non-'0' (default) sends the per-task compound PI_CREW_BROKER_TOKEN to the headless JSON worker over the spawn STDIN pipe (one buildBrokerStdinLine v2 payload; heap → pipe → heap — child-pi-spawn.ts writer, crew-broker-child.ts startChildBrokerClientAsync reader) so the token never appears in the child env; '0' rolls back to the legacy env route (token in built.env, stdin stays 'ignore') on BOTH sides. RPC-mode workers and surface TUI panes are unaffected (they keep the env route; the worker-side stdin read skips --mode rpc argv and TTY stdin by design)",
|
|
672
|
+
},
|
|
669
673
|
PI_CREW_GUEST: {
|
|
670
674
|
name: "PI_CREW_GUEST",
|
|
671
675
|
doc: "'1' marks the guest scratchpad process (written by scratchpad-lifecycle.ts:167)",
|
|
@@ -165,12 +165,21 @@ function on(events: EventBusLike, channel: string, handler: (raw: unknown) => un
|
|
|
165
165
|
// pi-crew:rpc:live-control, and the realtime pi-crew:live-control channel) are
|
|
166
166
|
// accessible to any extension on the shared event bus. Mitigations: rate
|
|
167
167
|
// limiting, explicit intent, operation allowlist, cwd containment validation,
|
|
168
|
-
// and HMAC origin signing (see rpc-hmac.ts). Trust model (R7-9)
|
|
169
|
-
// valid HMAC signature
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
168
|
+
// and HMAC origin signing (see rpc-hmac.ts). Trust model (R7-9) — what a
|
|
169
|
+
// publisher WITHOUT a valid HMAC signature may do:
|
|
170
|
+
// - pi-crew:rpc:run is the ONLY mutating channel that accepts unsigned
|
|
171
|
+
// requests. This is deliberate (SEC-03, test-pinned backward-compat):
|
|
172
|
+
// each call is rate-limited (RPC_RATE_LIMIT_MAX requests per
|
|
173
|
+
// RPC_RATE_LIMIT_WINDOW_MS — 5/60s), the payload is filtered to the
|
|
174
|
+
// ALLOWED_RPC_RUN_KEYS allowlist (unknown keys are stripped before
|
|
175
|
+
// handleTeamTool ever sees them), and config.cwd must pass containment
|
|
176
|
+
// validation (inside the project directory).
|
|
177
|
+
// - The remaining request/response channels are read-only for unsigned
|
|
178
|
+
// publishers, enforced via RPC_ALLOWED_OPERATIONS.
|
|
179
|
+
// - The realtime pi-crew:live-control channel rejects ALL unsigned
|
|
180
|
+
// operations — LIVE_CONTROL_UNAUTHENTICATED_OPERATIONS is the empty set
|
|
181
|
+
// (steer/stop/resume/follow-up are all mutating) — with a warn log and no
|
|
182
|
+
// publish.
|
|
174
183
|
|
|
175
184
|
export function registerPiCrewRpc(
|
|
176
185
|
events: EventBusLike | undefined,
|
|
@@ -4,7 +4,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
4
4
|
import { type Static, Type } from "@sinclair/typebox";
|
|
5
5
|
import { getCrewEnv } from "../config/env-vars.ts";
|
|
6
6
|
import { defineTool, type ToolDefinition } from "../extension/pi-api.ts";
|
|
7
|
-
import {
|
|
7
|
+
import { type ChildBrokerClientHandle, startChildBrokerClientAsync } from "../runtime/broker/crew-broker-child.ts";
|
|
8
8
|
import { CrewBrokerClient } from "../runtime/broker/crew-broker-client.ts";
|
|
9
9
|
import { hasLiveControlRealtimeListeners } from "../runtime/live-session/live-control-realtime.ts";
|
|
10
10
|
import { checkReplyToRequesterOrigin, type MailboxMessage, readAllMailboxMessages } from "../state/coordination/mailbox.ts";
|
|
@@ -1191,7 +1191,14 @@ export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
|
|
|
1191
1191
|
// deliver pushed steers with the SAME sanitize + pi.sendMessage path as the
|
|
1192
1192
|
// file poll above. The file poll remains the durable fallback; a broker
|
|
1193
1193
|
// connect failure is invisible to the worker.
|
|
1194
|
-
|
|
1194
|
+
// LOW-1 closure: the registration hook is SYNC, but the credential source
|
|
1195
|
+
// may now be the spawn stdin pipe (one buildBrokerStdinLine line) — resolve
|
|
1196
|
+
// the async bootstrap into a placeholder handle and swap it in when ready.
|
|
1197
|
+
// A session_shutdown racing the (bounded ≤2s) stdin read closes whichever
|
|
1198
|
+
// handle is live at that moment, and the late arrival closes itself.
|
|
1199
|
+
let brokerHandle: ChildBrokerClientHandle = { active: false, close: async () => undefined };
|
|
1200
|
+
let brokerShutdownSeen = false;
|
|
1201
|
+
startChildBrokerClientAsync({
|
|
1195
1202
|
onSteer: (rawMessage, id) => {
|
|
1196
1203
|
// FIX-S1: cross-channel dedup. The broker persists every steer to
|
|
1197
1204
|
// the steering JSONL (durable) AND pushes it via the mailbox
|
|
@@ -1212,12 +1219,18 @@ export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
|
|
|
1212
1219
|
}
|
|
1213
1220
|
pi.sendMessage({ customType: "crew-steer", content: sanitized.message, display: false }, { deliverAs: "steer" });
|
|
1214
1221
|
},
|
|
1215
|
-
})
|
|
1222
|
+
})
|
|
1223
|
+
.then((handle) => {
|
|
1224
|
+
brokerHandle = handle;
|
|
1225
|
+
if (brokerShutdownSeen) void brokerHandle.close().catch(() => undefined);
|
|
1226
|
+
})
|
|
1227
|
+
.catch(() => undefined);
|
|
1216
1228
|
// Close the broker connection on session shutdown to avoid leaking the
|
|
1217
1229
|
// persistent socket / reconnect timer. Fire-and-forget: the socket is
|
|
1218
1230
|
// already .unref()'d so it never blocks event-loop exit; errors are
|
|
1219
1231
|
// swallowed because teardown failures during shutdown are harmless.
|
|
1220
1232
|
pi.on("session_shutdown", () => {
|
|
1233
|
+
brokerShutdownSeen = true;
|
|
1221
1234
|
void brokerHandle.close().catch(() => undefined);
|
|
1222
1235
|
});
|
|
1223
1236
|
|
|
@@ -11,6 +11,7 @@ import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
|
11
11
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
12
12
|
import { packageRoot } from "../utils/paths.ts";
|
|
13
13
|
import { redactSecretString } from "../utils/redaction.ts";
|
|
14
|
+
import { buildBrokerStdinLine } from "./broker/stdin-handshake.ts";
|
|
14
15
|
import { registerWorker, unregisterWorker } from "./orphan-worker-registry.ts";
|
|
15
16
|
import { PEER_DEP_DIR_ENV, resolvePeerDepDir } from "./peer-dep.ts";
|
|
16
17
|
|
|
@@ -215,13 +216,10 @@ export function forwardModelControlEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEn
|
|
|
215
216
|
}
|
|
216
217
|
|
|
217
218
|
/** F4 v2: the ONE line written to the background-runner's stdin carrying
|
|
218
|
-
* PER-TASK compound broker tokens
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
|
|
222
|
-
export function buildBrokerStdinLine(runId: string, socketPath: string, tasks: Record<string, string>): string {
|
|
223
|
-
return `${JSON.stringify({ v: 2, runId, socketPath, tasks })}\n`;
|
|
224
|
-
}
|
|
219
|
+
* PER-TASK compound broker tokens. MOVED to broker/stdin-handshake.ts (pure
|
|
220
|
+
* protocol piece, shared with the child-pi spawn writer — LOW-1 closure);
|
|
221
|
+
* re-exported here so the writer API and existing test imports stay stable. */
|
|
222
|
+
export { buildBrokerStdinLine } from "./broker/stdin-handshake.ts";
|
|
225
223
|
|
|
226
224
|
/**
|
|
227
225
|
* IN-PROCESS ASYNC TEST SEAM (kills two classes of test pain):
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type * as net from "node:net";
|
|
18
|
+
import { getCrewEnv } from "../../config/env-vars.ts";
|
|
18
19
|
import { type BrokerEventFrame, CrewBrokerClient, type CrewBrokerClientOptions } from "./crew-broker-client.ts";
|
|
20
|
+
import { parseStdinBrokerPayload, readStdinFirstLine } from "./stdin-handshake.ts";
|
|
19
21
|
|
|
20
22
|
export interface ChildBrokerClientHandle {
|
|
21
23
|
/** True once the client has completed the handshake. */
|
|
@@ -37,6 +39,9 @@ export interface StartChildBrokerClientOptions {
|
|
|
37
39
|
netModule?: typeof net;
|
|
38
40
|
/** Test seam: override client construction. */
|
|
39
41
|
clientFactory?: (opts: CrewBrokerClientOptions) => CrewBrokerClient;
|
|
42
|
+
/** Test seam: override the stdin first-line read for the LOW-1 fallback
|
|
43
|
+
* (defaults to stdin-handshake.readStdinFirstLine on real process.stdin). */
|
|
44
|
+
readStdinLine?: () => Promise<string | undefined>;
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
const NOOP_HANDLE: ChildBrokerClientHandle = {
|
|
@@ -86,3 +91,56 @@ export function startChildBrokerClient(options: StartChildBrokerClientOptions =
|
|
|
86
91
|
},
|
|
87
92
|
};
|
|
88
93
|
}
|
|
94
|
+
|
|
95
|
+
/** True when this process was booted as an RPC-mode worker (pi --mode rpc):
|
|
96
|
+
* its stdin IS the JSONL protocol channel and must never be consumed by the
|
|
97
|
+
* credential handshake. Those spawns keep the token in env (the RPC branch of
|
|
98
|
+
* child-pi.ts never strips it), so the fallback below is unreachable there
|
|
99
|
+
* anyway — this guard is defense-in-depth against hybrid/old-parent skew. */
|
|
100
|
+
function argvIndicatesRpcMode(): boolean {
|
|
101
|
+
return process.argv.includes("--mode") && process.argv.includes("rpc");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** LOW-1 residual closure (2026-10-27): async wrapper used by the worker
|
|
105
|
+
* bootstrap (prompt-runtime Feature 2b). Identical contract to
|
|
106
|
+
* {@link startChildBrokerClient}, plus: when the env token is ABSENT but the
|
|
107
|
+
* socket/run/task identity env IS present (new parent on the stdin-handshake
|
|
108
|
+
* default), read the single buildBrokerStdinLine payload off stdin (bounded,
|
|
109
|
+
* never throws) and merge the per-task token into a COPY of the env before
|
|
110
|
+
* connecting. Ordering matrix:
|
|
111
|
+
* - new parent + new worker → token via stdin (env stays secret-free);
|
|
112
|
+
* - old parent → new worker → env token present → env route, stdin untouched;
|
|
113
|
+
* - new parent + PI_CREW_BROKER_STDIN=0 → env token present → env route;
|
|
114
|
+
* - payload missing/invalid/wrong-run/socket-mismatch → creds-less no-op
|
|
115
|
+
* handle (same as today), never a throw.
|
|
116
|
+
* The sync {@link startChildBrokerClient} stays untouched (env-only) so every
|
|
117
|
+
* existing caller/test keeps its exact behavior. */
|
|
118
|
+
export async function startChildBrokerClientAsync(options: StartChildBrokerClientOptions = {}): Promise<ChildBrokerClientHandle> {
|
|
119
|
+
let env = options.env ?? process.env;
|
|
120
|
+
const socketPath = env.PI_CREW_BROKER_SOCKET;
|
|
121
|
+
const runId = env.PI_CREW_BROKER_RUN_ID;
|
|
122
|
+
const taskId = env.PI_CREW_BROKER_TASK_ID;
|
|
123
|
+
if (socketPath && runId && taskId && !env.PI_CREW_BROKER_TOKEN && resolveBrokerStdinFallbackEnabled() && !argvIndicatesRpcMode()) {
|
|
124
|
+
try {
|
|
125
|
+
const raw = await (options.readStdinLine ?? readStdinFirstLine)();
|
|
126
|
+
const payload = raw ? parseStdinBrokerPayload(raw, runId) : undefined;
|
|
127
|
+
// socketPath cross-check: the payload must describe the SAME socket the
|
|
128
|
+
// parent announced via env (both are parent-controlled, but a mismatch
|
|
129
|
+
// means skew/garbage — refuse rather than connect with mixed creds).
|
|
130
|
+
const token = payload?.socketPath === socketPath ? payload.tasks[taskId] : undefined;
|
|
131
|
+
if (token) {
|
|
132
|
+
env = { ...env, PI_CREW_BROKER_TOKEN: token };
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
/* best-effort: fall through creds-less (previous behavior) */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return startChildBrokerClient({ ...options, env });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Worker-side mirror of the parent gate (child-pi-spawn
|
|
142
|
+
* .resolveBrokerStdinHandshakeEnabled): PI_CREW_BROKER_STDIN=0 keeps the
|
|
143
|
+
* fallback OFF so a forced rollback is env-only on both sides. Default ON. */
|
|
144
|
+
function resolveBrokerStdinFallbackEnabled(): boolean {
|
|
145
|
+
return getCrewEnv("PI_CREW_BROKER_STDIN") !== "0";
|
|
146
|
+
}
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crew-broker-delegate-request.ts — `delegate.request` broker handler
|
|
3
|
+
* (governed-nesting grandchild spawn).
|
|
4
|
+
*
|
|
5
|
+
* Moved from crew-broker.ts:1459-1748 (wc-gate ITEM C, pure move, no
|
|
6
|
+
* behavior change, 2026-10-27). Top-level free function so the class
|
|
7
|
+
* method stays a 1-line delegation (same deps-object pattern as
|
|
8
|
+
* protocol/wait-resolve.ts / protocol/msg-inbox.ts).
|
|
9
|
+
*
|
|
10
|
+
* Behavior contract (unchanged — ADR-5 §1-§12):
|
|
11
|
+
* - Auth: task-scoped token MANDATORY (worker role + compound auth
|
|
12
|
+
* match; ADR-5 pin iii) — plain run tokens are rejected `forbidden`.
|
|
13
|
+
* - Params parsed through the protocol parser (R7-H1: bad maxTurns /
|
|
14
|
+
* budgetTokens never reach admission or the slot reservation).
|
|
15
|
+
* - Capability gate `nestingEnabled` fail-closed (never silent —
|
|
16
|
+
* delegate.rejected recorded + typed `policy-disabled` frame).
|
|
17
|
+
* - Admission matrix (spawn-policy) evaluated under a bounded
|
|
18
|
+
* run-lock busy-retry (RR-023 F4) off a FRESH disk read; lock-busy
|
|
19
|
+
* degrades to a typed `busy` frame + delegate.rejected event, the
|
|
20
|
+
* connection SURVIVES. Nested slot is acquired INSIDE the lock
|
|
21
|
+
* (S1#1: no reserve-then-race refund window) and the grandchild
|
|
22
|
+
* token scopes to a subId minted BEFORE admission.
|
|
23
|
+
* - Budget reservation is pessimistic and seeds the parent allocation
|
|
24
|
+
* (M3-3/R43-N1); the reservation IS the spend (M3-4/R44-N4).
|
|
25
|
+
* - Grandchild SHADOW TASK registered in the same locked RMW (S1#1).
|
|
26
|
+
* - Responds IMMEDIATELY with `grandchildTaskRef` (principle 7: the
|
|
27
|
+
* tool self-polls the mailbox); the grandchild lifecycle runs in a
|
|
28
|
+
* background IIFE: abort-controller registered BEFORE the spawn
|
|
29
|
+
* (M3-5/R43-N3), parent-model inheritance (RR-023 F3), shadow
|
|
30
|
+
* queued→running promotion on live pid (RR-012 F16), end-fence
|
|
31
|
+
* neutralization before durable mailbox delivery + budget charge +
|
|
32
|
+
* shadow terminal flip (finalizeDelegateResult), abort + nested
|
|
33
|
+
* slot released in a `finally`, terminal delegate event recorded.
|
|
34
|
+
*
|
|
35
|
+
* Leaf-module imports ONLY — never import crew-broker.ts (no cycle).
|
|
36
|
+
* Broker-owned collaborators (token registry, socket path, nested-slot
|
|
37
|
+
* budget singleton, abort registry, send helpers, options slice) are
|
|
38
|
+
* threaded through the explicit {@link DelegateRequestDeps} object.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { randomUUID } from "node:crypto";
|
|
42
|
+
import { loadRunManifestById, saveRunTasks } from "../../state/stores/state-store.ts";
|
|
43
|
+
import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
|
|
44
|
+
import { type GrandchildSpawnResult, spawnDelegateGrandchild } from "../delegate-spawn.ts";
|
|
45
|
+
import { resolveCrewMaxDepth } from "../model/pi-args.ts";
|
|
46
|
+
import { NestedSlotBudget } from "../scheduling/nested-slots.ts";
|
|
47
|
+
import { evaluateDelegateAdmission } from "../spawn-policy.ts";
|
|
48
|
+
import { DelegateAbortRegistry } from "./delegate/delegate-abort.ts";
|
|
49
|
+
import { type DelegateEventTarget, type DelegateEventType } from "./delegate/delegate-event.ts";
|
|
50
|
+
import { delegateTerminalEvent, finalizeDelegateResult } from "./delegate/delegate-finalize.ts";
|
|
51
|
+
import { promoteShadowToRunning } from "./delegate/shadow-lifecycle.ts";
|
|
52
|
+
import { type CrewBrokerOptions, type ServerConnection } from "./protocol/connection-state.ts";
|
|
53
|
+
import { type LockBusyOutcome } from "./protocol/lock-busy.ts";
|
|
54
|
+
import { parseDelegateRequestParams } from "./protocol/request-parsers.ts";
|
|
55
|
+
|
|
56
|
+
/** Options slice the delegate handler reads off the broker's RESOLVED
|
|
57
|
+
* options (single source of truth: CrewBrokerOptions in
|
|
58
|
+
* protocol/connection-state.ts — field docs live there). */
|
|
59
|
+
export type DelegateRequestOptions = Pick<
|
|
60
|
+
CrewBrokerOptions,
|
|
61
|
+
| "cwd"
|
|
62
|
+
| "nestingEnabled"
|
|
63
|
+
| "nestingMaxDepth"
|
|
64
|
+
| "nestingTrustedEscalation"
|
|
65
|
+
| "serializeOnPathOverlap"
|
|
66
|
+
| "lockBusyRetryDelaysMs"
|
|
67
|
+
| "grandchildSpawner"
|
|
68
|
+
| "modelCatalog"
|
|
69
|
+
>;
|
|
70
|
+
|
|
71
|
+
/** Broker-owned collaborators the moved handler needs. The broker binds
|
|
72
|
+
* these once per dispatch (see CrewBroker.handleDelegateRequest). */
|
|
73
|
+
export interface DelegateRequestDeps {
|
|
74
|
+
sendError(conn: ServerConnection, id: string, code: string, message: string): void;
|
|
75
|
+
sendResult(conn: ServerConnection, id: string, result: unknown): void;
|
|
76
|
+
/** Resolved broker options (cwd + nesting gates + spawner/catalog seams). */
|
|
77
|
+
options: DelegateRequestOptions;
|
|
78
|
+
/** ADR-5 §5: lazy nested-slot budget singleton (broker owns sizing). */
|
|
79
|
+
nestedSlotBudget(): NestedSlotBudget;
|
|
80
|
+
/** Delegate event recorder (delegate-event.ts passthrough — never silent). */
|
|
81
|
+
recordDelegateEvent(manifest: DelegateEventTarget, type: DelegateEventType, taskId: string, data: Record<string, unknown>): void;
|
|
82
|
+
/** RR-023 F4: bounded busy-retry around a run-locked RMW (protocol/lock-busy.ts). */
|
|
83
|
+
runLockBusyRetry<T>(manifest: TeamRunManifest, fn: () => T): Promise<LockBusyOutcome<T>>;
|
|
84
|
+
/** M3-5/R43-N3: broker-owned grandchild abort registry (run-terminal + stop). */
|
|
85
|
+
delegateAborts: DelegateAbortRegistry;
|
|
86
|
+
/** Resolved socket path (broker getter) — threaded into grandchild creds. */
|
|
87
|
+
socketPath: string;
|
|
88
|
+
/** Mint a fresh grandchild-scoped run token (S1#1 pre-mint; heap-only). */
|
|
89
|
+
issueRunToken(runId: string, taskId: string): string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function handleDelegateRequest(conn: ServerConnection, id: string, params: unknown, deps: DelegateRequestDeps): Promise<void> {
|
|
93
|
+
// Auth: task-scoped token MANDATORY (ADR-5 pin iii) — same rule as wait.*.
|
|
94
|
+
if (!conn.runId || !conn.taskId) {
|
|
95
|
+
deps.sendError(conn, id, "auth", "not authed");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (conn.role !== "worker" || conn.authMatchKind !== "compound") {
|
|
99
|
+
deps.sendError(conn, id, "forbidden", "delegate requires a task-scoped token; re-dispatch with PI_CREW_BROKER_TASK_ID");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// R7-H1 fix: parse params through the protocol-level parser (mirrors
|
|
103
|
+
// parseWaitRequestParams discipline) — non-integer/non-finite/negative/
|
|
104
|
+
// over-ceiling maxTurns/budgetTokens never reach admission or the
|
|
105
|
+
// budget reservation (durable-negative tokensSpent escape).
|
|
106
|
+
const parsed = parseDelegateRequestParams(params);
|
|
107
|
+
if (!parsed) {
|
|
108
|
+
deps.sendError(conn, id, "bad-params", "delegate.request: invalid params");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const requested = parsed;
|
|
112
|
+
const cwd = deps.options.cwd;
|
|
113
|
+
if (!cwd) {
|
|
114
|
+
deps.sendError(conn, id, "no-manifest", "broker has no cwd configured");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
let loaded: NonNullable<ReturnType<typeof loadRunManifestById>>;
|
|
118
|
+
try {
|
|
119
|
+
const l = loadRunManifestById(cwd, conn.runId);
|
|
120
|
+
if (!l) {
|
|
121
|
+
deps.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
loaded = l;
|
|
125
|
+
} catch (err) {
|
|
126
|
+
deps.sendError(conn, id, "no-manifest", (err as Error).message);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// Capability gate (ADR-5 §10): fail-closed, NEVER silent. Since the D8
|
|
130
|
+
// flip the DEFAULT is true, so reaching this branch means the user
|
|
131
|
+
// closed the surface via config — the message points back at the knob.
|
|
132
|
+
if (deps.options.nestingEnabled !== true) {
|
|
133
|
+
deps.recordDelegateEvent(loaded.manifest, "delegate.rejected", conn.taskId, {
|
|
134
|
+
reason: "nesting-disabled",
|
|
135
|
+
policy: "nesting.enabled=false (user config; default is true since D8)",
|
|
136
|
+
});
|
|
137
|
+
deps.sendError(
|
|
138
|
+
conn,
|
|
139
|
+
id,
|
|
140
|
+
"policy-disabled",
|
|
141
|
+
"delegate is disabled: nesting.enabled=false (set nesting.enabled=true in user config; delegate.rejected recorded in events.jsonl)",
|
|
142
|
+
);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const runId = conn.runId;
|
|
146
|
+
const parentTaskId = conn.taskId;
|
|
147
|
+
// S1#1 fix (security round 1): the subId is minted BEFORE admission so the
|
|
148
|
+
// grandchild token scopes to the SUBID (never the parent task key) and the
|
|
149
|
+
// nested slot is acquired INSIDE the same lock as the admission snapshot.
|
|
150
|
+
const subId = `gc-${randomUUID()}`;
|
|
151
|
+
deps.recordDelegateEvent(loaded.manifest, "delegate.requested", parentTaskId, { subId, role: requested.role ?? "explorer" });
|
|
152
|
+
// Admission: full spawn-policy matrix (ADR-5 §2-§7), parent state from
|
|
153
|
+
// the RECORD under the run lock — never the worker's env/self-report.
|
|
154
|
+
// RR-023 F4: run-locked via bounded busy-retry (protocol/lock-busy.ts).
|
|
155
|
+
const admissionAttempt = await deps.runLockBusyRetry(loaded.manifest, () => {
|
|
156
|
+
const fresh = loadRunManifestById(loaded.manifest.cwd, runId);
|
|
157
|
+
if (!fresh) return { code: "no-manifest" as const, message: `run '${runId}' not found` };
|
|
158
|
+
const task = fresh.tasks.find((t) => t.id === parentTaskId);
|
|
159
|
+
if (!task) {
|
|
160
|
+
deps.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, { subId, reason: "no-task" });
|
|
161
|
+
return { code: "no-task" as const, message: `task '${parentTaskId}' not found` };
|
|
162
|
+
}
|
|
163
|
+
if (task.status !== "running") {
|
|
164
|
+
deps.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, {
|
|
165
|
+
subId,
|
|
166
|
+
reason: "parent-not-running",
|
|
167
|
+
message: `task is ${task.status}`,
|
|
168
|
+
});
|
|
169
|
+
return { code: "bad-params" as const, message: `delegate: parent task '${parentTaskId}' is ${task.status}, not running` };
|
|
170
|
+
}
|
|
171
|
+
// RR-012 F03: authoritative execution cwd = the parent task's cwd from
|
|
172
|
+
// this locked fresh read — ONE source for overlap check, shadow record
|
|
173
|
+
// and spawner input; broker cwd stays for manifest lookup only.
|
|
174
|
+
const executionCwd = task.cwd;
|
|
175
|
+
const catalog = deps.options.modelCatalog?.();
|
|
176
|
+
// S3 fail-closed: a DEFINED loader yielding undefined is a loader failure.
|
|
177
|
+
const effectiveCatalog = deps.options.modelCatalog !== undefined ? (catalog ?? []) : undefined;
|
|
178
|
+
// ADR-5 §9: count OTHER in-flight executor-class tasks sharing the
|
|
179
|
+
// parent task's cwd (manifest record — single source of truth).
|
|
180
|
+
const overlapping = fresh.tasks.filter(
|
|
181
|
+
(t) =>
|
|
182
|
+
t.id !== parentTaskId &&
|
|
183
|
+
t.status === "running" &&
|
|
184
|
+
(t.role === "executor" || t.role === "test-engineer") &&
|
|
185
|
+
t.cwd === executionCwd,
|
|
186
|
+
).length;
|
|
187
|
+
const decision = evaluateDelegateAdmission({
|
|
188
|
+
maxDepth: deps.options.nestingMaxDepth ?? resolveCrewMaxDepth(undefined), // config knob > env-clamped 1..10, default 4 (D8; ADR-5 §3)
|
|
189
|
+
parentTask: {
|
|
190
|
+
taskId: parentTaskId,
|
|
191
|
+
role: task.role,
|
|
192
|
+
...(task.depth !== undefined ? { depth: task.depth } : {}),
|
|
193
|
+
...(task.allocation !== undefined ? { allocation: task.allocation } : {}),
|
|
194
|
+
},
|
|
195
|
+
slots: deps.nestedSlotBudget().snapshot(),
|
|
196
|
+
requested,
|
|
197
|
+
...(effectiveCatalog !== undefined ? { modelCatalog: effectiveCatalog } : {}),
|
|
198
|
+
// ADR-5 §12: the delegate surface is an escalation — trusted only by the
|
|
199
|
+
// explicit user opt-in threaded from config.nesting.enabled (sensitive).
|
|
200
|
+
untrusted: deps.options.nestingTrustedEscalation !== true,
|
|
201
|
+
// M3-3 / R43-N1 SEED: the run-level token budget is the grant every
|
|
202
|
+
// task inherits when its own record carries no allocation (pre-fix
|
|
203
|
+
// state of EVERY task — nothing produced `tokensGranted`, so every
|
|
204
|
+
// budgeted delegate was denied and every unmetered one slipped by).
|
|
205
|
+
// `budgetUnlimited` opts out of enforcement entirely ⇒ no seed.
|
|
206
|
+
...(fresh.manifest.budgetTotal !== undefined && fresh.manifest.budgetUnlimited !== true
|
|
207
|
+
? { parentBudgetGrantTokens: fresh.manifest.budgetTotal }
|
|
208
|
+
: {}),
|
|
209
|
+
// M3-3 / R43-N1: unmetered delegation stays available (documented
|
|
210
|
+
// pre-fix behavior for nesting runs without a budgetTotal) but is now
|
|
211
|
+
// an EXPLICIT, audited allow (`unmetered: true` on delegate.admitted).
|
|
212
|
+
unmeteredDelegates: "allow",
|
|
213
|
+
workspace: {
|
|
214
|
+
serializeEnabled: deps.options.serializeOnPathOverlap === true,
|
|
215
|
+
overlappingInFlightExecutors: overlapping,
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
if (!decision.allowed) {
|
|
219
|
+
deps.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, {
|
|
220
|
+
subId,
|
|
221
|
+
reason: decision.reason,
|
|
222
|
+
message: (decision.message ?? "").slice(0, 120),
|
|
223
|
+
});
|
|
224
|
+
return { code: "policy-denied" as const, message: decision.message ?? decision.reason ?? "delegate denied" };
|
|
225
|
+
}
|
|
226
|
+
// Slot acquisition INSIDE the lock (no reserve-then-race refund window).
|
|
227
|
+
if (!deps.nestedSlotBudget().tryAcquire(subId)) {
|
|
228
|
+
deps.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, { subId, reason: "slots-exhausted" });
|
|
229
|
+
return {
|
|
230
|
+
code: "policy-denied" as const,
|
|
231
|
+
message: `delegate rejected: nested spawn budget exhausted; ${deps.nestedSlotBudget().statusLine}`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
// Reserve the requested budget pessimistically (ADR-5 §5): tokensSpent
|
|
235
|
+
// += budgetTokens now. M3-3/R43-N1: the reservation ALSO seeds the parent
|
|
236
|
+
// allocation when the record has none — the `decision.budget.grantedTokens`
|
|
237
|
+
// the gate admitted against is the write-once `tokensGranted`
|
|
238
|
+
// (state-helpers.ts merge keeps disk's value verbatim). Pre-fix the
|
|
239
|
+
// `parentAllocation` guard silently skipped the reserve for every task
|
|
240
|
+
// that had no allocation, i.e. for every task.
|
|
241
|
+
// M3-4/R44-N4: this reservation IS the spend — no settle-time refund.
|
|
242
|
+
let reserved = 0;
|
|
243
|
+
const budgetDecision = decision.budget;
|
|
244
|
+
let tasksAfterReserve = fresh.tasks;
|
|
245
|
+
if (budgetDecision?.metered === true) {
|
|
246
|
+
reserved = budgetDecision.requestedTokens ?? 0;
|
|
247
|
+
const updatedTasks = fresh.tasks.map((t) =>
|
|
248
|
+
t.id === parentTaskId
|
|
249
|
+
? {
|
|
250
|
+
...t,
|
|
251
|
+
allocation: {
|
|
252
|
+
tokensGranted: budgetDecision.grantedTokens,
|
|
253
|
+
tokensSpent: budgetDecision.spentTokens + reserved,
|
|
254
|
+
},
|
|
255
|
+
}
|
|
256
|
+
: t,
|
|
257
|
+
);
|
|
258
|
+
tasksAfterReserve = updatedTasks;
|
|
259
|
+
saveRunTasks(fresh.manifest, updatedTasks);
|
|
260
|
+
}
|
|
261
|
+
// S1#1: register the grandchild SHADOW TASK — the subId identity gets a
|
|
262
|
+
// real depth/role entry (unbounded-chain fix). Built on tasksAfterReserve
|
|
263
|
+
// so the budget reservation above is never clobbered.
|
|
264
|
+
saveRunTasks(fresh.manifest, [
|
|
265
|
+
...tasksAfterReserve,
|
|
266
|
+
{
|
|
267
|
+
id: subId,
|
|
268
|
+
runId,
|
|
269
|
+
role: requested.role ?? "explorer",
|
|
270
|
+
agent: "delegate",
|
|
271
|
+
title: `delegate: ${(requested as { description?: string }).description ?? subId}`,
|
|
272
|
+
status: "queued",
|
|
273
|
+
cwd: executionCwd,
|
|
274
|
+
dependsOn: [],
|
|
275
|
+
depth: decision.childDepth ?? 2,
|
|
276
|
+
startedAt: new Date().toISOString(),
|
|
277
|
+
} satisfies TeamTaskState,
|
|
278
|
+
]);
|
|
279
|
+
// RR-023 F3: thread the parent's model out of the locked read (gc fallback).
|
|
280
|
+
return { code: "ok" as const, decision, reserved, executionCwd, parentModel: task.model, parentRole: task.role };
|
|
281
|
+
});
|
|
282
|
+
// RR-023 F4 (finding #4): run.lock held by a live cross-process holder —
|
|
283
|
+
// degrade to a typed busy frame AND a delegate.rejected event (never
|
|
284
|
+
// silent); the connection survives (ping still answers).
|
|
285
|
+
if (!admissionAttempt.ok) {
|
|
286
|
+
deps.recordDelegateEvent(loaded.manifest, "delegate.rejected", parentTaskId, { subId, reason: "run-lock-busy" });
|
|
287
|
+
deps.sendError(conn, id, "busy", admissionAttempt.message);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const admissionOutcome = admissionAttempt.value;
|
|
291
|
+
if (admissionOutcome.code !== "ok") {
|
|
292
|
+
deps.sendError(conn, id, admissionOutcome.code, admissionOutcome.message);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const { decision, reserved, executionCwd, parentModel, parentRole } = admissionOutcome;
|
|
296
|
+
deps.recordDelegateEvent(loaded.manifest, "delegate.admitted", parentTaskId, {
|
|
297
|
+
subId,
|
|
298
|
+
childDepth: decision.childDepth,
|
|
299
|
+
role: requested.role ?? "explorer",
|
|
300
|
+
// M3-3/R43-N1: an unmetered allow is EXPLICIT in the audit trail.
|
|
301
|
+
...(decision.budget?.metered === false ? { unmetered: true } : {}),
|
|
302
|
+
...(reserved > 0 ? { reservedTokens: reserved } : {}),
|
|
303
|
+
});
|
|
304
|
+
// S1#1: pre-mint the grandchild-scoped token when (and only when) the
|
|
305
|
+
// child may itself delegate (childDepth < resolved maxDepth — the same
|
|
306
|
+
// gate as issueForChild). Depth-2 at default maxDepth gets NO creds.
|
|
307
|
+
const grandchildCreds =
|
|
308
|
+
(decision.childDepth ?? 2) < (deps.options.nestingMaxDepth ?? resolveCrewMaxDepth(undefined))
|
|
309
|
+
? { socketPath: deps.socketPath, token: deps.issueRunToken(runId, subId) }
|
|
310
|
+
: undefined;
|
|
311
|
+
// Return IMMEDIATELY (principle 7): the tool self-polls the mailbox.
|
|
312
|
+
deps.sendResult(conn, id, { ok: true, grandchildTaskRef: subId, childDepth: decision.childDepth, timeoutSec: decision.timeoutSec });
|
|
313
|
+
// RR-023 F3 (finding #3, run team_20260929041427): model-less gc spawn fell
|
|
314
|
+
// through to the pi GLOBAL default — inherit the parent task's model.
|
|
315
|
+
const gcModel = requested.model ?? parentModel;
|
|
316
|
+
// M3-5/R43-N3: register the grandchild's abort controller BEFORE the
|
|
317
|
+
// spawn starts, so a run terminalise / budget abort / broker stop that
|
|
318
|
+
// lands mid-spawn still cancels it (no orphan outliving the run).
|
|
319
|
+
const delegateAbort = deps.delegateAborts.register(subId, runId);
|
|
320
|
+
// Background grandchild lifecycle (ADR-5 §1/§6).
|
|
321
|
+
const spawner = deps.options.grandchildSpawner ?? spawnDelegateGrandchild;
|
|
322
|
+
void (async () => {
|
|
323
|
+
let outcome: GrandchildSpawnResult;
|
|
324
|
+
try {
|
|
325
|
+
outcome = await spawner({
|
|
326
|
+
cwd: executionCwd,
|
|
327
|
+
runId,
|
|
328
|
+
parentTaskId,
|
|
329
|
+
// R43-S1: transitive tier containment — the spawner applies
|
|
330
|
+
// min(parentRole, requestedRole) as the grandchild tool policy.
|
|
331
|
+
parentRole,
|
|
332
|
+
subId,
|
|
333
|
+
prompt: requested.prompt,
|
|
334
|
+
eventsPath: loaded.manifest.eventsPath,
|
|
335
|
+
role: requested.role ?? "explorer",
|
|
336
|
+
...(grandchildCreds ? { brokerSpawn: grandchildCreds } : {}),
|
|
337
|
+
...(gcModel !== undefined ? { model: gcModel } : {}),
|
|
338
|
+
...(requested.maxTurns !== undefined ? { maxTurns: requested.maxTurns } : {}),
|
|
339
|
+
timeoutSec: decision.timeoutSec ?? 900,
|
|
340
|
+
depthOverride: decision.childDepth ?? 2,
|
|
341
|
+
// M3-5/R43-N3: owner cancellation (registry-driven).
|
|
342
|
+
signal: delegateAbort.signal,
|
|
343
|
+
// RR-012 F16: promote shadow queued→running once the process exists.
|
|
344
|
+
onSpawn: (pid) => {
|
|
345
|
+
if (pid !== null) promoteShadowToRunning(cwd, runId, subId);
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
} catch (err) {
|
|
349
|
+
outcome = { ok: false, resultText: `delegate spawn failed: ${(err as Error).message}` };
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
// S3 fence hardening: neutralize smuggled end-fence markers in the
|
|
353
|
+
// grandchild's raw text, then wrap (the client re-fences too — the
|
|
354
|
+
// mailbox is an unauthenticated same-uid channel by design).
|
|
355
|
+
const sanitizedText = outcome.resultText.replace(/^--- (end )?delegate /gm, "-- ~delegate ").slice(0, 65_536);
|
|
356
|
+
const statusTag = outcome.cancelled ? "(cancelled)" : outcome.timedOut ? "(timed out)" : outcome.ok ? "(ok)" : "(failed)";
|
|
357
|
+
const fenced = `--- delegate ${subId} ${statusTag} ---\n${sanitizedText}\n--- end delegate ${subId} ---`;
|
|
358
|
+
// Durable delivery + budget charge + shadow terminal flip — all
|
|
359
|
+
// inside one run lock, off a fresh disk read (delegate-finalize.ts
|
|
360
|
+
// owns the R43-N4 reroute and the R44-N4 broker-side charge).
|
|
361
|
+
finalizeDelegateResult({
|
|
362
|
+
brokerCwd: cwd,
|
|
363
|
+
runId,
|
|
364
|
+
parentTaskId,
|
|
365
|
+
subId,
|
|
366
|
+
fenced,
|
|
367
|
+
ok: outcome.ok,
|
|
368
|
+
...(outcome.timedOut !== undefined ? { timedOut: outcome.timedOut } : {}),
|
|
369
|
+
reservedTokens: reserved,
|
|
370
|
+
...(outcome.usageTokens !== undefined ? { selfReportedTokens: outcome.usageTokens } : {}),
|
|
371
|
+
});
|
|
372
|
+
} finally {
|
|
373
|
+
deps.delegateAborts.release(subId);
|
|
374
|
+
deps.nestedSlotBudget().release(subId);
|
|
375
|
+
}
|
|
376
|
+
deps.recordDelegateEvent(loaded.manifest, delegateTerminalEvent(outcome), parentTaskId, {
|
|
377
|
+
subId,
|
|
378
|
+
ok: outcome.ok,
|
|
379
|
+
});
|
|
380
|
+
})();
|
|
381
|
+
}
|