pi-crew 0.11.39 → 0.11.41

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,