@worca/app 1.1.1 → 1.2.0-rc.1

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.
@@ -62,6 +62,11 @@ import {
62
62
  isValidSourceRef, snapshotWorktreePatch,
63
63
  } from './worktree.mjs';
64
64
  import { readPluginsLock, pluginCurrentDir } from './plugins-lock.mjs'; // §9.4 disabled-plugin hint
65
+ import { classifyError } from './recoverable-error.mjs';
66
+ import {
67
+ resolveFailure, isTerminal, markTerminal, answerFromDecision,
68
+ REASON, pauseConsequences, describePauseReason,
69
+ } from './failure-policy.mjs';
65
70
 
66
71
  // worca-cc repo root; holds skills/. fileURLToPath, never URL.pathname: the
67
72
  // latter is `/C:/…` on Windows and %-encoded everywhere (see DEFAULT_AGENTS_DIR
@@ -96,12 +101,6 @@ function findDisabledPluginFor(key) {
96
101
  return null;
97
102
  }
98
103
 
99
- /** Max auto-mode retries for a recoverable error before falling back to status error. */
100
- const RECOVERY_MAX_AUTO_ATTEMPTS = (() => {
101
- const n = Number(process.env.WORCA_RECOVERY_MAX_ATTEMPTS);
102
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : 3;
103
- })();
104
-
105
104
  /**
106
105
  * `attr` marking a log line whose text came from a subprocess's stderr.
107
106
  *
@@ -505,6 +504,36 @@ export function clipMiddle(text, n) {
505
504
  return s.slice(0, head) + '…' + s.slice(-(n - 1 - head));
506
505
  }
507
506
 
507
+ /** Longest pause detail we persist/broadcast (the run log keeps the full text). */
508
+ export const PAUSE_DETAIL_MAX = 400;
509
+
510
+ /** The human detail a converted failure pauses with: the WHOLE message, whitespace-
511
+ * collapsed and middle-clipped — the runner frames failures as "claude exited with
512
+ * code N: <cause>" with the cause at the END, so a head-only clip would drop it.
513
+ * Never empty: every consumer prints it verbatim. */
514
+ export function errorDetail(err, max = PAUSE_DETAIL_MAX) {
515
+ const message = err == null ? '' : (err.message ?? String(err));
516
+ return clipMiddle(message, max) || 'unknown error';
517
+ }
518
+
519
+ /** A snapshot row the scheduler marked 'error' is TERMINAL to reattach() — the node
520
+ * would never re-fire and a resumed run would quiesce to a false done. Under the
521
+ * errors-pause policy such a row can only come from a failure that bypassed the
522
+ * adapter's conversion. failExecution's fail-fast abort also settles every
523
+ * in-flight sibling as 'skipped' (TERMINAL too), so when an error row is present the
524
+ * skipped rows are its collateral and are re-armed with it; a snapshot WITHOUT an
525
+ * error row is returned by identity (its skipped rows are legitimate). */
526
+ export function scrubErrorRows(snapshot) {
527
+ if (!snapshot || !Array.isArray(snapshot.execs)) return snapshot ?? null;
528
+ if (!snapshot.execs.some((e) => e && e.status === 'error')) return snapshot;
529
+ const execs = snapshot.execs.map((e) => {
530
+ if (!e || (e.status !== 'error' && e.status !== 'skipped')) return e;
531
+ const { error: _dropped, ...rest } = e;
532
+ return { ...rest, status: 'paused' };
533
+ });
534
+ return { ...snapshot, execs };
535
+ }
536
+
508
537
  /**
509
538
  * Normalize an answer payload from answer()/auto into [{id, choice}].
510
539
  * Accepts { answers:[{id,choice}] } or a bare array. Fills any missing
@@ -631,12 +660,16 @@ export class RunHarness extends EventEmitter {
631
660
  this.abort = new AbortController();
632
661
  this.pauseRequested = false;
633
662
  this.pauseAbort = new AbortController(); // aborts ONLY node children on pause
634
- this.pauseReason = null; // set when a session/usage limit forces the pause
663
+ this.pauseReason = null; // WHY the run paused: 'cost_pipeline'|'cost_total'|'error'|<usage-limit line>|null
664
+ this.pauseDetail = null; // the human detail behind pauseReason ('error': the clipped message)
665
+ this._setupDone = false; // run()/resume() flip this right before _engineRun (setup replay)
666
+ this._rehydrated = true; // resume() clears this until the paused run is rehydrated (the 'resume' site)
667
+ this._modeRecorded = false; // resume(): the row recorded a run-root mode (a setup-incomplete point may not)
635
668
  this._pauseGate = null; // gate context snapshot when paused at a gate
636
669
  this._resumeNodeSessions = null; // nodeId -> sessionId map, set by resume() (Task 5)
637
670
  this.resumeOpts = this.opts.resume || null; // { row, resumePoint, steps } from readPipelineForResume
638
671
  this.pendingQuestion = null; // { id, resolve, reject, kind }
639
- this._recovery = null; // class -> in-flight Promise<'retry'|'abort'> (same-class dedupe)
672
+ this._recovery = null; // class -> in-flight Promise<'retry'|'pause'> (same-class dedupe)
640
673
  this._askTail = null; // serializes _ask: ONE prompt open at a time (recovery + step questions)
641
674
  this._recoverySeq = 0; // monotonic id source for recovery prompts (determinism-safe)
642
675
  this.agentPrompts = null;
@@ -684,6 +717,8 @@ export class RunHarness extends EventEmitter {
684
717
  // detached run throws TypeError on the first this.state.branches[key] = … .
685
718
  branches: {},
686
719
  checkpointRefs: {},
720
+ pauseReason: null, // mirrors this.pauseReason so getState() (a deep clone of state) carries it live
721
+ pauseDetail: null, // mirrors this.pauseDetail
687
722
  // Sub-agent lifecycle records (rides the existing `state` snapshot; mirrored to
688
723
  // the sub_agents table). Each: { id, label, nodeId, stepIndex, cycle, stepKey,
689
724
  // status, startedAt, finishedAt, durationMs?, tokens?, costUsd? };
@@ -759,6 +794,80 @@ export class RunHarness extends EventEmitter {
759
794
  if (this.pauseRequested) throw pauseErr();
760
795
  }
761
796
 
797
+ /**
798
+ * Record WHY the run is pausing: a machine-readable reason (the cost codes, 'error',
799
+ * or the usage-limit first line) plus an optional human detail. FIRST WRITER WINS —
800
+ * a pause kills its siblings and their unwinds must not overwrite the cause (the
801
+ * rule every _pauseFor site follows). Mirrored onto state so every
802
+ * `state` event and getState() carry it. @returns {boolean} true when recorded
803
+ */
804
+ _setPauseReason(reason, detail = null) {
805
+ if (this.pauseReason) return false;
806
+ this.pauseReason = String(reason);
807
+ this.pauseDetail = detail == null || detail === '' ? null : String(detail);
808
+ this.state.pauseReason = this.pauseReason;
809
+ this.state.pauseDetail = this.pauseDetail;
810
+ return true;
811
+ }
812
+
813
+ _clearPauseReason() {
814
+ this.pauseReason = null;
815
+ this.pauseDetail = null;
816
+ this.state.pauseReason = null;
817
+ this.state.pauseDetail = null;
818
+ }
819
+
820
+ /**
821
+ * Enact a 'pause' verdict (failure-policy.mjs) at ANY site — the one mechanism
822
+ * behind every forced pause: the ONE log line, the reason + detail (first writer
823
+ * wins), the audit line, then pause(). pause() sets pauseRequested BEFORE anything
824
+ * reaches the scheduler, so onSnapshot stays frozen at the last clean point, the
825
+ * failing row settles 'paused' (non-terminal) and reattach() re-invokes it on
826
+ * resume. Returns false — recording nothing — when a pause is already unwinding
827
+ * (the user's, or a sibling's: its reason stands) or a stop is in flight (never
828
+ * re-labelled: pause() would be a no-op on 'stopped' and the scheduler must keep
829
+ * seeing the stop). The caller throws pauseErr() itself where a throw is due.
830
+ * @param {string} reason a REASON code
831
+ * @param {Error|null} err the failure; null for a cap (`detail` carries the text)
832
+ * @param {{nc?:object|null, ctx?:object|null, label?:string, detail?:string|null, cls?:string|null}} [o]
833
+ * nc/ctx: the execution (orchestrator sites); label: the log source otherwise;
834
+ * cls: the error class behind a RECOVERABLE pause (kept in the log, audit and detail)
835
+ */
836
+ _pauseFor(reason, err, { nc = null, ctx = null, label = null, detail = null, cls = null } = {}) {
837
+ const where = label || nc?.key || ctx?.nodeId || 'orchestrator';
838
+ const meta = ctx ? { nodeId: ctx.nodeId, executionId: ctx.executionId, cycle: ctx.ordinal } : {};
839
+ const line = firstLine(err?.message || (err == null ? '' : String(err))) || 'unknown error';
840
+ const text = detail ?? (reason === REASON.ERROR ? errorDetail(err)
841
+ : reason === REASON.RECOVERABLE ? `${cls || 'recoverable'}: ${line}` : line);
842
+ if (reason === REASON.ERROR) {
843
+ // The ONE error-level line, written BEFORE the pause sentinel the caller
844
+ // throws next (a pause/abort is never logged as a failure).
845
+ this._log(where, 'error', `${ctx ? 'execution' : 'run'} failed: ${clipMiddle(err?.message || err, 500)}`,
846
+ { ...meta, ...(err?.stream ? { stream: err.stream } : {}) });
847
+ }
848
+ if (this.pauseRequested || this.state.status === 'stopped' || this.abort.signal.aborted) return false;
849
+ this._setPauseReason(reason, text);
850
+ let audit;
851
+ if (reason === REASON.ERROR) {
852
+ audit = ctx
853
+ ? `Pipeline **paused**: execution failed on ${where} — ${line}. Fix the cause, then resume.`
854
+ : `Pipeline **paused**: ${line}. Fix the cause, then resume.`;
855
+ } else if (reason === REASON.USAGE_LIMIT) {
856
+ this._log(where, 'warn', `${describePauseReason(reason)} — pausing for manual resume: ${text}`, meta);
857
+ audit = `Pipeline **paused**: session/usage limit on ${where} — ${text}. Resume after the reset.`;
858
+ } else if (reason === REASON.RECOVERABLE) {
859
+ this._log(where, 'warn', `recoverable ${cls || 'error'} error — pausing for manual resume: ${line}`,
860
+ { ...meta, ...(err?.stream ? { stream: err.stream } : {}) });
861
+ audit = `Pipeline **paused**: recoverable ${cls || 'error'} error on ${where} — ${line}. Resume to retry.`;
862
+ } else {
863
+ this._log(where, 'warn', `${text} — pausing for manual resume`, meta);
864
+ audit = `Pipeline **paused**: ${text}.`;
865
+ }
866
+ appendAudit(this.pipeline.dir, audit).catch(() => {});
867
+ this.pause();
868
+ return true;
869
+ }
870
+
762
871
  /**
763
872
  * Execute the full pipeline. Resolves with { status, pipelineDir } on success
764
873
  * or stop; rejects only on unexpected internal errors (it emits 'error' too).
@@ -944,7 +1053,7 @@ export class RunHarness extends EventEmitter {
944
1053
  let resolvedSkills = new Map(); // ← HOISTED; empty Map on the default workflow
945
1054
  if (requiredSkills.length) {
946
1055
  const skillCtx = { repoRoot: REPO_ROOT, projectDir: this.projectDir, pluginDirs: pluginSkillDirs() };
947
- resolvedSkills = validateSkills(requiredSkills, skillCtx); // throws => caught => run ends 'error'
1056
+ resolvedSkills = validateSkills(requiredSkills, skillCtx); // throws => caught => the run PAUSES (D6); the setup replay re-gates on resume
948
1057
  if (this.runRootMode !== 'detached') {
949
1058
  // LEGACY delivery, byte-identical to today: inject ONLY into real isolated
950
1059
  // worktrees, never the main projectDir, so a copy can never pollute the
@@ -978,6 +1087,9 @@ export class RunHarness extends EventEmitter {
978
1087
  await this._assembleContext(resolvedSkills);
979
1088
  }
980
1089
  this._checkAbort();
1090
+ // D7: every setup step above is done — a pause from here on has nothing to
1091
+ // replay, so _completePaused strips any `setupIncomplete` stamp instead.
1092
+ this._setupDone = true;
981
1093
 
982
1094
  // 4) (Clarify now runs as the first graph node — see _runClarifyNode.)
983
1095
 
@@ -1003,6 +1115,12 @@ export class RunHarness extends EventEmitter {
1003
1115
  return { status: 'done', pipelineDir: this.pipeline.dir };
1004
1116
  } catch (err) {
1005
1117
  if ((isPause(err) || this.state.status === 'pausing') && this.state.status !== 'stopped') {
1118
+ // A plain error that landed while a user pause was unwinding (a setup step that
1119
+ // failed under the pause): the user's pause keeps the reason, the run log keeps
1120
+ // the failure. _completePaused stamps setupIncomplete when setup never finished.
1121
+ if (!isPause(err) && !isAbort(err)) {
1122
+ this._log('orchestrator', 'error', `failed while pausing: ${clipMiddle(err?.message || err, 500)}`, err?.stream ? ERR_STREAM : null);
1123
+ }
1006
1124
  if (this.pipeline) {
1007
1125
  if (!this.state.resumePoint) {
1008
1126
  // Paused before the engine started (preflight/worktree): the engine
@@ -1041,6 +1159,23 @@ export class RunHarness extends EventEmitter {
1041
1159
  });
1042
1160
  return { status: 'stopped', pipelineDir: this.pipeline?.dir || null };
1043
1161
  }
1162
+ if (this.pipeline) {
1163
+ // The SETUP / SHELL site (failure-policy.mjs): a failure once the row exists.
1164
+ try {
1165
+ const paused = await this._pauseForFailure(err);
1166
+ if (paused) return paused;
1167
+ } catch (err2) {
1168
+ // Last resort: the pause bookkeeping itself failed. Fall through to today's
1169
+ // error shape (persist/audit/results/write-back) rather than reject run().
1170
+ // The finally tears the checkout down on 'error' — never leave a
1171
+ // point that names it (today's error branch does not clear it; the stop branch does).
1172
+ this._log('orchestrator', 'error', `pause bookkeeping failed: ${err2?.message || err2} — ending the run as a launch error`);
1173
+ this.state.resumePoint = null;
1174
+ }
1175
+ }
1176
+ // No row yet (topology, preflight, tool detection): the LAUNCH site. Its only
1177
+ // enactable verdict is a terminal error — there is nothing to resume into.
1178
+ else this._launchVerdict(err);
1044
1179
  this._setStatus('error');
1045
1180
  const message = err?.message || String(err);
1046
1181
  this._emit('error', { message });
@@ -1064,8 +1199,8 @@ export class RunHarness extends EventEmitter {
1064
1199
  });
1065
1200
  return { status: 'error', pipelineDir: this.pipeline?.dir || null, error: message };
1066
1201
  } finally {
1067
- this._stopHeartbeat(); // clear timer + NULL owner columns (done/stopped/error/paused)
1068
- // C1: tear the run root + worktree(s) down on done/stopped/error — the branch is
1202
+ this._stopHeartbeat(); // clear timer + NULL owner columns (done/stopped/launch-error/paused)
1203
+ // C1: tear the run root + worktree(s) down on done/stopped/launch-error — the branch is
1069
1204
  // always kept (every member's, on a workspace run), only the disposable checkout
1070
1205
  // is removed. But NEVER on a pause: the checkout (with any uncommitted agent
1071
1206
  // work) and the run root are the things we resume into (§8.13).
@@ -1079,7 +1214,9 @@ export class RunHarness extends EventEmitter {
1079
1214
  /**
1080
1215
  * Continue a paused pipeline from its persisted resume point. Mirrors run()'s
1081
1216
  * shell but skips createPipeline / checkpoint / worktree / graph setup — those
1082
- * artifacts exist from the original run. Resolves like run().
1217
+ * artifacts exist from the original run, unless the point is stamped
1218
+ * `setupIncomplete` (D7 replay), which re-runs whatever setup never finished.
1219
+ * Resolves like run().
1083
1220
  */
1084
1221
  async resume() {
1085
1222
  const saved = this.resumeOpts;
@@ -1098,6 +1235,13 @@ export class RunHarness extends EventEmitter {
1098
1235
  // may be async; v1's synchronous return is awaited unchanged.
1099
1236
  const rehydrated = await this._engineRehydrate(rp);
1100
1237
  if (!rehydrated || typeof rehydrated.audit !== 'string' || !Array.isArray(rehydrated.memberWorktrees)) throw new Error('engine hook contract: _engineRehydrate must return { checkpointRef, memberWorktrees:[], audit }');
1238
+ // D7: the point tells us whether run() ever finished its setup. Until the replay
1239
+ // below re-runs it, a pause here must re-stamp the flag (_completePaused reads it).
1240
+ this._setupDone = rp.setupIncomplete !== true;
1241
+ // The 'resume' site (failure-policy.mjs): until the paused run is rehydrated —
1242
+ // identity, worktrees, guardrails, prompts — a failure cannot be parked again
1243
+ // (the point on disk is all there is) and ends the run.
1244
+ this._rehydrated = false;
1101
1245
  try {
1102
1246
  // ── rehydrate identity + state ──
1103
1247
  this.state.id = row.id;
@@ -1116,6 +1260,8 @@ export class RunHarness extends EventEmitter {
1116
1260
  recordArtifact(row.id, RUN_LOG_KIND, RUN_LOG_FILE);
1117
1261
  this.stepModels = rp.stepModels || null;
1118
1262
  this.workflowId = rp.workflowId || this.workflowId;
1263
+ // The saved point carries the pause that produced it; a resumed run is running.
1264
+ this._clearPauseReason();
1119
1265
  // Rehydrate the run's selection BEFORE re-resolving so resume enforces the
1120
1266
  // LATEST saved set definition (missing set -> warn + Permissive, inside
1121
1267
  // _resolveGuardrails). Legacy resume points without the field fall back to
@@ -1137,9 +1283,9 @@ export class RunHarness extends EventEmitter {
1137
1283
  // pre-change row. A run can therefore never be resumed into a mode it was not
1138
1284
  // started in, no matter when the default flips or rolls back.
1139
1285
  const meta = safeParse(row.workspace_meta);
1140
- const recordedMode = this.isWorkspace
1141
- ? (meta?.runRootMode || 'legacy')
1142
- : (this.state.branch?.runRootMode || 'legacy');
1286
+ const recordedRaw = this.isWorkspace ? meta?.runRootMode : this.state.branch?.runRootMode;
1287
+ this._modeRecorded = !!recordedRaw; // a setup-incomplete point may carry none
1288
+ const recordedMode = recordedRaw || 'legacy';
1143
1289
  this.runRootMode = recordedMode === 'detached' ? 'detached' : 'legacy';
1144
1290
  // Re-stamp BEFORE the first persist so a resumed workspace run re-persists the
1145
1291
  // pin rather than dropping it (toPipelineRow reads it off state every persist).
@@ -1230,6 +1376,20 @@ export class RunHarness extends EventEmitter {
1230
1376
  this._startHeartbeat();
1231
1377
  await appendAudit(this.pipeline.dir, rehydrated.audit);
1232
1378
  this._emit('state', this.getState());
1379
+ this._rehydrated = true;
1380
+
1381
+ // ── setup replay (D7): a converted setup failure paused this run before its
1382
+ // checkout / graph / skills gate existed. Re-run exactly what run() never
1383
+ // finished. Placed HERE: _setupRunRoot persists, and the row must already
1384
+ // read 'running' (above), never the constructor's 'idle'.
1385
+ let replayedSkills = null;
1386
+ if (rp.setupIncomplete === true) {
1387
+ this.state.titleProvisional = rp.titleProvisional === true;
1388
+ replayedSkills = await this._replaySetup();
1389
+ // The replay (re)wrote the run manifest; the re-assembly below reads it.
1390
+ if (this.runRootMode === 'detached') resumeManifest = await readRunManifest(this.runRoot);
1391
+ }
1392
+ this._setupDone = true;
1233
1393
 
1234
1394
  // ── §5.2 detached resume: idempotent re-assembly (self-healing) ──
1235
1395
  // Only when the RECORDED mode is 'detached', and NEVER with a resolvedSkills
@@ -1245,10 +1405,10 @@ export class RunHarness extends EventEmitter {
1245
1405
  // member. A member real dir deleted while paused degrades per §8.20 — a
1246
1406
  // missing SOURCE never throws (a missing worktree still hard-fails, above).
1247
1407
  if (this.runRootMode === 'detached') {
1248
- await this._assembleContext(resumeManifest?.skillResolutions ?? new Map());
1408
+ await this._assembleContext(replayedSkills ?? (resumeManifest?.skillResolutions ?? new Map()));
1249
1409
  // AFTER the assembly: it rewrites run.json.warnings wholesale, so recording
1250
1410
  // this first would drop it from the durable ledger.
1251
- if (!resumeManifest) {
1411
+ if (!resumeManifest && !replayedSkills) {
1252
1412
  await this._recordRunWarning(
1253
1413
  'run.json was missing or unparseable, so the bundle/plugin skills this run mounted ' +
1254
1414
  'could not be restored to the skill mount; real-dir and root skills were re-mounted ' +
@@ -1272,6 +1432,12 @@ export class RunHarness extends EventEmitter {
1272
1432
  return { status: 'done', pipelineDir: this.pipeline.dir };
1273
1433
  } catch (err) {
1274
1434
  if ((isPause(err) || this.state.status === 'pausing') && this.state.status !== 'stopped') {
1435
+ // A plain error that landed while a user pause was unwinding (a replayed setup
1436
+ // step that failed under the pause): the user's pause keeps the reason, the run
1437
+ // log keeps the failure. _completePaused re-stamps setupIncomplete from _setupDone.
1438
+ if (!isPause(err) && !isAbort(err)) {
1439
+ this._log('orchestrator', 'error', `failed while pausing: ${clipMiddle(err?.message || err, 500)}`, err?.stream ? ERR_STREAM : null);
1440
+ }
1275
1441
  if (this.pipeline) {
1276
1442
  if (!this.state.resumePoint) this.state.resumePoint = rp; // re-arm the consumed point: a paused row must stay resumable
1277
1443
  return await this._completePaused();
@@ -1299,6 +1465,21 @@ export class RunHarness extends EventEmitter {
1299
1465
  this._emit('done', { status: 'stopped', pipelineDir: this.pipeline?.dir || null });
1300
1466
  return { status: 'stopped', pipelineDir: this.pipeline?.dir || null };
1301
1467
  }
1468
+ if (this.pipeline) {
1469
+ // The SETUP / SHELL site (failure-policy.mjs). `rp` is the point this resume
1470
+ // consumed — the fallback when the engine holds none.
1471
+ try {
1472
+ const paused = await this._pauseForFailure(err, rp);
1473
+ if (paused) return paused;
1474
+ } catch (err2) {
1475
+ // Last resort: the pause bookkeeping itself failed. Fall through to today's
1476
+ // error shape (persist/audit/results/write-back) rather than reject resume().
1477
+ // The finally tears the checkout down on 'error' — never leave a
1478
+ // point that names it (today's error branch does not clear it; the stop branch does).
1479
+ this._log('orchestrator', 'error', `pause bookkeeping failed: ${err2?.message || err2} — ending the run as a launch error`);
1480
+ this.state.resumePoint = null;
1481
+ }
1482
+ }
1302
1483
  this._setStatus('error');
1303
1484
  const message = err?.message || String(err);
1304
1485
  this._emit('error', { message });
@@ -1319,7 +1500,7 @@ export class RunHarness extends EventEmitter {
1319
1500
  this._emit('done', { status: 'error', pipelineDir: this.pipeline?.dir || null });
1320
1501
  return { status: 'error', pipelineDir: this.pipeline?.dir || null, error: message };
1321
1502
  } finally {
1322
- this._stopHeartbeat(); // clear timer + NULL owner columns (done/stopped/error/paused)
1503
+ this._stopHeartbeat(); // clear timer + NULL owner columns (done/stopped/launch-error/paused)
1323
1504
  // Same teardown as run()'s finally — wiring only run()'s would keep legacy
1324
1505
  // teardown on every detached run that finishes after a resume (including every
1325
1506
  // crash-interrupted run, §8.12's primary scenario): run root leaked until the
@@ -1358,9 +1539,11 @@ export class RunHarness extends EventEmitter {
1358
1539
  * resolution, workDirs/branchInfos/state.branches registration, the scalar
1359
1540
  * mirrors, the mode stamp, the persist + emit — runs identically in both modes.
1360
1541
  */
1361
- async _setupRunRoot() {
1542
+ async _setupRunRoot({ replay = false } = {}) {
1362
1543
  this.state.branches = this.state.branches || {}; // belt-and-braces for resumed/legacy shapes
1363
- this.runRootMode = runRootMode(); // §10 flag, read ONCE, here, per pipeline
1544
+ // A resume REPLAY keeps the mode the row recorded — never the live flag — unless
1545
+ // the paused run never got far enough to record one (then this IS run()'s read).
1546
+ if (!replay || !this._modeRecorded) this.runRootMode = runRootMode(); // §10 flag, read ONCE, here, per pipeline
1364
1547
  this.state.runRootMode = this.runRootMode; // top-level pin → workspace_meta (artifacts.mjs)
1365
1548
  const detached = this.runRootMode === 'detached';
1366
1549
  this.runRoot = detached ? join(worcaHome(), 'runs', this.pipeline.id) : null;
@@ -1377,6 +1560,15 @@ export class RunHarness extends EventEmitter {
1377
1560
  // (test/orchestrator-workspace.test.mjs) guards exactly this.
1378
1561
  const setupFailures = [];
1379
1562
  await mapWithCap(this.members, fanoutCap(), async (m) => {
1563
+ // Replay: a member whose checkout survived the pause is already re-attached by
1564
+ // resume() (workDirs/branchInfos/state.branches); `git worktree add` onto the
1565
+ // live dir would fail. (This skips the per-member "Worktree `<key>`" audit line
1566
+ // for kept members — say so in the run log instead.)
1567
+ const kept = replay ? this.workDirs.get(m.projectKey) : null;
1568
+ if (kept && existsSync(kept)) {
1569
+ this._log('orchestrator', 'info', `setup replay: ${m.projectKey} keeps its checkout ${kept}`);
1570
+ return;
1571
+ }
1380
1572
  try {
1381
1573
  const { source, featureRaw } = this.isWorkspace
1382
1574
  ? await this._resolveMemberBranches(m) // unchanged (member-suffixed names)
@@ -2211,7 +2403,7 @@ export class RunHarness extends EventEmitter {
2211
2403
  const spentHere = Math.max(this.state.totalCostUsd || 0, sumStepCosts(this.state.steps));
2212
2404
  if (pipeLimit != null && spentHere >= pipeLimit
2213
2405
  && !readCostCapOverride(this.pipeline.id)) {
2214
- this._pauseForCost('cost_pipeline',
2406
+ this._capReached(REASON.COST_PIPELINE,
2215
2407
  `pipeline cost limit reached ($${spentHere.toFixed(2)} >= $${pipeLimit.toFixed(2)})`);
2216
2408
  }
2217
2409
  const totalLimit = totalCostLimitUsd();
@@ -2219,64 +2411,71 @@ export class RunHarness extends EventEmitter {
2219
2411
  const period = costLimitResetPeriod();
2220
2412
  const spent = totalWindowSpendUsd(costWindowStart(new Date(), period).getTime());
2221
2413
  if (spent >= totalLimit) {
2222
- this._pauseForCost('cost_total',
2414
+ this._capReached(REASON.COST_TOTAL,
2223
2415
  `total cost limit reached ($${spent.toFixed(2)} >= $${totalLimit.toFixed(2)} this ${period === 'weekly' ? 'week' : 'month'})`);
2224
2416
  }
2225
2417
  }
2226
2418
  }
2227
2419
 
2228
- /** Mirror of _pauseForLimit, but with a MACHINE-READABLE reason code
2229
- * ('cost_pipeline' | 'cost_total') the UI switches on. Unlike _pauseForLimit
2230
- * (whose caller throws), this throws itself its caller is the boundary
2231
- * gate, not a catch block. The audit line here is required: _completePaused
2232
- * suppresses its generic audit whenever pauseReason is set. */
2233
- _pauseForCost(code, detail) {
2234
- if (!this.pauseReason) this.pauseReason = code;
2235
- this._log('orchestrator', 'warn', `${detail} — pausing for manual resume`);
2236
- appendAudit(this.pipeline.dir, `Pipeline **paused**: ${detail}.`).catch(() => {});
2237
- this.pause();
2238
- throw pauseErr();
2420
+ /** The BUDGET site (failure-policy.mjs): a cost cap was reached at a step
2421
+ * boundary. Unlike the catch-block sites this throws itself — its caller is
2422
+ * the boundary gate. The audit line is required: _completePaused suppresses
2423
+ * its generic audit whenever pauseReason is set. */
2424
+ _capReached(code, detail) {
2425
+ const verdict = resolveFailure({ site: 'budget', cls: code, auto: this.auto });
2426
+ if (verdict.outcome === 'pause') {
2427
+ this._pauseFor(verdict.reason, null, { detail });
2428
+ throw pauseErr();
2429
+ }
2430
+ throw markTerminal(new Error(detail));
2239
2431
  }
2240
2432
 
2241
- /** Decide how to recover from a classified error. Auto mode: bounded backoff
2242
- * then give up (and abort immediately if a pause fired during backoff, so a
2243
- * pause is never followed by a wasted retry). Interactive: ONE shared prompt
2244
- * per error class (same-class siblings await the same answer), and distinct
2245
- * classes are serialized so only one recovery prompt is open at a time (the
2246
- * gate holds a single pendingQuestion). Returns 'retry' | 'abort'. */
2433
+ /**
2434
+ * Resolve the NODE-site verdict for a failed execution (failure-policy.mjs),
2435
+ * running the recovery round it calls for on the way: auto mode backs off before
2436
+ * a 'retry' (a pause fired DURING backoff still returns 'retry' — the caller
2437
+ * checks pauseRequested first and unwinds as THAT pause, so a user pause is never
2438
+ * followed by a wasted retry); interactive mode opens ONE shared prompt per error
2439
+ * class (same-class siblings await the same answer), serialized so only one
2440
+ * recovery prompt is open at a time (the gate holds a single pendingQuestion),
2441
+ * and re-resolves with the answer. The per-class dedupe map shares ONE answer
2442
+ * across siblings; a sibling that receives a pause verdict second finds
2443
+ * pauseRequested already set and unwinds as that same pause.
2444
+ * @returns {Promise<{outcome:'retry'|'pause'|'error', reason?:string}>}
2445
+ */
2247
2446
  async _recover({ node, cls, err, attempt }) {
2447
+ const verdict = resolveFailure({ site: 'node', cls, auto: this.auto, attempt });
2448
+ if (verdict.outcome !== 'retry' && verdict.outcome !== 'prompt') return verdict; // no recovery round
2248
2449
  this._log(node.key, 'warn', `recoverable ${cls} error: ${err.message}`, err?.stream ? ERR_STREAM : null);
2249
2450
  await appendAudit(this.pipeline.dir, `Recoverable **${cls}** error on ${node.key}: ${firstLine(err.message)}`).catch(() => {});
2250
2451
 
2251
- if (this.auto) {
2252
- if (attempt > RECOVERY_MAX_AUTO_ATTEMPTS) return 'abort';
2452
+ if (verdict.outcome === 'retry') {
2253
2453
  await this._backoff(attempt, this.pauseAbort.signal);
2254
- // A pause during backoff must win: abort instead of retrying. The loop's
2255
- // outer catch then re-classifies the thrown error under pauseRequested and
2256
- // unwinds as a pause (the pauseAbort signal is aborted).
2257
- if (this.pauseRequested || this.pauseAbort.signal.aborted) return 'abort';
2258
- return 'retry';
2454
+ return verdict;
2259
2455
  }
2260
2456
 
2261
2457
  this._recovery ||= new Map();
2262
2458
  if (!this._recovery.has(cls)) {
2263
- const p = this._enqueueRecoveryPrompt(cls, firstLine(err.message))
2459
+ const p = this._enqueueRecoveryPrompt(cls, firstLine(err.message), verdict.options)
2264
2460
  .finally(() => { if (this._recovery) this._recovery.delete(cls); });
2265
2461
  this._recovery.set(cls, p);
2266
2462
  }
2267
- return this._recovery.get(cls);
2463
+ const answer = await this._recovery.get(cls);
2464
+ return resolveFailure({ site: 'node', cls, auto: this.auto, attempt, answer });
2268
2465
  }
2269
2466
 
2270
2467
  /** Open a recovery prompt for one class, serialized behind any in-flight
2271
2468
  * recovery prompt (the question gate has a single pendingQuestion slot, so
2272
- * distinct classes must queue — see the clarify answer). Returns 'retry'|'abort'. */
2273
- _enqueueRecoveryPrompt(cls, message) {
2469
+ * distinct classes must queue — see the clarify answer). The prompt carries the
2470
+ * row's options (what the give-up choice does); resolves the policy answer
2471
+ * 'retry' | 'giveup' (the legacy `{ decision: 'abort' }` wire value is a give-up). */
2472
+ _enqueueRecoveryPrompt(cls, message, options) {
2274
2473
  const run = () =>
2275
2474
  this._ask({
2276
2475
  id: `recovery-${cls}-${this._recoveryNonce()}`,
2277
2476
  kind: 'recovery',
2278
- recovery: { cls, message },
2279
- }).then((ans) => (ans && ans.decision === 'abort' ? 'abort' : 'retry'));
2477
+ recovery: { cls, message, options },
2478
+ }).then((ans) => answerFromDecision(ans && ans.decision));
2280
2479
  return this._enqueueAsk(run);
2281
2480
  }
2282
2481
 
@@ -2394,8 +2593,9 @@ export class RunHarness extends EventEmitter {
2394
2593
  if (this.auto) {
2395
2594
  if (kind === 'recovery') {
2396
2595
  // Auto mode handles recovery in _recover before ever calling _ask;
2397
- // this is a defensive fallback so an auto run can never hang.
2398
- return { decision: 'abort' };
2596
+ // this is a defensive fallback so an auto run can never hang. Giving up
2597
+ // pauses the run (errors never end one), so 'pause' is the answer.
2598
+ return { decision: 'pause' };
2399
2599
  }
2400
2600
  if (kind === 'clarify' || kind === 'questions') {
2401
2601
  this._log('orchestrator', 'info', `auto-answering ${kind} ${id}`);
@@ -2581,8 +2781,9 @@ export class RunHarness extends EventEmitter {
2581
2781
  /**
2582
2782
  * Task-source write-back (spec §7.5): report the finished run to the plugin
2583
2783
  * source that produced it. Runs on EVERY terminal path and ALWAYS after
2584
- * _buildResults() — done (statusToResult -> 'completed') and stopped/error alike
2585
- * (-> 'failed'; chat-connectivity design PR12 closed the old success-only gap).
2784
+ * _buildResults() — done (statusToResult -> 'completed'), stopped/launch-error
2785
+ * (-> 'failed'; chat-connectivity design PR12 closed the old success-only gap),
2786
+ * and error-pauses (-> 'needs-human', from _completePaused).
2586
2787
  * So the payload is the same SHAPE on all three: retryWriteback reads
2587
2788
  * results.json (sources.mjs:215), and a stopped/error run that persisted one now
2588
2789
  * carries the diffstat and "Key things to check" lines too. Only a run with
@@ -3497,15 +3698,163 @@ export class RunHarness extends EventEmitter {
3497
3698
  if (this.pipeline?.id) clearPipelineOwnership(this.pipeline.id);
3498
3699
  }
3499
3700
 
3500
- /** Terminal bookkeeping for a pause: persist the resume point + paused status. */
3701
+ /** Terminal bookkeeping for a pause: persist the resume point + paused status.
3702
+ * An ERROR-pause additionally keeps what the retired error path produced — the
3703
+ * diff artifact and the task-source write-back (statusToResult('paused') ->
3704
+ * 'needs-human'; retryWriteback re-reads the ROW, so this runs AFTER the persist).
3705
+ * Safe here and only here: the checkout and the checkpoint refs are live, and the
3706
+ * finally never tears a paused run down. Both helpers are no-ops with an empty
3707
+ * workDirs (a setup-phase pause). */
3501
3708
  async _completePaused() {
3709
+ // D7: a pause that landed BEFORE run()'s setup finished (a converted setup failure,
3710
+ // or a user pause racing one — run()'s pause branch catches a plain error while
3711
+ // 'pausing') must replay that setup on resume; a completed setup never leaves a
3712
+ // stale stamp behind (resume() re-arms the consumed point, which may carry one).
3713
+ const rp = this.state.resumePoint;
3714
+ if (rp && typeof rp === 'object') {
3715
+ if (this._setupDone) { delete rp.setupIncomplete; delete rp.titleProvisional; }
3716
+ else {
3717
+ rp.setupIncomplete = true;
3718
+ // Whether run() ever kicked the LLM title off (it does so right after the run
3719
+ // root exists): the replay reads this to finish the job, since the flag is
3720
+ // not a row column.
3721
+ rp.titleProvisional = this.state.titleProvisional === true;
3722
+ }
3723
+ }
3502
3724
  this._setStatus('paused');
3503
3725
  await this._persist();
3504
- // A plain manual pause has no reason; only a limit-pause records one (audited
3505
- // already at the pause site, so don't double-log it here).
3726
+ // A plain manual pause has no reason; every reasoned pause audited at its site.
3506
3727
  if (!this.pauseReason) await appendAudit(this.pipeline.dir, `Pipeline **paused**.`).catch(() => {});
3507
- this._emit('done', { status: 'paused', pipelineDir: this.pipeline.dir, reason: this.pauseReason || null });
3508
- return { status: 'paused', pipelineDir: this.pipeline.dir, reason: this.pauseReason || null };
3728
+ // A FORCED pause (pauseReason set: usage limit, cost cap, auto-mode
3729
+ // auth/quota, exhausted recoverable retries, an error) parks the run with
3730
+ // nobody attached, so the task source must hear it NOW — statusToResult
3731
+ // ('paused') -> 'needs-human' — or the external task stays claimed "in
3732
+ // progress" until a human stumbles on it. A manual pause skips this: the
3733
+ // user is present and resuming shortly, and the resumed run's terminal path
3734
+ // reports the real outcome. An ERROR-pause additionally keeps the diff
3735
+ // artifact the retired error path produced. Never throws (spec §7.5).
3736
+ const consequences = pauseConsequences(this.pauseReason);
3737
+ if (consequences.stagesResults) await this._buildResults({ stage: true });
3738
+ if (consequences.reportsToSource) await this._reportToSource();
3739
+ const payload = {
3740
+ status: 'paused',
3741
+ pipelineDir: this.pipeline.dir,
3742
+ reason: this.pauseReason || null,
3743
+ detail: this.pauseDetail || null,
3744
+ };
3745
+ this._emit('done', payload);
3746
+ return { ...payload };
3747
+ }
3748
+
3749
+ /** Engine hook (optional): the LAST clean graph point the engine holds — the final
3750
+ * all-terminal snapshot after a completed run, the last clean one mid-run. The
3751
+ * failure fallback prefers it over the pre-dispatch point so a failure AFTER the
3752
+ * engine finished never re-runs the graph (D14). Base engines: none. */
3753
+ _engineLastPoint() { return null; }
3754
+
3755
+ /** Engine hook (optional): the run's distinct agent keys from the frozen manifest —
3756
+ * what the skills gate needs on a setup replay (D7). Base engines: none. */
3757
+ _engineAgentKeys() { return new Set(); }
3758
+
3759
+ /**
3760
+ * The SETUP / SHELL / RESUME site (failure-policy.mjs): a failure the shell sees
3761
+ * once the pipeline row exists — before run()'s setup finished ('setup'), after it
3762
+ * ('shell'), or before resume() rehydrated the paused run ('resume', where the
3763
+ * only verdict that can be enacted is to end the run: the point on disk is
3764
+ * already the best the run can offer). A verdict already issued downstream (a terminal error from the node
3765
+ * or flow site) is enacted, never re-decided. Returns null when the verdict is a
3766
+ * terminal error — the caller then falls through to the error path — else records
3767
+ * the cause; kills anything still in flight (pause() is a no-op unless the run is
3768
+ * 'running', and the status write below is unconditional); picks the resume
3769
+ * point MOST-SPECIFIC FIRST — state.resumePoint (the engine's live point), the
3770
+ * engine's LAST clean point (the final all-terminal snapshot when the failure
3771
+ * came after the engine finished, D14), the consumed `fallbackPoint` (resume()'s
3772
+ * rp), else the engine's pre-dispatch point; scrubs any terminal error row (and
3773
+ * the fail-fast's skipped collateral) out of its snapshot; then completes as a
3774
+ * pause — _completePaused stamps/strips `setupIncomplete` from _setupDone (D7)
3775
+ * for EVERY pause path. Emits NO 'error' event: the `done` payload carries the
3776
+ * reason + detail, the run log the line.
3777
+ */
3778
+ async _pauseForFailure(err, fallbackPoint = null) {
3779
+ const site = !this._rehydrated ? 'resume' : this._setupDone ? 'shell' : 'setup';
3780
+ const verdict = isTerminal(err) ? { outcome: 'error' }
3781
+ : resolveFailure({ site, cls: classifyError(err), auto: this.auto });
3782
+ if (verdict.outcome !== 'pause') { markTerminal(err); return null; }
3783
+ this._pauseFor(verdict.reason, err);
3784
+ const source = this.state.resumePoint || this._engineLastPoint() || fallbackPoint || this._enginePrePausePoint();
3785
+ this.state.resumePoint = {
3786
+ ...source,
3787
+ snapshot: scrubErrorRows(source.snapshot ?? null),
3788
+ pauseReason: this.pauseReason,
3789
+ pauseDetail: this.pauseDetail,
3790
+ pausedAt: new Date().toISOString(),
3791
+ };
3792
+ return await this._completePaused();
3793
+ }
3794
+
3795
+ /** The LAUNCH site: no pipeline row yet, so a terminal error is the only verdict
3796
+ * the shell can enact. Consulted for completeness — a row flipped to 'pause'
3797
+ * cannot be honored here and says so in the run log. */
3798
+ _launchVerdict(err) {
3799
+ const verdict = resolveFailure({ site: 'launch', cls: classifyError(err), auto: this.auto });
3800
+ if (verdict.outcome !== 'error') {
3801
+ this._log('orchestrator', 'warn', `failure policy asks to ${verdict.outcome} a launch failure, but no pipeline row exists to resume into — ending the run as an error`);
3802
+ }
3803
+ markTerminal(err);
3804
+ }
3805
+
3806
+ /**
3807
+ * resume() of a point whose run() never finished its setup (D7) — run()'s steps
3808
+ * 3..3e in order, abort-checked, each guarded so a step that DID complete is not
3809
+ * redone. Returns the resolved skill map for the detached assembly.
3810
+ * @returns {Promise<Map>} resolvedSkills
3811
+ */
3812
+ async _replaySetup() {
3813
+ // 3) checkpoint
3814
+ if (!this.checkpointRef) {
3815
+ if (this.isWorkspace) await this._ensureGitCheckpointAll(); else await this._ensureGitCheckpoint();
3816
+ } else if (!this.isWorkspace) {
3817
+ const onlyKey = this.members[0]?.projectKey;
3818
+ if (onlyKey && !this.checkpointRefs[onlyKey]) {
3819
+ this.checkpointRefs[onlyKey] = this.checkpointRef;
3820
+ this.state.checkpointRefs = { ...this.checkpointRefs };
3821
+ }
3822
+ }
3823
+ // run() closes the preflight bookend right after the checkpoint; the paused
3824
+ // run's ledger still holds it at 'start' (the rehydrated steps), so close it
3825
+ // here or a finished run keeps an open preflight row forever.
3826
+ this._bookend('preflight', 'done');
3827
+ this._checkAbort();
3828
+ // 3b) run root + worktrees — keyed on the per-member map, NEVER on this.workDir
3829
+ // (it defaults to projectDir and is never falsy).
3830
+ const missing = this.members.some((m) => !this.workDirs.get(m.projectKey));
3831
+ if (missing) await this._setupRunRoot({ replay: true });
3832
+ // run() kicks the LLM title off once runCwd exists; a run that paused before
3833
+ // that point still carries its provisional title, so kick it off now. A run
3834
+ // that got past it already holds the generated row.title (loaded by resume()).
3835
+ if (this.state.titleProvisional) this._kickoffTitleGeneration();
3836
+ this._checkAbort();
3837
+ // 3c) graph build (fail-safe, idempotent)
3838
+ if (this.isWorkspace) await this._buildWorktreeGraphAll(); else await this._buildWorktreeGraph();
3839
+ this._checkAbort();
3840
+ // 3d) the skills gate + legacy injection — run()'s block, agent keys from the frozen manifest
3841
+ const requiredSkills = collectRequiredSkills(this.registry, this._engineAgentKeys());
3842
+ let resolvedSkills = new Map();
3843
+ if (requiredSkills.length) {
3844
+ const skillCtx = { repoRoot: REPO_ROOT, projectDir: this.projectDir, pluginDirs: pluginSkillDirs() };
3845
+ resolvedSkills = validateSkills(requiredSkills, skillCtx); // throws => caught => the run PAUSES again
3846
+ if (this.runRootMode !== 'detached') {
3847
+ const candidates = this.isWorkspace ? [...this.workDirs.values()] : [this.workDir];
3848
+ const worktrees = candidates.filter((d) => d && d !== this.projectDir);
3849
+ const injected = await injectSkills(resolvedSkills, { targets: worktrees });
3850
+ if (injected.length) {
3851
+ await appendAudit(this.pipeline.dir, `Skills: injected ${injected.join(', ')} into ${worktrees.length} worktree(s).`);
3852
+ }
3853
+ }
3854
+ }
3855
+ this._checkAbort();
3856
+ await appendAudit(this.pipeline.dir, 'Setup replayed on resume (the paused run never finished it).').catch(() => {});
3857
+ return resolvedSkills;
3509
3858
  }
3510
3859
 
3511
3860
  // ── engine hooks ─────────────────────────────────────────────────────────────