taskplane 0.30.5 → 0.30.6

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.
@@ -202,7 +202,15 @@ merge_health_stuck) are also written here when merge agents stall or die.
202
202
 
203
203
  **Audit trail:** `.pi/supervisor/actions.jsonl`
204
204
 
205
- Every recovery action you take is logged here as JSONL. Destructive actions
205
+ Every recovery action you take is logged here as JSONL **always via the
206
+ `log_recovery_action` tool**, which stamps `ts` and `batchId` in code. This
207
+ includes **hand-remediation**: any hot-fix commit, review-file ratification,
208
+ or manual state repair you perform under an operator ruling is a recovery
209
+ action — log it (`classification: "destructive"`, `command` = the commit sha
210
+ or file written, `context` = the ruling). The runtime has no other record of
211
+ hand edits. Never
212
+ append to this file by hand (your clock is unreliable; hand-written entries
213
+ carry fabricated timestamps). Destructive actions
206
214
  must be logged *before* execution (with result="pending"), then again after
207
215
  (with actual result). This file is read during takeover rehydration.
208
216
 
@@ -485,6 +493,57 @@ grep -c "^<<<<<<<" {file} # count conflicts per file
485
493
  to take effect. Alternatively, you (the supervisor) can read the config file
486
494
  directly and apply the relevant value when executing recovery.
487
495
 
496
+ ### Pattern 9: You Inherited an "executing" Batch (Replacement Supervisor)
497
+
498
+ **Symptom:** You took over the supervisor lock (previous session died, wedged,
499
+ or was replaced). `orch_status` says the batch is `executing`, but nothing is
500
+ happening: the registry's `updatedAt` is frozen, `orch_pause` is accepted but
501
+ inert, workers may be dead but still marked `running`.
502
+
503
+ **Cause:** The engine is a forked child of the *previous* supervisor's
504
+ process. Your session has no engine attached. Persisted `executing` means
505
+ "the orchestrator disconnected mid-batch" — which `orch_resume` is designed
506
+ to recover — but only once it is VERIFIED that the old engine is gone (a dead
507
+ supervisor pid does not prove a dead engine).
508
+
509
+ **What the runtime does for you (#631):**
510
+ - The takeover summary prints an **Engine:** line from
511
+ `.pi/runtime/<batchId>/engine.json`: `alive` (pid), `dead`/`exited`, or no
512
+ identity recorded. It also lists **dead agents still marked running** — do
513
+ NOT hand-edit `registry.json` for those; resume reconciles them.
514
+ - An orphaned engine pauses itself when its supervisor disconnects (finishes
515
+ in-flight lanes, persists `paused`, exits). Give it a moment.
516
+ - `orch_resume`, `orch_retry_task`, `orch_skip_task`, `orch_force_merge`
517
+ decide from that evidence: engine **dead/exited** → proceed via the normal
518
+ persisted-state eligibility; engine **alive** → refuse and name the pid
519
+ (`force` does NOT bypass this — double-driving corrupts state). The check
520
+ runs against the PERSISTED batch regardless of what phase you have cached.
521
+ - **No identity recorded** (pre-#631 engine) → the tools **refuse**: unknown
522
+ ownership is not confirmed shutdown, and a dead supervisor pid does not prove
523
+ a dead engine. Verify out-of-band that no engine process exists
524
+ (`Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match
525
+ "engine-worker" }` / `pgrep -af engine-worker`), then record it with
526
+ `orch_confirm_engine_shutdown(note)` — written to `engine.json` and the
527
+ audit trail — and re-run the tool. Never use it to override a refusal that
528
+ names a LIVE pid.
529
+ - `orch_pause` with no engine attached performs an **administrative pause**
530
+ (persists `phase: paused` on disk) when the engine is confirmed gone. This is
531
+ the non-destructive stop; you never need to hand-edit `batch-state.json`.
532
+
533
+ **Recovery:**
534
+ 1. Read the takeover summary's Engine line.
535
+ 2. Engine alive → wait for it to wind down (or, if the operator agrees, terminate
536
+ that pid explicitly) and re-check.
537
+ 3. Engine dead/exited → `orch_resume(force=true)`. Dead workers reconcile as
538
+ `re-execute` in their existing worktrees; committed work survives. Any
539
+ worker still alive is terminated with VERIFICATION (SIGTERM → wait →
540
+ SIGKILL → wait) before its lane re-executes; if termination cannot be
541
+ confirmed the task fails with that reason instead of running two agents in
542
+ one worktree.
543
+ 3b. No identity → verify, `orch_confirm_engine_shutdown(note)`, then step 3.
544
+ 4. Never `supervisor_takeover` or `orch_abort` here — both are destructive for
545
+ an inherited paused/held lane (see #628).
546
+
488
547
  ---
489
548
 
490
549
  ## 8. Batch State Editing Guide
@@ -701,7 +760,9 @@ When you're unsure:
701
760
  - Good for overnight/unattended batches
702
761
  - The operator trusts you to make reasonable decisions
703
762
 
704
- In ALL modes, you log every action to the audit trail.
763
+ In ALL modes, you log every action to the audit trail via the
764
+ `log_recovery_action` tool (never a hand-written append — timestamps must be
765
+ code-stamped).
705
766
 
706
767
  ---
707
768
 
@@ -719,6 +780,7 @@ or check status manually. The engine wakes you up when you're needed.
719
780
  | `merge-failure` | ⚠️ | Wave merge failed and batch paused |
720
781
  | `batch-complete` | ✅/⚠️ | Batch finished (all waves done, with or without failures) |
721
782
  | `worker-exit-intercept` | 🔄 | A worker exited without making progress — session still alive, awaiting instructions |
783
+ | `review-intervention-needed` | 🌀/⛔ | A step's reviews are spiraling (repeated non-approve) or the worker tripped the order-of-operations guard — adjudicate per **Playbook D** |
722
784
 
723
785
  ### Alert Format
724
786
 
@@ -782,6 +844,8 @@ If the batch is actively running, call `orch_pause()` first.
782
844
  - `read_agent_status(lane?)` — Read STATUS.md + telemetry for a lane (step, progress, context %, cost, elapsed). Omit lane for all lanes.
783
845
  - `trigger_wrap_up(lane)` — Write `.task-wrap-up` signal to gracefully stop a worker on a lane.
784
846
  - `read_lane_logs(lane)` — Read stderr/crash logs and exit diagnostics for a lane.
847
+ - `log_recovery_action(action, classification, context, command, result, detail, …)` — Append an audit-trail entry (ts/batchId code-stamped). The ONLY correct way to write `actions.jsonl`.
848
+ - `orch_confirm_engine_shutdown(note, batchId?)` — Record operator-verified engine shutdown for an inherited batch with NO engine identity (#631). Unblocks resume/retry/skip/force_merge through the verified path; refuses when a real engine identity exists. Audited.
785
849
  - `list_active_agents()` — List active worker/reviewer/merge agents with role, lane, task, context %, elapsed, cost.
786
850
 
787
851
  Plus general tools: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`
@@ -794,6 +858,37 @@ receive a critical alert with category `task-failure` and a 🔴 emoji. These
794
858
  indicate an infrastructure-level failure, not a task-level failure. Recovery
795
859
  typically requires `orch_resume(force=true)` after checking batch state.
796
860
 
861
+ ### Review-Boundary Notifications (Active Adjudication)
862
+
863
+ Beyond the alerts above, you are notified at **every review boundary** — when a
864
+ worker's `review_step` tool starts and completes. These are informational
865
+ (delivered follow-up, they queue to your next turn), but they exist so you can
866
+ adjudicate reviews **case by case** instead of waiting for a spiral to fully
867
+ form. Each review-completed notification carries:
868
+
869
+ - **Disposition** — `APPROVE` / `REVISE` / `RETHINK` / `REFUSED` / `UNAVAILABLE`.
870
+ - **Round** — how many times this step has been reviewed (e.g. `round 4`).
871
+ - **Findings + trend** (when the reviewer emits an `Issues Found` section) —
872
+ counts by severity (the project's configured `severityLabels`, e.g.
873
+ critical/important/minor or P0/P1/P2) and a **trend**: `dropping` (converging —
874
+ severity falling round over round), `flat`, or `rising`, plus a `mixed` flag
875
+ when severities move in opposite directions.
876
+
877
+ **Reading the signal — converging vs circling (the core judgment):**
878
+
879
+ - **Let it run:** disposition `REVISE`/`RETHINK` but trend `dropping` — the
880
+ worker is making the reviewer progressively happier; this is healthy
881
+ deepening. Do not interfere.
882
+ - **Intervene:** trend `flat`/`rising` across rounds, or the same finding class
883
+ recurring — the loop is circling, not converging. Adjudicate (see Playbook D).
884
+
885
+ When the deterministic threshold is crossed (default **3 consecutive
886
+ non-approve on the same step**) you additionally get an **urgent
887
+ `review-intervention-needed` alert** delivered as a *steer* — it interrupts your
888
+ current turn. Act on it per Playbook D. `REFUSED` (order-of-operations
889
+ violation) and `UNAVAILABLE` (broken reviewer) are surfaced as distinct signals,
890
+ not counted toward the revision spiral.
891
+
797
892
  ---
798
893
 
799
894
  ## 13b. Recovery Playbooks (TP-078)
@@ -997,6 +1092,91 @@ BATCH COMPLETE: {batchId}
997
1092
  │ → Suggest: orch_integrate()
998
1093
  ```
999
1094
 
1095
+ ### Playbook D: Review Spiral / Adjudication
1096
+
1097
+ **Trigger:** a `review-intervention-needed` alert, OR your own read of the
1098
+ review-boundary notifications (see "Review-Boundary Notifications" in §13a).
1099
+ Alert context includes `reviewInterventionKind` (`revision-spiral` |
1100
+ `order-violation`), `taskId`, `reviewStep`, `laneNumber`, `agentId`,
1101
+ `disposition`, `recentDispositions`, `consecutiveNonApprove`, `reviewRound`,
1102
+ `findingCounts`, `findingTrend`.
1103
+
1104
+ ```
1105
+ REVIEW INTERVENTION: {taskId} step {reviewStep} (lane {laneNumber})
1106
+
1107
+ ├─ kind = "order-violation" (disposition REFUSED)
1108
+ │ The worker marked the step complete BEFORE code review ran.
1109
+ │ → Steer the worker (send_agent_message to {agentId}) to:
1110
+ │ 1. Revert the step's Status to In Progress in STATUS.md
1111
+ │ 2. Re-run review_step for that step, THEN mark it complete
1112
+ │ → Report: "Order-of-operations violation on {taskId} step {N} —
1113
+ │ instructed the worker to revert and re-review."
1114
+
1115
+ └─ kind = "revision-spiral" (3+ consecutive non-approve on the same step)
1116
+
1117
+ ├─ 1. Read findingTrend + recentDispositions in the alert:
1118
+ │ │
1119
+ │ ├─ trend = "dropping" (CONVERGING)
1120
+ │ │ → Usually LET IT RUN one or two more rounds — the worker is
1121
+ │ │ resolving real findings and severity is falling. Only step in
1122
+ │ │ if the round count is very high (diminishing returns).
1123
+ │ │
1124
+ │ └─ trend = "flat" / "rising" (CIRCLING)
1125
+ │ │
1126
+ │ ├─ 2. Read the latest review file
1127
+ │ │ (.reviews/R{NNN}-{type}-step{N}.md) and the worker's
1128
+ │ │ STATUS.md to judge WHY it's stuck:
1129
+ │ │ │
1130
+ │ │ ├─ Findings are legitimate but the worker keeps missing
1131
+ │ │ │ them → steer with CONCRETE, specific instructions on
1132
+ │ │ │ exactly what to implement (quote the finding).
1133
+ │ │ │
1134
+ │ │ ├─ Findings are subjective / diminishing returns / the
1135
+ │ │ │ reviewer is over-strict → tell the worker the step is
1136
+ │ │ │ good enough; instruct it to proceed.
1137
+ │ │ │
1138
+ │ │ └─ The task is genuinely too hard / underspecified →
1139
+ │ │ tell the worker to STOP, log a clear blocker in
1140
+ │ │ STATUS.md, and exit. Then escalate to the operator
1141
+ │ │ with the blocker text.
1142
+ │ │
1143
+ │ └─ 3. Report your decision AND why (cite the trend + round,
1144
+ │ e.g. "step 4 at round 6, criticals flat — steering the
1145
+ │ worker to implement the two outstanding findings").
1146
+ ```
1147
+
1148
+ **Worker on HOLD for a ruling (#630 Tier-1 contract).** A worker that escalated
1149
+ and is waiting exits its turn; the runtime relaunches it (bounded, 3) with a
1150
+ hold-resume prompt instead of failing it as a stall. Your messages to it have
1151
+ two meanings, chosen by `send_agent_message` **type**:
1152
+
1153
+ - `type="info"` → **acknowledgement** ("received, ruling pending; expect ~N
1154
+ hours"). The worker stays on hold; its relaunch budget resets. Use this for
1155
+ any ruling that will take a while so the task does not fail as
1156
+ `Hold unresolved` before the ruling exists.
1157
+ - `type="steer"` (default) → **the ruling / instruction**. Releases the hold;
1158
+ the worker acts on it.
1159
+
1160
+ If neither arrives within 3 relaunches the task fails with `Hold unresolved`
1161
+ (work preserved in the worktree); after ruling, `orch_retry_task` +
1162
+ `orch_resume(force=true)`.
1163
+
1164
+ **kind = "unresolved-verdict"** (finalize refused): the task tried to complete
1165
+ while some gate's LATEST review file still reads REVISE/RETHINK — the runtime
1166
+ refused `.DONE` and marked the task failed instead of letting it merge
1167
+ unreviewed. Adjudicate: have the worker address the findings and re-run
1168
+ `review_step` (then `orch_retry_task` + `orch_resume(force=true)`), or — for an
1169
+ operator-ratified override — record the ruling as the next R-numbered review
1170
+ file with an explicit APPROVE verdict, then retry the task.
1171
+
1172
+ Steer the worker with `send_agent_message(to, content)` using the `agentId`
1173
+ from the alert context. **Your judgment IS the adjudication** — the goal is to
1174
+ keep the task converging on the project's real goals, not to let a review loop
1175
+ burn indefinitely nor to rubber-stamp incomplete work. Log the decision to the
1176
+ audit trail. Re-escalations are trend-gated (you won't be nagged while a spiral
1177
+ is converging), so a fresh `review-intervention-needed` after you've acted means
1178
+ it is still NOT converging — consider a firmer intervention (stop + blocker).
1179
+
1000
1180
  ### Quick Reference: Recovery Action Matrix
1001
1181
 
1002
1182
  | Alert | Diagnosis | Action | Autonomy |
@@ -50,6 +50,12 @@ import {
50
50
  rename as fsRename,
51
51
  } from "fs/promises";
52
52
  import { execFileSync } from "child_process";
53
+ import { assessEngineLiveness } from "./engine-identity.ts";
54
+ import {
55
+ isProcessAlive as registryIsProcessAlive,
56
+ isTerminalStatus as registryIsTerminalStatus,
57
+ readRegistrySnapshot,
58
+ } from "./process-registry.ts";
53
59
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
54
60
  import type { Model, Api } from "@mariozechner/pi-ai";
55
61
  import type {
@@ -2383,6 +2389,15 @@ Use these to:
2383
2389
  issue using the patterns in supervisor-primer.md and take appropriate
2384
2390
  recovery action based on your autonomy level (${autonomyLabel}).
2385
2391
 
2392
+ 2a. **Adjudicate reviews.** You are notified at every review boundary, and get
2393
+ an urgent (steer) \`review-intervention-needed\` alert when a step's reviews
2394
+ spiral (repeated non-approve) or a worker trips the order-of-operations guard.
2395
+ Actively adjudicate — don't just relay to the operator. Use the finding
2396
+ **trend** to tell converging (\`dropping\` — let it run) from circling
2397
+ (\`flat\`/\`rising\` — intervene), then steer the worker to a resolution
2398
+ (implement the remaining valid findings, or stop and log a blocker) via
2399
+ \`send_agent_message\`. Follow **Playbook D** in supervisor-primer.md.
2400
+
2386
2401
  3. **Keep the operator informed.** Provide clear, natural status updates.
2387
2402
  When the operator asks "how's it going?" — read batch state and summarize.
2388
2403
 
@@ -2430,24 +2445,23 @@ ${autonomyGuidance}
2430
2445
 
2431
2446
  ## Audit Trail
2432
2447
 
2433
- Log every recovery action to \`${actionsPath}\` as a single-line JSON entry.
2448
+ Log every recovery action with the **\`log_recovery_action\` tool** it appends
2449
+ to \`${actionsPath}\` with a **code-stamped timestamp and batchId**.
2434
2450
 
2435
- **Format** (one JSON object per line):
2436
- \`\`\`json
2437
- {"ts":"<ISO 8601>","action":"<action_name>","classification":"<diagnostic|tier0_known|destructive>","context":"<why>","command":"<what>","result":"<pending|success|failure|skipped>","detail":"<outcome>","batchId":"${batchState.batchId || "BATCH_ID"}"}
2438
- \`\`\`
2451
+ **NEVER hand-write \`actions.jsonl\`** (no bash \`echo >>\`): you have no reliable
2452
+ clock, so hand-written entries carry fabricated timestamps and break the audit
2453
+ trail's integrity as evidence.
2439
2454
 
2440
2455
  **Rules:**
2441
- 1. For **destructive** actions: write a "pending" entry BEFORE executing, then
2442
- write a result entry AFTER with "success" or "failure" and detail.
2443
- 2. For **diagnostic** and **tier0_known** actions: write a single result entry
2444
- AFTER execution.
2445
- 3. Include optional fields when relevant: \`waveIndex\`, \`laneNumber\`, \`taskId\`, \`durationMs\`.
2446
- 4. Use the \`bash\` tool to append entries. Example:
2447
- \`echo '{"ts":"...","action":"merge_retry","classification":"tier0_known","context":"merge timeout on wave 2","command":"git merge --no-ff task/lane-2","result":"success","detail":"merged with 0 conflicts","batchId":"..."}' >> ${actionsPath}\`
2456
+ 1. For **destructive** actions: call \`log_recovery_action(..., result="pending")\`
2457
+ BEFORE executing, then call again AFTER with \`"success"\` or \`"failure"\` and detail.
2458
+ 2. For **diagnostic** and **tier0_known** actions: one call AFTER execution.
2459
+ 3. Include \`waveIndex\`, \`laneNumber\`, \`taskId\` when relevant.
2460
+ 4. Stick to the schema fields do not invent ad-hoc field names.
2448
2461
 
2449
2462
  **Why this matters:** When you're taken over by another session or the operator
2450
- asks "what did you do?", the audit trail is the definitive record.
2463
+ asks "what did you do?", the audit trail is the definitive record — and its
2464
+ timestamps are only trustworthy because code stamps them.
2451
2465
 
2452
2466
  ## Operational Knowledge
2453
2467
 
@@ -3563,8 +3577,12 @@ export function isProcessAlive(pid: number): boolean {
3563
3577
  try {
3564
3578
  process.kill(pid, 0);
3565
3579
  return true;
3566
- } catch {
3567
- return false;
3580
+ } catch (err: unknown) {
3581
+ // #631: only ESRCH (no such process) is "dead". EPERM = exists without
3582
+ // signal permission; unknown errors fail closed (alive) — this feeds the
3583
+ // lock-takeover ownership decision.
3584
+ const code = (err as { code?: string } | null)?.code;
3585
+ return code !== "ESRCH";
3568
3586
  }
3569
3587
  }
3570
3588
 
@@ -3697,6 +3715,64 @@ export function buildTakeoverSummary(stateRoot: string, batchState: PersistedBat
3697
3715
  `**Tasks:** ${succeeded} succeeded, ${failed} failed, ${running} running, ${pending} pending`,
3698
3716
  );
3699
3717
 
3718
+ // #631: ownership evidence — is the previous ENGINE still running? A dead
3719
+ // supervisor pid does not imply a dead engine (forked child). This line is
3720
+ // what tells the replacement operator whether orch_resume can proceed.
3721
+ const activePhase =
3722
+ batchState.phase === "executing" ||
3723
+ batchState.phase === "launching" ||
3724
+ batchState.phase === "merging" ||
3725
+ batchState.phase === "planning";
3726
+ const liveness = assessEngineLiveness(stateRoot, batchState.batchId);
3727
+ if (liveness.identity?.taskplaneBuild) {
3728
+ lines.push(
3729
+ `**Build:** taskplane ${liveness.identity.taskplaneVersion ?? "?"} (build ${liveness.identity.taskplaneBuild}) drove this batch`,
3730
+ );
3731
+ }
3732
+ if (liveness.status === "alive") {
3733
+ lines.push(
3734
+ `**Engine:** ⚠️ PID ${liveness.identity!.pid} is still ALIVE (forked by supervisor PID ${liveness.identity!.supervisorPid}). ` +
3735
+ `This session has no engine attached; recovery tools will refuse until it exits (it pauses itself on supervisor disconnect) or is terminated.`,
3736
+ );
3737
+ } else if (liveness.status === "dead" || liveness.status === "exited") {
3738
+ lines.push(
3739
+ `**Engine:** PID ${liveness.identity!.pid} is ${liveness.status}${liveness.identity!.exitReason ? ` (${liveness.identity!.exitReason})` : ""}` +
3740
+ (activePhase
3741
+ ? ` — persisted phase "${batchState.phase}" is an orphan; orch_resume(force=true) reconciles and re-drives it.`
3742
+ : "."),
3743
+ );
3744
+ } else if (activePhase) {
3745
+ lines.push(
3746
+ `**Engine:** no identity recorded for this batch (pre-#631 engine or never forked). Recovery tools decide from the previous supervisor's liveness.`,
3747
+ );
3748
+ }
3749
+
3750
+ // #631/#630: workers the registry still calls "running" whose process is gone.
3751
+ // Operators should NOT hand-edit registry.json for these — resume's liveness
3752
+ // check (`!terminal && isProcessAlive(pid)`) already treats them as dead.
3753
+ try {
3754
+ const registry = readRegistrySnapshot(stateRoot, batchState.batchId);
3755
+ if (registry) {
3756
+ const deadRunning = Object.values(registry.agents).filter(
3757
+ (m) => !registryIsTerminalStatus(m.status) && !registryIsProcessAlive(m.pid),
3758
+ );
3759
+ if (deadRunning.length > 0) {
3760
+ lines.push("");
3761
+ lines.push(
3762
+ `**Dead agents still marked ${deadRunning[0].status} in the registry** (${deadRunning.length}):`,
3763
+ );
3764
+ for (const m of deadRunning) {
3765
+ lines.push(
3766
+ ` - ${m.agentId} (${m.role}${m.taskId ? `, ${m.taskId}` : ""}) PID ${m.pid} — process gone; registry last updated ${new Date(registry.updatedAt).toISOString()}. ` +
3767
+ `No registry edit needed: orch_resume reconciles it (re-execute in the existing worktree).`,
3768
+ );
3769
+ }
3770
+ }
3771
+ }
3772
+ } catch {
3773
+ /* best effort */
3774
+ }
3775
+
3700
3776
  // Recent actions from audit trail (using readAuditTrail helper)
3701
3777
  const recentActions = readAuditTrail(stateRoot, { limit: 5 });
3702
3778
  if (recentActions.length > 0) {
@@ -3832,6 +3908,56 @@ export function safeSendMessageFromTimer(
3832
3908
  }
3833
3909
  }
3834
3910
 
3911
+ /**
3912
+ * Stale-safe wrapper for a UI / side-effect call that touches a possibly-stale
3913
+ * `ExtensionContext` or `ExtensionAPI` from a long-lived ASYNC callback —
3914
+ * engine-worker IPC handlers (`child.on("message"|"error"|"exit")`), widget
3915
+ * refresh, the batch-end epilogue, and supervisor-alert delivery (#620).
3916
+ *
3917
+ * Pi invalidates captured `ctx`/`pi` handles on session replacement/reload, and
3918
+ * also at the end of headless `-p` runs while the forked engine worker is still
3919
+ * emitting IPC. Every `ctx` accessor (`ctx.ui`, `ctx.isIdle()`, …) and the
3920
+ * `pi.send*` methods then call Pi's `assertActive`, which throws
3921
+ * `"This extension ctx is stale after session replacement or reload"`. Such a
3922
+ * throw inside a `child_process` / EventEmitter callback is an
3923
+ * `uncaughtException` that kills the supervising Pi process.
3924
+ *
3925
+ * Return value semantics (to prevent caller misuse):
3926
+ * - `false` → STALE only. The session is gone; caller should skip any
3927
+ * further UI work for this event.
3928
+ * - `true` → success OR a non-stale failure that was logged. NOT a
3929
+ * success-only signal — a `true` may mean "logged and continued".
3930
+ *
3931
+ * Any non-stale error is logged (so genuine failures still surface in
3932
+ * stderr/telemetry) but is deliberately NOT rethrown:
3933
+ * rethrowing from an IPC/EventEmitter callback would re-introduce the exact
3934
+ * process-fatal crash class this guards against. This mirrors the proven #597
3935
+ * `safeSendMessageFromTimer` contract, generalized to any thunk so it can wrap
3936
+ * `ctx.ui.notify`, `ctx.ui.setWidget`, and `pi.sendUserMessage` alike.
3937
+ *
3938
+ * @since #620
3939
+ */
3940
+ export function safeCtxCallFromCallback(fn: () => void, label = "ui"): boolean {
3941
+ try {
3942
+ fn();
3943
+ return true;
3944
+ } catch (err) {
3945
+ if (isStaleExtensionCtx(err)) {
3946
+ // Pi replaced/ended the session — no live UI sink. Skip, never crash.
3947
+ return false;
3948
+ }
3949
+ // Not stale: surface it (so real failures are visible) but do not rethrow,
3950
+ // because a throw from an async IPC callback is a process-fatal uncaught
3951
+ // exception — the very failure mode #620 fixes.
3952
+ console.error(
3953
+ `[taskplane] ${label} call from async callback threw (non-stale): ${
3954
+ err instanceof Error ? (err.stack ?? err.message) : String(err)
3955
+ }`,
3956
+ );
3957
+ return true;
3958
+ }
3959
+ }
3960
+
3835
3961
  export function startHeartbeat(
3836
3962
  stateRoot: string,
3837
3963
  state: SupervisorState,
@@ -3983,6 +4109,18 @@ interface ParsedEvent {
3983
4109
  suggestion?: string;
3984
4110
  affectedTaskIds?: string[];
3985
4111
  message?: string;
4112
+ // ── Review-boundary optional fields ──────────────────────────
4113
+ agentId?: string;
4114
+ reviewStep?: number;
4115
+ reviewType?: string;
4116
+ disposition?: string;
4117
+ reviewRound?: number;
4118
+ reviewLabel?: string;
4119
+ reviewPath?: string;
4120
+ findingCounts?: Record<string, number>;
4121
+ findingTrend?: "dropping" | "flat" | "rising";
4122
+ findingDeltas?: Record<string, number>;
4123
+ findingMixed?: boolean;
3986
4124
  }
3987
4125
 
3988
4126
  /**
@@ -4007,6 +4145,11 @@ const SIGNIFICANT_EVENT_TYPES = new Set<UnifiedEventType>([
4007
4145
  "batch_complete",
4008
4146
  "batch_paused",
4009
4147
  "tier0_escalation",
4148
+ // Review boundaries: surfaced at EVERY start/end so the supervisor can
4149
+ // adjudicate each revision case-by-case (not coalesced into digests).
4150
+ "review_started",
4151
+ "review_completed",
4152
+ "review_failed",
4010
4153
  ]);
4011
4154
 
4012
4155
  /**
@@ -4248,6 +4391,38 @@ export function parseJsonlLines(data: string, partialLine: string): [ParsedEvent
4248
4391
  *
4249
4392
  * @since TP-041
4250
4393
  */
4394
+ /**
4395
+ * Compact "where" descriptor for a review-boundary notification: task, step,
4396
+ * and lane so the supervisor can address the right worker when adjudicating.
4397
+ */
4398
+ function reviewLocation(event: ParsedEvent): string {
4399
+ const parts: string[] = [];
4400
+ if (event.taskId) parts.push(`task ${event.taskId}`);
4401
+ if (typeof event.reviewStep === "number") parts.push(`step ${event.reviewStep}`);
4402
+ if (typeof event.laneNumber === "number") parts.push(`lane ${event.laneNumber}`);
4403
+ return parts.length > 0 ? parts.join(", ") : "a step";
4404
+ }
4405
+
4406
+ /**
4407
+ * Compact adjudication signals for a review notification: round, finding counts,
4408
+ * and severity trend — the three signals an adjudicating supervisor uses to tell
4409
+ * "converging (let it run)" from "circling (intervene)" at a glance.
4410
+ */
4411
+ function reviewSignals(event: ParsedEvent): string {
4412
+ const bits: string[] = [];
4413
+ if (typeof event.reviewRound === "number") bits.push(`round ${event.reviewRound}`);
4414
+ if (event.findingCounts && Object.keys(event.findingCounts).length > 0) {
4415
+ const counts = Object.entries(event.findingCounts)
4416
+ .map(([k, v]) => `${k}:${v}`)
4417
+ .join(" ");
4418
+ const trend = event.findingTrend
4419
+ ? `, trend ${event.findingTrend}${event.findingMixed ? " (mixed)" : ""}`
4420
+ : "";
4421
+ bits.push(`findings ${counts}${trend}`);
4422
+ }
4423
+ return bits.length > 0 ? ` [${bits.join("; ")}]` : "";
4424
+ }
4425
+
4251
4426
  export function formatEventNotification(
4252
4427
  event: ParsedEvent,
4253
4428
  autonomy: SupervisorAutonomyLevel,
@@ -4293,6 +4468,31 @@ export function formatEventNotification(
4293
4468
  const mins = event.stalledMinutes ?? "?";
4294
4469
  return `🔒 Merge agent on lane ${lane} appears stuck (no output for ${mins} min). Consider killing and retrying.`;
4295
4470
  }
4471
+ case "review_started": {
4472
+ const loc = reviewLocation(event);
4473
+ const typeLabel = event.reviewType ? `${event.reviewType} ` : "";
4474
+ return `🔍 **Review starting** — ${typeLabel}review of ${loc}.`;
4475
+ }
4476
+ case "review_completed": {
4477
+ const loc = reviewLocation(event);
4478
+ const disp = (event.disposition || "UNKNOWN").toUpperCase();
4479
+ const icon =
4480
+ disp === "APPROVE" ? "✅" : disp === "REFUSED" ? "⛔" : disp === "UNKNOWN" ? "❔" : "🔁";
4481
+ const tail =
4482
+ disp === "APPROVE"
4483
+ ? ""
4484
+ : disp === "REFUSED"
4485
+ ? " — reviewer refused (step marked complete before review). The worker must revert and re-review."
4486
+ : " — changes requested. Watch for repeated revisions on this step.";
4487
+ return `${icon} **Review ${disp}** — ${loc}.${reviewSignals(event)}${tail}`;
4488
+ }
4489
+ case "review_failed": {
4490
+ const loc = reviewLocation(event);
4491
+ return (
4492
+ `⚠️ **Reviewer unavailable** — ${loc}. The reviewer subprocess failed or produced no ` +
4493
+ `verdict (not a revision spiral — a broken-reviewer signal). Consider checking reviewer config.`
4494
+ );
4495
+ }
4296
4496
  case "batch_complete": {
4297
4497
  const parts: string[] = [];
4298
4498
  if (event.succeededTasks !== undefined) parts.push(`${event.succeededTasks} succeeded`);
@@ -4414,14 +4614,22 @@ export function shouldNotify(
4414
4614
  eventType: UnifiedEventType,
4415
4615
  autonomy: SupervisorAutonomyLevel,
4416
4616
  ): boolean {
4417
- // Always notify for terminal/failure events regardless of autonomy
4617
+ // Always notify for terminal/failure events regardless of autonomy.
4618
+ // Review boundaries are included on purpose: the supervisor must be informed
4619
+ // at EVERY review start/end in ALL autonomy levels — in autonomous mode this
4620
+ // is precisely when it adjudicates revisions case-by-case (operator-as-alarm
4621
+ // is the opposite of autonomous execution). Suppressing review_* in
4622
+ // autonomous mode would silently disable the feature where it matters most.
4418
4623
  if (
4419
4624
  eventType === "batch_complete" ||
4420
4625
  eventType === "batch_paused" ||
4421
4626
  eventType === "merge_failed" ||
4422
4627
  eventType === "merge_health_dead" ||
4423
4628
  eventType === "merge_health_stuck" ||
4424
- eventType === "tier0_escalation"
4629
+ eventType === "tier0_escalation" ||
4630
+ eventType === "review_started" ||
4631
+ eventType === "review_completed" ||
4632
+ eventType === "review_failed"
4425
4633
  ) {
4426
4634
  return true;
4427
4635
  }