pi-crew 0.10.4 → 0.10.6
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 +233 -0
- package/agents/analyst.md +37 -2
- package/agents/cold-verifier.md +10 -1
- package/agents/councillor-critic.md +39 -0
- package/agents/councillor-pragmatist.md +39 -0
- package/agents/councillor-skeptic.md +41 -0
- package/agents/critic.md +40 -2
- package/agents/designer.md +58 -0
- package/agents/executor.md +39 -2
- package/agents/explorer.md +38 -2
- package/agents/librarian.md +49 -0
- package/agents/oracle.md +54 -0
- package/agents/orchestrator.md +48 -0
- package/agents/planner.md +41 -2
- package/agents/reviewer.md +39 -2
- package/agents/security-reviewer.md +43 -2
- package/agents/test-engineer.md +48 -2
- package/agents/verifier.md +14 -1
- package/agents/writer.md +32 -2
- package/dist/index.mjs +1297 -853
- package/package.json +1 -1
- package/skills/async-worker-recovery/SKILL.md +4 -1
- package/skills/child-pi-spawning/SKILL.md +4 -1
- package/skills/context-artifact-hygiene/SKILL.md +4 -1
- package/skills/council/SKILL.md +24 -45
- package/skills/delegation-patterns/SKILL.md +18 -1
- package/skills/distill-persona/SKILL.md +4 -1
- package/skills/distill-software/SKILL.md +4 -1
- package/skills/event-log-tracing/SKILL.md +4 -1
- package/skills/git-master/SKILL.md +4 -1
- package/skills/iterative-audit/SKILL.md +4 -1
- package/skills/live-agent-lifecycle/SKILL.md +4 -1
- package/skills/mailbox-interactive/SKILL.md +4 -1
- package/skills/model-routing-context/SKILL.md +10 -1
- package/skills/multi-perspective-review/SKILL.md +18 -1
- package/skills/observability-reliability/SKILL.md +4 -1
- package/skills/orchestration/SKILL.md +18 -1
- package/skills/ownership-session-security/SKILL.md +4 -1
- package/skills/pi-extension-lifecycle/SKILL.md +4 -1
- package/skills/post-mortem/SKILL.md +4 -1
- package/skills/read-only-explorer/SKILL.md +4 -1
- package/skills/real-test-pi-crew/SKILL.md +165 -12
- package/skills/requirements-to-task-packet/SKILL.md +10 -1
- package/skills/research/SKILL.md +4 -1
- package/skills/resource-discovery-config/SKILL.md +10 -1
- package/skills/runtime-state-reader/SKILL.md +4 -1
- package/skills/safe-bash/SKILL.md +4 -1
- package/skills/scrutinize/SKILL.md +24 -1
- package/skills/secure-agent-orchestration-review/SKILL.md +4 -1
- package/skills/state-mutation-locking/SKILL.md +4 -1
- package/skills/systematic-debugging/SKILL.md +4 -1
- package/skills/verification-before-done/SKILL.md +18 -1
- package/skills/widget-rendering/SKILL.md +4 -1
- package/skills/workspace-isolation/SKILL.md +4 -1
- package/skills/worktree-isolation/SKILL.md +4 -1
- package/src/config/config-validation.ts +1 -0
- package/src/config/types.ts +8 -0
- package/src/errors.ts +1 -1
- package/src/extension/context-status-injection.ts +2 -2
- package/src/extension/knowledge-injection.ts +19 -7
- package/src/extension/post-init-skill-check.ts +32 -0
- package/src/extension/register.ts +9 -1
- package/src/extension/registration/hook-registration.ts +20 -3
- package/src/extension/registration/tool-loop-guard.ts +243 -0
- package/src/extension/team-tool/handle-settings.ts +10 -0
- package/src/extension/team-tool/run.ts +42 -1
- package/src/extension/team-tool-types.ts +6 -0
- package/src/prompt/prompt-runtime.ts +25 -6
- package/src/runtime/async-runner.ts +75 -11
- package/src/runtime/background-runner.ts +73 -7
- package/src/runtime/broker/crew-broker-client.ts +45 -2
- package/src/runtime/broker/crew-broker.ts +22 -27
- package/src/runtime/broker/protocol/request-parsers.ts +10 -2
- package/src/runtime/broker/stdin-handshake.ts +87 -0
- package/src/runtime/broker/wait-push.ts +45 -0
- package/src/runtime/detached-run-results.ts +25 -1
- package/src/runtime/foreground-watchdog.ts +24 -5
- package/src/runtime/live-session/live-session-runtime.ts +1 -1
- package/src/runtime/model/model-scope.ts +2 -2
- package/src/runtime/run-tracker.ts +74 -19
- package/src/runtime/skill-instructions.ts +20 -4
- package/src/runtime/task-runner/child-executor.ts +1 -1
- package/src/runtime/task-runner/prompt-builder.ts +22 -9
- package/src/schema/config-schema.ts +1 -0
- package/src/skills/discover-skills.ts +2 -2
- package/src/ui/settings-overlay.ts +40 -0
- package/src/utils/frontmatter.ts +7 -1
- package/src/utils/ndjson.ts +9 -1
|
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
6
|
import { getCrewEnv } from "../config/env-vars.ts";
|
|
7
7
|
import { appendEventAsync } from "../state/event-log/event-log.ts";
|
|
8
8
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
@@ -26,17 +26,14 @@ export type LoaderSpec = { kind: "jiti"; path: string } | { kind: "strip-types"
|
|
|
26
26
|
|
|
27
27
|
type LoaderInput = LoaderSpec | string | false | undefined;
|
|
28
28
|
|
|
29
|
-
function packageRootFromRuntime(): string {
|
|
30
|
-
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
31
|
-
}
|
|
32
|
-
|
|
33
29
|
function jitiRegisterPathFromPackageJson(packageJsonPath: string): string {
|
|
34
30
|
return path.join(path.dirname(packageJsonPath), "lib", "jiti-register.mjs");
|
|
35
31
|
}
|
|
36
32
|
|
|
37
|
-
export function resolveJitiRegisterPath(
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
export function resolveJitiRegisterPath(pkgRoot: string | undefined, exists: FileExists = fs.existsSync): string | undefined {
|
|
34
|
+
const effectiveRoot = pkgRoot ?? packageRoot();
|
|
35
|
+
// Walk upward from effectiveRoot looking for node_modules/jiti/lib/jiti-register.mjs
|
|
36
|
+
let current = path.resolve(effectiveRoot);
|
|
40
37
|
const root = path.parse(current).root;
|
|
41
38
|
while (true) {
|
|
42
39
|
const candidate = path.join(current, "node_modules", "jiti", "lib", "jiti-register.mjs");
|
|
@@ -115,7 +112,7 @@ export function getBackgroundRunnerCommand(
|
|
|
115
112
|
reportDirectory?: string,
|
|
116
113
|
): { args: string[]; loader: "jiti" | "strip-types" } {
|
|
117
114
|
const loader = normalizeLoaderInput(loaderInput);
|
|
118
|
-
if (!loader) throw new Error(buildLoaderUnavailableMessage(
|
|
115
|
+
if (!loader) throw new Error(buildLoaderUnavailableMessage(packageRoot()));
|
|
119
116
|
// Limit V8 heap to 512MB for the background runner to avoid triggering the
|
|
120
117
|
// Linux OOM killer. The runner itself is lightweight — it delegates work to
|
|
121
118
|
// child Pi processes — so 512MB is generous. Without this limit, Node.js
|
|
@@ -256,6 +253,15 @@ export function buildBackgroundRunnerEnv(env: NodeJS.ProcessEnv): NodeJS.Process
|
|
|
256
253
|
return { ...env, PI_CREW_ASYNC_RUN: "1" };
|
|
257
254
|
}
|
|
258
255
|
|
|
256
|
+
/** F4 v2: the ONE line written to the background-runner's stdin carrying
|
|
257
|
+
* PER-TASK compound broker tokens (see stdin-handshake.ts for why compound —
|
|
258
|
+
* ADR-0 item 6 rejects bare-runId tokens for wait.*). Kept pure + exported so
|
|
259
|
+
* tests pin the WRITER format against the READER parser — a drift between
|
|
260
|
+
* the two silently breaks coordination for every async run. */
|
|
261
|
+
export function buildBrokerStdinLine(runId: string, socketPath: string, tasks: Record<string, string>): string {
|
|
262
|
+
return `${JSON.stringify({ v: 2, runId, socketPath, tasks })}\n`;
|
|
263
|
+
}
|
|
264
|
+
|
|
259
265
|
export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise<SpawnBackgroundTeamRunResult> {
|
|
260
266
|
// FIX (2026-07-02, perf review F-critical): use packageRoot() instead of
|
|
261
267
|
// import.meta.url-relative path. The previous path.resolve walks
|
|
@@ -289,7 +295,7 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
|
|
|
289
295
|
|
|
290
296
|
const loader = resolveTypeScriptLoader();
|
|
291
297
|
if (!loader) {
|
|
292
|
-
const message = buildLoaderUnavailableMessage(
|
|
298
|
+
const message = buildLoaderUnavailableMessage(packageRoot());
|
|
293
299
|
// FIX-08: use async event append to avoid sleepSync event-loop blocking.
|
|
294
300
|
await appendEventAsync(manifest.eventsPath, {
|
|
295
301
|
type: "async.failed",
|
|
@@ -318,11 +324,69 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
|
|
|
318
324
|
cwd: manifest.cwd,
|
|
319
325
|
detached: true,
|
|
320
326
|
setsid: true,
|
|
321
|
-
|
|
327
|
+
// F4 (2026-09-12 live battery): stdin is a PIPE — broker credentials
|
|
328
|
+
// travel heap → pipe → heap right after spawn (see below). Previously
|
|
329
|
+
// "ignore", which (together with the env allowlist and the missing
|
|
330
|
+
// runner-side issuer) left EVERY async worker broker-less: ask/message
|
|
331
|
+
// fell back to "proceed with best judgment" silently.
|
|
332
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
322
333
|
env: childEnv,
|
|
323
334
|
windowsHide: true,
|
|
324
335
|
} as unknown as Parameters<typeof spawn>[2];
|
|
325
336
|
const child = spawn(process.execPath, command.args, spawnOpts);
|
|
337
|
+
// F4: hand the runner a per-run broker credential over stdin. The env
|
|
338
|
+
// route is CLOSED BY DESIGN — BACKGROUND_RUNNER_ENV_ALLOWLIST cannot carry
|
|
339
|
+
// PI_CREW_BROKER_TOKEN (secret-suffixed names are rejected by the
|
|
340
|
+
// sanitizeEnvSecrets validator, and a PI_CREW_BROKER_* glob is flagged
|
|
341
|
+
// isDangerousGlob). The token never touches disk (invariant,
|
|
342
|
+
// lifecycle-handlers.ts:990) and dies with the parent session (heap-only
|
|
343
|
+
// registry). issuer(runId) without taskId mints the LEGACY per-run token,
|
|
344
|
+
// which the registry accepts for any task of the run via its bare-runId
|
|
345
|
+
// fallback (crew-broker-tokens.ts get()). Best-effort: any failure here
|
|
346
|
+
// leaves the runner creds-less (= previous behavior), never throws.
|
|
347
|
+
try {
|
|
348
|
+
let line: string;
|
|
349
|
+
// LAZY: broker issuer only when a handshake payload needs minting.
|
|
350
|
+
const { getActiveBrokerIssuer } = await import("./broker/broker-issuer.ts");
|
|
351
|
+
const issuer = getActiveBrokerIssuer();
|
|
352
|
+
if (issuer) {
|
|
353
|
+
// F4 v2: pre-mint a COMPOUND (runId+taskId) token for every task of the
|
|
354
|
+
// run — wait.* rejects bare-runId tokens (ADR-0 item 6), so the legacy
|
|
355
|
+
// issuer(runId) shortcut left every park forbidden. Tasks are persisted
|
|
356
|
+
// BEFORE dispatch (tasksPath exists on the manifest); dynamic workflows
|
|
357
|
+
// that plan tasks inside the runner get no creds (follow-up: broker-side
|
|
358
|
+
// mint RPC).
|
|
359
|
+
// LAZY: state-store pulls the whole stores chain into the async runner.
|
|
360
|
+
const { loadRunManifestByIdAsync } = await import("../state/stores/state-store.ts");
|
|
361
|
+
const loaded = await loadRunManifestByIdAsync(manifest.cwd, manifest.runId);
|
|
362
|
+
const runTasks = loaded?.tasks ?? [];
|
|
363
|
+
const tasks: Record<string, string> = {};
|
|
364
|
+
let socketPath: string | undefined;
|
|
365
|
+
for (const task of runTasks) {
|
|
366
|
+
if (!task?.id) continue;
|
|
367
|
+
const creds = await issuer(manifest.runId, task.id);
|
|
368
|
+
if (creds) {
|
|
369
|
+
tasks[task.id] = creds.token;
|
|
370
|
+
socketPath ??= creds.socketPath;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (socketPath && Object.keys(tasks).length > 0) {
|
|
374
|
+
line = buildBrokerStdinLine(manifest.runId, socketPath, tasks);
|
|
375
|
+
child.stdin?.write(line);
|
|
376
|
+
} else {
|
|
377
|
+
child.stdin?.write("\n");
|
|
378
|
+
}
|
|
379
|
+
} else {
|
|
380
|
+
child.stdin?.write("\n");
|
|
381
|
+
}
|
|
382
|
+
} catch {
|
|
383
|
+
/* best-effort: runner proceeds creds-less */
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
child.stdin?.end();
|
|
387
|
+
} catch {
|
|
388
|
+
/* EPIPE: runner already died — the spawn error handling below owns that */
|
|
389
|
+
}
|
|
326
390
|
// Round 27 (BUG 3) history: the piped stdout/stderr were previously destroyed
|
|
327
391
|
// immediately to avoid a pipe-buffer deadlock (child writes >64KB with nobody
|
|
328
392
|
// draining → hang). BUT destroying stderr ALSO swallowed native crash
|
|
@@ -10,7 +10,6 @@ import { createRunPaths, loadRunManifestById, saveRunManifestAsync, updateRunSta
|
|
|
10
10
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
11
11
|
import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
|
|
12
12
|
import { errorMessage } from "../utils/guards.ts";
|
|
13
|
-
import { projectCrewRoot } from "../utils/paths.ts";
|
|
14
13
|
import { assertSafePathId } from "../utils/safe-paths.ts";
|
|
15
14
|
import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
|
|
16
15
|
// Heavy runtime — lazy-loaded to avoid pulling team-runner into background-runner
|
|
@@ -42,6 +41,10 @@ async function executeTeamRun(...args: Parameters<typeof ExecuteTeamRunFn>): Pro
|
|
|
42
41
|
|
|
43
42
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
44
43
|
import { writeAsyncStartMarker } from "./async-marker.ts";
|
|
44
|
+
// F4: broker creds handshake helpers live in broker/stdin-handshake.ts
|
|
45
|
+
// (pure module — background-runner runs await main() at module scope, so
|
|
46
|
+
// importing THIS file from tests would boot the runner).
|
|
47
|
+
import { parseStdinBrokerPayload, readStdinFirstLine } from "./broker/stdin-handshake.ts";
|
|
45
48
|
import { terminateActiveChildPiProcesses } from "./child-pi/child-pi.ts";
|
|
46
49
|
import { directTeamAndWorkflowFromRun } from "./direct-run.ts";
|
|
47
50
|
import { resolveCrewRuntime, runtimeResolutionState } from "./model/runtime-resolver.ts";
|
|
@@ -160,6 +163,27 @@ export function signalEventType(sig: string): "async.signal" | "async.failed" {
|
|
|
160
163
|
return BENIGN_SIGNALS.has(sig) ? "async.signal" : "async.failed";
|
|
161
164
|
}
|
|
162
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Scope-aware diagnostic paths for the background runner (issue #55, follows
|
|
168
|
+
* the #54 fix in run-tracker.ts). Runs created in a markerless (non-git) cwd
|
|
169
|
+
* live under userCrewRoot() — resolved via the same createRunPaths/
|
|
170
|
+
* scopeBaseRoot chain used at run CREATION — while project cwds keep .crew/ or
|
|
171
|
+
* the .pi/teams/ fallback (issue #29). Pure path math: createRunPaths asserts
|
|
172
|
+
* the runId (R11-2 boundary hardening preserved) and never touches the fs.
|
|
173
|
+
*
|
|
174
|
+
* These must stay (cwd, runId)-derived: both sites run BEFORE the manifest is
|
|
175
|
+
* loaded (the console redirect is the FIRST thing main() does; exitCodePath is
|
|
176
|
+
* computed at module load), so manifest.stateRoot is not available yet.
|
|
177
|
+
*/
|
|
178
|
+
export function backgroundLogPath(cwd: string, runId: string): string {
|
|
179
|
+
return path.join(createRunPaths(cwd, runId).stateRoot, "background.log");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** exit-code.txt sibling of background.log — same scope resolution (#55). */
|
|
183
|
+
export function backgroundExitCodePath(cwd: string, runId: string): string {
|
|
184
|
+
return path.join(createRunPaths(cwd, runId).stateRoot, "exit-code.txt");
|
|
185
|
+
}
|
|
186
|
+
|
|
163
187
|
/**
|
|
164
188
|
* Fire-and-forget event log for signal handlers. Extracted to module level
|
|
165
189
|
* (from inside main()) so the exported SIGINT handler installer (test seam)
|
|
@@ -421,10 +445,12 @@ async function main(): Promise<void> {
|
|
|
421
445
|
// best-effort try/catch so the hardening is never silently swallowed).
|
|
422
446
|
assertSafePathId("runId", _runId);
|
|
423
447
|
try {
|
|
424
|
-
//
|
|
425
|
-
// manifest
|
|
426
|
-
//
|
|
427
|
-
|
|
448
|
+
// Scope-aware (issue #55): resolve via createRunPaths so the log lands
|
|
449
|
+
// next to the manifest for BOTH project cwds (.crew/ or the .pi/teams/
|
|
450
|
+
// fallback, issue #29) and markerless cwds (user scope, issue #54). The
|
|
451
|
+
// previous projectCrewRoot join silently dropped the console redirect
|
|
452
|
+
// for user-scope runs — exactly the log needed to diagnose them.
|
|
453
|
+
const logPath = backgroundLogPath(_cwd, _runId);
|
|
428
454
|
logFd = fs.openSync(logPath, "a");
|
|
429
455
|
const origWrite =
|
|
430
456
|
(_prefix: string) =>
|
|
@@ -478,8 +504,11 @@ async function main(): Promise<void> {
|
|
|
478
504
|
// IIFE runs at MODULE LOAD, so an unsafe runId throws before the runner
|
|
479
505
|
// starts (intended fail-fast, matching run-import.ts:105 pattern).
|
|
480
506
|
assertSafePathId("runId", runId);
|
|
481
|
-
//
|
|
482
|
-
|
|
507
|
+
// Scope-aware (issue #55): same resolution as backgroundLogPath — project
|
|
508
|
+
// scope (.crew/ or .pi/teams/, issue #29) or user scope (#54). The
|
|
509
|
+
// previous projectCrewRoot join never landed exit-code.txt for user-scope
|
|
510
|
+
// runs, hiding non-zero exit diagnostics.
|
|
511
|
+
return backgroundExitCodePath(cwd, runId);
|
|
483
512
|
})();
|
|
484
513
|
if (exitCodePath) {
|
|
485
514
|
process.on("exit", (code) => {
|
|
@@ -498,6 +527,43 @@ async function main(): Promise<void> {
|
|
|
498
527
|
const cwd = argValue("--cwd");
|
|
499
528
|
const runId = argValue("--run-id");
|
|
500
529
|
if (!cwd || !runId) throw new Error("Usage: background-runner.ts --cwd <cwd> --run-id <runId>");
|
|
530
|
+
// F4 (2026-09-12 live battery): broker creds arrive on STDIN from the
|
|
531
|
+
// dispatching session (heap → pipe → heap; token never written to disk).
|
|
532
|
+
// Without this, every async worker loses ask/message/mailbox/steer — the
|
|
533
|
+
// env route is closed (allowlist rejects secret-suffixed tokens) and no
|
|
534
|
+
// extension lifecycle runs here to register an issuer. Best-effort:
|
|
535
|
+
// absent/invalid payload = creds-less runner = previous behavior.
|
|
536
|
+
try {
|
|
537
|
+
const raw = await readStdinFirstLine();
|
|
538
|
+
const payload = raw ? parseStdinBrokerPayload(raw, runId) : undefined;
|
|
539
|
+
if (payload) {
|
|
540
|
+
// LAZY: broker issuer module has process-level side effects at load.
|
|
541
|
+
const { setActiveBrokerIssuer } = await import("./broker/broker-issuer.ts");
|
|
542
|
+
// LAZY: pi-args pulls the model registry chain.
|
|
543
|
+
const { resolveCrewMaxDepth } = await import("./model/pi-args.ts");
|
|
544
|
+
// Static issuer scoped to THIS run, serving PRE-MINTED per-task COMPOUND
|
|
545
|
+
// tokens — wait.* rejects bare-runId tokens (ADR-0 item 6), so the v1
|
|
546
|
+
// single-token shortcut left every park forbidden. Unknown taskIds
|
|
547
|
+
// (dynamic workflows planned in-runner) get NO creds — follow-up:
|
|
548
|
+
// broker-side mint RPC. Depth-cap parity with the parent-side
|
|
549
|
+
// issueForChild gate (lifecycle-handlers.ts:1130-1136).
|
|
550
|
+
setActiveBrokerIssuer(async (rid, taskId, childDepth) => {
|
|
551
|
+
if (rid !== payload.runId) return undefined;
|
|
552
|
+
if (childDepth !== undefined && childDepth >= resolveCrewMaxDepth(undefined)) return undefined;
|
|
553
|
+
if (!taskId) return undefined;
|
|
554
|
+
const token = payload.tasks[taskId];
|
|
555
|
+
if (!token) return undefined;
|
|
556
|
+
return { socketPath: payload.socketPath, token };
|
|
557
|
+
});
|
|
558
|
+
debugLog(
|
|
559
|
+
`[broker] stdin handshake accepted for run ${runId} (${Object.keys(payload.tasks).length} task tokens, socket ${payload.socketPath})`,
|
|
560
|
+
);
|
|
561
|
+
} else {
|
|
562
|
+
debugLog(`[broker] no stdin creds payload — runner proceeds broker-less (pre-F4 behavior)`);
|
|
563
|
+
}
|
|
564
|
+
} catch {
|
|
565
|
+
/* best-effort: never fail boot over coordination creds */
|
|
566
|
+
}
|
|
501
567
|
// FIX Issue #3: Wrap in withRunLockSync to prevent concurrent background-runners
|
|
502
568
|
// for the same runId from reading stale manifest state. If lock cannot be
|
|
503
569
|
// be acquired within 5s, fail immediately rather than proceeding with stale data.
|
|
@@ -42,6 +42,14 @@ const BROKER_PROTOCOL = 1;
|
|
|
42
42
|
/** Per-attempt timeout for connect + hello. */
|
|
43
43
|
const CONNECT_HELLO_TIMEOUT_MS = 5_000;
|
|
44
44
|
|
|
45
|
+
/** F5 (2026-09-12 live probe): default per-request RPC timeout. A response
|
|
46
|
+
* frame lost on a half-dead socket (no close event — observed live: worker
|
|
47
|
+
* stuck inside `await client.request("wait.request")` past its own ask
|
|
48
|
+
* deadline, because the deadline check lives AFTER the request resolves)
|
|
49
|
+
* would otherwise hang the caller forever. Callers with a longer natural
|
|
50
|
+
* cap (ask: timeoutSec) pass their own timeoutMs. */
|
|
51
|
+
const REQUEST_TIMEOUT_DEFAULT_MS = 15_000;
|
|
52
|
+
|
|
45
53
|
/** Bounded backoff schedule (ms). At most 4 attempts means 3 retries after
|
|
46
54
|
* the first failure. Jitter is ±25%. */
|
|
47
55
|
const BACKOFF_SCHEDULE_MS: readonly number[] = [50, 100, 200, 400, 800] as const;
|
|
@@ -83,6 +91,8 @@ interface PendingRequest {
|
|
|
83
91
|
method: string;
|
|
84
92
|
resolve: (value: unknown) => void;
|
|
85
93
|
reject: (err: Error) => void;
|
|
94
|
+
/** F5: per-request timeout timer; cleared on settle + close. */
|
|
95
|
+
timer?: NodeJS.Timeout;
|
|
86
96
|
}
|
|
87
97
|
|
|
88
98
|
export class CrewBrokerClient {
|
|
@@ -153,7 +163,7 @@ export class CrewBrokerClient {
|
|
|
153
163
|
* Never throws. The caller can continue using file-based fallback paths
|
|
154
164
|
* without unwrapping anything.
|
|
155
165
|
*/
|
|
156
|
-
async request<T = unknown>(method: string, params: unknown): Promise<BrokerClientResult<T>> {
|
|
166
|
+
async request<T = unknown>(method: string, params: unknown, opts?: { timeoutMs?: number }): Promise<BrokerClientResult<T>> {
|
|
157
167
|
if (this._mode === "fallback") {
|
|
158
168
|
return { ok: false, fallback: true, errorCode: "fallback-sticky" };
|
|
159
169
|
}
|
|
@@ -176,9 +186,26 @@ export class CrewBrokerClient {
|
|
|
176
186
|
// Send the request. Send a frame FIRST so the server's hello gate
|
|
177
187
|
// cannot reject it as "method other than hello".
|
|
178
188
|
const id = `r-${randomUUID()}`;
|
|
189
|
+
let entry: PendingRequest | undefined;
|
|
179
190
|
const promise = new Promise<unknown>((resolve, reject) => {
|
|
180
|
-
|
|
191
|
+
entry = { id, method, resolve, reject };
|
|
192
|
+
this.pending.set(id, entry);
|
|
181
193
|
});
|
|
194
|
+
// F5: arm the per-request timeout BEFORE the write — a frame lost on a
|
|
195
|
+
// half-dead socket produces neither a response nor a close, and the
|
|
196
|
+
// caller would hang forever (observed live on a parked ask worker).
|
|
197
|
+
// Rejecting the pending entry funnels into the existing catch below
|
|
198
|
+
// (typed errorCode + enterFallbackOnce), so no new code path is needed.
|
|
199
|
+
const timeoutMs = opts?.timeoutMs ?? REQUEST_TIMEOUT_DEFAULT_MS;
|
|
200
|
+
const requestTimer: NodeJS.Timeout = (this.options.setTimeoutFn ?? ((cb: () => void, ms: number) => setTimeout(cb, ms)))(
|
|
201
|
+
() => {
|
|
202
|
+
const pendingEntry = this.pending.get(id);
|
|
203
|
+
this.pending.delete(id);
|
|
204
|
+
pendingEntry?.reject(new BrokerError("request-timeout", `no response for ${method} within ${timeoutMs}ms`));
|
|
205
|
+
},
|
|
206
|
+
Math.max(1, Math.floor(timeoutMs)),
|
|
207
|
+
);
|
|
208
|
+
if (entry) entry.timer = requestTimer;
|
|
182
209
|
try {
|
|
183
210
|
const frame = encodeBrokerFrame({ id, method, params });
|
|
184
211
|
// Write may emit EPIPE etc. We don't await drain here — the response
|
|
@@ -206,6 +233,15 @@ export class CrewBrokerClient {
|
|
|
206
233
|
const code = err instanceof BrokerError ? err.code : "request-failed";
|
|
207
234
|
this.enterFallbackOnce(code, err);
|
|
208
235
|
return { ok: false, fallback: true, errorCode: code };
|
|
236
|
+
} finally {
|
|
237
|
+
// F5: disarm the per-request timeout on ANY settle path (response,
|
|
238
|
+
// broker-error envelope, socket rejection) so late timer fires cannot
|
|
239
|
+
// reject an already-consumed pending entry.
|
|
240
|
+
try {
|
|
241
|
+
(this.options.clearTimeoutFn ?? ((t: NodeJS.Timeout) => clearTimeout(t)))(requestTimer);
|
|
242
|
+
} catch {
|
|
243
|
+
/* best-effort */
|
|
244
|
+
}
|
|
209
245
|
}
|
|
210
246
|
}
|
|
211
247
|
|
|
@@ -278,6 +314,13 @@ export class CrewBrokerClient {
|
|
|
278
314
|
// Resolving with undefined would have made request() return
|
|
279
315
|
// {ok:true, value:undefined}, which is misleading.
|
|
280
316
|
for (const [, p] of this.pending) {
|
|
317
|
+
if (p.timer) {
|
|
318
|
+
try {
|
|
319
|
+
(this.options.clearTimeoutFn ?? ((t: NodeJS.Timeout) => clearTimeout(t)))(p.timer);
|
|
320
|
+
} catch {
|
|
321
|
+
/* best-effort */
|
|
322
|
+
}
|
|
323
|
+
}
|
|
281
324
|
p.reject(new BrokerError("close", "client closed"));
|
|
282
325
|
}
|
|
283
326
|
this.pending.clear();
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
WAIT_REQUEST_TIMEOUT_SEC_MAX,
|
|
59
59
|
} from "./protocol/request-parsers.ts";
|
|
60
60
|
import { recordWaitPolicyRejection, waitAuthError } from "./protocol/wait-auth.ts";
|
|
61
|
+
import { pushWaitingToForegroundWaiter } from "./wait-push.ts";
|
|
61
62
|
import { WaitStatusCache } from "./wait-status-cache.ts";
|
|
62
63
|
|
|
63
64
|
/** Protocol version negotiated at `hello` time. Bump on breaking change.
|
|
@@ -1194,22 +1195,13 @@ export class CrewBroker {
|
|
|
1194
1195
|
await pollUntilDone();
|
|
1195
1196
|
}
|
|
1196
1197
|
|
|
1197
|
-
/**
|
|
1198
|
-
*
|
|
1199
|
-
*
|
|
1200
|
-
*
|
|
1201
|
-
*
|
|
1202
|
-
*
|
|
1203
|
-
*
|
|
1204
|
-
* 2. Steering-file append — writes the steer body to
|
|
1205
|
-
* ${artifactsRoot}/steering/${taskId}.jsonl, the same file the
|
|
1206
|
-
* child's pollSteering() polls via PI_CREW_STEERING_FILE. This is
|
|
1207
|
-
* the durable fallback: even if the recipient child's broker connection is down, the
|
|
1208
|
-
* child picks up the steer on its next poll tick.
|
|
1209
|
-
*
|
|
1210
|
-
* A steering-file write failure does NOT fail the steer push — the
|
|
1211
|
-
* mailbox write (1) has already succeeded.
|
|
1212
|
-
*/
|
|
1198
|
+
/** Phase 3: steer.push — push steering message to a running worker.
|
|
1199
|
+
* Dual-write for durability: (1) mailbox append feeds the live broker
|
|
1200
|
+
* fanout AND persists to the inbox JSONL; (2) steering-file append writes
|
|
1201
|
+
* ${artifactsRoot}/steering/${taskId}.jsonl — the durable fallback the
|
|
1202
|
+
* child's pollSteering() polls via PI_CREW_STEERING_FILE even when its
|
|
1203
|
+
* broker connection is down. A steering-file write failure does NOT fail
|
|
1204
|
+
* the push — the mailbox write has already succeeded. */
|
|
1213
1205
|
private async handleSteerPush(conn: ServerConnection, id: string, params: unknown): Promise<void> {
|
|
1214
1206
|
if (conn.role !== "orchestrator") {
|
|
1215
1207
|
this.sendError(conn, id, "forbidden", "steer.push requires orchestrator role");
|
|
@@ -1873,6 +1865,17 @@ export class CrewBroker {
|
|
|
1873
1865
|
timeoutSec: clampSec,
|
|
1874
1866
|
clamped,
|
|
1875
1867
|
});
|
|
1868
|
+
// F1 (2026-09-12 live battery): release the sync foreground waiter with
|
|
1869
|
+
// the question (evidence + design notes in broker/wait-push.ts).
|
|
1870
|
+
await pushWaitingToForegroundWaiter({
|
|
1871
|
+
cwd: this.options.cwd,
|
|
1872
|
+
runId,
|
|
1873
|
+
taskId,
|
|
1874
|
+
questionId,
|
|
1875
|
+
question: parsed.question,
|
|
1876
|
+
deadline,
|
|
1877
|
+
...(parsed.options ? { options: parsed.options } : {}),
|
|
1878
|
+
});
|
|
1876
1879
|
}
|
|
1877
1880
|
|
|
1878
1881
|
/** WP-2/R2: terminal report of the parked `ask` tool — flips the task
|
|
@@ -1986,14 +1989,6 @@ export class CrewBroker {
|
|
|
1986
1989
|
// Type guards (no `any`)
|
|
1987
1990
|
// ============================================================================
|
|
1988
1991
|
|
|
1989
|
-
/**
|
|
1990
|
-
*
|
|
1991
|
-
*
|
|
1992
|
-
* - parseMsgSendParams + MsgSendParams
|
|
1993
|
-
* - parseMsgInboxParams + MsgInboxParams
|
|
1994
|
-
* - parseWaitRequestParams + WaitRequestParams
|
|
1995
|
-
* - parseWaitResolveParams + WaitResolveParams
|
|
1996
|
-
* - safeStringify
|
|
1997
|
-
* - WAIT_* constants
|
|
1998
|
-
* Removed from this file; re-exported via "./protocol/request-parsers.ts".
|
|
1999
|
-
*/
|
|
1992
|
+
/** Parsers/constants moved to ./protocol/request-parsers.ts (M4 / WI-4.1):
|
|
1993
|
+
* hello/msg/wait params + safeStringify + WAIT_* constants — re-exported
|
|
1994
|
+
* from there. */
|
|
@@ -118,8 +118,16 @@ export function safeStringify(value: unknown): string {
|
|
|
118
118
|
* timeoutSec may NEVER exceed 1h — an unbounded timeout would pin slots and
|
|
119
119
|
* amplify I/O. Applied as deadline = now + min(timeoutSec, 3600). */
|
|
120
120
|
export const WAIT_REQUEST_TIMEOUT_SEC_MAX = 3600;
|
|
121
|
-
/** Default ask timeout when the caller omits timeoutSec (ADR item 1).
|
|
122
|
-
|
|
121
|
+
/** Default ask timeout when the caller omits timeoutSec (ADR item 1).
|
|
122
|
+
* F2 (2026-09-12 live battery): MUST stay strictly below the worker response
|
|
123
|
+
* watchdog (`DEFAULT_CHILD_PI.responseTimeoutMs` = 600s). A parked worker
|
|
124
|
+
* emits NO output, so the watchdog counts the whole park; at 600==600 the
|
|
125
|
+
* kill raced the wake and the worker died at its own deadline
|
|
126
|
+
* (team_20260912014448: parked 01:46:01, response_timeout 01:56:01). 480s
|
|
127
|
+
* leaves 120s grace for the worker to wake, answer its fallback, and finish
|
|
128
|
+
* the turn. MAX stays 3600 (ADR P2-7) — a park longer than ~600s needs
|
|
129
|
+
* PI_TEAMS_CHILD_RESPONSE_TIMEOUT_MS raised to survive the watchdog. */
|
|
130
|
+
export const WAIT_REQUEST_TIMEOUT_SEC_DEFAULT = 480;
|
|
123
131
|
/** Bounded question payload (defense-in-depth under the 256 KiB frame cap). */
|
|
124
132
|
export const WAIT_QUESTION_MAX_CHARS = 8192;
|
|
125
133
|
/** Bounded answer-choice list: at most 16 options, 256 chars each. */
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stdin-handshake.ts — F4 (2026-09-12 live battery): broker credentials for
|
|
3
|
+
* the DETACHED background runner travel on STDIN (heap → pipe → heap).
|
|
4
|
+
*
|
|
5
|
+
* Why stdin: the env route is CLOSED BY DESIGN — BACKGROUND_RUNNER_ENV_ALLOWLIST
|
|
6
|
+
* cannot carry PI_CREW_BROKER_TOKEN (secret-suffixed names are rejected by the
|
|
7
|
+
* sanitizeEnvSecrets validator, and a PI_CREW_BROKER_* glob is flagged
|
|
8
|
+
* isDangerousGlob), and the token must NEVER be written to disk (invariant,
|
|
9
|
+
* lifecycle-handlers.ts:990). Without this handshake every async-run worker
|
|
10
|
+
* loses ask/message/mailbox/steer coordination and silently falls back to
|
|
11
|
+
* "proceed with best judgment".
|
|
12
|
+
*
|
|
13
|
+
* Pure protocol pieces live here (not in background-runner.ts, which runs
|
|
14
|
+
* `await main()` at module scope) so tests can import them without booting
|
|
15
|
+
* the runner.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The one-line payload async-runner writes to the runner's stdin carrying
|
|
19
|
+
* PER-TASK compound tokens. v2 (ADR-0 2026-08-17 item 6): wait.* accepts
|
|
20
|
+
* task-scoped (compound) tokens ONLY — the legacy bare-runId token
|
|
21
|
+
* authenticates the connection but waitAuthError rejects its parks with
|
|
22
|
+
* `forbidden`. The dispatching session pre-mints a compound token for every
|
|
23
|
+
* task in the manifest (they exist before dispatch); dynamic-workflow tasks
|
|
24
|
+
* planned inside the runner get NO creds (follow-up: broker mint RPC). */
|
|
25
|
+
export interface StdinBrokerPayload {
|
|
26
|
+
v: 2;
|
|
27
|
+
runId: string;
|
|
28
|
+
socketPath: string;
|
|
29
|
+
tasks: Record<string, string>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Parse + validate one handshake line. Rejects wrong version, wrong run
|
|
33
|
+
* (cross-run containment), and missing/empty fields. Pure. */
|
|
34
|
+
export function parseStdinBrokerPayload(raw: string, expectedRunId: string): StdinBrokerPayload | undefined {
|
|
35
|
+
try {
|
|
36
|
+
const obj: unknown = JSON.parse(raw.trim());
|
|
37
|
+
if (!obj || typeof obj !== "object") return undefined;
|
|
38
|
+
const o = obj as Record<string, unknown>;
|
|
39
|
+
if (o.v !== 2) return undefined;
|
|
40
|
+
if (o.runId !== expectedRunId) return undefined;
|
|
41
|
+
if (typeof o.socketPath !== "string" || o.socketPath.length === 0) return undefined;
|
|
42
|
+
const tasks = o.tasks;
|
|
43
|
+
if (!tasks || typeof tasks !== "object" || Array.isArray(tasks)) return undefined;
|
|
44
|
+
for (const [id, tok] of Object.entries(tasks)) {
|
|
45
|
+
if (id.length === 0 || typeof tok !== "string" || tok.length === 0) return undefined;
|
|
46
|
+
}
|
|
47
|
+
return { v: 2, runId: o.runId, socketPath: o.socketPath, tasks: tasks as Record<string, string> };
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Read the FIRST stdin line with a hard cap. Resolves undefined for: no
|
|
54
|
+
* stdin / TTY / silence past the timeout / oversize / error. Never throws,
|
|
55
|
+
* never destroys stdin (leaves it paused for the rest of boot). */
|
|
56
|
+
export function readStdinFirstLine(timeoutMs = 2000): Promise<string | undefined> {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
const stdin = process.stdin;
|
|
59
|
+
if (!stdin || stdin.isTTY || stdin.destroyed || !stdin.readable) {
|
|
60
|
+
resolve(undefined);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
let settled = false;
|
|
64
|
+
const finish = (value: string | undefined): void => {
|
|
65
|
+
if (settled) return;
|
|
66
|
+
settled = true;
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
stdin.removeListener("data", onData);
|
|
69
|
+
resolve(value);
|
|
70
|
+
};
|
|
71
|
+
const timer = setTimeout(() => finish(undefined), timeoutMs);
|
|
72
|
+
let buf = "";
|
|
73
|
+
const onData = (chunk: Buffer | string): void => {
|
|
74
|
+
buf += typeof chunk === "string" ? chunk : chunk.toString("utf-8");
|
|
75
|
+
const nl = buf.indexOf("\n");
|
|
76
|
+
if (nl >= 0) {
|
|
77
|
+
stdin.pause();
|
|
78
|
+
finish(buf.slice(0, nl));
|
|
79
|
+
} else if (buf.length > 64 * 1024) {
|
|
80
|
+
finish(undefined);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
stdin.on("data", onData);
|
|
84
|
+
stdin.on("end", () => finish(buf.length > 0 ? buf : undefined));
|
|
85
|
+
stdin.on("error", () => finish(undefined));
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { loadRunManifestById } from "../../state/stores/state-store.ts";
|
|
2
|
+
|
|
3
|
+
/** F1 (2026-09-12 live battery): release the sync foreground waiter with the
|
|
4
|
+
* parked question. Without this push, the only entity that can answer (the
|
|
5
|
+
* leader LLM) stays suspended inside its own `team` tool call until the
|
|
6
|
+
* response watchdog kills the parked worker (evidence: team_20260912014448 —
|
|
7
|
+
* parked 01:46:01, response_timeout 01:56:01, tool call returned only after
|
|
8
|
+
* the failure; refuted-fix round 2 evidence: team_20260912053049).
|
|
9
|
+
*
|
|
10
|
+
* resolveRunPromise is a no-op when no waiter is registered (async/detached
|
|
11
|
+
* runs poll instead). Best-effort by design: a failure here must not fail
|
|
12
|
+
* the park itself. Kept in its own module (LAZY-imported from the broker) so
|
|
13
|
+
* crew-broker.ts stays under the M4 2000-line gate and no static
|
|
14
|
+
* broker→run-tracker edge exists at module load (there is no cycle —
|
|
15
|
+
* run-tracker has no broker import). */
|
|
16
|
+
export async function pushWaitingToForegroundWaiter(params: {
|
|
17
|
+
cwd: string | undefined;
|
|
18
|
+
runId: string;
|
|
19
|
+
taskId: string;
|
|
20
|
+
questionId: string;
|
|
21
|
+
question: string;
|
|
22
|
+
deadline: number;
|
|
23
|
+
options?: string[];
|
|
24
|
+
}): Promise<void> {
|
|
25
|
+
try {
|
|
26
|
+
// LAZY: run-tracker import kept lazy — see the module doc above.
|
|
27
|
+
const { resolveRunPromise } = await import("../run-tracker.ts");
|
|
28
|
+
const freshPark = loadRunManifestById(params.cwd ?? process.cwd(), params.runId);
|
|
29
|
+
if (freshPark) {
|
|
30
|
+
resolveRunPromise(params.runId, {
|
|
31
|
+
manifest: freshPark.manifest,
|
|
32
|
+
tasks: freshPark.tasks,
|
|
33
|
+
waiting: {
|
|
34
|
+
taskId: params.taskId,
|
|
35
|
+
questionId: params.questionId,
|
|
36
|
+
question: params.question,
|
|
37
|
+
deadline: params.deadline,
|
|
38
|
+
...(params.options ? { options: params.options } : {}),
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
/* best-effort push: a failure here must not fail the park itself */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -11,21 +11,31 @@
|
|
|
11
11
|
* The registry is in-process; a pending result is parked while an agent view is
|
|
12
12
|
* open so the worker's own view session never receives the parent's report.
|
|
13
13
|
*/
|
|
14
|
+
|
|
14
15
|
import { loadRunManifestById } from "../state/stores/state-store.ts";
|
|
15
16
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
17
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
16
18
|
import { isFinishedRunStatus } from "./process-status.ts";
|
|
17
19
|
|
|
18
20
|
interface DetachedRun {
|
|
19
21
|
runId: string;
|
|
20
22
|
cwd: string;
|
|
23
|
+
/** Delivery attempts (P2-7). Each peek of a finished run is one attempt;
|
|
24
|
+
* a successful send forgets the entry, so the counter only accumulates on
|
|
25
|
+
* repeated send failures. Bounded at MAX_DELIVERY_ATTEMPTS to stop an
|
|
26
|
+
* indefinitely-failing send from retrying every tick forever. */
|
|
27
|
+
attempts: number;
|
|
21
28
|
}
|
|
22
29
|
|
|
30
|
+
/** P2-7: give up after this many delivery attempts and log the drop. */
|
|
31
|
+
const MAX_DELIVERY_ATTEMPTS = 3;
|
|
32
|
+
|
|
23
33
|
const detachedRuns = new Map<string, DetachedRun>();
|
|
24
34
|
|
|
25
35
|
/** Record a run whose foreground waiter was released by a view switch. */
|
|
26
36
|
export function markRunDetached(runId: string, cwd: string): void {
|
|
27
37
|
if (!runId || !cwd) return;
|
|
28
|
-
detachedRuns.set(runId, { runId, cwd });
|
|
38
|
+
detachedRuns.set(runId, { runId, cwd, attempts: 0 });
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
/** Cheap guard for hot paths (render tick): nothing to do when empty. */
|
|
@@ -84,6 +94,20 @@ export function peekFinishedDetachedRunResults(options: { inViewSession?: boolea
|
|
|
84
94
|
continue;
|
|
85
95
|
}
|
|
86
96
|
if (!isFinishedRunStatus(loaded.manifest.status)) continue;
|
|
97
|
+
// P2-7: bounded delivery — drop after MAX_DELIVERY_ATTEMPTS failed
|
|
98
|
+
// cycles (a successful send forgets the entry, so reaching here again
|
|
99
|
+
// means the previous send threw).
|
|
100
|
+
entry.attempts += 1;
|
|
101
|
+
if (entry.attempts > MAX_DELIVERY_ATTEMPTS) {
|
|
102
|
+
detachedRuns.delete(entry.runId);
|
|
103
|
+
logInternalError(
|
|
104
|
+
"detached-run-results.delivery-gave-up",
|
|
105
|
+
new Error("delivery attempts exceeded"),
|
|
106
|
+
`runId=${entry.runId} attempts=${entry.attempts} — dropped after ${MAX_DELIVERY_ATTEMPTS} failed sends`,
|
|
107
|
+
"warn",
|
|
108
|
+
);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
87
111
|
ready.push({ runId: entry.runId, text: formatDetachedRunResult(loaded.manifest, loaded.tasks) });
|
|
88
112
|
}
|
|
89
113
|
return ready;
|