pi-crew 0.11.1 → 0.11.4

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 (154) hide show
  1. package/CHANGELOG.md +82 -9
  2. package/README.md +161 -1036
  3. package/agents/verifier.md +18 -7
  4. package/dist/index.mjs +742 -91462
  5. package/docs/README.md +57 -46
  6. package/docs/architecture.md +87 -33
  7. package/docs/commands-reference.md +9 -5
  8. package/docs/troubleshooting.md +3 -2
  9. package/package.json +5 -4
  10. package/schema.json +29 -0
  11. package/skills/real-test-pi-crew/SKILL.md +206 -41
  12. package/src/agents/agent-config.ts +1 -1
  13. package/src/agents/discover-agents.ts +1 -1
  14. package/src/config/config-validation.ts +15 -0
  15. package/src/config/config.ts +47 -13
  16. package/src/config/env-vars.ts +35 -0
  17. package/src/config/types.ts +19 -0
  18. package/src/errors.ts +2 -2
  19. package/src/extension/async-notifier.ts +55 -8
  20. package/src/extension/crew-input-router.ts +9 -2
  21. package/src/extension/help.ts +21 -10
  22. package/src/extension/knowledge-injection.ts +2 -1
  23. package/src/extension/management.ts +8 -3
  24. package/src/extension/notification-sink.ts +17 -0
  25. package/src/extension/plan-orchestrate.ts +19 -3
  26. package/src/extension/registration/command-utils.ts +28 -2
  27. package/src/extension/registration/commands/dashboard.ts +11 -1
  28. package/src/extension/registration/commands/manage.ts +31 -15
  29. package/src/extension/registration/commands/run.ts +24 -2
  30. package/src/extension/registration/commands/shared.ts +34 -10
  31. package/src/extension/registration/commands/status.ts +25 -2
  32. package/src/extension/registration/context-builder.ts +8 -2
  33. package/src/extension/registration/foreground-run-controller.ts +2 -2
  34. package/src/extension/registration/health-notify-policy.ts +100 -0
  35. package/src/extension/registration/lazy-configurers.ts +38 -0
  36. package/src/extension/registration/lifecycle-handlers.ts +107 -40
  37. package/src/extension/registration/lifecycle.ts +77 -10
  38. package/src/extension/registration/observability.ts +125 -53
  39. package/src/extension/registration/registration-types.ts +7 -5
  40. package/src/extension/registration/runtime-cleanup.ts +9 -3
  41. package/src/extension/registration/subagent-helpers.ts +38 -0
  42. package/src/extension/registration/team-tool.ts +9 -2
  43. package/src/extension/registration/wire-cross-extension.ts +30 -0
  44. package/src/extension/run-compare.ts +220 -0
  45. package/src/extension/run-export.ts +37 -5
  46. package/src/extension/run-maintenance.ts +155 -5
  47. package/src/extension/team-manager-command.ts +3 -2
  48. package/src/extension/team-tool/dispatch/index.ts +3 -2
  49. package/src/extension/team-tool/dispatch/manage.ts +5 -2
  50. package/src/extension/team-tool/goal-wrap.ts +7 -0
  51. package/src/extension/team-tool/goal.ts +16 -1
  52. package/src/extension/team-tool/handle-settings.ts +19 -2
  53. package/src/extension/team-tool/health-monitor.ts +21 -7
  54. package/src/extension/team-tool/lifecycle-actions.ts +49 -1
  55. package/src/extension/team-tool/parallel-dispatch.ts +16 -1
  56. package/src/extension/team-tool/plan.ts +10 -0
  57. package/src/extension/team-tool/routing-hint.ts +63 -0
  58. package/src/extension/team-tool/run-intent.ts +1 -1
  59. package/src/extension/team-tool/run.ts +1 -1
  60. package/src/extension/team-tool/status.ts +4 -0
  61. package/src/extension/team-tool.ts +52 -6
  62. package/src/extension/webhook-notify.ts +382 -0
  63. package/src/observability/metric-sink.ts +12 -2
  64. package/src/prompt/prompt-runtime.ts +82 -31
  65. package/src/prompt/worker-events-channel.ts +12 -0
  66. package/src/runtime/README.md +1 -1
  67. package/src/runtime/async-runner.ts +123 -1
  68. package/src/runtime/background-runner.ts +313 -234
  69. package/src/runtime/broker/crew-broker.ts +76 -71
  70. package/src/runtime/broker/delegate/delegate-event.ts +12 -8
  71. package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
  72. package/src/runtime/broker/protocol/connection-state.ts +10 -0
  73. package/src/runtime/broker/protocol/lock-busy.ts +76 -0
  74. package/src/runtime/broker/wait-status-cache.ts +1 -1
  75. package/src/runtime/child-pi/child-pi-timers.ts +1 -1
  76. package/src/runtime/child-pi/mock-fixtures.ts +48 -0
  77. package/src/runtime/crew-agent-records.ts +337 -45
  78. package/src/runtime/deadletter.ts +43 -1
  79. package/src/runtime/delegate-spawn.ts +21 -3
  80. package/src/runtime/deterministic-ast.ts +14 -2
  81. package/src/runtime/dispatch-batch.ts +72 -5
  82. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +78 -58
  83. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +14 -2
  84. package/src/runtime/goal-workflow/goal-loop-runner.ts +105 -4
  85. package/src/runtime/group-join.ts +11 -3
  86. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  87. package/src/runtime/live-session/live-session-runtime.ts +1 -1
  88. package/src/runtime/model/model-fallback.ts +21 -1
  89. package/src/runtime/model/pi-args.ts +8 -10
  90. package/src/runtime/model/runtime-warmup.ts +2 -0
  91. package/src/runtime/output/output-validator.ts +10 -5
  92. package/src/runtime/plan-approval.ts +14 -1
  93. package/src/runtime/recovery/crash-recovery.ts +96 -49
  94. package/src/runtime/run-worker.ts +12 -1
  95. package/src/runtime/scheduling/global-worker-cap.ts +13 -6
  96. package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
  97. package/src/runtime/scheduling/scheduler.ts +49 -13
  98. package/src/runtime/scheduling/semaphore.ts +148 -20
  99. package/src/runtime/scratchpad/README.md +1 -1
  100. package/src/runtime/scratchpad/protocol.ts +1 -1
  101. package/src/runtime/settings-store.ts +1 -1
  102. package/src/runtime/skill-instructions.ts +22 -0
  103. package/src/runtime/stale-reconciler.ts +85 -13
  104. package/src/runtime/subagent-manager.ts +43 -13
  105. package/src/runtime/task-runner/child-executor.ts +17 -6
  106. package/src/runtime/task-runner/post-execution.ts +44 -14
  107. package/src/runtime/task-runner/pre-execution.ts +26 -2
  108. package/src/runtime/task-runner/prompt-builder.ts +150 -45
  109. package/src/runtime/task-runner/retrieval-orchestrator.ts +36 -14
  110. package/src/runtime/task-runner.ts +28 -1
  111. package/src/runtime/team-runner.ts +42 -2
  112. package/src/runtime/verification/verification-worktree.ts +3 -0
  113. package/src/runtime/workspace-lock.ts +4 -1
  114. package/src/schema/config-schema.ts +14 -0
  115. package/src/schema/team-tool-schema.ts +17 -0
  116. package/src/state/atomic-write.ts +53 -0
  117. package/src/state/contracts.ts +109 -0
  118. package/src/state/coordination/locks.ts +191 -33
  119. package/src/state/coordination/mailbox.ts +147 -17
  120. package/src/state/crew-init.ts +87 -12
  121. package/src/state/event-log/cursor.ts +37 -1
  122. package/src/state/event-log/event-log-rotation.ts +72 -7
  123. package/src/state/stores/active-run-registry.ts +13 -1
  124. package/src/state/stores/state-store.ts +120 -23
  125. package/src/state/types.ts +4 -0
  126. package/src/ui/dashboard-panes/agents-pane.ts +22 -10
  127. package/src/ui/heartbeat-aggregator.ts +34 -0
  128. package/src/ui/inline-panel/index.ts +1 -1
  129. package/src/ui/keybinding-map.ts +22 -4
  130. package/src/ui/live-conversation-overlay.ts +6 -3
  131. package/src/ui/render-diff.ts +19 -6
  132. package/src/ui/run-dashboard.ts +109 -6
  133. package/src/ui/run-snapshot-cache.ts +27 -4
  134. package/src/ui/spinner.ts +26 -2
  135. package/src/ui/tool-progress-formatter.ts +2 -1
  136. package/src/ui/tool-renderers/brief-mode.ts +2 -1
  137. package/src/ui/tool-renderers/index.ts +3 -3
  138. package/src/ui/widget/task-list.ts +8 -5
  139. package/src/ui/widget/widget-model.ts +3 -1
  140. package/src/utils/incremental-reader.ts +11 -3
  141. package/src/utils/paths.ts +94 -12
  142. package/src/utils/project-markers.ts +40 -0
  143. package/src/worktree/worktree-manager.ts +206 -26
  144. package/tsconfig.json +2 -0
  145. package/workflows/distill.workflow.md +3 -3
  146. package/workflows/fast-fix.workflow.md +1 -1
  147. package/workflows/plan-execute.workflow.md +1 -1
  148. package/workflows/review.workflow.md +1 -1
  149. package/workflows/strict-fast-fix.workflow.md +1 -1
  150. package/docs/migration-v0.4-v0.5.md +0 -208
  151. package/docs/runtime-flow.md +0 -148
  152. package/src/ui/card-colors.ts +0 -168
  153. package/src/utils/completion-dedupe.ts +0 -63
  154. package/src/utils/resolve-shell.ts +0 -36
@@ -28,7 +28,7 @@ import { withRunLockSync } from "../../state/coordination/locks.ts";
28
28
  import { appendMailboxMessageAsync, type MailboxMessage, registerMailboxAppendObserver } from "../../state/coordination/mailbox.ts";
29
29
  import { appendEventAsync } from "../../state/event-log/event-log.ts";
30
30
  import { loadRunManifestById, saveRunManifest, saveRunTasks } from "../../state/stores/state-store.ts";
31
- import type { TeamTaskState } from "../../state/types.ts";
31
+ import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
32
32
  import { runEventBus } from "../../ui/run-event-bus.ts";
33
33
  import { logInternalError } from "../../utils/internal-error.ts";
34
34
  import { BrokerError, encodeBrokerFrame, MAX_BROKER_FRAME_BYTES, NdjsonDecoder } from "../../utils/ndjson.ts";
@@ -40,10 +40,12 @@ import { resolveCrewMaxDepth } from "../model/pi-args.ts";
40
40
  import { NestedSlotBudget } from "../scheduling/nested-slots.ts";
41
41
  import { evaluateDelegateAdmission } from "../spawn-policy.ts";
42
42
  import { type BrokerToken, BrokerTokenRegistry } from "./crew-broker-tokens.ts";
43
- import { recordDelegateEvent } from "./delegate/delegate-event.ts";
43
+ import { type DelegateEventTarget, type DelegateEventType, recordDelegateEvent } from "./delegate/delegate-event.ts";
44
+ import { promoteShadowToRunning } from "./delegate/shadow-lifecycle.ts";
44
45
  import { fanoutMailboxMessage } from "./mailbox-observer/mailbox-fanout.ts";
45
46
  import type { CrewBrokerOptions, ServerConnection } from "./protocol/connection-state.ts";
46
47
  import { handleEventsSince } from "./protocol/events-replay.ts";
48
+ import { DEFAULT_LOCK_BUSY_RETRY_DELAYS_MS, type LockBusyOutcome, withRunLockBusyRetry } from "./protocol/lock-busy.ts";
47
49
  import { loadRunForHello } from "./protocol/manifest-loader.ts";
48
50
  import { handleMsgInbox } from "./protocol/msg-inbox.ts";
49
51
  import {
@@ -103,6 +105,7 @@ export class CrewBroker {
103
105
  | "grandchildSpawner"
104
106
  | "modelCatalog"
105
107
  | "serializeOnPathOverlap"
108
+ | "lockBusyRetryDelaysMs"
106
109
  >;
107
110
  private readonly tokens = new BrokerTokenRegistry();
108
111
  /** Task 10 (mux-surface A1 §5.2): taskId → the compound token most
@@ -1331,28 +1334,31 @@ export class CrewBroker {
1331
1334
  }
1332
1335
 
1333
1336
  // WP-2/R2: wait.request / wait.resolve (ADR-0 2026-08-17-waiting-producer-ask)
1337
+ // waitAuthError: imported directly from protocol/wait-auth.ts (M4/WI-4.1).
1334
1338
 
1335
- // waitAuthError: protocol/wait-auth.ts (M4/WI-4.1).
1336
- private waitAuthError(conn: ServerConnection): { code: string; message: string } | null {
1337
- return waitAuthError(conn);
1338
- }
1339
-
1340
- // (The "ADR item 7" doc that used to dangle here documents
1341
- // recordWaitPolicyRejection — see wait-auth.ts, where it belongs.
1342
- // Removed 2026-09-10, review F6.)
1343
1339
  // T3/R5 (ADR-5): delegate.request — governed-nesting admission + background
1344
1340
  // grandchild spawn with durable mailbox delivery (WP-5 step 5).
1345
- // getDelegateNestedSlots: inlined at 4 call sites (5-line method; M4/WI-4.1).
1341
+
1342
+ /** ADR-5 §5: lazy singleton for the nested-slot budget — first use
1343
+ * constructs it from the broker options (4 call sites previously
1344
+ * inlined this IIFE). */
1345
+ private nestedSlotBudget(): NestedSlotBudget {
1346
+ if (!this.nestedSlots)
1347
+ this.nestedSlots = new NestedSlotBudget(this.options.globalWorkerSemaphore ?? 4, this.options.nestingMaxSlots);
1348
+ return this.nestedSlots;
1349
+ }
1350
+
1351
+ /** RR-023 F4: bounded busy-retry around a run-locked RMW (see
1352
+ * protocol/lock-busy.ts) — options override or the default schedule.
1353
+ * A busy-exhausted result is the handler's cue to answer a typed `busy`
1354
+ * frame (connection survives) instead of letting the throw kill it. */
1355
+ private runLockBusyRetry<T>(manifest: TeamRunManifest, fn: () => T): Promise<LockBusyOutcome<T>> {
1356
+ return withRunLockBusyRetry(manifest, this.options.lockBusyRetryDelaysMs ?? DEFAULT_LOCK_BUSY_RETRY_DELAYS_MS, fn);
1357
+ }
1346
1358
 
1347
1359
  private recordDelegateEvent(
1348
- manifest: { eventsPath: string; runId: string },
1349
- type:
1350
- | "delegate.requested"
1351
- | "delegate.admitted"
1352
- | "delegate.rejected"
1353
- | "delegate.completed"
1354
- | "delegate.timed_out"
1355
- | "delegate.rolled_up",
1360
+ manifest: DelegateEventTarget,
1361
+ type: DelegateEventType,
1356
1362
  taskId: string,
1357
1363
  data: Record<string, unknown>,
1358
1364
  ): void {
@@ -1433,7 +1439,8 @@ export class CrewBroker {
1433
1439
  this.recordDelegateEvent(loaded.manifest, "delegate.requested", parentTaskId, { subId, role: requested.role ?? "explorer" });
1434
1440
  // Admission: full spawn-policy matrix (ADR-5 §2-§7), parent state from
1435
1441
  // the RECORD under the run lock — never the worker's env/self-report.
1436
- const admissionOutcome = withRunLockSync(loaded.manifest, () => {
1442
+ // RR-023 F4: run-locked via bounded busy-retry (protocol/lock-busy.ts).
1443
+ const admissionAttempt = await this.runLockBusyRetry(loaded.manifest, () => {
1437
1444
  const fresh = loadRunManifestById(loaded.manifest.cwd, runId);
1438
1445
  if (!fresh) return { code: "no-manifest" as const, message: `run '${runId}' not found` };
1439
1446
  const task = fresh.tasks.find((t) => t.id === parentTaskId);
@@ -1449,6 +1456,10 @@ export class CrewBroker {
1449
1456
  });
1450
1457
  return { code: "bad-params" as const, message: `delegate: parent task '${parentTaskId}' is ${task.status}, not running` };
1451
1458
  }
1459
+ // RR-012 F03: authoritative execution cwd = the parent task's cwd from
1460
+ // this locked fresh read — ONE source for overlap check, shadow record
1461
+ // and spawner input; broker cwd stays for manifest lookup only.
1462
+ const executionCwd = task.cwd;
1452
1463
  const catalog = this.options.modelCatalog?.();
1453
1464
  // S3 fail-closed: a DEFINED loader yielding undefined is a loader failure.
1454
1465
  const effectiveCatalog = this.options.modelCatalog !== undefined ? (catalog ?? []) : undefined;
@@ -1459,7 +1470,7 @@ export class CrewBroker {
1459
1470
  t.id !== parentTaskId &&
1460
1471
  t.status === "running" &&
1461
1472
  (t.role === "executor" || t.role === "test-engineer") &&
1462
- t.cwd === task.cwd,
1473
+ t.cwd === executionCwd,
1463
1474
  ).length;
1464
1475
  const decision = evaluateDelegateAdmission({
1465
1476
  maxDepth: this.options.nestingMaxDepth ?? resolveCrewMaxDepth(undefined), // config knob > env-clamped 1..10, default 4 (D8; ADR-5 §3)
@@ -1469,11 +1480,7 @@ export class CrewBroker {
1469
1480
  ...(task.depth !== undefined ? { depth: task.depth } : {}),
1470
1481
  ...(task.allocation !== undefined ? { allocation: task.allocation } : {}),
1471
1482
  },
1472
- slots: (() => {
1473
- if (!this.nestedSlots)
1474
- this.nestedSlots = new NestedSlotBudget(this.options.globalWorkerSemaphore ?? 4, this.options.nestingMaxSlots);
1475
- return this.nestedSlots;
1476
- })().snapshot(),
1483
+ slots: this.nestedSlotBudget().snapshot(),
1477
1484
  requested,
1478
1485
  ...(effectiveCatalog !== undefined ? { modelCatalog: effectiveCatalog } : {}),
1479
1486
  // ADR-5 §12: the delegate surface is an escalation — trusted only by the
@@ -1493,28 +1500,11 @@ export class CrewBroker {
1493
1500
  return { code: "policy-denied" as const, message: decision.message ?? decision.reason ?? "delegate denied" };
1494
1501
  }
1495
1502
  // Slot acquisition INSIDE the lock (no reserve-then-race refund window).
1496
- if (
1497
- !(() => {
1498
- if (!this.nestedSlots)
1499
- this.nestedSlots = new NestedSlotBudget(this.options.globalWorkerSemaphore ?? 4, this.options.nestingMaxSlots);
1500
- return this.nestedSlots;
1501
- })().tryAcquire(subId)
1502
- ) {
1503
+ if (!this.nestedSlotBudget().tryAcquire(subId)) {
1503
1504
  this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, { subId, reason: "slots-exhausted" });
1504
1505
  return {
1505
1506
  code: "policy-denied" as const,
1506
- message: `delegate rejected: nested spawn budget exhausted; ${
1507
- (
1508
- () => {
1509
- if (!this.nestedSlots)
1510
- this.nestedSlots = new NestedSlotBudget(
1511
- this.options.globalWorkerSemaphore ?? 4,
1512
- this.options.nestingMaxSlots,
1513
- );
1514
- return this.nestedSlots;
1515
- }
1516
- )().statusLine
1517
- }`,
1507
+ message: `delegate rejected: nested spawn budget exhausted; ${this.nestedSlotBudget().statusLine}`,
1518
1508
  };
1519
1509
  }
1520
1510
  // Reserve the requested budget pessimistically (ADR-5 §5): tokensSpent
@@ -1551,19 +1541,29 @@ export class CrewBroker {
1551
1541
  agent: "delegate",
1552
1542
  title: `delegate: ${(requested as { description?: string }).description ?? subId}`,
1553
1543
  status: "queued",
1554
- cwd: task.cwd,
1544
+ cwd: executionCwd,
1555
1545
  dependsOn: [],
1556
1546
  depth: decision.childDepth ?? 2,
1557
1547
  startedAt: new Date().toISOString(),
1558
1548
  } satisfies TeamTaskState,
1559
1549
  ]);
1560
- return { code: "ok" as const, decision, reserved };
1550
+ // RR-023 F3: thread the parent's model out of the locked read (gc fallback).
1551
+ return { code: "ok" as const, decision, reserved, executionCwd, parentModel: task.model };
1561
1552
  });
1553
+ // RR-023 F4 (finding #4): run.lock held by a live cross-process holder —
1554
+ // degrade to a typed busy frame AND a delegate.rejected event (never
1555
+ // silent); the connection survives (ping still answers).
1556
+ if (!admissionAttempt.ok) {
1557
+ this.recordDelegateEvent(loaded.manifest, "delegate.rejected", parentTaskId, { subId, reason: "run-lock-busy" });
1558
+ this.sendError(conn, id, "busy", admissionAttempt.message);
1559
+ return;
1560
+ }
1561
+ const admissionOutcome = admissionAttempt.value;
1562
1562
  if (admissionOutcome.code !== "ok") {
1563
1563
  this.sendError(conn, id, admissionOutcome.code, admissionOutcome.message);
1564
1564
  return;
1565
1565
  }
1566
- const { decision, reserved } = admissionOutcome;
1566
+ const { decision, reserved, executionCwd, parentModel } = admissionOutcome;
1567
1567
  this.recordDelegateEvent(loaded.manifest, "delegate.admitted", parentTaskId, {
1568
1568
  subId,
1569
1569
  childDepth: decision.childDepth,
@@ -1579,13 +1579,16 @@ export class CrewBroker {
1579
1579
  : undefined;
1580
1580
  // Return IMMEDIATELY (principle 7): the tool self-polls the mailbox.
1581
1581
  this.sendResult(conn, id, { ok: true, grandchildTaskRef: subId, childDepth: decision.childDepth, timeoutSec: decision.timeoutSec });
1582
+ // RR-023 F3 (finding #3, run team_20260929041427): model-less gc spawn fell
1583
+ // through to the pi GLOBAL default — inherit the parent task's model.
1584
+ const gcModel = requested.model ?? parentModel;
1582
1585
  // Background grandchild lifecycle (ADR-5 §1/§6).
1583
1586
  const spawner = this.options.grandchildSpawner ?? spawnDelegateGrandchild;
1584
1587
  void (async () => {
1585
1588
  let outcome: GrandchildSpawnResult;
1586
1589
  try {
1587
1590
  outcome = await spawner({
1588
- cwd,
1591
+ cwd: executionCwd,
1589
1592
  runId,
1590
1593
  parentTaskId,
1591
1594
  subId,
@@ -1593,10 +1596,14 @@ export class CrewBroker {
1593
1596
  eventsPath: loaded.manifest.eventsPath,
1594
1597
  role: requested.role ?? "explorer",
1595
1598
  ...(grandchildCreds ? { brokerSpawn: grandchildCreds } : {}),
1596
- ...(requested.model !== undefined ? { model: requested.model } : {}),
1599
+ ...(gcModel !== undefined ? { model: gcModel } : {}),
1597
1600
  ...(requested.maxTurns !== undefined ? { maxTurns: requested.maxTurns } : {}),
1598
1601
  timeoutSec: decision.timeoutSec ?? 900,
1599
1602
  depthOverride: decision.childDepth ?? 2,
1603
+ // RR-012 F16: promote shadow queued→running once the process exists.
1604
+ onSpawn: (pid) => {
1605
+ if (pid !== null) promoteShadowToRunning(cwd, runId, subId);
1606
+ },
1600
1607
  });
1601
1608
  } catch (err) {
1602
1609
  outcome = { ok: false, resultText: `delegate spawn failed: ${(err as Error).message}` };
@@ -1649,12 +1656,9 @@ export class CrewBroker {
1649
1656
  });
1650
1657
  }
1651
1658
  }
1652
- // S1#1: flip the shadow task to terminal — UNCONDITIONAL (verifier N1): the
1653
- // flip must not depend on a reservation; no allocation producer ships
1654
- // yet so reserved===0 is the common case and the guard left every
1655
- // shadow "queued" forever.
1656
- // S1#1: flip the shadow task to terminal so the subId record does not
1657
- // linger as "queued" in team status views.
1659
+ // S1#1 (verifier N1): the terminal flip is UNCONDITIONAL — never depends
1660
+ // on a reservation. RR-012 F16: onSpawn promotes the record to "running"
1661
+ // during execution; this flip terminalizes every settle path.
1658
1662
  saveRunTasks(
1659
1663
  latest.manifest,
1660
1664
  tasksToWrite.map((t) =>
@@ -1705,11 +1709,7 @@ export class CrewBroker {
1705
1709
  }
1706
1710
  }
1707
1711
  } finally {
1708
- (() => {
1709
- if (!this.nestedSlots)
1710
- this.nestedSlots = new NestedSlotBudget(this.options.globalWorkerSemaphore ?? 4, this.options.nestingMaxSlots);
1711
- return this.nestedSlots;
1712
- })().release(subId);
1712
+ this.nestedSlotBudget().release(subId);
1713
1713
  }
1714
1714
  this.recordDelegateEvent(loaded.manifest, outcome.timedOut ? "delegate.timed_out" : "delegate.completed", parentTaskId, {
1715
1715
  subId,
@@ -1718,9 +1718,7 @@ export class CrewBroker {
1718
1718
  })();
1719
1719
  }
1720
1720
 
1721
- private recordWaitPolicyRejection(manifest: { eventsPath: string; runId: string }, taskId: string, method: string): void {
1722
- recordWaitPolicyRejection(manifest, taskId, method);
1723
- }
1721
+ // recordWaitPolicyRejection: imported directly from protocol/wait-auth.ts.
1724
1722
 
1725
1723
  /** WP-2/R2 step 4: park the calling task while its `ask` tool awaits a
1726
1724
  * leader answer. Park = task.status "waiting" + task.waiting marker +
@@ -1735,7 +1733,7 @@ export class CrewBroker {
1735
1733
  this.sendError(conn, id, "auth", "not authed");
1736
1734
  return;
1737
1735
  }
1738
- const authErr = this.waitAuthError(conn);
1736
+ const authErr = waitAuthError(conn);
1739
1737
  if (authErr) {
1740
1738
  this.sendError(conn, id, authErr.code, authErr.message);
1741
1739
  return;
@@ -1772,7 +1770,7 @@ export class CrewBroker {
1772
1770
  // Capability gate (ADR item 7): fail-closed, NEVER silent — every
1773
1771
  // rejection leaves a policy.action trace in the run's events.jsonl.
1774
1772
  if (this.options.waitMethodsEnabled !== true) {
1775
- this.recordWaitPolicyRejection(loaded.manifest, conn.taskId, "wait.request");
1773
+ recordWaitPolicyRejection(loaded.manifest, conn.taskId, "wait.request");
1776
1774
  this.sendError(
1777
1775
  conn,
1778
1776
  id,
@@ -1790,7 +1788,8 @@ export class CrewBroker {
1790
1788
  const deadline = Date.now() + clampSec * 1000;
1791
1789
  const runId = conn.runId;
1792
1790
  const taskId = conn.taskId;
1793
- const outcome = withRunLockSync(loaded.manifest, () => {
1791
+ // RR-023 F4: run-locked via bounded busy-retry (protocol/lock-busy.ts).
1792
+ const outcome = await this.runLockBusyRetry(loaded.manifest, () => {
1794
1793
  // Fresh reload INSIDE the lock (respond.ts:42-43 discipline).
1795
1794
  const fresh = loadRunManifestById(loaded.manifest.cwd, runId);
1796
1795
  if (!fresh) return { code: "no-manifest" as const, message: `run '${runId}' not found` };
@@ -1823,8 +1822,14 @@ export class CrewBroker {
1823
1822
  saveRunManifest(updatedManifest);
1824
1823
  return { code: "ok" as const, message: "" };
1825
1824
  });
1826
- if (outcome.code !== "ok") {
1827
- this.sendError(conn, id, outcome.code, outcome.message);
1825
+ // RR-023 F4 (finding #4): run.lock held by a live cross-process holder
1826
+ // (e.g. the detached runner) — typed busy frame, connection survives.
1827
+ if (!outcome.ok) {
1828
+ this.sendError(conn, id, "busy", outcome.message);
1829
+ return;
1830
+ }
1831
+ if (outcome.value.code !== "ok") {
1832
+ this.sendError(conn, id, outcome.value.code, outcome.value.message);
1828
1833
  return;
1829
1834
  }
1830
1835
  // Events AFTER the run lock is released (the event-log lock is a
@@ -1889,7 +1894,7 @@ export class CrewBroker {
1889
1894
  this.sendError(conn, id, "auth", "not authed");
1890
1895
  return;
1891
1896
  }
1892
- const authErr = this.waitAuthError(conn);
1897
+ const authErr = waitAuthError(conn);
1893
1898
  if (authErr) {
1894
1899
  this.sendError(conn, id, authErr.code, authErr.message);
1895
1900
  return;
@@ -1922,7 +1927,7 @@ export class CrewBroker {
1922
1927
  return;
1923
1928
  }
1924
1929
  if (this.options.waitMethodsEnabled !== true) {
1925
- this.recordWaitPolicyRejection(loaded.manifest, conn.taskId, "wait.resolve");
1930
+ recordWaitPolicyRejection(loaded.manifest, conn.taskId, "wait.resolve");
1926
1931
  this.sendError(
1927
1932
  conn,
1928
1933
  id,
@@ -13,15 +13,19 @@ import { logInternalError } from "../../../utils/internal-error.ts";
13
13
  /** Fire-and-forget async append; an append failure is logged, never thrown
14
14
  * (broker handlers must not block the event loop on the sync event-log lock).
15
15
  * See `crew-broker.delegate.event` event scope conventions in ADR-5 §10. */
16
+ export type DelegateEventType =
17
+ | "delegate.requested"
18
+ | "delegate.admitted"
19
+ | "delegate.rejected"
20
+ | "delegate.completed"
21
+ | "delegate.timed_out"
22
+ | "delegate.rolled_up";
23
+
24
+ export type DelegateEventTarget = { eventsPath: string; runId: string };
25
+
16
26
  export function recordDelegateEvent(
17
- manifest: { eventsPath: string; runId: string },
18
- type:
19
- | "delegate.requested"
20
- | "delegate.admitted"
21
- | "delegate.rejected"
22
- | "delegate.completed"
23
- | "delegate.timed_out"
24
- | "delegate.rolled_up",
27
+ manifest: DelegateEventTarget,
28
+ type: DelegateEventType,
25
29
  taskId: string,
26
30
  data: Record<string, unknown>,
27
31
  ): void {
@@ -0,0 +1,92 @@
1
+ /**
2
+ * shadow-lifecycle.ts — broker-owned lifecycle for delegate grandchild SHADOW
3
+ * task records (RR-012, findings F03/F16; ADR-5 §1/§3 S1#1).
4
+ *
5
+ * The `delegate.request` handler (crew-broker.ts) mints a `gc-*` shadow task
6
+ * (agent "delegate", NO stepId) so the subId identity gets a real depth/role
7
+ * entry. The record is NOT a workflow task: it is managed by the EXTERNAL
8
+ * grandchild spawner (the broker), never by the workflow scheduler.
9
+ * isDelegateShadowTask() is the single discriminator the scheduler uses to
10
+ * keep shadow records out of DAG/batch selection — they carry no stepId, and
11
+ * dispatching one would throw ResourceNotFound in findStep()/findAgent()
12
+ * (dispatch-batch.ts; RR-012 §3 reachability proof in
13
+ * test/unit/runtime/broker/shadow-task-dag-readiness.test.ts).
14
+ *
15
+ * BR-09 (discriminator correctness): the marker is STRUCTURAL — the absence of
16
+ * `stepId` — NOT the agent name. Every scheduler-managed task sets
17
+ * `stepId: step.id` (state-store.ts materializeTasks), so "no stepId" means
18
+ * "not a workflow task". The previous test (`agent === "delegate"`) filtered
19
+ * out any LEGITIMATE workflow task whose resolved agent happens to be named
20
+ * `delegate` — such a task never entered a batch, was never dispatched, and
21
+ * finalize-run then marked the whole run `blocked`. (`delegate` is deliberately
22
+ * NOT in PROTECTED_AGENT_NAMES, so the name itself carries no reserved
23
+ * meaning.) Backward compatible both ways: legacy persisted shadows also lack
24
+ * `stepId` (still excluded) and legacy workflow tasks carry one (still
25
+ * dispatched); no schema change.
26
+ *
27
+ * KNOWN overlap (cold-verify): `reconstructTasksFromEventLog`
28
+ * (manifest-io.ts, ST-4 corruption recovery) also emits tasks WITHOUT `stepId`
29
+ * — those are classified as shadows and excluded from dispatch selection.
30
+ * That is fail-closed, and strictly better than the pre-guard behavior
31
+ * (findStep would throw ResourceNotFound and kill the batch): reconstructed
32
+ * tasks carry agent "unknown" and no step, so they cannot be dispatched
33
+ * anyway. Do NOT reuse this predicate to mean "broker shadow" outside
34
+ * scheduler selection.
35
+ *
36
+ * F16 lifecycle contract (broker-owned; every write under the run lock):
37
+ * queued → saved by the admission write (crew-broker.ts handleDelegateRequest)
38
+ * running → promoteShadowToRunning() from the spawner's onSpawn hook the
39
+ * moment the grandchild process exists (pid !== null). Idempotent:
40
+ * only a record still "queued" is promoted — a terminal flip or a
41
+ * stale-reconciler cancel always wins over a late promote.
42
+ * completed | failed → the unconditional terminal flip when the spawner
43
+ * settles (success, failure, error, timeout — every path).
44
+ */
45
+
46
+ import { withRunLockSync } from "../../../state/coordination/locks.ts";
47
+ import { loadRunManifestById, saveRunTasks } from "../../../state/stores/state-store.ts";
48
+ import type { TeamTaskState } from "../../../state/types.ts";
49
+ import { logInternalError } from "../../../utils/internal-error.ts";
50
+
51
+ /** Shadow-task discriminator (RR-012 + BR-09): a task record managed by the
52
+ * external delegate-broker spawner rather than the workflow scheduler.
53
+ *
54
+ * The marker is STRUCTURAL: shadow records are persisted with NO `stepId`
55
+ * (the broker's shadow literal in crew-broker.ts sets none), while every
56
+ * scheduler-materialized task sets `stepId: step.id`. A record without a
57
+ * stepId cannot be dispatched anyway — `findStep()` matches on `stepId` and
58
+ * throws ResourceNotFound — so "no stepId" is exactly the set the scheduler
59
+ * must skip. The agent name is NOT part of the predicate (BR-09: a real
60
+ * workflow task may legitimately resolve to an agent named `delegate`). */
61
+ export function isDelegateShadowTask(task: Pick<TeamTaskState, "id" | "agent" | "stepId">): boolean {
62
+ return task.stepId === undefined;
63
+ }
64
+
65
+ /** F16: queued→running promote, called from the spawner's onSpawn hook when
66
+ * the grandchild process exists. Best-effort by design: a failure logs and
67
+ * leaves the record as-is — the unconditional terminal flip on settle still
68
+ * runs, so a promote failure can never strand a record in "queued" past the
69
+ * settle (and depth-3 admission merely stays blocked until then, which is
70
+ * the fail-closed direction). */
71
+ export function promoteShadowToRunning(brokerCwd: string, runId: string, subId: string): void {
72
+ try {
73
+ const fresh = loadRunManifestById(brokerCwd, runId);
74
+ if (!fresh) return;
75
+ withRunLockSync(fresh.manifest, () => {
76
+ const latest = loadRunManifestById(brokerCwd, runId);
77
+ if (!latest) return;
78
+ const shadow = latest.tasks.find((t) => t.id === subId);
79
+ if (shadow?.status !== "queued") return;
80
+ saveRunTasks(
81
+ latest.manifest,
82
+ latest.tasks.map((t) => (t.id === subId ? { ...t, status: "running" as const } : t)),
83
+ );
84
+ });
85
+ } catch (err) {
86
+ logInternalError(
87
+ "crew-broker.delegate.promote-shadow",
88
+ err instanceof Error ? err : new Error(String(err)),
89
+ `runId=${runId} subId=${subId}`,
90
+ );
91
+ }
92
+ }
@@ -55,6 +55,16 @@ export interface CrewBrokerOptions {
55
55
  nestingTrustedEscalation?: boolean;
56
56
  /** Global worker semaphore size, used to size the nested-slot budget. */
57
57
  globalWorkerSemaphore?: number;
58
+ /** RR-023 F4 (2026-09-29 battery, finding #4): bounded retry schedule
59
+ * (ms) for run.lock contention in the wait.request / delegate.request
60
+ * handlers. A live cross-process holder (e.g. the detached runner
61
+ * persisting task state) makes acquireLockWithRetry throw; instead of
62
+ * letting that escape handleData → closeConnection, the handler retries
63
+ * on this schedule and then answers a typed `busy` error frame with the
64
+ * connection SURVIVING (plus a delegate.rejected event on the delegate
65
+ * path — never silent). Default DEFAULT_LOCK_BUSY_RETRY_DELAYS_MS
66
+ * (50/100/200/400/800). See protocol/lock-busy.ts. */
67
+ lockBusyRetryDelaysMs?: readonly number[];
58
68
  /** Test seam / alternative spawner for delegate grandchildren. Production
59
69
  * uses spawnDelegateGrandchild (direct runChildPi call-site, ADR-5 §2). */
60
70
  grandchildSpawner?: (input: GrandchildSpawnInput) => Promise<GrandchildSpawnResult>;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * lock-busy.ts — RR-023 F4 (2026-09-29 full battery, finding #4).
3
+ *
4
+ * Broker-handler degradation for run.lock contention. Root cause: the
5
+ * wait.request / delegate.request handlers run their state RMW inside
6
+ * withRunLockSync; acquireLockWithRetry (locks.ts, canSteal=false) throws
7
+ * IMMEDIATELY when a live cross-process holder (e.g. the detached runner
8
+ * persisting task state) owns run.lock, and that throw escaped handleData →
9
+ * closeConnection — workers saw an untyped `code=close` socket death
10
+ * (evidence: team_20260929041005 ask-probe / team_20260929041020
11
+ * delegate-probe, both dead within ~5ms; the SYNC foreground run was the
12
+ * zero-contention control).
13
+ *
14
+ * Fix layer is the broker ONLY — locks.ts steal semantics are the v0.9.26
15
+ * lock-family invariant and stay untouched (a live holder is never stolen).
16
+ * Precedent: protocol/request-parsers.ts (M4/WI-4.1 extraction discipline).
17
+ */
18
+
19
+ import { withRunLockSync } from "../../../state/coordination/locks.ts";
20
+ import type { TeamRunManifest } from "../../../state/types.ts";
21
+
22
+ /** Default bounded retry schedule for run.lock contention (ms). Five
23
+ * back-off attempts (~1.55s total) absorb a transient holder — e.g. the
24
+ * detached runner's state flush — without parking the request. */
25
+ export const DEFAULT_LOCK_BUSY_RETRY_DELAYS_MS: readonly number[] = [50, 100, 200, 400, 800];
26
+
27
+ /** Match the exact identity locks.ts throws for a LIVE holder on the
28
+ * never-steal path (sync acquireLockWithRetry, its async twin, and the
29
+ * lockCtx fallthrough): `Run '<basename>' is locked by another operation.`
30
+ * Classification is by message identity — locks.ts exports no error class
31
+ * (plain Error), and src/state must not be edited from the broker layer. */
32
+ const RUN_LOCK_BUSY_MESSAGE = /^Run '[^']+' is locked by another operation\.$/;
33
+
34
+ export function isRunLockBusyError(error: unknown): boolean {
35
+ return error instanceof Error && RUN_LOCK_BUSY_MESSAGE.test(error.message);
36
+ }
37
+
38
+ /** Result of a run-locked RMW under the busy-retry policy: either the body's
39
+ * value, or the typed busy degradation (message derived from the underlying
40
+ * lock error — it names run.lock, so it matches /run\.lock|busy/i). */
41
+ export type LockBusyOutcome<T> = { ok: true; value: T } | { ok: false; message: string };
42
+
43
+ /** Run `fn` under withRunLockSync with a bounded busy-retry.
44
+ *
45
+ * Retry safety: the busy throw is raised by the lock ACQUIRE before `fn`
46
+ * runs (locks.ts), and in the broker's wait/delegate bodies every nested
47
+ * state write is lockCtx-reentrant (same-context nesting never re-acquires),
48
+ * so a retry never re-executes a partially-run body. Any NON-busy error
49
+ * propagates unchanged (previous handler behavior for real faults).
50
+ * Waits use async sleep — the broker event loop keeps serving other
51
+ * connections (and pings) between attempts. */
52
+ export async function withRunLockBusyRetry<T>(
53
+ manifest: TeamRunManifest,
54
+ delaysMs: readonly number[],
55
+ fn: () => T,
56
+ ): Promise<LockBusyOutcome<T>> {
57
+ for (let attempt = 0; ; attempt++) {
58
+ try {
59
+ return { ok: true, value: withRunLockSync(manifest, fn) };
60
+ } catch (error) {
61
+ if (!isRunLockBusyError(error)) throw error;
62
+ const delay = delaysMs[attempt];
63
+ if (delay === undefined) {
64
+ return {
65
+ ok: false,
66
+ message: `run.lock busy (bounded retry budget exhausted): ${(error as Error).message}`,
67
+ };
68
+ }
69
+ await sleep(delay);
70
+ }
71
+ }
72
+ }
73
+
74
+ function sleep(ms: number): Promise<void> {
75
+ return new Promise((resolve) => setTimeout(resolve, ms));
76
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * wait-status-cache.ts — R10-3: mtime+size-gated load cache for task.waitStatus.
3
3
  *
4
- * Problem (docs/refactor-plan.review.md §Round 10, R10-3): the broker's
4
+ * Problem (docs/archive/refactor-plan.review.md §Round 10, R10-3): the broker's
5
5
  * task.waitStatus poll loop ticks every 200ms per waiter and each tick calls
6
6
  * loadRunManifestById() — stat manifest + stat tasks + (on the state-store
7
7
  * manifest-cache miss, which every write invalidates via generation bump) a
@@ -20,7 +20,7 @@
20
20
  * `deps` (values) + `state` (getters/setters over the runChildPi-local flags)
21
21
  * so behavior is preserved byte-for-byte.
22
22
  *
23
- * R6-F1 (refactor-plan.review.md §ROUND 6): clearAll() MUST cover all six
23
+ * R6-F1 (docs/archive/refactor-plan.review.md §ROUND 6): clearAll() MUST cover all six
24
24
  * constructs — previously cancelHardKill was a local const inside abort() and
25
25
  * was NOT cleared by clearChildPiTimeouts(), leaking a 200 ms unref'd timer per
26
26
  * cancel on short-lived children.
@@ -131,6 +131,54 @@ export async function runMockChildPi(
131
131
  stderr: "[MOCK] rate limit: mock failure",
132
132
  };
133
133
 
134
+ // RR-013 (F04) test seam: reproduce the MuxSurface A1 degrade result WITHOUT a
135
+ // real multiplexer. child-executor's `childResult.surface?.degraded` branch is
136
+ // the ONLY producer of TaskExecutionResult.surfaceLost, and runTeamTask offers
137
+ // no seam to inject `surface.providers` (child-executor's runWorker() call
138
+ // passes no `surface` field), so no other mock mode can reach that branch.
139
+ //
140
+ // SCOPE NOTE: this mode does NOT simulate `classifyOnExit` (the 2s window) — it
141
+ // starts from the exact point where classifyOnExit already returned
142
+ // `degraded`. That is the correct scope: the F04 defect lives at the
143
+ // child-executor → task-runner → finalizer boundary, not in classify (which is
144
+ // covered by degrade.test.ts + child-pi-surface.test.ts).
145
+ //
146
+ // Guarded by the existing PI_CREW_ALLOW_MOCK parent-only check above, so it
147
+ // cannot be activated from a child worker.
148
+ if (mock === "surface-degraded") {
149
+ return {
150
+ exitCode: 0,
151
+ stdout: "",
152
+ stderr: "",
153
+ rawFinalText: "",
154
+ surface: {
155
+ kind: "tmux",
156
+ paneId: "%9",
157
+ scriptPath: "/tmp/pi-crew-mock-launch.sh",
158
+ degraded: {
159
+ cause: "pane-closed",
160
+ exitReason: "pane-closed",
161
+ classifiedAt: new Date().toISOString(),
162
+ },
163
+ },
164
+ };
165
+ }
166
+
167
+ // RR-013 (AC-2) test seam: the ONLY result channel carrying text is
168
+ // `rawFinalText` (stdout/transcript are empty). This reproduces the compaction
169
+ // scenario the TaskExecutionResult.rawFinalText docstring describes —
170
+ // parsedOutput.finalText and finalStdout are both empty, so the spec-evidence
171
+ // footer union can only see the text if rawFinalText survives the
172
+ // child-executor → task-runner → finalizer boundary.
173
+ if (mock === "raw-final-text-only") {
174
+ return {
175
+ exitCode: 0,
176
+ stdout: "",
177
+ stderr: "",
178
+ rawFinalText: "RR-013 boundary probe result.\n\nSPEC-EVIDENCE:\nacc-1: ran the boundary check\n",
179
+ };
180
+ }
181
+
134
182
  // E2E fallback-chain fixture: invocation #1 returns a SILENT retryable
135
183
  // failure (exit code 0, no real assistant text, message_end carries a
136
184
  // retryable-pattern errorMessage). Invocation #2+ delegates to the