pi-crew 0.10.1 → 0.10.3
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 +347 -0
- package/NOTICE.md +21 -0
- package/README.md +44 -2
- package/agents/executor.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/dist/index.mjs +2555 -932
- package/package.json +2 -1
- package/schema.json +503 -94
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
- package/skills/real-test-pi-crew/SKILL.md +278 -79
- package/src/config/config-merge.ts +11 -1
- package/src/config/config-validation.ts +47 -2
- package/src/config/config.ts +28 -6
- package/src/config/defaults.ts +43 -11
- package/src/config/env-vars.ts +27 -2
- package/src/config/role-tools.ts +4 -2
- package/src/config/types.ts +55 -1
- package/src/extension/crew-cleanup.ts +13 -0
- package/src/extension/crew-vibes/footer.ts +19 -0
- package/src/extension/crew-vibes/index.ts +11 -1
- package/src/extension/register.ts +8 -0
- package/src/extension/registration/command-registration.ts +1 -0
- package/src/extension/registration/commands/run.ts +15 -1
- package/src/extension/registration/commands/shared.ts +8 -0
- package/src/extension/registration/foreground-run-controller.ts +10 -2
- package/src/extension/registration/lifecycle-handlers.ts +92 -11
- package/src/extension/registration/runtime-cleanup.ts +23 -5
- package/src/extension/registration/team-tool.ts +58 -6
- package/src/extension/registration/ui.ts +5 -4
- package/src/extension/team-tool/doctor.ts +364 -7
- package/src/extension/team-tool/handle-settings.ts +19 -0
- package/src/extension/team-tool/inspect.ts +10 -2
- package/src/extension/team-tool/run-deadline.ts +20 -3
- package/src/extension/team-tool/run.ts +26 -2
- package/src/extension/team-tool/status.ts +7 -0
- package/src/extension/team-tool.ts +35 -2
- package/src/hooks/registry.ts +59 -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/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 +13 -7
- 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 +261 -41
- package/src/runtime/child-pi/child-pi-constants.ts +8 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
- package/src/runtime/child-pi/child-pi-streams.ts +30 -2
- package/src/runtime/child-pi/child-pi.ts +353 -5
- package/src/runtime/crew-agent-records.ts +13 -1
- package/src/runtime/detached-run-results.ts +90 -0
- package/src/runtime/dispatch-batch.ts +12 -1
- package/src/runtime/event-log-tail-source.ts +374 -0
- package/src/runtime/finalize-run.ts +4 -0
- package/src/runtime/goal-workflow/adaptive-plan.ts +30 -3
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +5 -1
- 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/merge-gate.ts +25 -9
- package/src/runtime/model/model-fallback.ts +7 -3
- package/src/runtime/model/pi-args.ts +54 -65
- package/src/runtime/output/sidechain-output.ts +61 -6
- package/src/runtime/process/proc-stat.ts +46 -0
- package/src/runtime/process/zombie-scanner.ts +32 -19
- package/src/runtime/process-status.ts +16 -1
- package/src/runtime/recovery/crash-recovery.ts +16 -0
- package/src/runtime/run-tracker.ts +77 -10
- package/src/runtime/spawn-policy.ts +27 -41
- 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 +47 -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 -2
- package/src/schema/config-schema.ts +25 -1
- 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/plan-store.ts +1 -1
- package/src/state/stores/state-store.ts +171 -9
- package/src/state/types.ts +53 -0
- package/src/ui/dock-footer.ts +49 -0
- package/src/ui/inline-panel/agent-pane.ts +378 -0
- package/src/ui/inline-panel/agent-transcript.ts +338 -0
- package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
- package/src/ui/inline-panel/crew-editor.ts +192 -0
- package/src/ui/inline-panel/index.ts +290 -0
- package/src/ui/inline-panel/panel-rows.ts +37 -0
- package/src/ui/inline-panel/panel-selection.ts +157 -0
- package/src/ui/inline-panel/panel-store.ts +111 -0
- package/src/ui/inline-panel/view-session-store.ts +36 -0
- package/src/ui/pi-ui-compat.ts +9 -0
- package/src/ui/render-diff.ts +16 -8
- 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/transcript-cache.ts +101 -13
- package/src/ui/transcript-viewer.ts +92 -24
- package/src/ui/widget/index.ts +203 -25
- package/src/ui/widget/task-list.ts +198 -0
- package/src/ui/widget/widget-formatters.ts +240 -4
- package/src/ui/widget/widget-renderer.ts +234 -37
- package/src/ui/widget/widget-types.ts +11 -0
- package/src/utils/child-process-shield.ts +106 -0
- package/src/utils/redaction.ts +7 -0
- package/src/utils/safe-abort.ts +45 -0
- package/src/utils/visual.ts +43 -0
- package/src/workflows/discover-workflows.ts +1 -0
- package/src/workflows/workflow-config.ts +7 -0
- package/src/worktree/worktree-manager.ts +65 -4
- package/workflows/default.workflow.md +36 -26
- package/workflows/strict-fast-fix.workflow.md +26 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
3
4
|
import { redactSecrets } from "../../utils/redaction.ts";
|
|
4
5
|
import { isSafePathId } from "../../utils/safe-paths.ts";
|
|
5
6
|
|
|
@@ -12,15 +13,69 @@ export interface SidechainEntry {
|
|
|
12
13
|
cwd: string;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
// ── Batched JSONL writer (Task 26, 2026-08-24) ──────────────────────────
|
|
17
|
+
// PERF (2026-08-24): the old writeSidechainEntry did one mkdirSync +
|
|
18
|
+
// appendFileSync + full redaction clone PER STREAMING EVENT (message_update
|
|
19
|
+
// fires per chunk). Batch per path on a 50ms unref'd timer like
|
|
20
|
+
// child-pi-transcript; redaction + serialization still run per event at
|
|
21
|
+
// queue time, and the flush does ONE mkdir + append per path per window.
|
|
22
|
+
interface PendingJsonlBatch {
|
|
23
|
+
lines: string[];
|
|
24
|
+
timer: NodeJS.Timeout;
|
|
25
|
+
}
|
|
26
|
+
const pendingJsonlBatches = new Map<string, PendingJsonlBatch>();
|
|
27
|
+
const JSONL_FLUSH_MS = 50;
|
|
28
|
+
|
|
29
|
+
function queueJsonlLine(filePath: string, line: string): void {
|
|
30
|
+
const pending = pendingJsonlBatches.get(filePath);
|
|
31
|
+
if (pending) {
|
|
32
|
+
pending.lines.push(line);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const timer = setTimeout(() => flushJsonlBatch(filePath), JSONL_FLUSH_MS);
|
|
36
|
+
timer.unref();
|
|
37
|
+
pendingJsonlBatches.set(filePath, { lines: [line], timer });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function flushJsonlBatch(filePath: string): void {
|
|
41
|
+
const pending = pendingJsonlBatches.get(filePath);
|
|
42
|
+
if (!pending) return;
|
|
43
|
+
pendingJsonlBatches.delete(filePath);
|
|
44
|
+
clearTimeout(pending.timer);
|
|
45
|
+
try {
|
|
46
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
47
|
+
fs.appendFileSync(filePath, pending.lines.join(""), "utf-8");
|
|
48
|
+
} catch (error) {
|
|
49
|
+
logInternalError("sidechain-output.flush", error as Error, `path=${filePath}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
15
53
|
export function writeSidechainEntry(filePath: string, entry: Omit<SidechainEntry, "isSidechain" | "timestamp">): void {
|
|
16
|
-
|
|
17
|
-
fs.appendFileSync(
|
|
18
|
-
filePath,
|
|
19
|
-
`${JSON.stringify(redactSecrets({ isSidechain: true, timestamp: new Date().toISOString(), ...entry }))}\n`,
|
|
20
|
-
"utf-8",
|
|
21
|
-
);
|
|
54
|
+
queueJsonlLine(filePath, `${JSON.stringify(redactSecrets({ isSidechain: true, timestamp: new Date().toISOString(), ...entry }))}\n`);
|
|
22
55
|
}
|
|
23
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Append one pre-serialized JSONL line through the shared batched writer.
|
|
59
|
+
* Task 26: the live-session transcript append routes through the same
|
|
60
|
+
* per-path 50ms batching as sidechain output. child-pi-transcript's batched
|
|
61
|
+
* writer was NOT reused — it takes a ChildPiRunInput and enforces
|
|
62
|
+
* artifactsRoot containment + a second redaction pass (redactJsonLine over
|
|
63
|
+
* the already-redacted line), so it is not generic over plain paths.
|
|
64
|
+
*/
|
|
65
|
+
export function appendBatchedJsonlLine(filePath: string, line: string): void {
|
|
66
|
+
queueJsonlLine(filePath, line);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @internal — flush on session teardown / process exit so files are complete. */
|
|
70
|
+
export function flushPendingSidechainWrites(): void {
|
|
71
|
+
for (const filePath of [...pendingJsonlBatches.keys()]) flushJsonlBatch(filePath);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The batch timers are unref'd, so a naturally-draining event loop can exit
|
|
75
|
+
// with unflushed telemetry. Same exit-hook pattern as atomic-write.ts and
|
|
76
|
+
// crew-agent-records.ts (sync-only work, safe inside "exit").
|
|
77
|
+
process.on("exit", () => flushPendingSidechainWrites());
|
|
78
|
+
|
|
24
79
|
export function sidechainOutputPath(stateRoot: string, taskId: string): string {
|
|
25
80
|
if (!isSafePathId(taskId)) throw new Error(`Invalid taskId: ${taskId}`);
|
|
26
81
|
return path.join(stateRoot, "agents", taskId, "sidechain.output.jsonl");
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* proc-stat.ts — THE single `/proc/<pid>/stat` field parser.
|
|
3
|
+
*
|
|
4
|
+
* Three consumers previously hand-rolled the same paren-aware split (the
|
|
5
|
+
* surface spawn's parent starttime capture, the worker-side parent-guard, and
|
|
6
|
+
* the zombie scanner's ppid/starttime read). A divergence between them is not
|
|
7
|
+
* cosmetic: the spawn writes `PI_CREW_PARENT_START_TIME` from field 22, and
|
|
8
|
+
* the worker kills itself when that value stops matching — so both sides MUST
|
|
9
|
+
* index identically. comm (field 2) may contain spaces AND ')' characters,
|
|
10
|
+
* hence everything up to the LAST ")" is skipped, never a naive whitespace
|
|
11
|
+
* split.
|
|
12
|
+
*
|
|
13
|
+
* Layout after the comm field: rest[0] = state, rest[1] = ppid, …
|
|
14
|
+
* rest[STARTTIME_INDEX] = starttime (field 22, clock ticks since boot).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Field 22 (starttime) minus the two consumed by the comm cut = index 19. */
|
|
18
|
+
export const PROC_STAT_STARTTIME_INDEX = 19;
|
|
19
|
+
/** Field 3 (state) → first entry after the comm cut. */
|
|
20
|
+
export const PROC_STAT_STATE_INDEX = 0;
|
|
21
|
+
/** Field 4 (ppid) → second entry after the comm cut. */
|
|
22
|
+
export const PROC_STAT_PPID_INDEX = 1;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Fields of a `/proc/<pid>/stat` line AFTER the comm column (paren-aware).
|
|
26
|
+
* Returns undefined when the buffer has no closing paren at all.
|
|
27
|
+
*/
|
|
28
|
+
export function fieldsAfterComm(stat: string): string[] | undefined {
|
|
29
|
+
const lastParen = stat.lastIndexOf(")");
|
|
30
|
+
if (lastParen === -1) return undefined;
|
|
31
|
+
return stat
|
|
32
|
+
.slice(lastParen + 1)
|
|
33
|
+
.trim()
|
|
34
|
+
.split(/\s+/);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Raw starttime ticks (field 22) as a string, for byte-exact comparison with
|
|
39
|
+
* the value the spawner recorded. undefined when absent/unparseable.
|
|
40
|
+
*/
|
|
41
|
+
export function procStartTimeTicks(stat: string): string | undefined {
|
|
42
|
+
const fields = fieldsAfterComm(stat);
|
|
43
|
+
if (!fields) return undefined;
|
|
44
|
+
const raw = fields[PROC_STAT_STARTTIME_INDEX];
|
|
45
|
+
return raw && Number.isFinite(Number(raw)) ? raw : undefined;
|
|
46
|
+
}
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import * as fs from "node:fs";
|
|
26
|
+
import { fieldsAfterComm, PROC_STAT_PPID_INDEX, PROC_STAT_STARTTIME_INDEX } from "./proc-stat.ts";
|
|
26
27
|
|
|
27
28
|
export interface ZombieSubagent {
|
|
28
29
|
pid: number;
|
|
@@ -32,6 +33,15 @@ export interface ZombieSubagent {
|
|
|
32
33
|
/** Whether the recorded crew parent PID is still alive. */
|
|
33
34
|
parentAlive: boolean;
|
|
34
35
|
role: string | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Mux kind from PI_CREW_SURFACE ("tmux"|"herdr") when the worker booted in
|
|
38
|
+
* a surface pane (T12). Surface workers have NO heartbeat (T9 handoff) —
|
|
39
|
+
* env markers are the only identity signal, so the scanner never gates a
|
|
40
|
+
* surface entry on liveness telemetry.
|
|
41
|
+
*/
|
|
42
|
+
surface?: "tmux" | "herdr";
|
|
43
|
+
/** Pane id from PI_CREW_SURFACE_PANE — doctor closes orphan panes by it. */
|
|
44
|
+
surfacePaneId?: string;
|
|
35
45
|
rssKb: number;
|
|
36
46
|
elapsedSec: number | undefined;
|
|
37
47
|
cmd: string;
|
|
@@ -66,17 +76,12 @@ function readProcStat(pid: number): { ppid: number; elapsedSec: number | undefin
|
|
|
66
76
|
try {
|
|
67
77
|
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
68
78
|
// stat format: pid (comm) state ppid ... starttime ...
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
.split(/\s+/);
|
|
76
|
-
// rest[0] = state, rest[1] = ppid
|
|
77
|
-
const ppid = Number.parseInt(rest[1] ?? "", 10);
|
|
78
|
-
// starttime (clock ticks since boot) is field 22 in the full stat → index 19 in `rest`
|
|
79
|
-
const starttimeTicksRaw = Number.parseInt(rest[19] ?? "", 10);
|
|
79
|
+
// Shared paren-aware cut (proc-stat.ts) — MUST index identically with
|
|
80
|
+
// surface-spawn's PI_CREW_PARENT_START_TIME capture.
|
|
81
|
+
const rest = fieldsAfterComm(stat);
|
|
82
|
+
if (!rest) return undefined;
|
|
83
|
+
const ppid = Number.parseInt(rest[PROC_STAT_PPID_INDEX] ?? "", 10);
|
|
84
|
+
const starttimeTicksRaw = Number.parseInt(rest[PROC_STAT_STARTTIME_INDEX] ?? "", 10);
|
|
80
85
|
const starttimeTicks = Number.isFinite(starttimeTicksRaw) ? starttimeTicksRaw : undefined;
|
|
81
86
|
const elapsedSec = computeElapsedSec(starttimeTicks);
|
|
82
87
|
return { ppid: Number.isFinite(ppid) ? ppid : 0, elapsedSec };
|
|
@@ -184,12 +189,17 @@ export function scanZombieSubagents(): ZombieScanResult {
|
|
|
184
189
|
|
|
185
190
|
const crewParentPid = Number.parseInt(environ.PI_CREW_PARENT_PID ?? "", 10);
|
|
186
191
|
const stat = readProcStat(pid);
|
|
192
|
+
// Surface identity (T12): strict allowlist — an unrelated PI_CREW_SURFACE
|
|
193
|
+
// value must not half-populate the fields doctor relies on for pane close.
|
|
194
|
+
const surface = environ.PI_CREW_SURFACE === "tmux" || environ.PI_CREW_SURFACE === "herdr" ? environ.PI_CREW_SURFACE : undefined;
|
|
187
195
|
const entry: ZombieSubagent = {
|
|
188
196
|
pid,
|
|
189
197
|
ppid: stat?.ppid ?? 0,
|
|
190
198
|
crewParentPid: Number.isFinite(crewParentPid) ? crewParentPid : 0,
|
|
191
199
|
parentAlive: Number.isFinite(crewParentPid) && isPidAlive(crewParentPid),
|
|
192
200
|
role: environ.PI_CREW_ROLE,
|
|
201
|
+
surface,
|
|
202
|
+
surfacePaneId: environ.PI_CREW_SURFACE_PANE || undefined,
|
|
193
203
|
rssKb: readProcRssKb(pid),
|
|
194
204
|
elapsedSec: stat?.elapsedSec,
|
|
195
205
|
cmd: readProcCmdline(pid),
|
|
@@ -256,11 +266,9 @@ export function formatZombieReport(scan: ZombieScanResult): string {
|
|
|
256
266
|
lines.push(`### Zombies — parent dead (${scan.zombies.length})`);
|
|
257
267
|
lines.push("These sub-agents are orphaned. Safe to kill after review:");
|
|
258
268
|
lines.push("");
|
|
259
|
-
lines.push(" PID PARENT RSS ROLE CMD");
|
|
269
|
+
lines.push(" PID PARENT RSS ROLE SURFACE CMD");
|
|
260
270
|
for (const z of scan.zombies) {
|
|
261
|
-
lines.push(
|
|
262
|
-
` ${String(z.pid).padEnd(9)}${String(z.crewParentPid).padEnd(8)}${formatRss(z.rssKb).padEnd(10)}${(z.role ?? "?").padEnd(14)}${z.cmd.slice(0, 60)}`,
|
|
263
|
-
);
|
|
271
|
+
lines.push(formatZombieRow(z));
|
|
264
272
|
}
|
|
265
273
|
lines.push("");
|
|
266
274
|
}
|
|
@@ -269,11 +277,9 @@ export function formatZombieReport(scan: ZombieScanResult): string {
|
|
|
269
277
|
lines.push(`### Live — parent still running (${scan.live.length})`);
|
|
270
278
|
lines.push("NOT zombies. Do not kill (parent PID is alive and may still reap them).");
|
|
271
279
|
lines.push("");
|
|
272
|
-
lines.push(" PID PARENT RSS ROLE CMD");
|
|
280
|
+
lines.push(" PID PARENT RSS ROLE SURFACE CMD");
|
|
273
281
|
for (const l of scan.live) {
|
|
274
|
-
lines.push(
|
|
275
|
-
` ${String(l.pid).padEnd(9)}${String(l.crewParentPid).padEnd(8)}${formatRss(l.rssKb).padEnd(10)}${(l.role ?? "?").padEnd(14)}${l.cmd.slice(0, 60)}`,
|
|
276
|
-
);
|
|
282
|
+
lines.push(formatZombieRow(l));
|
|
277
283
|
}
|
|
278
284
|
lines.push("");
|
|
279
285
|
}
|
|
@@ -288,6 +294,13 @@ export function formatZombieReport(scan: ZombieScanResult): string {
|
|
|
288
294
|
return lines.join("\n");
|
|
289
295
|
}
|
|
290
296
|
|
|
297
|
+
/** One table row — shared by the zombie + live sections so columns stay aligned. */
|
|
298
|
+
function formatZombieRow(z: ZombieSubagent): string {
|
|
299
|
+
// SURFACE = "kind:paneId" (T12) — the pane doctor closes when orphaned.
|
|
300
|
+
const surface = z.surface ? `${z.surface}:${z.surfacePaneId ?? "?"}` : "-";
|
|
301
|
+
return ` ${String(z.pid).padEnd(9)}${String(z.crewParentPid).padEnd(8)}${formatRss(z.rssKb).padEnd(10)}${(z.role ?? "?").padEnd(14)}${surface.padEnd(16)}${z.cmd.slice(0, 44)}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
291
304
|
function formatRss(kb: number): string {
|
|
292
305
|
if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(1)}G`;
|
|
293
306
|
if (kb >= 1024) return `${(kb / 1024).toFixed(0)}M`;
|
|
@@ -25,6 +25,12 @@ const ERROR_VISIBILITY_GRACE_MS = 10 * 60 * 1000;
|
|
|
25
25
|
/** Maximum age (ms) for an active run before it's considered stale.
|
|
26
26
|
* After this time, PID-only liveness is unreliable due to PID recycling. */
|
|
27
27
|
const STALE_ACTIVE_RUN_MS = 30 * 60 * 1000;
|
|
28
|
+
/** Window (ms) during which an active run whose agents are all
|
|
29
|
+
* completed/queued-without-evidence still counts as display-active: the
|
|
30
|
+
* runner just wrote the manifest (task transition) and the next worker has
|
|
31
|
+
* not recorded progress yet. Without this the dock flickers off between
|
|
32
|
+
* phases of a healthy run. */
|
|
33
|
+
const SPAWN_GAP_VISIBILITY_MS = 60 * 1000;
|
|
28
34
|
|
|
29
35
|
export function checkProcessLiveness(pid: number | undefined): ProcessLiveness {
|
|
30
36
|
if (pid === undefined || !Number.isInteger(pid) || pid <= 0) {
|
|
@@ -150,5 +156,14 @@ export function isDisplayActiveRun(run: TeamRunManifest, agents: CrewAgentRecord
|
|
|
150
156
|
// runs, or from cross-cwd registry history; showing them causes noisy 0/0 rows and
|
|
151
157
|
// needless spinner redraws. The full dashboard can still list historical runs.
|
|
152
158
|
if (agents.length === 0) return false;
|
|
153
|
-
|
|
159
|
+
if (agents.some(hasDurableActiveAgentEvidence)) return true;
|
|
160
|
+
// Spawn gap between phases: an active run whose manifest was JUST written
|
|
161
|
+
// and whose next agents are queued/waiting without evidence yet stays
|
|
162
|
+
// visible — the runner is alive and about to spawn them. Stale manifests
|
|
163
|
+
// (zombies) fall through and stay hidden.
|
|
164
|
+
if (agents.some((agent) => agent.status === "queued" || agent.status === "waiting")) {
|
|
165
|
+
const updatedAt = new Date(run.updatedAt).getTime();
|
|
166
|
+
if (Number.isFinite(updatedAt) && now - updatedAt < SPAWN_GAP_VISIBILITY_MS) return true;
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
154
169
|
}
|
|
@@ -283,6 +283,22 @@ export function cancelOrphanedRuns(
|
|
|
283
283
|
continue;
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
+
// Foreground (in-process) runs have NO async worker PID, so the liveness
|
|
287
|
+
// check above cannot apply. They execute inside THIS pi process — a
|
|
288
|
+
// recently-updated manifest conclusively proves the run is alive; only a
|
|
289
|
+
// run that has gone quiet for the stale threshold can be an orphan (a
|
|
290
|
+
// crashed process stops updating). Without this, a YOUNG foreground run
|
|
291
|
+
// (first worker heartbeat not yet written) was orphan-cancelled by the
|
|
292
|
+
// session that opened its agent view seconds after the run started —
|
|
293
|
+
// killing the very run the user was looking at.
|
|
294
|
+
if (!manifest.async) {
|
|
295
|
+
const updatedMs = manifest.updatedAt ? new Date(manifest.updatedAt).getTime() : Number.NaN;
|
|
296
|
+
if (Number.isFinite(updatedMs) && now - updatedMs <= staleThresholdMs) {
|
|
297
|
+
skipped.push(manifest.runId);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
286
302
|
// Check for recent heartbeat activity
|
|
287
303
|
const loaded = loadRunManifestById(cwd, manifest.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
288
304
|
if (!loaded) continue;
|
|
@@ -5,21 +5,38 @@ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
|
5
5
|
import { projectCrewRoot } from "../utils/paths.ts";
|
|
6
6
|
import { isFinishedRunStatus } from "./process-status.ts";
|
|
7
7
|
|
|
8
|
+
export interface RunWaitResult {
|
|
9
|
+
manifest: TeamRunManifest;
|
|
10
|
+
tasks: TeamTaskState[];
|
|
11
|
+
/** True when the waiter was released early by `detachRunPromise` while the
|
|
12
|
+
* run itself keeps executing (see that function). */
|
|
13
|
+
detached?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
8
16
|
export interface ActiveRunPromise {
|
|
9
|
-
promise: Promise<
|
|
10
|
-
resolve: (value:
|
|
17
|
+
promise: Promise<RunWaitResult>;
|
|
18
|
+
resolve: (value: RunWaitResult) => void;
|
|
11
19
|
reject: (reason: unknown) => void;
|
|
12
20
|
}
|
|
13
21
|
|
|
14
22
|
const activeRunPromises = new Map<string, ActiveRunPromise>();
|
|
15
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Runs whose waiter should be released on its next opportunity.
|
|
26
|
+
*
|
|
27
|
+
* A detach request can arrive BEFORE `executeTeamRun` registers its foreground
|
|
28
|
+
* promise (the tool starts the run and calls `waitForRun` immediately, so a
|
|
29
|
+
* slow-starting run puts the waiter on the polling path with no promise to
|
|
30
|
+
* resolve). The flag makes the request path-independent: the polling loop
|
|
31
|
+
* consumes it just like `detachRunPromise` resolves a registered promise.
|
|
32
|
+
*/
|
|
33
|
+
const detachRequests = new Set<string>();
|
|
34
|
+
|
|
16
35
|
export function registerRunPromise(runId: string): ActiveRunPromise {
|
|
17
|
-
|
|
36
|
+
detachRequests.delete(runId);
|
|
37
|
+
let resolve!: (value: RunWaitResult) => void;
|
|
18
38
|
let reject!: (reason: unknown) => void;
|
|
19
|
-
const promise = new Promise<{
|
|
20
|
-
manifest: TeamRunManifest;
|
|
21
|
-
tasks: TeamTaskState[];
|
|
22
|
-
}>((res, rej) => {
|
|
39
|
+
const promise = new Promise<RunWaitResult>((res, rej) => {
|
|
23
40
|
resolve = res;
|
|
24
41
|
reject = rej;
|
|
25
42
|
});
|
|
@@ -28,7 +45,41 @@ export function registerRunPromise(runId: string): ActiveRunPromise {
|
|
|
28
45
|
return entry;
|
|
29
46
|
}
|
|
30
47
|
|
|
31
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Release a foreground waiter WITHOUT stopping the run.
|
|
50
|
+
*
|
|
51
|
+
* A foreground `team run` keeps the parent pi turn streaming for the whole run,
|
|
52
|
+
* and pi's `switchSession` tears the current session down via
|
|
53
|
+
* `session.abort()` → `waitForIdle()`. Opening an agent view therefore hung
|
|
54
|
+
* silently until the run finished. Detaching resolves the tool's `waitForRun`
|
|
55
|
+
* with the run's current (still-running) state so the tool call returns, the
|
|
56
|
+
* parent turn can settle, and the session switch lands — `executeTeamRun`
|
|
57
|
+
* itself keeps going in this process and the async notifier reports completion.
|
|
58
|
+
*
|
|
59
|
+
* Returns false only when the run's state cannot be read at all. When no
|
|
60
|
+
* foreground promise is registered yet (or the waiter is on the polling path),
|
|
61
|
+
* the request is recorded and consumed by `waitForRun` on its next poll.
|
|
62
|
+
*/
|
|
63
|
+
export function detachRunPromise(runId: string, cwd: string): boolean {
|
|
64
|
+
const loaded = loadRunManifestById(cwd, runId);
|
|
65
|
+
if (!loaded) return false;
|
|
66
|
+
const entry = activeRunPromises.get(runId);
|
|
67
|
+
if (entry) {
|
|
68
|
+
activeRunPromises.delete(runId);
|
|
69
|
+
detachRequests.delete(runId);
|
|
70
|
+
entry.resolve({ ...loaded, detached: true });
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
detachRequests.add(runId);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True while a detach was requested but no waiter has consumed it yet. */
|
|
78
|
+
export function hasPendingRunDetach(runId: string): boolean {
|
|
79
|
+
return detachRequests.has(runId);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function resolveRunPromise(runId: string, result: RunWaitResult): void {
|
|
32
83
|
const entry = activeRunPromises.get(runId);
|
|
33
84
|
if (entry) {
|
|
34
85
|
entry.resolve(result);
|
|
@@ -54,15 +105,22 @@ export async function waitForRun(
|
|
|
54
105
|
runId: string,
|
|
55
106
|
cwd: string,
|
|
56
107
|
options: { timeoutMs?: number; pollIntervalMs?: number } = {},
|
|
57
|
-
): Promise<
|
|
108
|
+
): Promise<RunWaitResult> {
|
|
58
109
|
const { timeoutMs = 300_000, pollIntervalMs = 500 } = options;
|
|
59
110
|
const deadline = Date.now() + timeoutMs;
|
|
60
111
|
|
|
112
|
+
// A detach requested before this waiter existed applies to it (and is
|
|
113
|
+
// consumed here, so it can never leak onto a later waiter for the same run).
|
|
114
|
+
const detachPending = detachRequests.delete(runId);
|
|
115
|
+
|
|
61
116
|
// Fast path: already terminal on disk
|
|
62
117
|
const loaded = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
|
|
63
118
|
if (loaded && isFinishedRunStatus(loaded.manifest.status)) {
|
|
64
119
|
return loaded;
|
|
65
120
|
}
|
|
121
|
+
if (detachPending && loaded) {
|
|
122
|
+
return { ...loaded, detached: true };
|
|
123
|
+
}
|
|
66
124
|
|
|
67
125
|
// Medium path: foreground promise registered in this process
|
|
68
126
|
const entry = activeRunPromises.get(runId);
|
|
@@ -78,9 +136,17 @@ export async function waitForRun(
|
|
|
78
136
|
}
|
|
79
137
|
}
|
|
80
138
|
|
|
81
|
-
// Slow path: background run — poll with exponential backoff capped at pollIntervalMs
|
|
139
|
+
// Slow path: background run — poll with exponential backoff capped at pollIntervalMs.
|
|
140
|
+
// This path is ALSO taken by a foreground run whose executeTeamRun has not
|
|
141
|
+
// registered its promise yet (the tool calls waitForRun immediately after
|
|
142
|
+
// starting the run), so it must honour a pending detach request too —
|
|
143
|
+
// otherwise opening an agent view can never release the parent turn.
|
|
82
144
|
let attempt = 0;
|
|
83
145
|
while (Date.now() < deadline) {
|
|
146
|
+
if (detachRequests.delete(runId)) {
|
|
147
|
+
const current = loadRunManifestById(cwd, runId);
|
|
148
|
+
if (current) return { ...current, detached: true };
|
|
149
|
+
}
|
|
84
150
|
if (attempt === 0) {
|
|
85
151
|
// Early exit: if the run directory doesn't exist, don't waste time polling.
|
|
86
152
|
// Use projectCrewRoot() to honour the .pi/teams/ fallback for .pi-based
|
|
@@ -109,6 +175,7 @@ export function hasActiveRunPromise(runId: string): boolean {
|
|
|
109
175
|
}
|
|
110
176
|
|
|
111
177
|
export function clearRunPromisesForTest(): void {
|
|
178
|
+
detachRequests.clear();
|
|
112
179
|
for (const entry of activeRunPromises.values()) {
|
|
113
180
|
entry.reject(new Error("Cleared by test"));
|
|
114
181
|
}
|
|
@@ -8,26 +8,26 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Gate dimensions (each with a distinct machine-readable reason + fail-fast
|
|
10
10
|
* message; evaluated in this order — cheapest/most-global first):
|
|
11
|
-
* 1.
|
|
12
|
-
* 2. role-denied — the PARENT task's role is not executor-class
|
|
13
|
-
* (read-only roles cannot raise spawn privileges),
|
|
14
|
-
* or the requested grandchild role is not one of
|
|
15
|
-
* the delegate-tool's allowed roles
|
|
16
|
-
* 3. trust-denied — untrusted escalation context (manual-only trust
|
|
11
|
+
* 1. trust-denied — untrusted escalation context (manual-only trust
|
|
17
12
|
* gate; the handler resolves trust, policy enforces)
|
|
18
|
-
*
|
|
19
|
-
*
|
|
13
|
+
* 2. role-denied — the REQUESTED grandchild role is not one of the
|
|
14
|
+
* delegate-tool's allowed roles (the parent role is
|
|
15
|
+
* NO LONGER gated — D8: every role may delegate;
|
|
16
|
+
* the spawn-side checkCrewDepth cap is the boundary)
|
|
17
|
+
* 3. depth-exceeded — child depth (parent.depth + 1) exceeds the
|
|
18
|
+
* resolved maxDepth (default 4). Depth comes from
|
|
20
19
|
* the parent task RECORD, never worker env
|
|
21
20
|
* (design §7 rev-2 P0-2)
|
|
22
|
-
*
|
|
21
|
+
* 4. slots-exhausted — nested-slot budget exhausted ("N/M in flight");
|
|
23
22
|
* fail-fast, never queue
|
|
24
|
-
*
|
|
23
|
+
* 5. budget-insufficient— requested budgetTokens exceed the parent task's
|
|
25
24
|
* remaining allocation (tokensGranted - tokensSpent)
|
|
26
|
-
*
|
|
25
|
+
* 6. model-invalid — requested model is not in the resolved model
|
|
27
26
|
* catalog (the unvalidated provider/model
|
|
28
27
|
* pass-through — model-fallback.ts:282, the
|
|
29
28
|
* 429-cascade root — must not be reachable here)
|
|
30
|
-
*
|
|
29
|
+
* 7. timeout-invalid — timeoutSec outside 1..MAX (default 900)
|
|
30
|
+
* 8. workspace-conflict — write-capable grandchild narrowing cwd overlap
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
import { modelRefToString } from "./model/model-fallback.ts";
|
|
@@ -48,7 +48,6 @@ export const DEFAULT_DELEGATE_TIMEOUT_SEC = 900;
|
|
|
48
48
|
export const MAX_DELEGATE_TIMEOUT_SEC = 86_400;
|
|
49
49
|
|
|
50
50
|
export type SpawnPolicyDenyReason =
|
|
51
|
-
| "nesting-disabled"
|
|
52
51
|
| "role-denied"
|
|
53
52
|
| "trust-denied"
|
|
54
53
|
| "depth-exceeded"
|
|
@@ -59,14 +58,14 @@ export type SpawnPolicyDenyReason =
|
|
|
59
58
|
| "workspace-conflict";
|
|
60
59
|
|
|
61
60
|
export interface DelegateAdmissionInput {
|
|
62
|
-
/** Resolved
|
|
63
|
-
nestingEnabled: boolean;
|
|
64
|
-
/** Resolved max depth (nesting.maxDepth default 2; env clamp mirrors PI_CREW_MAX_DEPTH 1..10). */
|
|
61
|
+
/** Resolved max depth (nesting.maxDepth default 4; env clamp mirrors PI_CREW_MAX_DEPTH 1..10). */
|
|
65
62
|
maxDepth: number;
|
|
66
63
|
/** Parent task record fields the policy is allowed to trust. */
|
|
67
64
|
parentTask: {
|
|
68
65
|
taskId: string;
|
|
69
|
-
|
|
66
|
+
/** Role for workspace-capability evaluation (policy no longer gates the
|
|
67
|
+
* parent role — D8 opens delegation to every role). */
|
|
68
|
+
role?: string;
|
|
70
69
|
/** Absent on pre-v2 records — treated as depth 1 (a worker). */
|
|
71
70
|
depth?: number;
|
|
72
71
|
allocation?: { tokensGranted?: number; tokensSpent?: number };
|
|
@@ -122,26 +121,13 @@ function deny(reason: SpawnPolicyDenyReason, message: string): SpawnPolicyDecisi
|
|
|
122
121
|
}
|
|
123
122
|
|
|
124
123
|
export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnPolicyDecision {
|
|
125
|
-
const {
|
|
124
|
+
const { maxDepth, parentTask, requested } = input;
|
|
126
125
|
const parentDepth = parentTask.depth ?? 1; // pre-v2 records: workers are depth 1
|
|
127
126
|
const childDepth = parentDepth + 1;
|
|
128
127
|
|
|
129
|
-
// 1.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
"nesting-disabled",
|
|
133
|
-
"delegate rejected: governed nesting is disabled (nesting.enabled=false, the fail-closed default; enable it in USER config)",
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// 2. Role gate — parent must be executor-class; requested role must be in
|
|
138
|
-
// the tool's allowed set.
|
|
139
|
-
if (!isExecutorClassRole(parentTask.role)) {
|
|
140
|
-
return deny(
|
|
141
|
-
"role-denied",
|
|
142
|
-
`delegate rejected: role '${parentTask.role}' is not executor-class (${EXECUTOR_CLASS_ROLES.join("/")}) — read-only roles cannot delegate`,
|
|
143
|
-
);
|
|
144
|
-
}
|
|
128
|
+
// 1. Role gate — the REQUESTED grandchild role must be in the tool's allowed
|
|
129
|
+
// set (the PARENT role is no longer gated — D8: every role may delegate;
|
|
130
|
+
// the spawn-side checkCrewDepth cap + slot budget below are the boundary).
|
|
145
131
|
const requestedRole = requested?.role;
|
|
146
132
|
if (requestedRole !== undefined && !(DELEGATE_ALLOWED_ROLES as readonly string[]).includes(requestedRole)) {
|
|
147
133
|
return deny(
|
|
@@ -150,12 +136,12 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
|
|
|
150
136
|
);
|
|
151
137
|
}
|
|
152
138
|
|
|
153
|
-
//
|
|
139
|
+
// 2. Trust gate — untrusted escalation context never spawns.
|
|
154
140
|
if (input.untrusted) {
|
|
155
141
|
return deny("trust-denied", "delegate rejected: untrusted escalation context (trust gate is manual-only)");
|
|
156
142
|
}
|
|
157
143
|
|
|
158
|
-
//
|
|
144
|
+
// 3. Depth gate — computed from the parent RECORD (never worker env).
|
|
159
145
|
if (childDepth > maxDepth) {
|
|
160
146
|
return deny(
|
|
161
147
|
"depth-exceeded",
|
|
@@ -163,7 +149,7 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
|
|
|
163
149
|
);
|
|
164
150
|
}
|
|
165
151
|
|
|
166
|
-
//
|
|
152
|
+
// 4. Nested-slot budget — fail-fast, never queue.
|
|
167
153
|
if (input.slots.used >= input.slots.max) {
|
|
168
154
|
return deny(
|
|
169
155
|
"slots-exhausted",
|
|
@@ -171,7 +157,7 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
|
|
|
171
157
|
);
|
|
172
158
|
}
|
|
173
159
|
|
|
174
|
-
//
|
|
160
|
+
// 5. Parent allocation sufficiency — reserve up-front or reject.
|
|
175
161
|
if (requested?.budgetTokens !== undefined) {
|
|
176
162
|
const granted = parentTask.allocation?.tokensGranted ?? 0;
|
|
177
163
|
const spent = parentTask.allocation?.tokensSpent ?? 0;
|
|
@@ -184,7 +170,7 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
|
|
|
184
170
|
}
|
|
185
171
|
}
|
|
186
172
|
|
|
187
|
-
//
|
|
173
|
+
// 6. Model validation against the resolved catalog.
|
|
188
174
|
const normalizedModel = requested?.model !== undefined ? modelRefToString(requested.model) : undefined;
|
|
189
175
|
if (normalizedModel !== undefined && input.modelCatalog !== undefined) {
|
|
190
176
|
if (!input.modelCatalog.includes(normalizedModel)) {
|
|
@@ -192,7 +178,7 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
|
|
|
192
178
|
}
|
|
193
179
|
}
|
|
194
180
|
|
|
195
|
-
//
|
|
181
|
+
// 7. Timeout validation (mandatory default 900).
|
|
196
182
|
const timeoutSec = requested?.timeoutSec ?? DEFAULT_DELEGATE_TIMEOUT_SEC;
|
|
197
183
|
if (!Number.isFinite(timeoutSec) || timeoutSec < 1 || timeoutSec > MAX_DELEGATE_TIMEOUT_SEC) {
|
|
198
184
|
return deny(
|
|
@@ -201,7 +187,7 @@ export function evaluateDelegateAdmission(input: DelegateAdmissionInput): SpawnP
|
|
|
201
187
|
);
|
|
202
188
|
}
|
|
203
189
|
|
|
204
|
-
//
|
|
190
|
+
// 8. Workspace interaction (ADR-5 §9): a write-capable grandchild sharing
|
|
205
191
|
// the parent's cwd with ANOTHER in-flight executor is only admitted when
|
|
206
192
|
// serialization is established (limits.serializeOnPathOverlap) — otherwise
|
|
207
193
|
// reject; read-only grandchild roles (explorer/analyst) never conflict.
|