pi-crew 0.11.40 → 0.11.42

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.
@@ -5,6 +5,7 @@ import { appendEventFireAndForget } from "../../state/event-log/event-log.ts";
5
5
  import { writeArtifact } from "../../state/stores/artifact-store.ts";
6
6
  import { loadRunManifestById } from "../../state/stores/state-store.ts";
7
7
  import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../../state/types.ts";
8
+ import { logInternalError } from "../../utils/internal-error.ts";
8
9
  import type { WorkflowStep } from "../../workflows/workflow-config.ts";
9
10
  import {
10
11
  appendCrewAgentEvent,
@@ -104,6 +105,79 @@ function updateTask(tasks: TeamTaskState[], updated: TeamTaskState): TeamTaskSta
104
105
  return tasks.map((task) => (task.id === updated.id ? updated : task));
105
106
  }
106
107
 
108
+ /**
109
+ * Wave 13 (F2, GAP-1): default cadence (ms) of the live-executor tool
110
+ * heartbeat timer. 30s sits far inside every liveness window that consumes
111
+ * the task heartbeat (watchdog task-heartbeat evidence 5 min, stale-reconciler
112
+ * ACTIVE_EVIDENCE_TTL_MS 5 min, CLAIMED_RUN_HUNG_MS 45 min), costs at most one
113
+ * throttled tasks.json persist per running tool per 30s, and never touches
114
+ * the event log. Fixed constant, NOT env-configurable on purpose (SPEC §3.2):
115
+ * wiring an env kill-switch would require registering
116
+ * PI_CREW_LIVE_HEARTBEAT_MS in src/config/env-vars.ts AND a forwarding
117
+ * decision in src/config/env-allowlist-registry.ts (fail-fast at module
118
+ * load) — both outside this wave's file scope.
119
+ */
120
+ export const LIVE_TOOL_HEARTBEAT_INTERVAL_MS = 30_000;
121
+
122
+ /** Opaque handle for the per-attempt live tool heartbeat timer. */
123
+ export interface LiveToolHeartbeatTimer {
124
+ /** Stop the timer and clear its interval. Idempotent. */
125
+ stop(): void;
126
+ }
127
+
128
+ /**
129
+ * Wave 13 (F2, GAP-1) — start the tool heartbeat timer for one live attempt
130
+ * (SPEC §3.2). Every `intervalMs`, IF a tool is currently executing
131
+ * (`isToolInFlight()` — `agentProgress.currentToolStartedAt` set by
132
+ * tool_execution_start, cleared by tool_execution_end), call `touch()`
133
+ * (the heartbeat-only persist, shared 1s throttle with event-driven persists).
134
+ * Idle between tools ⇒ no write. `touch` must NOT append task.progress events
135
+ * so the event log cannot grow from heartbeats.
136
+ *
137
+ * Failure containment (mirrors startWorkerHeartbeatPulse [pulse-3]): a
138
+ * throwing gate/touch is logged and the timer keeps ticking — the heartbeat
139
+ * must never crash the host process. The interval is `unref`'d so it can
140
+ * never hold the event loop open.
141
+ */
142
+ export function startLiveToolHeartbeatTimer(opts: {
143
+ intervalMs: number;
144
+ isToolInFlight: () => boolean;
145
+ touch: () => void;
146
+ }): LiveToolHeartbeatTimer {
147
+ // Kill-switch shape (interval ≤ 0 ⇒ disabled, no timer) kept so a future
148
+ // env-configurable interval can slot in without touching call sites.
149
+ if (opts.intervalMs <= 0) {
150
+ return {
151
+ stop() {
152
+ // Intentional no-op: a disabled timer (interval ≤ 0) has no handle.
153
+ },
154
+ };
155
+ }
156
+ const timer = setInterval(() => {
157
+ try {
158
+ if (opts.isToolInFlight()) opts.touch();
159
+ } catch (err) {
160
+ logInternalError("task-runner.live-tool-heartbeat", err as Error);
161
+ }
162
+ }, opts.intervalMs);
163
+ timer.unref?.();
164
+ return {
165
+ stop() {
166
+ clearInterval(timer);
167
+ },
168
+ };
169
+ }
170
+
171
+ let armLiveToolHeartbeatTimer: typeof startLiveToolHeartbeatTimer = startLiveToolHeartbeatTimer;
172
+
173
+ /**
174
+ * @internal Test seam — replace the timer constructor used inside runLiveTask
175
+ * (null = real). Mirrors the loader seams in foreground-watchdog.ts.
176
+ */
177
+ export function setLiveToolHeartbeatTimerForTests(starter: typeof startLiveToolHeartbeatTimer | null): void {
178
+ armLiveToolHeartbeatTimer = starter ?? startLiveToolHeartbeatTimer;
179
+ }
180
+
107
181
  export async function runLiveTask(input: RunLiveTaskInput): Promise<RunLiveTaskOutput> {
108
182
  const { manifest, step, agent, prompt } = input;
109
183
  let task = input.task;
@@ -122,6 +196,24 @@ export async function runLiveTask(input: RunLiveTaskInput): Promise<RunLiveTaskO
122
196
  let lastRunProgressPersistedAt = 0;
123
197
  let lastHeartbeatPersistedAt = 0;
124
198
  let lastRunProgressSummary: ProgressEventSummary | undefined;
199
+ /**
200
+ * Wave 13 (F2, GAP-1): heartbeat-only persist, extracted from
201
+ * persistLiveProgress so the tool heartbeat timer can reuse the EXACT
202
+ * event-driven path (same 1s throttle via the shared lastHeartbeatPersistedAt,
203
+ * same persistSingleTaskUpdate write, same isCurrent gate). Touching only
204
+ * tasks.json — never the event log — so timer ticks cannot inflate it.
205
+ */
206
+ const touchTaskHeartbeat = (force = false): void => {
207
+ if (!isCurrent()) return;
208
+ const now = Date.now();
209
+ if (!force && now - lastHeartbeatPersistedAt < 1000) return;
210
+ task = {
211
+ ...task,
212
+ heartbeat: touchWorkerHeartbeat(task.heartbeat ?? createWorkerHeartbeat(task.id)),
213
+ };
214
+ tasks = persistSingleTaskUpdate(manifest, tasks, task);
215
+ lastHeartbeatPersistedAt = now;
216
+ };
125
217
  const persistLiveProgress = (event: unknown, force = false): void => {
126
218
  if (!isCurrent()) return;
127
219
  const now = Date.now();
@@ -130,14 +222,7 @@ export async function runLiveTask(input: RunLiveTaskInput): Promise<RunLiveTaskO
130
222
  lastAgentRecordPersistedAt = now;
131
223
  }
132
224
  // Bug #5 fix: also persist heartbeat to tasks.json so heartbeat-watcher can detect live-session activity
133
- if (force || now - lastHeartbeatPersistedAt >= 1000) {
134
- task = {
135
- ...task,
136
- heartbeat: touchWorkerHeartbeat(task.heartbeat ?? createWorkerHeartbeat(task.id)),
137
- };
138
- tasks = persistSingleTaskUpdate(manifest, tasks, task);
139
- lastHeartbeatPersistedAt = now;
140
- }
225
+ touchTaskHeartbeat(force);
141
226
  const summary = progressEventSummary(task, event);
142
227
  const decision = shouldAppendProgressEventUpdate({
143
228
  previous: lastRunProgressSummary,
@@ -166,44 +251,60 @@ export async function runLiveTask(input: RunLiveTaskInput): Promise<RunLiveTaskO
166
251
  agentMax != null && agentMax > 0 && (!input.runtimeConfig?.maxTurns || agentMax < input.runtimeConfig.maxTurns)
167
252
  ? { ...input.runtimeConfig, maxTurns: agentMax }
168
253
  : input.runtimeConfig;
169
- const liveResult = await runLiveSessionTask({
170
- manifest,
171
- task,
172
- step,
173
- agent,
174
- prompt,
175
- signal: input.signal,
176
- transcriptPath,
177
- runtimeConfig: effectiveRuntimeConfig,
178
- parentContext: input.parentContext,
179
- parentModel: input.parentModel,
180
- modelRegistry: input.modelRegistry,
181
- modelOverride: input.modelOverride,
182
- teamRoleModel: input.teamRoleModel,
183
- teamRoleFallbackModels: input.teamRoleFallbackModels,
184
- teamRoleThinking: input.teamRoleThinking,
185
- isCurrent,
186
- workspaceId: input.workspaceId,
187
- // U11: embedded-tier mitigation knobs (lane-B contract) — forwarded
188
- // verbatim; undefined keeps every mitigation inert (legacy path).
189
- embeddedMitigations: input.embeddedMitigations,
190
- // Phase 2: Pass output schema for yield validation
191
- outputSchema: undefined,
192
- onOutput: (text) => {
193
- if (!isCurrent()) return;
194
- appendCrewAgentOutput(manifest, task.id, text);
195
- },
196
- onEvent: (event) => {
197
- if (!isCurrent()) return;
198
- appendCrewAgentEvent(manifest, task.id, event);
199
- task = {
200
- ...task,
201
- agentProgress: applyAgentProgressEvent(task.agentProgress ?? emptyCrewAgentProgress(), event, task.startedAt),
202
- };
203
- tasks = updateTask(tasks, task);
204
- persistLiveProgress(event);
205
- },
254
+ // Wave 13 (F2, GAP-1): arm the tool heartbeat timer for this attempt. The
255
+ // tick no-ops unless a tool is executing (agentProgress.currentToolStartedAt
256
+ // set by tool_execution_start / cleared by tool_execution_end), so idle gaps
257
+ // between tools write nothing; the shared 1s throttle inside
258
+ // touchTaskHeartbeat bounds disk writes; and the finally below clears the
259
+ // interval on completion, throw AND cancel — no timer leak across attempts.
260
+ const heartbeatTimer = armLiveToolHeartbeatTimer({
261
+ intervalMs: LIVE_TOOL_HEARTBEAT_INTERVAL_MS,
262
+ isToolInFlight: () => task.agentProgress?.currentToolStartedAt !== undefined,
263
+ touch: () => touchTaskHeartbeat(false),
206
264
  });
265
+ let liveResult: Awaited<ReturnType<typeof runLiveSessionTask>>;
266
+ try {
267
+ liveResult = await runLiveSessionTask({
268
+ manifest,
269
+ task,
270
+ step,
271
+ agent,
272
+ prompt,
273
+ signal: input.signal,
274
+ transcriptPath,
275
+ runtimeConfig: effectiveRuntimeConfig,
276
+ parentContext: input.parentContext,
277
+ parentModel: input.parentModel,
278
+ modelRegistry: input.modelRegistry,
279
+ modelOverride: input.modelOverride,
280
+ teamRoleModel: input.teamRoleModel,
281
+ teamRoleFallbackModels: input.teamRoleFallbackModels,
282
+ teamRoleThinking: input.teamRoleThinking,
283
+ isCurrent,
284
+ workspaceId: input.workspaceId,
285
+ // U11: embedded-tier mitigation knobs (lane-B contract) — forwarded
286
+ // verbatim; undefined keeps every mitigation inert (legacy path).
287
+ embeddedMitigations: input.embeddedMitigations,
288
+ // Phase 2: Pass output schema for yield validation
289
+ outputSchema: undefined,
290
+ onOutput: (text) => {
291
+ if (!isCurrent()) return;
292
+ appendCrewAgentOutput(manifest, task.id, text);
293
+ },
294
+ onEvent: (event) => {
295
+ if (!isCurrent()) return;
296
+ appendCrewAgentEvent(manifest, task.id, event);
297
+ task = {
298
+ ...task,
299
+ agentProgress: applyAgentProgressEvent(task.agentProgress ?? emptyCrewAgentProgress(), event, task.startedAt),
300
+ };
301
+ tasks = updateTask(tasks, task);
302
+ persistLiveProgress(event);
303
+ },
304
+ });
305
+ } finally {
306
+ heartbeatTimer.stop();
307
+ }
207
308
  const startupEvidence = createStartupEvidence({
208
309
  command: "live-session",
209
310
  startedAt: attemptStartedAt,
@@ -1376,6 +1376,48 @@ export function appendEventFireAndForget(eventsPath: string, event: AppendTeamEv
1376
1376
  appendEventAsync(eventsPath, event).catch((error) => logInternalError("event-log.fire-and-forget", error, eventsPath));
1377
1377
  }
1378
1378
 
1379
+ /**
1380
+ * Wave-14 F1 (GAP-0): await a run's in-flight event-log writers.
1381
+ *
1382
+ * Safe to call from tests before removing a run state dir: awaits all
1383
+ * in-flight async appends for the given events path (all paths when omitted)
1384
+ * and flushes its buffer. No-op when nothing is in flight.
1385
+ *
1386
+ * - With `eventsPath`: awaiting the per-path queue TAIL settles the entire
1387
+ * write chain (tail-chaining guarantees the tail completes last), then
1388
+ * flushes that path's buffered tier via the internal `flushOneEventLogBuffer`
1389
+ * (per-path flush is a simple direct call — no complexity caveat).
1390
+ * The Map entry may already have self-deleted (compare-and-delete on
1391
+ * settle) — `?? Promise.resolve()` makes that a clean no-op.
1392
+ * - Without `eventsPath`: awaits every queue tail + `flushEventLogBuffer()`.
1393
+ *
1394
+ * Never throws: a rejected tail is absorbed by `allSettled` (a teardown
1395
+ * helper must never fail because an old write failed — the error-reset path
1396
+ * in appendEventAsync already logs and resets the queue). The buffer flush
1397
+ * is best-effort for the same reason. The MAX_ASYNC_QUEUES (256) cap is
1398
+ * irrelevant here: drain-by-key touches one entry, global drain iterates
1399
+ * whatever currently exists. `drainAsyncQueues` stays private — this is the
1400
+ * public, path-scoped surface.
1401
+ */
1402
+ export async function drainEventLogWriters(eventsPath?: string): Promise<void> {
1403
+ if (eventsPath !== undefined) {
1404
+ const tail = asyncQueues.get(eventsPath) ?? Promise.resolve();
1405
+ await Promise.allSettled([tail]);
1406
+ try {
1407
+ await flushOneEventLogBuffer(eventsPath);
1408
+ } catch {
1409
+ /* best-effort — teardown must not fail on a flush error */
1410
+ }
1411
+ return;
1412
+ }
1413
+ await Promise.allSettled([...asyncQueues.values()]);
1414
+ try {
1415
+ await flushEventLogBuffer();
1416
+ } catch {
1417
+ /* best-effort */
1418
+ }
1419
+ }
1420
+
1379
1421
  // Auto-flush on process exit so buffered events do not silently leak.
1380
1422
  // Defense-in-depth: SIGTERM/SIGINT use setImmediate so the handler returns
1381
1423
  // immediately and the main thread is not blocked by sync I/O.