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.
- package/CHANGELOG.md +94 -0
- package/dist/index.mjs +495 -495
- package/package.json +1 -1
- package/skills/real-test-pi-crew/SKILL.md +5 -0
- package/src/runtime/foreground-watchdog.ts +213 -26
- package/src/runtime/process/reap-group.ts +18 -1
- package/src/runtime/process/run-descendant-sweep.ts +24 -2
- package/src/runtime/task-runner/live-executor.ts +146 -45
|
@@ -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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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,
|