pi-crew 0.11.9 → 0.11.11

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.11.9",
3
+ "version": "0.11.11",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -2,7 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { readCrewAgents, saveCrewAgents } from "../runtime/crew-agent-records.ts";
3
3
  import { checkProcessLiveness, isActiveRunStatus } from "../runtime/process-status.ts";
4
4
  import { withRunLock } from "../state/coordination/locks.ts";
5
- import { appendEvent, readEventsCursor, type TeamEvent } from "../state/event-log/event-log.ts";
5
+ import { appendEventAsync, readEventsCursor, type TeamEvent } from "../state/event-log/event-log.ts";
6
6
  import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../state/stores/state-store.ts";
7
7
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
8
8
  import { logInternalError } from "../utils/internal-error.ts";
@@ -130,7 +130,10 @@ export async function markDeadAsyncRunIfNeeded(
130
130
  if (!fresh || !isActiveRunStatus(fresh.manifest.status)) return undefined;
131
131
  const failed = updateRunStatus(fresh.manifest, "failed", message);
132
132
  markActiveTasksAndAgentsFailed(failed, message);
133
- appendEvent(failed.eventsPath, {
133
+ // U2 (2026-10-10): awaited async append — the lock callback is async; the
134
+ // event lands after run.failed (sync updateRunStatus write above) in file
135
+ // order and is durable before the notifier tick resolves.
136
+ await appendEventAsync(failed.eventsPath, {
134
137
  type: "async.died",
135
138
  runId: failed.runId,
136
139
  message,
@@ -620,26 +620,16 @@ export async function openTeamDashboard(ctx: ExtensionContext): Promise<void> {
620
620
  continue;
621
621
  }
622
622
  if (selection.action === "agent-transcript" && (await openTranscriptViewer(cmdCtx, selection.runId))) continue;
623
+ // F1 (2026-10-08): openLiveConversation now owns ALL not-found messaging
624
+ // with a runtime-aware notice (child-process/scaffold runs point at the
625
+ // transcript viewer; live-session runs keep "No live agent found"). Its
626
+ // old fall-through block here fired even on picker-cancel — removed.
623
627
  if (selection.action === "agent-live" && (await openLiveConversation(cmdCtx, selection.runId))) continue;
624
628
  // feat/agents-browser: `b` from the dashboard opens the unified Agents
625
629
  // & Jobs overlay WITHOUT closing the loop — continue reopens the
626
630
  // dashboard underneath, mirroring the transcript/live-conversation
627
631
  // viewers. runId may be "" (no run selected — jobs-only view).
628
632
  if (selection.action === "browser-open" && (await openAgentsJobsBrowser(cmdCtx))) continue;
629
- if (selection.action === "agent-live") {
630
- await notifyCommandResult(
631
- cmdCtx,
632
- commandText({
633
- content: [
634
- {
635
- type: "text",
636
- text: "No live agent found for this run.",
637
- },
638
- ],
639
- }),
640
- );
641
- continue;
642
- }
643
633
  const result =
644
634
  selection.action === "api"
645
635
  ? await handleTeamTool(
@@ -10,6 +10,7 @@ import { requestRenderTarget } from "../../ui/pi-ui-compat.ts";
10
10
  import { asCrewTheme } from "../../ui/theme-adapter.ts";
11
11
  // Lazy-loaded: DurableTranscriptViewer is 658ms — only needed for /crew transcript command
12
12
  import type { DurableTranscriptViewer as DurableTranscriptViewerType } from "../../ui/transcript-viewer.ts";
13
+ import { notifyCommandResult } from "./command-utils.ts";
13
14
 
14
15
  async function getViewer(): Promise<typeof DurableTranscriptViewerType> {
15
16
  // LAZY: DurableTranscriptViewer is 658ms — only needed for /crew transcript.
@@ -68,6 +69,22 @@ export async function openTranscriptViewer(
68
69
  return true;
69
70
  }
70
71
 
72
+ /**
73
+ * F1 (2026-10-08 full-tier battery): notice shown when `V` (live conversation)
74
+ * finds no live agent for a run. With the default child-process runtime the
75
+ * in-process live-agent registry is ALWAYS empty by design — agents run as
76
+ * child Pis and stream transcripts to disk, and only the (ADR-frozen)
77
+ * live-session runtime ever registers handles. The old generic "No live agent
78
+ * found" therefore read as a bug on every child-process run. Route the user
79
+ * to the surface that works instead of a dead end.
80
+ */
81
+ export function liveConversationNotice(runtimeKind: string | undefined): string {
82
+ if (runtimeKind === "child-process" || runtimeKind === "scaffold") {
83
+ return `Live conversation requires the live-session runtime; this run executes via ${runtimeKind} and streams transcripts to disk. Press v (dashboard) or /team-transcript to follow along.`;
84
+ }
85
+ return "No live agent found for this run.";
86
+ }
87
+
71
88
  /** R2: Open live conversation overlay for a running live-session agent. */
72
89
  export async function openLiveConversation(
73
90
  ctx: ExtensionCommandContext,
@@ -78,7 +95,14 @@ export async function openLiveConversation(
78
95
  if (!selected || !ctx.hasUI) return false;
79
96
  const liveAgents = listLiveAgents();
80
97
  const handle = liveAgents.find((h) => h.runId === selected.runId && (selected.taskId ? h.taskId === selected.taskId : true));
81
- if (!handle) return false;
98
+ if (!handle) {
99
+ // F1: runtime-aware notice instead of a dead-end error. Contract change:
100
+ // `true` now also covers "answered with a notice" — `false` means the
101
+ // user cancelled the picker (or no UI), so callers stay silent on false.
102
+ const manifest = loadRunManifestById(ctx.cwd, selected.runId);
103
+ await notifyCommandResult(ctx, liveConversationNotice(manifest?.manifest.runtimeResolution?.kind));
104
+ return true;
105
+ }
82
106
  const theme = asCrewTheme({});
83
107
  await ctx.ui.custom<undefined>(
84
108
  (tui, _theme, _keybindings, done) => {
@@ -392,12 +392,11 @@ export async function handleCancel(params: TeamToolParamsValue, ctx: TeamContext
392
392
  }
393
393
  ctx.abortForegroundRun?.(fresh.manifest.runId);
394
394
  for (const taskId of abortResult.abortedIds) {
395
- // REVIEW FIX (2026-09-10): reverted M2b buffered conversion. Although
396
- // task.cancelled is a TERMINAL type (buffered terminal path bypasses
397
- // the 20ms buffer), that path still resolves via a microtask chain
398
- // (flushPromise.then(appendEvent)) — NOT same-tick. Cancel tests and
399
- // the immediately-following updateRunStatus reader need same-tick
400
- // durability, so plain sync appendEvent (base behavior).
395
+ // U2 (2026-10-10): kept SYNC — inside the withRunLockSync callback (cannot
396
+ // await) and each task.cancelled must precede the run.cancelled written by
397
+ // the updateRunStatus call below (audit order); an appends-after-lock
398
+ // restructure would invert that order. Cancel tests read events after
399
+ // handleCancel resolves (2026-09-10 M2b revert reason stands).
401
400
  appendEvent(fresh.manifest.eventsPath, {
402
401
  type: "task.cancelled",
403
402
  runId: fresh.manifest.runId,
@@ -18,6 +18,10 @@ export function appendTaskAttentionEvent(input: AppendTaskAttentionInput): boole
18
18
  `${event.taskId ?? ""}:${event.data?.reason ?? ""}:${event.data?.activityState ?? ""}` === dedupKey,
19
19
  );
20
20
  if (duplicate) return false;
21
+ // U2 (2026-10-10): kept SYNC — the dedup check above reads the events tail
22
+ // in the SAME call; an async/delayed write would let a rapid second
23
+ // attention call (before the flush lands) pass dedup and emit a duplicate.
24
+ // Same-tick write makes read-your-own-writes load-bearing for correctness.
21
25
  appendEvent(input.manifest.eventsPath, {
22
26
  type: "task.attention",
23
27
  runId: input.manifest.runId,
@@ -370,6 +370,12 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
370
370
  // in flight when it trips (they are killed by the cap abort below).
371
371
  if (agentCount >= maxAgentCalls) {
372
372
  // Durable record (dwf.log is the registered workflow-log event type).
373
+ // U2 (2026-10-10): kept SYNC — ctx closures are exercised DIRECTLY by
374
+ // unit tests that readEvents() synchronously right after the call
375
+ // (dynamic-workflow-context.test.ts rounds 12/14/18); no await boundary
376
+ // exists between a ctx call and those readers. The runner-level
377
+ // lifecycle events (dwf.started/completed/failed) ARE migrated —
378
+ // awaited appendEventAsync in dynamic-workflow-runner.ts.
373
379
  appendEvent(manifest.eventsPath, {
374
380
  type: "dwf.log",
375
381
  runId: manifest.runId,
@@ -773,10 +779,12 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
773
779
  // Idempotency: same phase title → no event, no state change.
774
780
  if (title === phaseState.currentPhase) return;
775
781
  // Close out the previous open phase BEFORE the new one opens.
776
- // REVIEW FIX (2026-09-10): reverted M2a buffered conversion — phase
777
- // transitions are low-frequency AND read back synchronously (tests,
778
- // checkpoint resume; dwf-setresult rounds 12/14/18 assert the events
779
- // file immediately after the run).
782
+ // U2 (2026-10-10): kept SYNC — ctx closures are exercised DIRECTLY by
783
+ // unit tests that readEvents() synchronously right after the call
784
+ // (dynamic-workflow-context.test.ts); no await boundary exists between a
785
+ // ctx call and those readers. The runner-level lifecycle events ARE
786
+ // migrated (awaited appendEventAsync in dynamic-workflow-runner.ts).
787
+ // Close out the previous open phase BEFORE the new one opens.
780
788
  if (phaseState.currentPhase !== undefined) {
781
789
  appendEvent(manifest.eventsPath, {
782
790
  type: "dwf.phase_completed",
@@ -800,7 +808,7 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
800
808
  );
801
809
  }
802
810
  }
803
- // REVIEW FIX (2026-09-10): reverted M2a buffered conversion — see phase_completed.
811
+ // U2 (2026-10-10): kept SYNC — see the phase_completed note above.
804
812
  appendEvent(manifest.eventsPath, {
805
813
  type: "dwf.phase_started",
806
814
  runId: manifest.runId,
@@ -815,7 +823,7 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
815
823
  if (wfState.logs.length < 1000) {
816
824
  wfState.logs.push(text);
817
825
  }
818
- // REVIEW FIX (2026-09-10): reverted M2a buffered conversion — see phase().
826
+ // U2 (2026-10-10): kept SYNC — see the phase_completed note above.
819
827
  appendEvent(manifest.eventsPath, {
820
828
  type: "dwf.log",
821
829
  runId: manifest.runId,
@@ -21,7 +21,7 @@ import { readFileSync } from "node:fs";
21
21
  import { createRequire } from "node:module";
22
22
  import { join } from "node:path";
23
23
  import { getCrewEnv } from "../../config/env-vars.ts";
24
- import { appendEvent } from "../../state/event-log/event-log.ts";
24
+ import { appendEventAsync } from "../../state/event-log/event-log.ts";
25
25
  import { writeArtifact } from "../../state/stores/artifact-store.ts";
26
26
  import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
27
27
  import { logInternalError } from "../../utils/internal-error.ts";
@@ -181,11 +181,13 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
181
181
  // require, fs, child_process). Cloning a hostile repo and running its workflow = arbitrary
182
182
  // code execution. Default-deny project workflows unless the user explicitly opts in via
183
183
  // PI_CREW_TRUST_PROJECT_DWF=1. Builtin and user workflows proceed without restriction.
184
+ // U2 (2026-10-10): awaited appendEventAsync — the 2026-09-10 M2a revert
185
+ // was required by BUFFERED (20ms-delayed) writes; awaiting the direct async
186
+ // append keeps the read-your-writes contract (resume flow, dwf-setresult
187
+ // tests, status display all read events only after runDynamicWorkflow
188
+ // resolves) while dropping the sleepSync event-loop block.
184
189
  if (workflow.source === "project" && getCrewEnv("PI_CREW_TRUST_PROJECT_DWF") !== "1") {
185
- // REVIEW FIX (2026-09-10): DWF lifecycle events reverted from M2a
186
- // buffered conversion — runner sites are read back synchronously
187
- // (dwf-setresult tests, resume flow, status display after run end).
188
- appendEvent(eventsPath, {
190
+ await appendEventAsync(eventsPath, {
189
191
  type: "dwf.trust_denied",
190
192
  runId: manifest.runId,
191
193
  data: { workflow: workflow.name, source: workflow.source, script: scriptPath },
@@ -195,7 +197,7 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
195
197
  );
196
198
  }
197
199
 
198
- appendEvent(eventsPath, {
200
+ await appendEventAsync(eventsPath, {
199
201
  type: "dwf.started",
200
202
  runId: manifest.runId,
201
203
  data: { workflow: workflow.name, script: scriptPath },
@@ -208,7 +210,7 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
208
210
  const dwfStore = new DwfStore(manifest.stateRoot);
209
211
  const resumedState = dwfStore.load();
210
212
  if (resumedState) {
211
- appendEvent(eventsPath, {
213
+ await appendEventAsync(eventsPath, {
212
214
  type: "dwf.resumed",
213
215
  runId: manifest.runId,
214
216
  data: {
@@ -285,7 +287,7 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
285
287
  }
286
288
  } catch (error) {
287
289
  logInternalError("dynamic-workflow-runner.run", error, `runId=${manifest.runId}, workflow=${workflow.name}`);
288
- appendEvent(eventsPath, {
290
+ await appendEventAsync(eventsPath, {
289
291
  type: "dwf.failed",
290
292
  runId: manifest.runId,
291
293
  data: {
@@ -320,7 +322,7 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
320
322
  // last open phase is always terminated before dwf.completed.
321
323
  const phaseState = getWorkflowPhaseState(ctx);
322
324
  if (phaseState?.currentPhase !== undefined) {
323
- appendEvent(eventsPath, {
325
+ await appendEventAsync(eventsPath, {
324
326
  type: "dwf.phase_completed",
325
327
  runId: manifest.runId,
326
328
  data: { phase: phaseState.currentPhase },
@@ -328,7 +330,7 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
328
330
  phaseState.currentPhase = undefined;
329
331
  }
330
332
 
331
- appendEvent(eventsPath, {
333
+ await appendEventAsync(eventsPath, {
332
334
  type: "dwf.completed",
333
335
  runId: manifest.runId,
334
336
  data: { workflow: workflow.name, summaryArtifact: summary.path },
@@ -132,6 +132,9 @@ export function deliverGroupJoin(input: {
132
132
  remaining,
133
133
  },
134
134
  });
135
+ // U2 (2026-10-10): kept SYNC — deliverGroupJoin is a sync API; group-join
136
+ // tests (and status display) fs.readFileSync(events.jsonl) immediately after
137
+ // it returns, so any delayed/async append would miss the events at read.
135
138
  appendEvent(input.manifest.eventsPath, {
136
139
  type: partial ? "agent.group_join.partial" : "agent.group_join.completed",
137
140
  runId: input.manifest.runId,
@@ -145,6 +148,8 @@ export function deliverGroupJoin(input: {
145
148
  },
146
149
  });
147
150
  if (existingMailbox)
151
+ // U2 (2026-10-10): kept SYNC — same sync-read contract as the completed/
152
+ // partial event above.
148
153
  appendEvent(input.manifest.eventsPath, {
149
154
  type: "agent.group_join.delivery_reused",
150
155
  runId: input.manifest.runId,
@@ -124,6 +124,15 @@ export interface LiveSessionSpawnInput {
124
124
  workspaceId: string;
125
125
  /** Phase 2: Output schema for validating yield data. */
126
126
  outputSchema?: unknown;
127
+ /**
128
+ * U3 test seam: optional pi-ai `Provider`-like object registered on the
129
+ * created session's ModelRuntime immediately after session creation, before
130
+ * anything streams. Lets tests run the REAL in-process live-session worker
131
+ * key-free and network-free against `@earendil-works/pi-ai/providers/faux`
132
+ * (tests construct the provider and pass it here; runtime code never
133
+ * imports the faux module). Must stay undefined in production runs.
134
+ */
135
+ modelProviderOverride?: unknown;
127
136
  }
128
137
 
129
138
  export interface LiveSessionRunResult {
@@ -179,6 +188,10 @@ type LiveSessionLike = {
179
188
  bindExtensions?: (bindings?: Record<string, unknown>) => Promise<void>;
180
189
  getActiveToolNames?: () => string[];
181
190
  setActiveToolsByName?: (names: string[]) => void;
191
+ /** U3 test-seam surface: SDK AgentSession exposes its ModelRuntime here. */
192
+ modelRuntime?: {
193
+ registerNativeProvider?: (provider: unknown) => void;
194
+ };
182
195
  };
183
196
 
184
197
  function appendTranscript(filePath: string | undefined, event: unknown): void {
@@ -200,6 +213,26 @@ function isString(value: unknown): value is string {
200
213
  return typeof value === "string";
201
214
  }
202
215
 
216
+ /**
217
+ * U3 test seam: register a caller-supplied pi-ai `Provider` on the created
218
+ * session's ModelRuntime right after creation. Passive by design — no faux
219
+ * import lives in runtime code; tests construct the provider and pass it via
220
+ * `LiveSessionSpawnInput.modelProviderOverride`. No-op when no override is
221
+ * supplied or the session does not expose a ModelRuntime with
222
+ * `registerNativeProvider` (older/foreign SDK shapes).
223
+ */
224
+ function applyModelProviderOverride(session: LiveSessionLike | undefined, provider: unknown): void {
225
+ if (!session || !provider) return;
226
+ const runtime = asRecord(session.modelRuntime);
227
+ const register = runtime?.registerNativeProvider;
228
+ if (typeof register !== "function") return;
229
+ try {
230
+ register.call(runtime, provider);
231
+ } catch (error) {
232
+ logInternalError("live-session.model-provider-override", error, "registerNativeProvider failed");
233
+ }
234
+ }
235
+
203
236
  /**
204
237
  * Type-safe extractor for message role from streaming events.
205
238
  * Handles the case where message may be a Record with a role field.
@@ -845,7 +878,6 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
845
878
  settingsManager: mod.SettingsManager.create(input.task.cwd, agentDir),
846
879
  }
847
880
  : {}),
848
- ...(input.modelRegistry ? { modelRegistry: input.modelRegistry } : {}),
849
881
  ...(resolvedModel ? { model: resolvedModel } : {}),
850
882
  ...(effectiveThinking ? { thinkingLevel: effectiveThinking } : {}),
851
883
  // R3-12: cycling/visibility scope = the fallback chain (see helper).
@@ -853,6 +885,10 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
853
885
  customTools,
854
886
  });
855
887
  session = created.session;
888
+ // U3: register the test-only provider override (faux E2E seam) before
889
+ // anything streams. No-op in production (modelProviderOverride stays
890
+ // undefined there).
891
+ applyModelProviderOverride(session, input.modelProviderOverride);
856
892
  // P7 (perf): fire-and-forget — return value not needed, blocks the
857
893
  // event loop less than the sync appendEvent under file-lock contention.
858
894
  appendEventFireAndForget(input.manifest.eventsPath, {
@@ -11,7 +11,7 @@
11
11
  import * as fs from "node:fs";
12
12
  import type { CrewRuntimeConfig } from "../config/config.ts";
13
13
  import { parsePlannerPlanOutput } from "../extension/plan-orchestrate.ts";
14
- import { appendEvent } from "../state/event-log/event-log.ts";
14
+ import { appendEventAsync } from "../state/event-log/event-log.ts";
15
15
  import { appendPlanRevision, getCurrentPlanRecord, setPlanApproval } from "../state/stores/plan-store.ts";
16
16
  import { saveRunManifestAsync } from "../state/stores/state-store.ts";
17
17
  import type { PlanRecord, TeamRunManifest, TeamTaskState } from "../state/types.ts";
@@ -122,7 +122,10 @@ export async function ensurePlanApprovalRequested(manifest: TeamRunManifest, tas
122
122
  plan: record ? { id: record.id, version: record.version } : manifest.plan,
123
123
  };
124
124
  await saveRunManifestAsync(updated);
125
- appendEvent(updated.eventsPath, {
125
+ // U2 (2026-10-10): awaited async append — async fn with no same-tick reader;
126
+ // durable before ensurePlanApprovalRequested resolves (plan-approval tests
127
+ // read events after the await boundary).
128
+ await appendEventAsync(updated.eventsPath, {
126
129
  type: "plan.approval_required",
127
130
  runId: updated.runId,
128
131
  taskId: planTask?.id,
@@ -5,7 +5,8 @@ import { DEFAULT_PATHS } from "../../config/defaults.ts";
5
5
  import { appendHookEvent, executeHook } from "../../hooks/registry.ts";
6
6
  import type { MetricRegistry } from "../../observability/metric-registry.ts";
7
7
  import { discoverRunLockFiles, sweepStaleLocks, withRunLock, withRunLockSync } from "../../state/coordination/locks.ts";
8
- import { appendEvent, scanSequence } from "../../state/event-log/event-log.ts";
8
+ import type { AppendTeamEvent } from "../../state/event-log/event-log.ts";
9
+ import { appendEvent, appendEventAsync, scanSequence } from "../../state/event-log/event-log.ts";
9
10
  import { readActiveRunRegistry, unregisterActiveRun } from "../../state/stores/active-run-registry.ts";
10
11
  import {
11
12
  loadRunManifestById,
@@ -158,28 +159,41 @@ export async function applyRecoveryPlan(plan: RecoveryPlan, ctx: Pick<ExtensionC
158
159
  // may have completed/cancelled the run in the meantime. Re-read inside the
159
160
  // lock and derive every write from the FRESH snapshot; never reset a run that
160
161
  // reached a terminal status.
162
+ // U2 (2026-10-10): recovery lifecycle events are captured under the sync run
163
+ // lock (payload derived from the fresh snapshot, as R14-4 requires) but
164
+ // appended via the awaited async primitive AFTER the lock — the sync lock
165
+ // callback cannot await, and awaiting outside keeps the read-your-writes
166
+ // contract (recovery-hooks tests read events.jsonl synchronously right after
167
+ // applyRecoveryPlan resolves) while dropping the sleepSync event-loop block.
168
+ let lifecycle: { eventsPath: string; event: AppendTeamEvent } | undefined;
161
169
  withRunLockSync(loaded.manifest, () => {
162
170
  const fresh = loadRunManifestById(ctx.cwd, plan.runId); // NOTE: inside withRunLockSync - consistent read
163
171
  if (!fresh) throw new Error(`Run '${plan.runId}' not found.`);
164
172
  if (fresh.manifest.status === "completed" || fresh.manifest.status === "failed" || fresh.manifest.status === "cancelled") {
165
173
  // Run reached a terminal status while the recovery hook was running —
166
174
  // do NOT reset it (no task reset, no status change).
167
- appendEvent(fresh.manifest.eventsPath, {
168
- type: "crew.run.recovery_skipped",
169
- runId: plan.runId,
170
- message: `Recovery skipped: run is already '${fresh.manifest.status}'`,
171
- data: { status: fresh.manifest.status },
172
- });
175
+ lifecycle = {
176
+ eventsPath: fresh.manifest.eventsPath,
177
+ event: {
178
+ type: "crew.run.recovery_skipped",
179
+ runId: plan.runId,
180
+ message: `Recovery skipped: run is already '${fresh.manifest.status}'`,
181
+ data: { status: fresh.manifest.status },
182
+ },
183
+ };
173
184
  return;
174
185
  }
175
186
  appendHookEvent(fresh.manifest, hookReport);
176
187
  if (hookReport.outcome === "block") {
177
- appendEvent(fresh.manifest.eventsPath, {
178
- type: "crew.run.recovery_blocked",
179
- runId: plan.runId,
180
- message: `Recovery blocked by hook: ${hookReport.reason ?? "run_recovery hook blocked the operation."}`,
181
- data: { hookOutcome: "block", reason: hookReport.reason },
182
- });
188
+ lifecycle = {
189
+ eventsPath: fresh.manifest.eventsPath,
190
+ event: {
191
+ type: "crew.run.recovery_blocked",
192
+ runId: plan.runId,
193
+ message: `Recovery blocked by hook: ${hookReport.reason ?? "run_recovery hook blocked the operation."}`,
194
+ data: { hookOutcome: "block", reason: hookReport.reason },
195
+ },
196
+ };
183
197
  return;
184
198
  }
185
199
 
@@ -203,17 +217,21 @@ export async function applyRecoveryPlan(plan: RecoveryPlan, ctx: Pick<ExtensionC
203
217
  : task,
204
218
  );
205
219
  saveRunTasks(fresh.manifest, tasks);
206
- appendEvent(fresh.manifest.eventsPath, {
207
- type: "crew.run.resumed",
208
- runId: plan.runId,
209
- message: `Recovered ${plan.resumableTasks.length} interrupted task(s).`,
210
- data: {
211
- recoveredFromSeq: plan.lastEventSeq,
212
- resumableTasks: plan.resumableTasks,
220
+ lifecycle = {
221
+ eventsPath: fresh.manifest.eventsPath,
222
+ event: {
223
+ type: "crew.run.resumed",
224
+ runId: plan.runId,
225
+ message: `Recovered ${plan.resumableTasks.length} interrupted task(s).`,
226
+ data: {
227
+ recoveredFromSeq: plan.lastEventSeq,
228
+ resumableTasks: plan.resumableTasks,
229
+ },
213
230
  },
214
- });
231
+ };
215
232
  registry?.counter("crew.run.count", "Total runs by status").inc({ status: "resumed" });
216
233
  });
234
+ if (lifecycle) await appendEventAsync(lifecycle.eventsPath, lifecycle.event);
217
235
  }
218
236
 
219
237
  export function declineRecoveryPlan(plan: RecoveryPlan, ctx: Pick<ExtensionContext, "cwd">): void {
@@ -226,6 +244,10 @@ export function declineRecoveryPlan(plan: RecoveryPlan, ctx: Pick<ExtensionConte
226
244
  const fresh = loadRunManifestById(ctx.cwd, plan.runId); // NOTE: inside withRunLockSync - consistent read
227
245
  if (!fresh) return;
228
246
  // Log the event first — if appendEvent fails, state remains consistent.
247
+ // U2 (2026-10-10): kept SYNC — this event must land on disk BEFORE the
248
+ // run.cancelled written by the updateRunStatus call below it (audit order:
249
+ // decline reason precedes the terminal status); declineRecoveryPlan is a
250
+ // sync API with no await boundary to flush an async append.
229
251
  appendEvent(fresh.manifest.eventsPath, {
230
252
  type: "crew.run.recovery_declined",
231
253
  runId: plan.runId,
@@ -335,6 +357,9 @@ export function cancelOrphanedRuns(
335
357
  if (!fresh) return;
336
358
  if (fresh.manifest.status !== "running" && fresh.manifest.status !== "blocked") {
337
359
  // Status changed between initial check (line 109) and acquiring the lock — normal concurrent update, not an orphan
360
+ // U2 (2026-10-10): kept SYNC — cancelOrphanedRuns is a sync API (returns
361
+ // {cancelled,skipped} synchronously to session-start callers); no await
362
+ // boundary exists to flush an async append before those callers read events.
338
363
  appendEvent(loaded.manifest.eventsPath, {
339
364
  type: "crew.run.orphan_skip",
340
365
  runId: manifest.runId,
@@ -374,6 +399,9 @@ export function cancelOrphanedRuns(
374
399
  }
375
400
  }
376
401
  updateRunStatus(fresh.manifest, "cancelled", `Orphaned run: owner session ${ownerId} no longer exists`);
402
+ // U2 (2026-10-10): kept SYNC — must land AFTER run.cancelled (updateRunStatus
403
+ // above, still sync) for audit order, and cancelOrphanedRuns is a sync API
404
+ // with no await boundary to flush an async append.
377
405
  appendEvent(fresh.manifest.eventsPath, {
378
406
  type: "crew.run.orphan_cancelled",
379
407
  runId: manifest.runId,
@@ -768,7 +796,10 @@ export async function reconcileAllStaleRuns(
768
796
  void terminateLiveAgentsForRun(fresh.manifest.runId, "failed", appendEvent, fresh.manifest.eventsPath).catch((error) =>
769
797
  logInternalError("crash-recovery.reconcile.terminate", error, `runId=${fresh.manifest.runId}`, "warn"),
770
798
  );
771
- appendEvent(fresh.manifest.eventsPath, {
799
+ // U2 (2026-10-10): awaited async append — the lock callback is async; the
800
+ // event lands after run.failed (sync updateRunStatus write above) in file
801
+ // order and is durable before reconcileAllStaleRuns resolves.
802
+ await appendEventAsync(fresh.manifest.eventsPath, {
772
803
  type: "crew.run.reconciled_stale",
773
804
  runId,
774
805
  message: result.detail,
@@ -548,9 +548,12 @@ function sanitizeFencedBody(body: string): string {
548
548
  return body.replace(DEPENDENCY_CONTROL_CHAR_PATTERN, "").replace(/<\/dependency-context/g, "&lt;/dependency-context");
549
549
  }
550
550
 
551
- // ── Handoff budget (est-token cap on this layer) ─────────────────────────────
551
+ // ── Handoff budget (effective-token cap on this layer: chars/4 heuristic ──
552
+ // anchored upward by measured worker usage — U10) ─────────────────────────
552
553
 
553
- /** Default est-token budget for the dynamic.dependencyContext layer. */
554
+ /** Default effective-token budget for the dynamic.dependencyContext layer
555
+ * (tokens as measured by the chars/4 heuristic, floored by measured worker
556
+ * usage where available — U10). */
554
557
  export const DEFAULT_HANDOFF_BUDGET_TOKENS = 1800;
555
558
  /** Budget ceiling: a resolved budget above this is treated as the off-switch
556
559
  * (same semantics as ≤0 — clearly not a real handoff cap, so render
@@ -562,16 +565,58 @@ export const HANDOFF_SUMMARY_HEAD_CHARS = 240;
562
565
  /** Strict integer env syntax — "12abc" must NOT parse as 12. */
563
566
  const HANDOFF_BUDGET_ENV_RE = /^[+-]?\d+$/;
564
567
 
565
- /** Est tokens = chars/4 — the same heuristic as `estimateTokens` in
568
+ /** Est tokens = chars/4 — the same heuristic as `estimateTokens(chars)` in
566
569
  * src/runtime/task-runner/prompt-builder.ts (SR-02 breakdown). Duplicated
567
570
  * here instead of imported: importing prompt-builder would pull its
568
571
  * retrieval/workspace-tree graph into every task-output-context consumer
569
572
  * (post-execution, aggregate outputs) for a one-line heuristic. Keep the
570
- * two in sync if either changes. */
573
+ * two in sync if either changes; the U10 max(heuristic, measured) combine
574
+ * rule mirrors prompt-builder's `estimateTokens(chars, measuredTokens)`. */
571
575
  function estimateHandoffTokens(chars: number): number {
572
576
  return Math.round(chars / 4);
573
577
  }
574
578
 
579
+ /** U10 measured-token anchor: Σ measured `usage.outputTokens` over the deps
580
+ * still rendered in FULL form. The usage record is the worker's REAL
581
+ * tokenizer measurement (message_end usage persisted on the task state via
582
+ * child-executor `parsedOutput?.usage ?? sessionUsage`, surfaced onto each
583
+ * entry by `aggregateUsage`) — not an estimate. Only the model's OUTPUT
584
+ * tokens count: they upper-bound the tokens of the result text whose
585
+ * (≤32KB-truncated) inline rendering is what this layer injects downstream
586
+ * (the session's INPUT tokens say nothing about the size of the text being
587
+ * injected). Charging the upper bound is deliberately conservative per spec
588
+ * U10's acceptance: when the worker reports MORE tokens than the heuristic,
589
+ * the measured number governs (trim fires at least as early); the recovery
590
+ * path is the compact form's artifact pointer.
591
+ *
592
+ * Compact (`budgetTrimmed`) deps are deliberately NOT charged: their inline
593
+ * body is a ≤240-char head + pointer, no longer the worker's output, so the
594
+ * chars/4 heuristic on the small head is the honest measure — otherwise an
595
+ * output-heavy dep could never fit any budget even fully trimmed. */
596
+ function measuredHandoffTokens(context: DependencyOutputContext): number {
597
+ let measured = 0;
598
+ for (const dep of context.dependencies) {
599
+ if (dep.budgetTrimmed) continue;
600
+ measured += dep.usage?.outputTokens ?? 0;
601
+ }
602
+ return measured;
603
+ }
604
+
605
+ /** Measure a candidate context in EFFECTIVE tokens (U10): the chars/4
606
+ * heuristic on the rendered body, floored by the measured-token anchor —
607
+ * `max(heuristic, Σ measured output tokens of full-form deps)`, mirroring
608
+ * the render byte-for-byte (same parts builder; `.trim()` matches the render
609
+ * join). The max rule mirrors `estimateTokens(chars, measuredTokens)` in
610
+ * prompt-builder.ts: a real measurement can only TIGHTEN the estimate
611
+ * (spec U10 — the budget must never be exceeded when measured usage is
612
+ * larger than the heuristic), never loosen it below the heuristic. */
613
+ function measureHandoffTokens(context: DependencyOutputContext): number {
614
+ return Math.max(
615
+ estimateHandoffTokens(buildDependencyOutputBodyParts(context).join("\n").trim().length),
616
+ measuredHandoffTokens(context),
617
+ );
618
+ }
619
+
575
620
  /** Budget active iff it is a positive finite number at or below the ceiling;
576
621
  * ≤0 or >ceiling = off-switch (render untrimmed). */
577
622
  function handoffBudgetActive(budgetTokens: number): boolean {
@@ -668,18 +713,19 @@ function buildDependencyOutputBodyParts(context: DependencyOutputContext): strin
668
713
  return parts;
669
714
  }
670
715
 
671
- /** Measure a candidate context in est tokens (chars/4), mirroring the render
672
- * byte-for-byte (same parts builder; `.trim()` matches the render join). */
673
- function measureHandoffTokens(context: DependencyOutputContext): number {
674
- return estimateHandoffTokens(buildDependencyOutputBodyParts(context).join("\n").trim().length);
675
- }
676
-
677
716
  /**
678
- * Handoff budget: trim the dependency-output context to an est-token
679
- * (chars/4) budget. PURE — no I/O, no env reads (callers pass the resolved
680
- * budget from {@link resolveHandoffBudgetTokens}); returns the input
681
- * reference unchanged when the budget is off (≤0 or >1_000_000) or the
682
- * context already fits.
717
+ * Handoff budget: trim the dependency-output context to an EFFECTIVE-token
718
+ * budget (U10). Effective tokens = max(chars/4 heuristic on the rendered
719
+ * body, Σ measured `usage.outputTokens` of full-form deps — real tokenizer
720
+ * numbers from message_end usage persisted on the task state). Measured
721
+ * usage can only tighten: when a worker reports MORE tokens than the
722
+ * heuristic estimates, the measured number governs and the trim fires at
723
+ * least as early as before (spec U10 acceptance — the budget is never
724
+ * exceeded in that direction); when usage is absent the pure heuristic
725
+ * applies unchanged (fallback). PURE — no I/O, no env reads (callers pass
726
+ * the resolved budget from {@link resolveHandoffBudgetTokens}); returns the
727
+ * input reference unchanged when the budget is off (≤0 or >1_000_000) or
728
+ * the context already fits.
683
729
  *
684
730
  * Trim policy (declaration order): dependencies are probed from ALL-full
685
731
  * downward — the largest prefix of dependencies that still fits keeps its