pi-crew 0.11.0 → 0.11.2
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 +155 -9
- package/README.md +161 -1037
- package/agents/verifier.md +18 -7
- package/dist/index.mjs +744 -90644
- package/docs/README.md +57 -46
- package/docs/architecture.md +87 -33
- package/docs/commands-reference.md +9 -5
- package/docs/troubleshooting.md +3 -2
- package/package.json +1 -3
- package/schema.json +39 -0
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +2 -0
- package/skills/real-test-pi-crew/SKILL.md +371 -34
- package/src/agents/agent-config.ts +1 -1
- package/src/agents/discover-agents.ts +1 -1
- package/src/config/config-validation.ts +15 -2
- package/src/config/config.ts +47 -13
- package/src/config/defaults.ts +0 -1
- package/src/config/env-vars.ts +35 -0
- package/src/config/types.ts +19 -5
- package/src/errors.ts +2 -2
- package/src/extension/async-notifier.ts +23 -0
- package/src/extension/crew-vibes/config.ts +0 -21
- package/src/extension/crew-vibes/index.ts +0 -2
- package/src/extension/crew-vibes/render.ts +1 -50
- package/src/extension/help.ts +21 -12
- package/src/extension/knowledge-injection.ts +2 -1
- package/src/extension/management.ts +8 -3
- package/src/extension/notification-sink.ts +17 -0
- package/src/extension/register.ts +7 -2
- package/src/extension/registration/command-utils.ts +28 -2
- package/src/extension/registration/commands/dashboard.ts +11 -1
- package/src/extension/registration/commands/manage.ts +36 -19
- package/src/extension/registration/commands/run.ts +24 -2
- package/src/extension/registration/commands/shared.ts +23 -1
- package/src/extension/registration/commands/status.ts +25 -2
- package/src/extension/registration/context-builder.ts +8 -2
- package/src/extension/registration/health-notify-policy.ts +100 -0
- package/src/extension/registration/lazy-configurers.ts +35 -0
- package/src/extension/registration/lifecycle-handlers.ts +91 -30
- package/src/extension/registration/lifecycle.ts +75 -10
- package/src/extension/registration/observability.ts +98 -35
- package/src/extension/registration/registration-types.ts +7 -5
- package/src/extension/registration/runtime-cleanup.ts +9 -3
- package/src/extension/registration/subagent-helpers.ts +38 -0
- package/src/extension/registration/subagent-tools.ts +16 -6
- package/src/extension/registration/team-tool.ts +10 -3
- package/src/extension/registration/terminal-status-wiring.ts +172 -0
- package/src/extension/registration/viewers.ts +6 -0
- package/src/extension/registration/wire-cross-extension.ts +28 -0
- package/src/extension/run-compare.ts +220 -0
- package/src/extension/run-export.ts +37 -5
- package/src/extension/run-maintenance.ts +155 -5
- package/src/extension/team-tool/dispatch/index.ts +3 -2
- package/src/extension/team-tool/dispatch/manage.ts +5 -2
- package/src/extension/team-tool/goal.ts +4 -1
- package/src/extension/team-tool/handle-settings.ts +33 -4
- package/src/extension/team-tool/health-monitor.ts +21 -7
- package/src/extension/team-tool/lifecycle-actions.ts +49 -1
- package/src/extension/team-tool/plan.ts +10 -0
- package/src/extension/team-tool/routing-hint.ts +63 -0
- package/src/extension/team-tool/status.ts +4 -0
- package/src/extension/team-tool.ts +52 -6
- package/src/extension/webhook-notify.ts +382 -0
- package/src/observability/metric-sink.ts +12 -2
- package/src/prompt/prompt-runtime.ts +82 -31
- package/src/prompt/worker-events-channel.ts +12 -0
- package/src/runtime/README.md +1 -1
- package/src/runtime/async-runner.ts +87 -1
- package/src/runtime/background-runner.ts +313 -234
- package/src/runtime/broker/crew-broker.ts +17 -11
- package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
- package/src/runtime/broker/wait-status-cache.ts +1 -1
- package/src/runtime/child-pi/child-pi-timers.ts +1 -1
- package/src/runtime/child-pi/mock-fixtures.ts +48 -0
- package/src/runtime/crew-agent-records.ts +337 -45
- package/src/runtime/deadletter.ts +43 -1
- package/src/runtime/delegate-spawn.ts +5 -1
- package/src/runtime/dispatch-batch.ts +72 -5
- package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
- package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
- package/src/runtime/model/model-fallback.ts +21 -1
- package/src/runtime/model/pi-args.ts +8 -10
- package/src/runtime/recovery/crash-recovery.ts +25 -1
- package/src/runtime/run-worker.ts +12 -1
- package/src/runtime/scheduling/global-worker-cap.ts +13 -6
- package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
- package/src/runtime/scheduling/scheduler.ts +49 -13
- package/src/runtime/scheduling/semaphore.ts +148 -20
- package/src/runtime/scratchpad/README.md +1 -1
- package/src/runtime/scratchpad/protocol.ts +1 -1
- package/src/runtime/settings-store.ts +1 -1
- package/src/runtime/skill-instructions.ts +22 -0
- package/src/runtime/stale-reconciler.ts +85 -13
- package/src/runtime/task-display.ts +1 -1
- package/src/runtime/task-runner/pre-execution.ts +26 -2
- package/src/runtime/task-runner/prompt-builder.ts +142 -45
- package/src/runtime/task-runner.ts +21 -1
- package/src/runtime/team-runner.ts +38 -1
- package/src/runtime/workspace-lock.ts +4 -1
- package/src/schema/config-schema.ts +18 -0
- package/src/schema/team-tool-schema.ts +17 -0
- package/src/state/atomic-write.ts +53 -0
- package/src/state/contracts.ts +109 -0
- package/src/state/coordination/locks.ts +191 -33
- package/src/state/coordination/mailbox.ts +140 -15
- package/src/state/crew-init.ts +87 -12
- package/src/state/event-log/cursor.ts +37 -1
- package/src/state/event-log/event-log-rotation.ts +72 -7
- package/src/state/stores/active-run-registry.ts +13 -1
- package/src/state/stores/state-store.ts +112 -22
- package/src/state/types.ts +4 -0
- package/src/ui/adaptive-card.ts +65 -0
- package/src/ui/agents-jobs-browser.ts +70 -64
- package/src/ui/card-colors.ts +36 -7
- package/src/ui/dashboard-panes/agents-pane.ts +55 -14
- package/src/ui/dashboard-panes/cancellation-pane.ts +0 -42
- package/src/ui/dashboard-panes/health-pane.ts +7 -5
- package/src/ui/dashboard-panes/mailbox-pane.ts +22 -6
- package/src/ui/dashboard-panes/metrics-pane.ts +15 -7
- package/src/ui/dashboard-panes/pane-theme.ts +21 -0
- package/src/ui/dashboard-panes/plan-pane.ts +63 -30
- package/src/ui/dashboard-panes/progress-pane.ts +3 -2
- package/src/ui/dashboard-panes/schedules-pane.ts +44 -21
- package/src/ui/dashboard-panes/transcript-pane.ts +11 -5
- package/src/ui/dwf-phase-display.ts +3 -20
- package/src/ui/format-helpers.ts +22 -0
- package/src/ui/heartbeat-aggregator.ts +34 -0
- package/src/ui/inline-panel/crew-editor.ts +13 -3
- package/src/ui/inline-panel/index.ts +60 -4
- package/src/ui/keybinding-map.ts +251 -35
- package/src/ui/live-conversation-overlay.ts +180 -47
- package/src/ui/live-run-sidebar.ts +134 -55
- package/src/ui/mascot.ts +32 -16
- package/src/ui/overlays/agent-picker-overlay.ts +81 -26
- package/src/ui/overlays/confirm-overlay.ts +55 -29
- package/src/ui/overlays/help-overlay.ts +108 -53
- package/src/ui/overlays/mailbox-compose-overlay.ts +89 -50
- package/src/ui/overlays/mailbox-detail-overlay.ts +137 -57
- package/src/ui/powerbar-publisher.ts +0 -1
- package/src/ui/rail.ts +333 -0
- package/src/ui/run-dashboard.ts +193 -79
- package/src/ui/run-snapshot-cache.ts +18 -1
- package/src/ui/settings-overlay.ts +81 -39
- package/src/ui/spinner.ts +26 -2
- package/src/ui/terminal-status.ts +7 -1
- package/src/ui/theme-adapter.ts +0 -45
- package/src/ui/theme-discovery.ts +12 -6
- package/src/ui/tool-progress-formatter.ts +128 -9
- package/src/ui/tool-renderers/brief-mode.ts +10 -67
- package/src/ui/tool-renderers/index.ts +374 -523
- package/src/ui/transcript-viewer.ts +30 -12
- package/src/ui/widget/index.ts +32 -52
- package/src/ui/widget/task-list.ts +64 -32
- package/src/ui/widget/widget-formatters.ts +3 -402
- package/src/ui/widget/widget-model.ts +28 -7
- package/src/ui/widget/widget-renderer.ts +201 -128
- package/src/ui/widget/widget-types.ts +0 -2
- package/src/utils/incremental-reader.ts +11 -3
- package/src/utils/paths.ts +94 -12
- package/src/utils/project-markers.ts +40 -0
- package/src/utils/visual.ts +0 -4
- package/src/worktree/worktree-manager.ts +206 -26
- package/workflows/distill.workflow.md +3 -3
- package/workflows/fast-fix.workflow.md +1 -1
- package/workflows/plan-execute.workflow.md +1 -1
- package/workflows/review.workflow.md +1 -1
- package/workflows/strict-fast-fix.workflow.md +1 -1
- package/docs/migration-v0.4-v0.5.md +0 -208
- package/docs/runtime-flow.md +0 -148
- package/src/extension/crew-vibes/figures.ts +0 -22
- package/src/extension/crew-vibes/font-detect.ts +0 -71
- package/src/ui/dynamic-border.ts +0 -35
- package/src/ui/loaders.ts +0 -6
- package/src/ui/overlay-stack.ts +0 -148
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
4
|
-
|
|
5
4
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
5
|
+
import { projectCrewRoot } from "../utils/paths.ts";
|
|
6
6
|
|
|
7
7
|
export type DeadletterReason = "max-retries" | "heartbeat-dead" | "manual";
|
|
8
8
|
|
|
@@ -14,12 +14,30 @@ export interface DeadletterEntry {
|
|
|
14
14
|
lastError?: string;
|
|
15
15
|
attemptId?: string;
|
|
16
16
|
timestamp: string;
|
|
17
|
+
/** US-003 (2026-09-22): richer schema so the entry is self-sufficient for
|
|
18
|
+
* post-mortem AFTER the run dir is pruned (the project-level index below is
|
|
19
|
+
* the record that survives; the run-local file dies with the run dir). */
|
|
20
|
+
agent?: string;
|
|
21
|
+
role?: string;
|
|
22
|
+
/** Model-fallback attempt count at give-up time. */
|
|
23
|
+
modelAttempts?: number;
|
|
24
|
+
/** Run manifest status at the time of the write. */
|
|
25
|
+
runStatus?: string;
|
|
17
26
|
}
|
|
18
27
|
|
|
19
28
|
export function deadletterPath(manifest: TeamRunManifest): string {
|
|
20
29
|
return path.join(manifest.stateRoot, "deadletter.jsonl");
|
|
21
30
|
}
|
|
22
31
|
|
|
32
|
+
/**
|
|
33
|
+
* US-003: project-level dead-letter index, `<projectRoot>/.crew/state/deadletter/<runId>.jsonl`.
|
|
34
|
+
* Lives OUTSIDE any run dir, so per-run auto-prune (DP-01 keep=10) cannot delete it —
|
|
35
|
+
* this is the durable out-of-band record for post-mortem analysis.
|
|
36
|
+
*/
|
|
37
|
+
export function deadletterIndexPath(manifest: TeamRunManifest): string {
|
|
38
|
+
return path.join(projectCrewRoot(manifest.cwd), "state", "deadletter", `${manifest.runId}.jsonl`);
|
|
39
|
+
}
|
|
40
|
+
|
|
23
41
|
export function appendDeadletter(manifest: TeamRunManifest, entry: DeadletterEntry): void {
|
|
24
42
|
try {
|
|
25
43
|
fs.mkdirSync(manifest.stateRoot, { recursive: true });
|
|
@@ -27,6 +45,19 @@ export function appendDeadletter(manifest: TeamRunManifest, entry: DeadletterEnt
|
|
|
27
45
|
} catch (error) {
|
|
28
46
|
logInternalError("deadletter.append", error, `taskId=${entry.taskId}`);
|
|
29
47
|
}
|
|
48
|
+
// US-003: project-level index (survives run pruning). Best-effort AFTER the
|
|
49
|
+
// run-local record — appendDeadletter must never throw into the retry path.
|
|
50
|
+
// Honest deviation from the spec's "atomically (both or neither)" wording:
|
|
51
|
+
// cross-file atomicity without a journal is not physically possible; the
|
|
52
|
+
// contract is "both on success, never a caller-visible failure", with each
|
|
53
|
+
// failure logged via logInternalError.
|
|
54
|
+
try {
|
|
55
|
+
const indexPath = deadletterIndexPath(manifest);
|
|
56
|
+
fs.mkdirSync(path.dirname(indexPath), { recursive: true });
|
|
57
|
+
fs.appendFileSync(indexPath, `${JSON.stringify({ ...entry, runStatus: entry.runStatus ?? manifest.status })}\n`, "utf-8");
|
|
58
|
+
} catch (error) {
|
|
59
|
+
logInternalError("deadletter.index-append", error, `taskId=${entry.taskId}`);
|
|
60
|
+
}
|
|
30
61
|
}
|
|
31
62
|
|
|
32
63
|
export function readDeadletter(manifest: TeamRunManifest, maxEntries = 1000): DeadletterEntry[] {
|
|
@@ -45,3 +76,14 @@ export function readDeadletter(manifest: TeamRunManifest, maxEntries = 1000): De
|
|
|
45
76
|
}
|
|
46
77
|
});
|
|
47
78
|
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* US-003 AC-3: `handleStatus` surfacing — a Deadletter line, only when entries
|
|
82
|
+
* exist (silent otherwise; a zero line would be noise on every healthy run).
|
|
83
|
+
*/
|
|
84
|
+
export function deadletterStatusLine(manifest: TeamRunManifest): string | undefined {
|
|
85
|
+
const entries = readDeadletter(manifest);
|
|
86
|
+
if (!entries.length) return undefined;
|
|
87
|
+
const reasons = [...new Set(entries.map((entry) => entry.reason))].join(", ");
|
|
88
|
+
return `Deadletter: ${entries.length} (${reasons}) — ${deadletterPath(manifest)}`;
|
|
89
|
+
}
|
|
@@ -96,7 +96,11 @@ export function grandchildArtifactsRoot(cwd: string, runId: string, parentTaskId
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
export async function spawnDelegateGrandchild(input: GrandchildSpawnInput): Promise<GrandchildSpawnResult> {
|
|
99
|
-
|
|
99
|
+
// RR-012 F03: single artifacts formula — grandchildArtifactsRoot(input.cwd, …).
|
|
100
|
+
// input.cwd is the PARENT TASK cwd threaded by the broker (the admitted
|
|
101
|
+
// worktree in worktree mode), so artifacts land under the admitted cwd —
|
|
102
|
+
// never under the leader/broker workspace.
|
|
103
|
+
const artifactsRoot = grandchildArtifactsRoot(input.cwd, input.runId, input.parentTaskId, input.subId);
|
|
100
104
|
fs.mkdirSync(artifactsRoot, { recursive: true });
|
|
101
105
|
|
|
102
106
|
const abort = new AbortController();
|
|
@@ -13,12 +13,14 @@ import type { CrewReliabilityConfig } from "../config/config.ts";
|
|
|
13
13
|
import { CrewError, ErrorCode } from "../errors.ts";
|
|
14
14
|
import { appendHookEvent, executeHook } from "../hooks/registry.ts";
|
|
15
15
|
import { childCorrelation, withCorrelation } from "../observability/correlation.ts";
|
|
16
|
+
import { TEAM_TERMINAL_RUN_STATUSES } from "../state/contracts.ts";
|
|
16
17
|
import { withRunLockSync } from "../state/coordination/locks.ts";
|
|
17
18
|
import { appendEventAsync, appendEventBuffered } from "../state/event-log/event-log.ts";
|
|
18
19
|
import { loadRunManifestById, saveRunManifest, saveRunTasks, saveRunTasksAsync, updateRunStatus } from "../state/stores/state-store.ts";
|
|
19
20
|
import type { TaskAttemptState, TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
20
21
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
21
22
|
import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
|
|
23
|
+
import { isDelegateShadowTask } from "./broker/delegate/shadow-lifecycle.ts";
|
|
22
24
|
import { readCrewAgents, recordFromTask, saveCrewAgents } from "./crew-agent-records.ts";
|
|
23
25
|
import { appendDeadletter } from "./deadletter.ts";
|
|
24
26
|
import { classifyHeartbeat, DEFAULT_GRADIENT_THRESHOLDS } from "./heartbeat/heartbeat-gradient.ts";
|
|
@@ -316,6 +318,26 @@ export function shouldUseRetry(reliability: CrewReliabilityConfig | undefined):
|
|
|
316
318
|
return reliability?.autoRetry !== false;
|
|
317
319
|
}
|
|
318
320
|
|
|
321
|
+
/**
|
|
322
|
+
* T9d cancel-race fix (2026-09-23, live battery finding 7): a retry attempt must
|
|
323
|
+
* re-queue ONLY the task's OWN terminal failure (status "failed") while the run is
|
|
324
|
+
* still active. Previously ANY non-queued/running status was re-queued — including
|
|
325
|
+
* "cancelled", so a cross-session cancel landing between attempt 1's hard-kill and
|
|
326
|
+
* attempt 2's start (measured live: cancel 16:46:22.987 → task.started 16:46:23.948,
|
|
327
|
+
* replacement worker then ran 47s post-cancel) silently resurrected the task.
|
|
328
|
+
* External terminal decisions (task cancelled/completed, or a terminal RUN manifest)
|
|
329
|
+
* are respected. Exported for unit testing.
|
|
330
|
+
*/
|
|
331
|
+
export function shouldRequeueForRetry(input: {
|
|
332
|
+
attempt: number;
|
|
333
|
+
taskStatus: TeamTaskState["status"];
|
|
334
|
+
manifestStatus: TeamRunManifest["status"];
|
|
335
|
+
}): boolean {
|
|
336
|
+
if (input.attempt <= 1) return false;
|
|
337
|
+
if (TEAM_TERMINAL_RUN_STATUSES.has(input.manifestStatus)) return false;
|
|
338
|
+
return input.taskStatus === "failed";
|
|
339
|
+
}
|
|
340
|
+
|
|
319
341
|
function failedTaskFrom(result: { tasks: TeamTaskState[] }, taskId: string): TeamTaskState | undefined {
|
|
320
342
|
return result.tasks.find((item) => item.id === taskId && item.status === "failed");
|
|
321
343
|
}
|
|
@@ -328,6 +350,13 @@ function failedTaskFrom(result: { tasks: TeamTaskState[] }, taskId: string): Tea
|
|
|
328
350
|
function dagReadyTaskIds(tasks: TeamTaskState[], completedIds: Set<string>): string[] | null {
|
|
329
351
|
const hasExplicitDeps = tasks.some((t) => t.dependsOn.length > 0);
|
|
330
352
|
if (!hasExplicitDeps) return null;
|
|
353
|
+
// RR-012 (adjacent scheduler risk — proven reachable): exclude
|
|
354
|
+
// delegate-broker shadow records from the DAG. They are managed by the
|
|
355
|
+
// external grandchild spawner, not this scheduler: (1) as WAVE members a
|
|
356
|
+
// running shadow would gate dependent waves / trigger markBlocked although
|
|
357
|
+
// it is never in ctx.pendingUnits; (2) getDagReadyTasks below ignores both
|
|
358
|
+
// status AND graph, so a queued OR running shadow surfaces as ready.
|
|
359
|
+
const workflowTasks = tasks.filter((t) => !isDelegateShadowTask(t));
|
|
331
360
|
// FIX (goal-wrap runtime test): task.dependsOn stores STEP IDs (e.g. "execute"), not
|
|
332
361
|
// task IDs (e.g. "02_execute"). The DAG scheduler compares deps against completedIds
|
|
333
362
|
// (which are task IDs), so step-ID deps would never match → dependent tasks stuck blocked
|
|
@@ -335,10 +364,10 @@ function dagReadyTaskIds(tasks: TeamTaskState[], completedIds: Set<string>): str
|
|
|
335
364
|
// task-graph-scheduler.ts which handles this via stepToTaskId). buildDagExecutionPlan +
|
|
336
365
|
// getDagReadyTasks then work on consistent task IDs.
|
|
337
366
|
const stepToTaskId = new Map<string, string>();
|
|
338
|
-
for (const t of
|
|
367
|
+
for (const t of workflowTasks) {
|
|
339
368
|
if (t.stepId) stepToTaskId.set(t.stepId, t.id);
|
|
340
369
|
}
|
|
341
|
-
const nodes: TaskNode[] =
|
|
370
|
+
const nodes: TaskNode[] = workflowTasks.map((t) => ({
|
|
342
371
|
id: t.id,
|
|
343
372
|
dependsOn: t.dependsOn.map((dep) => stepToTaskId.get(dep) ?? dep),
|
|
344
373
|
phase: t.adaptive?.phase ?? t.stepId,
|
|
@@ -410,7 +439,15 @@ export async function selectDispatchBatch(ctx: SchedulerContext): Promise<Schedu
|
|
|
410
439
|
// existing task-graph-scheduler when no explicit deps exist (backward compat).
|
|
411
440
|
const completedIds = new Set(ctx.tasks.filter((t) => t.status === "completed" || t.status === "needs_attention").map((t) => t.id));
|
|
412
441
|
const dagReady = dagReadyTaskIds(ctx.tasks, completedIds);
|
|
413
|
-
|
|
442
|
+
// RR-012 (adjacent scheduler risk — proven reachable): belt-and-braces
|
|
443
|
+
// selection guard. dagReadyTaskIds already excludes shadow records from the
|
|
444
|
+
// DAG; this filter also covers the snapshot fallback (a graphed queued
|
|
445
|
+
// shadow would resolve to queue "ready"). A shadow in a batch makes
|
|
446
|
+
// findStep() throw ResourceNotFound (task.stepId === undefined) — the run
|
|
447
|
+
// aborts (pre-warm site) or the record is force-failed by the synthesized
|
|
448
|
+
// unit error. Shadow records stay visible in snapshots / team status.
|
|
449
|
+
const shadowTaskIds = new Set(ctx.tasks.filter(isDelegateShadowTask).map((t) => t.id));
|
|
450
|
+
const readyBeforeFilter = (dagReady ?? snapshot.ready).filter((taskId) => !shadowTaskIds.has(taskId));
|
|
414
451
|
|
|
415
452
|
// Workflow phase precondition check (non-blocking: log warnings only).
|
|
416
453
|
if (ctx.wfMachine.currentPhaseIndex < ctx.wfMachine.phases.length) {
|
|
@@ -777,8 +814,25 @@ export async function dispatchBatch(ctx: SchedulerContext, decision: DispatchBat
|
|
|
777
814
|
// NOTE: no withRunLock — best-effort only; concurrent writes may cause inconsistency
|
|
778
815
|
const fresh = loadRunManifestById(ctx.manifest.cwd, ctx.manifest.runId);
|
|
779
816
|
const freshManifest = fresh?.manifest ?? ctx.manifest;
|
|
780
|
-
|
|
781
|
-
|
|
817
|
+
let freshTasks = fresh?.tasks ?? ctx.tasks;
|
|
818
|
+
let freshTask = freshTasks.find((item) => item.id === task.id) ?? task;
|
|
819
|
+
// US-003 (2026-09-22) — RETRY-NOOP BUG, found while wiring the dead-letter
|
|
820
|
+
// integration test: a retry (attempt > 1) only happens because OUR previous
|
|
821
|
+
// attempt THREW — i.e. we ourselves persisted the terminal failure (e.g.
|
|
822
|
+
// model-exhausted marks the task failed before the throw). The terminal-state
|
|
823
|
+
// early-return below then treated OUR OWN failure as "nothing to do" and
|
|
824
|
+
// returned it as SUCCESS: executeWithRetry never re-ran the task, never
|
|
825
|
+
// exhausted, and the onRetryGivenUp dead-letter never fired — autoRetry was
|
|
826
|
+
// silently a single attempt for every failure that persists task state.
|
|
827
|
+
// Fix: on a retry, re-queue OUR OWN terminal failure so the retry actually
|
|
828
|
+
// re-runs. Attempt 1 keeps the original guard (externally-terminal task =
|
|
829
|
+
// someone else's decision; external cancellation exits via the signal).
|
|
830
|
+
// 2026-09-23 (finding 7): re-queue ONLY own failures on an ACTIVE run —
|
|
831
|
+
// cancelled/completed tasks and terminal manifests must never resurrect.
|
|
832
|
+
if (shouldRequeueForRetry({ attempt, taskStatus: freshTask.status, manifestStatus: freshManifest.status })) {
|
|
833
|
+
freshTask = { ...freshTask, status: "queued", error: undefined, finishedAt: undefined };
|
|
834
|
+
freshTasks = freshTasks.map((item) => (item.id === task.id ? freshTask : item));
|
|
835
|
+
}
|
|
782
836
|
if (freshTask.status !== "queued" && freshTask.status !== "running")
|
|
783
837
|
return {
|
|
784
838
|
manifest: freshManifest,
|
|
@@ -855,6 +909,15 @@ export async function dispatchBatch(ctx: SchedulerContext, decision: DispatchBat
|
|
|
855
909
|
},
|
|
856
910
|
onRetryGivenUp: (attempts, error, info) => {
|
|
857
911
|
lastAttemptId = info.attemptId;
|
|
912
|
+
// US-003: aborts are NOT exhaustion — executeWithRetry also fires this
|
|
913
|
+
// hook on its cancelled exit, which previously deadlettered cancelled
|
|
914
|
+
// tasks as "max-retries" (false positive; keep the signal high).
|
|
915
|
+
if (runController.signal.aborted) return;
|
|
916
|
+
// US-003: richer entry (agent/role/modelAttempts/runStatus) so the
|
|
917
|
+
// project-level index is self-sufficient after the run dir is pruned.
|
|
918
|
+
// Prefer the FAILED attempt's final task state over the pre-run snapshot.
|
|
919
|
+
const failedTask = lastFailed?.tasks.find((item) => item.id === task.id);
|
|
920
|
+
const deadletterTask = failedTask ?? task;
|
|
858
921
|
appendDeadletter(ctx.manifest, {
|
|
859
922
|
runId: ctx.manifest.runId,
|
|
860
923
|
taskId: task.id,
|
|
@@ -863,6 +926,10 @@ export async function dispatchBatch(ctx: SchedulerContext, decision: DispatchBat
|
|
|
863
926
|
attemptId: info.attemptId,
|
|
864
927
|
lastError: error.message,
|
|
865
928
|
timestamp: new Date().toISOString(),
|
|
929
|
+
agent: deadletterTask.agent,
|
|
930
|
+
role: deadletterTask.role,
|
|
931
|
+
modelAttempts: deadletterTask.modelAttempts?.length,
|
|
932
|
+
runStatus: ctx.manifest.status,
|
|
866
933
|
});
|
|
867
934
|
input.metricRegistry
|
|
868
935
|
?.counter("crew.task.deadletter_total", "Deadletter triggers by reason")
|
|
@@ -23,7 +23,7 @@ import { effectiveRunConfig } from "../../extension/team-tool/config-patch.ts";
|
|
|
23
23
|
import { appendEventBuffered } from "../../state/event-log/event-log.ts";
|
|
24
24
|
import { registerActiveRun, unregisterActiveRun } from "../../state/stores/active-run-registry.ts";
|
|
25
25
|
import { collectRunMetrics } from "../../state/stores/run-metrics.ts";
|
|
26
|
-
import { createRunManifest, saveRunTasks } from "../../state/stores/state-store.ts";
|
|
26
|
+
import { createRunManifest, loadRunManifestById, saveRunTasks, updateRunStatus } from "../../state/stores/state-store.ts";
|
|
27
27
|
import type { GoalLoopState, GoalLoopStatus, GoalVerdict, TeamRunManifest, TeamTaskState } from "../../state/types.ts";
|
|
28
28
|
import type { TeamConfig } from "../../teams/team-config.ts";
|
|
29
29
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
@@ -39,6 +39,17 @@ import { GoalStore } from "./goal-state-store.ts";
|
|
|
39
39
|
export interface GoalLoopRuntimeDeps {
|
|
40
40
|
/** Resolve the agent configs reachable from cwd (used for executeTeamRun's agents arg). */
|
|
41
41
|
discoverAgents: (cwd: string) => AgentConfig[];
|
|
42
|
+
/**
|
|
43
|
+
* SR-01 test seam: override the per-turn config/runtime resolution. When
|
|
44
|
+
* provided, it replaces the loadConfig/effectiveRunConfig/resolveCrewRuntime
|
|
45
|
+
* chain — a test can throw here to exercise the pre-executeTeamRun failure
|
|
46
|
+
* path deterministically (the alternative, monkey-patching modules, is
|
|
47
|
+
* flaky). Production never sets it.
|
|
48
|
+
*/
|
|
49
|
+
resolveTurnConfig?: (cwd: string) => {
|
|
50
|
+
executedConfig: ReturnType<typeof effectiveRunConfig>;
|
|
51
|
+
runtime: Awaited<ReturnType<typeof resolveCrewRuntime>>;
|
|
52
|
+
};
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
export interface RunGoalLoopInput {
|
|
@@ -657,9 +668,17 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
657
668
|
// multi-step workflows (fast-fix, implementation) work correctly. Without these, the
|
|
658
669
|
// team-runner's DAG scheduler / runtime resolution can throw unhandled rejections on
|
|
659
670
|
// the second batch, which the background-runner's rejection guard catches → silent exit.
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
671
|
+
let turnExecutedConfig: ReturnType<typeof effectiveRunConfig>;
|
|
672
|
+
let turnRuntime: Awaited<ReturnType<typeof resolveCrewRuntime>>;
|
|
673
|
+
if (input.deps.resolveTurnConfig) {
|
|
674
|
+
const resolved = input.deps.resolveTurnConfig(goal.cwd);
|
|
675
|
+
turnExecutedConfig = resolved.executedConfig;
|
|
676
|
+
turnRuntime = resolved.runtime;
|
|
677
|
+
} else {
|
|
678
|
+
const turnConfig = loadConfig(goal.cwd);
|
|
679
|
+
turnExecutedConfig = effectiveRunConfig(turnConfig.config, {});
|
|
680
|
+
turnRuntime = await resolveCrewRuntime(turnExecutedConfig);
|
|
681
|
+
}
|
|
663
682
|
turnResult = await executeTeamRun({
|
|
664
683
|
manifest: created.manifest,
|
|
665
684
|
tasks: created.tasks,
|
|
@@ -674,6 +693,29 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
674
693
|
workspaceId: goal.ownerSessionId ?? goal.cwd,
|
|
675
694
|
signal,
|
|
676
695
|
});
|
|
696
|
+
} catch (error) {
|
|
697
|
+
// SR-01 (GL-1b part C, 2026-09-22): a throw between turn creation and
|
|
698
|
+
// executeTeamRun's own try (config resolution, runtime resolution, or
|
|
699
|
+
// executeTeamRun's PRE-try section) escaped with the turn manifest stuck
|
|
700
|
+
// at "queued" — a turn that is long dead still reported as in-flight, so
|
|
701
|
+
// `team status <turnId>` lied and a resume could double-run it. Mark it
|
|
702
|
+
// failed before re-throwing; the outer catch (GL-1b part B) then persists
|
|
703
|
+
// lastTurnError at the goal level.
|
|
704
|
+
//
|
|
705
|
+
// Guarded on status === "queued" so we never overwrite a status the
|
|
706
|
+
// executor already set (executeTeamRunCore failures already write failed
|
|
707
|
+
// themselves — no double-write). Best-effort: a failure to persist must
|
|
708
|
+
// not mask the original error.
|
|
709
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
710
|
+
try {
|
|
711
|
+
const loaded = loadRunManifestById(goal.cwd, created.manifest.runId);
|
|
712
|
+
if (loaded && loaded.manifest.status === "queued") {
|
|
713
|
+
updateRunStatus(loaded.manifest, "failed", `pre-execute failure: ${message}`);
|
|
714
|
+
}
|
|
715
|
+
} catch (persistError) {
|
|
716
|
+
logInternalError("goal-loop.markTurnFailed", persistError, `turnRunId=${created.manifest.runId}`);
|
|
717
|
+
}
|
|
718
|
+
throw error;
|
|
677
719
|
} finally {
|
|
678
720
|
unregisterActiveRun(created.manifest.runId);
|
|
679
721
|
}
|
|
@@ -689,6 +731,18 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
689
731
|
// Previously the loop continued to evaluateGoal on blocked/failed turns, burning maxTurns.
|
|
690
732
|
const turnStatus = turnResult.manifest.status;
|
|
691
733
|
if (turnStatus === "blocked" || turnStatus === "failed") {
|
|
734
|
+
// GL-1b (2026-09-22 diagnosability): the failure reason previously lived
|
|
735
|
+
// ONLY inside the turn-run dir (manifest.summary + run.failed event) —
|
|
736
|
+
// auto-prune (keep=10, session start) deletes turn dirs, making blocked
|
|
737
|
+
// goals undiagnosable afterwards. Persist the reason into the goal state
|
|
738
|
+
// (never pruned), carry it on the terminal event, and clear the stale
|
|
739
|
+
// currentRunId — the turn is over.
|
|
740
|
+
const turnError = turnResult.manifest.summary ?? `turn ${turnIndex} ended ${turnStatus}`;
|
|
741
|
+
try {
|
|
742
|
+
goal = store.patch(goal.goalId, { lastTurnError: turnError, currentRunId: undefined }, eventsPath) ?? goal;
|
|
743
|
+
} catch (error) {
|
|
744
|
+
logInternalError("goal-loop.persistTurnError", error, `goalId=${goal.goalId}`);
|
|
745
|
+
}
|
|
692
746
|
goal = safeSetStatus(store, goal.goalId, "blocked", goal, eventsPath);
|
|
693
747
|
appendEventBuffered(eventsPath, {
|
|
694
748
|
type: "goal.turn_terminal_status",
|
|
@@ -698,6 +752,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
698
752
|
turn: turnIndex,
|
|
699
753
|
turnRunId: created.manifest.runId,
|
|
700
754
|
turnStatus,
|
|
755
|
+
reason: turnError,
|
|
701
756
|
},
|
|
702
757
|
}).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.turn_terminal_status"));
|
|
703
758
|
break;
|
|
@@ -805,6 +860,20 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
|
|
|
805
860
|
}
|
|
806
861
|
} catch (error) {
|
|
807
862
|
logInternalError("goal-loop.run", error, `goalId=${goal.goalId}`);
|
|
863
|
+
// GL-1b: this catch previously only console-errored (logInternalError) —
|
|
864
|
+
// loop-level failures left NO persisted trace. Mirror the turn-level fix:
|
|
865
|
+
// persist the message into the goal state + event log.
|
|
866
|
+
const loopError = error instanceof Error ? error.message : String(error);
|
|
867
|
+
try {
|
|
868
|
+
goal = store.patch(goal.goalId, { lastTurnError: loopError, currentRunId: undefined }, eventsPath) ?? goal;
|
|
869
|
+
} catch {
|
|
870
|
+
/* best-effort — status transition below still runs */
|
|
871
|
+
}
|
|
872
|
+
appendEventBuffered(eventsPath, {
|
|
873
|
+
type: "goal.loop_error",
|
|
874
|
+
runId: manifest.runId,
|
|
875
|
+
data: { goalId: goal.goalId, error: loopError },
|
|
876
|
+
}).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.loop_error"));
|
|
808
877
|
goal = safeSetStatus(store, goal.goalId, "blocked", goal, eventsPath);
|
|
809
878
|
} finally {
|
|
810
879
|
// P1g: release the workspace lock (held since loop start).
|
|
@@ -98,6 +98,13 @@ export class HeartbeatWatcher {
|
|
|
98
98
|
if (loaded.manifest.status !== "running") continue;
|
|
99
99
|
for (const task of loaded.tasks) {
|
|
100
100
|
if (task.status !== "running") continue;
|
|
101
|
+
// Guest-child tasks (delegate subagents, agent === "delegate") have NO
|
|
102
|
+
// heartbeat channel: they never write task.heartbeat/agentProgress —
|
|
103
|
+
// their lifecycle is tracked by delegate.requested/admitted/completed
|
|
104
|
+
// broker events. Watching them here classified every guest as "dead"
|
|
105
|
+
// within one poll (52ms after admit, run team_20260926033657_2b6c6d2610b26d9d,
|
|
106
|
+
// 2026-09-26 battery) and enqueued false "stuck worker" ambients.
|
|
107
|
+
if (task.agent === "delegate") continue;
|
|
101
108
|
const key = `${run.runId}:${task.id}`;
|
|
102
109
|
activeKeys.add(key);
|
|
103
110
|
this.lastSeen.set(key, now);
|
|
@@ -70,6 +70,20 @@ function modelInfoFromUnknown(value: unknown): AvailableModelInfo | undefined {
|
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// US-012 (2026-09-22): cache the registry-derived model list. The registry is
|
|
74
|
+
// config/env-derived and invariant per session, but `availableModelInfosFromRegistry`
|
|
75
|
+
// is called on EVERY model resolution / worker spawn (a 4-worker team = 4
|
|
76
|
+
// rebuilds). Keyed on the registry object identity (WeakMap) so a new registry
|
|
77
|
+
// instance (config reload) gets a fresh entry automatically, and invalidated by
|
|
78
|
+
// the getAvailable() array identity so a mutated-in-place list is not served
|
|
79
|
+
// stale. WeakMap → no leak when a session's registry is dropped.
|
|
80
|
+
const registryModelCache = new WeakMap<object, { raw: unknown[]; infos: AvailableModelInfo[] }>();
|
|
81
|
+
|
|
82
|
+
/** @internal — test introspection. */
|
|
83
|
+
export function __test__registryModelCacheHas(registry: object): boolean {
|
|
84
|
+
return registryModelCache.has(registry);
|
|
85
|
+
}
|
|
86
|
+
|
|
73
87
|
export function availableModelInfosFromRegistry(registry: unknown): AvailableModelInfo[] | undefined {
|
|
74
88
|
if (!registry || typeof registry !== "object" || Array.isArray(registry)) return undefined;
|
|
75
89
|
const candidate = registry as ModelRegistryLike;
|
|
@@ -80,7 +94,13 @@ export function availableModelInfosFromRegistry(registry: unknown): AvailableMod
|
|
|
80
94
|
? candidate.getAll()
|
|
81
95
|
: undefined;
|
|
82
96
|
if (!Array.isArray(raw)) return undefined;
|
|
83
|
-
|
|
97
|
+
// Serve from cache only when the SAME array instance is returned — a
|
|
98
|
+
// rebuilt/refreshed list (different identity) must be re-normalized.
|
|
99
|
+
const cached = registryModelCache.get(registry as object);
|
|
100
|
+
if (cached && cached.raw === raw) return cached.infos;
|
|
101
|
+
const infos = raw.map(modelInfoFromUnknown).filter((entry): entry is AvailableModelInfo => entry !== undefined);
|
|
102
|
+
registryModelCache.set(registry as object, { raw, infos });
|
|
103
|
+
return infos;
|
|
84
104
|
}
|
|
85
105
|
|
|
86
106
|
export function modelStringFromUnknown(model: unknown): string | undefined {
|
|
@@ -3,7 +3,7 @@ import * as os from "node:os";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import type { AgentConfig } from "../../agents/agent-config.ts";
|
|
5
5
|
import { atomicWriteFile } from "../../state/atomic-write.ts";
|
|
6
|
-
import { packageRoot, userPiRoot } from "../../utils/paths.ts";
|
|
6
|
+
import { hasRunStateLayout, packageRoot, userPiRoot } from "../../utils/paths.ts";
|
|
7
7
|
|
|
8
8
|
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
9
9
|
// FIX (2026-07-02): use packageRoot() instead of import.meta.url-relative path.
|
|
@@ -629,16 +629,14 @@ export function cleanupLegacyOrphanTempDirs(
|
|
|
629
629
|
continue;
|
|
630
630
|
}
|
|
631
631
|
if (lstat.isSymbolicLink()) continue;
|
|
632
|
-
// Skip dirs containing
|
|
632
|
+
// Skip dirs containing pi-crew run state — those are handled by
|
|
633
633
|
// reconcileOrphanedTempWorkspaces which has run-state semantics.
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
}
|
|
641
|
-
if (crewDirLstat && !crewDirLstat.isSymbolicLink()) continue;
|
|
634
|
+
// RR-020 Fix 3: recognise BOTH supported layouts — `<dir>/.crew/` and
|
|
635
|
+
// the `.pi`-based `<dir>/.pi/teams/`. Only `.crew` used to be checked,
|
|
636
|
+
// so a temp workspace with live `.pi/teams` run state was deleted as
|
|
637
|
+
// debris. Symlinked layout dirs still do NOT protect the dir (the
|
|
638
|
+
// scanner keeps rejecting symlinks instead of trusting them).
|
|
639
|
+
if (hasRunStateLayout(dir)) continue;
|
|
642
640
|
// Skip dirs currently tracked by this process (defense in depth:
|
|
643
641
|
// with 8ba270d the Set should never contain /tmp/ paths, but
|
|
644
642
|
// future code or external callers might).
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { DEFAULT_PATHS } from "../../config/defaults.ts";
|
|
4
5
|
import { appendHookEvent, executeHook } from "../../hooks/registry.ts";
|
|
5
6
|
import type { MetricRegistry } from "../../observability/metric-registry.ts";
|
|
6
|
-
import { withRunLockSync } from "../../state/coordination/locks.ts";
|
|
7
|
+
import { discoverRunLockFiles, sweepStaleLocks, withRunLockSync } from "../../state/coordination/locks.ts";
|
|
7
8
|
import { appendEvent, scanSequence } from "../../state/event-log/event-log.ts";
|
|
8
9
|
import { readActiveRunRegistry, unregisterActiveRun } from "../../state/stores/active-run-registry.ts";
|
|
9
10
|
import { loadRunManifestById, saveRunManifest, saveRunTasks, updateRunStatus } from "../../state/stores/state-store.ts";
|
|
@@ -757,5 +758,28 @@ export function reconcileAllStaleRuns(
|
|
|
757
758
|
}
|
|
758
759
|
});
|
|
759
760
|
}
|
|
761
|
+
// US-002 (2026-09-22): structured stale-lock sweep. Runs after reconciliation
|
|
762
|
+
// so locks whose holder died (kill -9 / crash) do not linger. The sweep is
|
|
763
|
+
// strictly safer than acquire-time steal (stale AND holder-dead only), so a
|
|
764
|
+
// live holder is never disturbed. Best-effort.
|
|
765
|
+
try {
|
|
766
|
+
const runsRoots = [
|
|
767
|
+
path.join(projectCrewRoot(cwd), DEFAULT_PATHS.state.runsSubdir),
|
|
768
|
+
path.join(userCrewRoot(), DEFAULT_PATHS.state.runsSubdir),
|
|
769
|
+
];
|
|
770
|
+
for (const runsRoot of runsRoots) {
|
|
771
|
+
const { removed } = sweepStaleLocks(discoverRunLockFiles(runsRoot));
|
|
772
|
+
if (removed.length > 0) {
|
|
773
|
+
logInternalError(
|
|
774
|
+
"crash-recovery.sweep-stale-locks",
|
|
775
|
+
new Error(`swept ${removed.length} stale run lock(s)`),
|
|
776
|
+
removed.join(", "),
|
|
777
|
+
"warn",
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
} catch (error) {
|
|
782
|
+
logInternalError("crash-recovery.sweep-stale-locks", error);
|
|
783
|
+
}
|
|
760
784
|
return results;
|
|
761
785
|
}
|
|
@@ -72,7 +72,18 @@ export interface WorkerSpawnInput extends ChildPiRunInput {
|
|
|
72
72
|
export async function runWorker(input: WorkerSpawnInput): Promise<ChildPiRunResult> {
|
|
73
73
|
const { cap = true, ...childPiInput } = input;
|
|
74
74
|
if (cap) {
|
|
75
|
-
|
|
75
|
+
// RR-014 / F15: thread the caller's signal into the slot WAIT itself.
|
|
76
|
+
// Previously the signal lived only inside childPiInput and was read by
|
|
77
|
+
// runChildPi AFTER the acquire resolved — a cancelled task queued on a
|
|
78
|
+
// full pool stayed pending until the slot holder finished (and delayed
|
|
79
|
+
// every drainPendingUnits awaiting it). Two consistent outcomes:
|
|
80
|
+
// - abort wins the race → acquire rejects (SemaphoreAbortedError), no
|
|
81
|
+
// slot taken, fn never runs;
|
|
82
|
+
// - grant wins the race → acquire resolves, runChildPi's pre-spawn
|
|
83
|
+
// guard (child-pi-spawn.ts B5) sees signal.aborted and returns
|
|
84
|
+
// kind "aborted" WITHOUT spawning, and withWorkerSlot's finally
|
|
85
|
+
// releases the slot within a microtask.
|
|
86
|
+
return withWorkerSlot(() => runChildPi(childPiInput), childPiInput.signal);
|
|
76
87
|
}
|
|
77
88
|
return runChildPi(childPiInput);
|
|
78
89
|
}
|
|
@@ -64,11 +64,16 @@ export function __test_resetCap(testCapacity: number): void {
|
|
|
64
64
|
* Acquire a global worker slot. Resolves immediately if under cap, else queues
|
|
65
65
|
* (FIFO) until a slot frees. MUST be paired with releaseWorkerSlot().
|
|
66
66
|
*
|
|
67
|
+
* RR-014 / F15: an optional AbortSignal makes the WAIT itself cancellable — a
|
|
68
|
+
* waiter whose signal fires is removed from the queue eagerly and the acquire
|
|
69
|
+
* REJECTS (SemaphoreAbortedError) without consuming a slot. Without a signal
|
|
70
|
+
* the semantics are unchanged.
|
|
71
|
+
*
|
|
67
72
|
* Used to bound WORKER spawns only. Do NOT route the goal-judge through this
|
|
68
73
|
* (see the RFC MAJ#3 rationale in the module header).
|
|
69
74
|
*/
|
|
70
|
-
export async function acquireWorkerSlot(): Promise<void> {
|
|
71
|
-
await semaphore.acquire();
|
|
75
|
+
export async function acquireWorkerSlot(signal?: AbortSignal): Promise<void> {
|
|
76
|
+
await semaphore.acquire(signal);
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
/**
|
|
@@ -82,13 +87,15 @@ export function releaseWorkerSlot(): void {
|
|
|
82
87
|
/**
|
|
83
88
|
* Convenience: acquire a worker slot, run `fn`, and release on completion OR
|
|
84
89
|
* throw. The slot is ALWAYS released — including when `fn` rejects — so a
|
|
85
|
-
* throwing worker never leaks a slot (deadlock prevention).
|
|
90
|
+
* throwing worker never leaks a slot (deadlock prevention). If `signal` is
|
|
91
|
+
* provided and fires while WAITING for a slot, the acquire rejects and `fn`
|
|
92
|
+
* never runs (no slot was taken, so there is nothing to release).
|
|
86
93
|
*
|
|
87
94
|
* Example:
|
|
88
|
-
* const result = await withWorkerSlot(() => runChildPi(...));
|
|
95
|
+
* const result = await withWorkerSlot(() => runChildPi(...), input.signal);
|
|
89
96
|
*/
|
|
90
|
-
export async function withWorkerSlot<T>(fn: () => Promise<T
|
|
91
|
-
await acquireWorkerSlot();
|
|
97
|
+
export async function withWorkerSlot<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
98
|
+
await acquireWorkerSlot(signal);
|
|
92
99
|
try {
|
|
93
100
|
return await fn();
|
|
94
101
|
} finally {
|
|
@@ -5,6 +5,7 @@ import { writeArtifact } from "../../state/stores/artifact-store.ts";
|
|
|
5
5
|
import { loadRunManifestById, saveRunTasksAsync, updateRunStatus } from "../../state/stores/state-store.ts";
|
|
6
6
|
import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
|
|
7
7
|
import type { WorkflowStep } from "../../workflows/workflow-config.ts";
|
|
8
|
+
import { appendDeadletter } from "../deadletter.ts";
|
|
8
9
|
import { createWorkerHeartbeat, touchWorkerHeartbeat } from "../heartbeat/worker-heartbeat.ts";
|
|
9
10
|
import type { CrewRuntimeMode } from "../model/runtime-resolver.ts";
|
|
10
11
|
import type { RetryPolicy } from "../recovery/retry-executor.ts";
|
|
@@ -196,7 +197,32 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
|
|
|
196
197
|
}
|
|
197
198
|
};
|
|
198
199
|
try {
|
|
199
|
-
const result = useRetry
|
|
200
|
+
const result = useRetry
|
|
201
|
+
? await executeWithRetry(runOnce, policy, {
|
|
202
|
+
signal,
|
|
203
|
+
// US-003 (2026-09-22): exhausted retries on the COALESCED path were never
|
|
204
|
+
// deadlettered — only the singleton path (dispatch-batch) wired
|
|
205
|
+
// onRetryGivenUp, so an M6 group that burned its whole retry budget left
|
|
206
|
+
// no post-mortem record once the run dir was pruned. The group shares ONE
|
|
207
|
+
// worker: the entry is recorded against the group's first task and names
|
|
208
|
+
// the whole group in the message. Aborts are NOT exhaustion — skip them
|
|
209
|
+
// (executeWithRetry also fires this hook on the cancelled exit).
|
|
210
|
+
onRetryGivenUp: (attempts, error) => {
|
|
211
|
+
if (signal?.aborted) return;
|
|
212
|
+
appendDeadletter(manifest, {
|
|
213
|
+
runId: manifest.runId,
|
|
214
|
+
taskId: firstTask.id,
|
|
215
|
+
reason: "max-retries",
|
|
216
|
+
attempts,
|
|
217
|
+
lastError: `coalesced group (${taskIds.length} task(s): ${taskIds.join(", ")}) — ${error.message}`,
|
|
218
|
+
timestamp: new Date().toISOString(),
|
|
219
|
+
agent: agent.name,
|
|
220
|
+
role: firstTask.role,
|
|
221
|
+
runStatus: manifest.status,
|
|
222
|
+
});
|
|
223
|
+
},
|
|
224
|
+
})
|
|
225
|
+
: await runOnce();
|
|
200
226
|
rawOutput = result.rawFinalText ?? result.stdout ?? "";
|
|
201
227
|
// RT-5 #1/#2: distinguish cancel from failure. Cancel = run-level
|
|
202
228
|
// signal aborted OR the child reports cooperative cancellation.
|