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.
@@ -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, saveRunManifest, saveRunTasks, updateRunStatus } from "../state/state-store.ts";
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
- saveRunManifest(nextManifest);
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
- saveRunManifest(runtimeManifest);
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
- return result(`Cache stats: ${stats.entries} entries, ${stats.sizeBytes} bytes`, { action: "cache", status: "ok" });
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 { saveRunManifest } from "../state/state-store.ts";
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
- saveRunManifest({
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, saveRunManifest, updateRunStatus } from "../state/state-store.ts";
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
- saveRunManifest(finalGoalManifest);
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
- saveRunManifest(dwfResult.manifest);
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
- saveRunManifest(manifest);
690
+ await saveRunManifestAsync(manifest);
691
691
  appendEvent(manifest.eventsPath, {
692
692
  type: "runtime.resolved",
693
693
  runId: manifest.runId,
@@ -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
- const fd = fs.openSync(safePath, fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW | fs.constants.O_CREAT | fs.constants.O_APPEND, 0o600);
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
- fs.writeSync(fd, `${redactJsonLine(line)}\n`, undefined, "utf-8");
441
- } finally {
442
- fs.closeSync(fd);
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 (!this.buffer) return;
637
- const line = this.buffer;
638
- this.buffer = "";
639
- this.emitLine(line);
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
- function observeStdoutChunk(input: ChildPiRunInput, text: string): void {
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
- input.signal?.removeEventListener("abort", abort);
1329
- input.signal?.removeEventListener("abort", onParentAbort);
1330
- try {
1331
- cleanupTempDir(built.tempDir);
1332
- } catch (error) {
1333
- cleanupErrors.push(error instanceof Error ? error.message : String(error));
1334
- }
1335
- // Catch all errors from settle to prevent unhandled rejection from propagating
1336
- try {
1337
- resolve({
1338
- ...result,
1339
- rawFinalText: lineObserver.getRawFinalText(),
1340
- intermediateFindings: lineObserver.getIntermediateFindings(),
1341
- exitStatus: result.exitStatus ?? {
1342
- exitCode: result.exitCode,
1343
- cancelled: abortRequested,
1344
- timedOut: responseTimeoutHit,
1345
- killed: hardKilled,
1346
- // Phase-0 diagnostic (HB-003a): surface the final-drain race state.
1347
- // finalDrainArmed lets Phase 1 decide whether a signal-death (exitCode=null)
1348
- // should be treated as a forced final drain. READ-ONLY for now.
1349
- ...(finalDrainArmed || forcedFinalDrain
1350
- ? {
1351
- finalDrainArmed,
1352
- forcedFinalDrain,
1353
- finalDrainFiredMonotonicMs,
1354
- }
1355
- : {}),
1356
- cleanupErrors,
1357
- finalDrainMs,
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,