pi-crew 0.11.1 → 0.11.2

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.
Files changed (121) hide show
  1. package/CHANGELOG.md +39 -9
  2. package/README.md +161 -1036
  3. package/agents/verifier.md +18 -7
  4. package/dist/index.mjs +744 -91462
  5. package/docs/README.md +57 -46
  6. package/docs/architecture.md +87 -33
  7. package/docs/commands-reference.md +9 -5
  8. package/docs/troubleshooting.md +3 -2
  9. package/package.json +1 -3
  10. package/schema.json +29 -0
  11. package/skills/real-test-pi-crew/SKILL.md +193 -36
  12. package/src/agents/agent-config.ts +1 -1
  13. package/src/agents/discover-agents.ts +1 -1
  14. package/src/config/config-validation.ts +15 -0
  15. package/src/config/config.ts +47 -13
  16. package/src/config/env-vars.ts +35 -0
  17. package/src/config/types.ts +19 -0
  18. package/src/errors.ts +2 -2
  19. package/src/extension/async-notifier.ts +23 -0
  20. package/src/extension/help.ts +21 -10
  21. package/src/extension/knowledge-injection.ts +2 -1
  22. package/src/extension/management.ts +8 -3
  23. package/src/extension/notification-sink.ts +17 -0
  24. package/src/extension/registration/command-utils.ts +28 -2
  25. package/src/extension/registration/commands/dashboard.ts +11 -1
  26. package/src/extension/registration/commands/manage.ts +31 -15
  27. package/src/extension/registration/commands/run.ts +24 -2
  28. package/src/extension/registration/commands/shared.ts +23 -0
  29. package/src/extension/registration/commands/status.ts +25 -2
  30. package/src/extension/registration/context-builder.ts +8 -2
  31. package/src/extension/registration/health-notify-policy.ts +100 -0
  32. package/src/extension/registration/lazy-configurers.ts +35 -0
  33. package/src/extension/registration/lifecycle-handlers.ts +91 -30
  34. package/src/extension/registration/lifecycle.ts +75 -10
  35. package/src/extension/registration/observability.ts +98 -35
  36. package/src/extension/registration/registration-types.ts +7 -5
  37. package/src/extension/registration/runtime-cleanup.ts +9 -3
  38. package/src/extension/registration/subagent-helpers.ts +38 -0
  39. package/src/extension/registration/wire-cross-extension.ts +28 -0
  40. package/src/extension/run-compare.ts +220 -0
  41. package/src/extension/run-export.ts +37 -5
  42. package/src/extension/run-maintenance.ts +155 -5
  43. package/src/extension/team-tool/dispatch/index.ts +3 -2
  44. package/src/extension/team-tool/dispatch/manage.ts +5 -2
  45. package/src/extension/team-tool/goal.ts +4 -1
  46. package/src/extension/team-tool/handle-settings.ts +19 -2
  47. package/src/extension/team-tool/health-monitor.ts +21 -7
  48. package/src/extension/team-tool/lifecycle-actions.ts +49 -1
  49. package/src/extension/team-tool/plan.ts +10 -0
  50. package/src/extension/team-tool/routing-hint.ts +63 -0
  51. package/src/extension/team-tool/status.ts +4 -0
  52. package/src/extension/team-tool.ts +52 -6
  53. package/src/extension/webhook-notify.ts +382 -0
  54. package/src/observability/metric-sink.ts +12 -2
  55. package/src/prompt/prompt-runtime.ts +82 -31
  56. package/src/prompt/worker-events-channel.ts +12 -0
  57. package/src/runtime/README.md +1 -1
  58. package/src/runtime/async-runner.ts +87 -1
  59. package/src/runtime/background-runner.ts +313 -234
  60. package/src/runtime/broker/crew-broker.ts +17 -11
  61. package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
  62. package/src/runtime/broker/wait-status-cache.ts +1 -1
  63. package/src/runtime/child-pi/child-pi-timers.ts +1 -1
  64. package/src/runtime/child-pi/mock-fixtures.ts +48 -0
  65. package/src/runtime/crew-agent-records.ts +337 -45
  66. package/src/runtime/deadletter.ts +43 -1
  67. package/src/runtime/delegate-spawn.ts +5 -1
  68. package/src/runtime/dispatch-batch.ts +72 -5
  69. package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
  70. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  71. package/src/runtime/model/model-fallback.ts +21 -1
  72. package/src/runtime/model/pi-args.ts +8 -10
  73. package/src/runtime/recovery/crash-recovery.ts +25 -1
  74. package/src/runtime/run-worker.ts +12 -1
  75. package/src/runtime/scheduling/global-worker-cap.ts +13 -6
  76. package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
  77. package/src/runtime/scheduling/scheduler.ts +49 -13
  78. package/src/runtime/scheduling/semaphore.ts +148 -20
  79. package/src/runtime/scratchpad/README.md +1 -1
  80. package/src/runtime/scratchpad/protocol.ts +1 -1
  81. package/src/runtime/settings-store.ts +1 -1
  82. package/src/runtime/skill-instructions.ts +22 -0
  83. package/src/runtime/stale-reconciler.ts +85 -13
  84. package/src/runtime/task-runner/pre-execution.ts +26 -2
  85. package/src/runtime/task-runner/prompt-builder.ts +142 -45
  86. package/src/runtime/task-runner.ts +21 -1
  87. package/src/runtime/team-runner.ts +38 -1
  88. package/src/runtime/workspace-lock.ts +4 -1
  89. package/src/schema/config-schema.ts +14 -0
  90. package/src/schema/team-tool-schema.ts +17 -0
  91. package/src/state/atomic-write.ts +53 -0
  92. package/src/state/contracts.ts +109 -0
  93. package/src/state/coordination/locks.ts +191 -33
  94. package/src/state/coordination/mailbox.ts +140 -15
  95. package/src/state/crew-init.ts +87 -12
  96. package/src/state/event-log/cursor.ts +37 -1
  97. package/src/state/event-log/event-log-rotation.ts +72 -7
  98. package/src/state/stores/active-run-registry.ts +13 -1
  99. package/src/state/stores/state-store.ts +112 -22
  100. package/src/state/types.ts +4 -0
  101. package/src/ui/dashboard-panes/agents-pane.ts +11 -2
  102. package/src/ui/heartbeat-aggregator.ts +34 -0
  103. package/src/ui/keybinding-map.ts +22 -4
  104. package/src/ui/live-conversation-overlay.ts +6 -3
  105. package/src/ui/run-dashboard.ts +98 -5
  106. package/src/ui/run-snapshot-cache.ts +18 -1
  107. package/src/ui/spinner.ts +26 -2
  108. package/src/ui/tool-progress-formatter.ts +2 -1
  109. package/src/ui/tool-renderers/brief-mode.ts +2 -1
  110. package/src/ui/tool-renderers/index.ts +3 -3
  111. package/src/utils/incremental-reader.ts +11 -3
  112. package/src/utils/paths.ts +94 -12
  113. package/src/utils/project-markers.ts +40 -0
  114. package/src/worktree/worktree-manager.ts +206 -26
  115. package/workflows/distill.workflow.md +3 -3
  116. package/workflows/fast-fix.workflow.md +1 -1
  117. package/workflows/plan-execute.workflow.md +1 -1
  118. package/workflows/review.workflow.md +1 -1
  119. package/workflows/strict-fast-fix.workflow.md +1 -1
  120. package/docs/migration-v0.4-v0.5.md +0 -208
  121. package/docs/runtime-flow.md +0 -148
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
3
4
  import { allAgents, discoverAgents } from "../agents/discover-agents.ts";
4
5
  import { loadConfig } from "../config/config.ts";
5
6
  import { getCrewEnv } from "../config/env-vars.ts";
@@ -419,6 +420,251 @@ function runCleanup(
419
420
  // Used by the module-level catch to signal that finally should call process.exit(1).
420
421
  let exitDueToRejection = false;
421
422
 
423
+ export interface ExecuteBackgroundRunOptions {
424
+ /** Abort signal — the detached runner passes its controller's signal (SIGINT/SIGTERM/watchdog); the inline test seam passes none. */
425
+ signal?: AbortSignal;
426
+ /**
427
+ * Set PI_CREW_BACKGROUND_MODE=1 (detached-runner observability marker).
428
+ * SKIPPED by the in-process test seam: it must not pollute the host test
429
+ * process env (task-runner routes events by this flag).
430
+ */
431
+ markBackgroundMode?: boolean;
432
+ /**
433
+ * Progress log sink. Defaults to console.log — the detached runner's
434
+ * console is redirected to background.log by main(); the inline test seam
435
+ * appends to background.log directly.
436
+ */
437
+ log?: (message: string) => void;
438
+ }
439
+
440
+ /**
441
+ * The background-run EXECUTION CORE shared by two hosts:
442
+ * 1. the detached runner process (main() below), and
443
+ * 2. the in-process async test seam (async-runner.ts spawnBackgroundTeamRun
444
+ * with PI_CREW_TEST_ASYNC_INLINE=1 + PI_CREW_ALLOW_MOCK=1).
445
+ *
446
+ * Contains NO process-level side effects beyond the run itself: no console
447
+ * redirect, no signal handlers, no watchdog/keepalive/parent-guard, no exit
448
+ * codes. Hosts own those. This is why the seam kills the Windows Defender
449
+ * first-spawn stall (no second process to come up inside a test deadline)
450
+ * and the orphan-tmpdir leak (the run dies with the test process — nothing
451
+ * detached survives to write state after the test's rmSync).
452
+ */
453
+ export async function executeBackgroundRun(
454
+ input: { manifest: TeamRunManifest; tasks: TeamTaskState[] },
455
+ opts: ExecuteBackgroundRunOptions = {},
456
+ ): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> {
457
+ const log = opts.log ?? ((message: string) => console.log(message));
458
+ let manifest = input.manifest;
459
+ let tasks = input.tasks;
460
+ const cwd = manifest.cwd;
461
+ // Inline seam passes no signal — a never-aborted dummy is behaviorally
462
+ // identical ("no cancellation source") for the goal-loop/dwf/team-run
463
+ // inputs below that require a non-optional AbortSignal.
464
+ const signal: AbortSignal = opts.signal ?? new AbortController().signal;
465
+ debugLog(`[background-runner] about to call discoverAgents`);
466
+ const agents = allAgents(discoverAgents(cwd));
467
+ debugLog(`[background-runner] discoverAgents done, ${agents.length} agents`);
468
+ // Round 27 (BUG 2): openSync returned an fd that was never closed → FD
469
+ // leak per background runner startup. Close it in a finally (matches the
470
+ // canonical pattern in checkpoint.ts:83 and event-log.ts:582).
471
+ try {
472
+ const fd = fs.openSync(manifest.eventsPath, "a");
473
+ try {
474
+ fs.fsyncSync(fd);
475
+ } finally {
476
+ try {
477
+ fs.closeSync(fd);
478
+ } catch {
479
+ /* best-effort */
480
+ }
481
+ }
482
+ } catch {
483
+ /* best-effort */
484
+ } // FORCE flush so we see this before death
485
+ // Fix round-4 CRITICAL: goal-loop and dynamic-workflow manifests use SYNTHETIC
486
+ // team/workflow names not in discoverTeams/discoverWorkflows. The team+workflow
487
+ // lookup below would throw "Team not found" BEFORE the runKind switch, making the
488
+ // goal feature unreachable from background. Short-circuit the new runKinds here.
489
+ if (opts.markBackgroundMode) process.env.PI_CREW_BACKGROUND_MODE = "1";
490
+ let earlyResult: { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined;
491
+ let result: { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined;
492
+ if (manifest.runKind === "goal-loop" || manifest.runKind === "dynamic-workflow") {
493
+ debugLog(`[background-runner] short-circuiting ${manifest.runKind} (synthetic team/workflow)`);
494
+ if (manifest.runKind === "goal-loop") {
495
+ // LAZY: defer dynamic import of ./goal-loop-runner.ts to its call site.
496
+ const { runGoalLoop } = await import("./goal-workflow/goal-loop-runner.ts");
497
+ // LAZY: defer dynamic import of ./goal-state-store.ts to its call site.
498
+ const { GoalStore } = await import("./goal-workflow/goal-state-store.ts");
499
+ // LAZY: defer dynamic import of ../agents/discover-agents.ts to its call site.
500
+ const { discoverAgents, allAgents } = await import("../agents/discover-agents.ts");
501
+ const store = new GoalStore(manifest.cwd);
502
+ const goalState = store.load(manifest.runId);
503
+ if (!goalState) throw new Error(`runKind="goal-loop" but GoalLoopState '${manifest.runId}' not found (cwd=${manifest.cwd})`);
504
+ const goalResult = await runGoalLoop({
505
+ goalState,
506
+ manifest,
507
+ signal,
508
+ deps: {
509
+ discoverAgents: (c: string) => allAgents(discoverAgents(c)),
510
+ },
511
+ });
512
+ // Fix P1-1 + round-6 #5: persist terminal status reflecting the goal's actual outcome,
513
+ // not a blanket 'completed'. Map goal state → manifest status.
514
+ const goalStatusToRunStatus: Record<string, TeamRunManifest["status"]> = {
515
+ achieved: "completed",
516
+ max_turns: "completed",
517
+ budget_exceeded: "completed",
518
+ blocked: "blocked",
519
+ cancelled: "cancelled",
520
+ paused: "blocked",
521
+ running: "running",
522
+ };
523
+ const runStatus = goalStatusToRunStatus[goalResult.goalState.state] ?? "completed";
524
+ const finalGoalManifest: TeamRunManifest = {
525
+ ...goalResult.manifest,
526
+ status: runStatus,
527
+ updatedAt: new Date().toISOString(),
528
+ };
529
+ await saveRunManifestAsync(finalGoalManifest);
530
+ earlyResult = {
531
+ manifest: finalGoalManifest,
532
+ tasks: goalResult.tasks,
533
+ };
534
+ } else {
535
+ // LAZY: defer dynamic import of ./dynamic-workflow-runner.ts to its call site.
536
+ const { runDynamicWorkflow } = await import("./goal-workflow/dynamic-workflow-runner.ts");
537
+ // LAZY: defer dynamic import of ../workflows/discover-workflows.ts to its call site.
538
+ const { allWorkflows, discoverWorkflows } = await import("../workflows/discover-workflows.ts");
539
+ const wf = allWorkflows(discoverWorkflows(manifest.cwd)).find((w) => w.name === manifest.workflow);
540
+ if (wf?.runtime !== "dynamic" || !wf.dynamicScript)
541
+ throw new Error(`runKind="dynamic-workflow" but workflow '${manifest.workflow}' is not dynamic (runId=${manifest.runId})`);
542
+ const dwfResult = await runDynamicWorkflow({
543
+ manifest,
544
+ workflow: wf as import("../workflows/workflow-config.ts").DynamicWorkflowConfig,
545
+ signal,
546
+ tokenBudget: wf.maxTokenBudget,
547
+ });
548
+ await saveRunManifestAsync(dwfResult.manifest);
549
+ earlyResult = dwfResult;
550
+ }
551
+ log(`[background-runner] ${manifest.runKind} returned, status=${earlyResult.manifest.status}`);
552
+ result = earlyResult;
553
+ }
554
+ if (!earlyResult) {
555
+ debugLog(`[background-runner] calling directTeamAndWorkflowFromRun`);
556
+ const direct = directTeamAndWorkflowFromRun(manifest, tasks, agents);
557
+ debugLog(`[background-runner] direct done, finding team`);
558
+ const team = direct?.team ?? allTeams(discoverTeams(cwd)).find((candidate) => candidate.name === manifest.team);
559
+ if (!team) throw new Error(`Team '${manifest.team}' not found.`);
560
+ debugLog(`[background-runner] team=${team.name}, finding workflow`);
561
+ const baseWorkflow =
562
+ direct?.workflow ?? allWorkflows(discoverWorkflows(cwd)).find((candidate) => candidate.name === manifest.workflow);
563
+ if (!baseWorkflow) throw new Error(`Workflow '${manifest.workflow ?? ""}' not found.`);
564
+ debugLog(`[background-runner] workflow=${baseWorkflow.name}`);
565
+ const workflow = expandParallelResearchWorkflow(baseWorkflow, cwd);
566
+ debugLog(`[background-runner] loading config`);
567
+ const loadedConfig = loadConfig(cwd);
568
+ const runConfig =
569
+ manifest.runConfig && typeof manifest.runConfig === "object" && !Array.isArray(manifest.runConfig)
570
+ ? (manifest.runConfig as typeof loadedConfig.config)
571
+ : loadedConfig.config;
572
+ const runtime = manifest.runtimeResolution
573
+ ? {
574
+ kind: manifest.runtimeResolution.kind,
575
+ requestedMode: manifest.runtimeResolution.requestedMode,
576
+ available: manifest.runtimeResolution.available,
577
+ fallback: manifest.runtimeResolution.fallback,
578
+ steer: manifest.runtimeResolution.kind === "live-session",
579
+ resume: manifest.runtimeResolution.kind === "live-session",
580
+ liveToolActivity: manifest.runtimeResolution.kind === "live-session",
581
+ transcript: manifest.runtimeResolution.kind !== "scaffold",
582
+ reason: manifest.runtimeResolution.reason,
583
+ safety: manifest.runtimeResolution.safety,
584
+ }
585
+ : await resolveCrewRuntime(runConfig);
586
+ const runtimeResolution = manifest.runtimeResolution ?? runtimeResolutionState(runtime);
587
+ manifest = {
588
+ ...manifest,
589
+ runtimeResolution,
590
+ runConfig,
591
+ updatedAt: new Date().toISOString(),
592
+ };
593
+ await saveRunManifestAsync(manifest);
594
+ appendEventBuffered(manifest.eventsPath, {
595
+ type: "runtime.resolved",
596
+ runId: manifest.runId,
597
+ message: `Runtime resolved: ${runtime.kind} safety=${runtime.safety}`,
598
+ data: { runtimeResolution, async: true },
599
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=runtime.resolved"));
600
+ if (runtime.safety === "blocked")
601
+ throw new Error(runtime.reason ?? "Child worker execution is disabled; refusing to create no-op scaffold subagents.");
602
+ const executeWorkers = runtime.kind !== "scaffold";
603
+ // Use ownerSessionId for workspaceId to ensure agents are only visible to the session that spawned them.
604
+ // manifest.cwd would cause cross-session visibility since all sessions share the same project directory.
605
+ // Mark this as background mode so task-runner writes events to background.log for debugging.
606
+ if (opts.markBackgroundMode) process.env.PI_CREW_BACKGROUND_MODE = "1";
607
+ // BUG #17: Keep-alive interval (NOT unref'd) prevents event loop from exiting
608
+ // during jiti compilation of team-runner.ts. Without this, the event loop
609
+ // can drain when import() blocks, causing the process to exit prematurely.
610
+ // NOTE: abortController is already created above (before heartbeat/interrupt guard start)
611
+ // so it is available here and its signal is passed through to executeTeamRun → child-pi.
612
+
613
+ debugLog(`[background-runner] dispatching runKind=${manifest.runKind ?? "team-run"}`);
614
+ try {
615
+ // Fix round-4: goal-loop/dynamic-workflow handled by the short-circuit above.
616
+ // This switch now only carries the traditional team-run path.
617
+ switch (manifest.runKind ?? "team-run") {
618
+ default: {
619
+ // Existing "team-run" path — unchanged behavior.
620
+ // Forward budget fields from manifest (set by team-tool/run.ts
621
+ // from params.budgetTotal/etc.) so the team-runner's
622
+ // checkPerTaskBudget guard actually arms.
623
+ result = await executeTeamRun({
624
+ manifest,
625
+ tasks,
626
+ team,
627
+ workflow,
628
+ agents,
629
+ executeWorkers,
630
+ limits: runConfig.limits,
631
+ runtime,
632
+ runtimeConfig: runConfig.runtime,
633
+ skillOverride: manifest.skillOverride,
634
+ // Restore the caller's model routing inputs (see RunModelContext):
635
+ // this process has no ExtensionContext, so without these the
636
+ // `model=` override and the inherited session model are lost and
637
+ // every worker falls back to the first models.json entry.
638
+ ...restoredModelRouting(manifest),
639
+ reliability: runConfig.reliability,
640
+ workspaceId: manifest.ownerSessionId ?? manifest.cwd,
641
+ signal: opts.signal,
642
+ ...(manifest.budgetTotal !== undefined ? { budgetTotal: manifest.budgetTotal } : {}),
643
+ ...(manifest.budgetWarning !== undefined ? { budgetWarning: manifest.budgetWarning } : {}),
644
+ ...(manifest.budgetAbort !== undefined ? { budgetAbort: manifest.budgetAbort } : {}),
645
+ ...(manifest.budgetUnlimited !== undefined ? { budgetUnlimited: manifest.budgetUnlimited } : {}),
646
+ });
647
+ break;
648
+ }
649
+ }
650
+ log(`[background-runner] executeTeamRun returned, status=${result.manifest.status}`);
651
+ } catch (execError) {
652
+ log(`[background-runner] executeTeamRun THREW: ${errorMessage(execError)}`);
653
+ log(`[background-runner] stack: ${execError instanceof Error ? execError.stack : "N/A"}`);
654
+ throw execError;
655
+ }
656
+ } // close if (!earlyResult) — team-run setup+execute done; earlyResult path skips to here
657
+ manifest = result!.manifest;
658
+ tasks = result!.tasks;
659
+ appendEventBuffered(manifest.eventsPath, {
660
+ type: "async.completed",
661
+ runId: manifest.runId,
662
+ data: { status: manifest.status, tasks: tasks.length },
663
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=async.completed"));
664
+ log(`[background-runner] async.completed written, status=${manifest.status}`);
665
+ return { manifest, tasks };
666
+ }
667
+
422
668
  async function main(): Promise<void> {
423
669
  // FIX: Store logFd so it can be closed on exit to prevent file descriptor leak
424
670
  let logFd: number | undefined;
@@ -754,209 +1000,9 @@ async function main(): Promise<void> {
754
1000
  }, MAX_BACKGROUND_RUN_MS);
755
1001
 
756
1002
  try {
757
- debugLog(`[background-runner] about to call discoverAgents`);
758
- const agents = allAgents(discoverAgents(cwd));
759
- debugLog(`[background-runner] discoverAgents done, ${agents.length} agents`);
760
- // Round 27 (BUG 2): openSync returned an fd that was never closed → FD
761
- // leak per background runner startup. Close it in a finally (matches the
762
- // canonical pattern in checkpoint.ts:83 and event-log.ts:582).
763
- try {
764
- const fd = fs.openSync(manifest.eventsPath, "a");
765
- try {
766
- fs.fsyncSync(fd);
767
- } finally {
768
- try {
769
- fs.closeSync(fd);
770
- } catch {
771
- /* best-effort */
772
- }
773
- }
774
- } catch {
775
- /* best-effort */
776
- } // FORCE flush so we see this before death
777
- // Fix round-4 CRITICAL: goal-loop and dynamic-workflow manifests use SYNTHETIC
778
- // team/workflow names not in discoverTeams/discoverWorkflows. The team+workflow
779
- // lookup below would throw "Team not found" BEFORE the runKind switch, making the
780
- // goal feature unreachable from background. Short-circuit the new runKinds here.
781
- process.env.PI_CREW_BACKGROUND_MODE = "1";
782
- let earlyResult: { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined;
783
- let result: { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined;
784
- if (manifest.runKind === "goal-loop" || manifest.runKind === "dynamic-workflow") {
785
- debugLog(`[background-runner] short-circuiting ${manifest.runKind} (synthetic team/workflow)`);
786
- if (manifest.runKind === "goal-loop") {
787
- // LAZY: defer dynamic import of ./goal-loop-runner.ts to its call site.
788
- const { runGoalLoop } = await import("./goal-workflow/goal-loop-runner.ts");
789
- // LAZY: defer dynamic import of ./goal-state-store.ts to its call site.
790
- const { GoalStore } = await import("./goal-workflow/goal-state-store.ts");
791
- // LAZY: defer dynamic import of ../agents/discover-agents.ts to its call site.
792
- const { discoverAgents, allAgents } = await import("../agents/discover-agents.ts");
793
- const store = new GoalStore(manifest.cwd);
794
- const goalState = store.load(manifest.runId);
795
- if (!goalState)
796
- throw new Error(`runKind="goal-loop" but GoalLoopState '${manifest.runId}' not found (cwd=${manifest.cwd})`);
797
- const goalResult = await runGoalLoop({
798
- goalState,
799
- manifest,
800
- signal: abortController.signal,
801
- deps: {
802
- discoverAgents: (c: string) => allAgents(discoverAgents(c)),
803
- },
804
- });
805
- // Fix P1-1 + round-6 #5: persist terminal status reflecting the goal's actual outcome,
806
- // not a blanket 'completed'. Map goal state → manifest status.
807
- const goalStatusToRunStatus: Record<string, TeamRunManifest["status"]> = {
808
- achieved: "completed",
809
- max_turns: "completed",
810
- budget_exceeded: "completed",
811
- blocked: "blocked",
812
- cancelled: "cancelled",
813
- paused: "blocked",
814
- running: "running",
815
- };
816
- const runStatus = goalStatusToRunStatus[goalResult.goalState.state] ?? "completed";
817
- const finalGoalManifest: TeamRunManifest = {
818
- ...goalResult.manifest,
819
- status: runStatus,
820
- updatedAt: new Date().toISOString(),
821
- };
822
- await saveRunManifestAsync(finalGoalManifest);
823
- earlyResult = {
824
- manifest: finalGoalManifest,
825
- tasks: goalResult.tasks,
826
- };
827
- } else {
828
- // LAZY: defer dynamic import of ./dynamic-workflow-runner.ts to its call site.
829
- const { runDynamicWorkflow } = await import("./goal-workflow/dynamic-workflow-runner.ts");
830
- // LAZY: defer dynamic import of ../workflows/discover-workflows.ts to its call site.
831
- const { allWorkflows, discoverWorkflows } = await import("../workflows/discover-workflows.ts");
832
- const wf = allWorkflows(discoverWorkflows(manifest.cwd)).find((w) => w.name === manifest.workflow);
833
- if (wf?.runtime !== "dynamic" || !wf.dynamicScript)
834
- throw new Error(
835
- `runKind="dynamic-workflow" but workflow '${manifest.workflow}' is not dynamic (runId=${manifest.runId})`,
836
- );
837
- const dwfResult = await runDynamicWorkflow({
838
- manifest,
839
- workflow: wf as import("../workflows/workflow-config.ts").DynamicWorkflowConfig,
840
- signal: abortController.signal,
841
- tokenBudget: wf.maxTokenBudget,
842
- });
843
- await saveRunManifestAsync(dwfResult.manifest);
844
- earlyResult = dwfResult;
845
- }
846
- console.log(`[background-runner] ${manifest.runKind} returned, status=${earlyResult.manifest.status}`);
847
- result = earlyResult;
848
- }
849
- if (!earlyResult) {
850
- debugLog(`[background-runner] calling directTeamAndWorkflowFromRun`);
851
- const direct = directTeamAndWorkflowFromRun(manifest, tasks, agents);
852
- debugLog(`[background-runner] direct done, finding team`);
853
- const team = direct?.team ?? allTeams(discoverTeams(cwd)).find((candidate) => candidate.name === manifest.team);
854
- if (!team) throw new Error(`Team '${manifest.team}' not found.`);
855
- debugLog(`[background-runner] team=${team.name}, finding workflow`);
856
- const baseWorkflow =
857
- direct?.workflow ?? allWorkflows(discoverWorkflows(cwd)).find((candidate) => candidate.name === manifest.workflow);
858
- if (!baseWorkflow) throw new Error(`Workflow '${manifest.workflow ?? ""}' not found.`);
859
- debugLog(`[background-runner] workflow=${baseWorkflow.name}`);
860
- const workflow = expandParallelResearchWorkflow(baseWorkflow, cwd);
861
- debugLog(`[background-runner] loading config`);
862
- const loadedConfig = loadConfig(cwd);
863
- const runConfig =
864
- manifest.runConfig && typeof manifest.runConfig === "object" && !Array.isArray(manifest.runConfig)
865
- ? (manifest.runConfig as typeof loadedConfig.config)
866
- : loadedConfig.config;
867
- const runtime = manifest.runtimeResolution
868
- ? {
869
- kind: manifest.runtimeResolution.kind,
870
- requestedMode: manifest.runtimeResolution.requestedMode,
871
- available: manifest.runtimeResolution.available,
872
- fallback: manifest.runtimeResolution.fallback,
873
- steer: manifest.runtimeResolution.kind === "live-session",
874
- resume: manifest.runtimeResolution.kind === "live-session",
875
- liveToolActivity: manifest.runtimeResolution.kind === "live-session",
876
- transcript: manifest.runtimeResolution.kind !== "scaffold",
877
- reason: manifest.runtimeResolution.reason,
878
- safety: manifest.runtimeResolution.safety,
879
- }
880
- : await resolveCrewRuntime(runConfig);
881
- const runtimeResolution = manifest.runtimeResolution ?? runtimeResolutionState(runtime);
882
- manifest = {
883
- ...manifest,
884
- runtimeResolution,
885
- runConfig,
886
- updatedAt: new Date().toISOString(),
887
- };
888
- await saveRunManifestAsync(manifest);
889
- appendEventBuffered(manifest.eventsPath, {
890
- type: "runtime.resolved",
891
- runId: manifest.runId,
892
- message: `Runtime resolved: ${runtime.kind} safety=${runtime.safety}`,
893
- data: { runtimeResolution, async: true },
894
- }).catch((e) => logInternalError("background-runner.buffered", e, "type=runtime.resolved"));
895
- if (runtime.safety === "blocked")
896
- throw new Error(runtime.reason ?? "Child worker execution is disabled; refusing to create no-op scaffold subagents.");
897
- const executeWorkers = runtime.kind !== "scaffold";
898
- // Use ownerSessionId for workspaceId to ensure agents are only visible to the session that spawned them.
899
- // manifest.cwd would cause cross-session visibility since all sessions share the same project directory.
900
- // Mark this as background mode so task-runner writes events to background.log for debugging.
901
- process.env.PI_CREW_BACKGROUND_MODE = "1";
902
- // BUG #17: Keep-alive interval (NOT unref'd) prevents event loop from exiting
903
- // during jiti compilation of team-runner.ts. Without this, the event loop
904
- // can drain when import() blocks, causing the process to exit prematurely.
905
- // NOTE: abortController is already created above (before heartbeat/interrupt guard start)
906
- // so it is available here and its signal is passed through to executeTeamRun → child-pi.
907
-
908
- debugLog(`[background-runner] dispatching runKind=${manifest.runKind ?? "team-run"}`);
909
- try {
910
- // Fix round-4: goal-loop/dynamic-workflow handled by the short-circuit above.
911
- // This switch now only carries the traditional team-run path.
912
- switch (manifest.runKind ?? "team-run") {
913
- default: {
914
- // Existing "team-run" path — unchanged behavior.
915
- // Forward budget fields from manifest (set by team-tool/run.ts
916
- // from params.budgetTotal/etc.) so the team-runner's
917
- // checkPerTaskBudget guard actually arms.
918
- result = await executeTeamRun({
919
- manifest,
920
- tasks,
921
- team,
922
- workflow,
923
- agents,
924
- executeWorkers,
925
- limits: runConfig.limits,
926
- runtime,
927
- runtimeConfig: runConfig.runtime,
928
- skillOverride: manifest.skillOverride,
929
- // Restore the caller's model routing inputs (see RunModelContext):
930
- // this process has no ExtensionContext, so without these the
931
- // `model=` override and the inherited session model are lost and
932
- // every worker falls back to the first models.json entry.
933
- ...restoredModelRouting(manifest),
934
- reliability: runConfig.reliability,
935
- workspaceId: manifest.ownerSessionId ?? manifest.cwd,
936
- signal: abortController.signal,
937
- ...(manifest.budgetTotal !== undefined ? { budgetTotal: manifest.budgetTotal } : {}),
938
- ...(manifest.budgetWarning !== undefined ? { budgetWarning: manifest.budgetWarning } : {}),
939
- ...(manifest.budgetAbort !== undefined ? { budgetAbort: manifest.budgetAbort } : {}),
940
- ...(manifest.budgetUnlimited !== undefined ? { budgetUnlimited: manifest.budgetUnlimited } : {}),
941
- });
942
- break;
943
- }
944
- }
945
- console.log(`[background-runner] executeTeamRun returned, status=${result.manifest.status}`);
946
- } catch (execError) {
947
- console.log(`[background-runner] executeTeamRun THREW: ${errorMessage(execError)}`);
948
- console.log(`[background-runner] stack: ${execError instanceof Error ? execError.stack : "N/A"}`);
949
- throw execError;
950
- }
951
- } // close if (!earlyResult) — team-run setup+execute done; earlyResult path skips to here
952
- manifest = result!.manifest;
953
- tasks = result!.tasks;
954
- appendEventBuffered(manifest.eventsPath, {
955
- type: "async.completed",
956
- runId: manifest.runId,
957
- data: { status: manifest.status, tasks: tasks.length },
958
- }).catch((e) => logInternalError("background-runner.buffered", e, "type=async.completed"));
959
- console.log(`[background-runner] async.completed written, status=${manifest.status}`);
1003
+ const executed = await executeBackgroundRun({ manifest, tasks }, { signal: abortController.signal });
1004
+ manifest = executed.manifest;
1005
+ tasks = executed.tasks;
960
1006
  if (manifest.status === "failed" || manifest.status === "cancelled" || manifest.status === "blocked") process.exitCode = 1;
961
1007
  } catch (error) {
962
1008
  // Terminate live agents on failure too — agents are done when the run fails
@@ -1030,39 +1076,72 @@ async function main(): Promise<void> {
1030
1076
  // guard didn't fire), .catch() ran instead of the finally block doing the exit.
1031
1077
  // New pattern: move await main() inside main() itself, wrapped in try/catch that
1032
1078
  // sets exitDueToRejection so the finally block exits with code 1 after cleanup.
1033
- try {
1034
- await main();
1035
- } catch (err) {
1036
- console.error(`[background-runner] DEBUG: main() uncaught: ${errorMessage(err)}`);
1037
- // FIX Issue #1: Set the flag so the finally block's runCleanup() call
1038
- // will trigger process.exit(1) after cleanup completes. Previously this
1039
- // called process.exit(1) directly, bypassing the finally block and leaving
1040
- // orphaned child processes.
1041
- exitDueToRejection = true;
1042
- // RT-3 FIX: Startup failures (lock-fail, missing manifest, pre-try throws)
1043
- // previously wrote NO event and left exitCode at 0. The run stayed 'queued'
1044
- // until the stale reconciler reaped it. Now write async.failed so the
1045
- // notifier/foreground detects the failure immediately, and set exitCode=1
1046
- // so the process exits non-zero.
1079
+ /**
1080
+ * Entry-point guard: run main() ONLY when this module is the process entry
1081
+ * (detached runner spawned by async-runner, or direct CLI invocation by
1082
+ * integration tests). A plain import — notably the IN-PROCESS async test seam
1083
+ * (async-runner.ts lazy-imports executeBackgroundRun) — must not run a team.
1084
+ * Belt-and-braces: the explicit PI_CREW_BACKGROUND_RUNNER_ENTRY env marker
1085
+ * (set by the async-runner spawn, allowlisted) OR the classic argv[1] check
1086
+ * (keeps direct `node background-runner.ts --cwd ... --run-id ...` spawns in
1087
+ * tests working without the env).
1088
+ */
1089
+ function shouldRunMainAsEntry(): boolean {
1090
+ if (getCrewEnv("PI_CREW_BACKGROUND_RUNNER_ENTRY") === "1") return true;
1047
1091
  try {
1048
- const mCwd = argValue("--cwd");
1049
- const mRunId = argValue("--run-id");
1050
- if (mCwd && mRunId) {
1051
- const mEventsPath = createRunPaths(mCwd, mRunId).eventsPath;
1052
- appendEventBuffered(mEventsPath, {
1053
- type: "async.failed",
1054
- runId: mRunId,
1055
- message: errorMessage(err),
1056
- data: { stack: err instanceof Error ? err.stack : undefined },
1057
- }).catch((e) => logInternalError("background-runner.buffered", e, "type=async.failed source=catch"));
1092
+ const argv1 = process.argv[1];
1093
+ if (!argv1) return false;
1094
+ const a = path.resolve(argv1);
1095
+ const b = path.resolve(fileURLToPath(import.meta.url));
1096
+ const eq = (x: string, y: string): boolean => (process.platform === "win32" ? x.toLowerCase() === y.toLowerCase() : x === b);
1097
+ if (eq(a, b)) return true;
1098
+ try {
1099
+ const ra = fs.realpathSync(a);
1100
+ const rb = fs.realpathSync(b);
1101
+ return process.platform === "win32" ? ra.toLowerCase() === rb.toLowerCase() : ra === rb;
1102
+ } catch {
1103
+ return false;
1058
1104
  }
1059
1105
  } catch {
1060
- /* best-effort — don't let event-write failure mask the original error */
1106
+ return false;
1107
+ }
1108
+ }
1109
+
1110
+ if (shouldRunMainAsEntry()) {
1111
+ try {
1112
+ await main();
1113
+ } catch (err) {
1114
+ console.error(`[background-runner] DEBUG: main() uncaught: ${errorMessage(err)}`);
1115
+ // FIX Issue #1: Set the flag so the finally block's runCleanup() call
1116
+ // will trigger process.exit(1) after cleanup completes. Previously this
1117
+ // called process.exit(1) directly, bypassing the finally block and leaving
1118
+ // orphaned child processes.
1119
+ exitDueToRejection = true;
1120
+ // RT-3 FIX: Startup failures (lock-fail, missing manifest, pre-try throws)
1121
+ // previously wrote NO event and left exitCode at 0. The run stayed 'queued'
1122
+ // until the stale reconciler reaped it. Now write async.failed so the
1123
+ // notifier/foreground detects the failure immediately, and set exitCode=1
1124
+ // so the process exits non-zero.
1125
+ try {
1126
+ const mCwd = argValue("--cwd");
1127
+ const mRunId = argValue("--run-id");
1128
+ if (mCwd && mRunId) {
1129
+ const mEventsPath = createRunPaths(mCwd, mRunId).eventsPath;
1130
+ appendEventBuffered(mEventsPath, {
1131
+ type: "async.failed",
1132
+ runId: mRunId,
1133
+ message: errorMessage(err),
1134
+ data: { stack: err instanceof Error ? err.stack : undefined },
1135
+ }).catch((e) => logInternalError("background-runner.buffered", e, "type=async.failed source=catch"));
1136
+ }
1137
+ } catch {
1138
+ /* best-effort — don't let event-write failure mask the original error */
1139
+ }
1140
+ process.exitCode = 1;
1141
+ // FIX: Call stopParentGuard directly here as a safety net in case the
1142
+ // finally block (which calls runCleanup→stopParentGuard) does not complete.
1143
+ // This ensures the parent guard is stopped in ALL exit paths: normal
1144
+ // completion, unhandled rejection, and fatal errors.
1145
+ stopParentGuard();
1061
1146
  }
1062
- process.exitCode = 1;
1063
- // FIX: Call stopParentGuard directly here as a safety net in case the
1064
- // finally block (which calls runCleanup→stopParentGuard) does not complete.
1065
- // This ensures the parent guard is stopped in ALL exit paths: normal
1066
- // completion, unhandled rejection, and fatal errors.
1067
- stopParentGuard();
1068
1147
  }
@@ -41,6 +41,7 @@ import { NestedSlotBudget } from "../scheduling/nested-slots.ts";
41
41
  import { evaluateDelegateAdmission } from "../spawn-policy.ts";
42
42
  import { type BrokerToken, BrokerTokenRegistry } from "./crew-broker-tokens.ts";
43
43
  import { recordDelegateEvent } from "./delegate/delegate-event.ts";
44
+ import { promoteShadowToRunning } from "./delegate/shadow-lifecycle.ts";
44
45
  import { fanoutMailboxMessage } from "./mailbox-observer/mailbox-fanout.ts";
45
46
  import type { CrewBrokerOptions, ServerConnection } from "./protocol/connection-state.ts";
46
47
  import { handleEventsSince } from "./protocol/events-replay.ts";
@@ -1449,6 +1450,10 @@ export class CrewBroker {
1449
1450
  });
1450
1451
  return { code: "bad-params" as const, message: `delegate: parent task '${parentTaskId}' is ${task.status}, not running` };
1451
1452
  }
1453
+ // RR-012 F03: authoritative execution cwd = the parent task's cwd from
1454
+ // this locked fresh read — ONE source for overlap check, shadow record
1455
+ // and spawner input; broker cwd stays for manifest lookup only.
1456
+ const executionCwd = task.cwd;
1452
1457
  const catalog = this.options.modelCatalog?.();
1453
1458
  // S3 fail-closed: a DEFINED loader yielding undefined is a loader failure.
1454
1459
  const effectiveCatalog = this.options.modelCatalog !== undefined ? (catalog ?? []) : undefined;
@@ -1459,7 +1464,7 @@ export class CrewBroker {
1459
1464
  t.id !== parentTaskId &&
1460
1465
  t.status === "running" &&
1461
1466
  (t.role === "executor" || t.role === "test-engineer") &&
1462
- t.cwd === task.cwd,
1467
+ t.cwd === executionCwd,
1463
1468
  ).length;
1464
1469
  const decision = evaluateDelegateAdmission({
1465
1470
  maxDepth: this.options.nestingMaxDepth ?? resolveCrewMaxDepth(undefined), // config knob > env-clamped 1..10, default 4 (D8; ADR-5 §3)
@@ -1551,19 +1556,19 @@ export class CrewBroker {
1551
1556
  agent: "delegate",
1552
1557
  title: `delegate: ${(requested as { description?: string }).description ?? subId}`,
1553
1558
  status: "queued",
1554
- cwd: task.cwd,
1559
+ cwd: executionCwd,
1555
1560
  dependsOn: [],
1556
1561
  depth: decision.childDepth ?? 2,
1557
1562
  startedAt: new Date().toISOString(),
1558
1563
  } satisfies TeamTaskState,
1559
1564
  ]);
1560
- return { code: "ok" as const, decision, reserved };
1565
+ return { code: "ok" as const, decision, reserved, executionCwd };
1561
1566
  });
1562
1567
  if (admissionOutcome.code !== "ok") {
1563
1568
  this.sendError(conn, id, admissionOutcome.code, admissionOutcome.message);
1564
1569
  return;
1565
1570
  }
1566
- const { decision, reserved } = admissionOutcome;
1571
+ const { decision, reserved, executionCwd } = admissionOutcome;
1567
1572
  this.recordDelegateEvent(loaded.manifest, "delegate.admitted", parentTaskId, {
1568
1573
  subId,
1569
1574
  childDepth: decision.childDepth,
@@ -1585,7 +1590,7 @@ export class CrewBroker {
1585
1590
  let outcome: GrandchildSpawnResult;
1586
1591
  try {
1587
1592
  outcome = await spawner({
1588
- cwd,
1593
+ cwd: executionCwd,
1589
1594
  runId,
1590
1595
  parentTaskId,
1591
1596
  subId,
@@ -1597,6 +1602,10 @@ export class CrewBroker {
1597
1602
  ...(requested.maxTurns !== undefined ? { maxTurns: requested.maxTurns } : {}),
1598
1603
  timeoutSec: decision.timeoutSec ?? 900,
1599
1604
  depthOverride: decision.childDepth ?? 2,
1605
+ // RR-012 F16: promote shadow queued→running once the process exists.
1606
+ onSpawn: (pid) => {
1607
+ if (pid !== null) promoteShadowToRunning(cwd, runId, subId);
1608
+ },
1600
1609
  });
1601
1610
  } catch (err) {
1602
1611
  outcome = { ok: false, resultText: `delegate spawn failed: ${(err as Error).message}` };
@@ -1649,12 +1658,9 @@ export class CrewBroker {
1649
1658
  });
1650
1659
  }
1651
1660
  }
1652
- // S1#1: flip the shadow task to terminal — UNCONDITIONAL (verifier N1): the
1653
- // flip must not depend on a reservation; no allocation producer ships
1654
- // yet so reserved===0 is the common case and the guard left every
1655
- // shadow "queued" forever.
1656
- // S1#1: flip the shadow task to terminal so the subId record does not
1657
- // linger as "queued" in team status views.
1661
+ // S1#1 (verifier N1): the terminal flip is UNCONDITIONAL — never depends
1662
+ // on a reservation. RR-012 F16: onSpawn promotes the record to "running"
1663
+ // during execution; this flip terminalizes every settle path.
1658
1664
  saveRunTasks(
1659
1665
  latest.manifest,
1660
1666
  tasksToWrite.map((t) =>