pi-crew 0.9.68 → 0.10.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 (253) hide show
  1. package/CHANGELOG.md +222 -0
  2. package/NOTICE.md +21 -0
  3. package/README.md +44 -2
  4. package/agents/analyst.md +1 -1
  5. package/agents/cold-verifier.md +3 -1
  6. package/agents/critic.md +1 -1
  7. package/agents/executor.md +1 -1
  8. package/agents/explorer.md +1 -1
  9. package/agents/planner.md +1 -1
  10. package/agents/reviewer.md +1 -1
  11. package/agents/security-reviewer.md +1 -1
  12. package/agents/test-engineer.md +1 -1
  13. package/agents/verifier.md +1 -1
  14. package/agents/writer.md +1 -1
  15. package/dist/index.mjs +68113 -60774
  16. package/docs/README.md +2 -0
  17. package/docs/actions-reference.md +31 -0
  18. package/docs/commands-reference.md +17 -6
  19. package/docs/resource-formats.md +13 -0
  20. package/package.json +4 -2
  21. package/schema.json +503 -91
  22. package/scripts/resource-sampler.mjs +36 -2
  23. package/skills/requirements-to-task-packet/SKILL.md +26 -0
  24. package/skills/widget-rendering/SKILL.md +7 -7
  25. package/src/agents/agent-config.ts +2 -1
  26. package/src/agents/discover-agents.ts +23 -14
  27. package/src/config/config-merge.ts +183 -0
  28. package/src/config/config-validation.ts +687 -0
  29. package/src/config/config.ts +22 -864
  30. package/src/config/defaults.ts +43 -2
  31. package/src/config/drift-detector.ts +1 -1
  32. package/src/config/env-vars.ts +691 -0
  33. package/src/config/role-tools.ts +11 -9
  34. package/src/config/sanitize-project-config.ts +172 -0
  35. package/src/config/types.ts +49 -1
  36. package/src/extension/async-notifier.ts +25 -2
  37. package/src/extension/crew-cleanup.ts +13 -0
  38. package/src/extension/crew-vibes/config.ts +2 -1
  39. package/src/extension/crew-vibes/footer.ts +19 -0
  40. package/src/extension/crew-vibes/index.ts +11 -1
  41. package/src/extension/plan-orchestrate.ts +132 -0
  42. package/src/extension/register.ts +8 -0
  43. package/src/extension/registration/command-registration.ts +1 -0
  44. package/src/extension/registration/commands/dashboard.ts +158 -0
  45. package/src/extension/registration/commands/index.ts +35 -0
  46. package/src/extension/registration/commands/manage.ts +303 -0
  47. package/src/extension/registration/commands/run.ts +228 -0
  48. package/src/extension/registration/commands/shared.ts +639 -0
  49. package/src/extension/registration/commands/status.ts +60 -0
  50. package/src/extension/registration/commands.ts +13 -1224
  51. package/src/extension/registration/foreground-run-controller.ts +10 -2
  52. package/src/extension/registration/lifecycle-handlers.ts +178 -17
  53. package/src/extension/registration/runtime-cleanup.ts +23 -5
  54. package/src/extension/registration/subagent-tools.ts +218 -9
  55. package/src/extension/registration/team-tool.ts +5 -1
  56. package/src/extension/registration/ui.ts +5 -4
  57. package/src/extension/rpc-hmac.ts +5 -3
  58. package/src/extension/team-tool/api/heartbeat.ts +47 -10
  59. package/src/extension/team-tool/api/plan-approval.ts +9 -0
  60. package/src/extension/team-tool/api/task-claims.ts +109 -40
  61. package/src/extension/team-tool/cancel.ts +84 -50
  62. package/src/extension/team-tool/dispatch/index.ts +1 -0
  63. package/src/extension/team-tool/dispatch/run.ts +4 -1
  64. package/src/extension/team-tool/doctor.ts +103 -1
  65. package/src/extension/team-tool/orchestrate.ts +66 -1
  66. package/src/extension/team-tool/plans.ts +192 -0
  67. package/src/extension/team-tool/respond.ts +197 -65
  68. package/src/extension/team-tool/run-deadline.ts +35 -3
  69. package/src/extension/team-tool/run-intent.ts +63 -0
  70. package/src/extension/team-tool/run.ts +74 -20
  71. package/src/extension/team-tool/status.ts +84 -26
  72. package/src/extension/team-tool.ts +11 -2
  73. package/src/hooks/registry.ts +1 -6
  74. package/src/i18n.ts +9 -0
  75. package/src/prompt/prompt-runtime.ts +521 -2
  76. package/src/prompt/worker-events-channel.ts +173 -0
  77. package/src/runtime/README.md +8 -8
  78. package/src/runtime/async-runner.ts +7 -3
  79. package/src/runtime/background-runner.ts +42 -14
  80. package/src/runtime/broker/broker-issuer.ts +9 -2
  81. package/src/runtime/broker/crew-broker-tokens.ts +43 -6
  82. package/src/runtime/broker/crew-broker.ts +838 -10
  83. package/src/runtime/broker/wait-status-cache.ts +157 -0
  84. package/src/runtime/budget-enforcement.ts +281 -0
  85. package/src/runtime/child-pi/child-pi-constants.ts +8 -0
  86. package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
  87. package/src/runtime/child-pi/child-pi-streams.ts +21 -1
  88. package/src/runtime/child-pi/child-pi-timers.ts +324 -0
  89. package/src/runtime/child-pi/child-pi.ts +97 -201
  90. package/src/runtime/child-pi/mock-fixtures.ts +16 -2
  91. package/src/runtime/crew-agent-records.ts +259 -14
  92. package/src/runtime/delegate-spawn.ts +148 -0
  93. package/src/runtime/detached-run-results.ts +90 -0
  94. package/src/runtime/deterministic-ast.ts +2 -1
  95. package/src/runtime/dispatch-batch.ts +945 -0
  96. package/src/runtime/finalize-run.ts +557 -0
  97. package/src/runtime/goal-workflow/adaptive-plan.ts +116 -15
  98. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +8 -3
  99. package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
  100. package/src/runtime/group-join.ts +11 -125
  101. package/src/runtime/live-session/live-session-runtime.ts +26 -1
  102. package/src/runtime/merge-gate.ts +32 -10
  103. package/src/runtime/merge-loop.ts +130 -0
  104. package/src/runtime/model/model-budget-summary.ts +53 -0
  105. package/src/runtime/model/model-fallback.ts +36 -2
  106. package/src/runtime/model/pi-args.ts +10 -0
  107. package/src/runtime/model/provider-extensions.ts +10 -0
  108. package/src/runtime/orphan-worker-registry.ts +1 -1
  109. package/src/runtime/output/output-validator.ts +45 -0
  110. package/src/runtime/parent-guard.ts +3 -1
  111. package/src/runtime/peer-dep.ts +2 -1
  112. package/src/runtime/per-write-validator.ts +0 -5
  113. package/src/runtime/pi-spawn.ts +61 -15
  114. package/src/runtime/plan-approval.ts +125 -0
  115. package/src/runtime/plan-replan.ts +151 -0
  116. package/src/runtime/process-status.ts +16 -1
  117. package/src/runtime/recovery/checkpoint.ts +0 -18
  118. package/src/runtime/recovery/crash-recovery.ts +111 -46
  119. package/src/runtime/run-tracker.ts +77 -10
  120. package/src/runtime/scheduler-context.ts +98 -0
  121. package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
  122. package/src/runtime/scheduling/global-worker-cap.ts +2 -1
  123. package/src/runtime/scheduling/nested-slots.ts +70 -0
  124. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
  125. package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
  126. package/src/runtime/settings-store.ts +219 -0
  127. package/src/runtime/spawn-policy.ts +217 -0
  128. package/src/runtime/stale-reconciler.ts +87 -6
  129. package/src/runtime/subagent-manager.ts +25 -1
  130. package/src/runtime/task-output-context.ts +230 -9
  131. package/src/runtime/task-packet.ts +23 -1
  132. package/src/runtime/task-runner/child-executor.ts +106 -7
  133. package/src/runtime/task-runner/post-execution.ts +125 -1
  134. package/src/runtime/task-runner/pre-execution.ts +39 -1
  135. package/src/runtime/task-runner/prompt-builder.ts +51 -1
  136. package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
  137. package/src/runtime/task-runner/spec-evidence.ts +403 -0
  138. package/src/runtime/task-runner/state-helpers.ts +26 -24
  139. package/src/runtime/task-runner.ts +11 -0
  140. package/src/runtime/team-runner.ts +132 -1673
  141. package/src/runtime/verification/spec-sandbox.ts +255 -0
  142. package/src/runtime/verification/verification-gates.ts +3 -2
  143. package/src/runtime/verification/verification-worktree.ts +2 -1
  144. package/src/runtime/workflow-phase-advance.ts +100 -0
  145. package/src/runtime/workspace-tree.ts +9 -0
  146. package/src/schema/config-schema.ts +66 -26
  147. package/src/schema/sensitive-config-paths.ts +64 -0
  148. package/src/schema/team-tool-schema.ts +13 -3
  149. package/src/state/README.md +4 -10
  150. package/src/state/atomic-write.ts +20 -3
  151. package/src/state/contracts.ts +38 -0
  152. package/src/state/coordination/mailbox.ts +12 -2
  153. package/src/state/event-log/cursor.ts +223 -0
  154. package/src/state/event-log/event-log-rotation.ts +12 -4
  155. package/src/state/event-log/event-log.ts +152 -369
  156. package/src/state/event-log/sequence-cache.ts +373 -0
  157. package/src/state/event-log/worker-atomic-writer.ts +2 -1
  158. package/src/state/stores/active-run-registry.ts +3 -2
  159. package/src/state/stores/manifest-io.ts +237 -0
  160. package/src/state/stores/ownership-map.ts +162 -0
  161. package/src/state/stores/plan-store.ts +241 -0
  162. package/src/state/stores/run-cache.ts +0 -90
  163. package/src/state/stores/spec-store.ts +189 -0
  164. package/src/state/stores/state-store.ts +139 -232
  165. package/src/state/types.ts +199 -0
  166. package/src/ui/dashboard-panes/plan-pane.ts +136 -0
  167. package/src/ui/dashboard-panes/progress-pane.ts +6 -0
  168. package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
  169. package/src/ui/dock-footer.ts +49 -0
  170. package/src/ui/heartbeat-aggregator.ts +9 -1
  171. package/src/ui/inline-panel/agent-pane.ts +375 -0
  172. package/src/ui/inline-panel/agent-transcript.ts +338 -0
  173. package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
  174. package/src/ui/inline-panel/crew-editor.ts +192 -0
  175. package/src/ui/inline-panel/index.ts +290 -0
  176. package/src/ui/inline-panel/panel-rows.ts +37 -0
  177. package/src/ui/inline-panel/panel-selection.ts +157 -0
  178. package/src/ui/inline-panel/panel-store.ts +111 -0
  179. package/src/ui/inline-panel/view-session-store.ts +36 -0
  180. package/src/ui/keybinding-map.ts +54 -13
  181. package/src/ui/pi-ui-compat.ts +9 -0
  182. package/src/ui/powerbar-publisher.ts +52 -1
  183. package/src/ui/run-dashboard.ts +31 -5
  184. package/src/ui/run-snapshot-cache.ts +57 -30
  185. package/src/ui/snapshot-types.ts +6 -1
  186. package/src/ui/widget/index.ts +176 -22
  187. package/src/ui/widget/task-list.ts +198 -0
  188. package/src/ui/widget/widget-formatters.ts +240 -4
  189. package/src/ui/widget/widget-renderer.ts +243 -38
  190. package/src/ui/widget/widget-types.ts +11 -0
  191. package/src/utils/child-process-shield.ts +106 -0
  192. package/src/utils/file-coalescer.ts +0 -4
  193. package/src/utils/fs-errno.ts +66 -0
  194. package/src/utils/fs-watch.ts +1 -1
  195. package/src/utils/internal-error.ts +3 -1
  196. package/src/utils/paths.ts +11 -3
  197. package/src/utils/redaction.ts +7 -0
  198. package/src/utils/safe-abort.ts +45 -0
  199. package/src/utils/task-name-generator.ts +1 -8
  200. package/src/workflows/discover-workflows.ts +20 -2
  201. package/src/workflows/validate-workflow.ts +7 -1
  202. package/src/workflows/workflow-config.ts +17 -0
  203. package/src/workflows/workflow-serializer.ts +3 -0
  204. package/src/worktree/worktree-manager.ts +22 -0
  205. package/workflows/default.workflow.md +36 -26
  206. package/workflows/strict-fast-fix.workflow.md +26 -0
  207. package/src/agents/agent-search.ts +0 -98
  208. package/src/benchmark/benchmark-runner.ts +0 -313
  209. package/src/benchmark/feedback-loop.ts +0 -73
  210. package/src/config/resilient-parser.ts +0 -117
  211. package/src/extension/crew-vibes/cat-frames.ts +0 -18
  212. package/src/extension/result-watcher.ts +0 -139
  213. package/src/observability/exporters/prometheus-exporter.ts +0 -54
  214. package/src/observability/metric-retention.ts +0 -64
  215. package/src/runtime/compaction/compaction-summary.ts +0 -278
  216. package/src/runtime/errors/crew-errors.ts +0 -162
  217. package/src/runtime/live-session/intercom-bridge.ts +0 -187
  218. package/src/runtime/loop-gates.ts +0 -128
  219. package/src/runtime/metric-parser.ts +0 -36
  220. package/src/runtime/output/stream-preview.ts +0 -184
  221. package/src/runtime/output/tool-progress.ts +0 -278
  222. package/src/runtime/phase-tracker.ts +0 -385
  223. package/src/runtime/pipeline-runner.ts +0 -523
  224. package/src/runtime/process/process-lifecycle.ts +0 -491
  225. package/src/runtime/recovery/retry-runner.ts +0 -330
  226. package/src/runtime/run-drift.ts +0 -219
  227. package/src/runtime/task-quality.ts +0 -199
  228. package/src/runtime/task-runner/run-projection.ts +0 -128
  229. package/src/runtime/verification/post-checks.ts +0 -142
  230. package/src/state/coordination/schedule.ts +0 -166
  231. package/src/state/event-log/jsonl-writer.ts +0 -115
  232. package/src/state/hook-instinct-bridge.ts +0 -94
  233. package/src/state/hook-integrations.ts +0 -51
  234. package/src/state/session-state-map.ts +0 -51
  235. package/src/state/stores/blob-store.ts +0 -308
  236. package/src/state/stores/instinct-store.ts +0 -275
  237. package/src/state/stores/observation-store.ts +0 -176
  238. package/src/state/tiered-eval.ts +0 -480
  239. package/src/state/types-eval.ts +0 -58
  240. package/src/tools/safe-bash-extension.ts +0 -54
  241. package/src/tools/safe-bash.ts +0 -505
  242. package/src/ui/agent-management-overlay.ts +0 -160
  243. package/src/ui/crew-footer.ts +0 -102
  244. package/src/ui/crew-select-list.ts +0 -114
  245. package/src/ui/dashboard-panes/capability-pane.ts +0 -77
  246. package/src/ui/transcript-entries.ts +0 -256
  247. package/src/utils/conflict-detect.ts +0 -721
  248. package/src/utils/fingerprint.ts +0 -180
  249. package/src/utils/gh-protocol.ts +0 -556
  250. package/src/utils/project-detector.ts +0 -160
  251. package/src/utils/sse-parser.ts +0 -131
  252. package/src/workflows/cost-estimator.ts +0 -34
  253. package/src/workflows/intermediate-store.ts +0 -166
@@ -21,8 +21,10 @@
21
21
  * full contract and acceptance criteria.
22
22
  */
23
23
 
24
+ import { randomUUID } from "node:crypto";
24
25
  import * as fsp from "node:fs/promises";
25
26
  import * as net from "node:net";
27
+ import { withRunLockSync } from "../../state/coordination/locks.ts";
26
28
  import {
27
29
  appendMailboxMessageAsync,
28
30
  type MailboxMessage,
@@ -31,15 +33,21 @@ import {
31
33
  readMailbox,
32
34
  registerMailboxAppendObserver,
33
35
  } from "../../state/coordination/mailbox.ts";
34
- import { readEventsCursor } from "../../state/event-log/event-log.ts";
35
- import { loadRunManifestById } from "../../state/stores/state-store.ts";
36
+ import { appendEventAsync, readEventsCursor } from "../../state/event-log/event-log.ts";
37
+ import { loadRunManifestById, saveRunManifest, saveRunTasks } from "../../state/stores/state-store.ts";
38
+ import type { TeamTaskState } from "../../state/types.ts";
36
39
  import { runEventBus } from "../../ui/run-event-bus.ts";
37
40
  import { logInternalError } from "../../utils/internal-error.ts";
38
41
  import { BrokerError, encodeBrokerFrame, MAX_BROKER_FRAME_BYTES, NdjsonDecoder } from "../../utils/ndjson.ts";
39
42
  import { redactSecretString } from "../../utils/redaction.ts";
40
43
  import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
41
44
  import { getBrokerSocketPath, prepareBrokerSocketDir, removeStaleBrokerSocket } from "../../utils/socket-path.ts";
45
+ import { type GrandchildSpawnInput, type GrandchildSpawnResult, spawnDelegateGrandchild } from "../delegate-spawn.ts";
46
+ import { resolveCrewMaxDepth } from "../model/pi-args.ts";
47
+ import { NestedSlotBudget } from "../scheduling/nested-slots.ts";
48
+ import { evaluateDelegateAdmission } from "../spawn-policy.ts";
42
49
  import { BrokerTokenRegistry } from "./crew-broker-tokens.ts";
50
+ import { WaitStatusCache } from "./wait-status-cache.ts";
43
51
 
44
52
  /** Protocol version negotiated at `hello` time. Bump on breaking change. */
45
53
  const BROKER_PROTOCOL = 1;
@@ -69,6 +77,38 @@ export interface CrewBrokerOptions {
69
77
  cwd?: string;
70
78
  /** Optional test seam: override the `net` module (allows fake-server tests). */
71
79
  netModule?: typeof net;
80
+ /** Optional test seam: inject a pre-configured WaitStatusCache (e.g. one
81
+ * wrapping a loader spy). Production uses a plain cache — see
82
+ * wait-status-cache.ts (R10-3). */
83
+ waitStatusCache?: WaitStatusCache;
84
+ /** WP-2/R2 (ADR-0 2026-08-17-waiting-producer-ask item 7): capability
85
+ * gate for the `wait.*` methods. DEFAULT FALSE — fail-closed. When not
86
+ * explicitly true, wait.request/wait.resolve are rejected with a
87
+ * `policy-disabled` error AND a `policy.action` event is appended to the
88
+ * run's events.jsonl (never silent). The production wiring threads
89
+ * `config.broker.waitMethodsEnabled` here; tests pass it explicitly. */
90
+ waitMethodsEnabled?: boolean;
91
+ /** T3/R5 (ADR-5 §10): capability gate for the `delegate` surface. DEFAULT
92
+ * FALSE — fail-closed until the WP-5 completion gate flips it. The
93
+ * production wiring threads `config.nesting.enabled` here; tests pass it
94
+ * explicitly. Rejections are NEVER silent (delegate.rejected event). */
95
+ nestingEnabled?: boolean;
96
+ /** Optional override for the nested-slot budget size (config nesting.maxSlots). */
97
+ nestingMaxSlots?: number;
98
+ nestingMaxDepth?: number;
99
+ nestingTrustedEscalation?: boolean;
100
+ /** Global worker semaphore size, used to size the nested-slot budget. */
101
+ globalWorkerSemaphore?: number;
102
+ /** Test seam / alternative spawner for delegate grandchildren. Production
103
+ * uses spawnDelegateGrandchild (direct runChildPi call-site, ADR-5 §2). */
104
+ grandchildSpawner?: (input: GrandchildSpawnInput) => Promise<GrandchildSpawnResult>;
105
+ /** Resolved model catalog (canonical provider/id strings) for admission-time
106
+ * model validation (ADR-5 §7). When omitted, model validation is skipped
107
+ * (documented gap — the production wiring must always supply it). */
108
+ modelCatalog?: () => string[] | undefined;
109
+ /** ADR-5 §9: mirrors config limits.serializeOnPathOverlap for the workspace
110
+ * admission gate. Default false. */
111
+ serializeOnPathOverlap?: boolean;
72
112
  }
73
113
 
74
114
  /** Per-connection server-side state. */
@@ -83,6 +123,11 @@ interface ServerConnection {
83
123
  taskId?: string;
84
124
  /** Role bound by hello: orchestrator can steer/msg-send; workers default. */
85
125
  role?: "orchestrator" | "worker";
126
+ /** How the hello token matched the registry (ADR-0 item 6). Derived,
127
+ * non-secret metadata recorded at hello time so `wait.*` can reject a
128
+ * legacy bare-runId fallback match WITHOUT keeping the raw token on the
129
+ * connection (tokens stay confined to the heap-only registry). */
130
+ authMatchKind?: "compound" | "runId-fallback";
86
131
  /** Outbound queue of encoded frames awaiting drain. */
87
132
  outbound: Buffer[];
88
133
  /** Set when the queue has hit the cap and a frame was dropped. */
@@ -96,8 +141,23 @@ interface ServerConnection {
96
141
  }
97
142
 
98
143
  export class CrewBroker {
99
- private readonly options: Required<Pick<CrewBrokerOptions, "sessionId" | "enabled">> &
100
- Pick<CrewBrokerOptions, "socketPath" | "maxFrameBytes" | "outboundQueueCap" | "cwd" | "netModule">;
144
+ private readonly options: Required<Pick<CrewBrokerOptions, "sessionId" | "enabled" | "waitMethodsEnabled" | "nestingEnabled">> &
145
+ Pick<
146
+ CrewBrokerOptions,
147
+ | "socketPath"
148
+ | "maxFrameBytes"
149
+ | "outboundQueueCap"
150
+ | "cwd"
151
+ | "netModule"
152
+ | "waitStatusCache"
153
+ | "nestingMaxSlots"
154
+ | "nestingMaxDepth"
155
+ | "nestingTrustedEscalation"
156
+ | "globalWorkerSemaphore"
157
+ | "grandchildSpawner"
158
+ | "modelCatalog"
159
+ | "serializeOnPathOverlap"
160
+ >;
101
161
  private readonly tokens = new BrokerTokenRegistry();
102
162
  private server: net.Server | null = null;
103
163
  private resolvedSocketPath: string | null = null;
@@ -110,8 +170,14 @@ export class CrewBroker {
110
170
  private readonly subscriptionUnsubs = new WeakMap<ServerConnection, Set<() => void>>();
111
171
  /** Unsubscribe handle for the mailbox append observer (set on start, cleared on stop). */
112
172
  private mailboxObserverUnsub: (() => void) | null = null;
173
+ /** T3/R5 (ADR-5 §2): nested-slot budget for delegate grandchildren — lazily
174
+ * sized from options (max(1, floor(globalWorkerSemaphore/2)) or override). */
175
+ private nestedSlots: NestedSlotBudget | undefined;
113
176
  /** A single observable handshake counter (test/observability). */
114
177
  private handshakeCount = 0;
178
+ /** R10-3: stat-gated manifest/tasks cache shared by all task.waitStatus
179
+ * waiters on this broker (keyed by runId — the broker serves one cwd). */
180
+ private readonly waitStatusCache: WaitStatusCache;
115
181
 
116
182
  constructor(options: CrewBrokerOptions) {
117
183
  if (!options || typeof options !== "object") {
@@ -128,7 +194,17 @@ export class CrewBroker {
128
194
  outboundQueueCap: options.outboundQueueCap,
129
195
  cwd: options.cwd,
130
196
  netModule: options.netModule,
197
+ waitMethodsEnabled: options.waitMethodsEnabled === true,
198
+ nestingEnabled: options.nestingEnabled === true,
199
+ nestingMaxSlots: options.nestingMaxSlots,
200
+ nestingMaxDepth: options.nestingMaxDepth,
201
+ nestingTrustedEscalation: options.nestingTrustedEscalation === true,
202
+ globalWorkerSemaphore: options.globalWorkerSemaphore,
203
+ grandchildSpawner: options.grandchildSpawner,
204
+ modelCatalog: options.modelCatalog,
205
+ serializeOnPathOverlap: options.serializeOnPathOverlap === true,
131
206
  };
207
+ this.waitStatusCache = options.waitStatusCache ?? new WaitStatusCache();
132
208
  }
133
209
 
134
210
  /** Read the resolved socket path. Available after start() resolves. */
@@ -362,6 +438,13 @@ export class CrewBroker {
362
438
  // ------------------------------------------------------------------------
363
439
 
364
440
  private async handleConnection(sock: net.Socket): Promise<void> {
441
+ // B1 (Round 14): a connection event queued after stop() must not be
442
+ // processed — the broker is shutting down and the token registry is
443
+ // already cleared. Destroy (not end) since the server is stopping.
444
+ if (this.stopped) {
445
+ sock.destroy();
446
+ return;
447
+ }
365
448
  const conn: ServerConnection = {
366
449
  socket: sock,
367
450
  decoder: new NdjsonDecoder(),
@@ -369,6 +452,7 @@ export class CrewBroker {
369
452
  runId: undefined,
370
453
  taskId: undefined,
371
454
  role: undefined,
455
+ authMatchKind: undefined,
372
456
  outbound: [],
373
457
  needsResync: false,
374
458
  closed: false,
@@ -425,7 +509,12 @@ export class CrewBroker {
425
509
  const set = this.connectionsByRun.get(conn.runId);
426
510
  if (set) {
427
511
  set.delete(conn);
428
- if (set.size === 0) this.connectionsByRun.delete(conn.runId);
512
+ if (set.size === 0) {
513
+ this.connectionsByRun.delete(conn.runId);
514
+ // R10-3: no more waiters for this run — drop its stat-gated
515
+ // manifest cache entry so the Map stays bounded across runs.
516
+ this.waitStatusCache.delete(conn.runId);
517
+ }
429
518
  }
430
519
  }
431
520
  // Phase 2: tear down any per-connection event subscriptions.
@@ -544,6 +633,21 @@ export class CrewBroker {
544
633
  case "escalate":
545
634
  await this.handleEscalate(conn, id, params);
546
635
  return;
636
+ // WP-2/R2 (ADR-0 2026-08-17-waiting-producer-ask items 3,6,7):
637
+ // waiting-producer park/resolve. Task-scoped tokens only;
638
+ // capability-gated via options.waitMethodsEnabled (fail-closed).
639
+ case "wait.request":
640
+ await this.handleWaitRequest(conn, id, params);
641
+ return;
642
+ case "wait.resolve":
643
+ await this.handleWaitResolve(conn, id, params);
644
+ return;
645
+ // T3/R5 (ADR-5 §1): governed-nesting delegation. Task-scoped tokens
646
+ // only; capability-gated via options.nestingEnabled (fail-closed);
647
+ // admission runs the full spawn-policy gate matrix.
648
+ case "delegate.request":
649
+ await this.handleDelegateRequest(conn, id, params);
650
+ return;
547
651
  default:
548
652
  // Unhandled method → typed not-implemented.
549
653
  this.sendError(conn, id, "not-implemented", `method '${method}' is not implemented`);
@@ -571,8 +675,11 @@ export class CrewBroker {
571
675
  // vs worker), never from a self-declared hello field (F-06: otherwise a
572
676
  // worker could forge role:'orchestrator' and call steer.push/msg.send).
573
677
  // Constant-time compare; never include the token in the error path.
574
- const role = this.tokens.tokenRole(runId, taskId, token);
575
- if (role === null) {
678
+ // WP-2/R2 (ADR-0 item 6): the match KIND is recorded on the connection
679
+ // (compound vs legacy bare-runId fallback) so wait.* can enforce the
680
+ // task-scoped-token rule without retaining the secret candidate.
681
+ const resolved = this.tokens.tokenRoleWithMatchKind(runId, taskId, token);
682
+ if (resolved === null) {
576
683
  this.sendErrorAndClose(conn, id, "auth", "hello rejected");
577
684
  return;
578
685
  }
@@ -591,7 +698,8 @@ export class CrewBroker {
591
698
  conn.authed = true;
592
699
  conn.runId = runId;
593
700
  conn.taskId = taskId;
594
- conn.role = role;
701
+ conn.role = resolved.role;
702
+ conn.authMatchKind = resolved.matchKind;
595
703
  // Phase 1.3: index by runId for live mailbox fanout.
596
704
  let connsForRun = this.connectionsByRun.get(runId);
597
705
  if (!connsForRun) {
@@ -980,7 +1088,7 @@ export class CrewBroker {
980
1088
  this.sendError(conn, id, "bad-params", "task.waitStatus: taskId out of range");
981
1089
  return;
982
1090
  }
983
- const validStatuses = new Set(["queued", "running", "completed", "failed", "blocked", "cancelled"]);
1091
+ const validStatuses = new Set(["queued", "running", "waiting", "completed", "failed", "blocked", "cancelled"]);
984
1092
  if (!validStatuses.has(targetStatus)) {
985
1093
  this.sendError(conn, id, "bad-params", `task.waitStatus: invalid until '${targetStatus}'`);
986
1094
  return;
@@ -1012,7 +1120,9 @@ export class CrewBroker {
1012
1120
  return;
1013
1121
  }
1014
1122
  try {
1015
- const loaded = loadRunManifestById(cwd, connRunId);
1123
+ // R10-3: stat-gated cache — parse only when manifest/tasks
1124
+ // mtime/size changed; identical observable behavior per poll.
1125
+ const loaded = this.waitStatusCache.load(cwd, connRunId);
1016
1126
  if (!loaded) {
1017
1127
  this.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
1018
1128
  resolve();
@@ -1184,6 +1294,660 @@ export class CrewBroker {
1184
1294
  this.sendError(conn, id, "escalate-failed", (err as Error).message);
1185
1295
  }
1186
1296
  }
1297
+
1298
+ // ------------------------------------------------------------------------
1299
+ // WP-2/R2: wait.request / wait.resolve (ADR-0 2026-08-17-waiting-producer-ask)
1300
+ // ------------------------------------------------------------------------
1301
+
1302
+ /** Shared auth for wait.*: worker role + task-scoped (compound-key) token
1303
+ * ONLY (ADR item 6). A legacy bare-runId fallback match is REJECTED with
1304
+ * a migrate hint; the orchestrator token is rejected by role. Returns the
1305
+ * error to send, or null when auth passes. */
1306
+ private waitAuthError(conn: ServerConnection): { code: string; message: string } | null {
1307
+ if (conn.role !== "worker" || conn.authMatchKind === undefined) {
1308
+ return { code: "forbidden", message: "wait.* requires a worker task-scoped token" };
1309
+ }
1310
+ if (conn.authMatchKind !== "compound") {
1311
+ return {
1312
+ code: "forbidden",
1313
+ message: "wait.* requires a task-scoped token; re-dispatch with PI_CREW_BROKER_TASK_ID",
1314
+ };
1315
+ }
1316
+ return null;
1317
+ }
1318
+
1319
+ /** ADR item 7: a disabled-gate rejection MUST leave a durable trace in
1320
+ * events.jsonl — the gate fails CLOSED but never SILENTLY. Fire-and-forget
1321
+ * async append (broker handlers must not block the event loop on the sync
1322
+ * event-log lock); an append failure is logged, never thrown. */
1323
+ // T3/R5 (ADR-5): delegate.request — governed-nesting admission + background
1324
+ // grandchild spawn with durable mailbox delivery (WP-5 step 5).
1325
+ private getDelegateNestedSlots(): NestedSlotBudget {
1326
+ if (!this.nestedSlots) {
1327
+ this.nestedSlots = new NestedSlotBudget(this.options.globalWorkerSemaphore ?? 4, this.options.nestingMaxSlots);
1328
+ }
1329
+ return this.nestedSlots;
1330
+ }
1331
+
1332
+ private recordDelegateEvent(
1333
+ manifest: { eventsPath: string; runId: string },
1334
+ type:
1335
+ | "delegate.requested"
1336
+ | "delegate.admitted"
1337
+ | "delegate.rejected"
1338
+ | "delegate.completed"
1339
+ | "delegate.timed_out"
1340
+ | "delegate.rolled_up",
1341
+ taskId: string,
1342
+ data: Record<string, unknown>,
1343
+ ): void {
1344
+ void appendEventAsync(manifest.eventsPath, {
1345
+ type,
1346
+ runId: manifest.runId,
1347
+ taskId,
1348
+ message: `${type}: ${JSON.stringify(data).slice(0, 200)}`,
1349
+ data,
1350
+ }).catch((err) =>
1351
+ logInternalError("crew-broker.delegate.event", err instanceof Error ? err : new Error(String(err)), `runId=${manifest.runId}`),
1352
+ );
1353
+ }
1354
+
1355
+ private async handleDelegateRequest(conn: ServerConnection, id: string, params: unknown): Promise<void> {
1356
+ // Auth: task-scoped token MANDATORY (ADR-5 pin iii) — same rule as wait.*.
1357
+ if (!conn.runId || !conn.taskId) {
1358
+ this.sendError(conn, id, "auth", "not authed");
1359
+ return;
1360
+ }
1361
+ if (conn.role !== "worker" || conn.authMatchKind !== "compound") {
1362
+ this.sendError(conn, id, "forbidden", "delegate requires a task-scoped token; re-dispatch with PI_CREW_BROKER_TASK_ID");
1363
+ return;
1364
+ }
1365
+ const p = (params ?? {}) as {
1366
+ description?: unknown;
1367
+ prompt?: unknown;
1368
+ role?: unknown;
1369
+ model?: unknown;
1370
+ maxTurns?: unknown;
1371
+ budgetTokens?: unknown;
1372
+ timeoutSec?: unknown;
1373
+ };
1374
+ if (typeof p.prompt !== "string" || p.prompt.trim().length === 0) {
1375
+ this.sendError(conn, id, "bad-params", "delegate.request: 'prompt' (non-empty string) is required");
1376
+ return;
1377
+ }
1378
+ const requested = {
1379
+ prompt: p.prompt,
1380
+ ...(typeof p.description === "string" ? { description: p.description } : {}),
1381
+ ...(typeof p.role === "string" ? { role: p.role } : {}),
1382
+ ...(typeof p.model === "string" ? { model: p.model } : {}),
1383
+ ...(typeof p.maxTurns === "number" ? { maxTurns: p.maxTurns } : {}),
1384
+ ...(typeof p.budgetTokens === "number" ? { budgetTokens: p.budgetTokens } : {}),
1385
+ ...(typeof p.timeoutSec === "number" ? { timeoutSec: p.timeoutSec } : {}),
1386
+ };
1387
+ const cwd = this.options.cwd;
1388
+ if (!cwd) {
1389
+ this.sendError(conn, id, "no-manifest", "broker has no cwd configured");
1390
+ return;
1391
+ }
1392
+ let loaded: NonNullable<ReturnType<typeof loadRunManifestById>>;
1393
+ try {
1394
+ const l = loadRunManifestById(cwd, conn.runId);
1395
+ if (!l) {
1396
+ this.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
1397
+ return;
1398
+ }
1399
+ loaded = l;
1400
+ } catch (err) {
1401
+ this.sendError(conn, id, "no-manifest", (err as Error).message);
1402
+ return;
1403
+ }
1404
+ // Capability gate (ADR-5 §10): fail-closed, NEVER silent.
1405
+ if (this.options.nestingEnabled !== true) {
1406
+ this.recordDelegateEvent(loaded.manifest, "delegate.rejected", conn.taskId, {
1407
+ reason: "nesting-disabled",
1408
+ policy: "nesting.enabled=false (fail-closed default)",
1409
+ });
1410
+ this.sendError(
1411
+ conn,
1412
+ id,
1413
+ "policy-disabled",
1414
+ "delegate is disabled: nesting.enabled=false (fail-closed default; delegate.rejected recorded in events.jsonl)",
1415
+ );
1416
+ return;
1417
+ }
1418
+ const runId = conn.runId;
1419
+ const parentTaskId = conn.taskId;
1420
+ // S1#1 fix (security round 1): the subId is minted BEFORE admission so the
1421
+ // grandchild token scopes to the SUBID (never the parent task key) and the
1422
+ // nested slot is acquired INSIDE the same lock as the admission snapshot.
1423
+ const subId = `gc-${randomUUID()}`;
1424
+ this.recordDelegateEvent(loaded.manifest, "delegate.requested", parentTaskId, { subId, role: requested.role ?? "explorer" });
1425
+ // Admission: full spawn-policy matrix (ADR-5 §2-§7), parent state from
1426
+ // the RECORD under the run lock — never the worker's env/self-report.
1427
+ const admissionOutcome = withRunLockSync(loaded.manifest, () => {
1428
+ const fresh = loadRunManifestById(loaded.manifest.cwd, runId);
1429
+ if (!fresh) return { code: "no-manifest" as const, message: `run '${runId}' not found` };
1430
+ const task = fresh.tasks.find((t) => t.id === parentTaskId);
1431
+ if (!task) {
1432
+ this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, { subId, reason: "no-task" });
1433
+ return { code: "no-task" as const, message: `task '${parentTaskId}' not found` };
1434
+ }
1435
+ if (task.status !== "running") {
1436
+ this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, {
1437
+ subId,
1438
+ reason: "parent-not-running",
1439
+ message: `task is ${task.status}`,
1440
+ });
1441
+ return { code: "bad-params" as const, message: `delegate: parent task '${parentTaskId}' is ${task.status}, not running` };
1442
+ }
1443
+ const catalog = this.options.modelCatalog?.();
1444
+ // S3 fail-closed: a DEFINED loader yielding undefined is a loader failure.
1445
+ const effectiveCatalog = this.options.modelCatalog !== undefined ? (catalog ?? []) : undefined;
1446
+ // ADR-5 §9: count OTHER in-flight executor-class tasks sharing the
1447
+ // parent task's cwd (manifest record — single source of truth).
1448
+ const overlapping = fresh.tasks.filter(
1449
+ (t) =>
1450
+ t.id !== parentTaskId &&
1451
+ t.status === "running" &&
1452
+ (t.role === "executor" || t.role === "test-engineer") &&
1453
+ t.cwd === task.cwd,
1454
+ ).length;
1455
+ const decision = evaluateDelegateAdmission({
1456
+ nestingEnabled: true, // flag already checked above
1457
+ maxDepth: this.options.nestingMaxDepth ?? resolveCrewMaxDepth(undefined), // config knob > env-clamped 1..10, default 2 (ADR-5 §3)
1458
+ parentTask: {
1459
+ taskId: parentTaskId,
1460
+ role: task.role,
1461
+ ...(task.depth !== undefined ? { depth: task.depth } : {}),
1462
+ ...(task.allocation !== undefined ? { allocation: task.allocation } : {}),
1463
+ },
1464
+ slots: this.getDelegateNestedSlots().snapshot(),
1465
+ requested,
1466
+ ...(effectiveCatalog !== undefined ? { modelCatalog: effectiveCatalog } : {}),
1467
+ // ADR-5 §12: the delegate surface is an escalation — trusted only by the
1468
+ // explicit user opt-in threaded from config.nesting.enabled (sensitive).
1469
+ untrusted: this.options.nestingTrustedEscalation !== true,
1470
+ workspace: {
1471
+ serializeEnabled: this.options.serializeOnPathOverlap === true,
1472
+ overlappingInFlightExecutors: overlapping,
1473
+ },
1474
+ });
1475
+ if (!decision.allowed) {
1476
+ this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, {
1477
+ subId,
1478
+ reason: decision.reason,
1479
+ message: (decision.message ?? "").slice(0, 120),
1480
+ });
1481
+ return { code: "policy-denied" as const, message: decision.message ?? decision.reason ?? "delegate denied" };
1482
+ }
1483
+ // Slot acquisition INSIDE the lock (no reserve-then-race refund window).
1484
+ if (!this.getDelegateNestedSlots().tryAcquire(subId)) {
1485
+ this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, { subId, reason: "slots-exhausted" });
1486
+ return {
1487
+ code: "policy-denied" as const,
1488
+ message: `delegate rejected: nested spawn budget exhausted; ${this.getDelegateNestedSlots().statusLine}`,
1489
+ };
1490
+ }
1491
+ // Reserve the requested budget pessimistically (ADR-5 §5): tokensSpent
1492
+ // += budgetTokens now; the completion roll-up reconciles to actual usage
1493
+ // (refund the difference). Single writer under the run lock.
1494
+ let reserved = 0;
1495
+ const parentAllocation = task.allocation;
1496
+ let tasksAfterReserve = fresh.tasks;
1497
+ if (requested.budgetTokens !== undefined && parentAllocation) {
1498
+ reserved = requested.budgetTokens;
1499
+ const updatedTasks = fresh.tasks.map((t) =>
1500
+ t.id === parentTaskId
1501
+ ? {
1502
+ ...t,
1503
+ allocation: {
1504
+ tokensGranted: parentAllocation.tokensGranted,
1505
+ tokensSpent: (parentAllocation.tokensSpent ?? 0) + reserved,
1506
+ },
1507
+ }
1508
+ : t,
1509
+ );
1510
+ tasksAfterReserve = updatedTasks;
1511
+ saveRunTasks(fresh.manifest, updatedTasks);
1512
+ }
1513
+ // S1#1: register the grandchild SHADOW TASK — the subId identity gets a
1514
+ // real depth/role entry (unbounded-chain fix). Built on tasksAfterReserve
1515
+ // so the budget reservation above is never clobbered.
1516
+ saveRunTasks(fresh.manifest, [
1517
+ ...tasksAfterReserve,
1518
+ {
1519
+ id: subId,
1520
+ runId,
1521
+ role: requested.role ?? "explorer",
1522
+ agent: "delegate",
1523
+ title: `delegate: ${(requested as { description?: string }).description ?? subId}`,
1524
+ status: "queued",
1525
+ cwd: task.cwd,
1526
+ dependsOn: [],
1527
+ depth: decision.childDepth ?? 2,
1528
+ startedAt: new Date().toISOString(),
1529
+ } satisfies TeamTaskState,
1530
+ ]);
1531
+ return { code: "ok" as const, decision, reserved };
1532
+ });
1533
+ if (admissionOutcome.code !== "ok") {
1534
+ this.sendError(conn, id, admissionOutcome.code, admissionOutcome.message);
1535
+ return;
1536
+ }
1537
+ const { decision, reserved } = admissionOutcome;
1538
+ this.recordDelegateEvent(loaded.manifest, "delegate.admitted", parentTaskId, {
1539
+ subId,
1540
+ childDepth: decision.childDepth,
1541
+ role: requested.role ?? "explorer",
1542
+ ...(reserved > 0 ? { reservedTokens: reserved } : {}),
1543
+ });
1544
+ // S1#1: pre-mint the grandchild-scoped token when (and only when) the
1545
+ // child may itself delegate (childDepth < resolved maxDepth — the same
1546
+ // gate as issueForChild). Depth-2 at default maxDepth gets NO creds.
1547
+ const grandchildCreds =
1548
+ (decision.childDepth ?? 2) < (this.options.nestingMaxDepth ?? resolveCrewMaxDepth(undefined))
1549
+ ? { socketPath: this.socketPath, token: this.issueRunToken(runId, subId) }
1550
+ : undefined;
1551
+ // Return IMMEDIATELY (principle 7): the tool self-polls the mailbox.
1552
+ this.sendResult(conn, id, { ok: true, grandchildTaskRef: subId, childDepth: decision.childDepth, timeoutSec: decision.timeoutSec });
1553
+ // Background grandchild lifecycle (ADR-5 §1/§6).
1554
+ const spawner = this.options.grandchildSpawner ?? spawnDelegateGrandchild;
1555
+ void (async () => {
1556
+ let outcome: GrandchildSpawnResult;
1557
+ try {
1558
+ outcome = await spawner({
1559
+ cwd,
1560
+ runId,
1561
+ parentTaskId,
1562
+ subId,
1563
+ prompt: requested.prompt,
1564
+ eventsPath: loaded.manifest.eventsPath,
1565
+ role: requested.role ?? "explorer",
1566
+ ...(grandchildCreds ? { brokerSpawn: grandchildCreds } : {}),
1567
+ ...(requested.model !== undefined ? { model: requested.model } : {}),
1568
+ ...(requested.maxTurns !== undefined ? { maxTurns: requested.maxTurns } : {}),
1569
+ timeoutSec: decision.timeoutSec ?? 900,
1570
+ depthOverride: decision.childDepth ?? 2,
1571
+ });
1572
+ } catch (err) {
1573
+ outcome = { ok: false, resultText: `delegate spawn failed: ${(err as Error).message}` };
1574
+ }
1575
+ // S3 fence hardening: neutralize smuggled end-fence markers in the
1576
+ // grandchild's raw text, then wrap (the client re-fences too — the
1577
+ // mailbox is an unauthenticated same-uid channel by design).
1578
+ const sanitizedText = outcome.resultText.replace(/^--- (end )?delegate /gm, "-- ~delegate ").slice(0, 65_536);
1579
+ const fenced = `--- delegate ${subId} ${outcome.timedOut ? "(timed out)" : outcome.ok ? "(ok)" : "(failed)"} ---\n${sanitizedText}\n--- end delegate ${subId} ---`;
1580
+ // Durable delivery (ADR-5 §1): fenced result into the PARENT task's
1581
+ // mailbox + budget roll-up + slot release — run-locked RMW.
1582
+ try {
1583
+ const fresh = loadRunManifestById(cwd, runId);
1584
+ if (fresh) {
1585
+ withRunLockSync(fresh.manifest, () => {
1586
+ const latest = loadRunManifestById(cwd, runId);
1587
+ if (!latest) return;
1588
+ void appendMailboxMessageAsync(latest.manifest, {
1589
+ direction: "inbox",
1590
+ from: `delegate:${subId}`,
1591
+ to: parentTaskId,
1592
+ taskId: parentTaskId,
1593
+ body: fenced,
1594
+ kind: "response",
1595
+ data: { subId, ok: outcome.ok, timedOut: outcome.timedOut === true },
1596
+ }).catch((err) =>
1597
+ logInternalError(
1598
+ "crew-broker.delegate.mailbox",
1599
+ err instanceof Error ? err : new Error(String(err)),
1600
+ `runId=${runId}`,
1601
+ ),
1602
+ );
1603
+ // Roll-up (ADR-5 §5): reconcile the pessimistic reservation to
1604
+ // actual usage; refund the difference. Unattributed → keep reserve.
1605
+ let tasksToWrite = latest.tasks;
1606
+ if (reserved > 0) {
1607
+ const parent = latest.tasks.find((t) => t.id === parentTaskId);
1608
+ const parentAlloc = parent?.allocation;
1609
+ if (parentAlloc) {
1610
+ const actual = Math.max(0, Math.min(outcome.usageTokens ?? reserved, reserved));
1611
+ const tokensSpent = (parentAlloc.tokensSpent ?? 0) - reserved + actual;
1612
+ tasksToWrite = tasksToWrite.map((t) =>
1613
+ t.id === parentTaskId
1614
+ ? { ...t, allocation: { tokensGranted: parentAlloc.tokensGranted, tokensSpent } }
1615
+ : t,
1616
+ );
1617
+ this.recordDelegateEvent(latest.manifest, "delegate.rolled_up", parentTaskId, {
1618
+ subId,
1619
+ actualTokens: actual,
1620
+ });
1621
+ }
1622
+ }
1623
+ // S1#1: flip the shadow task to terminal — UNCONDITIONAL (verifier N1): the
1624
+ // flip must not depend on a reservation; no allocation producer ships
1625
+ // yet so reserved===0 is the common case and the guard left every
1626
+ // shadow "queued" forever.
1627
+ // S1#1: flip the shadow task to terminal so the subId record does not
1628
+ // linger as "queued" in team status views.
1629
+ saveRunTasks(
1630
+ latest.manifest,
1631
+ tasksToWrite.map((t) =>
1632
+ t.id === subId
1633
+ ? {
1634
+ ...t,
1635
+ status: outcome.ok ? ("completed" as const) : ("failed" as const),
1636
+ completedAt: new Date().toISOString(),
1637
+ }
1638
+ : t,
1639
+ ),
1640
+ );
1641
+ });
1642
+ }
1643
+ } catch (err) {
1644
+ logInternalError("crew-broker.delegate.finalize", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`);
1645
+ // P2-8c: best-effort refund when finalize fails — the reservation
1646
+ // must not linger inflated after a roll-up write failure.
1647
+ if (reserved > 0) {
1648
+ try {
1649
+ const rf = loadRunManifestById(cwd, runId);
1650
+ if (rf) {
1651
+ withRunLockSync(rf.manifest, () => {
1652
+ const rl = loadRunManifestById(cwd, runId);
1653
+ if (!rl) return;
1654
+ const par = rl.tasks.find((t) => t.id === parentTaskId);
1655
+ const pa = par?.allocation;
1656
+ if (pa) {
1657
+ saveRunTasks(
1658
+ rl.manifest,
1659
+ rl.tasks.map((t) =>
1660
+ t.id === parentTaskId
1661
+ ? {
1662
+ ...t,
1663
+ allocation: {
1664
+ tokensGranted: pa.tokensGranted,
1665
+ tokensSpent: Math.max(0, (pa.tokensSpent ?? 0) - reserved),
1666
+ },
1667
+ }
1668
+ : t,
1669
+ ),
1670
+ );
1671
+ }
1672
+ });
1673
+ }
1674
+ } catch {
1675
+ /* refund is best-effort; the startup reconcile is the follow-up */
1676
+ }
1677
+ }
1678
+ } finally {
1679
+ this.getDelegateNestedSlots().release(subId);
1680
+ }
1681
+ this.recordDelegateEvent(loaded.manifest, outcome.timedOut ? "delegate.timed_out" : "delegate.completed", parentTaskId, {
1682
+ subId,
1683
+ ok: outcome.ok,
1684
+ });
1685
+ })();
1686
+ }
1687
+
1688
+ private recordWaitPolicyRejection(manifest: { eventsPath: string; runId: string }, taskId: string, method: string): void {
1689
+ const runId = manifest.runId;
1690
+ void appendEventAsync(manifest.eventsPath, {
1691
+ type: "policy.action",
1692
+ runId,
1693
+ taskId,
1694
+ message: `${method} rejected: waitMethodsEnabled=false (fail-closed)`,
1695
+ data: { action: method, reason: "wait-methods-disabled", policy: "broker.waitMethodsEnabled=false" },
1696
+ }).catch((err) =>
1697
+ logInternalError("crew-broker.wait.policy-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
1698
+ );
1699
+ }
1700
+
1701
+ /** WP-2/R2 step 4: park the calling task while its `ask` tool awaits a
1702
+ * leader answer. Park = task.status "waiting" + task.waiting marker +
1703
+ * manifest.waitState pointer; manifest.status NEVER flips (stays
1704
+ * "running" — registry entry, sidebar visibility and
1705
+ * live-executor.isCurrent() all preserved; ADR item 3). Writes happen
1706
+ * under withRunLockSync with a fresh reload (respond.ts:42-43 discipline).
1707
+ * deadline = now + min(timeoutSec, 3600): the SERVER clamps —
1708
+ * worker-controlled timeoutSec may never exceed 1h (ADR P2-7). */
1709
+ private async handleWaitRequest(conn: ServerConnection, id: string, params: unknown): Promise<void> {
1710
+ if (!conn.runId || !conn.taskId) {
1711
+ this.sendError(conn, id, "auth", "not authed");
1712
+ return;
1713
+ }
1714
+ const authErr = this.waitAuthError(conn);
1715
+ if (authErr) {
1716
+ this.sendError(conn, id, authErr.code, authErr.message);
1717
+ return;
1718
+ }
1719
+ const parsed = parseWaitRequestParams(params);
1720
+ if (!parsed) {
1721
+ this.sendError(conn, id, "bad-params", "wait.request: invalid params");
1722
+ return;
1723
+ }
1724
+ // Server-side identity enforcement: `to` MUST equal the authenticated
1725
+ // task — a worker may only park ITSELF. (The escalate handler's
1726
+ // unvalidated `to` is the recorded anti-pattern; do NOT replicate.)
1727
+ if (parsed.to !== conn.taskId) {
1728
+ this.sendError(conn, id, "forbidden", "wait.request: 'to' must match the authenticated task");
1729
+ return;
1730
+ }
1731
+ const cwd = this.options.cwd;
1732
+ if (!cwd) {
1733
+ this.sendError(conn, id, "no-manifest", "broker has no cwd configured");
1734
+ return;
1735
+ }
1736
+ let loaded: NonNullable<ReturnType<typeof loadRunManifestById>>;
1737
+ try {
1738
+ const l = loadRunManifestById(cwd, conn.runId);
1739
+ if (!l) {
1740
+ this.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
1741
+ return;
1742
+ }
1743
+ loaded = l;
1744
+ } catch (err) {
1745
+ this.sendError(conn, id, "no-manifest", (err as Error).message);
1746
+ return;
1747
+ }
1748
+ // Capability gate (ADR item 7): fail-closed, NEVER silent — every
1749
+ // rejection leaves a policy.action trace in the run's events.jsonl.
1750
+ if (this.options.waitMethodsEnabled !== true) {
1751
+ this.recordWaitPolicyRejection(loaded.manifest, conn.taskId, "wait.request");
1752
+ this.sendError(
1753
+ conn,
1754
+ id,
1755
+ "policy-disabled",
1756
+ "wait.request is disabled: broker.waitMethodsEnabled=false (fail-closed; policy.action recorded in events.jsonl)",
1757
+ );
1758
+ return;
1759
+ }
1760
+ // Server clamp BEFORE any state write (ADR P2-7).
1761
+ const requestedSec = parsed.timeoutSec ?? WAIT_REQUEST_TIMEOUT_SEC_DEFAULT;
1762
+ const clampSec = Math.min(Math.max(1, Math.floor(requestedSec)), WAIT_REQUEST_TIMEOUT_SEC_MAX);
1763
+ const clamped = requestedSec > clampSec;
1764
+ const questionId = randomUUID();
1765
+ const askedAt = new Date().toISOString();
1766
+ const deadline = Date.now() + clampSec * 1000;
1767
+ const runId = conn.runId;
1768
+ const taskId = conn.taskId;
1769
+ const outcome = withRunLockSync(loaded.manifest, () => {
1770
+ // Fresh reload INSIDE the lock (respond.ts:42-43 discipline).
1771
+ const fresh = loadRunManifestById(loaded.manifest.cwd, runId);
1772
+ if (!fresh) return { code: "no-manifest" as const, message: `run '${runId}' not found` };
1773
+ const task = fresh.tasks.find((t) => t.id === taskId);
1774
+ if (!task) return { code: "no-task" as const, message: `task '${taskId}' not found` };
1775
+ if (task.status !== "running") {
1776
+ return { code: "bad-params" as const, message: `wait.request: task '${taskId}' is ${task.status}, not running` };
1777
+ }
1778
+ const updatedTasks = fresh.tasks.map((t) =>
1779
+ t.id === taskId
1780
+ ? {
1781
+ ...t,
1782
+ status: "waiting" as const,
1783
+ waiting: {
1784
+ questionId,
1785
+ askedAt,
1786
+ deadline,
1787
+ ...(parsed.options ? { options: parsed.options } : {}),
1788
+ },
1789
+ }
1790
+ : t,
1791
+ );
1792
+ const updatedManifest = {
1793
+ ...fresh.manifest,
1794
+ // manifest.status stays "running" — park NEVER flips run status (ADR item 3).
1795
+ waitState: { taskId, questionId, askedAt },
1796
+ updatedAt: askedAt,
1797
+ };
1798
+ saveRunTasks(updatedManifest, updatedTasks);
1799
+ saveRunManifest(updatedManifest);
1800
+ return { code: "ok" as const, message: "" };
1801
+ });
1802
+ if (outcome.code !== "ok") {
1803
+ this.sendError(conn, id, outcome.code, outcome.message);
1804
+ return;
1805
+ }
1806
+ // Events AFTER the run lock is released (the event-log lock is a
1807
+ // separate lock; no cross-lock ordering). task.waiting mirrors the
1808
+ // persisted status flip for event-log reconstruction (tasks.json
1809
+ // corruption recovery); ask.requested carries the question for the
1810
+ // leader/UI. Fire-and-forget: an event failure must not fail the park.
1811
+ const eventsPath = loaded.manifest.eventsPath;
1812
+ void appendEventAsync(eventsPath, {
1813
+ type: "task.waiting",
1814
+ runId,
1815
+ taskId,
1816
+ message: `Task parked awaiting leader answer (question ${questionId}, deadline in ${clampSec}s).`,
1817
+ data: { questionId, deadline },
1818
+ }).catch((err) =>
1819
+ logInternalError("crew-broker.wait.task-waiting-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
1820
+ );
1821
+ void appendEventAsync(eventsPath, {
1822
+ type: "ask.requested",
1823
+ runId,
1824
+ taskId,
1825
+ message: parsed.question,
1826
+ data: {
1827
+ questionId,
1828
+ deadline,
1829
+ timeoutSec: clampSec,
1830
+ clamped,
1831
+ ...(parsed.options ? { options: parsed.options } : {}),
1832
+ },
1833
+ }).catch((err) =>
1834
+ logInternalError("crew-broker.wait.ask-requested-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
1835
+ );
1836
+ this.sendResult(conn, id, {
1837
+ ok: true,
1838
+ questionId,
1839
+ askedAt,
1840
+ deadline,
1841
+ timeoutSec: clampSec,
1842
+ clamped,
1843
+ });
1844
+ }
1845
+
1846
+ /** WP-2/R2: terminal report of the parked `ask` tool — flips the task
1847
+ * waiting→running and clears the park coordination state. Scoped to
1848
+ * auth + state transition + ask.answered event ONLY (ADR item 6/8):
1849
+ * answer DELIVERY is the mailbox respond path (step 6), not this
1850
+ * method. A questionId mismatch (or a task that is not parked) is
1851
+ * rejected WITHOUT clearing anything — fail-closed. */
1852
+ private async handleWaitResolve(conn: ServerConnection, id: string, params: unknown): Promise<void> {
1853
+ if (!conn.runId || !conn.taskId) {
1854
+ this.sendError(conn, id, "auth", "not authed");
1855
+ return;
1856
+ }
1857
+ const authErr = this.waitAuthError(conn);
1858
+ if (authErr) {
1859
+ this.sendError(conn, id, authErr.code, authErr.message);
1860
+ return;
1861
+ }
1862
+ const parsed = parseWaitResolveParams(params);
1863
+ if (!parsed) {
1864
+ this.sendError(conn, id, "bad-params", "wait.resolve: invalid params");
1865
+ return;
1866
+ }
1867
+ // Server-side identity enforcement (same rule as wait.request).
1868
+ if (parsed.to !== conn.taskId) {
1869
+ this.sendError(conn, id, "forbidden", "wait.resolve: 'to' must match the authenticated task");
1870
+ return;
1871
+ }
1872
+ const cwd = this.options.cwd;
1873
+ if (!cwd) {
1874
+ this.sendError(conn, id, "no-manifest", "broker has no cwd configured");
1875
+ return;
1876
+ }
1877
+ let loaded: NonNullable<ReturnType<typeof loadRunManifestById>>;
1878
+ try {
1879
+ const l = loadRunManifestById(cwd, conn.runId);
1880
+ if (!l) {
1881
+ this.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
1882
+ return;
1883
+ }
1884
+ loaded = l;
1885
+ } catch (err) {
1886
+ this.sendError(conn, id, "no-manifest", (err as Error).message);
1887
+ return;
1888
+ }
1889
+ if (this.options.waitMethodsEnabled !== true) {
1890
+ this.recordWaitPolicyRejection(loaded.manifest, conn.taskId, "wait.resolve");
1891
+ this.sendError(
1892
+ conn,
1893
+ id,
1894
+ "policy-disabled",
1895
+ "wait.resolve is disabled: broker.waitMethodsEnabled=false (fail-closed; policy.action recorded in events.jsonl)",
1896
+ );
1897
+ return;
1898
+ }
1899
+ const runId = conn.runId;
1900
+ const taskId = conn.taskId;
1901
+ const outcome = withRunLockSync(loaded.manifest, () => {
1902
+ const fresh = loadRunManifestById(loaded.manifest.cwd, runId);
1903
+ if (!fresh) return { code: "no-manifest" as const, message: `run '${runId}' not found` };
1904
+ const task = fresh.tasks.find((t) => t.id === taskId);
1905
+ if (!task) return { code: "no-task" as const, message: `task '${taskId}' not found` };
1906
+ if (task.status !== "waiting" || task.waiting?.questionId !== parsed.questionId) {
1907
+ return {
1908
+ code: "bad-params" as const,
1909
+ message: `wait.resolve: no parked question '${parsed.questionId}' on task '${taskId}'`,
1910
+ };
1911
+ }
1912
+ const updatedTasks = fresh.tasks.map((t) => (t.id === taskId ? { ...t, status: "running" as const, waiting: undefined } : t));
1913
+ // waitState is a single run-level slot: clear it ONLY when it points
1914
+ // at this exact question — never clobber another task's newer park.
1915
+ const waitState = fresh.manifest.waitState;
1916
+ const clearWaitState = waitState !== undefined && waitState.taskId === taskId && waitState.questionId === parsed.questionId;
1917
+ const updatedManifest = {
1918
+ ...fresh.manifest,
1919
+ ...(clearWaitState ? { waitState: undefined } : {}),
1920
+ updatedAt: new Date().toISOString(),
1921
+ };
1922
+ saveRunTasks(updatedManifest, updatedTasks);
1923
+ saveRunManifest(updatedManifest);
1924
+ return { code: "ok" as const, message: "" };
1925
+ });
1926
+ if (outcome.code !== "ok") {
1927
+ this.sendError(conn, id, outcome.code, outcome.message);
1928
+ return;
1929
+ }
1930
+ const eventsPath = loaded.manifest.eventsPath;
1931
+ void appendEventAsync(eventsPath, {
1932
+ type: "ask.answered",
1933
+ runId,
1934
+ taskId,
1935
+ message: `Question ${parsed.questionId} answered; task resumed.`,
1936
+ data: { questionId: parsed.questionId },
1937
+ }).catch((err) =>
1938
+ logInternalError("crew-broker.wait.ask-answered-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
1939
+ );
1940
+ void appendEventAsync(eventsPath, {
1941
+ type: "task.resumed",
1942
+ runId,
1943
+ taskId,
1944
+ message: `Task resumed after ask answer (question ${parsed.questionId}).`,
1945
+ data: { questionId: parsed.questionId },
1946
+ }).catch((err) =>
1947
+ logInternalError("crew-broker.wait.task-resumed-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
1948
+ );
1949
+ this.sendResult(conn, id, { ok: true, taskId, questionId: parsed.questionId });
1950
+ }
1187
1951
  }
1188
1952
 
1189
1953
  // ============================================================================
@@ -1278,3 +2042,67 @@ function safeStringify(value: unknown): string {
1278
2042
  return "{}";
1279
2043
  }
1280
2044
  }
2045
+
2046
+ // ============================================================================
2047
+ // WP-2/R2 wait.* parameter parsers (ADR-0 2026-08-17-waiting-producer-ask)
2048
+ // ============================================================================
2049
+
2050
+ /** Server-side ceiling for the ask deadline (ADR P2-7): worker-controlled
2051
+ * timeoutSec may NEVER exceed 1h — an unbounded timeout would pin slots and
2052
+ * amplify I/O. Applied as deadline = now + min(timeoutSec, 3600). */
2053
+ const WAIT_REQUEST_TIMEOUT_SEC_MAX = 3600;
2054
+ /** Default ask timeout when the caller omits timeoutSec (ADR item 1). */
2055
+ const WAIT_REQUEST_TIMEOUT_SEC_DEFAULT = 600;
2056
+ /** Bounded question payload (defense-in-depth under the 256 KiB frame cap). */
2057
+ const WAIT_QUESTION_MAX_CHARS = 8192;
2058
+ /** Bounded answer-choice list: at most 16 options, 256 chars each. */
2059
+ const WAIT_OPTIONS_MAX = 16;
2060
+ const WAIT_OPTION_MAX_CHARS = 256;
2061
+
2062
+ interface WaitRequestParams {
2063
+ to: string;
2064
+ question: string;
2065
+ options?: string[];
2066
+ timeoutSec?: number;
2067
+ }
2068
+
2069
+ function parseWaitRequestParams(value: unknown): WaitRequestParams | undefined {
2070
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
2071
+ const v = value as Record<string, unknown>;
2072
+ if (typeof v.to !== "string" || v.to.length === 0 || v.to.length > 256) return undefined;
2073
+ if (typeof v.question !== "string" || v.question.length === 0 || v.question.length > WAIT_QUESTION_MAX_CHARS) {
2074
+ return undefined;
2075
+ }
2076
+ let options: string[] | undefined;
2077
+ if (v.options !== undefined) {
2078
+ if (!Array.isArray(v.options) || v.options.length === 0 || v.options.length > WAIT_OPTIONS_MAX) return undefined;
2079
+ for (const o of v.options) {
2080
+ if (typeof o !== "string" || o.length === 0 || o.length > WAIT_OPTION_MAX_CHARS) return undefined;
2081
+ }
2082
+ options = v.options as string[];
2083
+ }
2084
+ // timeoutSec is clamped server-side in the handler (max 3600); the parser
2085
+ // only rejects non-finite values. Non-positive values clamp to 1s.
2086
+ if (v.timeoutSec !== undefined && (typeof v.timeoutSec !== "number" || !Number.isFinite(v.timeoutSec))) {
2087
+ return undefined;
2088
+ }
2089
+ return {
2090
+ to: v.to,
2091
+ question: v.question,
2092
+ options,
2093
+ timeoutSec: v.timeoutSec as number | undefined,
2094
+ };
2095
+ }
2096
+
2097
+ interface WaitResolveParams {
2098
+ to: string;
2099
+ questionId: string;
2100
+ }
2101
+
2102
+ function parseWaitResolveParams(value: unknown): WaitResolveParams | undefined {
2103
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
2104
+ const v = value as Record<string, unknown>;
2105
+ if (typeof v.to !== "string" || v.to.length === 0 || v.to.length > 256) return undefined;
2106
+ if (typeof v.questionId !== "string" || v.questionId.length === 0 || v.questionId.length > 128) return undefined;
2107
+ return { to: v.to, questionId: v.questionId };
2108
+ }