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/CHANGELOG.md +50 -0
- package/dist/index.mjs +441 -441
- package/package.json +1 -1
- package/src/extension/async-notifier.ts +5 -2
- package/src/extension/registration/commands/shared.ts +4 -14
- package/src/extension/registration/viewers.ts +25 -1
- package/src/extension/team-tool/cancel.ts +5 -6
- package/src/runtime/attention-events.ts +4 -0
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +14 -6
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +12 -10
- package/src/runtime/group-join.ts +5 -0
- package/src/runtime/live-session/live-session-runtime.ts +37 -1
- package/src/runtime/plan-approval.ts +5 -2
- package/src/runtime/recovery/crash-recovery.ts +53 -22
- package/src/runtime/task-output-context.ts +61 -15
- package/src/runtime/task-packet.ts +33 -12
- package/src/runtime/task-runner/prompt-builder.ts +15 -5
- package/src/state/stores/state-store.ts +11 -0
package/package.json
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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)
|
|
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
|
-
//
|
|
396
|
-
// task.cancelled
|
|
397
|
-
// the
|
|
398
|
-
//
|
|
399
|
-
//
|
|
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
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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, "</dependency-context");
|
|
549
549
|
}
|
|
550
550
|
|
|
551
|
-
// ── Handoff budget (
|
|
551
|
+
// ── Handoff budget (effective-token cap on this layer: chars/4 heuristic ──
|
|
552
|
+
// anchored upward by measured worker usage — U10) ─────────────────────────
|
|
552
553
|
|
|
553
|
-
/** Default
|
|
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
|
|
679
|
-
* (
|
|
680
|
-
*
|
|
681
|
-
*
|
|
682
|
-
*
|
|
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
|