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.
Files changed (174) hide show
  1. package/CHANGELOG.md +155 -9
  2. package/README.md +161 -1037
  3. package/agents/verifier.md +18 -7
  4. package/dist/index.mjs +744 -90644
  5. package/docs/README.md +57 -46
  6. package/docs/architecture.md +87 -33
  7. package/docs/commands-reference.md +9 -5
  8. package/docs/troubleshooting.md +3 -2
  9. package/package.json +1 -3
  10. package/schema.json +39 -0
  11. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +2 -0
  12. package/skills/real-test-pi-crew/SKILL.md +371 -34
  13. package/src/agents/agent-config.ts +1 -1
  14. package/src/agents/discover-agents.ts +1 -1
  15. package/src/config/config-validation.ts +15 -2
  16. package/src/config/config.ts +47 -13
  17. package/src/config/defaults.ts +0 -1
  18. package/src/config/env-vars.ts +35 -0
  19. package/src/config/types.ts +19 -5
  20. package/src/errors.ts +2 -2
  21. package/src/extension/async-notifier.ts +23 -0
  22. package/src/extension/crew-vibes/config.ts +0 -21
  23. package/src/extension/crew-vibes/index.ts +0 -2
  24. package/src/extension/crew-vibes/render.ts +1 -50
  25. package/src/extension/help.ts +21 -12
  26. package/src/extension/knowledge-injection.ts +2 -1
  27. package/src/extension/management.ts +8 -3
  28. package/src/extension/notification-sink.ts +17 -0
  29. package/src/extension/register.ts +7 -2
  30. package/src/extension/registration/command-utils.ts +28 -2
  31. package/src/extension/registration/commands/dashboard.ts +11 -1
  32. package/src/extension/registration/commands/manage.ts +36 -19
  33. package/src/extension/registration/commands/run.ts +24 -2
  34. package/src/extension/registration/commands/shared.ts +23 -1
  35. package/src/extension/registration/commands/status.ts +25 -2
  36. package/src/extension/registration/context-builder.ts +8 -2
  37. package/src/extension/registration/health-notify-policy.ts +100 -0
  38. package/src/extension/registration/lazy-configurers.ts +35 -0
  39. package/src/extension/registration/lifecycle-handlers.ts +91 -30
  40. package/src/extension/registration/lifecycle.ts +75 -10
  41. package/src/extension/registration/observability.ts +98 -35
  42. package/src/extension/registration/registration-types.ts +7 -5
  43. package/src/extension/registration/runtime-cleanup.ts +9 -3
  44. package/src/extension/registration/subagent-helpers.ts +38 -0
  45. package/src/extension/registration/subagent-tools.ts +16 -6
  46. package/src/extension/registration/team-tool.ts +10 -3
  47. package/src/extension/registration/terminal-status-wiring.ts +172 -0
  48. package/src/extension/registration/viewers.ts +6 -0
  49. package/src/extension/registration/wire-cross-extension.ts +28 -0
  50. package/src/extension/run-compare.ts +220 -0
  51. package/src/extension/run-export.ts +37 -5
  52. package/src/extension/run-maintenance.ts +155 -5
  53. package/src/extension/team-tool/dispatch/index.ts +3 -2
  54. package/src/extension/team-tool/dispatch/manage.ts +5 -2
  55. package/src/extension/team-tool/goal.ts +4 -1
  56. package/src/extension/team-tool/handle-settings.ts +33 -4
  57. package/src/extension/team-tool/health-monitor.ts +21 -7
  58. package/src/extension/team-tool/lifecycle-actions.ts +49 -1
  59. package/src/extension/team-tool/plan.ts +10 -0
  60. package/src/extension/team-tool/routing-hint.ts +63 -0
  61. package/src/extension/team-tool/status.ts +4 -0
  62. package/src/extension/team-tool.ts +52 -6
  63. package/src/extension/webhook-notify.ts +382 -0
  64. package/src/observability/metric-sink.ts +12 -2
  65. package/src/prompt/prompt-runtime.ts +82 -31
  66. package/src/prompt/worker-events-channel.ts +12 -0
  67. package/src/runtime/README.md +1 -1
  68. package/src/runtime/async-runner.ts +87 -1
  69. package/src/runtime/background-runner.ts +313 -234
  70. package/src/runtime/broker/crew-broker.ts +17 -11
  71. package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
  72. package/src/runtime/broker/wait-status-cache.ts +1 -1
  73. package/src/runtime/child-pi/child-pi-timers.ts +1 -1
  74. package/src/runtime/child-pi/mock-fixtures.ts +48 -0
  75. package/src/runtime/crew-agent-records.ts +337 -45
  76. package/src/runtime/deadletter.ts +43 -1
  77. package/src/runtime/delegate-spawn.ts +5 -1
  78. package/src/runtime/dispatch-batch.ts +72 -5
  79. package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
  80. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  81. package/src/runtime/model/model-fallback.ts +21 -1
  82. package/src/runtime/model/pi-args.ts +8 -10
  83. package/src/runtime/recovery/crash-recovery.ts +25 -1
  84. package/src/runtime/run-worker.ts +12 -1
  85. package/src/runtime/scheduling/global-worker-cap.ts +13 -6
  86. package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
  87. package/src/runtime/scheduling/scheduler.ts +49 -13
  88. package/src/runtime/scheduling/semaphore.ts +148 -20
  89. package/src/runtime/scratchpad/README.md +1 -1
  90. package/src/runtime/scratchpad/protocol.ts +1 -1
  91. package/src/runtime/settings-store.ts +1 -1
  92. package/src/runtime/skill-instructions.ts +22 -0
  93. package/src/runtime/stale-reconciler.ts +85 -13
  94. package/src/runtime/task-display.ts +1 -1
  95. package/src/runtime/task-runner/pre-execution.ts +26 -2
  96. package/src/runtime/task-runner/prompt-builder.ts +142 -45
  97. package/src/runtime/task-runner.ts +21 -1
  98. package/src/runtime/team-runner.ts +38 -1
  99. package/src/runtime/workspace-lock.ts +4 -1
  100. package/src/schema/config-schema.ts +18 -0
  101. package/src/schema/team-tool-schema.ts +17 -0
  102. package/src/state/atomic-write.ts +53 -0
  103. package/src/state/contracts.ts +109 -0
  104. package/src/state/coordination/locks.ts +191 -33
  105. package/src/state/coordination/mailbox.ts +140 -15
  106. package/src/state/crew-init.ts +87 -12
  107. package/src/state/event-log/cursor.ts +37 -1
  108. package/src/state/event-log/event-log-rotation.ts +72 -7
  109. package/src/state/stores/active-run-registry.ts +13 -1
  110. package/src/state/stores/state-store.ts +112 -22
  111. package/src/state/types.ts +4 -0
  112. package/src/ui/adaptive-card.ts +65 -0
  113. package/src/ui/agents-jobs-browser.ts +70 -64
  114. package/src/ui/card-colors.ts +36 -7
  115. package/src/ui/dashboard-panes/agents-pane.ts +55 -14
  116. package/src/ui/dashboard-panes/cancellation-pane.ts +0 -42
  117. package/src/ui/dashboard-panes/health-pane.ts +7 -5
  118. package/src/ui/dashboard-panes/mailbox-pane.ts +22 -6
  119. package/src/ui/dashboard-panes/metrics-pane.ts +15 -7
  120. package/src/ui/dashboard-panes/pane-theme.ts +21 -0
  121. package/src/ui/dashboard-panes/plan-pane.ts +63 -30
  122. package/src/ui/dashboard-panes/progress-pane.ts +3 -2
  123. package/src/ui/dashboard-panes/schedules-pane.ts +44 -21
  124. package/src/ui/dashboard-panes/transcript-pane.ts +11 -5
  125. package/src/ui/dwf-phase-display.ts +3 -20
  126. package/src/ui/format-helpers.ts +22 -0
  127. package/src/ui/heartbeat-aggregator.ts +34 -0
  128. package/src/ui/inline-panel/crew-editor.ts +13 -3
  129. package/src/ui/inline-panel/index.ts +60 -4
  130. package/src/ui/keybinding-map.ts +251 -35
  131. package/src/ui/live-conversation-overlay.ts +180 -47
  132. package/src/ui/live-run-sidebar.ts +134 -55
  133. package/src/ui/mascot.ts +32 -16
  134. package/src/ui/overlays/agent-picker-overlay.ts +81 -26
  135. package/src/ui/overlays/confirm-overlay.ts +55 -29
  136. package/src/ui/overlays/help-overlay.ts +108 -53
  137. package/src/ui/overlays/mailbox-compose-overlay.ts +89 -50
  138. package/src/ui/overlays/mailbox-detail-overlay.ts +137 -57
  139. package/src/ui/powerbar-publisher.ts +0 -1
  140. package/src/ui/rail.ts +333 -0
  141. package/src/ui/run-dashboard.ts +193 -79
  142. package/src/ui/run-snapshot-cache.ts +18 -1
  143. package/src/ui/settings-overlay.ts +81 -39
  144. package/src/ui/spinner.ts +26 -2
  145. package/src/ui/terminal-status.ts +7 -1
  146. package/src/ui/theme-adapter.ts +0 -45
  147. package/src/ui/theme-discovery.ts +12 -6
  148. package/src/ui/tool-progress-formatter.ts +128 -9
  149. package/src/ui/tool-renderers/brief-mode.ts +10 -67
  150. package/src/ui/tool-renderers/index.ts +374 -523
  151. package/src/ui/transcript-viewer.ts +30 -12
  152. package/src/ui/widget/index.ts +32 -52
  153. package/src/ui/widget/task-list.ts +64 -32
  154. package/src/ui/widget/widget-formatters.ts +3 -402
  155. package/src/ui/widget/widget-model.ts +28 -7
  156. package/src/ui/widget/widget-renderer.ts +201 -128
  157. package/src/ui/widget/widget-types.ts +0 -2
  158. package/src/utils/incremental-reader.ts +11 -3
  159. package/src/utils/paths.ts +94 -12
  160. package/src/utils/project-markers.ts +40 -0
  161. package/src/utils/visual.ts +0 -4
  162. package/src/worktree/worktree-manager.ts +206 -26
  163. package/workflows/distill.workflow.md +3 -3
  164. package/workflows/fast-fix.workflow.md +1 -1
  165. package/workflows/plan-execute.workflow.md +1 -1
  166. package/workflows/review.workflow.md +1 -1
  167. package/workflows/strict-fast-fix.workflow.md +1 -1
  168. package/docs/migration-v0.4-v0.5.md +0 -208
  169. package/docs/runtime-flow.md +0 -148
  170. package/src/extension/crew-vibes/figures.ts +0 -22
  171. package/src/extension/crew-vibes/font-detect.ts +0 -71
  172. package/src/ui/dynamic-border.ts +0 -35
  173. package/src/ui/loaders.ts +0 -6
  174. 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
- const artifactsRoot = path.join(input.cwd, ".crew", "artifacts", input.runId, input.parentTaskId, "nested", input.subId);
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 tasks) {
367
+ for (const t of workflowTasks) {
339
368
  if (t.stepId) stepToTaskId.set(t.stepId, t.id);
340
369
  }
341
- const nodes: TaskNode[] = tasks.map((t) => ({
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
- const readyBeforeFilter = dagReady ?? snapshot.ready;
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
- const freshTasks = fresh?.tasks ?? ctx.tasks;
781
- const freshTask = freshTasks.find((item) => item.id === task.id) ?? task;
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
- const turnConfig = loadConfig(goal.cwd);
661
- const turnExecutedConfig = effectiveRunConfig(turnConfig.config, {});
662
- const turnRuntime = await resolveCrewRuntime(turnExecutedConfig);
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
- return raw.map(modelInfoFromUnknown).filter((entry): entry is AvailableModelInfo => entry !== undefined);
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 active run state — those are handled by
632
+ // Skip dirs containing pi-crew run state — those are handled by
633
633
  // reconcileOrphanedTempWorkspaces which has run-state semantics.
634
- const crewDir = path.join(dir, ".crew");
635
- let crewDirLstat: fs.Stats | undefined;
636
- try {
637
- crewDirLstat = fs.lstatSync(crewDir);
638
- } catch {
639
- // doesn't exist
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
- return withWorkerSlot(() => runChildPi(childPiInput));
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>): 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 ? await executeWithRetry(runOnce, policy, { signal }) : await runOnce();
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.