pi-crew 0.9.34 → 0.9.35
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 +109 -0
- package/dist/build-meta.json +257 -261
- package/dist/index.mjs +2127 -1937
- package/dist/index.mjs.map +4 -4
- package/docs/performance-optimization-execution-plan.md +520 -0
- package/package.json +1 -1
- package/src/extension/team-tool/api.ts +8 -8
- package/src/extension/team-tool/goal-wrap.ts +2 -2
- package/src/extension/team-tool/goal.ts +2 -2
- package/src/extension/team-tool/handle-schedule.ts +102 -0
- package/src/extension/team-tool/run.ts +0 -42
- package/src/extension/team-tool.ts +13 -8
- package/src/runtime/adaptive-plan.ts +3 -3
- package/src/runtime/background-runner.ts +4 -4
- package/src/runtime/child-pi.ts +191 -56
- package/src/runtime/live-session-runtime.ts +27 -12
- package/src/runtime/skill-instructions.ts +61 -2
- package/src/runtime/stale-reconciler.ts +5 -0
- package/src/runtime/task-runner/tail-read.ts +12 -0
- package/src/runtime/task-runner.ts +40 -19
- package/src/runtime/team-runner.ts +95 -52
- package/src/schema/team-tool-schema.ts +4 -0
- package/src/state/atomic-write.ts +5 -1
- package/src/state/state-store.ts +75 -2
- package/src/utils/token-counter.ts +168 -35
- package/workflows/pipeline.workflow.md +13 -12
|
@@ -10,6 +10,11 @@ const CREW_SCHEDULER_KEY = Symbol.for("pi-crew:scheduler");
|
|
|
10
10
|
type SchedulerRef = {
|
|
11
11
|
add(job: import("../../runtime/scheduler.ts").ScheduledJob): void;
|
|
12
12
|
list(): import("../../runtime/scheduler.ts").ScheduledJob[];
|
|
13
|
+
remove(id: string): boolean;
|
|
14
|
+
update(
|
|
15
|
+
id: string,
|
|
16
|
+
patch: Partial<import("../../runtime/scheduler.ts").ScheduledJob>,
|
|
17
|
+
): import("../../runtime/scheduler.ts").ScheduledJob | undefined;
|
|
13
18
|
};
|
|
14
19
|
|
|
15
20
|
function getCrewScheduler(): SchedulerRef | undefined {
|
|
@@ -65,7 +70,27 @@ function buildScheduleSpec(params: ScheduleParams): {
|
|
|
65
70
|
throw new Error("schedule requires one of: cron, interval, or once.");
|
|
66
71
|
}
|
|
67
72
|
|
|
73
|
+
function getSubAction(params: TeamToolParamsValue): string | undefined {
|
|
74
|
+
const raw = params.subAction ?? (params.config as { subAction?: unknown } | undefined)?.subAction;
|
|
75
|
+
return typeof raw === "string" ? raw.toLowerCase() : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getJobIdParam(params: TeamToolParamsValue): string {
|
|
79
|
+
const fromTop = typeof params.jobId === "string" ? params.jobId : "";
|
|
80
|
+
const fromConfig = ((params.config as { jobId?: unknown } | undefined)?.jobId as string | undefined) ?? "";
|
|
81
|
+
return (fromTop || fromConfig).trim();
|
|
82
|
+
}
|
|
83
|
+
|
|
68
84
|
export function handleSchedule(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
|
|
85
|
+
// Route subactions FIRST so remove/disable/enable don't require a goal.
|
|
86
|
+
const subAction = getSubAction(params);
|
|
87
|
+
if (subAction === "remove" || subAction === "delete") {
|
|
88
|
+
return handleRemoveScheduled(params, ctx);
|
|
89
|
+
}
|
|
90
|
+
if (subAction === "disable" || subAction === "enable" || subAction === "update") {
|
|
91
|
+
return handleUpdateScheduled(params, ctx);
|
|
92
|
+
}
|
|
93
|
+
|
|
69
94
|
const team = params.team ?? "default";
|
|
70
95
|
const goal = params.goal ?? params.task ?? "";
|
|
71
96
|
if (!goal) return result("Schedule requires goal or task.", { action: "schedule", status: "error" }, true);
|
|
@@ -239,3 +264,80 @@ export function handleListScheduled(_params: TeamToolParamsValue, ctx: TeamConte
|
|
|
239
264
|
}
|
|
240
265
|
return result(lines.join("\n"), { action: "scheduled", status: "ok" });
|
|
241
266
|
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Remove a scheduled job. Mirrors the `cancel` action for runs.
|
|
270
|
+
*
|
|
271
|
+
* Usage:
|
|
272
|
+
* team action='schedule' subAction='remove' jobId='<uuid>'
|
|
273
|
+
* team action='schedule' subAction='delete' jobId='<uuid>'
|
|
274
|
+
*/
|
|
275
|
+
export function handleRemoveScheduled(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
|
|
276
|
+
const jobId = getJobIdParam(params);
|
|
277
|
+
if (!jobId) {
|
|
278
|
+
return result(
|
|
279
|
+
"subAction=remove requires jobId. Usage: team action='schedule' subAction='remove' jobId='<uuid>'",
|
|
280
|
+
{ action: "schedule", status: "error" },
|
|
281
|
+
true,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
const scheduler = getCrewScheduler();
|
|
285
|
+
if (!scheduler) {
|
|
286
|
+
return result("Scheduler not running.", { action: "schedule", status: "error" }, true);
|
|
287
|
+
}
|
|
288
|
+
const removed = scheduler.remove(jobId);
|
|
289
|
+
persistScheduledJobRemove(ctx.cwd, jobId);
|
|
290
|
+
if (!removed) {
|
|
291
|
+
return result(`No scheduled job with id '${jobId}'.`, { action: "schedule", status: "error" }, true);
|
|
292
|
+
}
|
|
293
|
+
return result([`Scheduled job removed.`, ` Job ID: ${jobId}`].join("\n"), {
|
|
294
|
+
action: "schedule",
|
|
295
|
+
status: "ok",
|
|
296
|
+
data: { jobId, removed: true },
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Update a scheduled job — toggle enabled flag or apply patches.
|
|
302
|
+
*
|
|
303
|
+
* Usage:
|
|
304
|
+
* team action='schedule' subAction='disable' jobId='<uuid>'
|
|
305
|
+
* team action='schedule' subAction='enable' jobId='<uuid>'
|
|
306
|
+
* team action='schedule' subAction='update' jobId='<uuid>' cron='0 9 * * *'
|
|
307
|
+
*/
|
|
308
|
+
export function handleUpdateScheduled(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
|
|
309
|
+
const jobId = getJobIdParam(params);
|
|
310
|
+
if (!jobId) {
|
|
311
|
+
return result(
|
|
312
|
+
"subAction=update requires jobId. Usage: team action='schedule' subAction='update|enable|disable' jobId='<uuid>'",
|
|
313
|
+
{ action: "schedule", status: "error" },
|
|
314
|
+
true,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
const subAction = typeof params.subAction === "string" ? params.subAction.toLowerCase() : "update";
|
|
318
|
+
const scheduler = getCrewScheduler();
|
|
319
|
+
if (!scheduler) {
|
|
320
|
+
return result("Scheduler not running.", { action: "schedule", status: "error" }, true);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const patch: Partial<import("../../runtime/scheduler.ts").ScheduledJob> = {};
|
|
324
|
+
if (subAction === "disable") patch.enabled = false;
|
|
325
|
+
if (subAction === "enable") patch.enabled = true;
|
|
326
|
+
// For generic "update", allow patch of cron / interval / goal.
|
|
327
|
+
if (subAction === "update") {
|
|
328
|
+
if (typeof params.cron === "string") patch.schedule = params.cron;
|
|
329
|
+
if (typeof params.goal === "string" || typeof params.task === "string") {
|
|
330
|
+
patch.description = typeof params.task === "string" ? params.task : params.goal;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const updated = scheduler.update(jobId, patch);
|
|
335
|
+
if (!updated) {
|
|
336
|
+
return result(`No scheduled job with id '${jobId}'.`, { action: "schedule", status: "error" }, true);
|
|
337
|
+
}
|
|
338
|
+
persistScheduledJobUpdate(ctx.cwd, updated);
|
|
339
|
+
return result(
|
|
340
|
+
[`Scheduled job updated.`, ` Job ID: ${jobId}`, ` Enabled: ${updated.enabled}`, ` Schedule: ${updated.schedule}`].join("\n"),
|
|
341
|
+
{ action: "schedule", status: "ok", data: { jobId, enabled: updated.enabled, schedule: updated.schedule } },
|
|
342
|
+
);
|
|
343
|
+
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { allAgents, discoverAgents } from "../../agents/discover-agents.ts";
|
|
2
2
|
import { loadConfig } from "../../config/config.ts";
|
|
3
|
-
import { PipelineRunner, type PipelineWorkflow } from "../../runtime/pipeline-runner.ts";
|
|
4
3
|
import { sanitizeTaskText } from "../../runtime/task-packet.ts";
|
|
5
4
|
// Heavy runtime — lazy-loaded to avoid 1.4s import cost at extension registration.
|
|
6
5
|
import type { executeTeamRun as ExecuteTeamRunFn } from "../../runtime/team-runner.ts";
|
|
@@ -374,47 +373,6 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
374
373
|
}
|
|
375
374
|
}
|
|
376
375
|
|
|
377
|
-
// Check if this is a pipeline workflow - special handling for multi-stage execution
|
|
378
|
-
const isPipelineWorkflow = workflowName === "pipeline" && !directAgent;
|
|
379
|
-
if (isPipelineWorkflow) {
|
|
380
|
-
// For pipeline workflows, use PipelineRunner for execution
|
|
381
|
-
const pipelineRunner = new PipelineRunner();
|
|
382
|
-
const pipelineWorkflow: PipelineWorkflow = {
|
|
383
|
-
name: workflow.name,
|
|
384
|
-
description: workflow.description,
|
|
385
|
-
goal,
|
|
386
|
-
stages: workflow.steps.map((step) => ({
|
|
387
|
-
name: step.id,
|
|
388
|
-
team: step.role,
|
|
389
|
-
inputs: step.task,
|
|
390
|
-
usePreviousResults: step.dependsOn && step.dependsOn.length > 0,
|
|
391
|
-
})),
|
|
392
|
-
stopOnError: true,
|
|
393
|
-
defaultMaxConcurrency: workflow.maxConcurrency ?? 5,
|
|
394
|
-
};
|
|
395
|
-
|
|
396
|
-
// For now, show pipeline workflow info - full integration would require
|
|
397
|
-
// connecting PipelineRunner to the actual team execution system
|
|
398
|
-
const stageInfo = pipelineWorkflow.stages.map((s) => `- ${s.name} (${s.team})`).join("\n");
|
|
399
|
-
return result(
|
|
400
|
-
[
|
|
401
|
-
`Pipeline workflow '${workflow.name}' is not yet wired into the team execution system.`,
|
|
402
|
-
`Goal: ${goal}`,
|
|
403
|
-
`Defined stages (${pipelineWorkflow.stages.length}):`,
|
|
404
|
-
stageInfo,
|
|
405
|
-
"",
|
|
406
|
-
"To actually run work right now, use a supported workflow instead:",
|
|
407
|
-
" - action='run' workflow='default' (explore → plan → execute → verify)",
|
|
408
|
-
" - action='run' workflow='implementation' (adaptive, parallel specialists)",
|
|
409
|
-
" - action='run' workflow='research' (explore → analyze → write)",
|
|
410
|
-
"",
|
|
411
|
-
"Run action='list' resource='workflow' to see all available workflows.",
|
|
412
|
-
].join("\n"),
|
|
413
|
-
{ action: "run", status: "ok" },
|
|
414
|
-
false,
|
|
415
|
-
);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
376
|
// LAZY: dodge the jiti ESM/CJS interop TDZ race on the static `import { validateWorkflowForTeam }` above (issue #28, RFC 17).
|
|
419
377
|
const { validateWorkflowForTeam: validateWorkflow } = await import("../../workflows/validate-workflow.ts");
|
|
420
378
|
const validationErrors = validateWorkflow(workflow, team);
|
|
@@ -11,7 +11,7 @@ import { writeArtifact } from "../state/artifact-store.ts";
|
|
|
11
11
|
import { appendEvent, appendEventFireAndForget } from "../state/event-log.ts";
|
|
12
12
|
import { withRunLock } from "../state/locks.ts";
|
|
13
13
|
import { replayPendingMailboxMessages } from "../state/mailbox.ts";
|
|
14
|
-
import { loadRunManifestById,
|
|
14
|
+
import { loadRunManifestById, saveRunManifestAsync, saveRunTasks, updateRunStatus } from "../state/state-store.ts";
|
|
15
15
|
import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
16
16
|
import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
|
|
17
17
|
import { assertSafePathId } from "../utils/safe-paths.ts";
|
|
@@ -59,7 +59,7 @@ async function handleRun(...args: Parameters<HandleRunFn>): Promise<Awaited<Retu
|
|
|
59
59
|
|
|
60
60
|
import { FileCheckpointStore } from "../runtime/checkpoint.ts";
|
|
61
61
|
import { waitForRun } from "../runtime/run-tracker.ts";
|
|
62
|
-
import { normalizeSkillOverride } from "../runtime/skill-instructions.ts";
|
|
62
|
+
import { getSkillCacheStats, normalizeSkillOverride } from "../runtime/skill-instructions.ts";
|
|
63
63
|
import { computeRunCacheKey, getCachedRun, getCacheStats } from "../state/run-cache.ts";
|
|
64
64
|
import { listRunGraphs, loadRunGraph } from "../state/run-graph.ts";
|
|
65
65
|
import { searchAgents, searchTeams } from "../utils/bm25-search.ts";
|
|
@@ -223,10 +223,10 @@ function artifactKey(artifact: ArtifactDescriptor): string {
|
|
|
223
223
|
return `${artifact.kind}:${artifact.path}`;
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
function recoverCheckpointedTasks(
|
|
226
|
+
async function recoverCheckpointedTasks(
|
|
227
227
|
manifest: TeamRunManifest,
|
|
228
228
|
tasks: TeamTaskState[],
|
|
229
|
-
): { manifest: TeamRunManifest; tasks: TeamTaskState[]; recovered: string[] } {
|
|
229
|
+
): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[]; recovered: string[] }> {
|
|
230
230
|
const recovered: string[] = [];
|
|
231
231
|
let nextManifest = manifest;
|
|
232
232
|
const nextTasks = tasks.map((task) => {
|
|
@@ -302,7 +302,7 @@ function recoverCheckpointedTasks(
|
|
|
302
302
|
artifacts: [...artifacts.values()],
|
|
303
303
|
updatedAt: new Date().toISOString(),
|
|
304
304
|
};
|
|
305
|
-
|
|
305
|
+
await saveRunManifestAsync(nextManifest);
|
|
306
306
|
saveRunTasks(nextManifest, nextTasks);
|
|
307
307
|
}
|
|
308
308
|
return { manifest: nextManifest, tasks: nextTasks, recovered };
|
|
@@ -346,7 +346,7 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
346
346
|
const lockedManifest = fresh?.manifest ?? loaded.manifest;
|
|
347
347
|
const lockedTasks = fresh?.tasks ?? loaded.tasks;
|
|
348
348
|
const loadedConfig = loadConfig(ctx.cwd);
|
|
349
|
-
const recovered = recoverCheckpointedTasks(lockedManifest, lockedTasks);
|
|
349
|
+
const recovered = await recoverCheckpointedTasks(lockedManifest, lockedTasks);
|
|
350
350
|
const resumeManifest = recovered.manifest;
|
|
351
351
|
const executedConfig = {
|
|
352
352
|
...effectiveRunConfig(loadedConfig.config, params.config),
|
|
@@ -372,7 +372,7 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
372
372
|
runtimeResolution,
|
|
373
373
|
updatedAt: new Date().toISOString(),
|
|
374
374
|
};
|
|
375
|
-
|
|
375
|
+
await saveRunManifestAsync(runtimeManifest);
|
|
376
376
|
appendEvent(runtimeManifest.eventsPath, {
|
|
377
377
|
type: "runtime.resolved",
|
|
378
378
|
runId: runtimeManifest.runId,
|
|
@@ -974,7 +974,12 @@ export async function handleTeamTool(params: TeamToolParamsValue, ctx: TeamConte
|
|
|
974
974
|
});
|
|
975
975
|
}
|
|
976
976
|
const stats = getCacheStats(ctx.cwd);
|
|
977
|
-
|
|
977
|
+
const skillStats = getSkillCacheStats();
|
|
978
|
+
return result(
|
|
979
|
+
`Run cache: ${stats.entries} entries, ${stats.sizeBytes} bytes\n` +
|
|
980
|
+
`Skill cache: ${skillStats.hits} hits, ${skillStats.misses} misses (${(skillStats.hitRate * 100).toFixed(1)}% hit rate), ${skillStats.currentSize}/${skillStats.maxEntries} entries, ${skillStats.evictions} evictions`,
|
|
981
|
+
{ action: "cache", status: "ok" },
|
|
982
|
+
);
|
|
978
983
|
}
|
|
979
984
|
case "checkpoint": {
|
|
980
985
|
if (!params.runId || !params.taskId) {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import * as fs from "node:fs";
|
|
18
18
|
import { writeArtifact } from "../state/artifact-store.ts";
|
|
19
19
|
import { appendEvent } from "../state/event-log.ts";
|
|
20
|
-
import {
|
|
20
|
+
import { saveRunManifestAsync } from "../state/state-store.ts";
|
|
21
21
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
22
22
|
import type { TeamConfig } from "../teams/team-config.ts";
|
|
23
23
|
import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
|
|
@@ -369,7 +369,7 @@ export interface InjectAdaptivePlanResult {
|
|
|
369
369
|
missingPlan: boolean;
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
-
export function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput): InjectAdaptivePlanResult {
|
|
372
|
+
export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput): Promise<InjectAdaptivePlanResult> {
|
|
373
373
|
if (input.workflow.name !== "implementation")
|
|
374
374
|
return {
|
|
375
375
|
tasks: input.tasks,
|
|
@@ -442,7 +442,7 @@ export function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput): Injec
|
|
|
442
442
|
producer: assessTask.id,
|
|
443
443
|
content: `${JSON.stringify({ reason: repair.reason, phases: repair.plan.phases.map((phase) => ({ name: phase.name, count: phase.tasks.length, roles: phase.tasks.map((task) => task.role) })) }, null, 2)}\n`,
|
|
444
444
|
});
|
|
445
|
-
|
|
445
|
+
await saveRunManifestAsync({
|
|
446
446
|
...input.manifest,
|
|
447
447
|
updatedAt: new Date().toISOString(),
|
|
448
448
|
artifacts: [...input.manifest.artifacts, repairArtifact],
|
|
@@ -4,7 +4,7 @@ import { allAgents, discoverAgents } from "../agents/discover-agents.ts";
|
|
|
4
4
|
import { loadConfig } from "../config/config.ts";
|
|
5
5
|
import { appendEvent } from "../state/event-log.ts";
|
|
6
6
|
import { withRunLockSync } from "../state/locks.ts";
|
|
7
|
-
import { createRunPaths, loadRunManifestById,
|
|
7
|
+
import { createRunPaths, loadRunManifestById, saveRunManifestAsync, updateRunStatus } from "../state/state-store.ts";
|
|
8
8
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
9
9
|
import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
|
|
10
10
|
import { projectCrewRoot } from "../utils/paths.ts";
|
|
@@ -621,7 +621,7 @@ async function main(): Promise<void> {
|
|
|
621
621
|
status: runStatus,
|
|
622
622
|
updatedAt: new Date().toISOString(),
|
|
623
623
|
};
|
|
624
|
-
|
|
624
|
+
await saveRunManifestAsync(finalGoalManifest);
|
|
625
625
|
earlyResult = {
|
|
626
626
|
manifest: finalGoalManifest,
|
|
627
627
|
tasks: goalResult.tasks,
|
|
@@ -642,7 +642,7 @@ async function main(): Promise<void> {
|
|
|
642
642
|
signal: abortController.signal,
|
|
643
643
|
tokenBudget: wf.maxTokenBudget,
|
|
644
644
|
});
|
|
645
|
-
|
|
645
|
+
await saveRunManifestAsync(dwfResult.manifest);
|
|
646
646
|
earlyResult = dwfResult;
|
|
647
647
|
}
|
|
648
648
|
console.log(`[background-runner] ${manifest.runKind} returned, status=${earlyResult.manifest.status}`);
|
|
@@ -687,7 +687,7 @@ async function main(): Promise<void> {
|
|
|
687
687
|
runConfig,
|
|
688
688
|
updatedAt: new Date().toISOString(),
|
|
689
689
|
};
|
|
690
|
-
|
|
690
|
+
await saveRunManifestAsync(manifest);
|
|
691
691
|
appendEvent(manifest.eventsPath, {
|
|
692
692
|
type: "runtime.resolved",
|
|
693
693
|
runId: manifest.runId,
|
package/src/runtime/child-pi.ts
CHANGED
|
@@ -435,11 +435,75 @@ function appendTranscript(input: ChildPiRunInput, line: string): void {
|
|
|
435
435
|
// We skip mkdirSync here for security — adding it would create parent
|
|
436
436
|
// directories during validation, contradicting the original design where
|
|
437
437
|
// resolveRealContainedPath validates a pre-existing path.
|
|
438
|
-
|
|
438
|
+
// Async optimization: use fire-and-forget async write to avoid blocking the event loop.
|
|
439
|
+
// The caller does not need to await this — transcript writes are best-effort telemetry.
|
|
440
|
+
// OPT-06 follow-up: we still track the write promise in a module-scoped Set so
|
|
441
|
+
// lifecycle boundaries (ChildPiLineObserver.flush, runChildPi settle) can drain
|
|
442
|
+
// them before returning. Without that drain, callers that immediately read the
|
|
443
|
+
// transcript file post-flush (e.g. integration tests at phase3-runtime:50 and
|
|
444
|
+
// phase4-runtime:37/:68/:103) would see ENOENT or empty content because the
|
|
445
|
+
// async file handle had not yet been opened / flushed.
|
|
446
|
+
trackTranscriptWrite(safePath, line);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Async version of appendTranscript — fire-and-forget for non-blocking writes. */
|
|
450
|
+
async function appendTranscriptAsync(safePath: string, line: string): Promise<void> {
|
|
451
|
+
const content = `${redactJsonLine(line)}\n`;
|
|
439
452
|
try {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
fs.
|
|
453
|
+
// Use async file handle for better performance when many writes occur.
|
|
454
|
+
// O_NOFOLLOW | O_CREAT | O_APPEND ensures security and atomicity.
|
|
455
|
+
const fd = await fs.promises.open(
|
|
456
|
+
safePath,
|
|
457
|
+
fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW | fs.constants.O_CREAT | fs.constants.O_APPEND,
|
|
458
|
+
0o600,
|
|
459
|
+
);
|
|
460
|
+
try {
|
|
461
|
+
await fd.write(content, undefined, "utf-8");
|
|
462
|
+
} finally {
|
|
463
|
+
await fd.close();
|
|
464
|
+
}
|
|
465
|
+
} catch (error) {
|
|
466
|
+
logInternalError("child-pi.transcript-write-failed", error as Error, `path=${safePath}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Module-scoped set of in-flight transcript-write promises. Each call to
|
|
472
|
+
* {@link trackTranscriptWrite} registers its promise here; the promise
|
|
473
|
+
* removes itself on completion via `.finally`. Lifecycle boundaries
|
|
474
|
+
* (ChildPiLineObserver.flush, runChildPi settle) call
|
|
475
|
+
* {@link flushPendingTranscriptWrites} before returning so callers that
|
|
476
|
+
* immediately read the transcript file after `runChildPi` / `observer.flush()`
|
|
477
|
+
* await see the full content (and a non-ENOENT file).
|
|
478
|
+
*
|
|
479
|
+
* Module-scoped rather than per-input because all transcript writes for a
|
|
480
|
+
* given run share the same Node fs event loop and serialise through a single
|
|
481
|
+
* fs handle per path; one drain per lifecycle boundary is sufficient.
|
|
482
|
+
*/
|
|
483
|
+
const pendingTranscriptWrites: Set<Promise<void>> = new Set();
|
|
484
|
+
|
|
485
|
+
function trackTranscriptWrite(safePath: string, line: string): void {
|
|
486
|
+
const p = appendTranscriptAsync(safePath, line).finally(() => {
|
|
487
|
+
pendingTranscriptWrites.delete(p);
|
|
488
|
+
});
|
|
489
|
+
pendingTranscriptWrites.add(p);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Drain all currently-pending transcript writes. Awaits every promise in the
|
|
494
|
+
* set; if new writes are scheduled during the drain (i.e. the `.finally`
|
|
495
|
+
* callback has not yet removed them), the loop re-checks `size > 0` and
|
|
496
|
+
* awaits them too. Idempotent and safe to call concurrently / reentrantly —
|
|
497
|
+
* each pending promise is tracked exactly once.
|
|
498
|
+
*
|
|
499
|
+
* Exported so external callers (e.g. integration tests that construct a
|
|
500
|
+
* ChildPiLineObserver directly) can drain explicitly if they need to read
|
|
501
|
+
* the transcript file outside of the observer's lifecycle.
|
|
502
|
+
*/
|
|
503
|
+
export async function flushPendingTranscriptWrites(): Promise<void> {
|
|
504
|
+
while (pendingTranscriptWrites.size > 0) {
|
|
505
|
+
const drained = [...pendingTranscriptWrites];
|
|
506
|
+
await Promise.allSettled(drained);
|
|
443
507
|
}
|
|
444
508
|
}
|
|
445
509
|
|
|
@@ -632,11 +696,18 @@ export class ChildPiLineObserver {
|
|
|
632
696
|
for (const line of lines) this.emitLine(line);
|
|
633
697
|
}
|
|
634
698
|
|
|
635
|
-
flush(): void {
|
|
636
|
-
if (
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
699
|
+
flush(): Promise<void> {
|
|
700
|
+
if (this.buffer) {
|
|
701
|
+
const line = this.buffer;
|
|
702
|
+
this.buffer = "";
|
|
703
|
+
this.emitLine(line);
|
|
704
|
+
}
|
|
705
|
+
// OPT-06 follow-up: appendTranscript is fire-and-forget async, so the file
|
|
706
|
+
// may not exist on disk by the time this returns. Drain the module-scoped
|
|
707
|
+
// pending-write set before resolving so callers that immediately read the
|
|
708
|
+
// transcript file (e.g. integration tests at phase4-runtime:37/:68/:103
|
|
709
|
+
// after `await observer.flush()`) see the full content.
|
|
710
|
+
return flushPendingTranscriptWrites();
|
|
640
711
|
}
|
|
641
712
|
|
|
642
713
|
/** Last non-empty RAW assistant text (mirrors {@link parsePiJsonOutput}'s
|
|
@@ -715,11 +786,15 @@ export class ChildPiLineObserver {
|
|
|
715
786
|
}
|
|
716
787
|
}
|
|
717
788
|
|
|
718
|
-
/** Mock-only path — real code path reuses a single observer.
|
|
719
|
-
|
|
789
|
+
/** Mock-only path — real code path reuses a single observer.
|
|
790
|
+
* OPT-06 follow-up: returns a Promise so callers can await the transcript
|
|
791
|
+
* drain before resolving runChildPi. Without this, mock-mode callers that
|
|
792
|
+
* read the transcript file post-run see ENOENT (the async file handle had
|
|
793
|
+
* not yet been opened). */
|
|
794
|
+
async function observeStdoutChunk(input: ChildPiRunInput, text: string): Promise<void> {
|
|
720
795
|
const observer = new ChildPiLineObserver(input);
|
|
721
796
|
observer.observe(text);
|
|
722
|
-
observer.flush();
|
|
797
|
+
await observer.flush();
|
|
723
798
|
}
|
|
724
799
|
|
|
725
800
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
@@ -779,7 +854,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
779
854
|
logInternalError("child-pi.mock", new Error(`Mock mode active: ${mock}`), "NOT running real agents");
|
|
780
855
|
if (mock === "success") {
|
|
781
856
|
const stdout = `[MOCK] Success for ${input.agent.name}\n`;
|
|
782
|
-
observeStdoutChunk(input, stdout);
|
|
857
|
+
await observeStdoutChunk(input, stdout);
|
|
783
858
|
return { exitCode: 0, stdout, stderr: "" };
|
|
784
859
|
}
|
|
785
860
|
if (mock === "json-success" || mock === "adaptive-plan") {
|
|
@@ -834,7 +909,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
834
909
|
})}\nADAPTIVE_PLAN_JSON_END`
|
|
835
910
|
: `[MOCK] JSON success for ${input.agent.name}`;
|
|
836
911
|
const stdout = `${JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text }] } })}\n${JSON.stringify({ type: "message_end", usage: { input: 10, output: 5, cost: 0.001, turns: 1 } })}\n`;
|
|
837
|
-
observeStdoutChunk(input, stdout);
|
|
912
|
+
await observeStdoutChunk(input, stdout);
|
|
838
913
|
return { exitCode: 0, stdout, stderr: "" };
|
|
839
914
|
}
|
|
840
915
|
if (mock === "retryable-failure")
|
|
@@ -882,14 +957,14 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
882
957
|
},
|
|
883
958
|
};
|
|
884
959
|
const stdout = `${JSON.stringify(failureEvent)}\n`;
|
|
885
|
-
observeStdoutChunk(input, stdout);
|
|
960
|
+
await observeStdoutChunk(input, stdout);
|
|
886
961
|
return { exitCode: 0, stdout, stderr: "" };
|
|
887
962
|
}
|
|
888
963
|
// Subsequent invocations: delegate to json-success shape so the
|
|
889
964
|
// fallback chain's second attempt succeeds and the run completes.
|
|
890
965
|
const text = `[MOCK] JSON success for ${input.agent.name}`;
|
|
891
966
|
const stdout = `${JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text }] } })}\n${JSON.stringify({ type: "message_end", usage: { input: 10, output: 5, cost: 0.001, turns: 1 } })}\n`;
|
|
892
|
-
observeStdoutChunk(input, stdout);
|
|
967
|
+
await observeStdoutChunk(input, stdout);
|
|
893
968
|
return { exitCode: 0, stdout, stderr: "" };
|
|
894
969
|
}
|
|
895
970
|
return { exitCode: 1, stdout: "", stderr: `[MOCK] failure: ${mock}` };
|
|
@@ -1107,7 +1182,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
1107
1182
|
process.kill(child.pid!, 0);
|
|
1108
1183
|
// Child still alive — force settle with timeout error.
|
|
1109
1184
|
const timeoutErr = `Child Pi produced no new output for ${responseTimeoutMs}ms; killed but did not exit within ${SAFETY_SETTLE_MS}ms (possible zombie).`;
|
|
1110
|
-
settle({
|
|
1185
|
+
void settle({
|
|
1111
1186
|
exitCode: null,
|
|
1112
1187
|
stdout,
|
|
1113
1188
|
stderr,
|
|
@@ -1320,46 +1395,106 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
1320
1395
|
clearPostExitGuard();
|
|
1321
1396
|
};
|
|
1322
1397
|
|
|
1323
|
-
const settle = (result: ChildPiRunResult): void => {
|
|
1324
|
-
if (settled) return;
|
|
1398
|
+
const settle = (result: ChildPiRunResult): Promise<void> => {
|
|
1399
|
+
if (settled) return Promise.resolve();
|
|
1325
1400
|
settled = true;
|
|
1326
1401
|
clearChildPiTimeouts();
|
|
1327
|
-
lineObserver.flush()
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
//
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1402
|
+
// OPT-06 follow-up: lineObserver.flush() is now async (returns
|
|
1403
|
+
// Promise<void>) and drains the module-scoped pending-write set
|
|
1404
|
+
// before resolving. We must await it before calling `resolve()`
|
|
1405
|
+
// below so callers that read the transcript file post-`runChildPi`
|
|
1406
|
+
// see all written lines. Caller invocations of `settle` from
|
|
1407
|
+
// sync event handlers (`child.on('close'|'exit'|'error')`,
|
|
1408
|
+
// safety timer) use `void settle(...)` — errors are caught and
|
|
1409
|
+
// logged inside, and `resolve()` only fires after the drain
|
|
1410
|
+
// completes, so runChildPi's outer Promise resolves with a
|
|
1411
|
+
// durable transcript on disk.
|
|
1412
|
+
return lineObserver
|
|
1413
|
+
.flush()
|
|
1414
|
+
.then(() => {
|
|
1415
|
+
input.signal?.removeEventListener("abort", abort);
|
|
1416
|
+
input.signal?.removeEventListener("abort", onParentAbort);
|
|
1417
|
+
try {
|
|
1418
|
+
cleanupTempDir(built.tempDir);
|
|
1419
|
+
} catch (error) {
|
|
1420
|
+
cleanupErrors.push(error instanceof Error ? error.message : String(error));
|
|
1421
|
+
}
|
|
1422
|
+
// Catch all errors from settle to prevent unhandled rejection from propagating
|
|
1423
|
+
try {
|
|
1424
|
+
resolve({
|
|
1425
|
+
...result,
|
|
1426
|
+
rawFinalText: lineObserver.getRawFinalText(),
|
|
1427
|
+
intermediateFindings: lineObserver.getIntermediateFindings(),
|
|
1428
|
+
exitStatus: result.exitStatus ?? {
|
|
1429
|
+
exitCode: result.exitCode,
|
|
1430
|
+
cancelled: abortRequested,
|
|
1431
|
+
timedOut: responseTimeoutHit,
|
|
1432
|
+
killed: hardKilled,
|
|
1433
|
+
// Phase-0 diagnostic (HB-003a): surface the final-drain race state.
|
|
1434
|
+
// finalDrainArmed lets Phase 1 decide whether a signal-death (exitCode=null)
|
|
1435
|
+
// should be treated as a forced final drain. READ-ONLY for now.
|
|
1436
|
+
...(finalDrainArmed || forcedFinalDrain
|
|
1437
|
+
? {
|
|
1438
|
+
finalDrainArmed,
|
|
1439
|
+
forcedFinalDrain,
|
|
1440
|
+
finalDrainFiredMonotonicMs,
|
|
1441
|
+
}
|
|
1442
|
+
: {}),
|
|
1443
|
+
cleanupErrors,
|
|
1444
|
+
finalDrainMs,
|
|
1445
|
+
},
|
|
1446
|
+
});
|
|
1447
|
+
} catch (resolveError) {
|
|
1448
|
+
logInternalError(
|
|
1449
|
+
"child-pi.settle-resolve",
|
|
1450
|
+
resolveError,
|
|
1451
|
+
`result=${JSON.stringify({ exitCode: result.exitCode })}`,
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
})
|
|
1455
|
+
.catch((flushError) => {
|
|
1456
|
+
// Drain failed — log and still resolve so runChildPi doesn't hang.
|
|
1457
|
+
logInternalError(
|
|
1458
|
+
"child-pi.settle-flush-failed",
|
|
1459
|
+
flushError,
|
|
1460
|
+
`result=${JSON.stringify({ exitCode: result.exitCode })}`,
|
|
1461
|
+
);
|
|
1462
|
+
input.signal?.removeEventListener("abort", abort);
|
|
1463
|
+
input.signal?.removeEventListener("abort", onParentAbort);
|
|
1464
|
+
try {
|
|
1465
|
+
cleanupTempDir(built.tempDir);
|
|
1466
|
+
} catch (error) {
|
|
1467
|
+
cleanupErrors.push(error instanceof Error ? error.message : String(error));
|
|
1468
|
+
}
|
|
1469
|
+
try {
|
|
1470
|
+
resolve({
|
|
1471
|
+
...result,
|
|
1472
|
+
rawFinalText: lineObserver.getRawFinalText(),
|
|
1473
|
+
intermediateFindings: lineObserver.getIntermediateFindings(),
|
|
1474
|
+
exitStatus: result.exitStatus ?? {
|
|
1475
|
+
exitCode: result.exitCode,
|
|
1476
|
+
cancelled: abortRequested,
|
|
1477
|
+
timedOut: responseTimeoutHit,
|
|
1478
|
+
killed: hardKilled,
|
|
1479
|
+
...(finalDrainArmed || forcedFinalDrain
|
|
1480
|
+
? {
|
|
1481
|
+
finalDrainArmed,
|
|
1482
|
+
forcedFinalDrain,
|
|
1483
|
+
finalDrainFiredMonotonicMs,
|
|
1484
|
+
}
|
|
1485
|
+
: {}),
|
|
1486
|
+
cleanupErrors,
|
|
1487
|
+
finalDrainMs,
|
|
1488
|
+
},
|
|
1489
|
+
});
|
|
1490
|
+
} catch (resolveError) {
|
|
1491
|
+
logInternalError(
|
|
1492
|
+
"child-pi.settle-resolve",
|
|
1493
|
+
resolveError,
|
|
1494
|
+
`result=${JSON.stringify({ exitCode: result.exitCode })}`,
|
|
1495
|
+
);
|
|
1496
|
+
}
|
|
1359
1497
|
});
|
|
1360
|
-
} catch (resolveError) {
|
|
1361
|
-
logInternalError("child-pi.settle-resolve", resolveError, `result=${JSON.stringify({ exitCode: result.exitCode })}`);
|
|
1362
|
-
}
|
|
1363
1498
|
};
|
|
1364
1499
|
|
|
1365
1500
|
const abort = (): void => {
|
|
@@ -1446,7 +1581,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
1446
1581
|
} catch (err) {
|
|
1447
1582
|
logInternalError("child-pi.on-lifecycle-event", err, `event=error, pid=${child.pid}`);
|
|
1448
1583
|
}
|
|
1449
|
-
settle({
|
|
1584
|
+
void settle({
|
|
1450
1585
|
exitCode: null,
|
|
1451
1586
|
stdout,
|
|
1452
1587
|
stderr,
|
|
@@ -1585,7 +1720,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
1585
1720
|
spawnError: undefined,
|
|
1586
1721
|
stderrSnippet: stderr ? redactStderrExcerpt(stderr, 1000) : undefined,
|
|
1587
1722
|
});
|
|
1588
|
-
settle({
|
|
1723
|
+
void settle({
|
|
1589
1724
|
exitCode: finalExitCode,
|
|
1590
1725
|
stdout,
|
|
1591
1726
|
stderr,
|