pi-crew 0.9.48 → 0.9.50
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/AGENTS.md +18 -0
- package/CHANGELOG.md +314 -0
- package/dist/build-meta.json +70 -42
- package/dist/index.mjs +505 -430
- package/dist/index.mjs.map +4 -4
- package/docs/decisions/2026-07-24-oidc-trusted-publishing.md +112 -0
- package/package.json +2 -3
- package/skills/.gitkeep +0 -0
- package/skills/distill-persona/BUILD-NOTES.md +55 -0
- package/skills/distill-persona/SKILL.md +550 -0
- package/skills/distill-persona/UPGRADE-LOG-RESEARCH-SKILLS.md +100 -0
- package/skills/distill-persona/references/coverage-manifest.md +65 -0
- package/skills/distill-persona/references/cross-skill-differentiation.md +12 -0
- package/skills/distill-persona/references/description-discipline.md +6 -0
- package/skills/distill-persona/references/diagnostic-path.md +25 -0
- package/skills/distill-persona/references/distillation-field-synthesis-pass2.md +59 -0
- package/skills/distill-persona/references/distillation-field-synthesis.md +108 -0
- package/skills/distill-persona/references/fidelity-rubric.md +19 -0
- package/skills/distill-persona/references/field-models.md +20 -0
- package/skills/distill-persona/references/handoff.md +42 -0
- package/skills/distill-persona/references/optional-body-sections.md +9 -0
- package/skills/distill-persona/references/registry-routing.md +11 -0
- package/skills/distill-persona/references/research/lesson-memory-shortcut.md +33 -0
- package/skills/distill-persona/references/research/r1-a-examples.md +23 -0
- package/skills/distill-persona/references/research/r1-b-scripts.md +26 -0
- package/skills/distill-persona/references/research/r1-c-human-readme.md +31 -0
- package/skills/distill-persona/references/research/r1-d-tests.md +28 -0
- package/skills/distill-persona/references/research/r1-verification.md +36 -0
- package/skills/distill-persona/references/research/r2-low-yield.md +26 -0
- package/skills/distill-persona/references/self-upgrade-directive.md +20 -0
- package/skills/distill-persona/references/taste-principles.md +8 -0
- package/skills/distill-persona/references/topic-variant.md +13 -0
- package/skills/distill-persona/references/update-mode.md +7 -0
- package/skills/distill-persona/scripts/fidelity_eval.py +244 -0
- package/skills/distill-persona/scripts/validate-run.mjs +297 -0
- package/skills/distill-persona/scripts/validate-skill-structure.mjs +177 -0
- package/skills/distill-software/BUILD-NOTES.md +56 -0
- package/skills/distill-software/SKILL.md +363 -0
- package/skills/distill-software/references/handoff.md +47 -0
- package/skills/distill-software/scripts/code_dna.py +290 -0
- package/skills/research/DISTILLATION-PROCESS-CHECKLIST.md +120 -0
- package/skills/research/EXCAVATION-CHECKLIST.md +142 -0
- package/skills/research/FIDELITY.md +180 -0
- package/skills/research/SKILL.md +432 -0
- package/skills/research/references/anti-patterns.md +184 -0
- package/skills/research/references/fidelity.md +241 -0
- package/skills/research/references/handoff.md +48 -0
- package/skills/research/references/research-protocol.md +162 -0
- package/skills/research/references/source-inventory.md +135 -0
- package/skills/research/references/verified-models.md +163 -0
- package/skills/research/scripts/__pycache__/safe_io.cpython-312.pyc +0 -0
- package/skills/research/scripts/code_dna.py +233 -0
- package/skills/research/scripts/emit_run_summary.py +142 -0
- package/skills/research/scripts/safe_io.py +314 -0
- package/skills/research/scripts/source_evaluator.py +234 -0
- package/skills/research/scripts/validate-skill-structure.mjs +177 -0
- package/skills/research/scripts/verify_citations.py +225 -0
- package/skills/security-priority.json +28 -0
- package/src/config/config.ts +1 -0
- package/src/config/role-tools.ts +6 -3
- package/src/config/types.ts +8 -0
- package/src/extension/crew-cleanup.ts +18 -1
- package/src/extension/crew-vibes/index.ts +11 -2
- package/src/extension/register.ts +1 -1
- package/src/extension/registration/command-registration.ts +1 -0
- package/src/extension/registration/commands.ts +7 -3
- package/src/extension/registration/lifecycle-handlers.ts +1 -3
- package/src/extension/registration/ui.ts +4 -0
- package/src/extension/registration/viewers.ts +3 -0
- package/src/extension/team-tool/run.ts +7 -6
- package/src/runtime/background-runner.ts +11 -16
- package/src/runtime/chain-runner.ts +3 -2
- package/src/runtime/heartbeat-watcher.ts +28 -1
- package/src/runtime/pipeline-runner.ts +8 -7
- package/src/runtime/task-runner.ts +165 -119
- package/src/schema/config-schema.ts +1 -0
- package/src/ui/live-run-sidebar.ts +2 -0
- package/src/ui/mascot.ts +11 -9
- package/src/ui/render-coalescer.ts +9 -0
- package/src/ui/run-snapshot-cache.ts +10 -11
- package/src/ui/terminal-status.ts +5 -0
- package/src/ui/widget/index.ts +3 -5
- package/src/ui/widget/widget-types.ts +0 -1
- package/src/utils/gh-protocol.ts +9 -8
- package/workflows/distill.workflow.md +198 -0
- package/assets/runner-spritesheet.png +0 -0
|
@@ -15,6 +15,7 @@ import { assertCleanLeaderAsync, findGitRootAsync } from "../../worktree/worktre
|
|
|
15
15
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- type-only import for TS inference
|
|
16
16
|
const _typeCheck: typeof ExecuteTeamRunFn = null as never as typeof ExecuteTeamRunFn;
|
|
17
17
|
|
|
18
|
+
import { errorMessage } from "../../utils/guards.ts";
|
|
18
19
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
19
20
|
import { resolveContainedPath, resolveRealContainedPath } from "../../utils/safe-paths.ts";
|
|
20
21
|
|
|
@@ -432,7 +433,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
432
433
|
try {
|
|
433
434
|
await assertCleanLeaderAsync(gitRoot);
|
|
434
435
|
} catch (err) {
|
|
435
|
-
const msg =
|
|
436
|
+
const msg = errorMessage(err);
|
|
436
437
|
return result(
|
|
437
438
|
`${msg}\nCommit or stash changes before using worktree mode, or use workspaceMode: 'single'.`,
|
|
438
439
|
{ action: "run", status: "error" },
|
|
@@ -655,7 +656,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
655
656
|
// Round-11 runtime fix: persist manifest with status=failed when runner throws
|
|
656
657
|
// (e.g., script timeout, script syntax error, async failure). Previously the
|
|
657
658
|
// manifest stayed at 'queued' indefinitely, leaving an orphan state file.
|
|
658
|
-
const failureReason =
|
|
659
|
+
const failureReason = errorMessage(runnerError);
|
|
659
660
|
const failedManifest = {
|
|
660
661
|
...dwfManifest,
|
|
661
662
|
status: "failed" as const,
|
|
@@ -856,13 +857,13 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
856
857
|
workspaceId: ctx.sessionId ?? ctx.cwd,
|
|
857
858
|
});
|
|
858
859
|
} catch (waitError: unknown) {
|
|
859
|
-
const
|
|
860
|
+
const waitErrMsg = errorMessage(waitError);
|
|
860
861
|
return result(
|
|
861
862
|
[
|
|
862
863
|
`pi-crew run timed out or failed: ${updatedManifest.runId}`,
|
|
863
864
|
`Team: ${team.name}`,
|
|
864
865
|
`Workflow: ${workflow.name}`,
|
|
865
|
-
`Error: ${
|
|
866
|
+
`Error: ${waitErrMsg}`,
|
|
866
867
|
"",
|
|
867
868
|
`Check status with: team status runId=${updatedManifest.runId}`,
|
|
868
869
|
`State: ${updatedManifest.stateRoot}`,
|
|
@@ -970,13 +971,13 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
970
971
|
workspaceId: ctx.sessionId ?? ctx.cwd,
|
|
971
972
|
});
|
|
972
973
|
} catch (waitError: unknown) {
|
|
973
|
-
const
|
|
974
|
+
const waitErrMsg = errorMessage(waitError);
|
|
974
975
|
return result(
|
|
975
976
|
[
|
|
976
977
|
`pi-crew run timed out or failed: ${updatedManifest.runId}`,
|
|
977
978
|
`Team: ${team.name}`,
|
|
978
979
|
`Workflow: ${workflow.name}`,
|
|
979
|
-
`Error: ${
|
|
980
|
+
`Error: ${waitErrMsg}`,
|
|
980
981
|
"",
|
|
981
982
|
`Check status with: team status runId=${updatedManifest.runId}`,
|
|
982
983
|
`State: ${updatedManifest.stateRoot}`,
|
|
@@ -8,6 +8,7 @@ import { withRunLockSync } from "../state/locks.ts";
|
|
|
8
8
|
import { createRunPaths, loadRunManifestById, saveRunManifestAsync, updateRunStatus } from "../state/state-store.ts";
|
|
9
9
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
10
10
|
import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
|
|
11
|
+
import { errorMessage } from "../utils/guards.ts";
|
|
11
12
|
import { projectCrewRoot } from "../utils/paths.ts";
|
|
12
13
|
import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
|
|
13
14
|
// Heavy runtime — lazy-loaded to avoid pulling team-runner into background-runner
|
|
@@ -175,7 +176,7 @@ function setupUnhandledRejectionGuard(
|
|
|
175
176
|
setExitFlag: () => void,
|
|
176
177
|
): void {
|
|
177
178
|
process.on("unhandledRejection", (reason, promise) => {
|
|
178
|
-
const message =
|
|
179
|
+
const message = errorMessage(reason);
|
|
179
180
|
console.error("[background-runner] UNHANDLED REJECTION:", reason);
|
|
180
181
|
console.error("[background-runner] Stack:", reason instanceof Error ? reason.stack : "N/A");
|
|
181
182
|
try {
|
|
@@ -234,9 +235,7 @@ function runCleanup(
|
|
|
234
235
|
try {
|
|
235
236
|
killed = terminateActiveChildPiProcesses();
|
|
236
237
|
} catch (error) {
|
|
237
|
-
console.log(
|
|
238
|
-
`[background-runner] runCleanup: terminateActiveChildPiProcesses error: ${error instanceof Error ? error.message : String(error)}`,
|
|
239
|
-
);
|
|
238
|
+
console.log(`[background-runner] runCleanup: terminateActiveChildPiProcesses error: ${errorMessage(error)}`);
|
|
240
239
|
}
|
|
241
240
|
console.log(`[background-runner] runCleanup: killed ${killed} child processes`);
|
|
242
241
|
// FIX Issue #5: Unregister this worker from the orphan registry on exit.
|
|
@@ -245,13 +244,13 @@ function runCleanup(
|
|
|
245
244
|
try {
|
|
246
245
|
unregisterWorker(process.pid);
|
|
247
246
|
} catch (error) {
|
|
248
|
-
console.log(`[background-runner] runCleanup: unregisterWorker error: ${
|
|
247
|
+
console.log(`[background-runner] runCleanup: unregisterWorker error: ${errorMessage(error)}`);
|
|
249
248
|
if (eventsPath) {
|
|
250
249
|
try {
|
|
251
250
|
appendEvent(eventsPath, {
|
|
252
251
|
type: "background.unregister_worker_failed",
|
|
253
252
|
runId: argValue("--run-id") ?? "unknown",
|
|
254
|
-
message: `unregisterWorker failed: ${
|
|
253
|
+
message: `unregisterWorker failed: ${errorMessage(error)}`,
|
|
255
254
|
data: { pid: process.pid },
|
|
256
255
|
});
|
|
257
256
|
} catch {
|
|
@@ -391,7 +390,7 @@ async function main(): Promise<void> {
|
|
|
391
390
|
staleMs: 30_000,
|
|
392
391
|
});
|
|
393
392
|
} catch (lockErr) {
|
|
394
|
-
throw new Error(`Failed to acquire lock for run '${runId}': ${
|
|
393
|
+
throw new Error(`Failed to acquire lock for run '${runId}': ${errorMessage(lockErr)}`);
|
|
395
394
|
}
|
|
396
395
|
if (!loaded) throw new Error(`Run '${runId}' not found.`);
|
|
397
396
|
let { manifest, tasks } = loaded;
|
|
@@ -750,9 +749,7 @@ async function main(): Promise<void> {
|
|
|
750
749
|
}
|
|
751
750
|
console.log(`[background-runner] executeTeamRun returned, status=${result.manifest.status}`);
|
|
752
751
|
} catch (execError) {
|
|
753
|
-
console.log(
|
|
754
|
-
`[background-runner] executeTeamRun THREW: ${execError instanceof Error ? execError.message : String(execError)}`,
|
|
755
|
-
);
|
|
752
|
+
console.log(`[background-runner] executeTeamRun THREW: ${errorMessage(execError)}`);
|
|
756
753
|
console.log(`[background-runner] stack: ${execError instanceof Error ? execError.stack : "N/A"}`);
|
|
757
754
|
throw execError;
|
|
758
755
|
}
|
|
@@ -782,7 +779,7 @@ async function main(): Promise<void> {
|
|
|
782
779
|
} catch {
|
|
783
780
|
/* best-effort */
|
|
784
781
|
}
|
|
785
|
-
const message =
|
|
782
|
+
const message = errorMessage(error);
|
|
786
783
|
manifest = updateRunStatus(manifest, "failed", message);
|
|
787
784
|
appendEvent(manifest.eventsPath, {
|
|
788
785
|
type: "async.failed",
|
|
@@ -790,7 +787,7 @@ async function main(): Promise<void> {
|
|
|
790
787
|
message,
|
|
791
788
|
});
|
|
792
789
|
process.exitCode = 1;
|
|
793
|
-
console.log(`[background-runner] catch block, error=${
|
|
790
|
+
console.log(`[background-runner] catch block, error=${errorMessage(error)}`);
|
|
794
791
|
} finally {
|
|
795
792
|
// FIX Issue #4: Use shared runCleanup() function for consistent cleanup
|
|
796
793
|
// across all exit paths (normal, unhandled rejection, main() exception).
|
|
@@ -807,9 +804,7 @@ async function main(): Promise<void> {
|
|
|
807
804
|
manifest.eventsPath,
|
|
808
805
|
);
|
|
809
806
|
} catch (cleanupError) {
|
|
810
|
-
console.error(
|
|
811
|
-
`[background-runner] runCleanup threw: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`,
|
|
812
|
-
);
|
|
807
|
+
console.error(`[background-runner] runCleanup threw: ${errorMessage(cleanupError)}`);
|
|
813
808
|
}
|
|
814
809
|
// NOTE: If exitDueToRejection was set, runCleanup() already called process.exit(1)
|
|
815
810
|
// so this finally block never continues past that point.
|
|
@@ -828,7 +823,7 @@ async function main(): Promise<void> {
|
|
|
828
823
|
try {
|
|
829
824
|
await main();
|
|
830
825
|
} catch (err) {
|
|
831
|
-
console.error(`[background-runner] DEBUG: main() uncaught: ${
|
|
826
|
+
console.error(`[background-runner] DEBUG: main() uncaught: ${errorMessage(err)}`);
|
|
832
827
|
// FIX Issue #1: Set the flag so the finally block's runCleanup() call
|
|
833
828
|
// will trigger process.exit(1) after cleanup completes. Previously this
|
|
834
829
|
// called process.exit(1) directly, bypassing the finally block and leaving
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* @see docs/pi-boomerang-integration-plan.md
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { errorMessage } from "../utils/guards.ts";
|
|
13
14
|
import type { ChainStep as DSLChainStep } from "./chain-parser.ts";
|
|
14
15
|
import { parseChainDSL } from "./chain-parser.ts";
|
|
15
16
|
import type { HandoffManager, HandoffSummary, TaskPacket, TaskResult } from "./handoff-manager.ts";
|
|
@@ -250,14 +251,14 @@ export class ChainRunner {
|
|
|
250
251
|
});
|
|
251
252
|
}
|
|
252
253
|
} catch (error) {
|
|
253
|
-
const
|
|
254
|
+
const errMsg = errorMessage(error);
|
|
254
255
|
|
|
255
256
|
stepResults.push({
|
|
256
257
|
step: i + 1,
|
|
257
258
|
name: step.name,
|
|
258
259
|
outcome: "failure",
|
|
259
260
|
duration: Date.now() - stepStart,
|
|
260
|
-
error:
|
|
261
|
+
error: errMsg,
|
|
261
262
|
});
|
|
262
263
|
|
|
263
264
|
// Stop chain on failure unless configured to continue
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
2
3
|
import type { NotificationDescriptor } from "../extension/notification-router.ts";
|
|
3
4
|
import type { MetricRegistry } from "../observability/metric-registry.ts";
|
|
4
5
|
import { appendEvent } from "../state/event-log.ts";
|
|
@@ -142,6 +143,28 @@ export class HeartbeatWatcher {
|
|
|
142
143
|
if (level === "dead" && isProcessAlive) {
|
|
143
144
|
level = "stale";
|
|
144
145
|
}
|
|
146
|
+
// W8 fix: completion-artifact check — prevents false-positive "dead"
|
|
147
|
+
// during the exit-before-manifest-update race. When a worker process
|
|
148
|
+
// exits normally after completing its task, the result artifact is
|
|
149
|
+
// already on disk, but the manifest status update may lag by a few
|
|
150
|
+
// seconds (status + finishedAt are set atomically in task-runner.ts).
|
|
151
|
+
// If the result file exists, the task completed — downgrade to
|
|
152
|
+
// "stale" so the watcher doesn't fire a misleading "dead" notification
|
|
153
|
+
// for a task that already produced its output.
|
|
154
|
+
// W8-fix-v2 — path-traversal defense-in-depth. task.id is
|
|
155
|
+
// generated internally (e.g. "ts1", "ts2") but we still
|
|
156
|
+
// resolve the candidate path and verify it's strictly
|
|
157
|
+
// contained within <artifactsRoot>/results/. If task.id
|
|
158
|
+
// contained "../" or absolute path segments, the containment
|
|
159
|
+
// check fails and we skip the W8 check (fail-closed: don't
|
|
160
|
+
// accidentally treat a malicious task ID as "completed").
|
|
161
|
+
if (level === "dead" && !isProcessAlive && loaded.manifest.artifactsRoot) {
|
|
162
|
+
const resultsDir = path.resolve(loaded.manifest.artifactsRoot, "results");
|
|
163
|
+
const candidate = path.resolve(resultsDir, `${task.id}.txt`);
|
|
164
|
+
if (candidate.startsWith(resultsDir + path.sep) && fs.existsSync(candidate)) {
|
|
165
|
+
level = "stale";
|
|
166
|
+
}
|
|
167
|
+
}
|
|
145
168
|
this.opts.registry
|
|
146
169
|
.gauge("crew.heartbeat.staleness_ms", "Heartbeat elapsed since last seen, milliseconds")
|
|
147
170
|
.set({ runId: run.runId, taskId: task.id }, Number.isFinite(elapsed) ? elapsed : thresholds.deadMs);
|
|
@@ -161,12 +184,16 @@ export class HeartbeatWatcher {
|
|
|
161
184
|
elapsedMs: Number.isFinite(elapsed) ? elapsed : undefined,
|
|
162
185
|
},
|
|
163
186
|
});
|
|
187
|
+
// W9 fix — prefix title with short run label (first 8 chars of runId)
|
|
188
|
+
// so ambient notifications are scannable when multiple runs are
|
|
189
|
+
// in flight. Full runId remains in the notification object.
|
|
190
|
+
const runLabel = run.runId.slice(0, 8);
|
|
164
191
|
this.opts.router.enqueue({
|
|
165
192
|
id: `dead_${run.runId}_${task.id}`,
|
|
166
193
|
severity: "warning",
|
|
167
194
|
source: "heartbeat-watcher",
|
|
168
195
|
runId: run.runId,
|
|
169
|
-
title: `Task ${task.id} heartbeat dead`,
|
|
196
|
+
title: `[${runLabel}] Task ${task.id} heartbeat dead`,
|
|
170
197
|
body: "Background watcher detected a stuck worker.",
|
|
171
198
|
});
|
|
172
199
|
this.opts.onDead?.(run.runId, task.id, Number.isFinite(elapsed) ? elapsed : thresholds.deadMs);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { errors } from "../errors.ts";
|
|
2
2
|
import { appendEventAsync, flushEventLogBuffer } from "../state/event-log.ts";
|
|
3
|
+
import { errorMessage } from "../utils/guards.ts";
|
|
3
4
|
import type { WorkflowConfig } from "../workflows/workflow-config.ts";
|
|
4
5
|
import { mapConcurrent } from "./parallel-utils.ts";
|
|
5
6
|
|
|
@@ -166,26 +167,26 @@ export class PipelineRunner {
|
|
|
166
167
|
});
|
|
167
168
|
} catch (error) {
|
|
168
169
|
const duration = Date.now() - stageStartTime;
|
|
169
|
-
const
|
|
170
|
+
const errMsg = errorMessage(error);
|
|
170
171
|
|
|
171
172
|
if (effectiveStopOnError) {
|
|
172
173
|
stages.push({
|
|
173
174
|
name: stage.name,
|
|
174
175
|
status: "failed",
|
|
175
176
|
results: [],
|
|
176
|
-
error:
|
|
177
|
+
error: errMsg,
|
|
177
178
|
duration,
|
|
178
179
|
});
|
|
179
180
|
|
|
180
181
|
await appendEventAsync(eventsPath, {
|
|
181
182
|
type: "pipeline:stage_failed",
|
|
182
183
|
runId,
|
|
183
|
-
message: `Stage '${stage.name}' failed: ${
|
|
184
|
+
message: `Stage '${stage.name}' failed: ${errMsg}`,
|
|
184
185
|
data: {
|
|
185
186
|
stageIndex: i,
|
|
186
187
|
stageName: stage.name,
|
|
187
188
|
duration,
|
|
188
|
-
error:
|
|
189
|
+
error: errMsg,
|
|
189
190
|
},
|
|
190
191
|
});
|
|
191
192
|
|
|
@@ -193,7 +194,7 @@ export class PipelineRunner {
|
|
|
193
194
|
type: "pipeline:failed",
|
|
194
195
|
runId,
|
|
195
196
|
message: `Pipeline '${workflow.name}' failed at stage '${stage.name}'`,
|
|
196
|
-
data: { failedStage: stage.name, error:
|
|
197
|
+
data: { failedStage: stage.name, error: errMsg },
|
|
197
198
|
});
|
|
198
199
|
|
|
199
200
|
return {
|
|
@@ -207,7 +208,7 @@ export class PipelineRunner {
|
|
|
207
208
|
name: stage.name,
|
|
208
209
|
status: "failed",
|
|
209
210
|
results: [],
|
|
210
|
-
error:
|
|
211
|
+
error: errMsg,
|
|
211
212
|
duration,
|
|
212
213
|
});
|
|
213
214
|
|
|
@@ -219,7 +220,7 @@ export class PipelineRunner {
|
|
|
219
220
|
stageIndex: i,
|
|
220
221
|
stageName: stage.name,
|
|
221
222
|
duration,
|
|
222
|
-
error:
|
|
223
|
+
error: errMsg,
|
|
223
224
|
},
|
|
224
225
|
});
|
|
225
226
|
}
|
|
@@ -485,131 +485,177 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
485
485
|
data: { role: task.role, model: model ?? "default" },
|
|
486
486
|
});
|
|
487
487
|
upsertCrewAgent(manifest, recordFromTask(manifest, task, "child-process"));
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
}
|
|
518
|
-
} catch (err) {
|
|
519
|
-
logInternalError("task-runner.on-spawn", err as Error, `pid=${pid}, taskId=${task.id}`);
|
|
520
|
-
}
|
|
521
|
-
},
|
|
522
|
-
onLifecycleEvent: (event: ChildPiLifecycleEvent) => {
|
|
523
|
-
void appendEventAsync(manifest.eventsPath, {
|
|
524
|
-
type: `worker.${event.type}` as const,
|
|
525
|
-
runId: manifest.runId,
|
|
526
|
-
taskId: task.id,
|
|
527
|
-
message: `Worker lifecycle: ${event.type}${event.error ? ` error=${event.error}` : ""}${event.exitCode != null ? ` exit=${event.exitCode}` : ""}`,
|
|
528
|
-
data: { ...event },
|
|
529
|
-
}).catch((error) =>
|
|
530
|
-
logInternalError("task-runner.lifecycle-event", error, `taskId=${task.id}, type=${event.type}`),
|
|
531
|
-
);
|
|
532
|
-
},
|
|
533
|
-
onStdoutLine: (line) => {
|
|
534
|
-
appendCrewAgentOutput(manifest, task.id, line);
|
|
535
|
-
persistHeartbeat();
|
|
536
|
-
// Check for supervisor contact requests from child Pi
|
|
537
|
-
const contact = parseSupervisorContactFromLine(line);
|
|
538
|
-
if (contact) {
|
|
539
|
-
recordSupervisorContact(manifest, {
|
|
540
|
-
runId: manifest.runId,
|
|
541
|
-
...contact,
|
|
542
|
-
});
|
|
488
|
+
// W2 fix — wall-clock timeout per task. We create our own
|
|
489
|
+
// AbortController, link the caller's signal to it, and abort
|
|
490
|
+
// from a timer. The internal signal is passed to runChildPi so
|
|
491
|
+
// the existing SIGTERM → SIGKILL escalation in child-pi.ts
|
|
492
|
+
// handles cleanup. Prevents runaway agent loops (e.g. 11_build
|
|
493
|
+
// in the oh-my-pi distill run that re-verified completed files
|
|
494
|
+
// 14+ times).
|
|
495
|
+
const taskTimeoutMs = input.runtimeConfig?.taskTimeoutMs ?? 0;
|
|
496
|
+
const timeoutController = new AbortController();
|
|
497
|
+
// W2 fix (memory leak) — store the listener reference so we can
|
|
498
|
+
// removeEventListener() it in the finally block below. { once: true }
|
|
499
|
+
// alone is NOT enough: when the timeout fires first, the listener
|
|
500
|
+
// never fires → { once: true } never auto-removes → listener stays
|
|
501
|
+
// attached to input.signal for the rest of the run (run-level
|
|
502
|
+
// signal = long-lived → leak accumulates per task run).
|
|
503
|
+
let externalAbortListener: (() => void) | undefined;
|
|
504
|
+
if (input.signal) {
|
|
505
|
+
if (input.signal.aborted) {
|
|
506
|
+
timeoutController.abort(input.signal.reason);
|
|
507
|
+
} else {
|
|
508
|
+
externalAbortListener = () => timeoutController.abort(input.signal!.reason);
|
|
509
|
+
input.signal.addEventListener("abort", externalAbortListener, { once: true });
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
513
|
+
if (taskTimeoutMs > 0 && !timeoutController.signal.aborted) {
|
|
514
|
+
timeoutHandle = setTimeout(() => {
|
|
515
|
+
if (!timeoutController.signal.aborted) {
|
|
516
|
+
timeoutController.abort(new Error(`Task exceeded wall-clock timeout of ${taskTimeoutMs}ms`));
|
|
543
517
|
}
|
|
544
|
-
},
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
518
|
+
}, taskTimeoutMs);
|
|
519
|
+
timeoutHandle.unref?.();
|
|
520
|
+
}
|
|
521
|
+
let childResult;
|
|
522
|
+
try {
|
|
523
|
+
childResult = await runChildPi({
|
|
524
|
+
cwd: task.cwd,
|
|
525
|
+
task: prompt,
|
|
526
|
+
agent: input.agent,
|
|
527
|
+
model,
|
|
528
|
+
signal: timeoutController.signal,
|
|
529
|
+
transcriptPath,
|
|
530
|
+
maxDepth: input.limits?.maxTaskDepth,
|
|
531
|
+
skillPaths,
|
|
532
|
+
maxTurns: input.runtimeConfig?.maxTurns,
|
|
533
|
+
graceTurns: input.runtimeConfig?.graceTurns,
|
|
534
|
+
inheritContext: input.runtimeConfig?.inheritContext,
|
|
535
|
+
parentContext: input.parentContext,
|
|
536
|
+
excludeContextBash: input.runtimeConfig?.excludeContextBash,
|
|
537
|
+
sessionId: manifest.sessionId,
|
|
538
|
+
role: task.role,
|
|
539
|
+
runId: manifest.runId,
|
|
540
|
+
agentId: task.id,
|
|
541
|
+
artifactsRoot: manifest.artifactsRoot,
|
|
542
|
+
steeringFile: resolveContainedPath(`${manifest.artifactsRoot}/steering`, `${task.id}.jsonl`),
|
|
543
|
+
onSpawn: (pid) => {
|
|
544
|
+
try {
|
|
545
|
+
({ task, tasks } = checkpointTask(manifest, tasks, task, "child-spawned", pid));
|
|
546
|
+
if (task.pendingSteers?.length) {
|
|
547
|
+
const steeringDir = `${manifest.artifactsRoot}/steering`;
|
|
548
|
+
// Fire-and-forget async write for steering events
|
|
549
|
+
void appendSteeringAsync(steeringDir, task.id, task.pendingSteers);
|
|
550
|
+
task.pendingSteers = [];
|
|
551
|
+
tasks = persistSingleTaskUpdate(manifest, tasks, task);
|
|
567
552
|
}
|
|
553
|
+
} catch (err) {
|
|
554
|
+
logInternalError("task-runner.on-spawn", err as Error, `pid=${pid}, taskId=${task.id}`);
|
|
568
555
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
// Always keep in-memory agentProgress fresh (cheap) so the UI/events see
|
|
580
|
-
// the latest progress, but THROTTLE the disk persist. Previously this
|
|
581
|
-
// did a full locked read-parse-write of tasks.json on EVERY child JSON
|
|
582
|
-
// event — a 200-event task produced 200 such cycles (Round 15 P1).
|
|
583
|
-
// Final state is force-flushed on task completion (persistHeartbeat(true)).
|
|
584
|
-
const nextProgress = applyAgentProgressEvent(
|
|
585
|
-
task.agentProgress ?? emptyCrewAgentProgress(),
|
|
586
|
-
event,
|
|
587
|
-
task.startedAt,
|
|
556
|
+
},
|
|
557
|
+
onLifecycleEvent: (event: ChildPiLifecycleEvent) => {
|
|
558
|
+
void appendEventAsync(manifest.eventsPath, {
|
|
559
|
+
type: `worker.${event.type}` as const,
|
|
560
|
+
runId: manifest.runId,
|
|
561
|
+
taskId: task.id,
|
|
562
|
+
message: `Worker lifecycle: ${event.type}${event.error ? ` error=${event.error}` : ""}${event.exitCode != null ? ` exit=${event.exitCode}` : ""}`,
|
|
563
|
+
data: { ...event },
|
|
564
|
+
}).catch((error) =>
|
|
565
|
+
logInternalError("task-runner.lifecycle-event", error, `taskId=${task.id}, type=${event.type}`),
|
|
588
566
|
);
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
if (input.onJsonEvent) {
|
|
601
|
-
input.onJsonEvent(task.id, manifest.runId, event);
|
|
567
|
+
},
|
|
568
|
+
onStdoutLine: (line) => {
|
|
569
|
+
appendCrewAgentOutput(manifest, task.id, line);
|
|
570
|
+
persistHeartbeat();
|
|
571
|
+
// Check for supervisor contact requests from child Pi
|
|
572
|
+
const contact = parseSupervisorContactFromLine(line);
|
|
573
|
+
if (contact) {
|
|
574
|
+
recordSupervisorContact(manifest, {
|
|
575
|
+
runId: manifest.runId,
|
|
576
|
+
...contact,
|
|
577
|
+
});
|
|
602
578
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
579
|
+
},
|
|
580
|
+
onJsonEvent: (event) => {
|
|
581
|
+
// Top-level error boundary: prevent any single event from crashing the task.
|
|
582
|
+
// Errors are logged but processing continues so subsequent events still update state.
|
|
583
|
+
try {
|
|
584
|
+
appendCrewAgentEvent(manifest, task.id, event);
|
|
585
|
+
if (collectedJsonEvents && event && typeof event === "object" && !Array.isArray(event))
|
|
586
|
+
collectedJsonEvents.push(event as Record<string, unknown>);
|
|
587
|
+
if (collectedJsonEvents && collectedJsonEvents.length > 1000) {
|
|
588
|
+
collectedJsonEvents.splice(0, collectedJsonEvents.length - 1000);
|
|
589
|
+
}
|
|
590
|
+
// Accumulate lifetime usage via message_end events (survives compaction)
|
|
591
|
+
if (event && typeof event === "object" && (event as Record<string, unknown>).type === "message_end") {
|
|
592
|
+
const msg = (event as Record<string, unknown>).message as Record<string, unknown> | undefined;
|
|
593
|
+
if (msg?.role === "assistant") {
|
|
594
|
+
const usage = msg.usage as Record<string, number> | undefined;
|
|
595
|
+
if (usage) {
|
|
596
|
+
task.lifetimeUsage = {
|
|
597
|
+
input: (task.lifetimeUsage?.input ?? 0) + (usage.input ?? 0),
|
|
598
|
+
output: (task.lifetimeUsage?.output ?? 0) + (usage.output ?? 0),
|
|
599
|
+
cacheWrite: (task.lifetimeUsage?.cacheWrite ?? 0) + (usage.cacheWrite ?? 0),
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
persistHeartbeat();
|
|
605
|
+
// Bug #3 fix: Write worker JSON events to background.log for debugging when running in background mode.
|
|
606
|
+
// This supplements the event log so developers can see what the child Pi worker produced.
|
|
607
|
+
if (process.env.PI_CREW_BACKGROUND_MODE === "1" && event) {
|
|
608
|
+
const bgLogPath = `${manifest.stateRoot}/background.log`;
|
|
609
|
+
const eventLine =
|
|
610
|
+
typeof event === "object" && !Array.isArray(event) ? JSON.stringify(event) : String(event);
|
|
611
|
+
// Fire-and-forget async write for background log
|
|
612
|
+
void appendBackgroundLogAsync(bgLogPath, eventLine);
|
|
613
|
+
}
|
|
614
|
+
// Always keep in-memory agentProgress fresh (cheap) so the UI/events see
|
|
615
|
+
// the latest progress, but THROTTLE the disk persist. Previously this
|
|
616
|
+
// did a full locked read-parse-write of tasks.json on EVERY child JSON
|
|
617
|
+
// event — a 200-event task produced 200 such cycles (Round 15 P1).
|
|
618
|
+
// Final state is force-flushed on task completion (persistHeartbeat(true)).
|
|
619
|
+
const nextProgress = applyAgentProgressEvent(
|
|
620
|
+
task.agentProgress ?? emptyCrewAgentProgress(),
|
|
621
|
+
event,
|
|
622
|
+
task.startedAt,
|
|
623
|
+
);
|
|
624
|
+
task = { ...task, agentProgress: nextProgress };
|
|
625
|
+
tasks = updateTask(tasks, task);
|
|
626
|
+
const progressNow = Date.now();
|
|
627
|
+
if (progressNow - lastTaskProgressPersistedAt >= 500) {
|
|
628
|
+
tasks = persistSingleTaskUpdate(manifest, tasks, task);
|
|
629
|
+
lastTaskProgressPersistedAt = progressNow;
|
|
630
|
+
}
|
|
631
|
+
// Bridge event to UI event bus for near-instant updates
|
|
632
|
+
const bridgeEvent = bridgeEventFromJsonEvent(manifest.runId, task.id, event);
|
|
633
|
+
if (bridgeEvent) streamBridge?.handler(bridgeEvent);
|
|
634
|
+
// Feed overflow recovery tracker
|
|
635
|
+
if (input.onJsonEvent) {
|
|
636
|
+
input.onJsonEvent(task.id, manifest.runId, event);
|
|
637
|
+
}
|
|
638
|
+
if (!finalCheckpointWritten && isFinalChildEvent(event)) {
|
|
639
|
+
finalCheckpointWritten = true;
|
|
640
|
+
({ task, tasks } = checkpointTask(manifest, tasks, task, "child-stdout-final"));
|
|
641
|
+
}
|
|
642
|
+
persistChildProgress(event);
|
|
643
|
+
} catch (err) {
|
|
644
|
+
logInternalError("task-runner.on-json-event", err as Error, `taskId=${task.id}`);
|
|
606
645
|
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
}
|
|
612
|
-
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
} finally {
|
|
649
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
650
|
+
// W2 fix — release the listener so it doesn't leak. {once:true}
|
|
651
|
+
// only auto-removes when the listener FIRES; if the timeout
|
|
652
|
+
// fires first, the listener never fires and stays attached
|
|
653
|
+
// to input.signal (run-level signal = long-lived → leak per
|
|
654
|
+
// task). Remove explicitly here.
|
|
655
|
+
if (externalAbortListener && input.signal) {
|
|
656
|
+
input.signal.removeEventListener("abort", externalAbortListener);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
613
659
|
const evidenceStatus = childResult.exitStatus?.cancelled
|
|
614
660
|
? "cancelled"
|
|
615
661
|
: childResult.error || (childResult.exitCode && childResult.exitCode !== 0)
|
|
@@ -51,6 +51,7 @@ export const PiTeamsRuntimeConfigSchema = Type.Object(
|
|
|
51
51
|
allowChildProcessFallback: Type.Optional(Type.Boolean()),
|
|
52
52
|
maxTurns: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
53
53
|
graceTurns: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
54
|
+
taskTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
54
55
|
inheritContext: Type.Optional(Type.Boolean()),
|
|
55
56
|
promptMode: Type.Optional(Type.Union([Type.Literal("replace"), Type.Literal("append")])),
|
|
56
57
|
groupJoin: Type.Optional(Type.Union([Type.Literal("off"), Type.Literal("group"), Type.Literal("smart")])),
|
|
@@ -270,10 +270,12 @@ export class LiveRunSidebar {
|
|
|
270
270
|
if (isTerminal && !hasActiveAgents && !this.hasAutoClosed) {
|
|
271
271
|
const autoCloseMs = this.config?.autoCloseDashboardMs ?? 3000;
|
|
272
272
|
if (autoCloseMs > 0) {
|
|
273
|
+
if (this.autoCloseTimeout) clearTimeout(this.autoCloseTimeout);
|
|
273
274
|
this.autoCloseTimeout = setTimeout(() => {
|
|
274
275
|
this.hasAutoClosed = true;
|
|
275
276
|
this.done(undefined);
|
|
276
277
|
}, autoCloseMs);
|
|
278
|
+
this.autoCloseTimeout?.unref();
|
|
277
279
|
lines.push(line(`auto-close in ${Math.round(autoCloseMs / 1000)}s…`, w));
|
|
278
280
|
}
|
|
279
281
|
}
|