pi-crew 0.11.1 → 0.11.4

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 (154) hide show
  1. package/CHANGELOG.md +82 -9
  2. package/README.md +161 -1036
  3. package/agents/verifier.md +18 -7
  4. package/dist/index.mjs +742 -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 +5 -4
  10. package/schema.json +29 -0
  11. package/skills/real-test-pi-crew/SKILL.md +206 -41
  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 +55 -8
  20. package/src/extension/crew-input-router.ts +9 -2
  21. package/src/extension/help.ts +21 -10
  22. package/src/extension/knowledge-injection.ts +2 -1
  23. package/src/extension/management.ts +8 -3
  24. package/src/extension/notification-sink.ts +17 -0
  25. package/src/extension/plan-orchestrate.ts +19 -3
  26. package/src/extension/registration/command-utils.ts +28 -2
  27. package/src/extension/registration/commands/dashboard.ts +11 -1
  28. package/src/extension/registration/commands/manage.ts +31 -15
  29. package/src/extension/registration/commands/run.ts +24 -2
  30. package/src/extension/registration/commands/shared.ts +34 -10
  31. package/src/extension/registration/commands/status.ts +25 -2
  32. package/src/extension/registration/context-builder.ts +8 -2
  33. package/src/extension/registration/foreground-run-controller.ts +2 -2
  34. package/src/extension/registration/health-notify-policy.ts +100 -0
  35. package/src/extension/registration/lazy-configurers.ts +38 -0
  36. package/src/extension/registration/lifecycle-handlers.ts +107 -40
  37. package/src/extension/registration/lifecycle.ts +77 -10
  38. package/src/extension/registration/observability.ts +125 -53
  39. package/src/extension/registration/registration-types.ts +7 -5
  40. package/src/extension/registration/runtime-cleanup.ts +9 -3
  41. package/src/extension/registration/subagent-helpers.ts +38 -0
  42. package/src/extension/registration/team-tool.ts +9 -2
  43. package/src/extension/registration/wire-cross-extension.ts +30 -0
  44. package/src/extension/run-compare.ts +220 -0
  45. package/src/extension/run-export.ts +37 -5
  46. package/src/extension/run-maintenance.ts +155 -5
  47. package/src/extension/team-manager-command.ts +3 -2
  48. package/src/extension/team-tool/dispatch/index.ts +3 -2
  49. package/src/extension/team-tool/dispatch/manage.ts +5 -2
  50. package/src/extension/team-tool/goal-wrap.ts +7 -0
  51. package/src/extension/team-tool/goal.ts +16 -1
  52. package/src/extension/team-tool/handle-settings.ts +19 -2
  53. package/src/extension/team-tool/health-monitor.ts +21 -7
  54. package/src/extension/team-tool/lifecycle-actions.ts +49 -1
  55. package/src/extension/team-tool/parallel-dispatch.ts +16 -1
  56. package/src/extension/team-tool/plan.ts +10 -0
  57. package/src/extension/team-tool/routing-hint.ts +63 -0
  58. package/src/extension/team-tool/run-intent.ts +1 -1
  59. package/src/extension/team-tool/run.ts +1 -1
  60. package/src/extension/team-tool/status.ts +4 -0
  61. package/src/extension/team-tool.ts +52 -6
  62. package/src/extension/webhook-notify.ts +382 -0
  63. package/src/observability/metric-sink.ts +12 -2
  64. package/src/prompt/prompt-runtime.ts +82 -31
  65. package/src/prompt/worker-events-channel.ts +12 -0
  66. package/src/runtime/README.md +1 -1
  67. package/src/runtime/async-runner.ts +123 -1
  68. package/src/runtime/background-runner.ts +313 -234
  69. package/src/runtime/broker/crew-broker.ts +76 -71
  70. package/src/runtime/broker/delegate/delegate-event.ts +12 -8
  71. package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
  72. package/src/runtime/broker/protocol/connection-state.ts +10 -0
  73. package/src/runtime/broker/protocol/lock-busy.ts +76 -0
  74. package/src/runtime/broker/wait-status-cache.ts +1 -1
  75. package/src/runtime/child-pi/child-pi-timers.ts +1 -1
  76. package/src/runtime/child-pi/mock-fixtures.ts +48 -0
  77. package/src/runtime/crew-agent-records.ts +337 -45
  78. package/src/runtime/deadletter.ts +43 -1
  79. package/src/runtime/delegate-spawn.ts +21 -3
  80. package/src/runtime/deterministic-ast.ts +14 -2
  81. package/src/runtime/dispatch-batch.ts +72 -5
  82. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +78 -58
  83. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +14 -2
  84. package/src/runtime/goal-workflow/goal-loop-runner.ts +105 -4
  85. package/src/runtime/group-join.ts +11 -3
  86. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  87. package/src/runtime/live-session/live-session-runtime.ts +1 -1
  88. package/src/runtime/model/model-fallback.ts +21 -1
  89. package/src/runtime/model/pi-args.ts +8 -10
  90. package/src/runtime/model/runtime-warmup.ts +2 -0
  91. package/src/runtime/output/output-validator.ts +10 -5
  92. package/src/runtime/plan-approval.ts +14 -1
  93. package/src/runtime/recovery/crash-recovery.ts +96 -49
  94. package/src/runtime/run-worker.ts +12 -1
  95. package/src/runtime/scheduling/global-worker-cap.ts +13 -6
  96. package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
  97. package/src/runtime/scheduling/scheduler.ts +49 -13
  98. package/src/runtime/scheduling/semaphore.ts +148 -20
  99. package/src/runtime/scratchpad/README.md +1 -1
  100. package/src/runtime/scratchpad/protocol.ts +1 -1
  101. package/src/runtime/settings-store.ts +1 -1
  102. package/src/runtime/skill-instructions.ts +22 -0
  103. package/src/runtime/stale-reconciler.ts +85 -13
  104. package/src/runtime/subagent-manager.ts +43 -13
  105. package/src/runtime/task-runner/child-executor.ts +17 -6
  106. package/src/runtime/task-runner/post-execution.ts +44 -14
  107. package/src/runtime/task-runner/pre-execution.ts +26 -2
  108. package/src/runtime/task-runner/prompt-builder.ts +150 -45
  109. package/src/runtime/task-runner/retrieval-orchestrator.ts +36 -14
  110. package/src/runtime/task-runner.ts +28 -1
  111. package/src/runtime/team-runner.ts +42 -2
  112. package/src/runtime/verification/verification-worktree.ts +3 -0
  113. package/src/runtime/workspace-lock.ts +4 -1
  114. package/src/schema/config-schema.ts +14 -0
  115. package/src/schema/team-tool-schema.ts +17 -0
  116. package/src/state/atomic-write.ts +53 -0
  117. package/src/state/contracts.ts +109 -0
  118. package/src/state/coordination/locks.ts +191 -33
  119. package/src/state/coordination/mailbox.ts +147 -17
  120. package/src/state/crew-init.ts +87 -12
  121. package/src/state/event-log/cursor.ts +37 -1
  122. package/src/state/event-log/event-log-rotation.ts +72 -7
  123. package/src/state/stores/active-run-registry.ts +13 -1
  124. package/src/state/stores/state-store.ts +120 -23
  125. package/src/state/types.ts +4 -0
  126. package/src/ui/dashboard-panes/agents-pane.ts +22 -10
  127. package/src/ui/heartbeat-aggregator.ts +34 -0
  128. package/src/ui/inline-panel/index.ts +1 -1
  129. package/src/ui/keybinding-map.ts +22 -4
  130. package/src/ui/live-conversation-overlay.ts +6 -3
  131. package/src/ui/render-diff.ts +19 -6
  132. package/src/ui/run-dashboard.ts +109 -6
  133. package/src/ui/run-snapshot-cache.ts +27 -4
  134. package/src/ui/spinner.ts +26 -2
  135. package/src/ui/tool-progress-formatter.ts +2 -1
  136. package/src/ui/tool-renderers/brief-mode.ts +2 -1
  137. package/src/ui/tool-renderers/index.ts +3 -3
  138. package/src/ui/widget/task-list.ts +8 -5
  139. package/src/ui/widget/widget-model.ts +3 -1
  140. package/src/utils/incremental-reader.ts +11 -3
  141. package/src/utils/paths.ts +94 -12
  142. package/src/utils/project-markers.ts +40 -0
  143. package/src/worktree/worktree-manager.ts +206 -26
  144. package/tsconfig.json +2 -0
  145. package/workflows/distill.workflow.md +3 -3
  146. package/workflows/fast-fix.workflow.md +1 -1
  147. package/workflows/plan-execute.workflow.md +1 -1
  148. package/workflows/review.workflow.md +1 -1
  149. package/workflows/strict-fast-fix.workflow.md +1 -1
  150. package/docs/migration-v0.4-v0.5.md +0 -208
  151. package/docs/runtime-flow.md +0 -148
  152. package/src/ui/card-colors.ts +0 -168
  153. package/src/utils/completion-dedupe.ts +0 -63
  154. package/src/utils/resolve-shell.ts +0 -36
@@ -13,12 +13,14 @@ import type { CrewReliabilityConfig } from "../config/config.ts";
13
13
  import { CrewError, ErrorCode } from "../errors.ts";
14
14
  import { appendHookEvent, executeHook } from "../hooks/registry.ts";
15
15
  import { childCorrelation, withCorrelation } from "../observability/correlation.ts";
16
+ import { TEAM_TERMINAL_RUN_STATUSES } from "../state/contracts.ts";
16
17
  import { withRunLockSync } from "../state/coordination/locks.ts";
17
18
  import { appendEventAsync, appendEventBuffered } from "../state/event-log/event-log.ts";
18
19
  import { loadRunManifestById, saveRunManifest, saveRunTasks, saveRunTasksAsync, updateRunStatus } from "../state/stores/state-store.ts";
19
20
  import type { TaskAttemptState, TeamRunManifest, TeamTaskState } from "../state/types.ts";
20
21
  import { logInternalError } from "../utils/internal-error.ts";
21
22
  import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
23
+ import { isDelegateShadowTask } from "./broker/delegate/shadow-lifecycle.ts";
22
24
  import { readCrewAgents, recordFromTask, saveCrewAgents } from "./crew-agent-records.ts";
23
25
  import { appendDeadletter } from "./deadletter.ts";
24
26
  import { classifyHeartbeat, DEFAULT_GRADIENT_THRESHOLDS } from "./heartbeat/heartbeat-gradient.ts";
@@ -316,6 +318,26 @@ export function shouldUseRetry(reliability: CrewReliabilityConfig | undefined):
316
318
  return reliability?.autoRetry !== false;
317
319
  }
318
320
 
321
+ /**
322
+ * T9d cancel-race fix (2026-09-23, live battery finding 7): a retry attempt must
323
+ * re-queue ONLY the task's OWN terminal failure (status "failed") while the run is
324
+ * still active. Previously ANY non-queued/running status was re-queued — including
325
+ * "cancelled", so a cross-session cancel landing between attempt 1's hard-kill and
326
+ * attempt 2's start (measured live: cancel 16:46:22.987 → task.started 16:46:23.948,
327
+ * replacement worker then ran 47s post-cancel) silently resurrected the task.
328
+ * External terminal decisions (task cancelled/completed, or a terminal RUN manifest)
329
+ * are respected. Exported for unit testing.
330
+ */
331
+ export function shouldRequeueForRetry(input: {
332
+ attempt: number;
333
+ taskStatus: TeamTaskState["status"];
334
+ manifestStatus: TeamRunManifest["status"];
335
+ }): boolean {
336
+ if (input.attempt <= 1) return false;
337
+ if (TEAM_TERMINAL_RUN_STATUSES.has(input.manifestStatus)) return false;
338
+ return input.taskStatus === "failed";
339
+ }
340
+
319
341
  function failedTaskFrom(result: { tasks: TeamTaskState[] }, taskId: string): TeamTaskState | undefined {
320
342
  return result.tasks.find((item) => item.id === taskId && item.status === "failed");
321
343
  }
@@ -328,6 +350,13 @@ function failedTaskFrom(result: { tasks: TeamTaskState[] }, taskId: string): Tea
328
350
  function dagReadyTaskIds(tasks: TeamTaskState[], completedIds: Set<string>): string[] | null {
329
351
  const hasExplicitDeps = tasks.some((t) => t.dependsOn.length > 0);
330
352
  if (!hasExplicitDeps) return null;
353
+ // RR-012 (adjacent scheduler risk — proven reachable): exclude
354
+ // delegate-broker shadow records from the DAG. They are managed by the
355
+ // external grandchild spawner, not this scheduler: (1) as WAVE members a
356
+ // running shadow would gate dependent waves / trigger markBlocked although
357
+ // it is never in ctx.pendingUnits; (2) getDagReadyTasks below ignores both
358
+ // status AND graph, so a queued OR running shadow surfaces as ready.
359
+ const workflowTasks = tasks.filter((t) => !isDelegateShadowTask(t));
331
360
  // FIX (goal-wrap runtime test): task.dependsOn stores STEP IDs (e.g. "execute"), not
332
361
  // task IDs (e.g. "02_execute"). The DAG scheduler compares deps against completedIds
333
362
  // (which are task IDs), so step-ID deps would never match → dependent tasks stuck blocked
@@ -335,10 +364,10 @@ function dagReadyTaskIds(tasks: TeamTaskState[], completedIds: Set<string>): str
335
364
  // task-graph-scheduler.ts which handles this via stepToTaskId). buildDagExecutionPlan +
336
365
  // getDagReadyTasks then work on consistent task IDs.
337
366
  const stepToTaskId = new Map<string, string>();
338
- for (const t of tasks) {
367
+ for (const t of workflowTasks) {
339
368
  if (t.stepId) stepToTaskId.set(t.stepId, t.id);
340
369
  }
341
- const nodes: TaskNode[] = tasks.map((t) => ({
370
+ const nodes: TaskNode[] = workflowTasks.map((t) => ({
342
371
  id: t.id,
343
372
  dependsOn: t.dependsOn.map((dep) => stepToTaskId.get(dep) ?? dep),
344
373
  phase: t.adaptive?.phase ?? t.stepId,
@@ -410,7 +439,15 @@ export async function selectDispatchBatch(ctx: SchedulerContext): Promise<Schedu
410
439
  // existing task-graph-scheduler when no explicit deps exist (backward compat).
411
440
  const completedIds = new Set(ctx.tasks.filter((t) => t.status === "completed" || t.status === "needs_attention").map((t) => t.id));
412
441
  const dagReady = dagReadyTaskIds(ctx.tasks, completedIds);
413
- const readyBeforeFilter = dagReady ?? snapshot.ready;
442
+ // RR-012 (adjacent scheduler risk — proven reachable): belt-and-braces
443
+ // selection guard. dagReadyTaskIds already excludes shadow records from the
444
+ // DAG; this filter also covers the snapshot fallback (a graphed queued
445
+ // shadow would resolve to queue "ready"). A shadow in a batch makes
446
+ // findStep() throw ResourceNotFound (task.stepId === undefined) — the run
447
+ // aborts (pre-warm site) or the record is force-failed by the synthesized
448
+ // unit error. Shadow records stay visible in snapshots / team status.
449
+ const shadowTaskIds = new Set(ctx.tasks.filter(isDelegateShadowTask).map((t) => t.id));
450
+ const readyBeforeFilter = (dagReady ?? snapshot.ready).filter((taskId) => !shadowTaskIds.has(taskId));
414
451
 
415
452
  // Workflow phase precondition check (non-blocking: log warnings only).
416
453
  if (ctx.wfMachine.currentPhaseIndex < ctx.wfMachine.phases.length) {
@@ -777,8 +814,25 @@ export async function dispatchBatch(ctx: SchedulerContext, decision: DispatchBat
777
814
  // NOTE: no withRunLock — best-effort only; concurrent writes may cause inconsistency
778
815
  const fresh = loadRunManifestById(ctx.manifest.cwd, ctx.manifest.runId);
779
816
  const freshManifest = fresh?.manifest ?? ctx.manifest;
780
- const freshTasks = fresh?.tasks ?? ctx.tasks;
781
- const freshTask = freshTasks.find((item) => item.id === task.id) ?? task;
817
+ let freshTasks = fresh?.tasks ?? ctx.tasks;
818
+ let freshTask = freshTasks.find((item) => item.id === task.id) ?? task;
819
+ // US-003 (2026-09-22) — RETRY-NOOP BUG, found while wiring the dead-letter
820
+ // integration test: a retry (attempt > 1) only happens because OUR previous
821
+ // attempt THREW — i.e. we ourselves persisted the terminal failure (e.g.
822
+ // model-exhausted marks the task failed before the throw). The terminal-state
823
+ // early-return below then treated OUR OWN failure as "nothing to do" and
824
+ // returned it as SUCCESS: executeWithRetry never re-ran the task, never
825
+ // exhausted, and the onRetryGivenUp dead-letter never fired — autoRetry was
826
+ // silently a single attempt for every failure that persists task state.
827
+ // Fix: on a retry, re-queue OUR OWN terminal failure so the retry actually
828
+ // re-runs. Attempt 1 keeps the original guard (externally-terminal task =
829
+ // someone else's decision; external cancellation exits via the signal).
830
+ // 2026-09-23 (finding 7): re-queue ONLY own failures on an ACTIVE run —
831
+ // cancelled/completed tasks and terminal manifests must never resurrect.
832
+ if (shouldRequeueForRetry({ attempt, taskStatus: freshTask.status, manifestStatus: freshManifest.status })) {
833
+ freshTask = { ...freshTask, status: "queued", error: undefined, finishedAt: undefined };
834
+ freshTasks = freshTasks.map((item) => (item.id === task.id ? freshTask : item));
835
+ }
782
836
  if (freshTask.status !== "queued" && freshTask.status !== "running")
783
837
  return {
784
838
  manifest: freshManifest,
@@ -855,6 +909,15 @@ export async function dispatchBatch(ctx: SchedulerContext, decision: DispatchBat
855
909
  },
856
910
  onRetryGivenUp: (attempts, error, info) => {
857
911
  lastAttemptId = info.attemptId;
912
+ // US-003: aborts are NOT exhaustion — executeWithRetry also fires this
913
+ // hook on its cancelled exit, which previously deadlettered cancelled
914
+ // tasks as "max-retries" (false positive; keep the signal high).
915
+ if (runController.signal.aborted) return;
916
+ // US-003: richer entry (agent/role/modelAttempts/runStatus) so the
917
+ // project-level index is self-sufficient after the run dir is pruned.
918
+ // Prefer the FAILED attempt's final task state over the pre-run snapshot.
919
+ const failedTask = lastFailed?.tasks.find((item) => item.id === task.id);
920
+ const deadletterTask = failedTask ?? task;
858
921
  appendDeadletter(ctx.manifest, {
859
922
  runId: ctx.manifest.runId,
860
923
  taskId: task.id,
@@ -863,6 +926,10 @@ export async function dispatchBatch(ctx: SchedulerContext, decision: DispatchBat
863
926
  attemptId: info.attemptId,
864
927
  lastError: error.message,
865
928
  timestamp: new Date().toISOString(),
929
+ agent: deadletterTask.agent,
930
+ role: deadletterTask.role,
931
+ modelAttempts: deadletterTask.modelAttempts?.length,
932
+ runStatus: ctx.manifest.status,
866
933
  });
867
934
  input.metricRegistry
868
935
  ?.counter("crew.task.deadletter_total", "Deadletter triggers by reason")
@@ -906,67 +906,87 @@ function describeSchemaShape(schema: unknown, depth: number): string {
906
906
  * reject is checked first because a review can mention both "correctly" (describing existing code)
907
907
  * AND "critical bug" (the verdict) — the verdict signal must win.
908
908
  */
909
+ // Precompiled review-outcome signals (module scope — compiled once, not per call).
910
+ // RR-021 WI-1.1: these were previously string literals like "\baccept\b" compiled
911
+ // per call — and "\b" in a TS string literal is a BACKSPACE character (U+0008),
912
+ // so those two signals never matched anything. Regex literals here give real word
913
+ // boundaries. No /g flags → no shared lastIndex state across .test() calls.
914
+ const REJECT_SIGNAL_REGEXES: readonly RegExp[] = [
915
+ /\breject\b/,
916
+ /fundamentally/,
917
+ /completely broken/,
918
+ /totally broken/,
919
+ /critical bug/,
920
+ /critical issue/,
921
+ /critical flaw/,
922
+ /security vulnerability/,
923
+ /does not work/,
924
+ /doesn't work/,
925
+ /will not work/,
926
+ /fails to/,
927
+ /unacceptable/,
928
+ /must not be merged/,
929
+ /do not merge/,
930
+ /wrong approach/,
931
+ /logically incorrect/,
932
+ /incorrectly implements/,
933
+ /returns the opposite/,
934
+ /subtraction instead of addition/,
935
+ /opposite of its intended/,
936
+ ];
937
+ const ACCEPT_SIGNAL_REGEXES: readonly RegExp[] = [
938
+ /\baccept\b/,
939
+ /looks good/,
940
+ /well done/,
941
+ /no issues/,
942
+ /no real issues/,
943
+ /no problems/,
944
+ /no concerns/,
945
+ /nothing to change/,
946
+ /ready to merge/,
947
+ /lgtm/,
948
+ /ship it/,
949
+ /correctly implements/,
950
+ /correctly returns/,
951
+ /works as expected/,
952
+ /works correctly/,
953
+ /no bugs/,
954
+ /no defects/,
955
+ /meets all requirements/,
956
+ /all requirements met/,
957
+ /passes all/,
958
+ /is correct/,
959
+ /are correct/,
960
+ /no changes needed/,
961
+ /no changes required/,
962
+ /no further changes/,
963
+ /nothing more to/,
964
+ /complete and correct/,
965
+ /sound implementation/,
966
+ ];
967
+ // RR-021 WI-1.1 negation guard: phrases that NEGATE the bare "accept" signal.
968
+ // "I cannot accept this" is a complaint, not approval — the accept hit must be
969
+ // discarded so the outcome falls through to changes_requested (or reject if a
970
+ // reject signal is also present).
971
+ const NEGATED_ACCEPT_REGEXES: readonly RegExp[] = [
972
+ /\bcannot\s+accept\b/,
973
+ /\bcan'?t\s+accept\b/,
974
+ /\bdon'?t\s+accept\b/,
975
+ /\bwon'?t\s+accept\b/,
976
+ /\bdo\s+not\s+accept\b/,
977
+ /\bwill\s+not\s+accept\b/,
978
+ /\bunable\s+to\s+accept\b/,
979
+ /\bnot\s+accept(?:ed|ing|able)?\b/,
980
+ ];
981
+
909
982
  export function classifyReviewOutcome(prose: string): "accept" | "reject" | "changes_requested" {
910
983
  const text = prose.toLowerCase();
911
- // Strong negative signals → reject. These indicate fundamental/critical problems.
912
- const rejectSignals = [
913
- "\breject\b",
914
- "fundamentally",
915
- "completely broken",
916
- "totally broken",
917
- "critical bug",
918
- "critical issue",
919
- "critical flaw",
920
- "security vulnerability",
921
- "does not work",
922
- "doesn't work",
923
- "will not work",
924
- "fails to",
925
- "unacceptable",
926
- "must not be merged",
927
- "do not merge",
928
- "wrong approach",
929
- "logically incorrect",
930
- "incorrectly implements",
931
- "returns the opposite",
932
- "subtraction instead of addition",
933
- "opposite of its intended",
934
- ];
935
- // Acceptance signals → accept. These indicate explicit approval with no real issues.
936
- const acceptSignals = [
937
- "\baccept\b",
938
- "looks good",
939
- "well done",
940
- "no issues",
941
- "no real issues",
942
- "no problems",
943
- "no concerns",
944
- "nothing to change",
945
- "ready to merge",
946
- "lgtm",
947
- "ship it",
948
- "correctly implements",
949
- "correctly returns",
950
- "works as expected",
951
- "works correctly",
952
- "no bugs",
953
- "no defects",
954
- "meets all requirements",
955
- "all requirements met",
956
- "passes all",
957
- "is correct",
958
- "are correct",
959
- "no changes needed",
960
- "no changes required",
961
- "no further changes",
962
- "nothing more to",
963
- "complete and correct",
964
- "sound implementation",
965
- ];
966
- const hasReject = rejectSignals.some((sig) => new RegExp(sig).test(text));
967
- const hasAccept = acceptSignals.some((sig) => new RegExp(sig).test(text));
984
+ // Reject-first precedence: a review can mention both "correctly" (describing
985
+ // existing code) AND "critical bug" (the verdict) — the verdict signal must win.
986
+ const hasReject = REJECT_SIGNAL_REGEXES.some((re) => re.test(text));
968
987
  if (hasReject) return "reject";
969
- if (hasAccept) return "accept";
988
+ const hasAccept = ACCEPT_SIGNAL_REGEXES.some((re) => re.test(text));
989
+ if (hasAccept && !NEGATED_ACCEPT_REGEXES.some((re) => re.test(text))) return "accept";
970
990
  return "changes_requested";
971
991
  }
972
992
 
@@ -18,8 +18,8 @@
18
18
  */
19
19
 
20
20
  import { readFileSync } from "node:fs";
21
+ import { createRequire } from "node:module";
21
22
  import { join } from "node:path";
22
- import { transformSync } from "esbuild";
23
23
  import { getCrewEnv } from "../../config/env-vars.ts";
24
24
  import { appendEvent } from "../../state/event-log/event-log.ts";
25
25
  import { writeArtifact } from "../../state/stores/artifact-store.ts";
@@ -33,6 +33,18 @@ import { assertDeterministicScript, isDeterminismCheckEnabled } from "../determi
33
33
  import { DwfStore } from "../dwf-state-store.ts";
34
34
  import { getWorkflowFinalResult, getWorkflowPhaseState, makeWorkflowCtx } from "./dynamic-workflow-context.ts";
35
35
 
36
+ // RR-021 WI-2.1: esbuild is a heavy native dep (~35ms+ cold load) but is only
37
+ // needed when a .dwf.ts script actually executes. Lazy-load via a synchronous
38
+ // createRequire shim on first use (model: ui/syntax-highlight.ts) — call sites
39
+ // stay sync. esbuild remains external in the bundle and resolves through here.
40
+ const nodeRequire = createRequire(import.meta.url);
41
+ type EsbuildModule = typeof import("esbuild");
42
+ let cachedEsbuild: EsbuildModule | undefined;
43
+ function esbuild(): EsbuildModule {
44
+ if (!cachedEsbuild) cachedEsbuild = nodeRequire("esbuild") as EsbuildModule;
45
+ return cachedEsbuild;
46
+ }
47
+
36
48
  export interface RunDynamicWorkflowInput {
37
49
  manifest: TeamRunManifest;
38
50
  workflow: DynamicWorkflowConfig;
@@ -119,7 +131,7 @@ async function loadWorkflowModule(scriptPath: string): Promise<DynamicWorkflowSc
119
131
  // DISC-1: acorn can only parse JavaScript, not TypeScript. Real .dwf.ts files
120
132
  // contain type annotations/imports that cause a silent parse error, so the
121
133
  // determinism check never runs. Transpile to JS via esbuild first.
122
- const js = transformSync(scriptSource, { loader: "ts", format: "esm" }).code;
134
+ const js = esbuild().transformSync(scriptSource, { loader: "ts", format: "esm" }).code;
123
135
  assertDeterministicScript(js);
124
136
  }
125
137
  // jiti is the same loader async-runner.ts uses (resolveTypeScriptLoader). We require it
@@ -23,12 +23,13 @@ import { effectiveRunConfig } from "../../extension/team-tool/config-patch.ts";
23
23
  import { appendEventBuffered } from "../../state/event-log/event-log.ts";
24
24
  import { registerActiveRun, unregisterActiveRun } from "../../state/stores/active-run-registry.ts";
25
25
  import { collectRunMetrics } from "../../state/stores/run-metrics.ts";
26
- import { createRunManifest, saveRunTasks } from "../../state/stores/state-store.ts";
26
+ import { createRunManifest, loadRunManifestById, saveRunTasks, updateRunStatus } from "../../state/stores/state-store.ts";
27
27
  import type { GoalLoopState, GoalLoopStatus, GoalVerdict, TeamRunManifest, TeamTaskState } from "../../state/types.ts";
28
28
  import type { TeamConfig } from "../../teams/team-config.ts";
29
29
  import { logInternalError } from "../../utils/internal-error.ts";
30
30
  import type { WorkflowConfig } from "../../workflows/workflow-config.ts";
31
31
  import { resolveCrewRuntime } from "../model/runtime-resolver.ts";
32
+ import { registryFromModelContext } from "../model/session-model.ts";
32
33
  import { executeTeamRun } from "../team-runner.ts";
33
34
  import { compareSnapshot, snapshotManifests } from "../verification/verification-integrity.ts";
34
35
  import { acquireWorkspaceLock, type WorkspaceLockHandle } from "../workspace-lock.ts";
@@ -39,6 +40,17 @@ import { GoalStore } from "./goal-state-store.ts";
39
40
  export interface GoalLoopRuntimeDeps {
40
41
  /** Resolve the agent configs reachable from cwd (used for executeTeamRun's agents arg). */
41
42
  discoverAgents: (cwd: string) => AgentConfig[];
43
+ /**
44
+ * SR-01 test seam: override the per-turn config/runtime resolution. When
45
+ * provided, it replaces the loadConfig/effectiveRunConfig/resolveCrewRuntime
46
+ * chain — a test can throw here to exercise the pre-executeTeamRun failure
47
+ * path deterministically (the alternative, monkey-patching modules, is
48
+ * flaky). Production never sets it.
49
+ */
50
+ resolveTurnConfig?: (cwd: string) => {
51
+ executedConfig: ReturnType<typeof effectiveRunConfig>;
52
+ runtime: Awaited<ReturnType<typeof resolveCrewRuntime>>;
53
+ };
42
54
  }
43
55
 
44
56
  export interface RunGoalLoopInput {
@@ -54,6 +66,29 @@ export interface RunGoalLoopResult {
54
66
  goalState: GoalLoopState;
55
67
  }
56
68
 
69
+ /**
70
+ * Finding #1 (battery 2026-09-29): re-hydrate the model routing inputs the
71
+ * OUTER goal-loop manifest persisted at dispatch time (captureRunModelContext
72
+ * in goal.ts / goal-wrap.ts). The detached goal-loop runs in the background
73
+ * runner with no ExtensionContext — without this, every turn's executeTeamRun
74
+ * resolves the DEFAULT chain (live: minimax/MiniMax-M3 402 while the parent
75
+ * session ran zai/glm-5.3). Mirrors background-runner's restoredModelRouting().
76
+ */
77
+ function restoredTurnModelRouting(manifest: TeamRunManifest): {
78
+ modelOverride?: string;
79
+ parentModel?: string;
80
+ modelRegistry?: { getAvailable: () => unknown[] };
81
+ } {
82
+ const context = manifest.modelContext;
83
+ if (!context) return {};
84
+ const modelRegistry = registryFromModelContext(context);
85
+ return {
86
+ ...(context.override ? { modelOverride: context.override } : {}),
87
+ ...(context.parentModel ? { parentModel: context.parentModel } : {}),
88
+ ...(modelRegistry ? { modelRegistry } : {}),
89
+ };
90
+ }
91
+
57
92
  /**
58
93
  * The placeholder evaluator for P0: always returns {achieved:false}.
59
94
  * Kept for unit tests of the loop's max_turns exit path. The production loop
@@ -619,6 +654,10 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
619
654
  workspaceMode: "single",
620
655
  ownerSessionId: goal.ownerSessionId,
621
656
  runKind: "team-run", // §0a v2 note: turns are normal team-runs; the OUTER loop is goal-loop
657
+ // Finding #1: turns execute in the detached process — carry the outer
658
+ // manifest's model routing snapshot onto each turn manifest too, so
659
+ // status/resume paths see the same routing the turn was dispatched with.
660
+ ...(manifest.modelContext ? { modelContext: manifest.modelContext } : {}),
622
661
  });
623
662
  goal =
624
663
  store.patch(
@@ -657,9 +696,17 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
657
696
  // multi-step workflows (fast-fix, implementation) work correctly. Without these, the
658
697
  // team-runner's DAG scheduler / runtime resolution can throw unhandled rejections on
659
698
  // the second batch, which the background-runner's rejection guard catches → silent exit.
660
- const turnConfig = loadConfig(goal.cwd);
661
- const turnExecutedConfig = effectiveRunConfig(turnConfig.config, {});
662
- const turnRuntime = await resolveCrewRuntime(turnExecutedConfig);
699
+ let turnExecutedConfig: ReturnType<typeof effectiveRunConfig>;
700
+ let turnRuntime: Awaited<ReturnType<typeof resolveCrewRuntime>>;
701
+ if (input.deps.resolveTurnConfig) {
702
+ const resolved = input.deps.resolveTurnConfig(goal.cwd);
703
+ turnExecutedConfig = resolved.executedConfig;
704
+ turnRuntime = resolved.runtime;
705
+ } else {
706
+ const turnConfig = loadConfig(goal.cwd);
707
+ turnExecutedConfig = effectiveRunConfig(turnConfig.config, {});
708
+ turnRuntime = await resolveCrewRuntime(turnExecutedConfig);
709
+ }
663
710
  turnResult = await executeTeamRun({
664
711
  manifest: created.manifest,
665
712
  tasks: created.tasks,
@@ -670,10 +717,37 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
670
717
  limits: turnExecutedConfig.limits,
671
718
  runtime: turnRuntime,
672
719
  runtimeConfig: turnExecutedConfig.runtime,
720
+ // Finding #1: restore the dispatch-time model routing (see
721
+ // restoredTurnModelRouting) — executeTeamRun in THIS process has no
722
+ // ExtensionContext to inherit the session model from.
723
+ ...restoredTurnModelRouting(manifest),
673
724
  reliability: turnExecutedConfig.reliability,
674
725
  workspaceId: goal.ownerSessionId ?? goal.cwd,
675
726
  signal,
676
727
  });
728
+ } catch (error) {
729
+ // SR-01 (GL-1b part C, 2026-09-22): a throw between turn creation and
730
+ // executeTeamRun's own try (config resolution, runtime resolution, or
731
+ // executeTeamRun's PRE-try section) escaped with the turn manifest stuck
732
+ // at "queued" — a turn that is long dead still reported as in-flight, so
733
+ // `team status <turnId>` lied and a resume could double-run it. Mark it
734
+ // failed before re-throwing; the outer catch (GL-1b part B) then persists
735
+ // lastTurnError at the goal level.
736
+ //
737
+ // Guarded on status === "queued" so we never overwrite a status the
738
+ // executor already set (executeTeamRunCore failures already write failed
739
+ // themselves — no double-write). Best-effort: a failure to persist must
740
+ // not mask the original error.
741
+ const message = error instanceof Error ? error.message : String(error);
742
+ try {
743
+ const loaded = loadRunManifestById(goal.cwd, created.manifest.runId);
744
+ if (loaded && loaded.manifest.status === "queued") {
745
+ updateRunStatus(loaded.manifest, "failed", `pre-execute failure: ${message}`);
746
+ }
747
+ } catch (persistError) {
748
+ logInternalError("goal-loop.markTurnFailed", persistError, `turnRunId=${created.manifest.runId}`);
749
+ }
750
+ throw error;
677
751
  } finally {
678
752
  unregisterActiveRun(created.manifest.runId);
679
753
  }
@@ -689,6 +763,18 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
689
763
  // Previously the loop continued to evaluateGoal on blocked/failed turns, burning maxTurns.
690
764
  const turnStatus = turnResult.manifest.status;
691
765
  if (turnStatus === "blocked" || turnStatus === "failed") {
766
+ // GL-1b (2026-09-22 diagnosability): the failure reason previously lived
767
+ // ONLY inside the turn-run dir (manifest.summary + run.failed event) —
768
+ // auto-prune (keep=10, session start) deletes turn dirs, making blocked
769
+ // goals undiagnosable afterwards. Persist the reason into the goal state
770
+ // (never pruned), carry it on the terminal event, and clear the stale
771
+ // currentRunId — the turn is over.
772
+ const turnError = turnResult.manifest.summary ?? `turn ${turnIndex} ended ${turnStatus}`;
773
+ try {
774
+ goal = store.patch(goal.goalId, { lastTurnError: turnError, currentRunId: undefined }, eventsPath) ?? goal;
775
+ } catch (error) {
776
+ logInternalError("goal-loop.persistTurnError", error, `goalId=${goal.goalId}`);
777
+ }
692
778
  goal = safeSetStatus(store, goal.goalId, "blocked", goal, eventsPath);
693
779
  appendEventBuffered(eventsPath, {
694
780
  type: "goal.turn_terminal_status",
@@ -698,6 +784,7 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
698
784
  turn: turnIndex,
699
785
  turnRunId: created.manifest.runId,
700
786
  turnStatus,
787
+ reason: turnError,
701
788
  },
702
789
  }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.turn_terminal_status"));
703
790
  break;
@@ -805,6 +892,20 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
805
892
  }
806
893
  } catch (error) {
807
894
  logInternalError("goal-loop.run", error, `goalId=${goal.goalId}`);
895
+ // GL-1b: this catch previously only console-errored (logInternalError) —
896
+ // loop-level failures left NO persisted trace. Mirror the turn-level fix:
897
+ // persist the message into the goal state + event log.
898
+ const loopError = error instanceof Error ? error.message : String(error);
899
+ try {
900
+ goal = store.patch(goal.goalId, { lastTurnError: loopError, currentRunId: undefined }, eventsPath) ?? goal;
901
+ } catch {
902
+ /* best-effort — status transition below still runs */
903
+ }
904
+ appendEventBuffered(eventsPath, {
905
+ type: "goal.loop_error",
906
+ runId: manifest.runId,
907
+ data: { goalId: goal.goalId, error: loopError },
908
+ }).catch((e) => logInternalError("goal-loop.buffered", e, "type=goal.loop_error"));
808
909
  goal = safeSetStatus(store, goal.goalId, "blocked", goal, eventsPath);
809
910
  } finally {
810
911
  // P1g: release the workspace lock (held since loop start).
@@ -59,9 +59,17 @@ export function deliverGroupJoin(input: {
59
59
  }): CrewGroupJoinDelivery | undefined {
60
60
  if (!shouldGroupJoin(input.mode, input.batch)) return undefined;
61
61
  const taskIds = input.batch.map((task) => task.id);
62
- const latest = taskIds
63
- .map((id) => input.allTasks.find((task) => task.id === id))
64
- .filter((task): task is TeamTaskState => Boolean(task));
62
+ // RR-021 WI-4.3d: first-wins Map — the old taskIds.map(id => allTasks.find())
63
+ // was O(batch×allTasks). NOT a raw input.batch pass-through: callers may
64
+ // hold stale batch snapshots, and the only audited production caller
65
+ // (team-runner closeout) passes fresh references — but the Map keeps the
66
+ // exact `.find` first-match semantics for every caller (incl. tests) while
67
+ // staying O(n+m). Batch order is preserved (map follows taskIds).
68
+ const taskById = new Map<string, TeamTaskState>();
69
+ for (const task of input.allTasks) {
70
+ if (!taskById.has(task.id)) taskById.set(task.id, task);
71
+ }
72
+ const latest = taskIds.map((id) => taskById.get(id)).filter((task): task is TeamTaskState => Boolean(task));
65
73
  const completed = statusList(latest, "completed");
66
74
  const failed = statusList(latest, "failed");
67
75
  const skipped = statusList(latest, "skipped");
@@ -98,6 +98,13 @@ export class HeartbeatWatcher {
98
98
  if (loaded.manifest.status !== "running") continue;
99
99
  for (const task of loaded.tasks) {
100
100
  if (task.status !== "running") continue;
101
+ // Guest-child tasks (delegate subagents, agent === "delegate") have NO
102
+ // heartbeat channel: they never write task.heartbeat/agentProgress —
103
+ // their lifecycle is tracked by delegate.requested/admitted/completed
104
+ // broker events. Watching them here classified every guest as "dead"
105
+ // within one poll (52ms after admit, run team_20260926033657_2b6c6d2610b26d9d,
106
+ // 2026-09-26 battery) and enqueued false "stuck worker" ambients.
107
+ if (task.agent === "delegate") continue;
101
108
  const key = `${run.runId}:${task.id}`;
102
109
  activeKeys.add(key);
103
110
  this.lastSeen.set(key, now);
@@ -96,7 +96,7 @@ let liveSessionModulePromise: Promise<LiveSessionModule> | undefined;
96
96
  let experimentalWarned = false;
97
97
  function loadLiveSessionModule(): Promise<LiveSessionModule> {
98
98
  if (!liveSessionModulePromise) {
99
- liveSessionModulePromise = import("@earendil-works/pi-coding-agent") as unknown as Promise<LiveSessionModule>;
99
+ liveSessionModulePromise = import("@earendil-works/pi-coding-agent") as unknown as Promise<LiveSessionModule>; // LAZY: defer pi-coding-agent live-session module until first use
100
100
  }
101
101
  return liveSessionModulePromise;
102
102
  }
@@ -70,6 +70,20 @@ function modelInfoFromUnknown(value: unknown): AvailableModelInfo | undefined {
70
70
  };
71
71
  }
72
72
 
73
+ // US-012 (2026-09-22): cache the registry-derived model list. The registry is
74
+ // config/env-derived and invariant per session, but `availableModelInfosFromRegistry`
75
+ // is called on EVERY model resolution / worker spawn (a 4-worker team = 4
76
+ // rebuilds). Keyed on the registry object identity (WeakMap) so a new registry
77
+ // instance (config reload) gets a fresh entry automatically, and invalidated by
78
+ // the getAvailable() array identity so a mutated-in-place list is not served
79
+ // stale. WeakMap → no leak when a session's registry is dropped.
80
+ const registryModelCache = new WeakMap<object, { raw: unknown[]; infos: AvailableModelInfo[] }>();
81
+
82
+ /** @internal — test introspection. */
83
+ export function __test__registryModelCacheHas(registry: object): boolean {
84
+ return registryModelCache.has(registry);
85
+ }
86
+
73
87
  export function availableModelInfosFromRegistry(registry: unknown): AvailableModelInfo[] | undefined {
74
88
  if (!registry || typeof registry !== "object" || Array.isArray(registry)) return undefined;
75
89
  const candidate = registry as ModelRegistryLike;
@@ -80,7 +94,13 @@ export function availableModelInfosFromRegistry(registry: unknown): AvailableMod
80
94
  ? candidate.getAll()
81
95
  : undefined;
82
96
  if (!Array.isArray(raw)) return undefined;
83
- return raw.map(modelInfoFromUnknown).filter((entry): entry is AvailableModelInfo => entry !== undefined);
97
+ // Serve from cache only when the SAME array instance is returned — a
98
+ // rebuilt/refreshed list (different identity) must be re-normalized.
99
+ const cached = registryModelCache.get(registry as object);
100
+ if (cached && cached.raw === raw) return cached.infos;
101
+ const infos = raw.map(modelInfoFromUnknown).filter((entry): entry is AvailableModelInfo => entry !== undefined);
102
+ registryModelCache.set(registry as object, { raw, infos });
103
+ return infos;
84
104
  }
85
105
 
86
106
  export function modelStringFromUnknown(model: unknown): string | undefined {
@@ -3,7 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import type { AgentConfig } from "../../agents/agent-config.ts";
5
5
  import { atomicWriteFile } from "../../state/atomic-write.ts";
6
- import { packageRoot, userPiRoot } from "../../utils/paths.ts";
6
+ import { hasRunStateLayout, packageRoot, userPiRoot } from "../../utils/paths.ts";
7
7
 
8
8
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
9
9
  // FIX (2026-07-02): use packageRoot() instead of import.meta.url-relative path.
@@ -629,16 +629,14 @@ export function cleanupLegacyOrphanTempDirs(
629
629
  continue;
630
630
  }
631
631
  if (lstat.isSymbolicLink()) continue;
632
- // Skip dirs containing active run state — those are handled by
632
+ // Skip dirs containing pi-crew run state — those are handled by
633
633
  // reconcileOrphanedTempWorkspaces which has run-state semantics.
634
- const crewDir = path.join(dir, ".crew");
635
- let crewDirLstat: fs.Stats | undefined;
636
- try {
637
- crewDirLstat = fs.lstatSync(crewDir);
638
- } catch {
639
- // doesn't exist
640
- }
641
- if (crewDirLstat && !crewDirLstat.isSymbolicLink()) continue;
634
+ // RR-020 Fix 3: recognise BOTH supported layouts — `<dir>/.crew/` and
635
+ // the `.pi`-based `<dir>/.pi/teams/`. Only `.crew` used to be checked,
636
+ // so a temp workspace with live `.pi/teams` run state was deleted as
637
+ // debris. Symlinked layout dirs still do NOT protect the dir (the
638
+ // scanner keeps rejecting symlinks instead of trusting them).
639
+ if (hasRunStateLayout(dir)) continue;
642
640
  // Skip dirs currently tracked by this process (defense in depth:
643
641
  // with 8ba270d the Set should never contain /tmp/ paths, but
644
642
  // future code or external callers might).
@@ -124,6 +124,7 @@ export function startRuntimeWarmup(): void {
124
124
  for (const spec of HOT_MODULE_SPECIFIERS) {
125
125
  imports.push(
126
126
  import(new URL(spec, import.meta.url).href).catch(() => {
127
+ // LAZY: best-effort warmup import, failures tolerated
127
128
  // swallow — never block registration on a warmup failure
128
129
  }),
129
130
  );
@@ -131,6 +132,7 @@ export function startRuntimeWarmup(): void {
131
132
  for (const dep of HOT_PEER_DEPS) {
132
133
  imports.push(
133
134
  import(dep).catch(() => {
135
+ // LAZY: best-effort warmup import, failures tolerated
134
136
  // peer dep may be absent (optional dep) — swallow
135
137
  }),
136
138
  );