pi-crew 0.10.2 → 0.10.4
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/AGENTS.md +2 -1
- package/CHANGELOG.md +249 -0
- package/README.md +5 -1
- package/dist/index.mjs +10844 -7250
- package/docs/architecture.md +4 -4
- package/docs/commands-reference.md +3 -0
- package/docs/publishing.md +15 -3
- package/install.mjs +90 -39
- package/package.json +9 -3
- package/schema.json +11 -0
- package/scripts/README.md +4 -3
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +7 -2
- package/skills/real-test-pi-crew/SKILL.md +428 -82
- package/src/config/config-merge.ts +11 -1
- package/src/config/config-validation.ts +40 -1
- package/src/config/config.ts +28 -6
- package/src/config/defaults.ts +35 -10
- package/src/config/env-vars.ts +27 -2
- package/src/config/migration-validator.ts +113 -0
- package/src/config/types.ts +36 -0
- package/src/extension/cross-extension-rpc.ts +3 -7
- package/src/extension/register.ts +13 -0
- package/src/extension/registration/lifecycle-handlers.ts +40 -9
- package/src/extension/registration/observability.ts +3 -7
- package/src/extension/registration/subagent-tools.ts +3 -7
- package/src/extension/registration/team-tool.ts +56 -12
- package/src/extension/registration/ui.ts +3 -8
- package/src/extension/registration/viewers.ts +3 -10
- package/src/extension/team-manager-command.ts +3 -7
- package/src/extension/team-tool/api/agent-control.ts +17 -10
- package/src/extension/team-tool/api/heartbeat.ts +4 -3
- package/src/extension/team-tool/api/mailbox.ts +33 -20
- package/src/extension/team-tool/api/plan-approval.ts +5 -5
- package/src/extension/team-tool/api/task-claims.ts +8 -7
- package/src/extension/team-tool/cancel.ts +6 -0
- package/src/extension/team-tool/doctor.ts +364 -7
- package/src/extension/team-tool/handle-settings.ts +23 -1
- package/src/extension/team-tool/inspect.ts +10 -2
- package/src/extension/team-tool/run.ts +3 -7
- package/src/extension/team-tool/status.ts +12 -0
- package/src/extension/team-tool.ts +41 -16
- package/src/hooks/registry.ts +62 -56
- package/src/prompt/inbox-poll.ts +90 -0
- package/src/prompt/message-tool.ts +166 -0
- package/src/prompt/prompt-runtime.ts +201 -18
- package/src/prompt/scratchpad-lifecycle.ts +3 -3
- package/src/prompt/surface-worker.ts +720 -0
- package/src/prompt/worker-events-channel.ts +49 -3
- package/src/runtime/async-runner.ts +29 -1
- package/src/runtime/background-runner.ts +43 -42
- package/src/runtime/broker/broker-issuer.ts +27 -2
- package/src/runtime/broker/crew-broker-tokens.ts +56 -4
- package/src/runtime/broker/crew-broker.ts +334 -443
- package/src/runtime/broker/delegate/delegate-event.ts +37 -0
- package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
- package/src/runtime/broker/protocol/connection-state.ts +103 -0
- package/src/runtime/broker/protocol/events-replay.ts +68 -0
- package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
- package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
- package/src/runtime/broker/protocol/request-parsers.ts +175 -0
- package/src/runtime/broker/protocol/wait-auth.ts +46 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
- package/src/runtime/child-pi/child-pi-streams.ts +9 -1
- package/src/runtime/child-pi/child-pi.ts +368 -5
- package/src/runtime/crew-agent-records.ts +13 -1
- package/src/runtime/dispatch-batch.ts +12 -1
- package/src/runtime/event-log-tail-source.ts +374 -0
- package/src/runtime/finalize-run.ts +19 -7
- package/src/runtime/foreground-control.ts +19 -6
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
- package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
- package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
- package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
- package/src/runtime/live-session/live-agent-manager.ts +34 -1
- package/src/runtime/live-session/live-control-realtime.ts +10 -0
- package/src/runtime/live-session/live-session-runtime.ts +47 -27
- package/src/runtime/manifest-cache.ts +128 -17
- package/src/runtime/model/pi-args.ts +59 -65
- package/src/runtime/output/sidechain-output.ts +61 -6
- package/src/runtime/plan-replan.ts +3 -0
- package/src/runtime/process/proc-stat.ts +46 -0
- package/src/runtime/process/zombie-scanner.ts +32 -19
- package/src/runtime/spawn-policy.ts +27 -41
- package/src/runtime/stale-reconciler.ts +28 -3
- package/src/runtime/supervisor-contact.ts +3 -0
- package/src/runtime/surface/degrade.ts +776 -0
- package/src/runtime/surface/herdr-provider.ts +546 -0
- package/src/runtime/surface/launch-script.ts +172 -0
- package/src/runtime/surface/resolve-surface.ts +274 -0
- package/src/runtime/surface/surface-provider.ts +129 -0
- package/src/runtime/surface/surface-spawn.ts +475 -0
- package/src/runtime/surface/tmux-provider.ts +400 -0
- package/src/runtime/task-runner/child-executor.ts +80 -0
- package/src/runtime/task-runner/post-execution.ts +57 -2
- package/src/runtime/task-runner/prompt-builder.ts +1 -0
- package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
- package/src/runtime/task-runner/state-helpers.ts +54 -30
- package/src/runtime/task-runner.ts +4 -2
- package/src/runtime/team-runner.ts +104 -3
- package/src/schema/config-schema.ts +24 -0
- package/src/state/atomic-write.ts +219 -40
- package/src/state/coordination/locks.ts +7 -5
- package/src/state/coordination/mailbox.ts +56 -10
- package/src/state/event-log/cursor.ts +413 -23
- package/src/state/event-log/event-log.ts +120 -113
- package/src/state/event-log/sequence-cache.ts +21 -3
- package/src/state/stores/ownership-map.ts +5 -4
- package/src/state/stores/plan-store.ts +12 -0
- package/src/state/stores/state-store.ts +103 -6
- package/src/state/types.ts +51 -0
- package/src/ui/inline-panel/agent-pane.ts +3 -0
- package/src/ui/powerbar-publisher.ts +3 -7
- package/src/ui/render-diff.ts +16 -8
- package/src/ui/run-action-dispatcher.ts +7 -10
- package/src/ui/run-dashboard.ts +87 -42
- package/src/ui/run-event-bus.ts +10 -1
- package/src/ui/run-snapshot-cache.ts +83 -35
- package/src/ui/settings-overlay.ts +4 -1
- package/src/ui/transcript-cache.ts +101 -13
- package/src/ui/transcript-viewer.ts +92 -24
- package/src/ui/widget/index.ts +32 -8
- package/src/utils/visual.ts +43 -0
- package/src/worktree/worktree-manager.ts +65 -4
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* informational only — the scheduler never derives liveness from them.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import { appendFileSync,
|
|
29
|
+
import { appendFileSync, closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
30
30
|
|
|
31
31
|
export const WORKER_EVENT_TYPE_PATTERN = /^worker\.[a-z0-9_.-]{1,63}$/;
|
|
32
32
|
|
|
@@ -46,6 +46,8 @@ export interface WorkerEventsChannelOptions {
|
|
|
46
46
|
export interface WorkerEventsChannel {
|
|
47
47
|
/** Emit a worker.* event. false = dropped (schema/rate/buffer reasons). */
|
|
48
48
|
emit(type: string, data: Record<string, unknown>): boolean;
|
|
49
|
+
/** Emit a terminal worker.* event (bypasses rate-limit; schema/buffer still apply). */
|
|
50
|
+
emitTerminal(type: string, data: Record<string, unknown>): boolean;
|
|
49
51
|
/** Retry queued appends (called on the next emit internally). */
|
|
50
52
|
flush(): void;
|
|
51
53
|
/** Test introspection. */
|
|
@@ -67,8 +69,21 @@ export function createWorkerEventsChannel(options: WorkerEventsChannelOptions =
|
|
|
67
69
|
// which the orchestrator reader skips).
|
|
68
70
|
let prefix = "";
|
|
69
71
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
+
// PERF (2026-08-24): 1-byte tail read (the comment above always
|
|
73
|
+
// described this; the implementation used to readFileSync the
|
|
74
|
+
// WHOLE file — O(file) reads up to 300x/min per worker for a
|
|
75
|
+
// single byte of information).
|
|
76
|
+
const fd = openSync(eventsPath, "r");
|
|
77
|
+
try {
|
|
78
|
+
const size = fstatSync(fd).size;
|
|
79
|
+
if (size > 0) {
|
|
80
|
+
const tail = Buffer.alloc(1);
|
|
81
|
+
readSync(fd, tail, 0, 1, size - 1);
|
|
82
|
+
if (tail[0] !== 0x0a) prefix = "\n";
|
|
83
|
+
}
|
|
84
|
+
} finally {
|
|
85
|
+
closeSync(fd);
|
|
86
|
+
}
|
|
72
87
|
} catch {
|
|
73
88
|
/* absent file — nothing to separate */
|
|
74
89
|
}
|
|
@@ -163,6 +178,37 @@ export function createWorkerEventsChannel(options: WorkerEventsChannelOptions =
|
|
|
163
178
|
suppressedSinceLast = 0;
|
|
164
179
|
return true;
|
|
165
180
|
},
|
|
181
|
+
emitTerminal(type: string, data: Record<string, unknown>): boolean {
|
|
182
|
+
if (!eventsPath || !runId) return false; // non-team context — no-op
|
|
183
|
+
if (!WORKER_EVENT_TYPE_PATTERN.test(type)) {
|
|
184
|
+
counters.droppedSchema++;
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
// NO sliding-window rate limit — terminal events bypass.
|
|
188
|
+
// Drain any queued items first (FIFO — oldest first), then the new one.
|
|
189
|
+
if (pending.length > 0) {
|
|
190
|
+
const carry = flushInternal(0);
|
|
191
|
+
if (carry > 0) {
|
|
192
|
+
// Writer failing: queue the new item under the FIFO cap.
|
|
193
|
+
if (pending.length >= bufferCap) {
|
|
194
|
+
pending.shift();
|
|
195
|
+
counters.droppedBuffer++;
|
|
196
|
+
}
|
|
197
|
+
pending.push({ type, data, droppedSinceLast: 0 });
|
|
198
|
+
return true; // accepted (queued)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (write({ type, data, droppedSinceLast: 0 })) {
|
|
202
|
+
counters.accepted++;
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
if (pending.length >= bufferCap) {
|
|
206
|
+
pending.shift();
|
|
207
|
+
counters.droppedBuffer++;
|
|
208
|
+
}
|
|
209
|
+
pending.push({ type, data, droppedSinceLast: 0 });
|
|
210
|
+
return true;
|
|
211
|
+
},
|
|
166
212
|
flush(): void {
|
|
167
213
|
flushInternal(0);
|
|
168
214
|
},
|
|
@@ -226,8 +226,36 @@ export const BACKGROUND_RUNNER_ENV_ALLOWLIST: string[] = [
|
|
|
226
226
|
// Phase 1.5 #3: V8 diagnostic report on fatal error (RFC 17 — investigation).
|
|
227
227
|
"PI_CREW_BG_REPORT_ON_FATAL",
|
|
228
228
|
"PI_TEAMS_BG_REPORT_ON_FATAL",
|
|
229
|
+
// MuxSurface: multiplexer env the detached runner needs to DETECT and
|
|
230
|
+
// REACH the host's tmux/herdr — surface follows env + config for async runs
|
|
231
|
+
// too (2026-08-30 Finding 2: without these, every async run gates to no-mux
|
|
232
|
+
// and stays headless). None of these are secret-named (sanitizeEnvSecrets
|
|
233
|
+
// would reject them from the allowlist otherwise).
|
|
234
|
+
"TMUX",
|
|
235
|
+
"TMUX_PANE",
|
|
236
|
+
"TMUX_TMPDIR",
|
|
237
|
+
"HERDR_ENV",
|
|
238
|
+
"HERDR_SESSION",
|
|
239
|
+
"HERDR_PANE_ID",
|
|
240
|
+
"HERDR_SOCKET_PATH",
|
|
241
|
+
"HERDR_WORKSPACE_ID",
|
|
242
|
+
"HERDR_PING_TIMEOUT_MS",
|
|
229
243
|
];
|
|
230
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Stamp the async telemetry marker onto the background runner's env.
|
|
247
|
+
* (MuxSurface async policy, 2026-08-30): `PI_CREW_ASYNC_RUN` is NOT a surface
|
|
248
|
+
* gate — surface follows env + `runtime.surface.*` config, same as sync runs.
|
|
249
|
+
* The marker stays for telemetry (`SurfaceGateEnvSnapshot.asyncRun`). Pane
|
|
250
|
+
* attach from a detached process is safe: parent-guard (PI_CREW_PARENT_PID)
|
|
251
|
+
* still reaps panes when the host dies.
|
|
252
|
+
*
|
|
253
|
+
* Exported for the unit test asserting the invariant.
|
|
254
|
+
*/
|
|
255
|
+
export function buildBackgroundRunnerEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
256
|
+
return { ...env, PI_CREW_ASYNC_RUN: "1" };
|
|
257
|
+
}
|
|
258
|
+
|
|
231
259
|
export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise<SpawnBackgroundTeamRunResult> {
|
|
232
260
|
// FIX (2026-07-02, perf review F-critical): use packageRoot() instead of
|
|
233
261
|
// import.meta.url-relative path. The previous path.resolve walks
|
|
@@ -257,7 +285,7 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
|
|
|
257
285
|
// `npm root -g` probe. No-op when pi-crew and pi are co-located. See
|
|
258
286
|
// src/runtime/peer-dep.ts.
|
|
259
287
|
const peerDepDir = resolvePeerDepDir();
|
|
260
|
-
const childEnv = peerDepDir ? { ...filteredEnv, [PEER_DEP_DIR_ENV]: peerDepDir } : filteredEnv;
|
|
288
|
+
const childEnv = buildBackgroundRunnerEnv(peerDepDir ? { ...filteredEnv, [PEER_DEP_DIR_ENV]: peerDepDir } : filteredEnv);
|
|
261
289
|
|
|
262
290
|
const loader = resolveTypeScriptLoader();
|
|
263
291
|
if (!loader) {
|
|
@@ -5,7 +5,7 @@ import { loadConfig } from "../config/config.ts";
|
|
|
5
5
|
import { getCrewEnv } from "../config/env-vars.ts";
|
|
6
6
|
import { atomicWriteFile } from "../state/atomic-write.ts";
|
|
7
7
|
import { withRunLockSync } from "../state/coordination/locks.ts";
|
|
8
|
-
import { appendEvent, appendEventFireAndForget } from "../state/event-log/event-log.ts";
|
|
8
|
+
import { appendEvent, appendEventBuffered, appendEventFireAndForget } from "../state/event-log/event-log.ts";
|
|
9
9
|
import { createRunPaths, loadRunManifestById, saveRunManifestAsync, updateRunStatus } from "../state/stores/state-store.ts";
|
|
10
10
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
11
11
|
import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
|
|
@@ -18,8 +18,6 @@ import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows
|
|
|
18
18
|
import { primePeerDep } from "./peer-dep.ts";
|
|
19
19
|
import type { executeTeamRun as ExecuteTeamRunFn } from "./team-runner.ts";
|
|
20
20
|
|
|
21
|
-
let _cachedExecuteTeamRun: typeof ExecuteTeamRunFn | undefined;
|
|
22
|
-
|
|
23
21
|
/** Maximum runtime for a single background run before the watchdog force-aborts
|
|
24
22
|
* it. Prevents zombie background-runner processes when a team run hangs forever
|
|
25
23
|
* (e.g. a hung child Pi process, a stuck lock, or a test that spawns a run
|
|
@@ -33,16 +31,13 @@ const MAX_BACKGROUND_RUN_MS = (() => {
|
|
|
33
31
|
return Number.isFinite(env) && env > 0 ? env : 2 * 60 * 60 * 1000;
|
|
34
32
|
})();
|
|
35
33
|
async function executeTeamRun(...args: Parameters<typeof ExecuteTeamRunFn>): Promise<Awaited<ReturnType<typeof ExecuteTeamRunFn>>> {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
_cachedExecuteTeamRun = mod.executeTeamRun;
|
|
44
|
-
}
|
|
45
|
-
return _cachedExecuteTeamRun(...args);
|
|
34
|
+
// FIX (split-scope install): prime the ESM peer dep BEFORE team-runner is
|
|
35
|
+
// imported, so its transitive skill-instructions.ts can read getAgentDir()
|
|
36
|
+
// from the primed cache instead of crashing on `Cannot find module`.
|
|
37
|
+
await primePeerDep().catch(() => undefined);
|
|
38
|
+
// LAZY: avoid pulling team-runner into background-runner at module load time.
|
|
39
|
+
const mod = await import("./team-runner.ts");
|
|
40
|
+
return mod.executeTeamRun(...args);
|
|
46
41
|
}
|
|
47
42
|
|
|
48
43
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
@@ -214,13 +209,19 @@ export function startInterruptGuard(
|
|
|
214
209
|
): () => void {
|
|
215
210
|
const controlPath = path.join(manifest.stateRoot, "foreground-control.json");
|
|
216
211
|
// FIX: Made configurable via PI_CREW_INTERRUPT_GUARD_INTERVAL_MS env var.
|
|
217
|
-
//
|
|
218
|
-
|
|
212
|
+
// PERF (2026-08-24): default 1000ms. Each tick does existsSync + readFileSync
|
|
213
|
+
// + JSON.parse for the whole background run, and the guard is best-effort
|
|
214
|
+
// interrupt LATENCY, not a correctness deadline — verified by reading the
|
|
215
|
+
// trigger body below: a late trigger merely delays the SIGTERM (children are
|
|
216
|
+
// SIGKILLed after HARD_KILL_MS=3s regardless) and no state machine aborts on
|
|
217
|
+
// a deadline when the guard fires late. Nothing downstream requires the old
|
|
218
|
+
// 250ms cadence, so trade up to 4×/s of fs+parse churn for 1×/s.
|
|
219
|
+
const interruptGuardInterval = Number(getCrewEnv("PI_CREW_INTERRUPT_GUARD_INTERVAL_MS")) || 1000;
|
|
219
220
|
// RT-4 FIX: Module-local gate so the interrupt body runs only once per
|
|
220
221
|
// interrupt request. Without this, the guard re-fires every
|
|
221
|
-
// interruptGuardInterval
|
|
222
|
-
// terminateActiveChildPiProcesses sweep + sync appendEvent
|
|
223
|
-
//
|
|
222
|
+
// interruptGuardInterval — each tick does a full
|
|
223
|
+
// terminateActiveChildPiProcesses sweep + sync appendEvent. The ack write
|
|
224
|
+
// stops the re-fire; this gate is defense-in-depth if
|
|
224
225
|
// the ack write fails (e.g. transient fs error).
|
|
225
226
|
let interruptHandled = false;
|
|
226
227
|
const interval = setInterval(() => {
|
|
@@ -238,8 +239,8 @@ export function startInterruptGuard(
|
|
|
238
239
|
|
|
239
240
|
// RT-4 FIX: Write acknowledged:true back to foreground-control.json
|
|
240
241
|
// SYNCHRONOUSLY. This stops the guard from re-firing on the next tick
|
|
241
|
-
// (
|
|
242
|
-
// — we cannot await in a polling callback.
|
|
242
|
+
// (interruptGuardInterval). Must be sync because this is a setInterval
|
|
243
|
+
// polling callback — we cannot await in a polling callback.
|
|
243
244
|
try {
|
|
244
245
|
const reqs = parsed.requests ?? [];
|
|
245
246
|
if (reqs.length > 0) {
|
|
@@ -250,11 +251,11 @@ export function startInterruptGuard(
|
|
|
250
251
|
/* best-effort ack — interruptHandled gate prevents re-fire */
|
|
251
252
|
}
|
|
252
253
|
|
|
253
|
-
|
|
254
|
+
appendEventBuffered(manifest.eventsPath, {
|
|
254
255
|
type: "async.interrupt_detected",
|
|
255
256
|
runId: manifest.runId,
|
|
256
257
|
message: "Background runner detected foreground interrupt — killing child processes and exiting.",
|
|
257
|
-
});
|
|
258
|
+
}).catch((e) => logInternalError("background-runner.buffered", e, "type=async.interrupt_detected"));
|
|
258
259
|
// FIX: Terminate ALL child-pi processes IMMEDIATELY before exiting.
|
|
259
260
|
// Previously this was missing, causing orphaned child processes to run forever
|
|
260
261
|
// after the background-runner exited. terminateActiveChildPiProcesses sends
|
|
@@ -305,7 +306,7 @@ function setupUnhandledRejectionGuard(
|
|
|
305
306
|
console.error("[background-runner] Stack:", reason instanceof Error ? reason.stack : "N/A");
|
|
306
307
|
try {
|
|
307
308
|
if (state.eventsPath && state.runId) {
|
|
308
|
-
|
|
309
|
+
appendEventBuffered(state.eventsPath, {
|
|
309
310
|
type: "async.failed",
|
|
310
311
|
runId: state.runId,
|
|
311
312
|
message: `Unhandled rejection: ${message}`,
|
|
@@ -314,7 +315,7 @@ function setupUnhandledRejectionGuard(
|
|
|
314
315
|
stack: reason instanceof Error ? reason.stack : undefined,
|
|
315
316
|
handled: false,
|
|
316
317
|
},
|
|
317
|
-
});
|
|
318
|
+
}).catch((e) => logInternalError("background-runner.buffered", e, "type=async.failed source=unhandledRejection"));
|
|
318
319
|
}
|
|
319
320
|
} catch (appendErr) {
|
|
320
321
|
console.error("[background-runner] Failed to write async.failed event:", appendErr);
|
|
@@ -371,12 +372,12 @@ function runCleanup(
|
|
|
371
372
|
console.log(`[background-runner] runCleanup: unregisterWorker error: ${errorMessage(error)}`);
|
|
372
373
|
if (eventsPath) {
|
|
373
374
|
try {
|
|
374
|
-
|
|
375
|
+
appendEventBuffered(eventsPath, {
|
|
375
376
|
type: "background.unregister_worker_failed",
|
|
376
377
|
runId: argValue("--run-id") ?? "unknown",
|
|
377
378
|
message: `unregisterWorker failed: ${errorMessage(error)}`,
|
|
378
379
|
data: { pid: process.pid },
|
|
379
|
-
});
|
|
380
|
+
}).catch((e) => logInternalError("background-runner.buffered", e, "type=background.unregister_worker_failed"));
|
|
380
381
|
} catch {
|
|
381
382
|
/* best-effort */
|
|
382
383
|
}
|
|
@@ -532,12 +533,12 @@ async function main(): Promise<void> {
|
|
|
532
533
|
const runId = argValue("--run-id");
|
|
533
534
|
if (runId && manifest.eventsPath) {
|
|
534
535
|
try {
|
|
535
|
-
|
|
536
|
+
appendEventBuffered(manifest.eventsPath, {
|
|
536
537
|
type: "async.sigterm_received_graceful_shutdown",
|
|
537
538
|
runId,
|
|
538
539
|
message: `SIGTERM received, graceful shutdown via abort pid=${process.pid}`,
|
|
539
540
|
data: { pid: process.pid, ppid: process.ppid },
|
|
540
|
-
});
|
|
541
|
+
}).catch((e) => logInternalError("background-runner.buffered", e, "type=async.sigterm_received_graceful_shutdown"));
|
|
541
542
|
} catch {
|
|
542
543
|
/* best-effort */
|
|
543
544
|
}
|
|
@@ -590,12 +591,12 @@ async function main(): Promise<void> {
|
|
|
590
591
|
const codeStr = code === undefined ? "<none>" : String(code);
|
|
591
592
|
if (runId2 && manifest.eventsPath) {
|
|
592
593
|
try {
|
|
593
|
-
|
|
594
|
+
appendEventBuffered(manifest.eventsPath, {
|
|
594
595
|
type: "async.exit",
|
|
595
596
|
runId: runId2,
|
|
596
597
|
message: `Background runner exit(${codeStr}) pid=${process.pid}`,
|
|
597
598
|
data: { code, pid: process.pid },
|
|
598
|
-
});
|
|
599
|
+
}).catch((e) => logInternalError("background-runner.buffered", e, "type=async.exit"));
|
|
599
600
|
} catch {
|
|
600
601
|
/* best-effort */
|
|
601
602
|
}
|
|
@@ -629,11 +630,11 @@ async function main(): Promise<void> {
|
|
|
629
630
|
// NOTE: intentionally no unref() — the guard keeps the event loop alive
|
|
630
631
|
// to prevent premature worker exit. See parent-guard.ts:86 for rationale.
|
|
631
632
|
|
|
632
|
-
|
|
633
|
+
appendEventBuffered(manifest.eventsPath, {
|
|
633
634
|
type: "async.started",
|
|
634
635
|
runId: manifest.runId,
|
|
635
636
|
data: { pid: process.pid },
|
|
636
|
-
});
|
|
637
|
+
}).catch((e) => logInternalError("background-runner.buffered", e, "type=async.started"));
|
|
637
638
|
debugLog(`[background-runner] async.started written, pid=${process.pid}`);
|
|
638
639
|
writeAsyncStartMarker(manifest, {
|
|
639
640
|
pid: process.pid,
|
|
@@ -657,12 +658,12 @@ async function main(): Promise<void> {
|
|
|
657
658
|
const watchdogTimer = setTimeout(() => {
|
|
658
659
|
console.error(`[background-runner] WATCHDOG: run ${runId} exceeded ${MAX_BACKGROUND_RUN_MS}ms — aborting (zombie prevention)`);
|
|
659
660
|
try {
|
|
660
|
-
|
|
661
|
+
appendEventBuffered(manifest.eventsPath, {
|
|
661
662
|
type: "async.watchdog_fired",
|
|
662
663
|
runId,
|
|
663
664
|
message: `Run exceeded ${MAX_BACKGROUND_RUN_MS}ms and was force-aborted to prevent a zombie background-runner process.`,
|
|
664
665
|
data: { maxRunMs: MAX_BACKGROUND_RUN_MS },
|
|
665
|
-
});
|
|
666
|
+
}).catch((e) => logInternalError("background-runner.buffered", e, "type=async.watchdog_fired"));
|
|
666
667
|
} catch {
|
|
667
668
|
/* best-effort event log */
|
|
668
669
|
}
|
|
@@ -819,12 +820,12 @@ async function main(): Promise<void> {
|
|
|
819
820
|
updatedAt: new Date().toISOString(),
|
|
820
821
|
};
|
|
821
822
|
await saveRunManifestAsync(manifest);
|
|
822
|
-
|
|
823
|
+
appendEventBuffered(manifest.eventsPath, {
|
|
823
824
|
type: "runtime.resolved",
|
|
824
825
|
runId: manifest.runId,
|
|
825
826
|
message: `Runtime resolved: ${runtime.kind} safety=${runtime.safety}`,
|
|
826
827
|
data: { runtimeResolution, async: true },
|
|
827
|
-
});
|
|
828
|
+
}).catch((e) => logInternalError("background-runner.buffered", e, "type=runtime.resolved"));
|
|
828
829
|
if (runtime.safety === "blocked")
|
|
829
830
|
throw new Error(runtime.reason ?? "Child worker execution is disabled; refusing to create no-op scaffold subagents.");
|
|
830
831
|
const executeWorkers = runtime.kind !== "scaffold";
|
|
@@ -884,11 +885,11 @@ async function main(): Promise<void> {
|
|
|
884
885
|
} // close if (!earlyResult) — team-run setup+execute done; earlyResult path skips to here
|
|
885
886
|
manifest = result!.manifest;
|
|
886
887
|
tasks = result!.tasks;
|
|
887
|
-
|
|
888
|
+
appendEventBuffered(manifest.eventsPath, {
|
|
888
889
|
type: "async.completed",
|
|
889
890
|
runId: manifest.runId,
|
|
890
891
|
data: { status: manifest.status, tasks: tasks.length },
|
|
891
|
-
});
|
|
892
|
+
}).catch((e) => logInternalError("background-runner.buffered", e, "type=async.completed"));
|
|
892
893
|
console.log(`[background-runner] async.completed written, status=${manifest.status}`);
|
|
893
894
|
if (manifest.status === "failed" || manifest.status === "cancelled" || manifest.status === "blocked") process.exitCode = 1;
|
|
894
895
|
} catch (error) {
|
|
@@ -909,11 +910,11 @@ async function main(): Promise<void> {
|
|
|
909
910
|
const fresh = loaded?.manifest ?? manifest;
|
|
910
911
|
if (fresh) {
|
|
911
912
|
manifest = updateRunStatus(fresh, "failed", message);
|
|
912
|
-
|
|
913
|
+
appendEventBuffered(manifest.eventsPath, {
|
|
913
914
|
type: "async.failed",
|
|
914
915
|
runId: manifest.runId,
|
|
915
916
|
message,
|
|
916
|
-
});
|
|
917
|
+
}).catch((e) => logInternalError("background-runner.buffered", e, "type=async.failed source=updateFailed"));
|
|
917
918
|
}
|
|
918
919
|
return fresh;
|
|
919
920
|
},
|
|
@@ -982,12 +983,12 @@ try {
|
|
|
982
983
|
const mRunId = argValue("--run-id");
|
|
983
984
|
if (mCwd && mRunId) {
|
|
984
985
|
const mEventsPath = createRunPaths(mCwd, mRunId).eventsPath;
|
|
985
|
-
|
|
986
|
+
appendEventBuffered(mEventsPath, {
|
|
986
987
|
type: "async.failed",
|
|
987
988
|
runId: mRunId,
|
|
988
989
|
message: errorMessage(err),
|
|
989
990
|
data: { stack: err instanceof Error ? err.stack : undefined },
|
|
990
|
-
});
|
|
991
|
+
}).catch((e) => logInternalError("background-runner.buffered", e, "type=async.failed source=catch"));
|
|
991
992
|
}
|
|
992
993
|
} catch {
|
|
993
994
|
/* best-effort — don't let event-write failure mask the original error */
|
|
@@ -28,8 +28,9 @@ export interface BrokerSpawnCredentials {
|
|
|
28
28
|
* ADR-5 §4 (governed nesting): `childDepth` carries the DEPTH-2+ grandchild's
|
|
29
29
|
* depth when the root-side delegate handler spawns it. The issuer mints only
|
|
30
30
|
* for children that may themselves delegate (childDepth < resolved
|
|
31
|
-
* PI_CREW_MAX_DEPTH) — at the default maxDepth=
|
|
32
|
-
* credentials (env containment AC: no PI_CREW_BROKER_SOCKET/TOKEN at
|
|
31
|
+
* PI_CREW_MAX_DEPTH) — at the default maxDepth=4 a depth-4 grandchild gets NO
|
|
32
|
+
* credentials (env containment AC: no PI_CREW_BROKER_SOCKET/TOKEN at the cap
|
|
33
|
+
* depth).
|
|
33
34
|
* Undefined childDepth = legacy worker spawn (depth 1) — unchanged behavior. */
|
|
34
35
|
export type BrokerIssuer = (runId: string, taskId?: string, childDepth?: number) => Promise<BrokerSpawnCredentials | undefined>;
|
|
35
36
|
|
|
@@ -44,3 +45,27 @@ export function setActiveBrokerIssuer(issuer: BrokerIssuer | undefined): void {
|
|
|
44
45
|
export function getActiveBrokerIssuer(): BrokerIssuer | undefined {
|
|
45
46
|
return activeIssuer;
|
|
46
47
|
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* MuxSurface A1 (spec §7 D3 step 2): process-local revoker for per-task tokens.
|
|
51
|
+
* The team-runner's surface-degrade controller calls it when a pane is lost so
|
|
52
|
+
* the zombie worker left in that pane can no longer authenticate to the broker;
|
|
53
|
+
* the headless respawn mints a FRESH token (T10 re-issue).
|
|
54
|
+
*
|
|
55
|
+
* Same registration model as the issuer: the lifecycle controller publishes a
|
|
56
|
+
* closure on start and clears it on stop, so the degrade path needs no wiring
|
|
57
|
+
* through every runner layer. A function reference only — never a token.
|
|
58
|
+
*/
|
|
59
|
+
export type BrokerRevoker = (taskId: string) => void;
|
|
60
|
+
|
|
61
|
+
let activeRevoker: BrokerRevoker | undefined;
|
|
62
|
+
|
|
63
|
+
/** Register the active revoker (called by the lifecycle controller on start). */
|
|
64
|
+
export function setActiveBrokerRevoker(revoker: BrokerRevoker | undefined): void {
|
|
65
|
+
activeRevoker = revoker;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Read the active revoker, if any. Returns undefined when no broker is wired. */
|
|
69
|
+
export function getActiveBrokerRevoker(): BrokerRevoker | undefined {
|
|
70
|
+
return activeRevoker;
|
|
71
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* independently testable.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
13
|
+
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
14
14
|
|
|
15
15
|
/** Length guard: tokens are 128-bit-class (UUID v4). */
|
|
16
16
|
export type BrokerToken = string;
|
|
@@ -37,6 +37,21 @@ export function newBrokerToken(): BrokerToken {
|
|
|
37
37
|
*/
|
|
38
38
|
export class BrokerTokenRegistry {
|
|
39
39
|
private readonly map = new Map<string, BrokerToken>();
|
|
40
|
+
/** Task 10 (mux-surface A1 §5.2): sha256 hashes of explicitly revoked
|
|
41
|
+
* tokens. Heap-only like the map; hashing keeps the registry from holding
|
|
42
|
+
* a second plaintext copy of a secret. Revocation is per-SECRET, not
|
|
43
|
+
* per-key: `issue()` mints a fresh token when the registered one is in
|
|
44
|
+
* this set (fix round 1 BUG #1), so the A2 re-issue remedy always
|
|
45
|
+
* produces a live token while the old secret stays dead. */
|
|
46
|
+
private readonly revokedTokenHashes = new Set<string>();
|
|
47
|
+
|
|
48
|
+
/** Public so the broker can hash the hello secret ONCE at auth time and
|
|
49
|
+
* keep only the digest on the connection (fix round 2 BUG #3: the frame
|
|
50
|
+
* revocation check is secret-based — a connection authenticated with a
|
|
51
|
+
* revoked secret stays dead even after the key is re-issued). */
|
|
52
|
+
static hashToken(token: BrokerToken): string {
|
|
53
|
+
return createHash("sha256").update(token, "utf8").digest("hex");
|
|
54
|
+
}
|
|
40
55
|
|
|
41
56
|
/** Compute the registry key. Compound when taskId is present, bare
|
|
42
57
|
* runId otherwise (backward-compat with the original per-run model). */
|
|
@@ -72,8 +87,12 @@ export class BrokerTokenRegistry {
|
|
|
72
87
|
/** Issue a token for `runId` (+optional `taskId`). Idempotent per key:
|
|
73
88
|
* if a token already exists for the computed key, the existing token is
|
|
74
89
|
* returned unchanged so that concurrent sibling tasks sharing a key all
|
|
75
|
-
* authenticate with the same token
|
|
76
|
-
*
|
|
90
|
+
* authenticate with the same token — UNLESS that token was revoked (fix
|
|
91
|
+
* round 1, BUG #1): a revoked secret is never resurrected, a fresh one
|
|
92
|
+
* is minted and overwrites the entry (the degrade respawn flow re-issues
|
|
93
|
+
* after revokeTaskToken and the new worker must get a live token).
|
|
94
|
+
* Pass an explicit `token` only in tests that need a deterministic
|
|
95
|
+
* value. */
|
|
77
96
|
issue(runId: string, taskId?: string, token?: BrokerToken): BrokerToken {
|
|
78
97
|
if (typeof runId !== "string" || runId.length === 0) {
|
|
79
98
|
throw new Error("BrokerTokenRegistry.issue: runId must be a non-empty string");
|
|
@@ -81,7 +100,9 @@ export class BrokerTokenRegistry {
|
|
|
81
100
|
const k = this.key(runId, taskId);
|
|
82
101
|
if (token === undefined) {
|
|
83
102
|
const existing = this.map.get(k);
|
|
84
|
-
if (existing !== undefined
|
|
103
|
+
if (existing !== undefined && !this.revokedTokenHashes.has(BrokerTokenRegistry.hashToken(existing))) {
|
|
104
|
+
return existing;
|
|
105
|
+
}
|
|
85
106
|
const fresh = newBrokerToken();
|
|
86
107
|
this.map.set(k, fresh);
|
|
87
108
|
return fresh;
|
|
@@ -184,9 +205,40 @@ export class BrokerTokenRegistry {
|
|
|
184
205
|
if (!taskId) this.map.delete(this.orchestratorKey(runId));
|
|
185
206
|
}
|
|
186
207
|
|
|
208
|
+
/** Task 10 (mux-surface A1 §5.2): mark `token` revoked. The next hello
|
|
209
|
+
* presenting it — and every subsequent frame on a connection already
|
|
210
|
+
* authenticated with it — is rejected with code "revoked". No-op on a
|
|
211
|
+
* non-string/empty input. */
|
|
212
|
+
revokeToken(token: BrokerToken): void {
|
|
213
|
+
if (typeof token !== "string" || token.length === 0) return;
|
|
214
|
+
this.revokedTokenHashes.add(BrokerTokenRegistry.hashToken(token));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Task 10: whether the token currently registered for the compound
|
|
218
|
+
* (runId, taskId) key has been revoked. False when no such key exists
|
|
219
|
+
* (undefined ids, orchestrator connections, legacy bare-runId fallbacks)
|
|
220
|
+
* and short-circuits while nothing was ever revoked. This is the HELLO
|
|
221
|
+
* path check — at hello time the candidate must still match the map, so
|
|
222
|
+
* key resolution is equivalent to secret resolution. */
|
|
223
|
+
isTaskTokenRevoked(runId: string | undefined, taskId: string | undefined): boolean {
|
|
224
|
+
if (this.revokedTokenHashes.size === 0 || !runId || !taskId) return false;
|
|
225
|
+
const token = this.map.get(this.key(runId, taskId));
|
|
226
|
+
return token !== undefined && this.revokedTokenHashes.has(BrokerTokenRegistry.hashToken(token));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Fix round 2 (BUG #3): whether a specific SECRET digest has been
|
|
230
|
+
* revoked. The post-hello frame check uses this — it must evaluate the
|
|
231
|
+
* secret the connection ACTUALLY authenticated with, not whatever token
|
|
232
|
+
* the key currently holds (a revoke → re-issue window must not let an
|
|
233
|
+
* old connection ride a freshly issued token). */
|
|
234
|
+
isSecretRevoked(hash: string): boolean {
|
|
235
|
+
return this.revokedTokenHashes.has(hash);
|
|
236
|
+
}
|
|
237
|
+
|
|
187
238
|
/** Wipe every token. Called from CrewBroker.stop(). */
|
|
188
239
|
clear(): void {
|
|
189
240
|
this.map.clear();
|
|
241
|
+
this.revokedTokenHashes.clear();
|
|
190
242
|
}
|
|
191
243
|
|
|
192
244
|
/** Diagnostic — count of registered tokens. Never returns the tokens. */
|