@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.
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/cli/worca-cc.mjs +72 -16
- package/src/core/artifacts.mjs +10 -2
- package/src/core/ask/attachment-kind.mjs +95 -0
- package/src/core/ask/events.mjs +42 -3
- package/src/core/ask/follow.mjs +10 -4
- package/src/core/ask/limits.mjs +6 -3
- package/src/core/ask/prompt.mjs +37 -12
- package/src/core/ask/spawn.mjs +6 -3
- package/src/core/ask/store.mjs +89 -11
- package/src/core/ask/tool-deps.mjs +27 -3
- package/src/core/ask/tools.mjs +41 -10
- package/src/core/ask/turn.mjs +58 -12
- package/src/core/chat/command-router.mjs +8 -4
- package/src/core/chat/notifier.mjs +6 -1
- package/src/core/chat/renderers.mjs +15 -8
- package/src/core/claude-runner.mjs +120 -18
- package/src/core/config.mjs +46 -3
- package/src/core/db.mjs +92 -9
- package/src/core/failure-policy.mjs +201 -0
- package/src/core/graph/scheduler.mjs +8 -1
- package/src/core/host-guard.mjs +271 -0
- package/src/core/model-env.mjs +68 -0
- package/src/core/orchestrator.mjs +128 -35
- package/src/core/plugin-shim.mjs +3 -3
- package/src/core/run-harness.mjs +410 -61
- package/src/core/settings.mjs +76 -1
- package/ui/public/app.js +259 -39
- package/ui/public/ask-model.mjs +60 -7
- package/ui/public/ask-panel.mjs +314 -65
- package/ui/public/index.html +42 -0
- package/ui/public/style.css +28 -0
- package/ui/server.mjs +286 -65
package/src/core/run-harness.mjs
CHANGED
|
@@ -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; //
|
|
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'|'
|
|
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
|
|
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
|
|
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
|
|
1141
|
-
|
|
1142
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
2229
|
-
*
|
|
2230
|
-
*
|
|
2231
|
-
*
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
if (
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
throw
|
|
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
|
-
/**
|
|
2242
|
-
*
|
|
2243
|
-
*
|
|
2244
|
-
*
|
|
2245
|
-
*
|
|
2246
|
-
*
|
|
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 (
|
|
2252
|
-
if (attempt > RECOVERY_MAX_AUTO_ATTEMPTS) return 'abort';
|
|
2452
|
+
if (verdict.outcome === 'retry') {
|
|
2253
2453
|
await this._backoff(attempt, this.pauseAbort.signal);
|
|
2254
|
-
|
|
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
|
-
|
|
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).
|
|
2273
|
-
|
|
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
|
|
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
|
-
|
|
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')
|
|
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;
|
|
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
|
-
|
|
3508
|
-
|
|
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 ─────────────────────────────────────────────────────────────
|