@worca/app 1.2.0 → 1.3.0
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 +42 -0
- package/agents/memoryDefragmenter.meta.json +24 -0
- package/agents/worca-cc-code-reviewer.md +6 -1
- package/agents/worca-cc-implementer.md +6 -1
- package/agents/worca-cc-memory-defragmenter.md +32 -0
- package/agents/worca-cc-planner.md +5 -1
- package/package.json +5 -2
- package/src/cli/render.mjs +36 -0
- package/src/cli/worca-cc.mjs +137 -8
- package/src/core/agent-registry.mjs +12 -34
- package/src/core/artifacts.mjs +132 -8
- package/src/core/ask/catalog.mjs +32 -7
- package/src/core/ask/comment-deps.mjs +5 -2
- package/src/core/ask/events.mjs +65 -2
- package/src/core/ask/limits.mjs +9 -0
- package/src/core/ask/mcp-stdio.mjs +10 -0
- package/src/core/ask/memory-deps.mjs +107 -0
- package/src/core/ask/metrics-deps.mjs +124 -0
- package/src/core/ask/metrics-proposal.mjs +175 -0
- package/src/core/ask/prompt.mjs +53 -10
- package/src/core/ask/proposal.mjs +49 -2
- package/src/core/ask/spawn.mjs +21 -4
- package/src/core/ask/store.mjs +14 -5
- package/src/core/ask/tool-deps.mjs +26 -2
- package/src/core/ask/tools.mjs +439 -6
- package/src/core/ask/turn.mjs +163 -4
- package/src/core/ask/workflow-deps.mjs +226 -0
- package/src/core/auto/classify.mjs +352 -0
- package/src/core/auto/fingerprint.mjs +141 -0
- package/src/core/auto/match.mjs +30 -0
- package/src/core/auto/model.mjs +23 -0
- package/src/core/auto/proposal.mjs +132 -0
- package/src/core/auto/recipes.mjs +75 -0
- package/src/core/auto/repo-look.mjs +46 -0
- package/src/core/claude-runner.mjs +132 -11
- package/src/core/config.mjs +120 -3
- package/src/core/db.mjs +44 -1
- package/src/core/diff-comments.mjs +55 -9
- package/src/core/frontmatter.mjs +75 -0
- package/src/core/git-info.mjs +233 -26
- package/src/core/graph/builtin-workflows.mjs +50 -0
- package/src/core/graph/executor.mjs +11 -3
- package/src/core/index-html.mjs +17 -0
- package/src/core/memory-store.mjs +441 -0
- package/src/core/memory-sync.mjs +300 -0
- package/src/core/metrics/ledger.mjs +47 -0
- package/src/core/metrics/lock.mjs +117 -0
- package/src/core/metrics/read.mjs +303 -0
- package/src/core/metrics/record.mjs +389 -0
- package/src/core/metrics/sync.mjs +1100 -0
- package/src/core/onboarding.mjs +99 -0
- package/src/core/orchestrator.mjs +394 -7
- package/src/core/phases.mjs +16 -3
- package/src/core/pipeline-delete.mjs +1 -1
- package/src/core/plugin-store.mjs +2 -10
- package/src/core/preflight.mjs +2 -3
- package/src/core/projects.mjs +16 -1
- package/src/core/run-harness.mjs +458 -32
- package/src/core/run-report.mjs +896 -0
- package/src/core/settings.mjs +162 -0
- package/src/core/sources.mjs +4 -1
- package/src/core/store.mjs +5 -0
- package/src/core/workflow-export.mjs +2 -0
- package/src/core/workflow-share.mjs +1 -0
- package/src/core/workflows.mjs +43 -23
- package/src/core/workspaces.mjs +37 -8
- package/src/shared/graph/agent-meta.mjs +5 -2
- package/src/shared/graph/assemble.mjs +455 -0
- package/src/shared/graph/flow-layout.mjs +249 -0
- package/src/shared/graph/geometry.mjs +48 -28
- package/src/shared/graph/isomorphic.mjs +101 -0
- package/src/shared/report-reasons.mjs +58 -0
- package/src/shared/team-metrics/aggregate.mjs +341 -0
- package/src/shared/team-metrics/workspace-match.mjs +13 -0
- package/ui/public/about-links.mjs +21 -0
- package/ui/public/app.js +3715 -479
- package/ui/public/artifact-view.mjs +135 -0
- package/ui/public/ask-model.mjs +18 -1
- package/ui/public/ask-panel.mjs +1359 -214
- package/ui/public/ask-run-card.mjs +209 -0
- package/ui/public/assets/worca-logo-mask.png +0 -0
- package/ui/public/assets/worca-mark-mask.png +0 -0
- package/ui/public/auto-build.mjs +95 -0
- package/ui/public/auto-proposal.mjs +174 -0
- package/ui/public/comment-thread.mjs +55 -0
- package/ui/public/getting-started.mjs +261 -0
- package/ui/public/graph/composer.mjs +41 -5
- package/ui/public/graph/inspector.mjs +3 -1
- package/ui/public/graph/model.mjs +1 -0
- package/ui/public/graph/run-hosts.mjs +73 -12
- package/ui/public/graph/view.mjs +218 -50
- package/ui/public/guide-spot.mjs +215 -0
- package/ui/public/index.html +423 -25
- package/ui/public/memory-view.mjs +192 -0
- package/ui/public/node-tunables.mjs +201 -0
- package/ui/public/report-run.mjs +75 -0
- package/ui/public/results-view.mjs +25 -0
- package/ui/public/source-pane.mjs +16 -2
- package/ui/public/stats-view.mjs +2 -2
- package/ui/public/style.css +1450 -303
- package/ui/public/team-metrics-surfaces.mjs +452 -0
- package/ui/public/team-metrics-view.mjs +533 -0
- package/ui/public/thinking-orb.mjs +46 -8
- package/ui/server.mjs +1282 -193
package/src/core/run-harness.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { homedir } from 'node:os';
|
|
|
19
19
|
import { fileURLToPath } from 'node:url';
|
|
20
20
|
import { join, basename, resolve, sep, relative } from 'node:path';
|
|
21
21
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
22
|
-
import { readFile, writeFile, readdir, mkdir, realpath } from 'node:fs/promises';
|
|
22
|
+
import { readFile, writeFile, readdir, mkdir, realpath, rename } from 'node:fs/promises';
|
|
23
23
|
|
|
24
24
|
import { generateTitle } from './title.mjs';
|
|
25
25
|
import {
|
|
@@ -38,7 +38,10 @@ import { worcaHome } from './projects.mjs';
|
|
|
38
38
|
import {
|
|
39
39
|
runRootMode, getProjectsRoot,
|
|
40
40
|
pipelineCostLimitUsd, totalCostLimitUsd, costLimitResetPeriod,
|
|
41
|
+
memoryCaps,
|
|
41
42
|
} from './settings.mjs';
|
|
43
|
+
import { mountDirs, mountMemory, syncBack, memoryTotals, validateMemoryScope, withStoreLock, memoryMountPath, MEMORY_RULES_REL, MEMORY_INJECTED_ENTRY } from './memory-sync.mjs';
|
|
44
|
+
import { memoryRoot, renderMemoryBlock, bumpScopeState } from './memory-store.mjs';
|
|
42
45
|
import { readCostCapOverride, totalWindowSpendUsd, costWindowStart, recordCostDelta } from './cost-budget.mjs';
|
|
43
46
|
import {
|
|
44
47
|
writeRunManifest, readRunManifest, updateRunManifest, rmGuarded, rescueModifiedMounts,
|
|
@@ -67,6 +70,7 @@ import {
|
|
|
67
70
|
resolveFailure, isTerminal, markTerminal, answerFromDecision,
|
|
68
71
|
REASON, pauseConsequences, describePauseReason,
|
|
69
72
|
} from './failure-policy.mjs';
|
|
73
|
+
import { recordRunMetrics } from './metrics/record.mjs';
|
|
70
74
|
|
|
71
75
|
// worca-cc repo root; holds skills/. fileURLToPath, never URL.pathname: the
|
|
72
76
|
// latter is `/C:/…` on Windows and %-encoded everywhere (see DEFAULT_AGENTS_DIR
|
|
@@ -557,6 +561,11 @@ export function normalizeClarifyAnswer(payload, questions) {
|
|
|
557
561
|
}));
|
|
558
562
|
}
|
|
559
563
|
|
|
564
|
+
// Upper bound for one RunHarness._git call. Matches worktree.mjs's slow-git
|
|
565
|
+
// budget (SLOW_GIT_TIMEOUT_MS): `diff --cached` on a large agent change is the
|
|
566
|
+
// slowest command issued here, and it legitimately takes seconds, never minutes.
|
|
567
|
+
const HARNESS_GIT_TIMEOUT_MS = 120_000;
|
|
568
|
+
|
|
560
569
|
export class RunHarness extends EventEmitter {
|
|
561
570
|
constructor(opts) {
|
|
562
571
|
super();
|
|
@@ -612,6 +621,10 @@ export class RunHarness extends EventEmitter {
|
|
|
612
621
|
}
|
|
613
622
|
this.agentsDir = this.opts.agentsDir || DEFAULT_AGENTS_DIR;
|
|
614
623
|
this.auto = !!this.opts.auto;
|
|
624
|
+
// Auto workflow (spec D3/D15): the human-in-the-loop switch. `--yes` (auto
|
|
625
|
+
// mode) implies it is off. It changes nothing on a saved workflow — only an
|
|
626
|
+
// Auto run reads it (proposal question, clarifier stage, agent questions).
|
|
627
|
+
this.humanInLoop = !this.auto && this.opts.humanInLoop !== false;
|
|
615
628
|
this.stepModels = null; // { planner:{model,effort}, refiner:{...}, ... } | null until run()
|
|
616
629
|
// Guardrails: resolved by _resolveGuardrails() from run() AND resume(); null
|
|
617
630
|
// until then, so dispatcher tests that bypass run() get claudeOpts without
|
|
@@ -662,17 +675,36 @@ export class RunHarness extends EventEmitter {
|
|
|
662
675
|
this.pauseAbort = new AbortController(); // aborts ONLY node children on pause
|
|
663
676
|
this.pauseReason = null; // WHY the run paused: 'cost_pipeline'|'cost_total'|'error'|<usage-limit line>|null
|
|
664
677
|
this.pauseDetail = null; // the human detail behind pauseReason ('error': the clipped message)
|
|
678
|
+
// Team metrics (§4.4 interventions). Resume runs on a NEW instance, so the counters are
|
|
679
|
+
// stamped into the persisted resume point at every pause and re-seeded in resume().
|
|
680
|
+
this._metricsIv = { questions: 0, pauses: 0, resumes: 0, lastPauseReason: null, lastPauseDetail: null };
|
|
681
|
+
this._metricsRecorded = false;
|
|
665
682
|
this._setupDone = false; // run()/resume() flip this right before _engineRun (setup replay)
|
|
666
683
|
this._rehydrated = true; // resume() clears this until the paused run is rehydrated (the 'resume' site)
|
|
667
684
|
this._modeRecorded = false; // resume(): the row recorded a run-root mode (a setup-incomplete point may not)
|
|
668
685
|
this._pauseGate = null; // gate context snapshot when paused at a gate
|
|
669
686
|
this._resumeNodeSessions = null; // nodeId -> sessionId map, set by resume() (Task 5)
|
|
670
687
|
this.resumeOpts = this.opts.resume || null; // { row, resumePoint, steps } from readPipelineForResume
|
|
688
|
+
// Agent memory (agent-memory-design.md §7.3): the defragment run option. A resumed run reads
|
|
689
|
+
// it back from its resume point — the resume sites pass no run options (B10). The API and the
|
|
690
|
+
// CLI validated already; this throw is the programming-error backstop (never a 400).
|
|
691
|
+
this.memoryScope = this.opts.memoryScope || this.resumeOpts?.resumePoint?.memoryScope || null;
|
|
692
|
+
{
|
|
693
|
+
const wf = this.resumeOpts?.resumePoint?.workflowId || this.workflowId;
|
|
694
|
+
const reason = validateMemoryScope({ workflowId: wf, memoryScope: this.memoryScope, isWorkspace: this.isWorkspace });
|
|
695
|
+
if (reason) throw new Error(reason);
|
|
696
|
+
}
|
|
671
697
|
this.pendingQuestion = null; // { id, resolve, reject, kind }
|
|
672
698
|
this._recovery = null; // class -> in-flight Promise<'retry'|'pause'> (same-class dedupe)
|
|
673
699
|
this._askTail = null; // serializes _ask: ONE prompt open at a time (recovery + step questions)
|
|
674
700
|
this._recoverySeq = 0; // monotonic id source for recovery prompts (determinism-safe)
|
|
675
701
|
this.agentPrompts = null;
|
|
702
|
+
this.memory = null; // { root, mount, dirs, baseline } after _mountMemory
|
|
703
|
+
this.memoryBlock = ''; // the ## Worca memory pointer block, rendered once per mount (files load natively — no per-spawn re-render)
|
|
704
|
+
this.memoryChanges = []; // Change[] — the durable ledger's `changes`
|
|
705
|
+
this._memoryWarned = new Set();
|
|
706
|
+
this._memoryTail = null; // per-run sync chain: one syncBack at a time (F1)
|
|
707
|
+
this._ledgerSeq = 0; // monotonic: two ledger writes must never share a temp name
|
|
676
708
|
this.toolInstruction = '';
|
|
677
709
|
// Cap for the in-worktree graphify build (macOS has no timeout(1)).
|
|
678
710
|
// Resolution order: constructor option → WORCA_GRAPH_TIMEOUT_MS env → 120s.
|
|
@@ -717,6 +749,7 @@ export class RunHarness extends EventEmitter {
|
|
|
717
749
|
// detached run throws TypeError on the first this.state.branches[key] = … .
|
|
718
750
|
branches: {},
|
|
719
751
|
checkpointRefs: {},
|
|
752
|
+
memoryMount: null, // <runCwd>/.claude/rules/worca after _mountMemory (native rules: inside every spawn's cwd)
|
|
720
753
|
pauseReason: null, // mirrors this.pauseReason so getState() (a deep clone of state) carries it live
|
|
721
754
|
pauseDetail: null, // mirrors this.pauseDetail
|
|
722
755
|
// Sub-agent lifecycle records (rides the existing `state` snapshot; mirrored to
|
|
@@ -743,6 +776,18 @@ export class RunHarness extends EventEmitter {
|
|
|
743
776
|
this._log('orchestrator', 'warn', `answer() ignored: no pending question with id ${id}`);
|
|
744
777
|
return false;
|
|
745
778
|
}
|
|
779
|
+
if (pq.validate) {
|
|
780
|
+
// A question that carries a validator (the Auto proposal) stays OPEN on a
|
|
781
|
+
// malformed payload (spec §5.4); the awaiting code receives the CLEAN value.
|
|
782
|
+
const clean = pq.validate(payload);
|
|
783
|
+
if (clean == null) {
|
|
784
|
+
this._log('orchestrator', 'warn', `answer() ignored: malformed payload for ${id} — the question stays open`);
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
787
|
+
this.pendingQuestion = null;
|
|
788
|
+
pq.resolve(clean);
|
|
789
|
+
return true;
|
|
790
|
+
}
|
|
746
791
|
this.pendingQuestion = null;
|
|
747
792
|
pq.resolve(payload);
|
|
748
793
|
return true;
|
|
@@ -891,7 +936,7 @@ export class RunHarness extends EventEmitter {
|
|
|
891
936
|
// fan-out forcing + the v1 stepper manifest; v2 = resolveGraph +
|
|
892
937
|
// buildGraphManifest. It yields the manifest the UI renders, the agent-key
|
|
893
938
|
// set the preflight and skills gates walk, and the workflow's id/name.
|
|
894
|
-
|
|
939
|
+
let topology = await this._resolveTopology(registry);
|
|
895
940
|
if (!topology?.manifest || !topology.agentKeys || !topology.workflow?.id) throw new Error('engine hook contract: _resolveTopology must return { manifest, agentKeys, workflow:{id,name} }');
|
|
896
941
|
// §9.4: hard-fail BEFORE the stepper is STAMPED / createPipeline / worktree
|
|
897
942
|
// (the manifest is built inside the hook, which tolerates unknown keys) —
|
|
@@ -1040,6 +1085,15 @@ export class RunHarness extends EventEmitter {
|
|
|
1040
1085
|
if (!this.resumeOpts) this._kickoffTitleGeneration();
|
|
1041
1086
|
this._checkAbort();
|
|
1042
1087
|
|
|
1088
|
+
// 3b') Auto workflow (spec §5.3): the run row and the run root exist, so the
|
|
1089
|
+
// engine may now DECIDE the graph (classifier call, proposal question, reuse
|
|
1090
|
+
// or create) and re-stamp the manifest. Returns null for a saved workflow.
|
|
1091
|
+
// `topology` is consumed AFTER this point (collectRequiredSkills, the workflow
|
|
1092
|
+
// audit line), so the adopted graph is what they see.
|
|
1093
|
+
const decided = await this._decideTopology();
|
|
1094
|
+
if (decided) topology = decided;
|
|
1095
|
+
this._checkAbort();
|
|
1096
|
+
|
|
1043
1097
|
// 3c) Build the knowledge graph INSIDE each worktree so agents can query it.
|
|
1044
1098
|
if (this.isWorkspace) await this._buildWorktreeGraphAll();
|
|
1045
1099
|
else await this._buildWorktreeGraph();
|
|
@@ -1087,6 +1141,12 @@ export class RunHarness extends EventEmitter {
|
|
|
1087
1141
|
await this._assembleContext(resolvedSkills);
|
|
1088
1142
|
}
|
|
1089
1143
|
this._checkAbort();
|
|
1144
|
+
// 3f) Agent memory: mount the store into <runCwd>/.claude/rules/worca — the CLI loads it
|
|
1145
|
+
// natively — and render the pointer block every spawn carries. Pure fs work, both modes,
|
|
1146
|
+
// mock included. AFTER 3e: the assembly rewrites injectedPaths and the mount registers
|
|
1147
|
+
// itself into that map.
|
|
1148
|
+
await this._mountMemory();
|
|
1149
|
+
this._checkAbort();
|
|
1090
1150
|
// D7: every setup step above is done — a pause from here on has nothing to
|
|
1091
1151
|
// replay, so _completePaused strips any `setupIncomplete` stamp instead.
|
|
1092
1152
|
this._setupDone = true;
|
|
@@ -1110,7 +1170,9 @@ export class RunHarness extends EventEmitter {
|
|
|
1110
1170
|
await this._persist();
|
|
1111
1171
|
await appendAudit(this.pipeline.dir, `Pipeline finished with status **done**.`);
|
|
1112
1172
|
await this._buildResults(); // refs + worktree still live here
|
|
1173
|
+
await this._stampDefrag(); // AFTER the final sync inside _buildResults counted the defragmenter's writes
|
|
1113
1174
|
await this._reportToSource(); // task-source write-back (never throws, spec §7.5)
|
|
1175
|
+
await this._recordRunMetrics('done');
|
|
1114
1176
|
this._emit('done', { status: 'done', pipelineDir: this.pipeline.dir });
|
|
1115
1177
|
return { status: 'done', pipelineDir: this.pipeline.dir };
|
|
1116
1178
|
} catch (err) {
|
|
@@ -1153,6 +1215,7 @@ export class RunHarness extends EventEmitter {
|
|
|
1153
1215
|
await this._buildResults({ stage: true });
|
|
1154
1216
|
await this._reportToSource(); // statusToResult('stopped') -> 'failed' (design PR12: no longer success-only)
|
|
1155
1217
|
}
|
|
1218
|
+
await this._recordRunMetrics('stopped');
|
|
1156
1219
|
this._emit('done', {
|
|
1157
1220
|
status: 'stopped',
|
|
1158
1221
|
pipelineDir: this.pipeline?.dir || null,
|
|
@@ -1193,6 +1256,7 @@ export class RunHarness extends EventEmitter {
|
|
|
1193
1256
|
await this._buildResults({ stage: true });
|
|
1194
1257
|
await this._reportToSource(); // statusToResult('error') -> 'failed' (design PR12: no longer success-only)
|
|
1195
1258
|
}
|
|
1259
|
+
await this._recordRunMetrics('error', err);
|
|
1196
1260
|
this._emit('done', {
|
|
1197
1261
|
status: 'error',
|
|
1198
1262
|
pipelineDir: this.pipeline?.dir || null,
|
|
@@ -1260,6 +1324,13 @@ export class RunHarness extends EventEmitter {
|
|
|
1260
1324
|
recordArtifact(row.id, RUN_LOG_KIND, RUN_LOG_FILE);
|
|
1261
1325
|
this.stepModels = rp.stepModels || null;
|
|
1262
1326
|
this.workflowId = rp.workflowId || this.workflowId;
|
|
1327
|
+
// Pauses are counted only in _completePaused, so a crash-resume of an `interrupted`
|
|
1328
|
+
// run adds a resume but no pause (§4.4 decision 6).
|
|
1329
|
+
const iv = rp.interventions && typeof rp.interventions === 'object' ? rp.interventions : {};
|
|
1330
|
+
this._metricsIv = {
|
|
1331
|
+
questions: iv.questions | 0, pauses: iv.pauses | 0, resumes: (iv.resumes | 0) + 1,
|
|
1332
|
+
lastPauseReason: iv.lastPauseReason ?? null, lastPauseDetail: iv.lastPauseDetail ?? null,
|
|
1333
|
+
};
|
|
1263
1334
|
// The saved point carries the pause that produced it; a resumed run is running.
|
|
1264
1335
|
this._clearPauseReason();
|
|
1265
1336
|
// Rehydrate the run's selection BEFORE re-resolving so resume enforces the
|
|
@@ -1377,6 +1448,18 @@ export class RunHarness extends EventEmitter {
|
|
|
1377
1448
|
await appendAudit(this.pipeline.dir, rehydrated.audit);
|
|
1378
1449
|
this._emit('state', this.getState());
|
|
1379
1450
|
this._rehydrated = true;
|
|
1451
|
+
// The pause that parked this run is over; it must not colour a later failure
|
|
1452
|
+
// or stop (§4.4 decision 2).
|
|
1453
|
+
this._metricsIv.lastPauseReason = null;
|
|
1454
|
+
this._metricsIv.lastPauseDetail = null;
|
|
1455
|
+
|
|
1456
|
+
// 3a') Auto workflow (spec §5.6): a run that paused BEFORE its graph was
|
|
1457
|
+
// decided re-enters the decision HERE — before the setup replay, so the
|
|
1458
|
+
// skills gate and the context assembly below see the ADOPTED agent keys, not
|
|
1459
|
+
// the bootstrap's empty set. null for a saved workflow and for an Auto run
|
|
1460
|
+
// that already adopted (rp.workflowId is then the real id).
|
|
1461
|
+
await this._decideTopology({ resume: rp });
|
|
1462
|
+
this._checkAbort();
|
|
1380
1463
|
|
|
1381
1464
|
// ── setup replay (D7): a converted setup failure paused this run before its
|
|
1382
1465
|
// checkout / graph / skills gate existed. Re-run exactly what run() never
|
|
@@ -1417,6 +1500,10 @@ export class RunHarness extends EventEmitter {
|
|
|
1417
1500
|
}
|
|
1418
1501
|
}
|
|
1419
1502
|
|
|
1503
|
+
// Agent memory on resume (§5): capture what the interrupted execution wrote,
|
|
1504
|
+
// then remount fresh from the store.
|
|
1505
|
+
await this._mountMemory({ resume: true });
|
|
1506
|
+
|
|
1420
1507
|
const dispatched = await this._engineRun({ resume: rp, rehydrated });
|
|
1421
1508
|
this._checkAbort();
|
|
1422
1509
|
if (dispatched === 'paused') return await this._completePaused();
|
|
@@ -1427,7 +1514,9 @@ export class RunHarness extends EventEmitter {
|
|
|
1427
1514
|
await this._persist();
|
|
1428
1515
|
await appendAudit(this.pipeline.dir, `Pipeline finished with status **done**.`);
|
|
1429
1516
|
await this._buildResults(); // refs + worktree still live here
|
|
1517
|
+
await this._stampDefrag(); // AFTER the final sync inside _buildResults counted the defragmenter's writes
|
|
1430
1518
|
await this._reportToSource(); // task-source write-back (never throws, spec §7.5)
|
|
1519
|
+
await this._recordRunMetrics('done');
|
|
1431
1520
|
this._emit('done', { status: 'done', pipelineDir: this.pipeline.dir });
|
|
1432
1521
|
return { status: 'done', pipelineDir: this.pipeline.dir };
|
|
1433
1522
|
} catch (err) {
|
|
@@ -1462,6 +1551,7 @@ export class RunHarness extends EventEmitter {
|
|
|
1462
1551
|
await this._buildResults({ stage: true });
|
|
1463
1552
|
await this._reportToSource(); // statusToResult('stopped') -> 'failed' (design PR12: no longer success-only)
|
|
1464
1553
|
}
|
|
1554
|
+
await this._recordRunMetrics('stopped');
|
|
1465
1555
|
this._emit('done', { status: 'stopped', pipelineDir: this.pipeline?.dir || null });
|
|
1466
1556
|
return { status: 'stopped', pipelineDir: this.pipeline?.dir || null };
|
|
1467
1557
|
}
|
|
@@ -1497,6 +1587,7 @@ export class RunHarness extends EventEmitter {
|
|
|
1497
1587
|
await this._buildResults({ stage: true });
|
|
1498
1588
|
await this._reportToSource(); // statusToResult('error') -> 'failed' (design PR12: no longer success-only)
|
|
1499
1589
|
}
|
|
1590
|
+
await this._recordRunMetrics('error', err);
|
|
1500
1591
|
this._emit('done', { status: 'error', pipelineDir: this.pipeline?.dir || null });
|
|
1501
1592
|
return { status: 'error', pipelineDir: this.pipeline?.dir || null, error: message };
|
|
1502
1593
|
} finally {
|
|
@@ -1754,6 +1845,244 @@ export class RunHarness extends EventEmitter {
|
|
|
1754
1845
|
return rc;
|
|
1755
1846
|
}
|
|
1756
1847
|
|
|
1848
|
+
/** Absolute `<pipeline.dir>/memory.json` — the durable memory ledger (amendment A2). */
|
|
1849
|
+
_memoryLedgerPath() { return join(this.pipeline.dir, 'memory.json'); }
|
|
1850
|
+
|
|
1851
|
+
/** Mount the memory store into this run — best-effort. Memory is additive (spec §4.3):
|
|
1852
|
+
* a store/mount fs failure degrades the run to "no memory" (no pointer block, no sync) and is
|
|
1853
|
+
* logged + audited; it never pauses the run at 'setup', where the replay would hit the
|
|
1854
|
+
* same error again. EXCEPTION (amendment B8): a DEFRAGMENT run (`this.memoryScope` set) IS
|
|
1855
|
+
* its mount — the error is rethrown and run()'s setup failure policy parks the run
|
|
1856
|
+
* paused/error with setupIncomplete, so a retryable mount failure can resume. */
|
|
1857
|
+
async _mountMemory({ resume = false } = {}) {
|
|
1858
|
+
if (!this.pipeline?.dir) return;
|
|
1859
|
+
try { await this._mountMemoryUnguarded({ resume }); }
|
|
1860
|
+
catch (err) {
|
|
1861
|
+
this.memory = null; this.memoryBlock = ''; this.state.memoryMount = null;
|
|
1862
|
+
if (existsSync(this._memoryLedgerPath())) await this._writeMemoryLedger({ neutralised: true });
|
|
1863
|
+
const why = String(err?.message || err).split('\n')[0];
|
|
1864
|
+
// A defragment run IS its mount (B8): rethrow, and run()'s setup failure policy parks the run
|
|
1865
|
+
// as paused/error with setupIncomplete — a retryable mount failure resumes, a persistent one
|
|
1866
|
+
// stays visible. An ordinary run degrades to "no memory" as in P1.
|
|
1867
|
+
if (this.memoryScope) {
|
|
1868
|
+
await appendAudit(this.pipeline.dir, `Memory: not mounted (${why}) — a defragment run cannot continue.`).catch(() => {});
|
|
1869
|
+
throw new Error(`memory not mounted: ${why}`);
|
|
1870
|
+
}
|
|
1871
|
+
this._log('memory', 'warn', `memory not mounted: ${why} — this run's agents see no memory and their memory writes are not captured`);
|
|
1872
|
+
await appendAudit(this.pipeline.dir, `Memory: not mounted (${why}).`).catch(() => {});
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
/**
|
|
1877
|
+
* Mount the memory store into this run at `<runCwd>/.claude/rules/worca` — INSIDE the cwd of
|
|
1878
|
+
* every spawn, where Claude Code discovers rules natively (run root on a detached workspace
|
|
1879
|
+
* run, the primary worktree otherwise). Always recomputed, never the ledger's absolute path.
|
|
1880
|
+
* On resume, the previous segment's ledger is read first and its mount is synced back BEFORE
|
|
1881
|
+
* anything else (§5 "resume of a paused run"): the sync is pure fs and needs neither git nor
|
|
1882
|
+
* the tracked guard, so a guard that fails only NOW (git broken, the previous segment's agent
|
|
1883
|
+
* staged the mount) can never lose the interrupted segment's writes.
|
|
1884
|
+
*/
|
|
1885
|
+
async _mountMemoryUnguarded({ resume }) {
|
|
1886
|
+
const root = memoryRoot();
|
|
1887
|
+
// Pre-setup there is no run cwd, and the LIVE checkout must never take a mount.
|
|
1888
|
+
const cwd = this.runCwd || null;
|
|
1889
|
+
if (!cwd || cwd === this.projectDir) throw new Error('no run cwd to mount into');
|
|
1890
|
+
const mount = memoryMountPath(cwd);
|
|
1891
|
+
// §8.8 scope of the record: 'runRoot' when the cwd IS the run root, else the member whose
|
|
1892
|
+
// checkout is the cwd (the primary member on single and legacy-workspace runs).
|
|
1893
|
+
const scope = (this.runRoot && cwd === this.runRoot) ? 'runRoot'
|
|
1894
|
+
: ([...this.workDirs.entries()].find(([, d]) => d === cwd)?.[0] ?? null);
|
|
1895
|
+
if (!scope) throw new Error(`run cwd ${cwd} is neither the run root nor a member checkout`);
|
|
1896
|
+
const dirs = mountDirs({ members: this.members, isWorkspace: this.isWorkspace, memoryScope: this.memoryScope });
|
|
1897
|
+
const onError = (p, err) => this._memoryReadWarn(p, err);
|
|
1898
|
+
if (resume) {
|
|
1899
|
+
let ledger = null;
|
|
1900
|
+
try { ledger = JSON.parse(await readFile(this._memoryLedgerPath(), 'utf8')); } catch { ledger = null; }
|
|
1901
|
+
if (ledger && ledger.baseline && Array.isArray(ledger.dirs)) {
|
|
1902
|
+
this.memoryChanges = Array.isArray(ledger.changes) ? ledger.changes : [];
|
|
1903
|
+
// A run paused BEFORE the native-rules revision still has its files at the ledger's
|
|
1904
|
+
// old path (<pipeline.dir>/memory): sync THAT dir once, so nothing the interrupted
|
|
1905
|
+
// execution wrote is lost; the recomputed path is used from here on.
|
|
1906
|
+
const prev = (typeof ledger.mount === 'string' && ledger.mount !== mount && existsSync(ledger.mount)) ? ledger.mount : mount;
|
|
1907
|
+
try {
|
|
1908
|
+
await this._syncMemoryWith({ mount: prev, dirs: ledger.dirs, baseline: ledger.baseline, nodeId: 'resume', executionId: null, agentKey: null, label: 'the interrupted execution' });
|
|
1909
|
+
} catch (err) {
|
|
1910
|
+
// Defensive — syncBack does not reject today (every fs error is per-file or routed
|
|
1911
|
+
// through onError). If it ever does: do NOT remount over unsynced writes; keep the
|
|
1912
|
+
// PREVIOUS mount + baseline so the next execution's sync retries them.
|
|
1913
|
+
this._log('memory', 'warn', `memory: the interrupted execution's writes could not be synced (${err?.message || err}); keeping the previous mount`);
|
|
1914
|
+
this.memory = { root, mount: prev, dirs: ledger.dirs, baseline: ledger.baseline };
|
|
1915
|
+
this.state.memoryMount = prev;
|
|
1916
|
+
// Register ONLY when the kept mount is the one inside this run's cwd: a mount there must
|
|
1917
|
+
// stay excluded from the commit and removed at teardown, while the pre-revision
|
|
1918
|
+
// <pipeline.dir>/memory path is outside every checkout and needs no §8.8 record.
|
|
1919
|
+
if (prev === mount) await this._registerMemoryMount(scope);
|
|
1920
|
+
this._refreshMemoryBlock();
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
// A checkout that TRACKS the mount path would have its committed files overwritten, excluded
|
|
1926
|
+
// from the commit and deleted at teardown — refuse, like the skill mount's trackedNames guard.
|
|
1927
|
+
// `:(icase)`: on a case-insensitive file system a repo tracking `.Claude/rules/worca` would
|
|
1928
|
+
// otherwise pass the guard and have those files rm'd through the case-folded path. The
|
|
1929
|
+
// detached workspace run root has no git and no check.
|
|
1930
|
+
if (scope !== 'runRoot') {
|
|
1931
|
+
const tracked = await this._git(['ls-files', '--', `:(icase)${MEMORY_RULES_REL}`], { cwd });
|
|
1932
|
+
if (!tracked.ok) throw new Error(`cannot tell whether the checkout tracks ${MEMORY_RULES_REL} (git ls-files: ${tracked.stderr.trim() || `exit ${tracked.code}`})`);
|
|
1933
|
+
// The way out differs: an ordinary run has nowhere else to go (its checkout IS the project's),
|
|
1934
|
+
// a defragment targets a scope and can be started from any other project's checkout.
|
|
1935
|
+
if (tracked.stdout.trim()) throw new Error(`the checkout tracks ${MEMORY_RULES_REL} — untrack it${this.memoryScope ? ' (or start the defragment from another project)' : ''}`);
|
|
1936
|
+
}
|
|
1937
|
+
// Register BEFORE anything touches the disk: a mount that fails half-way (EACCES, a Windows
|
|
1938
|
+
// EBUSY past the retries) leaves files under the cwd, and only the §8.8 entry keeps them out
|
|
1939
|
+
// of the commit and gets them removed at teardown. Idempotent, harmless on failure.
|
|
1940
|
+
await this._registerMemoryMount(scope);
|
|
1941
|
+
// gitIgnore: the mount now lives INSIDE a checkout an AGENT runs git in. `<mount>/.gitignore`
|
|
1942
|
+
// = `*` makes it invisible to an agent's own `git add -A`, to a staging pre-commit hook, to
|
|
1943
|
+
// snapshotWorktreePatch's bare `git add -A` and to the reviewer's `git status`. The §8.8
|
|
1944
|
+
// `:(exclude)` pathspec stays as defence in depth. Harmless at a non-git run root.
|
|
1945
|
+
const m = await mountMemory({ root, mount, dirs, onError, gitIgnore: true });
|
|
1946
|
+
this.memory = { root, mount, dirs, baseline: m.baseline };
|
|
1947
|
+
this.state.memoryMount = mount;
|
|
1948
|
+
this._refreshMemoryBlock();
|
|
1949
|
+
await this._writeMemoryLedger();
|
|
1950
|
+
this._log('memory', 'info', `Memory mounted at ${mount}: ${m.files} file(s) across ${dirs.length} scope(s)`);
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
/**
|
|
1954
|
+
* §8.8: the mount rides `injectedPaths[<scope>]` as a `kind:'memory'` entry — excluded from the
|
|
1955
|
+
* commit, the intent-to-add staging and the three result diffs (_excludePathspecs), removed at
|
|
1956
|
+
* teardown (removeInjectedPaths), never rescued (sync-back is its rescue). Idempotent: a resume
|
|
1957
|
+
* re-assembly rewrites the map without it, so it is re-added here; persisted into run.json on
|
|
1958
|
+
* detached runs so the boot sweep and pipeline-delete see the same set. Under legacy the map was
|
|
1959
|
+
* always {} — the memory entry is the ONE legacy pathspec, and the legacy `git add -A` becomes
|
|
1960
|
+
* `git add -A -- . :(exclude).claude/rules/worca` (§10's byte-identical contract, amended: memory
|
|
1961
|
+
* has been mounted in both modes since P1, and an unexcluded mount would be committed).
|
|
1962
|
+
*/
|
|
1963
|
+
async _registerMemoryMount(scope) {
|
|
1964
|
+
const map = { ...(this.injectedPaths || {}) };
|
|
1965
|
+
map[scope] = [...(map[scope] || []).filter((e) => e?.kind !== 'memory'), { ...MEMORY_INJECTED_ENTRY }];
|
|
1966
|
+
this.injectedPaths = map;
|
|
1967
|
+
if (this.runRoot) await updateRunManifest(this.runRoot, { injectedPaths: map }).catch(() => {});
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
/** The `## Worca memory` pointer block: heading, one-line intro, one `Label — /abs/dir:` line per
|
|
1971
|
+
* mounted scope. Depends on dirs + mount only (never on file contents), so one render per mount. */
|
|
1972
|
+
_refreshMemoryBlock() {
|
|
1973
|
+
if (!this.memory) { this.memoryBlock = ''; return; }
|
|
1974
|
+
this.memoryBlock = renderMemoryBlock(this.memory.dirs.map((d) => ({ label: d.label, dir: join(this.memory.mount, d.rel) })));
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
/** The `onError` every memory listing gets. A junk NAME is not an I/O failure — phrasing it
|
|
1978
|
+
* as "cannot read" sends the user hunting a broken disk instead of renaming a file. */
|
|
1979
|
+
_memoryReadWarn(p, err) {
|
|
1980
|
+
if (err?.code === 'ENAME') this._memoryWarn(`memory: ignored ${p} (invalid name — not a memory file)`);
|
|
1981
|
+
else this._memoryWarn(`memory: cannot read ${p}: ${err?.code || err?.message || err}`);
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
/** Record-once warnings: the pointer block is rendered once per mount. */
|
|
1985
|
+
_memoryWarn(text) {
|
|
1986
|
+
if (this._memoryWarned.has(text)) return;
|
|
1987
|
+
this._memoryWarned.add(text);
|
|
1988
|
+
this._log('memory', 'warn', text);
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
/** After ONE execution (orchestrator._afterExecution) — never rejects, and serialised per
|
|
1992
|
+
* run: the job reads `this.memory` when the PREVIOUS sync has published its baseline.
|
|
1993
|
+
* (Composite slices and parallel branches finish together; two syncs diffing against one
|
|
1994
|
+
* baseline would both write and both report the same files.) */
|
|
1995
|
+
_syncMemory(nc, ctx) {
|
|
1996
|
+
if (!this.memory) return Promise.resolve(null);
|
|
1997
|
+
const job = async () => {
|
|
1998
|
+
if (!this.memory) return null;
|
|
1999
|
+
try {
|
|
2000
|
+
return await this._syncMemoryWith({ ...this.memory, nodeId: ctx.nodeId, executionId: ctx.executionId, agentKey: nc?.key ?? null, label: nc?.key || ctx.label || ctx.nodeId });
|
|
2001
|
+
} catch (err) {
|
|
2002
|
+
this._log('memory', 'warn', `memory sync failed after ${ctx.executionId}: ${err?.message || err}`);
|
|
2003
|
+
return null;
|
|
2004
|
+
}
|
|
2005
|
+
};
|
|
2006
|
+
this._memoryTail = (this._memoryTail || Promise.resolve()).then(job, job);
|
|
2007
|
+
return this._memoryTail;
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
async _syncMemoryWith({ mount, dirs, baseline, nodeId, executionId, agentKey, label }) {
|
|
2011
|
+
const now = new Date().toISOString();
|
|
2012
|
+
const res = await syncBack({
|
|
2013
|
+
root: memoryRoot(), mount, dirs, baseline, source: `${this.memoryScope ? 'defrag' : 'run'}:${this.pipeline.id}`, now,
|
|
2014
|
+
caps: memoryCaps(), onWarn: (w) => this._log('memory', 'warn', w),
|
|
2015
|
+
onError: (p, err) => this._memoryReadWarn(p, err),
|
|
2016
|
+
});
|
|
2017
|
+
// `mount` equals this.memory.mount for every in-run sync and for a same-process resume;
|
|
2018
|
+
// an OLD instance can never race a resumed one because the scheduler drains in-flight
|
|
2019
|
+
// executions before the run reports 'paused' (scheduler.mjs, the pause drain).
|
|
2020
|
+
if (this.memory && this.memory.mount === mount) this.memory.baseline = res.baseline;
|
|
2021
|
+
if (res.total || res.rejected.length) {
|
|
2022
|
+
this.memoryChanges.push({
|
|
2023
|
+
executionId, nodeId, agentKey, at: now,
|
|
2024
|
+
added: res.added, modified: res.modified, deleted: res.deleted, rejected: res.rejected,
|
|
2025
|
+
});
|
|
2026
|
+
const head = `Memory: +${res.added.length} ~${res.modified.length} -${res.deleted.length}` +
|
|
2027
|
+
`${res.rejected.length ? ` (${res.rejected.length} rejected)` : ''} by ${label}`;
|
|
2028
|
+
const name = (r) => `${r.scope}/${r.name}.md`;
|
|
2029
|
+
const details = [
|
|
2030
|
+
...res.added.map((r) => `added ${name(r)}`), ...res.modified.map((r) => `updated ${name(r)}`),
|
|
2031
|
+
...res.deleted.map((r) => `deleted ${name(r)}`), ...res.rejected.map((r) => `rejected ${name(r)} — ${r.reason}`),
|
|
2032
|
+
];
|
|
2033
|
+
this._log('memory', 'info', `${head}: ${details.join('; ')}`, { nodeId, executionId });
|
|
2034
|
+
await appendAudit(this.pipeline.dir, `${head}: ${details.join('; ')}`).catch(() => {});
|
|
2035
|
+
}
|
|
2036
|
+
if (this.memory && this.memory.mount === mount) await this._writeMemoryLedger();
|
|
2037
|
+
return res;
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
/** `{ mount, dirs, baseline, changes }` — best-effort, atomic via temp + rename. */
|
|
2041
|
+
async _writeMemoryLedger({ neutralised = false } = {}) {
|
|
2042
|
+
if (!this.pipeline?.dir || (!this.memory && !neutralised)) return;
|
|
2043
|
+
const file = this._memoryLedgerPath();
|
|
2044
|
+
// `neutralised` (after a mount failure) keeps the change history but writes no dirs and
|
|
2045
|
+
// no baseline, so a later resume has nothing stale to diff against (a missing mount dir
|
|
2046
|
+
// must never read as "the run deleted every file").
|
|
2047
|
+
const payload = neutralised
|
|
2048
|
+
? { mount: null, dirs: [], baseline: {}, changes: this.memoryChanges }
|
|
2049
|
+
: { mount: this.memory.mount, dirs: this.memory.dirs, baseline: this.memory.baseline, changes: this.memoryChanges };
|
|
2050
|
+
const tmp = `${file}.tmp-${process.pid}-${++this._ledgerSeq}`;
|
|
2051
|
+
try { await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); await rename(tmp, file); }
|
|
2052
|
+
catch (err) { this._log('memory', 'warn', `memory ledger not written: ${err?.message || err}`); }
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
/** A finished defragment run resets the scope's counters (spec §5, §7): called on the `done`
|
|
2056
|
+
* arms only, after _buildResults' final sync. `this.memory.dirs[0]` is the one mounted scope.
|
|
2057
|
+
* Amendment B31: a run whose ledger holds ANY rejected write did not produce the scope the
|
|
2058
|
+
* agent intended — leave the counters alone (health stays `due`) and say so, loudly.
|
|
2059
|
+
* `withStoreLock` is a per-process promise chain: it serialises this stamp against this
|
|
2060
|
+
* process's own syncs only, not against another worca process. */
|
|
2061
|
+
async _stampDefrag() {
|
|
2062
|
+
if (!this.memoryScope || !this.memory?.dirs?.length) return;
|
|
2063
|
+
const d = this.memory.dirs[0];
|
|
2064
|
+
const { rejected } = memoryTotals(this.memoryChanges);
|
|
2065
|
+
if (rejected) {
|
|
2066
|
+
this._log('memory', 'warn', `Memory: ${d.label} — ${rejected} write(s) were rejected during this defragment; counters NOT reset (see the rejections above)`);
|
|
2067
|
+
await appendAudit(this.pipeline.dir, `Memory: ${d.label} defragment finished with ${rejected} rejected write(s) — counters not reset.`).catch(() => {});
|
|
2068
|
+
return;
|
|
2069
|
+
}
|
|
2070
|
+
const now = new Date().toISOString();
|
|
2071
|
+
try {
|
|
2072
|
+
await withStoreLock(memoryRoot(), () => bumpScopeState(memoryRoot(), d.scope, { lastDefragAt: now, lastDefragRunId: this.pipeline.id, writesSinceDefrag: 0 }));
|
|
2073
|
+
this._log('memory', 'info', `Memory: ${d.label} defragmented — write counter reset`);
|
|
2074
|
+
await appendAudit(this.pipeline.dir, `Memory: ${d.label} defragmented by this run.`).catch(() => {});
|
|
2075
|
+
} catch (err) {
|
|
2076
|
+
this._log('memory', 'warn', `memory: defrag stamp failed: ${err?.message || err}`);
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
/** The run-summary shape (§6). null when nothing changed, so results.json is unchanged for such runs. */
|
|
2081
|
+
memorySummary() {
|
|
2082
|
+
if (!this.memoryChanges.length) return null;
|
|
2083
|
+
return { changes: this.memoryChanges, totals: memoryTotals(this.memoryChanges) };
|
|
2084
|
+
}
|
|
2085
|
+
|
|
1757
2086
|
/**
|
|
1758
2087
|
* §8.18 / gate V5: parse `claude --help` ONCE per run and assert `--mcp-config`.
|
|
1759
2088
|
* On absence, degrade gracefully — skip the flag, warn loudly naming the required
|
|
@@ -1920,7 +2249,12 @@ export class RunHarness extends EventEmitter {
|
|
|
1920
2249
|
// branch carries no changes (the staging in _stageWorkingTree is intent-to-add
|
|
1921
2250
|
// for the reviewer's diff only — it never creates a commit). On error/stop this
|
|
1922
2251
|
// is what captures the partial work made up to that point.
|
|
1923
|
-
const
|
|
2252
|
+
const key = this.members[0]?.projectKey ?? null;
|
|
2253
|
+
const injected = key ? (this.injectedPaths?.[key] ?? []) : [];
|
|
2254
|
+
const commit = await this._commitWork(info, this.state.branch, { excludePathspecs: this._excludePathspecs(key) });
|
|
2255
|
+
// The memory mount (the one legacy injected path) was synced at _buildResults; remove it now
|
|
2256
|
+
// so it rides neither the retained-work snapshot nor an outlived checkout.
|
|
2257
|
+
await removeInjectedPaths(info.worktreeDir, injected);
|
|
1924
2258
|
const retained = await this._recordCommitFailure(commit, { info, branchRecord: this.state.branch });
|
|
1925
2259
|
if (retained) {
|
|
1926
2260
|
await this._snapshotRetained(info);
|
|
@@ -1970,7 +2304,8 @@ export class RunHarness extends EventEmitter {
|
|
|
1970
2304
|
for (const [projectKey_, info] of entries) {
|
|
1971
2305
|
if (!info || !info.worktreeDir) continue;
|
|
1972
2306
|
const branchRecord = (this.state.branches && this.state.branches[projectKey_]) || null;
|
|
1973
|
-
const commit = await this._commitWork(info, branchRecord);
|
|
2307
|
+
const commit = await this._commitWork(info, branchRecord, { excludePathspecs: this._excludePathspecs(projectKey_) });
|
|
2308
|
+
await removeInjectedPaths(info.worktreeDir, this.injectedPaths?.[projectKey_] ?? []);
|
|
1974
2309
|
if (await this._recordCommitFailure(commit, { key: projectKey_, info, branchRecord })) {
|
|
1975
2310
|
anyRetained = true;
|
|
1976
2311
|
await this._snapshotRetained(info, projectKey_);
|
|
@@ -2018,7 +2353,8 @@ export class RunHarness extends EventEmitter {
|
|
|
2018
2353
|
* Still skipped entirely when the run paused (§8.13) — the caller guards.
|
|
2019
2354
|
*
|
|
2020
2355
|
* Under `legacy` this delegates to today's _teardownWorktree / _teardownWorktreeAll
|
|
2021
|
-
* verbatim
|
|
2356
|
+
* verbatim, except that both now commit with the §8.8 exclusion set (the memory mount)
|
|
2357
|
+
* and remove the mount after the commit attempt. Under `detached`, per member, in NORMATIVE order:
|
|
2022
2358
|
* 1. modified-mount rescue (§8.20) — read-only, so it survives any later failure
|
|
2023
2359
|
* 2. strip every claudeMdSection fenced block (must precede the commit — that
|
|
2024
2360
|
* file is deliberately NOT in the exclusion pathspecs)
|
|
@@ -2260,8 +2596,12 @@ export class RunHarness extends EventEmitter {
|
|
|
2260
2596
|
* (defaults to the scalar this.state.branch; a workspace member passes its own
|
|
2261
2597
|
* state.branches[projectKey] so per-member SHAs are recorded distinctly).
|
|
2262
2598
|
* @param {{excludePathspecs?:string[]}} [opts] §8.8 exclusion set for this
|
|
2263
|
-
* worktree.
|
|
2264
|
-
*
|
|
2599
|
+
* worktree. Since the native-rules revision every run passes one (the memory mount),
|
|
2600
|
+
* so the argv is `git add -A -- . :(exclude).claude/rules/worca`, preceded by a
|
|
2601
|
+
* `git rm -r --cached --ignore-unmatch -- .claude/rules/worca` that unstages anything an
|
|
2602
|
+
* agent force-staged there (`git add -A` with an exclude never unstages); the DEFAULT empty
|
|
2603
|
+
* array (a refused mount) still reproduces the bare `git add -A`. The detached
|
|
2604
|
+
* `_teardownRunRoot` path commits through this same method, so it is covered too.
|
|
2265
2605
|
* @returns {Promise<{ok:true,committed:boolean,sha:string|null}|
|
|
2266
2606
|
* {ok:false,step:'status'|'add'|'commit',message:string,fromStderr:boolean}>}
|
|
2267
2607
|
* `fromStderr` records whether `message` embeds real stderr bytes (vs. the
|
|
@@ -2289,6 +2629,15 @@ export class RunHarness extends EventEmitter {
|
|
|
2289
2629
|
this._log('git', 'info', 'No changes to commit (working tree clean).');
|
|
2290
2630
|
return { ok: true, committed: false, sha: null };
|
|
2291
2631
|
}
|
|
2632
|
+
// `git add -A -- . :(exclude)X` does not UNSTAGE what is already in the index: an agent that
|
|
2633
|
+
// ran `git add -f .claude/rules/worca` (the one way past the mount's `.gitignore` sentinel)
|
|
2634
|
+
// would otherwise put memory files on the kept branch, and on a resume the tracked guard would
|
|
2635
|
+
// then refuse the mount for the rest of the run. Drop the exclusion set from the index first —
|
|
2636
|
+
// a no-op (exit 0) when nothing under it is staged, thanks to --ignore-unmatch.
|
|
2637
|
+
if (excludePathspecs.length) {
|
|
2638
|
+
await this._git(['rm', '-r', '--cached', '-q', '--ignore-unmatch', '--',
|
|
2639
|
+
...excludePathspecs.map((s) => s.replace(/^:\(exclude\)/, ''))], gitOpts);
|
|
2640
|
+
}
|
|
2292
2641
|
const add = excludePathspecs.length
|
|
2293
2642
|
? await this._git(['add', '-A', '--', '.', ...excludePathspecs], gitOpts)
|
|
2294
2643
|
: await this._git(['add', '-A'], gitOpts);
|
|
@@ -2319,8 +2668,9 @@ export class RunHarness extends EventEmitter {
|
|
|
2319
2668
|
gitOpts,
|
|
2320
2669
|
);
|
|
2321
2670
|
}
|
|
2322
|
-
if (!commit.ok &&
|
|
2323
|
-
// §8.8 (detached runs —
|
|
2671
|
+
if (!commit.ok && this.runRootMode === 'detached') {
|
|
2672
|
+
// §8.8 (detached runs only — legacy keeps its verbatim commit even now that it carries an
|
|
2673
|
+
// exclusion set): a failing hook
|
|
2324
2674
|
// must never silently delete an agent's work. Teardown removeWorktree(force:true)s
|
|
2325
2675
|
// the checkout right after a successful commit, so this commit is the ONLY thing
|
|
2326
2676
|
// that carries the work onto the kept branch. A diff artifact does now survive
|
|
@@ -2329,7 +2679,9 @@ export class RunHarness extends EventEmitter {
|
|
|
2329
2679
|
// rebase or push. Detached worktrees make hook failure MORE likely (§8.1:
|
|
2330
2680
|
// husky/lint-staged resolve through an ancestor node_modules today and do not
|
|
2331
2681
|
// detached). Retry ONCE with hooks disabled for that invocation only, logging
|
|
2332
|
-
// both facts.
|
|
2682
|
+
// both facts. The gate is the MODE, not the exclusion set: before the native-rules
|
|
2683
|
+
// revision a detached default-workflow run recorded no injected path at all and was
|
|
2684
|
+
// silently excluded from the retry — exactly the runs §8.1 is about.
|
|
2333
2685
|
const hookErr = commit.stderr.trim() || `exit ${commit.code}`;
|
|
2334
2686
|
this._log('git', 'warn', `commit failed with hooks enabled: ${hookErr}`, errStreamAttr(commit.stderr));
|
|
2335
2687
|
const retry = await this._git(
|
|
@@ -2556,7 +2908,7 @@ export class RunHarness extends EventEmitter {
|
|
|
2556
2908
|
* Freezes the active-time clock while blocked on the user (active-time-only).
|
|
2557
2909
|
* @returns {Promise<any>} the answer payload
|
|
2558
2910
|
*/
|
|
2559
|
-
async _ask({ id, kind, questions, issues, recovery, agent, nodeId, wireId, executionId, deliveryNo, holdNo }) {
|
|
2911
|
+
async _ask({ id, kind, questions, issues, recovery, agent, nodeId, wireId, executionId, deliveryNo, holdNo, workflow, validate }) {
|
|
2560
2912
|
this._checkAbort();
|
|
2561
2913
|
// No interactive prompt may OPEN on a pausing run. pause() rejects only the
|
|
2562
2914
|
// prompt that is currently open; a queued ask (a parallel sibling's questions
|
|
@@ -2587,7 +2939,9 @@ export class RunHarness extends EventEmitter {
|
|
|
2587
2939
|
// trail — reads these fields instead of parsing the id (MAJ-11).
|
|
2588
2940
|
...(deliveryNo != null ? { deliveryNo } : {}),
|
|
2589
2941
|
...(holdNo != null ? { holdNo } : {}),
|
|
2942
|
+
...(workflow !== undefined ? { workflow } : {}),
|
|
2590
2943
|
});
|
|
2944
|
+
this._metricsIv.questions += 1;
|
|
2591
2945
|
|
|
2592
2946
|
try {
|
|
2593
2947
|
if (this.auto) {
|
|
@@ -2597,6 +2951,11 @@ export class RunHarness extends EventEmitter {
|
|
|
2597
2951
|
// pauses the run (errors never end one), so 'pause' is the answer.
|
|
2598
2952
|
return { decision: 'pause' };
|
|
2599
2953
|
}
|
|
2954
|
+
if (kind === 'workflow') {
|
|
2955
|
+
// Auto workflow under --yes: the proposal is accepted as proposed (spec D3).
|
|
2956
|
+
this._log('orchestrator', 'info', `auto-accepting workflow proposal ${id}`);
|
|
2957
|
+
return { decision: 'accept' };
|
|
2958
|
+
}
|
|
2600
2959
|
if (kind === 'clarify' || kind === 'questions') {
|
|
2601
2960
|
this._log('orchestrator', 'info', `auto-answering ${kind} ${id}`);
|
|
2602
2961
|
return {
|
|
@@ -2610,7 +2969,7 @@ export class RunHarness extends EventEmitter {
|
|
|
2610
2969
|
return { decision: 'continue' };
|
|
2611
2970
|
}
|
|
2612
2971
|
return await new Promise((resolveP, rejectP) => {
|
|
2613
|
-
this.pendingQuestion = { id, kind, resolve: resolveP, reject: rejectP };
|
|
2972
|
+
this.pendingQuestion = { id, kind, resolve: resolveP, reject: rejectP, validate: typeof validate === 'function' ? validate : null };
|
|
2614
2973
|
});
|
|
2615
2974
|
} finally {
|
|
2616
2975
|
// Resume only the rows that are STILL running AND only while the run has not
|
|
@@ -2718,6 +3077,10 @@ export class RunHarness extends EventEmitter {
|
|
|
2718
3077
|
// bound signal kills the staging before git can touch the index.
|
|
2719
3078
|
// INSIDE the try: the stopped path calls _buildResults from run()'s catch, so
|
|
2720
3079
|
// anything that escaped here would reject run() itself.
|
|
3080
|
+
// The final sync runs BEFORE the two early returns below (`!members.length`,
|
|
3081
|
+
// `noPatch && stage && !listed`), so results.json.memory is absent on those paths;
|
|
3082
|
+
// the ledger (memory.json) is the durable carrier and History reads it.
|
|
3083
|
+
if (this.memory) await this._syncMemory(null, { nodeId: 'final', executionId: null, label: 'the run end' }).catch(() => {});
|
|
2721
3084
|
if (stage) await this._stageWorkingTree({ ignoreAbort: true });
|
|
2722
3085
|
const reviews = readPipelineExtras(this.pipeline.id).reviews || [];
|
|
2723
3086
|
// Unified iteration over workDirs + checkpointRefs — the ref map is filled in
|
|
@@ -2764,12 +3127,14 @@ export class RunHarness extends EventEmitter {
|
|
|
2764
3127
|
// no-op run must not lose them (review of PR #376). The 0-byte
|
|
2765
3128
|
// diff-patch.patch is still never written on any path.
|
|
2766
3129
|
if (noPatch && stage && !listed) return;
|
|
3130
|
+
const memory = this.memorySummary();
|
|
2767
3131
|
if (members.length === 1 && !this.isWorkspace) {
|
|
3132
|
+
if (memory) members[0].results.memory = memory;
|
|
2768
3133
|
await persistResults(this.pipeline.dir, members[0].results);
|
|
2769
3134
|
if (!noPatch) await persistDiffPatch(this.pipeline.dir, patches[0].patch);
|
|
2770
3135
|
} else {
|
|
2771
3136
|
const perProject = buildPerProject(members);
|
|
2772
|
-
const results = { summary: rollupSummary(perProject), perProject };
|
|
3137
|
+
const results = { summary: rollupSummary(perProject), perProject, ...(memory ? { memory } : {}) };
|
|
2773
3138
|
await persistResults(this.pipeline.dir, results);
|
|
2774
3139
|
if (!noPatch) await persistDiffPatch(this.pipeline.dir, patches.map((p) => `# ${p.key}\n${p.patch}`).join('\n\n'));
|
|
2775
3140
|
}
|
|
@@ -2809,6 +3174,19 @@ export class RunHarness extends EventEmitter {
|
|
|
2809
3174
|
}
|
|
2810
3175
|
}
|
|
2811
3176
|
|
|
3177
|
+
/** Team metrics (team-metrics-design.md §4.5): one record per terminal run. Idempotent per
|
|
3178
|
+
* instance and fail-soft — a metrics failure is a log line, never a run failure. */
|
|
3179
|
+
async _recordRunMetrics(status, error = null) {
|
|
3180
|
+
if (this._metricsRecorded) return null;
|
|
3181
|
+
this._metricsRecorded = true;
|
|
3182
|
+
try {
|
|
3183
|
+
return await recordRunMetrics(this, { status, error });
|
|
3184
|
+
} catch (err) {
|
|
3185
|
+
try { this._log('metrics', 'warn', `team metrics: ${err?.message || err}`); } catch { /* never */ }
|
|
3186
|
+
return null;
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
|
|
2812
3190
|
/** Single-project checkpoint: own repo + commit, record the scalar ref + state. */
|
|
2813
3191
|
async _ensureGitCheckpoint() {
|
|
2814
3192
|
this.checkpointRef = await this._ensureGitCheckpointFor(this.projectDir);
|
|
@@ -2877,8 +3255,8 @@ export class RunHarness extends EventEmitter {
|
|
|
2877
3255
|
// setup never ran, in which case staging must be a NO-OP — the old single arm
|
|
2878
3256
|
// fell back to this.workDir, which pre-setup is the user's LIVE checkout.
|
|
2879
3257
|
for (const [key, dir] of this.workDirs.entries()) {
|
|
2880
|
-
// §8.8:
|
|
2881
|
-
//
|
|
3258
|
+
// §8.8: the exclusion set is the memory mount in both modes (and the skill mount under
|
|
3259
|
+
// detached); an empty set (a refused mount) reproduces the bare argv.
|
|
2882
3260
|
const ex = this._excludePathspecs(key);
|
|
2883
3261
|
const args = ex.length ? ['add', '-A', '-N', '--', '.', ...ex] : ['add', '-A', '-N'];
|
|
2884
3262
|
const res = await this._git(args, { cwd: dir, ignoreAbort });
|
|
@@ -2894,8 +3272,9 @@ export class RunHarness extends EventEmitter {
|
|
|
2894
3272
|
* `kind:'claudeMdSection'` entries are deliberately EXCLUDED from the set — their
|
|
2895
3273
|
* file is the user's tracked CLAUDE.md, and a blanket `:(exclude)CLAUDE.md` would
|
|
2896
3274
|
* silently strip the agent's legitimate edits (teardown strips the fence instead).
|
|
2897
|
-
*
|
|
2898
|
-
*
|
|
3275
|
+
* Under legacy the set holds exactly the memory mount (`_registerMemoryMount`), so
|
|
3276
|
+
* `git add -A -- . :(exclude).claude/rules/worca` is the legacy argv since the
|
|
3277
|
+
* native-rules revision.
|
|
2899
3278
|
* @param {string} projectKey
|
|
2900
3279
|
* @returns {string[]}
|
|
2901
3280
|
*/
|
|
@@ -2909,9 +3288,17 @@ export class RunHarness extends EventEmitter {
|
|
|
2909
3288
|
|
|
2910
3289
|
/**
|
|
2911
3290
|
* Run a git command in the project dir. Never throws; returns
|
|
2912
|
-
* { ok, code, stdout, stderr }. Honors the abort signal.
|
|
3291
|
+
* { ok, code, stdout, stderr }. Honors the abort signal. Bounded by
|
|
3292
|
+
* `timeoutMs` (default HARNESS_GIT_TIMEOUT_MS): the commands issued here are
|
|
3293
|
+
* local (init/add/status/rev-parse/commit/diff --cached), and a git that does
|
|
3294
|
+
* not come back in that time is stuck, not working — it is SIGKILLed and
|
|
3295
|
+
* reported as `{ ok: false, stderr: 'git timed out' }`, which every caller
|
|
3296
|
+
* already handles as a failed git step. Without this bound a wedged git on
|
|
3297
|
+
* the stop/teardown path (which deliberately ignores the abort signal) held
|
|
3298
|
+
* the whole process, and under `npm test` the runner, until the CI job's
|
|
3299
|
+
* 30-minute limit killed it.
|
|
2913
3300
|
*/
|
|
2914
|
-
_git(args, { cwd, ignoreAbort = false } = {}) {
|
|
3301
|
+
_git(args, { cwd, ignoreAbort = false, timeoutMs = HARNESS_GIT_TIMEOUT_MS } = {}) {
|
|
2915
3302
|
return new Promise((resolveP) => {
|
|
2916
3303
|
let child;
|
|
2917
3304
|
try {
|
|
@@ -2929,12 +3316,26 @@ export class RunHarness extends EventEmitter {
|
|
|
2929
3316
|
}
|
|
2930
3317
|
let stdout = '';
|
|
2931
3318
|
let stderr = '';
|
|
3319
|
+
let settled = false;
|
|
3320
|
+
const done = (val) => {
|
|
3321
|
+
if (settled) return;
|
|
3322
|
+
settled = true;
|
|
3323
|
+
clearTimeout(timer);
|
|
3324
|
+
resolveP(val);
|
|
3325
|
+
};
|
|
3326
|
+
const timer = timeoutMs > 0
|
|
3327
|
+
? setTimeout(() => {
|
|
3328
|
+
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
|
3329
|
+
// A grandchild (hook, alias, pager) that inherited the pipes would keep
|
|
3330
|
+
// them — and this process's event loop — open after git itself is dead.
|
|
3331
|
+
try { child.stdout?.destroy(); child.stderr?.destroy(); } catch { /* best effort */ }
|
|
3332
|
+
done({ ok: false, code: -1, stdout, stderr: stderr ? `git timed out: ${stderr}` : 'git timed out' });
|
|
3333
|
+
}, timeoutMs)
|
|
3334
|
+
: null;
|
|
2932
3335
|
child.stdout?.on('data', (d) => (stdout += d.toString()));
|
|
2933
3336
|
child.stderr?.on('data', (d) => (stderr += d.toString()));
|
|
2934
|
-
child.on('error', (err) =>
|
|
2935
|
-
|
|
2936
|
-
);
|
|
2937
|
-
child.on('close', (code) => resolveP({ ok: code === 0, code: code ?? -1, stdout, stderr }));
|
|
3337
|
+
child.on('error', (err) => done({ ok: false, code: -1, stdout, stderr: stderr || err.message }));
|
|
3338
|
+
child.on('close', (code) => done({ ok: code === 0, code: code ?? -1, stdout, stderr }));
|
|
2938
3339
|
});
|
|
2939
3340
|
}
|
|
2940
3341
|
|
|
@@ -3096,7 +3497,7 @@ export class RunHarness extends EventEmitter {
|
|
|
3096
3497
|
/**
|
|
3097
3498
|
* @param {string} kind
|
|
3098
3499
|
* @param {string} path
|
|
3099
|
-
* @param {{nodeId?:string, executionId?:string, port?:string|null}|null} [attr]
|
|
3500
|
+
* @param {{nodeId?:string, executionId?:string, port?:string|null, cycle?:number|null}|null} [attr]
|
|
3100
3501
|
* v2 attribution (§5.7). Omitted keys are omitted from the event, so every
|
|
3101
3502
|
* 2-arg v1 call emits the byte-identical `{kind, path}` payload it always did.
|
|
3102
3503
|
*/
|
|
@@ -3106,15 +3507,19 @@ export class RunHarness extends EventEmitter {
|
|
|
3106
3507
|
if (attr.nodeId != null) evt.nodeId = attr.nodeId;
|
|
3107
3508
|
if (attr.executionId != null) evt.executionId = attr.executionId;
|
|
3108
3509
|
if (attr.port != null) evt.port = attr.port;
|
|
3510
|
+
if (attr.cycle != null) evt.cycle = attr.cycle;
|
|
3109
3511
|
}
|
|
3110
3512
|
this._emit('artifact', evt);
|
|
3111
|
-
//
|
|
3112
|
-
//
|
|
3113
|
-
//
|
|
3114
|
-
// 'pipeline'
|
|
3115
|
-
//
|
|
3116
|
-
// the
|
|
3117
|
-
|
|
3513
|
+
// ALSO index FS markdown/extra paths so pipeline-delete can unlink the EXACT
|
|
3514
|
+
// files later, per-step attribution rides along (best-effort; never blocks a
|
|
3515
|
+
// run). Every kind with a durable on-disk relPath is recorded. Skipped:
|
|
3516
|
+
// 'pipeline' (the run DIR itself, no single file) and 'questions' (a scratch
|
|
3517
|
+
// file the orchestrator deletes once the round is answered — the Q&A lives in
|
|
3518
|
+
// the step_questions table, so an index row would only ever 404). The WS
|
|
3519
|
+
// event above still carries 'questions' for the live view. plan/review
|
|
3520
|
+
// markdown live under <store>/<key>/{plans,reviews} (store-root-relative);
|
|
3521
|
+
// prompt/checklist/webui live in the pipeline dir (dir-relative).
|
|
3522
|
+
if (!this.pipeline || !path || kind === 'pipeline' || kind === 'questions') return;
|
|
3118
3523
|
let relPath = null;
|
|
3119
3524
|
const pdir = this.pipeline.dir;
|
|
3120
3525
|
if (path.startsWith(pdir + sep)) {
|
|
@@ -3128,7 +3533,13 @@ export class RunHarness extends EventEmitter {
|
|
|
3128
3533
|
// Indexed with '/' on every OS: the row is a store-layout key, not a native
|
|
3129
3534
|
// path (pipeline-delete re-roots 'plans/…' / 'reviews/…' under the store),
|
|
3130
3535
|
// so a Windows-native 'reviews\\x.md' would silently miss that re-rooting.
|
|
3131
|
-
if (relPath)
|
|
3536
|
+
if (relPath) {
|
|
3537
|
+
recordArtifact(this.pipeline.id, kind, relPath.split(sep).join('/'), {
|
|
3538
|
+
stepKey: attr?.executionId ?? null,
|
|
3539
|
+
nodeId: attr?.nodeId ?? null,
|
|
3540
|
+
cycle: attr?.cycle ?? null,
|
|
3541
|
+
});
|
|
3542
|
+
}
|
|
3132
3543
|
}
|
|
3133
3544
|
|
|
3134
3545
|
/** Translate a low-level claude/mock event into a pipeline 'log' event. */
|
|
@@ -3680,6 +4091,8 @@ export class RunHarness extends EventEmitter {
|
|
|
3680
4091
|
}
|
|
3681
4092
|
|
|
3682
4093
|
async _persist() {
|
|
4094
|
+
const rpNow = this.state.resumePoint;
|
|
4095
|
+
if (rpNow && typeof rpNow === 'object' && this._metricsIv) rpNow.interventions = { ...this._metricsIv };
|
|
3683
4096
|
if (!this.pipeline) return;
|
|
3684
4097
|
try {
|
|
3685
4098
|
await writeState(this.pipeline.dir, this.state);
|
|
@@ -3718,6 +4131,11 @@ export class RunHarness extends EventEmitter {
|
|
|
3718
4131
|
// 'pausing') must replay that setup on resume; a completed setup never leaves a
|
|
3719
4132
|
// stale stamp behind (resume() re-arms the consumed point, which may carry one).
|
|
3720
4133
|
const rp = this.state.resumePoint;
|
|
4134
|
+
// ABOVE the `if (rp …)` — a pause counts whether or not the engine produced a resume point.
|
|
4135
|
+
this._metricsIv.pauses += 1;
|
|
4136
|
+
this._metricsIv.lastPauseReason = this.pauseReason || null;
|
|
4137
|
+
// _setPauseReason (run-harness.mjs:820) always stores a string or null.
|
|
4138
|
+
this._metricsIv.lastPauseDetail = this.pauseDetail == null ? null : String(this.pauseDetail).slice(0, 400);
|
|
3721
4139
|
if (rp && typeof rp === 'object') {
|
|
3722
4140
|
if (this._setupDone) { delete rp.setupIncomplete; delete rp.titleProvisional; }
|
|
3723
4141
|
else {
|
|
@@ -3727,6 +4145,7 @@ export class RunHarness extends EventEmitter {
|
|
|
3727
4145
|
// not a row column.
|
|
3728
4146
|
rp.titleProvisional = this.state.titleProvisional === true;
|
|
3729
4147
|
}
|
|
4148
|
+
rp.interventions = { ...this._metricsIv };
|
|
3730
4149
|
}
|
|
3731
4150
|
this._setStatus('paused');
|
|
3732
4151
|
await this._persist();
|
|
@@ -3878,6 +4297,13 @@ export class RunHarness extends EventEmitter {
|
|
|
3878
4297
|
* workflow -> the run's audit line. */
|
|
3879
4298
|
async _resolveTopology(_registry) { throw new Error('engine hook not implemented: _resolveTopology'); }
|
|
3880
4299
|
|
|
4300
|
+
/** Decide the run's topology AFTER the run row + run root exist (the Auto
|
|
4301
|
+
* workflow). run() calls it with no argument and REPLACES the bootstrap
|
|
4302
|
+
* manifest with a non-null return (the same bag as _resolveTopology); resume()
|
|
4303
|
+
* calls it with `{ resume: rp }` before the setup replay. null keeps what
|
|
4304
|
+
* _resolveTopology produced (every saved workflow). */
|
|
4305
|
+
async _decideTopology(_o = {}) { return null; }
|
|
4306
|
+
|
|
3881
4307
|
/** Run the pipeline to completion or to a pause.
|
|
3882
4308
|
* @param {{resume?:object|null, rehydrated?:object|null}} _args resume point + _engineRehydrate's bag
|
|
3883
4309
|
* @returns {Promise<'done'|'paused'>} */
|