pi-crew 0.9.57 → 0.9.59
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 +49 -0
- package/dist/index.mjs +22609 -21373
- package/package.json +1 -1
- package/skills/distill-software/SKILL.md +7 -1
- package/src/agents/discover-agents.ts +6 -0
- package/src/config/config.ts +13 -2
- package/src/config/markers.ts +9 -1
- package/src/extension/action-suggestions.ts +1 -1
- package/src/extension/async-notifier.ts +13 -5
- package/src/extension/registration/context-builder.ts +5 -2
- package/src/extension/registration/crash-recovery-cache.ts +2 -2
- package/src/extension/registration/lazy-configurers.ts +1 -1
- package/src/extension/registration/lifecycle-handlers.ts +25 -10
- package/src/extension/registration/observability.ts +8 -4
- package/src/extension/registration/registration-types.ts +2 -2
- package/src/extension/registration/runtime-cleanup.ts +10 -2
- package/src/extension/registration/subagent-manager-setup.ts +9 -4
- package/src/extension/registration/subagent-tools.ts +15 -3
- package/src/extension/run-import.ts +19 -2
- package/src/extension/run-maintenance.ts +25 -9
- package/src/extension/session-summary.ts +5 -0
- package/src/extension/team-tool/destructive-gate.ts +12 -6
- package/src/extension/team-tool/health-monitor.ts +7 -5
- package/src/extension/team-tool/intent-policy.ts +9 -0
- package/src/extension/team-tool/lifecycle-actions.ts +27 -4
- package/src/extension/team-tool/run-deadline.ts +7 -2
- package/src/extension/team-tool/run.ts +23 -4
- package/src/extension/team-tool/status.ts +2 -2
- package/src/extension/team-tool.ts +126 -51
- package/src/runtime/background-runner.ts +36 -2
- package/src/runtime/delivery-coordinator.ts +24 -3
- package/src/runtime/foreground-watchdog.ts +2 -2
- package/src/runtime/model/model-fallback.ts +3 -1
- package/src/runtime/model/provider-extensions.ts +36 -0
- package/src/runtime/model/runtime-warmup.ts +41 -0
- package/src/runtime/peer-dep.ts +35 -8
- package/src/runtime/recovery/crash-recovery.ts +34 -4
- package/src/runtime/skill-instructions.ts +1 -1
- package/src/runtime/stale-reconciler.ts +11 -19
- package/src/runtime/subagent-manager.ts +15 -6
- package/src/runtime/task-packet.ts +26 -4
- package/src/runtime/task-runner/run-projection.ts +4 -1
- package/src/runtime/team-runner.ts +94 -72
- package/src/runtime/verification/completion-guard.ts +10 -1
- package/src/schema/team-tool-schema.ts +152 -38
- package/src/skills/validate.ts +28 -2
- package/src/state/atomic-write.ts +23 -12
- package/src/state/contracts.ts +1 -0
- package/src/state/coordination/locks.ts +20 -7
- package/src/state/coordination/mailbox.ts +20 -2
- package/src/state/gitignore-manager.ts +5 -1
- package/src/state/stores/artifact-store.ts +5 -5
- package/src/state/stores/run-cache.ts +9 -1
- package/src/state/stores/state-store.ts +64 -10
- package/src/ui/deploy-bundled-themes.ts +11 -0
- package/src/ui/powerbar-publisher.ts +31 -6
- package/src/ui/run-dashboard.ts +7 -1
- package/src/ui/syntax-highlight.ts +31 -12
- package/src/ui/widget/index.ts +9 -1
- package/src/ui/widget/widget-model.ts +1 -1
- package/src/ui/widget/widget-types.ts +3 -0
- package/src/utils/env-filter.ts +25 -11
- package/src/utils/paths.ts +20 -1
- package/src/utils/redaction.ts +22 -1
- package/src/utils/session-utils.ts +42 -19
- package/src/worktree/worktree-manager.ts +2 -2
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
3
3
|
import { isDisplayActiveRun } from "../runtime/process-status.ts";
|
|
4
|
+
import { extractSessionId } from "../utils/session-utils.ts";
|
|
4
5
|
import { listRuns } from "./run-index.ts";
|
|
5
6
|
|
|
6
7
|
export function notifyActiveRuns(ctx: ExtensionContext): void {
|
|
8
|
+
const sid = extractSessionId(ctx);
|
|
7
9
|
const active = listRuns(ctx.cwd)
|
|
8
10
|
.filter((run) => {
|
|
11
|
+
// Vector #11: never surface another session's runs in the active-runs
|
|
12
|
+
// toast — session B must not advertise session A's in-flight runs.
|
|
13
|
+
if (sid && run.ownerSessionId && run.ownerSessionId !== sid) return false;
|
|
9
14
|
if (run.status !== "queued" && run.status !== "planning" && run.status !== "running") return false;
|
|
10
15
|
// Use the same display filter as the widget/powerbar — runs without
|
|
11
16
|
// real agent evidence (e.g. integration test fixtures) must not appear.
|
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Rules (in order):
|
|
13
13
|
* 1. Non-team / non-destructive actions → allowed (caller pre-filters, but safe).
|
|
14
|
-
* 2. `cleanup` with `dryRun=true` → ALWAYS allowed (a preview writes
|
|
15
|
-
* so gating it would block users from previewing what
|
|
16
|
-
* this was a UX bug: team action=cleanup dryRun=true returned
|
|
17
|
-
* confirm=true" even though it changed no files
|
|
14
|
+
* 2. `cleanup`/`prune` with `dryRun=true` → ALWAYS allowed (a preview writes
|
|
15
|
+
* nothing, so gating it would block users from previewing what the action
|
|
16
|
+
* would do — this was a UX bug: team action=cleanup dryRun=true returned
|
|
17
|
+
* "requires confirm=true" even though it changed no files; prune gained a
|
|
18
|
+
* true dryRun preview in the prune-honor-dryRun fix).
|
|
18
19
|
* 3. `confirm=true` on the input → allowed (explicit user intent).
|
|
19
20
|
* 4. `delete` with `force=true` → allowed (force bypasses reference checks).
|
|
20
21
|
* 5. Otherwise → blocked with a reason telling the user what to pass.
|
|
@@ -43,8 +44,13 @@ export interface TeamToolInputLike {
|
|
|
43
44
|
*/
|
|
44
45
|
export function shouldBlockDestructiveTeamAction(action: string | undefined, input: TeamToolInputLike): string | undefined {
|
|
45
46
|
if (!action || !DESTRUCTIVE_TEAM_ACTIONS.has(action)) return undefined;
|
|
46
|
-
// dryRun
|
|
47
|
-
|
|
47
|
+
// dryRun is a PREVIEW (no writes) — never needs confirm. Only allow the
|
|
48
|
+
// bypass for actions whose handlers actually honor dryRun as non-destructive:
|
|
49
|
+
// - cleanup (handleProjectCleanup/handleUserCleanup branch on dryRun)
|
|
50
|
+
// - prune (pruneFinishedRuns skips deletion/worktree-cleanup/audit when
|
|
51
|
+
// dryRun; handlePrune bypasses confirm)
|
|
52
|
+
// forget/delete have no dryRun path (they always mutate), so they stay gated.
|
|
53
|
+
if ((action === "cleanup" || action === "prune") && input.dryRun === true) return undefined;
|
|
48
54
|
if (input.confirm === true) return undefined;
|
|
49
55
|
const forceBypassesReferenceChecks = action === "delete" && input.force === true;
|
|
50
56
|
if (forceBypassesReferenceChecks) return undefined;
|
|
@@ -7,6 +7,7 @@ import * as path from "node:path";
|
|
|
7
7
|
import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
|
|
8
8
|
import { hasStaleAsyncProcess, isActiveRunStatus, isLikelyOrphanedActiveRun } from "../../runtime/process-status.ts";
|
|
9
9
|
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
10
|
+
import { loadTasksWithRecovery } from "../../state/stores/state-store.ts";
|
|
10
11
|
import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
|
|
11
12
|
import { listRuns } from "../run-index.ts";
|
|
12
13
|
import type { PiTeamsToolResult } from "../tool-result.ts";
|
|
@@ -63,12 +64,13 @@ export const STUCK_TASK_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
63
64
|
* Read tasks.json for a given run's stateRoot. Returns empty array on error.
|
|
64
65
|
*/
|
|
65
66
|
function readRunTasks(stateRoot: string): TeamTaskState[] {
|
|
67
|
+
// NEW-R2: use corruption recovery instead of bare JSON.parse. A corrupt
|
|
68
|
+
// tasks.json previously returned [] → stuck/crashed workers invisible to the
|
|
69
|
+
// health monitor. Now: corrupt → quarantine + reconstruct from events.jsonl.
|
|
66
70
|
const tasksPath = path.join(stateRoot, "tasks.json");
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return [];
|
|
71
|
-
}
|
|
71
|
+
const eventsPath = path.join(stateRoot, "events.jsonl");
|
|
72
|
+
const runId = path.basename(stateRoot);
|
|
73
|
+
return loadTasksWithRecovery(tasksPath, eventsPath, runId);
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
/**
|
|
@@ -31,6 +31,15 @@ export function enforceDestructiveIntent(
|
|
|
31
31
|
config: PiTeamsConfig | undefined,
|
|
32
32
|
): PiTeamsToolResult | undefined {
|
|
33
33
|
if (!shouldRequireIntent(config)) return undefined;
|
|
34
|
+
// DI-4: The cleanup bypass for non-forced cleanup is INTENTIONAL — do NOT
|
|
35
|
+
// "fix" this into a regression. Non-forced cleanup (`cleanup` without
|
|
36
|
+
// `force: true`) only removes STALE artifacts and preserves dirty worktrees
|
|
37
|
+
// (see cleanupRunWorktrees: dirty worktrees are snapshotted + preserved
|
|
38
|
+
// unless force=true, and project-level cleanup only removes the AGENTS.md
|
|
39
|
+
// guidance block without touching `.crew/`). The destructive path (forced
|
|
40
|
+
// cleanup) still requires intent. Requiring intent for the non-forced,
|
|
41
|
+
// stale-only path would break routine maintenance flows (e.g. periodic
|
|
42
|
+
// stale-artifact reaping) that must not need a per-call intent.
|
|
34
43
|
if (action === "cleanup" && params.force !== true) return undefined;
|
|
35
44
|
if (intentFromConfig(params.config)) return undefined;
|
|
36
45
|
const label = DESTRUCTIVE_ACTION_LABELS[action];
|
|
@@ -10,6 +10,7 @@ import { loadRunManifestById } from "../../state/stores/state-store.ts";
|
|
|
10
10
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
11
11
|
import { projectCrewRoot, userCrewRoot, userPiRoot } from "../../utils/paths.ts";
|
|
12
12
|
import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
|
|
13
|
+
import { sleep } from "../../utils/sleep.ts";
|
|
13
14
|
import { cleanupRunWorktrees } from "../../worktree/cleanup.ts";
|
|
14
15
|
import { listImportedRuns } from "../import-index.ts";
|
|
15
16
|
import { exportRunBundle } from "../run-export.ts";
|
|
@@ -146,7 +147,12 @@ export async function handleExport(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
146
147
|
export async function handlePrune(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
|
|
147
148
|
const intentError = enforceDestructiveIntent("prune", params, ctx.config);
|
|
148
149
|
if (intentError) return intentError;
|
|
149
|
-
|
|
150
|
+
// dryRun:true is a non-destructive preview (no deletion) and is always allowed
|
|
151
|
+
// without confirm — mirrors handleCleanup. Without this, a caller requesting a
|
|
152
|
+
// dry-run preview with confirm:false was forced to set confirm:true, and the
|
|
153
|
+
// handler then ignored dryRun entirely and DELETED runs (data-safety bug).
|
|
154
|
+
const dryRun = params.dryRun === true;
|
|
155
|
+
if (!params.confirm && !dryRun)
|
|
150
156
|
return result(
|
|
151
157
|
paramRequired("prune", "confirm: true", "{ action: 'prune', confirm: true }"),
|
|
152
158
|
{ action: "prune", status: "error" },
|
|
@@ -158,6 +164,7 @@ export async function handlePrune(params: TeamToolParamsValue, ctx: TeamContext)
|
|
|
158
164
|
const pruned = pruneFinishedRuns(ctx.cwd, keep, {
|
|
159
165
|
intent,
|
|
160
166
|
signal: ctx.signal,
|
|
167
|
+
dryRun,
|
|
161
168
|
});
|
|
162
169
|
// Fire hook once with all removed run IDs for batch visibility
|
|
163
170
|
if (pruned.removed.length > 0) {
|
|
@@ -176,11 +183,13 @@ export async function handlePrune(params: TeamToolParamsValue, ctx: TeamContext)
|
|
|
176
183
|
}
|
|
177
184
|
return result(
|
|
178
185
|
[
|
|
179
|
-
|
|
186
|
+
`${dryRun ? "Preview: " : ""}Pruned finished pi-crew runs.`,
|
|
180
187
|
`Kept: ${pruned.kept.length}`,
|
|
181
|
-
|
|
188
|
+
`${dryRun ? "Would remove" : "Removed"}: ${pruned.removed.length}`,
|
|
182
189
|
...(pruned.auditPath ? [`Audit: ${pruned.auditPath}`] : []),
|
|
183
|
-
...(pruned.removed.length
|
|
190
|
+
...(pruned.removed.length
|
|
191
|
+
? [`${dryRun ? "Would remove" : "Removed"} runs:`, ...pruned.removed.map((runId) => `- ${runId}`)]
|
|
192
|
+
: []),
|
|
184
193
|
].join("\n"),
|
|
185
194
|
{ action: "prune", status: "ok", intent },
|
|
186
195
|
);
|
|
@@ -282,6 +291,20 @@ export async function handleForget(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
282
291
|
const resolvedArtifactsRoot = resolveRealContainedPath(crewRoot, loaded.manifest.artifactsRoot);
|
|
283
292
|
fs.rmSync(resolvedStateRoot, { recursive: true, force: true });
|
|
284
293
|
fs.rmSync(resolvedArtifactsRoot, { recursive: true, force: true });
|
|
294
|
+
// FORGET-RESURRECT (observed 2026-08-04): the SIGTERM above is fire-and-forget
|
|
295
|
+
// (killProcessPid returns void; no await on process-group exit). A still-alive
|
|
296
|
+
// background runner/worker can land one final heartbeat write (15s interval)
|
|
297
|
+
// AFTER the first rmSync — atomicWriteFile mkdirSync(recursive) RE-CREATES the
|
|
298
|
+
// state dir, resurrecting heartbeat.json in a dir we just forgot. Drain briefly
|
|
299
|
+
// (grace period for in-flight writes) then re-remove any resurrected residue.
|
|
300
|
+
// A 250ms window catches the common in-flight write race; a still-running
|
|
301
|
+
// (un-killable) process could resurrect later, but that is a separate leak
|
|
302
|
+
// (zombie process) that the stale-reconciler re-prunes.
|
|
303
|
+
if (fs.existsSync(resolvedStateRoot) || fs.existsSync(resolvedArtifactsRoot)) {
|
|
304
|
+
await sleep(250);
|
|
305
|
+
fs.rmSync(resolvedStateRoot, { recursive: true, force: true });
|
|
306
|
+
fs.rmSync(resolvedArtifactsRoot, { recursive: true, force: true });
|
|
307
|
+
}
|
|
285
308
|
return result(
|
|
286
309
|
[
|
|
287
310
|
`Forgot run ${loaded.manifest.runId}.`,
|
|
@@ -13,6 +13,8 @@ export interface RunDeadline {
|
|
|
13
13
|
deadlineMs: number;
|
|
14
14
|
/** Underlying controller — callers may link additional parent signals. */
|
|
15
15
|
controller: AbortController;
|
|
16
|
+
/** RC-02: the deadline timer handle — callers clear it on normal completion. */
|
|
17
|
+
timer: NodeJS.Timeout | undefined;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/** Fallback deadline when no config or param override is available: 1 hour. */
|
|
@@ -58,9 +60,12 @@ export function resolveRunDeadline<T extends object>(
|
|
|
58
60
|
else ctx.signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
59
61
|
}
|
|
60
62
|
// Arm the deadline timer (unref'd so it never blocks process exit).
|
|
63
|
+
// RC-02: expose the timer so callers can clearTimeout on normal completion —
|
|
64
|
+
// otherwise every run leaves a dangling 1h timer retaining ctx/params in closure.
|
|
65
|
+
let timer: NodeJS.Timeout | undefined;
|
|
61
66
|
if (deadlineMs > 0) {
|
|
62
|
-
|
|
67
|
+
timer = setTimeout(() => controller.abort(), deadlineMs);
|
|
63
68
|
timer.unref?.();
|
|
64
69
|
}
|
|
65
|
-
return { signal: controller.signal, deadlineMs, controller };
|
|
70
|
+
return { signal: controller.signal, deadlineMs, controller, timer };
|
|
66
71
|
}
|
|
@@ -32,7 +32,7 @@ async function executeTeamRun(...args: Parameters<typeof ExecuteTeamRunFn>): Pro
|
|
|
32
32
|
|
|
33
33
|
import { spawnBackgroundTeamRun } from "../../runtime/async-runner.ts";
|
|
34
34
|
import { resolveCrewRuntime, runtimeResolutionState } from "../../runtime/model/runtime-resolver.ts";
|
|
35
|
-
import { appendEventAsync,
|
|
35
|
+
import { appendEventAsync, readEventsCursor } from "../../state/event-log/event-log.ts";
|
|
36
36
|
import type { RunMetrics } from "../../state/stores/run-metrics.ts";
|
|
37
37
|
import type { RuntimeResolutionState, TeamRunManifest, TeamTaskState } from "../../state/types.ts";
|
|
38
38
|
|
|
@@ -110,7 +110,7 @@ function scheduleBackgroundEarlyExitGuard(cwd: string, runId: string, pid: numbe
|
|
|
110
110
|
if (!loaded || !isActiveRunStatus(loaded.manifest.status)) return;
|
|
111
111
|
if (hasAsyncStartMarker(loaded.manifest)) return;
|
|
112
112
|
if (
|
|
113
|
-
|
|
113
|
+
readEventsCursor(loaded.manifest.eventsPath).events.some(
|
|
114
114
|
(event) => event.type === "async.started" || event.type === "async.completed" || event.type === "async.failed",
|
|
115
115
|
)
|
|
116
116
|
)
|
|
@@ -704,6 +704,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
704
704
|
);
|
|
705
705
|
} finally {
|
|
706
706
|
unregisterActiveRun(dwfManifest.runId);
|
|
707
|
+
clearTimeout(dwfDeadline.timer); // RC-02
|
|
707
708
|
}
|
|
708
709
|
}
|
|
709
710
|
|
|
@@ -863,6 +864,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
863
864
|
// Wait for the async run to complete and return actual results.
|
|
864
865
|
try {
|
|
865
866
|
const completed = await waitForRun(updatedManifest.runId, resolvedCtx.cwd, { timeoutMs: asyncDeadline.deadlineMs });
|
|
867
|
+
clearTimeout(asyncDeadline.timer); // RC-02
|
|
866
868
|
return formatRunResult(completed.manifest, {
|
|
867
869
|
tasks: completed.tasks,
|
|
868
870
|
metrics: collectRunMetrics(resolvedCtx.cwd, completed.manifest.runId),
|
|
@@ -943,12 +945,22 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
943
945
|
// CORE-8: unified deadline — resolves params > config > 1h default.
|
|
944
946
|
const fgDeadline = resolveRunDeadline(ctx, params, executedConfig);
|
|
945
947
|
ctx.onRunStarted?.(updatedManifest.runId);
|
|
948
|
+
const fgSignal = fgDeadline.signal;
|
|
949
|
+
let fgAbortListener: (() => void) | undefined;
|
|
950
|
+
let fgCallbackSignal: AbortSignal | undefined;
|
|
946
951
|
ctx.startForegroundRun(async (signal) => {
|
|
947
952
|
// Link the foreground-run callback signal to the deadline controller
|
|
948
953
|
// so cancel-via-abortForegroundRun propagates to executeTeamRun.
|
|
949
|
-
|
|
954
|
+
fgCallbackSignal = signal;
|
|
955
|
+
if (signal && signal !== fgSignal) {
|
|
950
956
|
if (signal.aborted) fgDeadline.controller.abort();
|
|
951
|
-
else
|
|
957
|
+
else {
|
|
958
|
+
// RC-03: keep the ref so we can removeEventListener on completion (the
|
|
959
|
+
// {once:true} alone leaks on the success path — the listener stays
|
|
960
|
+
// attached to the long-lived callback signal if the deadline never fires).
|
|
961
|
+
fgAbortListener = () => fgDeadline.controller.abort();
|
|
962
|
+
signal.addEventListener("abort", fgAbortListener, { once: true });
|
|
963
|
+
}
|
|
952
964
|
}
|
|
953
965
|
try {
|
|
954
966
|
await executeTeamRun({
|
|
@@ -979,6 +991,11 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
979
991
|
});
|
|
980
992
|
} finally {
|
|
981
993
|
unregisterActiveRun(updatedManifest.runId);
|
|
994
|
+
// RC-02/03: stop the deadline timer once the run's executeTeamRun completes
|
|
995
|
+
// (success or error). clearTimeout is idempotent — safe if already fired.
|
|
996
|
+
clearTimeout(fgDeadline.timer);
|
|
997
|
+
// Detach the abort listener from the callback signal (RC-03).
|
|
998
|
+
if (fgCallbackSignal && fgAbortListener) fgCallbackSignal.removeEventListener("abort", fgAbortListener);
|
|
982
999
|
}
|
|
983
1000
|
}, updatedManifest.runId);
|
|
984
1001
|
|
|
@@ -1047,6 +1064,8 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
1047
1064
|
});
|
|
1048
1065
|
} finally {
|
|
1049
1066
|
unregisterActiveRun(updatedManifest.runId);
|
|
1067
|
+
// RC-02: clear the inline-path deadline timer (idempotent).
|
|
1068
|
+
clearTimeout(inlineDeadline.timer);
|
|
1050
1069
|
}
|
|
1051
1070
|
return formatRunResult(executed.manifest, {
|
|
1052
1071
|
tasks: executed.tasks,
|
|
@@ -9,7 +9,7 @@ import { formatTaskGraphLines, waitingReason } from "../../runtime/task-display.
|
|
|
9
9
|
import { verifyTaskCompletion } from "../../runtime/verification/completion-guard.ts";
|
|
10
10
|
import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
|
|
11
11
|
import { readDeliveryState, readMailbox } from "../../state/coordination/mailbox.ts";
|
|
12
|
-
import { appendEvent,
|
|
12
|
+
import { appendEvent, readEventsCursor } from "../../state/event-log/event-log.ts";
|
|
13
13
|
import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../../state/stores/state-store.ts";
|
|
14
14
|
import { aggregateUsage, formatCost, formatUsage } from "../../state/usage.ts";
|
|
15
15
|
import { formatDuration } from "../../ui/format-helpers.ts";
|
|
@@ -64,7 +64,7 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
|
|
|
64
64
|
const counts = new Map<string, number>();
|
|
65
65
|
for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
|
|
66
66
|
const phaseProgress = computePhaseProgress(tasks);
|
|
67
|
-
const allEvents =
|
|
67
|
+
const { events: allEvents } = readEventsCursor(manifest.eventsPath);
|
|
68
68
|
const events = allEvents.slice(-8);
|
|
69
69
|
// P1-8: pre-build the ack-timeout requestId set once (was O(events × messages)
|
|
70
70
|
// via allEvents.some() inside the mailbox loop).
|
|
@@ -185,6 +185,51 @@ function artifactKey(artifact: ArtifactDescriptor): string {
|
|
|
185
185
|
return `${artifact.kind}:${artifact.path}`;
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
/** Optional numeric params that accept the empty-string unset marker AND
|
|
189
|
+
* stringified numbers in their schema (Union with Literal("") + a numeric-string
|
|
190
|
+
* pattern branch). pi-ai's tool-argument coercion sometimes stringifies a
|
|
191
|
+
* numeric value (e.g. interval:0 → "0") when the schema Union has a
|
|
192
|
+
* string-literal branch, so the schema accepts the string form (Value.Check
|
|
193
|
+
* passes) but handlers expect real numbers (TeamToolParamsValue types them as
|
|
194
|
+
* number). This converts the string form back to a number — and treats "" as
|
|
195
|
+
* unset (deleted) — before any domain router reads the param. */
|
|
196
|
+
const LOOSE_NUMERIC_PARAM_KEYS = ["interval", "budgetWarning", "budgetAbort", "tokenBudget", "replyDeadline"] as const;
|
|
197
|
+
|
|
198
|
+
function normalizeLooseNumericFields(params: TeamToolParamsValue): TeamToolParamsValue {
|
|
199
|
+
let mutated = false;
|
|
200
|
+
const out: Record<string, unknown> = { ...params };
|
|
201
|
+
for (const key of LOOSE_NUMERIC_PARAM_KEYS) {
|
|
202
|
+
const v = out[key];
|
|
203
|
+
if (v === undefined || v === null) continue;
|
|
204
|
+
if (v === "") {
|
|
205
|
+
delete out[key];
|
|
206
|
+
mutated = true;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (typeof v === "string") {
|
|
210
|
+
const n = Number(v);
|
|
211
|
+
if (Number.isFinite(n)) {
|
|
212
|
+
out[key] = n;
|
|
213
|
+
mutated = true;
|
|
214
|
+
} else {
|
|
215
|
+
delete out[key];
|
|
216
|
+
mutated = true;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// `once` is Union([Boolean, String, Number]); normalize its string form too.
|
|
221
|
+
if (typeof out.once === "string") {
|
|
222
|
+
if (out.once === "false") out.once = false;
|
|
223
|
+
else if (out.once === "true") out.once = true;
|
|
224
|
+
else {
|
|
225
|
+
const n = Number(out.once);
|
|
226
|
+
if (Number.isFinite(n)) out.once = n;
|
|
227
|
+
}
|
|
228
|
+
mutated = true;
|
|
229
|
+
}
|
|
230
|
+
return mutated ? (out as TeamToolParamsValue) : params;
|
|
231
|
+
}
|
|
232
|
+
|
|
188
233
|
async function recoverCheckpointedTasks(
|
|
189
234
|
manifest: TeamRunManifest,
|
|
190
235
|
tasks: TeamTaskState[],
|
|
@@ -296,7 +341,14 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
296
341
|
const workflow =
|
|
297
342
|
direct?.workflow ?? allWorkflows(discoverWorkflows(ctx.cwd)).find((candidate) => candidate.name === loaded.manifest.workflow);
|
|
298
343
|
if (!workflow) return result(`Workflow '${loaded.manifest.workflow}' not found.`, { action: "resume", status: "error" }, true);
|
|
299
|
-
|
|
344
|
+
// LOCK-2 (Round 2): Lock held only for recovery + reset (the read-modify-write
|
|
345
|
+
// critical section). executeTeamRun runs OUTSIDE the lock — it uses
|
|
346
|
+
// team-runner's own per-operation withRunLock calls (mergeUnitResult,
|
|
347
|
+
// handleFailedTask merge, finalizeRun), mirroring handleRun (which never wraps
|
|
348
|
+
// executeTeamRun in an outer lock). Holding the resume lock across the
|
|
349
|
+
// minutes-long executeTeamRun would let any process steal the lock after
|
|
350
|
+
// DEFAULT_LOCKS.staleMs (30s) — see defaults.ts.
|
|
351
|
+
const decision = await withRunLock(loaded.manifest, async () => {
|
|
300
352
|
// R2: re-read inside the lock so recovery + resetTasks reflect committed
|
|
301
353
|
// state, not the pre-lock snapshot. Between the pre-lock load and lock
|
|
302
354
|
// acquisition a task may have been cancelled (stale-reconciler) or
|
|
@@ -354,23 +406,26 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
354
406
|
message: blocked.summary,
|
|
355
407
|
data: { runtime, action: "resume" },
|
|
356
408
|
});
|
|
357
|
-
return
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
409
|
+
return {
|
|
410
|
+
kind: "blocked" as const,
|
|
411
|
+
payload: result(
|
|
412
|
+
[
|
|
413
|
+
`Blocked resume for pi-crew run ${blocked.runId}: real subagent workers are disabled.`,
|
|
414
|
+
`Runtime: ${runtime.kind} (requested ${runtime.requestedMode})`,
|
|
415
|
+
runtime.reason ?? "Child worker execution is disabled.",
|
|
416
|
+
"",
|
|
417
|
+
"To resume effective subagents, remove executeWorkers=false / PI_CREW_EXECUTE_WORKERS=0 / PI_TEAMS_EXECUTE_WORKERS=0 or set runtime.mode=child-process.",
|
|
418
|
+
"Use runtime.mode=scaffold only for explicit dry-run prompt/artifact generation.",
|
|
419
|
+
].join("\n"),
|
|
420
|
+
{
|
|
421
|
+
action: "resume",
|
|
422
|
+
status: "error",
|
|
423
|
+
runId: blocked.runId,
|
|
424
|
+
artifactsRoot: blocked.artifactsRoot,
|
|
425
|
+
},
|
|
426
|
+
true,
|
|
427
|
+
),
|
|
428
|
+
};
|
|
374
429
|
}
|
|
375
430
|
const resetTasks = recovered.tasks.map((task) =>
|
|
376
431
|
task.status === "failed" || task.status === "cancelled" || task.status === "skipped" || task.status === "running"
|
|
@@ -413,42 +468,59 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
413
468
|
});
|
|
414
469
|
const executeWorkers = runtime.kind !== "scaffold";
|
|
415
470
|
const resumeSkillOverride = normalizeSkillOverride(params.skill) ?? runtimeManifest.skillOverride;
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
agents,
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
kind: "execute" as const,
|
|
474
|
+
runtimeManifest,
|
|
475
|
+
resetTasks,
|
|
422
476
|
executeWorkers,
|
|
423
|
-
|
|
477
|
+
resumeSkillOverride,
|
|
424
478
|
runtime,
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
479
|
+
executedConfig,
|
|
480
|
+
};
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// Lock is now RELEASED. executeTeamRun runs without holding the resume
|
|
484
|
+
// lock — it uses team-runner's own per-operation withRunLock calls
|
|
485
|
+
// (mergeUnitResult, handleFailedTask merge, finalizeRun), mirroring
|
|
486
|
+
// handleRun (which never wraps executeTeamRun in an outer lock).
|
|
487
|
+
if (decision.kind === "blocked") return decision.payload;
|
|
488
|
+
|
|
489
|
+
const executed = await executeTeamRun({
|
|
490
|
+
manifest: decision.runtimeManifest,
|
|
491
|
+
tasks: decision.resetTasks,
|
|
492
|
+
team,
|
|
493
|
+
workflow,
|
|
494
|
+
agents,
|
|
495
|
+
executeWorkers: decision.executeWorkers,
|
|
496
|
+
limits: decision.executedConfig.limits,
|
|
497
|
+
runtime: decision.runtime,
|
|
498
|
+
runtimeConfig: decision.executedConfig.runtime,
|
|
499
|
+
parentContext: buildParentContext(ctx),
|
|
500
|
+
parentModel: ctx.model,
|
|
501
|
+
modelRegistry: ctx.modelRegistry,
|
|
502
|
+
modelOverride: params.model,
|
|
503
|
+
skillOverride: decision.resumeSkillOverride,
|
|
504
|
+
signal: ctx.signal,
|
|
505
|
+
reliability: decision.executedConfig.reliability,
|
|
506
|
+
metricRegistry: ctx.metricRegistry,
|
|
507
|
+
workspaceId: ctx.sessionId ?? ctx.cwd,
|
|
451
508
|
});
|
|
509
|
+
return result(
|
|
510
|
+
[
|
|
511
|
+
`Resumed run ${executed.manifest.runId}.`,
|
|
512
|
+
`Status: ${executed.manifest.status}`,
|
|
513
|
+
`Tasks: ${executed.tasks.length}`,
|
|
514
|
+
`Artifacts: ${executed.manifest.artifactsRoot}`,
|
|
515
|
+
].join("\n"),
|
|
516
|
+
{
|
|
517
|
+
action: "resume",
|
|
518
|
+
status: executed.manifest.status === "failed" ? "error" : "ok",
|
|
519
|
+
runId: executed.manifest.runId,
|
|
520
|
+
artifactsRoot: executed.manifest.artifactsRoot,
|
|
521
|
+
},
|
|
522
|
+
executed.manifest.status === "failed",
|
|
523
|
+
);
|
|
452
524
|
}
|
|
453
525
|
|
|
454
526
|
export function handleSteer(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
|
|
@@ -626,6 +698,9 @@ export async function handleTeamTool(params: TeamToolParamsValue, ctx: TeamConte
|
|
|
626
698
|
// a missing action defaulted to "list" at the facade but the router read
|
|
627
699
|
// params.action=undefined → "Unhandled status-domain action: undefined".
|
|
628
700
|
params = { ...params, action: params.action ?? "list" };
|
|
701
|
+
// Coerce stringified-numeric params (pi-ai coercion artifact) back to real
|
|
702
|
+
// numbers before domain routers read them. See normalizeLooseNumericFields.
|
|
703
|
+
params = normalizeLooseNumericFields(params);
|
|
629
704
|
const action = params.action ?? "list";
|
|
630
705
|
const domain = domainForAction(action);
|
|
631
706
|
switch (domain) {
|
|
@@ -110,6 +110,38 @@ function argValue(name: string): string | undefined {
|
|
|
110
110
|
return process.argv[index + 1];
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Signals the catch-all loop registers that do NOT terminate the background
|
|
115
|
+
* runner (SIGWINCH = terminal resize, SIGPIPE = closed pipe, SIGCONT etc.).
|
|
116
|
+
* Logging these as fatal `async.failed` poisons dead-run detection:
|
|
117
|
+
* async-notifier's isAsyncTerminalEvent treats async.failed as terminal, so a
|
|
118
|
+
* single benign signal (e.g. a terminal resize) would permanently disable
|
|
119
|
+
* markDeadAsyncRunIfNeeded for the run. Benign signals are logged as
|
|
120
|
+
* non-terminal `async.signal` instead.
|
|
121
|
+
*/
|
|
122
|
+
export const BENIGN_SIGNALS = new Set([
|
|
123
|
+
"SIGWINCH",
|
|
124
|
+
"SIGPIPE",
|
|
125
|
+
"SIGCONT",
|
|
126
|
+
"SIGTSTP",
|
|
127
|
+
"SIGTTIN",
|
|
128
|
+
"SIGTTOU",
|
|
129
|
+
"SIGURG",
|
|
130
|
+
"SIGPROF",
|
|
131
|
+
"SIGVTALRM",
|
|
132
|
+
"SIGALRM",
|
|
133
|
+
"SIGIO",
|
|
134
|
+
"SIGPWR",
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Classify a caught signal as terminal (`async.failed` — the process is
|
|
139
|
+
* dying) or benign (`async.signal` — the process keeps running).
|
|
140
|
+
*/
|
|
141
|
+
export function signalEventType(sig: string): "async.signal" | "async.failed" {
|
|
142
|
+
return BENIGN_SIGNALS.has(sig) ? "async.signal" : "async.failed";
|
|
143
|
+
}
|
|
144
|
+
|
|
113
145
|
/**
|
|
114
146
|
* Fire-and-forget event log for signal handlers. Extracted to module level
|
|
115
147
|
* (from inside main()) so the exported SIGINT handler installer (test seam)
|
|
@@ -119,10 +151,12 @@ function argValue(name: string): string | undefined {
|
|
|
119
151
|
function signalLog(sig: string, eventsPath: string): void {
|
|
120
152
|
const runId = argValue("--run-id");
|
|
121
153
|
if (runId && eventsPath) {
|
|
154
|
+
const type = signalEventType(sig);
|
|
122
155
|
appendEventFireAndForget(eventsPath, {
|
|
123
|
-
type
|
|
156
|
+
type,
|
|
124
157
|
runId,
|
|
125
|
-
|
|
158
|
+
// Benign signals don't exit the runner — don't claim they do.
|
|
159
|
+
message: type === "async.failed" ? `Background runner received ${sig} — exiting.` : `Background runner received ${sig}.`,
|
|
126
160
|
data: { signal: sig, pid: process.pid },
|
|
127
161
|
});
|
|
128
162
|
}
|
|
@@ -7,6 +7,11 @@ export interface PendingDelivery {
|
|
|
7
7
|
timestamp: number;
|
|
8
8
|
type: "result" | "notification" | "steer";
|
|
9
9
|
generation?: number;
|
|
10
|
+
/** Pi session that owns the underlying run. Set at enqueue time; a delivery
|
|
11
|
+
* whose ownerSessionId differs from the currently active session is parked
|
|
12
|
+
* (not flushed) so queued results never leak into the wrong session after an
|
|
13
|
+
* in-process session switch (vector #12). Absent = ownerless/legacy (always flush). */
|
|
14
|
+
ownerSessionId?: string;
|
|
10
15
|
}
|
|
11
16
|
|
|
12
17
|
export interface DeliveryCoordinatorDeps {
|
|
@@ -28,6 +33,9 @@ export class DeliveryCoordinator {
|
|
|
28
33
|
private readonly deps: DeliveryCoordinatorDeps;
|
|
29
34
|
private ttlTimer: ReturnType<typeof setInterval> | undefined;
|
|
30
35
|
private timerStarted = false;
|
|
36
|
+
/** The session id passed to the most recent activate(); used by flushQueuedResults
|
|
37
|
+
* to park deliveries owned by a different session (vector #12). */
|
|
38
|
+
private activeSessionId?: string;
|
|
31
39
|
|
|
32
40
|
constructor(deps: DeliveryCoordinatorDeps) {
|
|
33
41
|
this.deps = deps;
|
|
@@ -35,6 +43,7 @@ export class DeliveryCoordinator {
|
|
|
35
43
|
|
|
36
44
|
activate(sessionId: string): void {
|
|
37
45
|
this.active = true;
|
|
46
|
+
this.activeSessionId = sessionId;
|
|
38
47
|
this.flushQueuedResults();
|
|
39
48
|
}
|
|
40
49
|
|
|
@@ -51,7 +60,7 @@ export class DeliveryCoordinator {
|
|
|
51
60
|
return this.pending.length;
|
|
52
61
|
}
|
|
53
62
|
|
|
54
|
-
deliverResult(runId: string, result: unknown): void {
|
|
63
|
+
deliverResult(runId: string, result: unknown, ownerSessionId?: string): void {
|
|
55
64
|
if (this.active && this.deps.emit) {
|
|
56
65
|
try {
|
|
57
66
|
this.deps.emit("pi-crew:run-result", result);
|
|
@@ -66,10 +75,11 @@ export class DeliveryCoordinator {
|
|
|
66
75
|
payload: result,
|
|
67
76
|
timestamp: Date.now(),
|
|
68
77
|
type: "result",
|
|
78
|
+
ownerSessionId,
|
|
69
79
|
});
|
|
70
80
|
}
|
|
71
81
|
|
|
72
|
-
deliverNotification(notification: NotificationDescriptor): void {
|
|
82
|
+
deliverNotification(notification: NotificationDescriptor, ownerSessionId?: string): void {
|
|
73
83
|
let delivered = false;
|
|
74
84
|
if (this.active && this.deps.sendFollowUp) {
|
|
75
85
|
try {
|
|
@@ -95,10 +105,11 @@ export class DeliveryCoordinator {
|
|
|
95
105
|
payload: notification,
|
|
96
106
|
timestamp: Date.now(),
|
|
97
107
|
type: "notification",
|
|
108
|
+
ownerSessionId,
|
|
98
109
|
});
|
|
99
110
|
}
|
|
100
111
|
|
|
101
|
-
deliverSteer(runId: string, message: string): void {
|
|
112
|
+
deliverSteer(runId: string, message: string, ownerSessionId?: string): void {
|
|
102
113
|
if (this.active && this.deps.sendWakeUp) {
|
|
103
114
|
try {
|
|
104
115
|
this.deps.sendWakeUp(message);
|
|
@@ -113,6 +124,7 @@ export class DeliveryCoordinator {
|
|
|
113
124
|
payload: message,
|
|
114
125
|
timestamp: Date.now(),
|
|
115
126
|
type: "steer",
|
|
127
|
+
ownerSessionId,
|
|
116
128
|
});
|
|
117
129
|
}
|
|
118
130
|
|
|
@@ -127,6 +139,15 @@ export class DeliveryCoordinator {
|
|
|
127
139
|
try {
|
|
128
140
|
const retryLater: PendingDelivery[] = [];
|
|
129
141
|
for (const delivery of batch) {
|
|
142
|
+
// Vector #12: park deliveries owned by a session other than the one
|
|
143
|
+
// currently active. They must never flush into the wrong session after
|
|
144
|
+
// an in-process session switch. Re-queued as-is (generation preserved)
|
|
145
|
+
// so the existing stale-steer check still applies when the owning
|
|
146
|
+
// session becomes active again.
|
|
147
|
+
if (delivery.ownerSessionId && this.activeSessionId && delivery.ownerSessionId !== this.activeSessionId) {
|
|
148
|
+
retryLater.push(delivery);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
130
151
|
if (delivery.type === "steer" && delivery.generation !== undefined && delivery.generation !== this.generation) {
|
|
131
152
|
logInternalError("delivery-coordinator.flush.stale", undefined, `runId=${delivery.runId} type=${delivery.type}`);
|
|
132
153
|
continue;
|
|
@@ -41,8 +41,8 @@ export function stopWatchdog(runId: string): void {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/** @internal Stop all active watchdogs. Called on session shutdown. */
|
|
45
|
-
function stopAllWatchdogs(): void {
|
|
44
|
+
/** @internal Stop all active watchdogs. Called on session shutdown (RC-01). */
|
|
45
|
+
export function stopAllWatchdogs(): void {
|
|
46
46
|
for (const [runId, timer] of activeWatchdogs) {
|
|
47
47
|
clearTimeout(timer);
|
|
48
48
|
}
|