pi-crew 0.10.3 → 0.10.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 (62) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +5 -1
  3. package/dist/index.mjs +10685 -6882
  4. package/docs/architecture.md +4 -4
  5. package/docs/commands-reference.md +3 -0
  6. package/docs/publishing.md +15 -3
  7. package/install.mjs +90 -39
  8. package/package.json +8 -3
  9. package/scripts/README.md +4 -3
  10. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +1 -0
  11. package/skills/real-test-pi-crew/SKILL.md +153 -6
  12. package/src/config/migration-validator.ts +113 -0
  13. package/src/extension/cross-extension-rpc.ts +3 -7
  14. package/src/extension/register.ts +13 -0
  15. package/src/extension/registration/observability.ts +3 -7
  16. package/src/extension/registration/subagent-tools.ts +3 -7
  17. package/src/extension/registration/team-tool.ts +3 -7
  18. package/src/extension/registration/ui.ts +3 -8
  19. package/src/extension/registration/viewers.ts +3 -10
  20. package/src/extension/team-manager-command.ts +3 -7
  21. package/src/extension/team-tool/api/agent-control.ts +17 -10
  22. package/src/extension/team-tool/api/heartbeat.ts +4 -3
  23. package/src/extension/team-tool/api/mailbox.ts +33 -20
  24. package/src/extension/team-tool/api/plan-approval.ts +5 -5
  25. package/src/extension/team-tool/api/task-claims.ts +8 -7
  26. package/src/extension/team-tool/cancel.ts +6 -0
  27. package/src/extension/team-tool/handle-settings.ts +4 -1
  28. package/src/extension/team-tool/run.ts +3 -7
  29. package/src/extension/team-tool/status.ts +5 -0
  30. package/src/extension/team-tool.ts +6 -14
  31. package/src/hooks/registry.ts +3 -0
  32. package/src/prompt/scratchpad-lifecycle.ts +3 -3
  33. package/src/runtime/background-runner.ts +30 -35
  34. package/src/runtime/broker/crew-broker.ts +112 -441
  35. package/src/runtime/broker/delegate/delegate-event.ts +37 -0
  36. package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
  37. package/src/runtime/broker/protocol/connection-state.ts +103 -0
  38. package/src/runtime/broker/protocol/events-replay.ts +68 -0
  39. package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
  40. package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
  41. package/src/runtime/broker/protocol/request-parsers.ts +175 -0
  42. package/src/runtime/broker/protocol/wait-auth.ts +46 -0
  43. package/src/runtime/child-pi/child-pi.ts +15 -0
  44. package/src/runtime/finalize-run.ts +15 -7
  45. package/src/runtime/foreground-control.ts +19 -6
  46. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
  47. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
  48. package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
  49. package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
  50. package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
  51. package/src/runtime/model/pi-args.ts +6 -1
  52. package/src/runtime/plan-replan.ts +3 -0
  53. package/src/runtime/stale-reconciler.ts +28 -3
  54. package/src/runtime/supervisor-contact.ts +3 -0
  55. package/src/runtime/task-runner/child-executor.ts +33 -0
  56. package/src/runtime/team-runner.ts +3 -3
  57. package/src/state/stores/ownership-map.ts +5 -4
  58. package/src/state/stores/plan-store.ts +12 -0
  59. package/src/state/stores/state-store.ts +5 -0
  60. package/src/ui/powerbar-publisher.ts +3 -7
  61. package/src/ui/run-action-dispatcher.ts +7 -10
  62. package/src/ui/settings-overlay.ts +4 -1
@@ -149,6 +149,11 @@ export function writeForegroundInterruptRequest(
149
149
  /* no pid file — fall through to sleep */
150
150
  }
151
151
  // Brief sleep to avoid CPU spinning
152
+ // WI-7.3 (M7 spec §5): kept sync. Making this async would force
153
+ // requestForegroundControl() + its caller chain async (10+
154
+ // callers in message-tool/lifecycle paths). 10ms spin per
155
+ // iteration is bounded by the 5s timeout above (max ~500 iters
156
+ // = 5s total worst-case block, matching the lock-timeout).
152
157
  sleepSync(10);
153
158
  }
154
159
  }
@@ -180,12 +185,20 @@ export function writeForegroundInterruptRequest(
180
185
  };
181
186
  fs.mkdirSync(path.dirname(controlPath), { recursive: true });
182
187
  atomicWriteFile(controlPath, `${JSON.stringify({ requests: [...requests, request] }, null, 2)}\n`);
183
- appendEvent(manifest.eventsPath, {
184
- type: "foreground.interrupt_requested",
185
- runId: manifest.runId,
186
- message: reason,
187
- data: { requestId: request.id, controlPath },
188
- });
188
+ // Read-your-writes (CI 2026-09-11, phase6 integration): for a fresh
189
+ // manifest this may be the FIRST event — buffered append leaves
190
+ // events.jsonl nonexistent at the sync read right after (ENOENT).
191
+ // Sync appendEvent (base behavior, b6eba80f pattern).
192
+ try {
193
+ appendEvent(manifest.eventsPath, {
194
+ type: "foreground.interrupt_requested",
195
+ runId: manifest.runId,
196
+ message: reason,
197
+ data: { requestId: request.id, controlPath },
198
+ });
199
+ } catch (e) {
200
+ logInternalError("foreground_control.append", e, "type=foreground.interrupt_requested");
201
+ }
189
202
  return request;
190
203
  } finally {
191
204
  releaseLock();
@@ -687,6 +687,10 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
687
687
  // Idempotency: same phase title → no event, no state change.
688
688
  if (title === phaseState.currentPhase) return;
689
689
  // Close out the previous open phase BEFORE the new one opens.
690
+ // REVIEW FIX (2026-09-10): reverted M2a buffered conversion — phase
691
+ // transitions are low-frequency AND read back synchronously (tests,
692
+ // checkpoint resume; dwf-setresult rounds 12/14/18 assert the events
693
+ // file immediately after the run).
690
694
  if (phaseState.currentPhase !== undefined) {
691
695
  appendEvent(manifest.eventsPath, {
692
696
  type: "dwf.phase_completed",
@@ -710,6 +714,7 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
710
714
  );
711
715
  }
712
716
  }
717
+ // REVIEW FIX (2026-09-10): reverted M2a buffered conversion — see phase_completed.
713
718
  appendEvent(manifest.eventsPath, {
714
719
  type: "dwf.phase_started",
715
720
  runId: manifest.runId,
@@ -724,6 +729,7 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
724
729
  if (wfState.logs.length < 1000) {
725
730
  wfState.logs.push(text);
726
731
  }
732
+ // REVIEW FIX (2026-09-10): reverted M2a buffered conversion — see phase().
727
733
  appendEvent(manifest.eventsPath, {
728
734
  type: "dwf.log",
729
735
  runId: manifest.runId,
@@ -154,6 +154,9 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
154
154
  // code execution. Default-deny project workflows unless the user explicitly opts in via
155
155
  // PI_CREW_TRUST_PROJECT_DWF=1. Builtin and user workflows proceed without restriction.
156
156
  if (workflow.source === "project" && getCrewEnv("PI_CREW_TRUST_PROJECT_DWF") !== "1") {
157
+ // REVIEW FIX (2026-09-10): DWF lifecycle events reverted from M2a
158
+ // buffered conversion — runner sites are read back synchronously
159
+ // (dwf-setresult tests, resume flow, status display after run end).
157
160
  appendEvent(eventsPath, {
158
161
  type: "dwf.trust_denied",
159
162
  runId: manifest.runId,
@@ -20,7 +20,7 @@ import { createRequire } from "node:module";
20
20
  import type { AgentConfig } from "../../agents/agent-config.ts";
21
21
  import { loadConfig } from "../../config/config.ts";
22
22
  import { effectiveRunConfig } from "../../extension/team-tool/config-patch.ts";
23
- import { appendEvent } from "../../state/event-log/event-log.ts";
23
+ import { appendEventBuffered } from "../../state/event-log/event-log.ts";
24
24
  import { registerActiveRun, unregisterActiveRun } from "../../state/stores/active-run-registry.ts";
25
25
  import { collectRunMetrics } from "../../state/stores/run-metrics.ts";
26
26
  import { createRunManifest, saveRunTasks } from "../../state/stores/state-store.ts";
@@ -121,7 +121,7 @@ export const realGoalEvaluator = async (
121
121
  const drift = compareSnapshot(snapshot.snapshot, current);
122
122
  if (drift.length > 0) {
123
123
  verificationCompromised = drift;
124
- appendEvent(turnManifest.eventsPath, {
124
+ appendEventBuffered(turnManifest.eventsPath, {
125
125
  type: "goal.verification_compromised",
126
126
  runId: turnRunId,
127
127
  data: {
@@ -129,7 +129,7 @@ export const realGoalEvaluator = async (
129
129
  driftedFiles: drift,
130
130
  phase: "T_snap",
131
131
  },
132
- });
132
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.verification_compromised phase=T_snap"));
133
133
  }
134
134
  } catch (error) {
135
135
  logInternalError("goal-loop.integritySnap", error, `goalId=${goal.goalId} phase=T_snap`);
@@ -201,7 +201,7 @@ export const realGoalEvaluator = async (
201
201
  const postDrift = compareSnapshot(snapshot.snapshot, post);
202
202
  if (postDrift.length > 0) {
203
203
  verificationCompromised = postDrift;
204
- appendEvent(turnManifest.eventsPath, {
204
+ appendEventBuffered(turnManifest.eventsPath, {
205
205
  type: "goal.verification_compromised",
206
206
  runId: turnRunId,
207
207
  data: {
@@ -209,7 +209,9 @@ export const realGoalEvaluator = async (
209
209
  driftedFiles: postDrift,
210
210
  phase: "T_verify_done",
211
211
  },
212
- });
212
+ }).catch((e) =>
213
+ logInternalError("goal-loop.buffered", e, "type=goal.verification_compromised phase=T_verify_done"),
214
+ );
213
215
  }
214
216
  } catch (error) {
215
217
  logInternalError("goal-loop.integritySnap", error, `goalId=${goal.goalId} phase=T_verify_done`);
@@ -521,7 +523,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
521
523
  const workflow = resolveGoalTurnWorkflow(goal);
522
524
  const agents = input.deps.discoverAgents(goal.cwd);
523
525
 
524
- appendEvent(eventsPath, {
526
+ appendEventBuffered(eventsPath, {
525
527
  type: "goal.loop_start",
526
528
  runId: manifest.runId,
527
529
  data: {
@@ -529,7 +531,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
529
531
  objective: goal.objective,
530
532
  maxTurns: goal.maxTurns,
531
533
  },
532
- });
534
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.loop_start"));
533
535
 
534
536
  // P1g (RFC v0.5 §P1g, cold-review #2 BLOCKING fix): acquire the workspace lock for the
535
537
  // goal's lifetime. This serializes concurrent goals targeting the same cwd (workspaceMode:
@@ -545,14 +547,14 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
545
547
  } catch (error) {
546
548
  logInternalError("goal-loop.workspaceLock", error, `goalId=${goal.goalId} cwd=${goal.cwd}`);
547
549
  goal = safeSetStatus(store, goal.goalId, "blocked", goal, eventsPath);
548
- appendEvent(eventsPath, {
550
+ appendEventBuffered(eventsPath, {
549
551
  type: "goal.workspace_lock_failed",
550
552
  runId: manifest.runId,
551
553
  data: {
552
554
  goalId: goal.goalId,
553
555
  error: error instanceof Error ? error.message : String(error),
554
556
  },
555
- });
557
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.workspace_lock_failed"));
556
558
  return { manifest, tasks: [], goalState: goal };
557
559
  }
558
560
 
@@ -570,7 +572,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
570
572
  if (goal.budgetUnlimited !== true && goal.budgetTotal !== undefined && goal.budgetTotal > 0 && goal.budgetAbort !== undefined) {
571
573
  if (goal.budgetUsed >= goal.budgetAbort * goal.budgetTotal) {
572
574
  goal = safeSetStatus(store, goal.goalId, "budget_exceeded", goal, eventsPath);
573
- appendEvent(eventsPath, {
575
+ appendEventBuffered(eventsPath, {
574
576
  type: "goal.budget_warning",
575
577
  runId: manifest.runId,
576
578
  data: {
@@ -579,11 +581,11 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
579
581
  budgetTotal: goal.budgetTotal,
580
582
  threshold: "abort",
581
583
  },
582
- });
584
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.budget_warning threshold=abort"));
583
585
  break;
584
586
  }
585
587
  if (goal.budgetUsed >= (goal.budgetWarning ?? 0.8) * goal.budgetTotal) {
586
- appendEvent(eventsPath, {
588
+ appendEventBuffered(eventsPath, {
587
589
  type: "goal.budget_warning",
588
590
  runId: manifest.runId,
589
591
  data: {
@@ -592,12 +594,12 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
592
594
  budgetTotal: goal.budgetTotal,
593
595
  threshold: "warning",
594
596
  },
595
- });
597
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.budget_warning threshold=warning"));
596
598
  }
597
599
  }
598
600
 
599
601
  const turnIndex = goal.turnsUsed + 1;
600
- appendEvent(eventsPath, {
602
+ appendEventBuffered(eventsPath, {
601
603
  type: "goal.turn_start",
602
604
  runId: manifest.runId,
603
605
  data: {
@@ -605,7 +607,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
605
607
  turn: turnIndex,
606
608
  maxTurns: goal.maxTurns,
607
609
  },
608
- });
610
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.turn_start"));
609
611
 
610
612
  // ── TURN: fresh manifest per turn (G2) + executeTeamRun ──────────────────
611
613
  const turnGoalText = composeGoalPrompt(goal);
@@ -630,7 +632,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
630
632
  // Fix round-6: re-check state AFTER patching (user may have paused/stopped in the inter-turn gap).
631
633
  // Without this, a pause that lands between store.patch and executeTeamRun lets one extra turn run.
632
634
  if (goal.state !== "running") {
633
- appendEvent(eventsPath, {
635
+ appendEventBuffered(eventsPath, {
634
636
  type: "goal.loop_end",
635
637
  runId: manifest.runId,
636
638
  data: {
@@ -638,7 +640,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
638
640
  state: goal.state,
639
641
  reason: "state changed before turn spawn",
640
642
  },
641
- });
643
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.loop_end reason=state_changed"));
642
644
  break;
643
645
  }
644
646
  registerActiveRun(created.manifest);
@@ -688,7 +690,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
688
690
  const turnStatus = turnResult.manifest.status;
689
691
  if (turnStatus === "blocked" || turnStatus === "failed") {
690
692
  goal = safeSetStatus(store, goal.goalId, "blocked", goal, eventsPath);
691
- appendEvent(eventsPath, {
693
+ appendEventBuffered(eventsPath, {
692
694
  type: "goal.turn_terminal_status",
693
695
  runId: manifest.runId,
694
696
  data: {
@@ -697,7 +699,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
697
699
  turnRunId: created.manifest.runId,
698
700
  turnStatus,
699
701
  },
700
- });
702
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.turn_terminal_status"));
701
703
  break;
702
704
  }
703
705
 
@@ -735,7 +737,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
735
737
  eventsPath,
736
738
  ) ?? goal;
737
739
 
738
- appendEvent(eventsPath, {
740
+ appendEventBuffered(eventsPath, {
739
741
  type: "goal.turn_evaluated",
740
742
  runId: manifest.runId,
741
743
  data: {
@@ -744,9 +746,9 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
744
746
  achieved: verdict.achieved,
745
747
  reason: verdict.reason,
746
748
  },
747
- });
749
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.turn_evaluated"));
748
750
  if (!verdict.achieved && goal.nextTurnFeedback) {
749
- appendEvent(eventsPath, {
751
+ appendEventBuffered(eventsPath, {
750
752
  type: "goal.feedback_steered",
751
753
  runId: manifest.runId,
752
754
  data: {
@@ -754,7 +756,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
754
756
  turn: turnIndex,
755
757
  feedback: goal.nextTurnFeedback,
756
758
  },
757
- });
759
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.feedback_steered"));
758
760
  }
759
761
 
760
762
  // ── STOP CONDITIONS (round-7: re-read disk before applying — external cancel/pause wins) ─
@@ -781,7 +783,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
781
783
  const stuck = store.compareAndSetStatus(goal.goalId, "running", "stuck", eventsPath);
782
784
  if (stuck) {
783
785
  goal = stuck;
784
- appendEvent(eventsPath, {
786
+ appendEventBuffered(eventsPath, {
785
787
  type: "goal.stuck",
786
788
  runId: manifest.runId,
787
789
  data: {
@@ -789,7 +791,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
789
791
  turn: goal.turnsUsed,
790
792
  lastReasons: goal.verdicts.slice(-3).map((v) => v.reason.slice(0, 200)),
791
793
  },
792
- });
794
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.stuck"));
793
795
  break;
794
796
  }
795
797
  }
@@ -811,7 +813,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
811
813
  } catch {
812
814
  /* best-effort */
813
815
  }
814
- appendEvent(eventsPath, {
816
+ appendEventBuffered(eventsPath, {
815
817
  type: "goal.loop_end",
816
818
  runId: manifest.runId,
817
819
  data: {
@@ -820,7 +822,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
820
822
  turnsUsed: goal.turnsUsed,
821
823
  budgetUsed: goal.budgetUsed,
822
824
  },
823
- });
825
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.loop_end reason=normal"));
824
826
  }
825
827
 
826
828
  return { manifest, tasks: [], goalState: goal };
@@ -81,6 +81,9 @@ export class GoalStore {
81
81
  mkdirSync(dirname(path), { recursive: true });
82
82
  atomicWriteJson(path, next);
83
83
  if (eventsPath) {
84
+ // REVIEW FIX (2026-09-10): reverted M2b buffered conversion —
85
+ // goal.state_changed is read back synchronously by CAS tests and
86
+ // goal status display; low-frequency transition event.
84
87
  appendEvent(eventsPath, {
85
88
  type: "goal.state_changed",
86
89
  runId: state.goalId,
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { NotificationDescriptor } from "../../extension/notification-router.ts";
4
4
  import type { MetricRegistry } from "../../observability/metric-registry.ts";
5
- import { appendEvent } from "../../state/event-log/event-log.ts";
5
+ import { appendEventBuffered } from "../../state/event-log/event-log.ts";
6
6
  import { loadRunManifestById } from "../../state/stores/state-store.ts";
7
7
  import type { TeamRunManifest } from "../../state/types.ts";
8
8
  import { logInternalError } from "../../utils/internal-error.ts";
@@ -175,7 +175,7 @@ export class HeartbeatWatcher {
175
175
  this.lastLevel.set(key, level);
176
176
  if (level === "dead" && previous !== "dead") {
177
177
  this.opts.registry.counter("crew.heartbeat.dead_total", "Dead heartbeat detections").inc({ runId: run.runId });
178
- appendEvent(loaded.manifest.eventsPath, {
178
+ appendEventBuffered(loaded.manifest.eventsPath, {
179
179
  type: "crew.task.heartbeat_dead",
180
180
  runId: run.runId,
181
181
  taskId: task.id,
@@ -183,7 +183,7 @@ export class HeartbeatWatcher {
183
183
  data: {
184
184
  elapsedMs: Number.isFinite(elapsed) ? elapsed : undefined,
185
185
  },
186
- });
186
+ }).catch((e) => logInternalError("heartbeat_watcher.buffered", e, "type=crew.task.heartbeat_dead"));
187
187
  // W9 fix — prefix title with short run label (first 8 chars of runId)
188
188
  // so ambient notifications are scannable when multiple runs are
189
189
  // in flight. Full runId remains in the notification object.
@@ -281,7 +281,12 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
281
281
 
282
282
  // D5 (spec v0.7 §6): default loadout = FULL session (như main session).
283
283
  // --no-extensions/--no-skills/--tools CHỈ xuất hiện khi agent .md khai explicit.
284
- const CONTROL_TOOLS = ["ask", "delegate"] as const;
284
+ // "message" MUST stay in CONTROL_TOOLS: it is registered by prompt-runtime
285
+ // behind the PI_CREW_MSG_ENABLED dormant-env gate (child-pi-spawn.ts sets it
286
+ // unconditionally), but pi's --tools allowlist filters the tool SURFACE — a
287
+ // control tool missing here is invisible to every frontmatter-pinned worker
288
+ // (battery 2026-09-10: message dead for all builtin roles since f843e14a).
289
+ const CONTROL_TOOLS = ["ask", "delegate", "message"] as const;
285
290
  if (input.agent.disableTools === true) {
286
291
  args.push("--no-tools"); // capability-locked agents giữ hành vi (goal-judge)
287
292
  } else {
@@ -111,6 +111,8 @@ export function sweepDroppedPlanItems(initialManifest: TeamRunManifest, initialT
111
111
  : t,
112
112
  );
113
113
  cancelledTaskIds.push(task.id);
114
+ // REVIEW FIX (2026-09-10): reverted M2b buffered conversion —
115
+ // sweep tests read plan.item.dropped events synchronously.
114
116
  appendEvent(fresh.manifest.eventsPath, {
115
117
  type: "plan.item.dropped",
116
118
  runId,
@@ -127,6 +129,7 @@ export function sweepDroppedPlanItems(initialManifest: TeamRunManifest, initialT
127
129
  if (!appendSteeringAdvisory(fresh.manifest, task.id)) continue;
128
130
  tasks = tasks.map((t) => (t.id === task.id ? { ...t, replanDroppedAt: new Date().toISOString() } : t));
129
131
  advisedTaskIds.push(task.id);
132
+ // REVIEW FIX (2026-09-10): reverted M2b buffered conversion — see above.
130
133
  appendEvent(fresh.manifest.eventsPath, {
131
134
  type: "plan.item.dropped",
132
135
  runId,
@@ -270,10 +270,35 @@ function isTaskHeartbeatStale(task: TeamTaskState, now: number): boolean {
270
270
  // Compute elapsed from both sources and use the fresher (minimum) one,
271
271
  // mirroring heartbeat-watcher logic: if either source has recent activity,
272
272
  // the task is not stale even if the other is stale.
273
- const heartbeatAge = Number.isFinite(heartbeatAt) ? now - heartbeatAt : Infinity;
274
- const activityAge = Number.isFinite(activityAt) ? now - activityAt : Infinity;
273
+ const heartbeatAge = Number.isFinite(heartbeatAt) ? now - heartbeatAt : Number.MAX_SAFE_INTEGER;
274
+ const activityAge = Number.isFinite(activityAt) ? now - activityAt : Number.MAX_SAFE_INTEGER;
275
275
  const elapsed = Math.min(heartbeatAge, activityAge);
276
- return elapsed > NO_PID_HEARTBEAT_STALE_MS;
276
+ if (elapsed <= NO_PID_HEARTBEAT_STALE_MS) return false;
277
+ // F1 part 2 (battery 2026-09-10, live evidence run team_20260910171307): a
278
+ // surface worker's recorder only flushes at turn boundaries — one slow LLM
279
+ // turn (observed: 6.7min) freezes lastSeenAt/lastActivityAt while the worker
280
+ // process is alive and mid-turn. The heartbeat-watcher already has a
281
+ // PID-liveness gate for exactly this ("alive → downgrade dead→stale"); the
282
+ // reconciler MUST apply the same gate before repairing, or it kills healthy
283
+ // runs at the 5-minute mark mid-turn. A truly dead pid still repairs as before.
284
+ const taskPid = task.heartbeat?.pid ?? task.checkpoint?.childPid;
285
+ const pidAlive = taskPid ? checkProcessLiveness(taskPid).alive : false;
286
+ if (taskPid && pidAlive) return false;
287
+ if (process.env.PI_CREW_DEBUG_STALE === "1") {
288
+ // F1 forensic (battery 2026-09-10): sidecar log of every STALE verdict so
289
+ // live repros can show exactly what the reconciler saw (pid present? alive?
290
+ // elapsed?) — the reconciler may run in ANY host process, hence a fixed
291
+ // sidecar path instead of console.
292
+ try {
293
+ fs.appendFileSync(
294
+ "/tmp/pi-crew-f1-debug.log",
295
+ `${JSON.stringify({ ts: new Date().toISOString(), taskId: task.id, status: task.status, taskPid, pidAlive, heartbeatAge, activityAge, hbLastSeen: task.heartbeat?.lastSeenAt })}\n`,
296
+ );
297
+ } catch {
298
+ /* best-effort forensic log */
299
+ }
300
+ }
301
+ return true;
277
302
  }
278
303
 
279
304
  /**
@@ -22,6 +22,9 @@ export function recordSupervisorContact(manifest: TeamRunManifest, payload: Omit
22
22
  timestamp: new Date().toISOString(),
23
23
  };
24
24
  try {
25
+ // REVIEW FIX (2026-09-10): reverted M2b buffered conversion —
26
+ // supervisor.contact events are read back synchronously (tests +
27
+ // contact-history display); low-frequency by design.
25
28
  appendEvent(manifest.eventsPath, {
26
29
  type: "supervisor.contact",
27
30
  runId: manifest.runId,
@@ -723,6 +723,39 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
723
723
  logInternalError("task-runner.on-json-event", err as Error, `taskId=${task.id}`);
724
724
  }
725
725
  },
726
+ onSurfaceActivity: (event) => {
727
+ // F1 fix (battery 2026-09-10): surface workers have no stdout pipe, so
728
+ // onJsonEvent/onStdoutLine never fire and task liveness state starves.
729
+ // Reuses the SAME throttled persisters as the stdout path:
730
+ // persistHeartbeat (1s) touches lastSeenAt + persists task state;
731
+ // persistChildProgress (coalesced) appends task.progress to the RUN
732
+ // event log - a DIFFERENT file than the per-agent log being tailed, so
733
+ // no watch->append loop. NEVER call appendCrewAgentEventBuffered here:
734
+ // the worker already wrote this event to the tailed file itself.
735
+ try {
736
+ const pidFromStart = event?.type === "worker.started" && typeof event.pid === "number" ? event.pid : undefined;
737
+ if (pidFromStart !== undefined) {
738
+ // Surface pid was previously never persisted into task state - the
739
+ // heartbeat-watcher PID-liveness gate (alive -> downgrade dead->stale)
740
+ // had nothing to check. Pin it once from the worker.started self-report.
741
+ task = {
742
+ ...task,
743
+ heartbeat: touchWorkerHeartbeat(task.heartbeat ?? createWorkerHeartbeat(task.id), {
744
+ pid: pidFromStart,
745
+ }),
746
+ };
747
+ }
748
+ task = {
749
+ ...task,
750
+ agentProgress: applyAgentProgressEvent(task.agentProgress ?? emptyCrewAgentProgress(), event, task.startedAt),
751
+ };
752
+ tasks = updateTask(tasks, task);
753
+ persistHeartbeat();
754
+ persistChildProgress(event);
755
+ } catch (err) {
756
+ logInternalError("task-runner.on-surface-activity", err as Error, `taskId=${task.id}`);
757
+ }
758
+ },
726
759
  });
727
760
  } finally {
728
761
  // R10-5 (Wave 2B item 4): task-boundary flush — land every buffered
@@ -8,7 +8,7 @@ import { appendHookEvent, executeHook } from "../hooks/registry.ts";
8
8
  import type { MetricRegistry } from "../observability/metric-registry.ts";
9
9
  import { atomicWriteFile } from "../state/atomic-write.ts";
10
10
  import { canTransitionRunStatus } from "../state/contracts.ts";
11
- import { appendEvent, appendEventAsync, flushEventLogBuffer } from "../state/event-log/event-log.ts";
11
+ import { appendEvent, appendEventAsync, appendEventBuffered, flushEventLogBuffer } from "../state/event-log/event-log.ts";
12
12
  import { hashArtifactContent as hashContent, writeArtifact } from "../state/stores/artifact-store.ts";
13
13
  import { loadRunManifestById, saveRunManifestAsync, saveRunTasksAsync, updateRunStatus } from "../state/stores/state-store.ts";
14
14
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
@@ -426,7 +426,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
426
426
  );
427
427
  }
428
428
  }
429
- appendEvent(manifest.eventsPath, {
429
+ appendEventBuffered(manifest.eventsPath, {
430
430
  type: "run.goal_achievement",
431
431
  runId: manifest.runId,
432
432
  message: gaApplied.manifest.goalAchievementNote ?? "",
@@ -436,7 +436,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
436
436
  reason: gaAssessment.reason,
437
437
  signals: gaAssessment.signals,
438
438
  },
439
- });
439
+ }).catch((e) => logInternalError("team-runner.buffered", e, "type=run.goal_achievement"));
440
440
  if (gaApplied.downgraded)
441
441
  logInternalError(
442
442
  "team-runner.goalAchievement.falseGreen",
@@ -26,7 +26,7 @@
26
26
  import * as fs from "node:fs";
27
27
  import * as path from "node:path";
28
28
  import { logInternalError } from "../../utils/internal-error.ts";
29
- import { atomicWriteJson, flushPendingAtomicWrites } from "../atomic-write.ts";
29
+ import { atomicWriteJson } from "../atomic-write.ts";
30
30
  import { withRunLockSync } from "../coordination/locks.ts";
31
31
  import type { TeamRunManifest } from "../types.ts";
32
32
 
@@ -100,9 +100,10 @@ export function readOwnershipMap(manifest: TeamRunManifest): OwnershipMapFile {
100
100
  export function upsertOwnershipEntry(manifest: TeamRunManifest, entry: OwnershipEntry): void {
101
101
  try {
102
102
  withRunLockSync(manifest, () => {
103
- // Defeat the atomic-write coalescer's stale-read window: force any
104
- // pending buffered write for THIS file to land before we reload.
105
- flushPendingAtomicWrites(ownershipMapPath(manifest));
103
+ // REVIEW FIX (2026-09-10): reverted WI-2.2's coalesced conversion (and
104
+ // its flush-before-reload guard). The map is small, written at spawn /
105
+ // steer frequency (not a hot loop), and round-trip readers expect
106
+ // read-your-writes immediately after upsert returns.
106
107
  const fresh = readOwnershipMap(manifest);
107
108
  const prev = fresh.entries[entry.taskId] ?? {};
108
109
  const merged: OwnershipEntry = {
@@ -74,6 +74,13 @@ export function getCurrentPlanRecord(manifest: TeamRunManifest): PlanRecord | un
74
74
  function writePlanFile(manifest: TeamRunManifest, revisions: PlanRecord[]): void {
75
75
  const file: PlanFile = { version: 1, revisions };
76
76
  fs.mkdirSync(path.dirname(planFilePath(manifest)), { recursive: true });
77
+ // REVIEW FIX (2026-09-10): reverted WI-2.2's coalesced conversion.
78
+ // `loadPlanRecords` is the PUBLIC read API and every caller expects
79
+ // read-your-writes (getCurrentPlanRecord, plan-replan, handleStatus); a
80
+ // 50ms coalesce window made the mutators' own reload-inside-lock read
81
+ // stale state (lost-update) and broke plan-store.test.ts 11/15. Plan
82
+ // writes are low-frequency (revision append / linkage / approval) — the
83
+ // coalesce win is negligible. Same exclusion rule as status.ts A2/A4.
77
84
  atomicWriteJson(planFilePath(manifest), file);
78
85
  }
79
86
 
@@ -141,6 +148,10 @@ export function appendPlanRevision(manifest: TeamRunManifest, record: PlanRecord
141
148
  revisions.push(record);
142
149
  writePlanFile(manifest, revisions);
143
150
  const dropped = record.items.filter((i) => i.status === "dropped").length;
151
+ // REVIEW FIX (2026-09-10): reverted M2b buffered conversion — plan lifecycle
152
+ // events are low-frequency and consumers (plan-store tests, status display)
153
+ // expect read-your-writes; also removes the policy-A catch (sync errors
154
+ // propagate to the producer inside the lock, as at base).
144
155
  appendEvent(manifest.eventsPath, {
145
156
  type: record.version === 1 ? "plan.created" : "plan.revised",
146
157
  runId: manifest.runId,
@@ -199,6 +210,7 @@ export function setPlanApproval(
199
210
  // ADR-4 §9: events for approval MUTATIONS. `pending` emits none — the
200
211
  // request surface (ensurePlanApprovalRequested) appends its own
201
212
  // plan.approval_required event.
213
+ // REVIEW FIX (2026-09-10): reverted M2b buffered conversion — see above.
202
214
  if (approval.status !== "pending") {
203
215
  appendEvent(manifest.eventsPath, {
204
216
  type: approval.status === "approved" ? "plan.approved" : "plan.rejected",
@@ -502,6 +502,11 @@ export function saveRunManifest(manifest: TeamRunManifest): void {
502
502
  // which is always safe.
503
503
  invalidateRunCache(manifest.stateRoot);
504
504
  const manifestPath = path.join(manifest.stateRoot, "manifest.json");
505
+ // REVIEW FIX (2026-09-10): reverted WI-2.2's coalesced conversion —
506
+ // saveRunManifest is a SYNCHRONOUS persist by name/contract (tests assert
507
+ // it, broker loadRunManifestById is a cross-process reader, and the
508
+ // statSync-based cache repopulation below needs the real post-write
509
+ // mtime/size). The 50ms coalesce window broke all three.
505
510
  atomicWriteJson(manifestPath, manifest);
506
511
  // FIX: Re-populate cache with actual mtime/size so loadRunManifestById
507
512
  // doesn't miss the cache on next read. Without this, every load until
@@ -41,13 +41,9 @@ function hasPowerbarConsumer(events: EventBus): boolean {
41
41
  }
42
42
  }
43
43
 
44
- function setStatusFallback(ctx: StatusContext, text: string | undefined): void {
45
- try {
46
- if (ctx?.hasUI) ctx.ui?.setStatus?.("pi-crew", text);
47
- } catch (error) {
48
- logInternalError("powerbar.statusFallback", error);
49
- }
50
- }
44
+ // setStatusFallback removed (WI-5.1 / docs/decisions/2026-09-10): 0 callers,
45
+ // function was deliberately-unused; crew-widget owns the "pi-crew" status
46
+ // bar surface (see test/unit/ui/powerbar-publisher.test.ts:227).
51
47
 
52
48
  function safeEmit(events: EventBus, event: string, data: unknown): void {
53
49
  try {
@@ -3,25 +3,22 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import type { handleTeamTool as HandleTeamToolFn } from "../extension/team-tool.ts";
4
4
  import type { MetricRegistry } from "../observability/metric-registry.ts";
5
5
 
6
- let _cachedHandleTeamTool: typeof HandleTeamToolFn | undefined;
7
6
  async function handleTeamTool(
8
7
  params: Parameters<typeof HandleTeamToolFn>[0],
9
8
  ctx: Parameters<typeof HandleTeamToolFn>[1],
10
9
  ): Promise<Awaited<ReturnType<typeof HandleTeamToolFn>>> {
11
- if (!_cachedHandleTeamTool) {
12
- // LAZY: avoid pulling team-tool.ts (and its entire runtime chain) into module load.
13
- const mod = await import("../extension/team-tool.ts");
14
- _cachedHandleTeamTool = mod.handleTeamTool;
15
- }
16
- return _cachedHandleTeamTool(params, ctx);
10
+ // LAZY: avoid pulling team-tool.ts (and its entire runtime chain) into module load.
11
+ const mod = await import("../extension/team-tool.ts");
12
+ return mod.handleTeamTool(params, ctx);
17
13
  }
18
14
 
19
15
  import { isToolError, textFromToolResult } from "../extension/tool-result.ts";
20
16
  import { readCrewAgents } from "../runtime/crew-agent-records.ts";
21
17
  import { exportDiagnostic } from "../runtime/diagnostic-export.ts";
22
18
  import type { MailboxDirection, MailboxMessage } from "../state/coordination/mailbox.ts";
23
- import { appendEvent } from "../state/event-log/event-log.ts";
19
+ import { appendEventBuffered } from "../state/event-log/event-log.ts";
24
20
  import { loadRunManifestById, saveRunTasks } from "../state/stores/state-store.ts";
21
+ import { logInternalError } from "../utils/internal-error.ts";
25
22
 
26
23
  export interface RunActionResult {
27
24
  ok: boolean;
@@ -141,12 +138,12 @@ export async function dispatchKillStaleWorkers(ctx: ExtensionContext, runId: str
141
138
  };
142
139
  });
143
140
  saveRunTasks(loaded.manifest, tasks);
144
- appendEvent(loaded.manifest.eventsPath, {
141
+ appendEventBuffered(loaded.manifest.eventsPath, {
145
142
  type: "worker.kill_stale",
146
143
  runId,
147
144
  message: `Marked ${count} stale worker heartbeat(s) dead.`,
148
145
  data: { count },
149
- });
146
+ }).catch((e) => logInternalError("run_action_dispatcher.buffered", e, "type=worker.kill_stale"));
150
147
  return {
151
148
  ok: true,
152
149
  message: `Marked ${count} stale worker heartbeat(s) dead.`,
@@ -345,7 +345,10 @@ const EFFECTIVE_DEFAULTS: Record<string, unknown> = {
345
345
  "ui.dashboardPlacement": "center",
346
346
  "ui.dashboardWidth": 72,
347
347
  "ui.autoOpenDashboard": false,
348
- "ui.widgetPlacement": "aboveEditor",
348
+ // G17 sync (2026-09-10 review): canonical DEFAULT_UI.widgetPlacement =
349
+ // "bottom" (defaults.ts / install.mjs / project-init) — was drifted
350
+ // "aboveEditor" in this duplicated EFFECTIVE_DEFAULTS map.
351
+ "ui.widgetPlacement": "bottom",
349
352
  "autonomous.enabled": true,
350
353
  "autonomous.injectPolicy": true,
351
354
  "autonomous.preferAsyncForLongTasks": false,