pi-crew 0.9.67 → 0.10.1

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 (222) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/agents/analyst.md +1 -1
  3. package/agents/cold-verifier.md +3 -1
  4. package/agents/critic.md +1 -1
  5. package/agents/executor.md +1 -1
  6. package/agents/explorer.md +1 -1
  7. package/agents/planner.md +1 -1
  8. package/agents/reviewer.md +1 -1
  9. package/agents/security-reviewer.md +1 -1
  10. package/agents/test-engineer.md +1 -1
  11. package/agents/verifier.md +1 -1
  12. package/agents/writer.md +1 -1
  13. package/dist/index.mjs +65847 -60331
  14. package/docs/README.md +2 -0
  15. package/docs/actions-reference.md +31 -0
  16. package/docs/commands-reference.md +17 -6
  17. package/docs/resource-formats.md +13 -0
  18. package/package.json +9 -6
  19. package/schema.json +15 -1
  20. package/scripts/resource-sampler.mjs +36 -2
  21. package/skills/requirements-to-task-packet/SKILL.md +26 -0
  22. package/skills/widget-rendering/SKILL.md +7 -7
  23. package/src/agents/agent-config.ts +36 -14
  24. package/src/agents/discover-agents.ts +23 -14
  25. package/src/config/config-merge.ts +183 -0
  26. package/src/config/config-validation.ts +681 -0
  27. package/src/config/config.ts +22 -864
  28. package/src/config/defaults.ts +35 -1
  29. package/src/config/drift-detector.ts +1 -1
  30. package/src/config/env-vars.ts +691 -0
  31. package/src/config/role-tools.ts +8 -8
  32. package/src/config/sanitize-project-config.ts +172 -0
  33. package/src/config/types.ts +30 -0
  34. package/src/extension/async-notifier.ts +25 -2
  35. package/src/extension/crew-vibes/config.ts +2 -1
  36. package/src/extension/plan-orchestrate.ts +132 -0
  37. package/src/extension/registration/commands/dashboard.ts +158 -0
  38. package/src/extension/registration/commands/index.ts +35 -0
  39. package/src/extension/registration/commands/manage.ts +303 -0
  40. package/src/extension/registration/commands/run.ts +214 -0
  41. package/src/extension/registration/commands/shared.ts +631 -0
  42. package/src/extension/registration/commands/status.ts +60 -0
  43. package/src/extension/registration/commands.ts +13 -1224
  44. package/src/extension/registration/lifecycle-handlers.ts +126 -15
  45. package/src/extension/registration/subagent-tools.ts +218 -9
  46. package/src/extension/rpc-hmac.ts +5 -3
  47. package/src/extension/team-tool/api/heartbeat.ts +47 -10
  48. package/src/extension/team-tool/api/plan-approval.ts +9 -0
  49. package/src/extension/team-tool/api/task-claims.ts +109 -40
  50. package/src/extension/team-tool/cancel.ts +84 -50
  51. package/src/extension/team-tool/dispatch/index.ts +1 -0
  52. package/src/extension/team-tool/dispatch/run.ts +4 -1
  53. package/src/extension/team-tool/doctor.ts +103 -1
  54. package/src/extension/team-tool/orchestrate.ts +66 -1
  55. package/src/extension/team-tool/plans.ts +192 -0
  56. package/src/extension/team-tool/respond.ts +197 -65
  57. package/src/extension/team-tool/run-deadline.ts +16 -1
  58. package/src/extension/team-tool/run-intent.ts +63 -0
  59. package/src/extension/team-tool/run.ts +48 -18
  60. package/src/extension/team-tool/status.ts +84 -26
  61. package/src/extension/team-tool.ts +11 -2
  62. package/src/hooks/registry.ts +1 -6
  63. package/src/i18n.ts +9 -0
  64. package/src/prompt/prompt-runtime.ts +521 -2
  65. package/src/prompt/worker-events-channel.ts +173 -0
  66. package/src/runtime/README.md +8 -8
  67. package/src/runtime/async-runner.ts +7 -3
  68. package/src/runtime/background-runner.ts +42 -14
  69. package/src/runtime/broker/broker-issuer.ts +9 -2
  70. package/src/runtime/broker/crew-broker-tokens.ts +43 -6
  71. package/src/runtime/broker/crew-broker.ts +838 -10
  72. package/src/runtime/broker/wait-status-cache.ts +157 -0
  73. package/src/runtime/budget-enforcement.ts +281 -0
  74. package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
  75. package/src/runtime/child-pi/child-pi-timers.ts +324 -0
  76. package/src/runtime/child-pi/child-pi.ts +97 -201
  77. package/src/runtime/child-pi/mock-fixtures.ts +16 -2
  78. package/src/runtime/crew-agent-records.ts +259 -14
  79. package/src/runtime/delegate-spawn.ts +148 -0
  80. package/src/runtime/deterministic-ast.ts +2 -1
  81. package/src/runtime/dispatch-batch.ts +945 -0
  82. package/src/runtime/finalize-run.ts +557 -0
  83. package/src/runtime/goal-workflow/adaptive-plan.ts +87 -13
  84. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -2
  85. package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
  86. package/src/runtime/group-join.ts +11 -125
  87. package/src/runtime/live-session/live-session-runtime.ts +26 -1
  88. package/src/runtime/merge-gate.ts +7 -1
  89. package/src/runtime/merge-loop.ts +130 -0
  90. package/src/runtime/model/model-budget-summary.ts +53 -0
  91. package/src/runtime/model/model-fallback.ts +32 -2
  92. package/src/runtime/model/pi-args.ts +10 -0
  93. package/src/runtime/model/provider-extensions.ts +10 -0
  94. package/src/runtime/orphan-worker-registry.ts +1 -1
  95. package/src/runtime/output/output-validator.ts +45 -0
  96. package/src/runtime/parent-guard.ts +3 -1
  97. package/src/runtime/peer-dep.ts +2 -1
  98. package/src/runtime/per-write-validator.ts +0 -5
  99. package/src/runtime/pi-spawn.ts +61 -15
  100. package/src/runtime/plan-approval.ts +125 -0
  101. package/src/runtime/plan-replan.ts +151 -0
  102. package/src/runtime/recovery/checkpoint.ts +0 -18
  103. package/src/runtime/recovery/crash-recovery.ts +95 -46
  104. package/src/runtime/scheduler-context.ts +98 -0
  105. package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
  106. package/src/runtime/scheduling/global-worker-cap.ts +2 -1
  107. package/src/runtime/scheduling/nested-slots.ts +70 -0
  108. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
  109. package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
  110. package/src/runtime/scratchpad/README.md +10 -5
  111. package/src/runtime/scratchpad/guest.ts +103 -2
  112. package/src/runtime/scratchpad/protocol.ts +13 -1
  113. package/src/runtime/scratchpad/transform.ts +206 -12
  114. package/src/runtime/settings-store.ts +219 -0
  115. package/src/runtime/spawn-policy.ts +217 -0
  116. package/src/runtime/stale-reconciler.ts +87 -6
  117. package/src/runtime/subagent-manager.ts +25 -1
  118. package/src/runtime/task-output-context.ts +230 -9
  119. package/src/runtime/task-packet.ts +23 -1
  120. package/src/runtime/task-runner/child-executor.ts +106 -7
  121. package/src/runtime/task-runner/post-execution.ts +125 -1
  122. package/src/runtime/task-runner/pre-execution.ts +39 -1
  123. package/src/runtime/task-runner/prompt-builder.ts +51 -1
  124. package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
  125. package/src/runtime/task-runner/spec-evidence.ts +403 -0
  126. package/src/runtime/task-runner/state-helpers.ts +26 -24
  127. package/src/runtime/task-runner.ts +11 -0
  128. package/src/runtime/team-runner.ts +129 -1671
  129. package/src/runtime/verification/spec-sandbox.ts +255 -0
  130. package/src/runtime/verification/verification-gates.ts +3 -2
  131. package/src/runtime/verification/verification-worktree.ts +2 -1
  132. package/src/runtime/workflow-phase-advance.ts +100 -0
  133. package/src/runtime/workspace-tree.ts +9 -0
  134. package/src/schema/config-schema.ts +65 -25
  135. package/src/schema/sensitive-config-paths.ts +64 -0
  136. package/src/schema/team-tool-schema.ts +13 -3
  137. package/src/state/README.md +4 -10
  138. package/src/state/atomic-write.ts +20 -3
  139. package/src/state/contracts.ts +38 -0
  140. package/src/state/coordination/mailbox.ts +12 -2
  141. package/src/state/event-log/cursor.ts +223 -0
  142. package/src/state/event-log/event-log-rotation.ts +12 -4
  143. package/src/state/event-log/event-log.ts +152 -369
  144. package/src/state/event-log/sequence-cache.ts +373 -0
  145. package/src/state/event-log/worker-atomic-writer.ts +2 -1
  146. package/src/state/stores/active-run-registry.ts +3 -2
  147. package/src/state/stores/manifest-io.ts +237 -0
  148. package/src/state/stores/ownership-map.ts +162 -0
  149. package/src/state/stores/plan-store.ts +241 -0
  150. package/src/state/stores/run-cache.ts +0 -90
  151. package/src/state/stores/spec-store.ts +189 -0
  152. package/src/state/stores/state-store.ts +66 -229
  153. package/src/state/types.ts +197 -0
  154. package/src/ui/dashboard-panes/plan-pane.ts +136 -0
  155. package/src/ui/dashboard-panes/progress-pane.ts +6 -0
  156. package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
  157. package/src/ui/heartbeat-aggregator.ts +9 -1
  158. package/src/ui/keybinding-map.ts +54 -13
  159. package/src/ui/powerbar-publisher.ts +52 -1
  160. package/src/ui/run-dashboard.ts +31 -5
  161. package/src/ui/run-snapshot-cache.ts +57 -30
  162. package/src/ui/snapshot-types.ts +6 -1
  163. package/src/ui/widget/widget-renderer.ts +9 -1
  164. package/src/utils/file-coalescer.ts +0 -4
  165. package/src/utils/fs-errno.ts +66 -0
  166. package/src/utils/fs-watch.ts +1 -1
  167. package/src/utils/internal-error.ts +3 -1
  168. package/src/utils/paths.ts +11 -3
  169. package/src/utils/task-name-generator.ts +1 -8
  170. package/src/workflows/discover-workflows.ts +19 -2
  171. package/src/workflows/validate-workflow.ts +7 -1
  172. package/src/workflows/workflow-config.ts +10 -0
  173. package/src/workflows/workflow-serializer.ts +3 -0
  174. package/src/worktree/worktree-manager.ts +22 -0
  175. package/src/agents/agent-search.ts +0 -98
  176. package/src/benchmark/benchmark-runner.ts +0 -313
  177. package/src/benchmark/feedback-loop.ts +0 -73
  178. package/src/config/resilient-parser.ts +0 -117
  179. package/src/extension/crew-vibes/cat-frames.ts +0 -18
  180. package/src/extension/result-watcher.ts +0 -139
  181. package/src/observability/exporters/prometheus-exporter.ts +0 -54
  182. package/src/observability/metric-retention.ts +0 -64
  183. package/src/runtime/compaction/compaction-summary.ts +0 -278
  184. package/src/runtime/errors/crew-errors.ts +0 -162
  185. package/src/runtime/live-session/intercom-bridge.ts +0 -187
  186. package/src/runtime/loop-gates.ts +0 -128
  187. package/src/runtime/metric-parser.ts +0 -36
  188. package/src/runtime/output/stream-preview.ts +0 -184
  189. package/src/runtime/output/tool-progress.ts +0 -278
  190. package/src/runtime/phase-tracker.ts +0 -385
  191. package/src/runtime/pipeline-runner.ts +0 -523
  192. package/src/runtime/process/process-lifecycle.ts +0 -491
  193. package/src/runtime/recovery/retry-runner.ts +0 -330
  194. package/src/runtime/run-drift.ts +0 -219
  195. package/src/runtime/scratchpad/snapshot-hmac.ts +0 -167
  196. package/src/runtime/task-quality.ts +0 -199
  197. package/src/runtime/task-runner/run-projection.ts +0 -128
  198. package/src/runtime/verification/post-checks.ts +0 -142
  199. package/src/state/coordination/schedule.ts +0 -166
  200. package/src/state/event-log/jsonl-writer.ts +0 -115
  201. package/src/state/hook-instinct-bridge.ts +0 -94
  202. package/src/state/hook-integrations.ts +0 -51
  203. package/src/state/session-state-map.ts +0 -51
  204. package/src/state/stores/blob-store.ts +0 -308
  205. package/src/state/stores/instinct-store.ts +0 -275
  206. package/src/state/stores/observation-store.ts +0 -176
  207. package/src/state/tiered-eval.ts +0 -480
  208. package/src/state/types-eval.ts +0 -58
  209. package/src/tools/safe-bash-extension.ts +0 -54
  210. package/src/tools/safe-bash.ts +0 -505
  211. package/src/ui/agent-management-overlay.ts +0 -160
  212. package/src/ui/crew-footer.ts +0 -102
  213. package/src/ui/crew-select-list.ts +0 -114
  214. package/src/ui/dashboard-panes/capability-pane.ts +0 -77
  215. package/src/ui/transcript-entries.ts +0 -256
  216. package/src/utils/conflict-detect.ts +0 -721
  217. package/src/utils/fingerprint.ts +0 -180
  218. package/src/utils/gh-protocol.ts +0 -556
  219. package/src/utils/project-detector.ts +0 -160
  220. package/src/utils/sse-parser.ts +0 -131
  221. package/src/workflows/cost-estimator.ts +0 -34
  222. package/src/workflows/intermediate-store.ts +0 -166
@@ -0,0 +1,151 @@
1
+ /**
2
+ * T2/R4 (ADR-4 §4) — re-plan sweep: dropped-item soft-cancel.
3
+ *
4
+ * Every scheduler tick (selectDispatchBatch, after the ask-deadline sweep)
5
+ * checks the CURRENT plan revision for `dropped` items and reconciles their
6
+ * tasks:
7
+ * - QUEUED tasks are cancelled outright (never dispatched — the re-plan
8
+ * removed their reason to exist).
9
+ * - RUNNING / WAITING / NEEDS_ATTENTION tasks get a SOFT cancel: a wrap-up
10
+ * advisory appended to the worker's steering file (the same JSONL contract
11
+ * as `team steer` and the ChildPiSteeringController soft limit — advisory
12
+ * first, the existing maxTurns+grace machinery stays the hard enforcement).
13
+ * `task.replanDroppedAt` stamps the advisory as issued (exactly-once across
14
+ * ticks) and doubles as the terminal marker "cancelled-by-replan".
15
+ * - A `plan.item.dropped` event is appended per affected task.
16
+ *
17
+ * Reload+persist discipline mirrors sweepExpiredWaitingTasks: best-effort
18
+ * reload, all mutations under withRunLockSync with a fresh in-lock reload,
19
+ * save only when something changed. Never throws into the scheduler tick —
20
+ * failures are logged and the sweep returns undefined (next tick retries).
21
+ */
22
+ import * as fs from "node:fs";
23
+ import * as path from "node:path";
24
+ import { withRunLockSync } from "../state/coordination/locks.ts";
25
+ import { appendEvent } from "../state/event-log/event-log.ts";
26
+ import { getCurrentPlanRecord } from "../state/stores/plan-store.ts";
27
+ import { loadRunManifestById, saveRunManifest, saveRunTasks } from "../state/stores/state-store.ts";
28
+ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
29
+ import { logInternalError } from "../utils/internal-error.ts";
30
+ import { resolveRealContainedPath } from "../utils/safe-paths.ts";
31
+
32
+ export interface DroppedPlanSweepResult {
33
+ manifest: TeamRunManifest;
34
+ tasks: TeamTaskState[];
35
+ cancelledTaskIds: string[];
36
+ advisedTaskIds: string[];
37
+ }
38
+
39
+ const WRAP_UP_ADVISORY =
40
+ "Plan item dropped by a newer plan revision (re-plan). Wrap up immediately — provide your best final answer now; this task will not be continued.";
41
+
42
+ function appendSteeringAdvisory(manifest: TeamRunManifest, taskId: string): boolean {
43
+ try {
44
+ const steeringDir = path.join(manifest.artifactsRoot, "steering");
45
+ fs.mkdirSync(steeringDir, { recursive: true });
46
+ const safePath = resolveRealContainedPath(steeringDir, `${taskId}.jsonl`);
47
+ // Same 256KB growth cap as the steer tool (subagent-tools.ts) — refuse
48
+ // loudly rather than poisoning the next incarnation's first poll.
49
+ try {
50
+ if (fs.statSync(safePath).size > 256 * 1024) {
51
+ logInternalError("plan-replan.steer-refused-too-large", new Error("steering file over cap"), `taskId=${taskId}`);
52
+ return false;
53
+ }
54
+ } catch {
55
+ /* no file yet — fine */
56
+ }
57
+ fs.appendFileSync(
58
+ safePath,
59
+ `${JSON.stringify({ type: "steer", message: WRAP_UP_ADVISORY, ts: new Date().toISOString() })}\n`,
60
+ "utf-8",
61
+ );
62
+ return true;
63
+ } catch (error) {
64
+ logInternalError("plan-replan.steer-write-failed", error instanceof Error ? error : new Error(String(error)), `taskId=${taskId}`);
65
+ return false;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Takes the scheduler's IN-MEMORY manifest+tasks (selectDispatchBatch already
71
+ * holds both) — NO disk load on the no-op path. The per-tick cost for runs
72
+ * without plan-linked tasks is one array scan; plans.json is only read when a
73
+ * task carries planItem, and the reload+persist below only runs when an
74
+ * affected task actually exists.
75
+ */
76
+ export function sweepDroppedPlanItems(initialManifest: TeamRunManifest, initialTasks: TeamTaskState[]): DroppedPlanSweepResult | undefined {
77
+ // Cheap exits FIRST (no I/O): no plan-linked tasks at all.
78
+ if (!initialTasks.some((t) => t.planItem)) return undefined;
79
+ const record = getCurrentPlanRecord(initialManifest);
80
+ if (!record) return undefined;
81
+ const dropped = new Set(record.items.filter((i) => i.status === "dropped").map((i) => i.id));
82
+ if (dropped.size === 0) return undefined;
83
+ if (!initialTasks.some((t) => t.planItem && dropped.has(t.planItem))) return undefined;
84
+
85
+ const cwd = initialManifest.cwd;
86
+ const runId = initialManifest.runId;
87
+ const cancelledTaskIds: string[] = [];
88
+ const advisedTaskIds: string[] = [];
89
+ try {
90
+ const outcome = withRunLockSync(initialManifest, () => {
91
+ const fresh = loadRunManifestById(cwd, runId); // in-lock consistent read
92
+ if (!fresh) return null;
93
+ const freshRecord = getCurrentPlanRecord(fresh.manifest);
94
+ if (!freshRecord) return null;
95
+ const freshDropped = new Set(freshRecord.items.filter((i) => i.status === "dropped").map((i) => i.id));
96
+ if (freshDropped.size === 0) return null;
97
+ let tasks = fresh.tasks;
98
+ let changed = false;
99
+ for (const task of fresh.tasks) {
100
+ if (!task.planItem || !freshDropped.has(task.planItem)) continue;
101
+ if (task.status === "queued") {
102
+ // Never dispatched — cancel outright.
103
+ tasks = tasks.map((t) =>
104
+ t.id === task.id
105
+ ? {
106
+ ...t,
107
+ status: "cancelled" as const,
108
+ finishedAt: new Date().toISOString(),
109
+ error: "Plan item dropped by re-plan.",
110
+ }
111
+ : t,
112
+ );
113
+ cancelledTaskIds.push(task.id);
114
+ appendEvent(fresh.manifest.eventsPath, {
115
+ type: "plan.item.dropped",
116
+ runId,
117
+ taskId: task.id,
118
+ message: `Task ${task.id} cancelled: plan item '${task.planItem}' dropped by re-plan v${freshRecord.version}.`,
119
+ data: { itemId: task.planItem, planId: freshRecord.id, planVersion: freshRecord.version },
120
+ });
121
+ changed = true;
122
+ } else if (
123
+ (task.status === "running" || task.status === "waiting" || task.status === "needs_attention") &&
124
+ !task.replanDroppedAt
125
+ ) {
126
+ // In flight — advisory once; maxTurns+grace stays the hard stop.
127
+ if (!appendSteeringAdvisory(fresh.manifest, task.id)) continue;
128
+ tasks = tasks.map((t) => (t.id === task.id ? { ...t, replanDroppedAt: new Date().toISOString() } : t));
129
+ advisedTaskIds.push(task.id);
130
+ appendEvent(fresh.manifest.eventsPath, {
131
+ type: "plan.item.dropped",
132
+ runId,
133
+ taskId: task.id,
134
+ message: `Wrap-up advisory delivered to ${task.id}: plan item '${task.planItem}' dropped by re-plan v${freshRecord.version}.`,
135
+ data: { itemId: task.planItem, planId: freshRecord.id, planVersion: freshRecord.version, softCancel: true },
136
+ });
137
+ changed = true;
138
+ }
139
+ }
140
+ if (!changed) return { manifest: fresh.manifest, tasks: fresh.tasks, cancelledTaskIds, advisedTaskIds };
141
+ saveRunTasks(fresh.manifest, tasks);
142
+ const manifest = { ...fresh.manifest, updatedAt: new Date().toISOString() };
143
+ saveRunManifest(manifest);
144
+ return { manifest, tasks, cancelledTaskIds, advisedTaskIds };
145
+ });
146
+ return outcome ?? undefined;
147
+ } catch (error) {
148
+ logInternalError("plan-replan.sweep-failed", error instanceof Error ? error : new Error(String(error)), `runId=${runId}`);
149
+ return undefined;
150
+ }
151
+ }
@@ -166,13 +166,6 @@ export function getCheckpointStore(stateRoot: string): CheckpointStore {
166
166
  return _stores.get(stateRoot)!;
167
167
  }
168
168
 
169
- /**
170
- * Clear all checkpoint stores (for testing).
171
- */
172
- export function clearCheckpointStores(): void {
173
- _stores.clear();
174
- }
175
-
176
169
  /**
177
170
  * Save a checkpoint during agent execution.
178
171
  */
@@ -219,17 +212,6 @@ export function loadCheckpoint(runId: string, taskId: string, cwd?: string): Che
219
212
  return store.load(runId, taskId);
220
213
  }
221
214
 
222
- /**
223
- * Delete a checkpoint after successful completion.
224
- */
225
- export function clearCheckpoint(runId: string, taskId: string, cwd?: string): void {
226
- assertSafePathId("runId", runId);
227
- assertSafePathId("taskId", taskId);
228
- const stateRoot = path.join(projectCrewRoot(cwd ?? process.cwd()), "state", "runs", runId);
229
- const store = getCheckpointStore(stateRoot);
230
- store.delete(runId, taskId);
231
- }
232
-
233
215
  /**
234
216
  * Check if a checkpoint exists for a task.
235
217
  */
@@ -6,7 +6,7 @@ import type { MetricRegistry } from "../../observability/metric-registry.ts";
6
6
  import { withRunLockSync } from "../../state/coordination/locks.ts";
7
7
  import { appendEvent, scanSequence } from "../../state/event-log/event-log.ts";
8
8
  import { readActiveRunRegistry, unregisterActiveRun } from "../../state/stores/active-run-registry.ts";
9
- import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../../state/stores/state-store.ts";
9
+ import { loadRunManifestById, saveRunManifest, saveRunTasks, updateRunStatus } from "../../state/stores/state-store.ts";
10
10
  import type { TeamTaskState } from "../../state/types.ts";
11
11
  import { logInternalError } from "../../utils/internal-error.ts";
12
12
  import { projectCrewRoot, userCrewRoot } from "../../utils/paths.ts";
@@ -17,7 +17,7 @@ import { isWorkerHeartbeatStale } from "../heartbeat/worker-heartbeat.ts";
17
17
  import { terminateLiveAgentsForRun } from "../live-session/live-agent-manager.ts";
18
18
  import type { ManifestCache } from "../manifest-cache.ts";
19
19
  import { checkProcessLiveness } from "../process-status.ts";
20
- import { isPlanApprovalPending, type ReconcileResult, reconcileStaleRun } from "../stale-reconciler.ts";
20
+ import { isIntentionalWait, isPlanApprovalPendingEffective, type ReconcileResult, reconcileStaleRun } from "../stale-reconciler.ts";
21
21
 
22
22
  export interface RecoveryPlan {
23
23
  runId: string;
@@ -114,7 +114,10 @@ export function detectInterruptedRuns(
114
114
  for (const manifest of manifestCache.list(50)) {
115
115
  if (manifest.status !== "running" && manifest.status !== "blocked") continue;
116
116
  // Preserve runs intentionally blocked on plan approval — not crashes.
117
- if (isPlanApprovalPending(manifest)) continue;
117
+ // B1 battery 2026-08-18 (case b): generalized to isIntentionalWait — a run
118
+ // parked on an ask answer is equally intentional and must not be auto-resumed
119
+ // out from under its parked worker.
120
+ if (isIntentionalWait(manifest)) continue;
118
121
  if (manifest.async?.pid !== undefined && checkProcessLiveness(manifest.async.pid).alive) continue;
119
122
  // Skip runs owned by the current live session — a live session B must NOT
120
123
  // detect session A's still-running run as interrupted.
@@ -142,54 +145,87 @@ export async function applyRecoveryPlan(plan: RecoveryPlan, ctx: Pick<ExtensionC
142
145
  runId: plan.runId,
143
146
  cwd: ctx.cwd,
144
147
  });
145
- appendHookEvent(loaded.manifest, hookReport);
146
- if (hookReport.outcome === "block") {
147
- appendEvent(loaded.manifest.eventsPath, {
148
- type: "crew.run.recovery_blocked",
148
+
149
+ // R14-4: the hook await above is an arbitrary async gap — a concurrent writer
150
+ // may have completed/cancelled the run in the meantime. Re-read inside the
151
+ // lock and derive every write from the FRESH snapshot; never reset a run that
152
+ // reached a terminal status.
153
+ withRunLockSync(loaded.manifest, () => {
154
+ const fresh = loadRunManifestById(ctx.cwd, plan.runId); // NOTE: inside withRunLockSync - consistent read
155
+ if (!fresh) throw new Error(`Run '${plan.runId}' not found.`);
156
+ if (fresh.manifest.status === "completed" || fresh.manifest.status === "failed" || fresh.manifest.status === "cancelled") {
157
+ // Run reached a terminal status while the recovery hook was running —
158
+ // do NOT reset it (no task reset, no status change).
159
+ appendEvent(fresh.manifest.eventsPath, {
160
+ type: "crew.run.recovery_skipped",
161
+ runId: plan.runId,
162
+ message: `Recovery skipped: run is already '${fresh.manifest.status}'`,
163
+ data: { status: fresh.manifest.status },
164
+ });
165
+ return;
166
+ }
167
+ appendHookEvent(fresh.manifest, hookReport);
168
+ if (hookReport.outcome === "block") {
169
+ appendEvent(fresh.manifest.eventsPath, {
170
+ type: "crew.run.recovery_blocked",
171
+ runId: plan.runId,
172
+ message: `Recovery blocked by hook: ${hookReport.reason ?? "run_recovery hook blocked the operation."}`,
173
+ data: { hookOutcome: "block", reason: hookReport.reason },
174
+ });
175
+ return;
176
+ }
177
+
178
+ const reset = new Set(plan.resumableTasks);
179
+ const tasks = fresh.tasks.map((task) =>
180
+ reset.has(task.id)
181
+ ? {
182
+ ...task,
183
+ status: "queued" as const,
184
+ startedAt: undefined,
185
+ finishedAt: undefined,
186
+ error: undefined,
187
+ heartbeat: undefined,
188
+ // WP-2/R2 (ADR-0 item 9): a requeued task must not resume parked
189
+ // state — drop any stale ask-park marker along with the other
190
+ // per-attempt transient fields. Tasks NOT reset (e.g. a task parked
191
+ // in `waiting`) pass through untouched, marker intact; the marker's
192
+ // deadline is then owned by the scheduler tick (dispatch-batch).
193
+ waiting: undefined,
194
+ }
195
+ : task,
196
+ );
197
+ saveRunTasks(fresh.manifest, tasks);
198
+ appendEvent(fresh.manifest.eventsPath, {
199
+ type: "crew.run.resumed",
149
200
  runId: plan.runId,
150
- message: `Recovery blocked by hook: ${hookReport.reason ?? "run_recovery hook blocked the operation."}`,
151
- data: { hookOutcome: "block", reason: hookReport.reason },
201
+ message: `Recovered ${plan.resumableTasks.length} interrupted task(s).`,
202
+ data: {
203
+ recoveredFromSeq: plan.lastEventSeq,
204
+ resumableTasks: plan.resumableTasks,
205
+ },
152
206
  });
153
- return;
154
- }
155
-
156
- const reset = new Set(plan.resumableTasks);
157
- const tasks = loaded.tasks.map((task) =>
158
- reset.has(task.id)
159
- ? {
160
- ...task,
161
- status: "queued" as const,
162
- startedAt: undefined,
163
- finishedAt: undefined,
164
- error: undefined,
165
- heartbeat: undefined,
166
- }
167
- : task,
168
- );
169
- saveRunTasks(loaded.manifest, tasks);
170
- appendEvent(loaded.manifest.eventsPath, {
171
- type: "crew.run.resumed",
172
- runId: plan.runId,
173
- message: `Recovered ${plan.resumableTasks.length} interrupted task(s).`,
174
- data: {
175
- recoveredFromSeq: plan.lastEventSeq,
176
- resumableTasks: plan.resumableTasks,
177
- },
207
+ registry?.counter("crew.run.count", "Total runs by status").inc({ status: "resumed" });
178
208
  });
179
- registry?.counter("crew.run.count", "Total runs by status").inc({ status: "resumed" });
180
209
  }
181
210
 
182
211
  export function declineRecoveryPlan(plan: RecoveryPlan, ctx: Pick<ExtensionContext, "cwd">): void {
183
212
  const loaded = loadRunManifestById(ctx.cwd, plan.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
184
213
  if (!loaded) throw new Error(`Run '${plan.runId}' not found.`);
185
- // Log the event first — if appendEvent fails, state remains consistent.
186
- appendEvent(loaded.manifest.eventsPath, {
187
- type: "crew.run.recovery_declined",
188
- runId: plan.runId,
189
- message: "Interrupted run was not resumed.",
190
- data: { recoveredFromSeq: plan.lastEventSeq },
214
+ // R14-4: re-read inside the lock so the decline decision applies to the
215
+ // on-disk state, not the best-effort snapshot read above. Kept SYNC —
216
+ // withRunLockSync is the sync lock variant.
217
+ withRunLockSync(loaded.manifest, () => {
218
+ const fresh = loadRunManifestById(ctx.cwd, plan.runId); // NOTE: inside withRunLockSync - consistent read
219
+ if (!fresh) return;
220
+ // Log the event first — if appendEvent fails, state remains consistent.
221
+ appendEvent(fresh.manifest.eventsPath, {
222
+ type: "crew.run.recovery_declined",
223
+ runId: plan.runId,
224
+ message: "Interrupted run was not resumed.",
225
+ data: { recoveredFromSeq: plan.lastEventSeq },
226
+ });
227
+ updateRunStatus(fresh.manifest, "cancelled", "interrupted-not-resumed");
191
228
  });
192
- updateRunStatus(loaded.manifest, "cancelled", "interrupted-not-resumed");
193
229
  }
194
230
 
195
231
  /**
@@ -224,9 +260,14 @@ export function cancelOrphanedRuns(
224
260
  // Phase 1: Scan project-level manifests via manifestCache
225
261
  for (const manifest of manifestCache.list(50)) {
226
262
  if (manifest.status !== "running" && manifest.status !== "blocked") continue;
227
- // Preserve plan-approval-blocked runs — they belong to their owner and are
228
- // waiting on a human decision, not orphaned by a dead owner process.
229
- if (isPlanApprovalPending(manifest)) {
263
+ // B1 battery 2026-08-18 (case b): preserve runs intentionally blocked on a
264
+ // HUMAN DECISION — plan approval (pre-v2 semantics) OR a pending ask answer
265
+ // within the waiting TTL (ADR-0 item 9: "not crashes and must not be
266
+ // stale-repaired or cancelled by reconciliation"). Live proof: a resumed
267
+ // run parked on ask (worker alive, waitState set, parked workers do NOT
268
+ // heartbeat) was orphan-cancelled 5m18s into a 10-minute park by a THIRD
269
+ // session's startup scan because the predicate only knew plan approval.
270
+ if (isIntentionalWait(manifest)) {
230
271
  skipped.push(manifest.runId);
231
272
  continue;
232
273
  }
@@ -293,6 +334,14 @@ export function cancelOrphanedRuns(
293
334
  });
294
335
 
295
336
  saveRunTasks(fresh.manifest, repairedTasks);
337
+ // B1 battery 2026-08-18 (case b leak fix): a cancelled run must not keep
338
+ // its waitState pointer — observed live: orphan-cancel left waitState set,
339
+ // shielding the dead run from staleness repair for the full 24h TTL.
340
+ if (fresh.manifest.waitState) {
341
+ const cleared = { ...fresh.manifest, waitState: undefined, updatedAt: new Date(now).toISOString() };
342
+ saveRunManifest(cleared);
343
+ fresh.manifest = cleared;
344
+ }
296
345
  for (const task of repairedTasks) {
297
346
  try {
298
347
  upsertCrewAgent(fresh.manifest, recordFromTask(fresh.manifest, task, "scaffold"));
@@ -655,7 +704,7 @@ export function reconcileAllStaleRuns(
655
704
  // Belt-and-suspenders: reconcileStaleRun itself guards this, but the run
656
705
  // may have flipped to blocked+plan-approval between cache-list and lock
657
706
  // acquisition — re-check the freshest manifest under the lock.
658
- if (isPlanApprovalPending(fresh.manifest)) {
707
+ if (isPlanApprovalPendingEffective(fresh.manifest)) {
659
708
  results.push({
660
709
  runId,
661
710
  verdict: "blocked_awaiting_approval",
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Scheduler context types for the team-run scheduler loop.
3
+ *
4
+ * Extracted from team-runner.ts (2026-08, Phase 2.6 maintainability split —
5
+ * CORE-4 scheduler context, resolves RT-15). Pure type motion: SettledUnit /
6
+ * PendingUnit / SchedulerContext / SchedulerDecision moved verbatim.
7
+ */
8
+
9
+ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
10
+ import type { WorkflowConfig } from "../workflows/workflow-config.ts";
11
+ import type { CrewRuntimeKind } from "./crew-agent-runtime.ts";
12
+ import type { BatchConcurrencyDecision } from "./scheduling/concurrency.ts";
13
+ import type { TaskGraphIndex, TaskGraphSchedulerSnapshot } from "./scheduling/task-graph-scheduler.ts";
14
+ import type { ResultArtifactReadCache } from "./task-output-context.ts";
15
+ // ExecuteTeamRunInput is defined in team-runner.ts — import type ONLY so the
16
+ // type-level team-runner↔scheduler-context reference is erased at runtime
17
+ // (no runtime import cycle; team-runner.ts imports these types back).
18
+ import type { ExecuteTeamRunInput } from "./team-runner.ts";
19
+ import type { WorkflowStateMachine } from "./workflow-state.ts";
20
+
21
+ /** RT-12: result shape from a settled dispatch unit (pre-created wrapper). */
22
+ export type SettledUnit = {
23
+ unitKey: string;
24
+ result: { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined;
25
+ error: Error | undefined;
26
+ };
27
+
28
+ /**
29
+ * RT-12: in-flight dispatch unit. `wrapped` is a pre-created wrapper promise
30
+ * (try/catch → SettledUnit) so mergeUnitResult can Promise.race without
31
+ * allocating new async closures every loop iteration (O(C) total wrappers
32
+ * instead of O(C×T) churn).
33
+ */
34
+ export type PendingUnit = {
35
+ taskIds: string[];
36
+ promise: Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }>;
37
+ wrapped: Promise<SettledUnit>;
38
+ };
39
+
40
+ // ── CORE-4: SchedulerContext state bag ─────────────────────────────
41
+ // A mutable bag of the closure locals used across executeTeamRunCore.
42
+ // Extracted scheduler functions receive this context and mutate it
43
+ // in-place. This enables incremental extraction of the ~1075-line god
44
+ // function into scheduler/ functions without changing control flow.
45
+
46
+ /**
47
+ * Mutable state shared across the team-run scheduler loop.
48
+ *
49
+ * Fields mirror the closure locals of `executeTeamRunCore`. Extracted
50
+ * scheduler functions mutate these fields in-place; the caller keeps the
51
+ * local variables in sync by assigning back from `ctx` after each call.
52
+ */
53
+ export interface SchedulerContext {
54
+ input: ExecuteTeamRunInput;
55
+ workflow: WorkflowConfig;
56
+ manifest: TeamRunManifest;
57
+ tasks: TeamTaskState[];
58
+ queueIndex: TaskGraphIndex;
59
+ wfMachine: WorkflowStateMachine;
60
+ pendingUnits: Map<string, PendingUnit>;
61
+ /** Task ids ever dispatched (grows monotonically; never removed). Used by
62
+ * terminaliseRunWithDrain to cancel — not skip — tasks that were in-flight
63
+ * even after their dispatch unit settled + left pendingUnits (RT-NEW-2 race). */
64
+ dispatchedTaskIds: Set<string>;
65
+ runController: AbortController;
66
+ runtimeKind: CrewRuntimeKind;
67
+ adaptivePlanInjected: boolean;
68
+ adaptivePlanMissing: boolean;
69
+ /** Outcome of the most recent mergeUnitResult call: the settled unit's
70
+ * taskIds and the merged result object. Read by the post-merge inline
71
+ * logic (cancel-during-exec check + batch summary). Set by extraction 5. */
72
+ settledMerge: { taskIds: string[]; result: { manifest: TeamRunManifest; tasks: TeamTaskState[] } } | null;
73
+ /** R10-1: per-run result-artifact read cache (single instance, created in
74
+ * executeTeamRunCore). Shared by closeout aggregation and — via baseInput
75
+ * in dispatch-batch.ts — dependency-context reads in prepareTaskExecutionContext. */
76
+ resultReadCache: ResultArtifactReadCache;
77
+ }
78
+
79
+ /**
80
+ * Discriminated union representing a scheduler sub-function's decision.
81
+ *
82
+ * - `continue`: proceed to the next phase of the loop body.
83
+ * - `return`: short-circuit the loop and return the given result.
84
+ * - `skip-dispatch`: skip the dispatch phase this iteration (reserved for
85
+ * future extractions).
86
+ */
87
+ export type SchedulerDecision =
88
+ | { kind: "continue" }
89
+ | { kind: "return"; result: { manifest: TeamRunManifest; tasks: TeamTaskState[] } }
90
+ | { kind: "skip-dispatch" }
91
+ | {
92
+ kind: "dispatch";
93
+ batch: TeamTaskState[];
94
+ concurrency: BatchConcurrencyDecision;
95
+ snapshot: TaskGraphSchedulerSnapshot;
96
+ approvalPending: boolean;
97
+ coalesceEnabled: boolean;
98
+ };
@@ -91,6 +91,11 @@ export function planCoalescedGroups(
91
91
  // doesn't compose in a multi-task worker. These flags set up side
92
92
  // effects per-task that can't be combined.
93
93
  if (step.worktree) continue;
94
+ // T4/R6 (round-1 P2): spec-carrying steps never coalesce — the coalesced
95
+ // path bypasses buildTaskPacket/finalizeTaskResult, which would silently
96
+ // drop the snapshot freeze, the SPEC contract prompt block, and the gate.
97
+ if (step.specRefs?.length) continue;
98
+ if (step.specStrict === true) continue;
94
99
  if (step.preStepScript) continue;
95
100
  if (step.verify) continue;
96
101
  if (step.output !== undefined && step.output !== false && step.output !== "") continue;
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import * as os from "node:os";
25
+ import { getCrewEnv } from "../../config/env-vars.ts";
25
26
  import { Semaphore } from "./semaphore.ts";
26
27
 
27
28
  /**
@@ -30,7 +31,7 @@ import { Semaphore } from "./semaphore.ts";
30
31
  * rather than silently disabling the cap.
31
32
  */
32
33
  function resolveCapacity(): number {
33
- const env = process.env.PI_CREW_MAX_WORKERS;
34
+ const env = getCrewEnv("PI_CREW_MAX_WORKERS");
34
35
  if (env !== undefined && env !== "") {
35
36
  const parsed = Number.parseInt(env, 10);
36
37
  if (Number.isFinite(parsed) && parsed > 0) {
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Nested-slot budget for delegate-spawned grandchildren (ADR-5 §2, WP-5 step 3).
3
+ *
4
+ * Grandchildren NEVER ride the global worker semaphore — a waiting parent
5
+ * worker keeps holding its own global slot, so ride-along spawn is the
6
+ * permanent-deadlock shape the repo already records for the judge
7
+ * (`scheduling/global-worker-cap.ts:14-19` MAJ#3). Instead the root-side
8
+ * delegate handler draws from THIS separate budget:
9
+ *
10
+ * max = max(1, floor(globalSem / 2)) (or an explicit config override)
11
+ *
12
+ * FAIL-FAST, NEVER QUEUE: when the budget is exhausted, `tryAcquire` returns
13
+ * false immediately — the caller rejects the delegate with a policy message
14
+ * ("nested spawn budget exhausted; N/M in flight"). No waiting, no queueing.
15
+ *
16
+ * The budget is owned by the single root-side delegate handler process, so an
17
+ * in-memory id-keyed map is the source of truth (no disk state).
18
+ */
19
+
20
+ export interface NestedSlotSnapshot {
21
+ used: number;
22
+ max: number;
23
+ }
24
+
25
+ /** Compute the nested-slot budget from the global worker semaphore. */
26
+ export function defaultNestedSlotBudget(globalWorkerSemaphore: number): number {
27
+ return Math.max(1, Math.floor(globalWorkerSemaphore / 2));
28
+ }
29
+
30
+ export class NestedSlotBudget {
31
+ private readonly held = new Set<string>();
32
+ readonly max: number;
33
+
34
+ constructor(globalWorkerSemaphore: number, override?: number) {
35
+ if (override !== undefined && (!Number.isFinite(override) || override < 1)) {
36
+ throw new TypeError(`nesting.maxSlots must be a positive integer, got ${String(override)}`);
37
+ }
38
+ this.max = override ?? defaultNestedSlotBudget(globalWorkerSemaphore);
39
+ }
40
+
41
+ /**
42
+ * Try to reserve one nested slot for the given grandchild id.
43
+ * Fail-fast: returns false when the budget is exhausted — the caller MUST
44
+ * reject the delegate immediately (never queue).
45
+ */
46
+ tryAcquire(grandchildId: string): boolean {
47
+ if (this.held.has(grandchildId)) return true; // idempotent re-acquire
48
+ if (this.held.size >= this.max) return false;
49
+ this.held.add(grandchildId);
50
+ return true;
51
+ }
52
+
53
+ /** Release the slot held by a finished/dead grandchild. Idempotent. */
54
+ release(grandchildId: string): void {
55
+ this.held.delete(grandchildId);
56
+ }
57
+
58
+ get used(): number {
59
+ return this.held.size;
60
+ }
61
+
62
+ /** Human-facing status line for policy messages: "N/M in flight". */
63
+ get statusLine(): string {
64
+ return `${this.held.size}/${this.max} in flight`;
65
+ }
66
+
67
+ snapshot(): NestedSlotSnapshot {
68
+ return { used: this.held.size, max: this.max };
69
+ }
70
+ }