@worca/app 1.0.0 → 1.1.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 +22 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +319 -45
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +189 -21
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +506 -0
- package/src/core/ask/follow.mjs +107 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +54 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +261 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +153 -0
- package/src/core/ask/store.mjs +360 -0
- package/src/core/ask/tool-deps.mjs +63 -0
- package/src/core/ask/tools.mjs +848 -0
- package/src/core/ask/turn.mjs +416 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +20 -3
- package/src/core/claude-runner.mjs +434 -57
- package/src/core/config.mjs +264 -41
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +684 -47
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1065 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/model-env.mjs +112 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +902 -4098
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +77 -14
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3585 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +109 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4008 -1670
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +264 -0
- package/ui/public/ask-panel.mjs +1880 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +269 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1459 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1667 -98
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- package/ui/public/composer-core.mjs +0 -211
|
@@ -0,0 +1,3585 @@
|
|
|
1
|
+
// src/core/run-harness.mjs
|
|
2
|
+
// The engine-agnostic run harness: everything a pipeline run needs regardless of
|
|
3
|
+
// which engine sequences the work — construction, the run()/resume() shells,
|
|
4
|
+
// run root + worktrees, guardrails, run context, cost limits, recovery, user
|
|
5
|
+
// asks, git checkpoints, results, the step ledger + clocks, logs, artifacts,
|
|
6
|
+
// events, persistence and the heartbeat.
|
|
7
|
+
//
|
|
8
|
+
// Engines subclass it and implement six hooks (bottom of the class):
|
|
9
|
+
// _resolveTopology, _engineRun, _enginePrePausePoint, _engineRehydrate,
|
|
10
|
+
// _bookend (implemented here), _initRunners (no-op here). The v1 engine is
|
|
11
|
+
// src/core/orchestrator.mjs (class Orchestrator extends RunHarness).
|
|
12
|
+
//
|
|
13
|
+
// It is an EventEmitter. Consumers (CLI, UI) subscribe to events and drive
|
|
14
|
+
// interaction via answer()/stop().
|
|
15
|
+
|
|
16
|
+
import { EventEmitter } from 'node:events';
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { join, basename, resolve, sep, relative } from 'node:path';
|
|
21
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { readFile, writeFile, readdir, mkdir, realpath } from 'node:fs/promises';
|
|
23
|
+
|
|
24
|
+
import { generateTitle } from './title.mjs';
|
|
25
|
+
import {
|
|
26
|
+
createPipeline, updatePipelineTitle, appendAudit, writeState, artifactPaths, slugify, today,
|
|
27
|
+
recordArtifact, writeClarify, readPipelineExtras, claimPipelineOwnership, touchHeartbeat,
|
|
28
|
+
clearPipelineOwnership, HEARTBEAT_INTERVAL_MS, upsertSubAgent,
|
|
29
|
+
} from './artifacts.mjs';
|
|
30
|
+
import { diffNameStatus, diffNumstat, diffPatch } from './git-info.mjs';
|
|
31
|
+
import {
|
|
32
|
+
assembleResults, persistResults, persistDiffPatch, buildPerProject, rollupSummary,
|
|
33
|
+
retainedWorkPatchName,
|
|
34
|
+
} from './results.mjs';
|
|
35
|
+
import { resolveTaskInput, retryWriteback } from './sources.mjs';
|
|
36
|
+
import { projectKey, projectStorePath, workspaceStorePath } from './store.mjs';
|
|
37
|
+
import { worcaHome } from './projects.mjs';
|
|
38
|
+
import {
|
|
39
|
+
runRootMode, getProjectsRoot,
|
|
40
|
+
pipelineCostLimitUsd, totalCostLimitUsd, costLimitResetPeriod,
|
|
41
|
+
} from './settings.mjs';
|
|
42
|
+
import { readCostCapOverride, totalWindowSpendUsd, costWindowStart, recordCostDelta } from './cost-budget.mjs';
|
|
43
|
+
import {
|
|
44
|
+
writeRunManifest, readRunManifest, updateRunManifest, rmGuarded, rescueModifiedMounts,
|
|
45
|
+
scanStrayEntries, copyRunManifestTo, removeInjectedPaths, stripClaudeMdFence,
|
|
46
|
+
RETAIN_REASONS,
|
|
47
|
+
} from './run-manifest.mjs';
|
|
48
|
+
import { assembleRunContext, renderContextAudit, MCP_GRANT_MODE } from './run-context.mjs';
|
|
49
|
+
import { createRunLogWriter, RUN_LOG_FILE, RUN_LOG_KIND } from './run-log.mjs';
|
|
50
|
+
import {
|
|
51
|
+
detectTools, detectToolsPerProject, runGraphifyUpdate, worktreeGraphInstruction,
|
|
52
|
+
probeClaudeCapabilities, explainUnspawnableClaude,
|
|
53
|
+
} from './preflight.mjs';
|
|
54
|
+
import { fanoutCap, mapWithCap } from './fanout.mjs';
|
|
55
|
+
import { resolveStepModels, observeModelCost, resolveModelCost, modelCostConfig } from './config.mjs';
|
|
56
|
+
import { readGuardrailSet } from './guardrail-store.mjs';
|
|
57
|
+
import { unionGuardrails, guardrailsToPermissionRules, mergePermissionRules } from './guardrails.mjs';
|
|
58
|
+
import { collectRequiredSkills, validateSkills, injectSkills, pluginSkillDirs } from './skills.mjs';
|
|
59
|
+
import { loadAgentRegistry, DEFAULT_AGENTS_DIR } from './agent-registry.mjs';
|
|
60
|
+
import {
|
|
61
|
+
createWorktree, removeWorktree, suggestBranchName, sanitizeBranchName, resolveDefaultBranch,
|
|
62
|
+
isValidSourceRef, snapshotWorktreePatch,
|
|
63
|
+
} from './worktree.mjs';
|
|
64
|
+
import { readPluginsLock, pluginCurrentDir } from './plugins-lock.mjs'; // §9.4 disabled-plugin hint
|
|
65
|
+
|
|
66
|
+
// worca-cc repo root; holds skills/. fileURLToPath, never URL.pathname: the
|
|
67
|
+
// latter is `/C:/…` on Windows and %-encoded everywhere (see DEFAULT_AGENTS_DIR
|
|
68
|
+
// in agent-registry.mjs, which is the single source for the built-in agents dir).
|
|
69
|
+
const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url));
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* §9.4 message enrichment: does a DISABLED plugin ship this agent key? Scans
|
|
73
|
+
* lock entries with enabled === false, reading key fields from each plugin's
|
|
74
|
+
* current/agents/*.meta.json. Returns the plugin name or null. try/catch
|
|
75
|
+
* throughout: no resolvable home / no lock / broken current => null (callers
|
|
76
|
+
* fall back to the generic "not installed" message).
|
|
77
|
+
* @param {string} key
|
|
78
|
+
* @returns {string|null}
|
|
79
|
+
*/
|
|
80
|
+
function findDisabledPluginFor(key) {
|
|
81
|
+
try {
|
|
82
|
+
const lock = readPluginsLock();
|
|
83
|
+
for (const name of Object.keys(lock).sort()) {
|
|
84
|
+
if (!lock[name] || lock[name].enabled !== false) continue;
|
|
85
|
+
const dir = join(pluginCurrentDir(name), 'agents');
|
|
86
|
+
let files;
|
|
87
|
+
try { files = readdirSync(dir); } catch { continue; }
|
|
88
|
+
for (const f of files) {
|
|
89
|
+
if (!f.endsWith('.meta.json')) continue;
|
|
90
|
+
try {
|
|
91
|
+
if (JSON.parse(readFileSync(join(dir, f), 'utf8'))?.key === key) return name;
|
|
92
|
+
} catch { /* malformed sidecar: skip */ }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch { /* no home / unreadable lock */ }
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
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
|
+
/**
|
|
106
|
+
* `attr` marking a log line whose text came from a subprocess's stderr.
|
|
107
|
+
*
|
|
108
|
+
* ONE convention for every subprocess worca spawns — the agent CLI (framed
|
|
109
|
+
* line-by-line by claude-runner), git (`_git`), and graphify. It records the
|
|
110
|
+
* origin CHANNEL, never the severity: each call site keeps the level it already
|
|
111
|
+
* had, because these git/graphify lines are worca's own summaries of a failure,
|
|
112
|
+
* not raw stderr echoes. Frozen and shared: `_log` only reads from `attr`.
|
|
113
|
+
*/
|
|
114
|
+
export const ERR_STREAM = Object.freeze({ stream: 'err' });
|
|
115
|
+
|
|
116
|
+
/** The `: <stderr>` suffix for a failed subprocess result, or '' when it said
|
|
117
|
+
* nothing. runGraphifyUpdate already returns its child's stderr and the log
|
|
118
|
+
* line used to drop it — tagging a line as stderr-derived while discarding the
|
|
119
|
+
* stderr would make the tag a lie. Clipped: a build failure can be verbose. */
|
|
120
|
+
function errDetail(res, max = 200) {
|
|
121
|
+
const text = (res?.stderr || '').trim().replace(/\s+/g, ' ');
|
|
122
|
+
return text ? `: ${clip(text, max)}` : '';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** attr for a log line whose text embeds subprocess output: ERR_STREAM only
|
|
126
|
+
* when the subprocess actually said something on stderr. A `|| 'exit N'`
|
|
127
|
+
* fallback carries no stderr bytes — tagging it would make the tag a lie
|
|
128
|
+
* (the same rule errDetail documents for the text itself). */
|
|
129
|
+
export function errStreamAttr(stderrText, extra = null) {
|
|
130
|
+
if (!(stderrText && String(stderrText).trim())) return extra;
|
|
131
|
+
return extra ? { ...extra, ...ERR_STREAM } : ERR_STREAM;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Round a USD amount to 4 decimals (tenth-of-a-cent) to avoid float drift. */
|
|
135
|
+
export function roundUsd(n) {
|
|
136
|
+
return Math.round((Number(n) || 0) * 1e4) / 1e4;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Sum per-step costUsd into the pipeline total, rounded ONCE so the total is
|
|
141
|
+
* exactly Σ steps (avoids the drift of independently rounding a separate running
|
|
142
|
+
* total on every add). Absent/NaN step costs are ignored.
|
|
143
|
+
* @param {Array<{costUsd?:number}>} steps
|
|
144
|
+
* @returns {number}
|
|
145
|
+
*/
|
|
146
|
+
export function sumStepCosts(steps) {
|
|
147
|
+
let sum = 0;
|
|
148
|
+
for (const s of Array.isArray(steps) ? steps : []) {
|
|
149
|
+
if (Number.isFinite(s?.costUsd)) sum += s.costUsd;
|
|
150
|
+
}
|
|
151
|
+
return roundUsd(sum);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Sum per-step active processing time (ms) into the pipeline total. Only the
|
|
156
|
+
* FINALIZED activeMs is summed here; a still-running step's tail is added live
|
|
157
|
+
* by consumers (liveActiveMs / the UI). Absent/NaN values are ignored. No
|
|
158
|
+
* rounding (durations are integer ms).
|
|
159
|
+
* @param {Array<{activeMs?:number}>} steps
|
|
160
|
+
* @returns {number}
|
|
161
|
+
*/
|
|
162
|
+
export function sumStepActive(steps) {
|
|
163
|
+
let sum = 0;
|
|
164
|
+
for (const s of Array.isArray(steps) ? steps : []) {
|
|
165
|
+
if (Number.isFinite(s?.activeMs)) sum += s.activeMs;
|
|
166
|
+
}
|
|
167
|
+
return sum;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function isAbort(err) {
|
|
171
|
+
// NAME only. Every abort/stop throw in this codebase stamps name='AbortError'
|
|
172
|
+
// (see stop()/_checkAbort/claude-runner); sniffing the message here also
|
|
173
|
+
// matched real CLI failures containing "aborted"/"stopped" and swallowed
|
|
174
|
+
// their terminal error line, recovery, and decomposed failure detection.
|
|
175
|
+
return !!err && err.name === 'AbortError';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Pause sentinel: thrown to unwind _dispatch when pause() was requested. */
|
|
179
|
+
export function pauseErr() {
|
|
180
|
+
const e = new Error('paused');
|
|
181
|
+
e.name = 'PauseError';
|
|
182
|
+
return e;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function isPause(err) {
|
|
186
|
+
return !!err && err.name === 'PauseError';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Fail-safe JSON.parse for nullable DB text columns; null on absent/bad JSON. */
|
|
190
|
+
export function safeParse(text) {
|
|
191
|
+
try {
|
|
192
|
+
return text ? JSON.parse(text) : null;
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function firstLine(text) {
|
|
199
|
+
if (!text) return '';
|
|
200
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
201
|
+
const t = line.replace(/^#+\s*/, '').trim();
|
|
202
|
+
if (t) return t;
|
|
203
|
+
}
|
|
204
|
+
return '';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function rel(base, p) {
|
|
208
|
+
if (!p) return '';
|
|
209
|
+
const b = resolve(base);
|
|
210
|
+
const full = resolve(p);
|
|
211
|
+
// Native separator: resolve() yields backslashes on Windows, where a '/'
|
|
212
|
+
// comparison never matched and every tool-call log line carried the full path.
|
|
213
|
+
return full.startsWith(b + sep) ? full.slice(b.length + 1) : full;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Collapse whitespace and truncate to n chars with an ellipsis. */
|
|
217
|
+
export function clip(text, n) {
|
|
218
|
+
if (!text) return '';
|
|
219
|
+
const s = String(text).replace(/\s+/g, ' ').trim();
|
|
220
|
+
return s.length > n ? s.slice(0, n - 1) + '…' : s;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── shared pure helpers (agent-event/telemetry block) ─────────────────────────
|
|
224
|
+
|
|
225
|
+
export function numOr(v, d) {
|
|
226
|
+
const n = Number(v);
|
|
227
|
+
return Number.isFinite(n) && n > 0 ? n : d;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** JSON round-trip clone; drops functions/undefined. Bus channels and resolved
|
|
231
|
+
* plan nodes are plain data, so this is lossless for them. */
|
|
232
|
+
export function jsonClone(v) {
|
|
233
|
+
return v == null ? null : JSON.parse(JSON.stringify(v));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Describe the tool calls in a stream-json `assistant` event as readable
|
|
238
|
+
* one-liners (e.g. `Read src/app.js`, `Bash npm test`). Returns [] for events
|
|
239
|
+
* with no tool_use blocks — tool_result echoes, the system init event — so the
|
|
240
|
+
* caller drops them instead of logging a contentless envelope type.
|
|
241
|
+
*/
|
|
242
|
+
// Max chars for the sub-agent label inside the "[role ▸ label]" tag. Deliberately
|
|
243
|
+
// shorter than toolTarget's 60-char Task clip: that 60 governs the parent's own
|
|
244
|
+
// "→ Task <desc>" debug line, which has a whole row to itself; this 40 governs the
|
|
245
|
+
// label embedded inside "[role ▸ label]", which shares a single flex row (web) and
|
|
246
|
+
// sits inline in the terminal, so it must stay compact. The two clips are
|
|
247
|
+
// independent on purpose — a long description may render at ≤60 on the parent line
|
|
248
|
+
// and ≤40 inside the child tag.
|
|
249
|
+
const SUBAGENT_LABEL_MAX = 40;
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Which model id prices a sub-agent. The child runs inside the parent node's CLI
|
|
253
|
+
* invocation — same endpoint, same price — so the parent's dispatched model is the
|
|
254
|
+
* default. A Task input MAY name its own model; that only changes the price when
|
|
255
|
+
* the named model carries an explicit cost override of its own (a bare alias like
|
|
256
|
+
* 'haiku' resolves to nothing and must not drop the parent's override).
|
|
257
|
+
* @param {unknown} inputModel the Task/Agent tool_use input's `model`, if any
|
|
258
|
+
* @param {string|undefined} parentModel the parent node's dispatched model
|
|
259
|
+
* @returns {string|null}
|
|
260
|
+
*/
|
|
261
|
+
function subAgentCostModel(inputModel, parentModel) {
|
|
262
|
+
const own = typeof inputModel === 'string' && inputModel.trim() ? inputModel.trim() : null;
|
|
263
|
+
try { if (own && modelCostConfig(own)) return own; } catch { /* catalog read is best-effort */ }
|
|
264
|
+
return parentModel ?? null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Record id -> short description for every Task/Agent tool_use block in a
|
|
269
|
+
* MAIN-agent event, so a sub-agent's later events (which carry that id as
|
|
270
|
+
* parent_tool_use_id) can be labeled by the job they were given. Safe when
|
|
271
|
+
* `raw` is a string (non-JSON runner line): raw?.message?.content is undefined.
|
|
272
|
+
*/
|
|
273
|
+
function registerSubAgents(raw, labels) {
|
|
274
|
+
const content = raw?.message?.content;
|
|
275
|
+
if (!Array.isArray(content)) return;
|
|
276
|
+
for (const c of content) {
|
|
277
|
+
if (c?.type === 'tool_use' && (c.name === 'Task' || c.name === 'Agent') && c.id && !labels.has(c.id)) {
|
|
278
|
+
const desc = clip(c.input?.description || c.input?.prompt, SUBAGENT_LABEL_MAX);
|
|
279
|
+
if (desc) labels.set(c.id, desc); // empty desc left unset → fallback assigns sub-agent-N
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function describeToolUses(raw, projectDir) {
|
|
285
|
+
const content = raw?.message?.content;
|
|
286
|
+
if (!Array.isArray(content)) return [];
|
|
287
|
+
const calls = [];
|
|
288
|
+
for (const c of content) {
|
|
289
|
+
if (c?.type === 'tool_use' && typeof c.name === 'string') {
|
|
290
|
+
const target = toolTarget(c.name, c.input, projectDir);
|
|
291
|
+
calls.push(target ? `${c.name} ${target}` : c.name);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return calls;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Describe tool_result blocks in a stream-json event as short outcome one-liners
|
|
299
|
+
* (`result ok <id8>` / `result error <id8>`). Scans message.content for
|
|
300
|
+
* {type:'tool_result', tool_use_id, is_error?}. Returns [] when `raw` is a string
|
|
301
|
+
* (non-JSON runner line) or carries no tool_result blocks (assistant turns, the
|
|
302
|
+
* init event), so the caller adds no line. The 8-char tool_use_id prefix matches
|
|
303
|
+
* worca's contract and is enough to correlate a result with its call within one
|
|
304
|
+
* turn. Mirrors describeToolUses: the `← ` arrow prefix is added by the caller.
|
|
305
|
+
*/
|
|
306
|
+
function describeToolResults(raw) {
|
|
307
|
+
const content = raw?.message?.content;
|
|
308
|
+
if (!Array.isArray(content)) return [];
|
|
309
|
+
const lines = [];
|
|
310
|
+
for (const b of content) {
|
|
311
|
+
if (b?.type !== 'tool_result') continue;
|
|
312
|
+
const id = typeof b.tool_use_id === 'string' ? b.tool_use_id.slice(0, 8) : '?';
|
|
313
|
+
lines.push(`result ${b.is_error ? 'error' : 'ok'} ${id}`);
|
|
314
|
+
}
|
|
315
|
+
return lines;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** A short, human-readable target for a tool call (file, command, pattern…). */
|
|
319
|
+
function toolTarget(name, input, projectDir) {
|
|
320
|
+
if (!input || typeof input !== 'object') return '';
|
|
321
|
+
switch (name) {
|
|
322
|
+
case 'Read':
|
|
323
|
+
case 'Write':
|
|
324
|
+
case 'Edit':
|
|
325
|
+
case 'MultiEdit':
|
|
326
|
+
case 'NotebookEdit':
|
|
327
|
+
return rel(projectDir, input.file_path || input.path || input.notebook_path || '');
|
|
328
|
+
case 'Bash':
|
|
329
|
+
return clip(input.command, 80);
|
|
330
|
+
case 'Grep':
|
|
331
|
+
return input.pattern
|
|
332
|
+
? `"${input.pattern}"${input.path ? ' ' + rel(projectDir, input.path) : ''}`
|
|
333
|
+
: '';
|
|
334
|
+
case 'Glob':
|
|
335
|
+
return input.pattern || '';
|
|
336
|
+
case 'Task':
|
|
337
|
+
case 'Agent':
|
|
338
|
+
return clip(input.description || input.prompt, 60);
|
|
339
|
+
case 'WebFetch':
|
|
340
|
+
case 'WebSearch':
|
|
341
|
+
return clip(input.url || input.query, 60);
|
|
342
|
+
default:
|
|
343
|
+
return '';
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── Skill / MCP-tool capture (for the Sub-agents dropdown pills) ──────────────
|
|
348
|
+
// Pills surface ONLY named skills (the Skill tool) and MCP server tools
|
|
349
|
+
// (mcp__<server>__<tool>). Core file/bash/search/web tools and the sub-agent
|
|
350
|
+
// spawn tools (Task/Agent) are NOT skills. Labels are kind-tagged strings —
|
|
351
|
+
// "skill:<name>" / "mcp:<server>:<tool>" (or "mcp:<server>" when a name carries
|
|
352
|
+
// no tool token) — so the set dedups cleanly and the UI styles the kinds without
|
|
353
|
+
// a second field. Capped per agent, and the cap is SURFACED (see mergeSkills).
|
|
354
|
+
//
|
|
355
|
+
// §7.1: 64, raised from 24 because per-tool granularity multiplies distinct
|
|
356
|
+
// labels (one MCP server can contribute a dozen tools to one agent).
|
|
357
|
+
export const SKILLS_MAX = 64;
|
|
358
|
+
|
|
359
|
+
/** The overflow SENTINEL that makes the cap visible instead of silent: an
|
|
360
|
+
* `overflow:<n>` entry rides inside the same V6 `skills` array (zero schema
|
|
361
|
+
* change; to storage it is one more opaque string) and the UI renders it as a
|
|
362
|
+
* muted `+N more` pill. Because it rides the array it re-enters mergeSkills as
|
|
363
|
+
* part of `existing` on every later merge, so the merge is sentinel-aware. */
|
|
364
|
+
const OVERFLOW_RE = /^overflow:(\d+)$/;
|
|
365
|
+
|
|
366
|
+
/** Display server token for an MCP tool name `mcp__<server>__<tool>`: strip a
|
|
367
|
+
* leading `plugin_`, then collapse consecutive duplicate words. */
|
|
368
|
+
function mcpServerLabel(name) {
|
|
369
|
+
const parts = String(name).split('__');
|
|
370
|
+
let server = (parts[1] || '').trim();
|
|
371
|
+
if (!server) return '';
|
|
372
|
+
server = server.replace(/^plugin_/, '');
|
|
373
|
+
const words = server.split('_').filter(Boolean);
|
|
374
|
+
const collapsed = words.filter((w, i) => w !== words[i - 1]); // playwright_playwright -> playwright
|
|
375
|
+
return collapsed.join('_') || server;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Kind-tagged pill label for ONE tool_use block, or '' if it is not a skill /
|
|
379
|
+
* MCP tool. The Skill slug key is read defensively (the one stream-json detail
|
|
380
|
+
* not pinned by a fixture). */
|
|
381
|
+
export function skillLabel(name, input) {
|
|
382
|
+
if (typeof name !== 'string') return '';
|
|
383
|
+
if (name === 'Skill') {
|
|
384
|
+
const raw = input && typeof input === 'object'
|
|
385
|
+
? (input.skill ?? input.name ?? input.command ?? input.skill_name) : '';
|
|
386
|
+
const slug = typeof raw === 'string' ? raw.trim() : '';
|
|
387
|
+
return slug ? `skill:${slug}` : '';
|
|
388
|
+
}
|
|
389
|
+
if (name.startsWith('mcp__')) {
|
|
390
|
+
// §7.1: keep the TOOL token — `mcp__<server>__<tool>` -> `mcp:<server>:<tool>`.
|
|
391
|
+
// A tool token may itself contain `__`, so rejoin everything past the server
|
|
392
|
+
// (`mcp__srv__deep__nested` -> tool `deep__nested`). §5.5's `__`-normalization
|
|
393
|
+
// is what keeps the server segment unambiguous for every merged server.
|
|
394
|
+
const server = mcpServerLabel(name);
|
|
395
|
+
if (!server) return '';
|
|
396
|
+
const tool = name.split('__').slice(2).join('__');
|
|
397
|
+
return tool ? `mcp:${server}:${tool}` : `mcp:${server}`; // legacy shape when no tool token
|
|
398
|
+
}
|
|
399
|
+
return ''; // Read/Write/Edit/Bash/Grep/Glob/Task/Agent/WebFetch/WebSearch/… excluded
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** All kind-tagged skill labels in ONE stream-json envelope (deduped within the
|
|
403
|
+
* turn, order-preserving). */
|
|
404
|
+
function extractSkillLabels(raw) {
|
|
405
|
+
const content = raw?.message?.content;
|
|
406
|
+
if (!Array.isArray(content)) return [];
|
|
407
|
+
const out = [];
|
|
408
|
+
const seen = new Set();
|
|
409
|
+
for (const c of content) {
|
|
410
|
+
if (c?.type !== 'tool_use') continue;
|
|
411
|
+
const label = skillLabel(c.name, c.input);
|
|
412
|
+
if (label && !seen.has(label)) { seen.add(label); out.push(label); }
|
|
413
|
+
}
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ── graphify CLI-invocation counter ──────────────────────────────────────────
|
|
418
|
+
// Counts how many times a Bash command INVOKES the `graphify` CLI, as opposed to
|
|
419
|
+
// merely mentioning the word (reading graphify-out/, grepping for "graphify", rm
|
|
420
|
+
// graphify-out). Match `graphify` only at a COMMAND position: string start, after a
|
|
421
|
+
// shell separator (; | & && || newline or subshell `(`), or after leading VAR=val
|
|
422
|
+
// env assignments — optionally path-prefixed (~/.local/bin/graphify) — and followed
|
|
423
|
+
// by whitespace or end-of-string, so `graphify-out` (next char `-`) never matches.
|
|
424
|
+
// Known gaps (rare; documented, not counted): `npx graphify`, `python -m graphify`,
|
|
425
|
+
// `sh -c "graphify …"` — graphify there is an argument, not the command word.
|
|
426
|
+
const GRAPHIFY_CMD_RE = /(?:^|[;&|\n(]|&&|\|\|)\s*(?:\w+=\S+\s+)*(?:[^\s;&|()]*\/)?graphify(?=\s|$)/g;
|
|
427
|
+
|
|
428
|
+
/** How many graphify CLI invocations the Bash tool_use blocks of ONE stream-json
|
|
429
|
+
* envelope contain (0 when none / not a tool turn). Pure + module-scoped. */
|
|
430
|
+
function countGraphifyBashCalls(raw) {
|
|
431
|
+
const content = raw?.message?.content;
|
|
432
|
+
if (!Array.isArray(content)) return 0;
|
|
433
|
+
let n = 0;
|
|
434
|
+
for (const c of content) {
|
|
435
|
+
if (c?.type !== 'tool_use' || c.name !== 'Bash') continue;
|
|
436
|
+
const cmd = c.input?.command;
|
|
437
|
+
if (typeof cmd !== 'string') continue;
|
|
438
|
+
const m = cmd.match(GRAPHIFY_CMD_RE);
|
|
439
|
+
if (m) n += m.length;
|
|
440
|
+
}
|
|
441
|
+
return n;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Union `incoming` into `existing` (order-preserving, deduped, capped) with the
|
|
446
|
+
* §7.1 overflow-sentinel semantics, so the cap is a SURFACED truncation and not a
|
|
447
|
+
* silent gap. Returns the NEW array when it grew, else null (caller skips
|
|
448
|
+
* persist/emit — unchanged contract).
|
|
449
|
+
*
|
|
450
|
+
* 1. STRIP every `overflow:<n>` from `existing`, remembering the largest `n` as a
|
|
451
|
+
* monotonic floor. Sentinels arriving in `incoming` (a snapshot-rebuild merge
|
|
452
|
+
* of two persisted arrays) are likewise never labels: skipped in the union,
|
|
453
|
+
* their `n` folded into the same floor.
|
|
454
|
+
* 2. UNION real labels, capping at SKILLS_MAX counting REAL labels only — the
|
|
455
|
+
* sentinel never consumes a cap slot. Count the DISTINCT incoming labels the
|
|
456
|
+
* cap rejected this merge.
|
|
457
|
+
* 3. overflow = floor + rejected; when > 0 append EXACTLY ONE sentinel, LAST.
|
|
458
|
+
* 4. "Grew" = more real labels than before, OR a larger overflow count.
|
|
459
|
+
*/
|
|
460
|
+
export function mergeSkills(existing, incoming) {
|
|
461
|
+
const inc = Array.isArray(incoming) ? incoming : [];
|
|
462
|
+
if (!inc.length) return null;
|
|
463
|
+
const base = Array.isArray(existing) ? existing : [];
|
|
464
|
+
|
|
465
|
+
// (1) Strip `existing`'s sentinels; its largest n is the floor to carry forward.
|
|
466
|
+
const real = [];
|
|
467
|
+
let wasOverflow = 0; // what `existing` itself recorded (the growth baseline)
|
|
468
|
+
for (const x of base) {
|
|
469
|
+
const m = OVERFLOW_RE.exec(String(x));
|
|
470
|
+
if (m) { wasOverflow = Math.max(wasOverflow, Number(m[1])); continue; }
|
|
471
|
+
real.push(x);
|
|
472
|
+
}
|
|
473
|
+
let floor = wasOverflow;
|
|
474
|
+
|
|
475
|
+
// (2) Union real labels; the cap counts real labels only.
|
|
476
|
+
const seen = new Set(real);
|
|
477
|
+
const rejected = new Set();
|
|
478
|
+
const out = real.slice();
|
|
479
|
+
for (const x of inc) {
|
|
480
|
+
const m = OVERFLOW_RE.exec(String(x));
|
|
481
|
+
if (m) { floor = Math.max(floor, Number(m[1])); continue; } // a sentinel, never a label
|
|
482
|
+
if (seen.has(x) || rejected.has(x)) continue; // dedup, incl. repeated rejects
|
|
483
|
+
if (out.length >= SKILLS_MAX) { rejected.add(x); continue; }
|
|
484
|
+
seen.add(x); out.push(x);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// (3) Exactly one sentinel, always last.
|
|
488
|
+
const realAfter = out.length;
|
|
489
|
+
const overflow = floor + rejected.size;
|
|
490
|
+
if (overflow > 0) out.push(`overflow:${overflow}`);
|
|
491
|
+
|
|
492
|
+
// (4) Growth is either a new real label or a risen overflow count.
|
|
493
|
+
return (realAfter > real.length || overflow > wasOverflow) ? out : null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** clip(), but keeping HEAD and TAIL with an ellipsis between when over budget.
|
|
497
|
+
* For runner exit details the frame ("claude exited with code N") leads and
|
|
498
|
+
* the terminal cause sits at the END — the runner tail-caps for that reason —
|
|
499
|
+
* so a head-only clip discards exactly the cause. Tail gets the larger share. */
|
|
500
|
+
export function clipMiddle(text, n) {
|
|
501
|
+
if (!text) return '';
|
|
502
|
+
const s = String(text).replace(/\s+/g, ' ').trim();
|
|
503
|
+
if (s.length <= n) return s;
|
|
504
|
+
const head = Math.floor((n - 1) / 3);
|
|
505
|
+
return s.slice(0, head) + '…' + s.slice(-(n - 1 - head));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Normalize an answer payload from answer()/auto into [{id, choice}].
|
|
510
|
+
* Accepts { answers:[{id,choice}] } or a bare array. Fills any missing
|
|
511
|
+
* questions with their first option so downstream never sees gaps.
|
|
512
|
+
*/
|
|
513
|
+
export function normalizeClarifyAnswer(payload, questions) {
|
|
514
|
+
const arr = Array.isArray(payload?.answers)
|
|
515
|
+
? payload.answers
|
|
516
|
+
: Array.isArray(payload)
|
|
517
|
+
? payload
|
|
518
|
+
: [];
|
|
519
|
+
const byId = new Map();
|
|
520
|
+
for (const a of arr) {
|
|
521
|
+
if (a && a.id != null) byId.set(String(a.id), String(a.choice ?? ''));
|
|
522
|
+
}
|
|
523
|
+
return (questions || []).map((q) => ({
|
|
524
|
+
id: q.id,
|
|
525
|
+
choice: byId.has(q.id)
|
|
526
|
+
? byId.get(q.id)
|
|
527
|
+
: (q.options && q.options.find((o) => o && o.trim())) || '',
|
|
528
|
+
}));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export class RunHarness extends EventEmitter {
|
|
532
|
+
constructor(opts) {
|
|
533
|
+
super();
|
|
534
|
+
this.opts = opts || {};
|
|
535
|
+
|
|
536
|
+
// ── Workspace mode (opt-in; absent => single-project, every path unchanged) ──
|
|
537
|
+
// A workspace run targets 2+ member projects (sorted by projectKey). The scalar
|
|
538
|
+
// projectDir/workDir below point at the PRIMARY (members[0]) so every existing
|
|
539
|
+
// call site that reads them keeps working; per-project data lives in the maps.
|
|
540
|
+
this.workspace = this.opts.workspace || null;
|
|
541
|
+
this.isWorkspace = !!this.workspace;
|
|
542
|
+
this.workspaceKey = this.workspace?.key || null;
|
|
543
|
+
// Single-project runs synthesize a ONE-element member array so every
|
|
544
|
+
// downstream map (workDirs / branchInfos / checkpointRefs / state.branches) has
|
|
545
|
+
// exactly one shape in both modes. projectKey is worktree-location-independent
|
|
546
|
+
// and falls back to the resolved path for a non-git dir (store.mjs), so it is
|
|
547
|
+
// the same value the first _persist already derives — no new identity. The
|
|
548
|
+
// synthesized projectName is pinned to basename(resolve(projectDir)) so it can
|
|
549
|
+
// never leak `undefined` into a branch slug.
|
|
550
|
+
this.members = Array.isArray(this.workspace?.projects)
|
|
551
|
+
? this.workspace.projects
|
|
552
|
+
.slice()
|
|
553
|
+
.sort((a, b) => (a.projectKey < b.projectKey ? -1 : a.projectKey > b.projectKey ? 1 : 0))
|
|
554
|
+
: (() => {
|
|
555
|
+
const dir = resolve(this.opts.projectDir || process.cwd());
|
|
556
|
+
return [{ projectKey: projectKey(dir), projectName: basename(dir), projectDir: dir }];
|
|
557
|
+
})();
|
|
558
|
+
this.memberByKey = new Map(this.members.map((m) => [m.projectKey, m]));
|
|
559
|
+
this.workDirs = new Map(); // projectKey -> worktree checkout dir
|
|
560
|
+
this.checkpointRefs = {}; // projectKey -> pre-run commit
|
|
561
|
+
this.branchInfos = new Map(); // projectKey -> createWorktree() result
|
|
562
|
+
this.toolInstructions = new Map(); // projectKey -> per-project graph instruction
|
|
563
|
+
this.workspaceDescription = ''; // frozen at run start (after createPipeline)
|
|
564
|
+
|
|
565
|
+
// primaryCwd: the lowest-projectKey member in workspace mode, else the scalar
|
|
566
|
+
// projectDir. resolve() keeps the single-project behavior byte-identical.
|
|
567
|
+
this.projectDir = this.isWorkspace
|
|
568
|
+
? resolve(this.members[0].projectDir)
|
|
569
|
+
: resolve(this.opts.projectDir || process.cwd());
|
|
570
|
+
this.claude = {
|
|
571
|
+
bin: this.opts.claude?.bin,
|
|
572
|
+
permissionMode: this.opts.claude?.permissionMode || 'acceptEdits',
|
|
573
|
+
model: this.opts.claude?.model,
|
|
574
|
+
mock: !!this.opts.claude?.mock,
|
|
575
|
+
};
|
|
576
|
+
// The mock runner routes EVERY dontAsk spawn to the Ask Worca mock (claude-runner.mjs
|
|
577
|
+
// runMock, rule R-F), so a mock pipeline role under dontAsk writes no artifact and
|
|
578
|
+
// the run dies at its first artifact read with no hint why. Fail at construction
|
|
579
|
+
// instead (review of PR #376). WORCA_MOCK counts: the runner honours the env too.
|
|
580
|
+
if (this.claude.permissionMode === 'dontAsk'
|
|
581
|
+
&& (this.claude.mock || /^(1|true|yes|on)$/i.test(String(process.env.WORCA_MOCK ?? process.env.ORCH_MOCK ?? '')))) {
|
|
582
|
+
throw new Error('permissionMode "dontAsk" is reserved for the Ask Worca runner in mock mode — a mock pipeline role spawned with it would take the ask mock and write no artifact');
|
|
583
|
+
}
|
|
584
|
+
this.agentsDir = this.opts.agentsDir || DEFAULT_AGENTS_DIR;
|
|
585
|
+
this.auto = !!this.opts.auto;
|
|
586
|
+
this.stepModels = null; // { planner:{model,effort}, refiner:{...}, ... } | null until run()
|
|
587
|
+
// Guardrails: resolved by _resolveGuardrails() from run() AND resume(); null
|
|
588
|
+
// until then, so dispatcher tests that bypass run() get claudeOpts without
|
|
589
|
+
// the fields (legacy parity).
|
|
590
|
+
this.guardrails = null;
|
|
591
|
+
this.guardrailPermissionRules = null;
|
|
592
|
+
this.guardrailHonorByKey = null;
|
|
593
|
+
// Which saved workflow topology to run (default reproduces today's pipeline) and
|
|
594
|
+
// the runner registry the dispatcher consults (overridable for tests).
|
|
595
|
+
this.workflowId = this.opts.workflowId || 'wf_default';
|
|
596
|
+
// Which guardrail set governs this run (guardrails are selected PER RUN;
|
|
597
|
+
// there is no per-project guardrails dimension). 'permissive' = the empty
|
|
598
|
+
// policy = byte-identical legacy spawn, so callers that never pass the
|
|
599
|
+
// option (CLI, tests, pre-picker API bodies) keep today's behavior exactly.
|
|
600
|
+
this.guardrailsId = this.opts.guardrailsId || 'permissive';
|
|
601
|
+
// Engine hook: the v1 runner registry (see Orchestrator._initRunners).
|
|
602
|
+
this._initRunners(this.opts);
|
|
603
|
+
|
|
604
|
+
// Worktree isolation: workDir is the per-pipeline checkout. Until
|
|
605
|
+
// _setupRunRoot() runs, it mirrors projectDir so the existing tests/paths
|
|
606
|
+
// (dispatcher tests that bypass run()) behave identically.
|
|
607
|
+
this.workDir = this.projectDir;
|
|
608
|
+
this.branchOpts = {
|
|
609
|
+
source: (this.opts.branch && this.opts.branch.source) || null,
|
|
610
|
+
feature: (this.opts.branch && this.opts.branch.feature) || null,
|
|
611
|
+
};
|
|
612
|
+
this.branchInfo = null;
|
|
613
|
+
// ── Run root (§5.2). All three are assigned in _setupRunRoot() (or rehydrated
|
|
614
|
+
// by resume() from the RECORDED mode, never the live flag). Under `legacy`
|
|
615
|
+
// runRoot stays null and runCwd is the worktree, so every legacy path is
|
|
616
|
+
// byte-identical to today. workDir keeps its name but is now only "the single
|
|
617
|
+
// project's worktree, or the primary's, for back-compat readers".
|
|
618
|
+
this.runRoot = null;
|
|
619
|
+
this.runCwd = null;
|
|
620
|
+
this.runRootMode = null;
|
|
621
|
+
// §8.8 exclusion set: { <projectKey>|'runRoot': [{ path, source, kind }] }.
|
|
622
|
+
// Permanently {} under legacy; on a detached run Phase 3 fills it from
|
|
623
|
+
// assembleRunContext and rehydrates it from run.json on resume.
|
|
624
|
+
this.injectedPaths = {};
|
|
625
|
+
// §5.4-§5.6 generated context. All three stay null/[] under legacy, which is
|
|
626
|
+
// what keeps every legacy spawn argv byte-identical (§10 rollback contract).
|
|
627
|
+
this.runContext = null;
|
|
628
|
+
this.mcpConfigPath = null; // <runRoot>/mcp.json -> --mcp-config
|
|
629
|
+
this.mcpServerGrants = []; // `mcp__<server>` per merged server (V1 branch (a))
|
|
630
|
+
|
|
631
|
+
this.abort = new AbortController();
|
|
632
|
+
this.pauseRequested = false;
|
|
633
|
+
this.pauseAbort = new AbortController(); // aborts ONLY node children on pause
|
|
634
|
+
this.pauseReason = null; // set when a session/usage limit forces the pause
|
|
635
|
+
this._pauseGate = null; // gate context snapshot when paused at a gate
|
|
636
|
+
this._resumeNodeSessions = null; // nodeId -> sessionId map, set by resume() (Task 5)
|
|
637
|
+
this.resumeOpts = this.opts.resume || null; // { row, resumePoint, steps } from readPipelineForResume
|
|
638
|
+
this.pendingQuestion = null; // { id, resolve, reject, kind }
|
|
639
|
+
this._recovery = null; // class -> in-flight Promise<'retry'|'abort'> (same-class dedupe)
|
|
640
|
+
this._askTail = null; // serializes _ask: ONE prompt open at a time (recovery + step questions)
|
|
641
|
+
this._recoverySeq = 0; // monotonic id source for recovery prompts (determinism-safe)
|
|
642
|
+
this.agentPrompts = null;
|
|
643
|
+
this.toolInstruction = '';
|
|
644
|
+
// Cap for the in-worktree graphify build (macOS has no timeout(1)).
|
|
645
|
+
// Resolution order: constructor option → WORCA_GRAPH_TIMEOUT_MS env → 120s.
|
|
646
|
+
const _gt = Number(this.opts.graphBuildTimeoutMs ?? process.env.WORCA_GRAPH_TIMEOUT_MS);
|
|
647
|
+
this.graphBuildTimeoutMs = Number.isFinite(_gt) && _gt > 0 ? _gt : 120000;
|
|
648
|
+
this.checkpointRef = null;
|
|
649
|
+
this.registry = null; // ▲ v3: set in run(); used by _dispatch's D4 validation
|
|
650
|
+
this.extrasFiles = []; // attached files copied into <pipeline>/extras (set in _dispatch)
|
|
651
|
+
this.pipeline = null; // { id, dir, promptText }
|
|
652
|
+
this.logWriter = createRunLogWriter(); // buffered NDJSON persistence of the `log` stream
|
|
653
|
+
this.baseName = null;
|
|
654
|
+
this.planDatePrefix = null; // DD-MM-YY captured once so -vN versions share it
|
|
655
|
+
|
|
656
|
+
// Sub-agent live-log labels: parent_tool_use_id -> label shown after "▸".
|
|
657
|
+
// Tool-use ids are unique per claude process, so entries never collide across
|
|
658
|
+
// runs/cycles; bounded by the number of sub-agents in a pipeline, so no reset.
|
|
659
|
+
this._subAgentLabels = new Map();
|
|
660
|
+
// Monotonic ordinal for sub-agents whose Task description was never captured,
|
|
661
|
+
// so their fallback tag (sub-agent-N) is an honest "Nth undescribed sub-agent",
|
|
662
|
+
// independent of how many described sub-agents share the map.
|
|
663
|
+
this._subAgentFallbackSeq = 0;
|
|
664
|
+
|
|
665
|
+
this.state = {
|
|
666
|
+
id: this.opts.pipelineId || null,
|
|
667
|
+
title: this.opts.title || null,
|
|
668
|
+
projectDir: this.projectDir,
|
|
669
|
+
status: 'idle',
|
|
670
|
+
phase: 'idle',
|
|
671
|
+
cycle: 0,
|
|
672
|
+
startedAt: null,
|
|
673
|
+
updatedAt: null,
|
|
674
|
+
steps: [],
|
|
675
|
+
stepper: null, // UI stepper manifest, snapshotted at run start (Task 2)
|
|
676
|
+
tools: null,
|
|
677
|
+
checkpointRef: null,
|
|
678
|
+
pipelineDir: null,
|
|
679
|
+
totalCostUsd: 0, // cumulative actual spend (sum of steps[].costUsd)
|
|
680
|
+
totalActiveMs: 0, // cumulative active processing time (sum of steps[].activeMs)
|
|
681
|
+
branch: null, // { source, feature, worktreeDir, reusedExisting } after _setupRunRoot
|
|
682
|
+
// Per-member maps, initialized HERE (not lazily) so getState()'s snapshot
|
|
683
|
+
// shape is stable across modes and targets. Without this a single-project
|
|
684
|
+
// detached run throws TypeError on the first this.state.branches[key] = … .
|
|
685
|
+
branches: {},
|
|
686
|
+
checkpointRefs: {},
|
|
687
|
+
// Sub-agent lifecycle records (rides the existing `state` snapshot; mirrored to
|
|
688
|
+
// the sub_agents table). Each: { id, label, nodeId, stepIndex, cycle, stepKey,
|
|
689
|
+
// status, startedAt, finishedAt, durationMs?, tokens?, costUsd? };
|
|
690
|
+
// status ∈ 'running'|'finished'|'error'|'stopped'.
|
|
691
|
+
subAgents: [],
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** @returns {object} a deep-ish snapshot of current state. */
|
|
696
|
+
getState() {
|
|
697
|
+
return JSON.parse(JSON.stringify(this.state));
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Resolve a pending question.
|
|
702
|
+
* @param {string} id
|
|
703
|
+
* @param {object} payload clarify: {answers:[{id,choice}]} ; gate: {decision}
|
|
704
|
+
*/
|
|
705
|
+
answer(id, payload) {
|
|
706
|
+
const pq = this.pendingQuestion;
|
|
707
|
+
if (!pq || pq.id !== id) {
|
|
708
|
+
this._log('orchestrator', 'warn', `answer() ignored: no pending question with id ${id}`);
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
this.pendingQuestion = null;
|
|
712
|
+
pq.resolve(payload);
|
|
713
|
+
return true;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Abort the run; marks state stopped and kills any child via the signal. */
|
|
717
|
+
stop() {
|
|
718
|
+
if (this.state.status === 'done' || this.state.status === 'stopped') return;
|
|
719
|
+
this._setStatus('stopped');
|
|
720
|
+
try {
|
|
721
|
+
this.abort.abort();
|
|
722
|
+
} catch {
|
|
723
|
+
/* ignore */
|
|
724
|
+
}
|
|
725
|
+
// Unblock any awaiting question.
|
|
726
|
+
if (this.pendingQuestion) {
|
|
727
|
+
const pq = this.pendingQuestion;
|
|
728
|
+
this.pendingQuestion = null;
|
|
729
|
+
const err = new Error('stopped');
|
|
730
|
+
err.name = 'AbortError';
|
|
731
|
+
pq.reject(err);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Gracefully pause the run: kill in-flight node children (SIGTERM via the
|
|
737
|
+
* pause-only signal), unwind _dispatch, persist a resume point. The worktree is
|
|
738
|
+
* kept. Returns false unless the run is currently 'running'.
|
|
739
|
+
*/
|
|
740
|
+
pause() {
|
|
741
|
+
if (this.state.status !== 'running') return false;
|
|
742
|
+
this.pauseRequested = true;
|
|
743
|
+
this._setStatus('pausing');
|
|
744
|
+
try {
|
|
745
|
+
this.pauseAbort.abort();
|
|
746
|
+
} catch {
|
|
747
|
+
/* ignore */
|
|
748
|
+
}
|
|
749
|
+
// Unblock any awaiting clarify/gate question with the pause sentinel.
|
|
750
|
+
if (this.pendingQuestion) {
|
|
751
|
+
const pq = this.pendingQuestion;
|
|
752
|
+
this.pendingQuestion = null;
|
|
753
|
+
pq.reject(pauseErr());
|
|
754
|
+
}
|
|
755
|
+
return true;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
_checkPause() {
|
|
759
|
+
if (this.pauseRequested) throw pauseErr();
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Execute the full pipeline. Resolves with { status, pipelineDir } on success
|
|
764
|
+
* or stop; rejects only on unexpected internal errors (it emits 'error' too).
|
|
765
|
+
*/
|
|
766
|
+
async run() {
|
|
767
|
+
try {
|
|
768
|
+
this.state.startedAt = new Date().toISOString();
|
|
769
|
+
this._setStatus('running');
|
|
770
|
+
|
|
771
|
+
// Resolve the workflow topology + per-node run-config and snapshot the UI
|
|
772
|
+
// stepper manifest BEFORE any blocking work (preflight/clarify). It depends
|
|
773
|
+
// only on workflowId + run-config + registry — none of clarify's output — so
|
|
774
|
+
// Running/History render the right nodes (and per-node model·effort) at once
|
|
775
|
+
// instead of the legacy default until clarify ends. resolveWorkflow reads
|
|
776
|
+
// projectDir (NOT the pipeline dir, which doesn't exist yet), so this is safe
|
|
777
|
+
// here. pipelineDir is null in this first event; it is persisted + re-emitted
|
|
778
|
+
// after createPipeline below.
|
|
779
|
+
const registry = loadAgentRegistry(this.agentsDir);
|
|
780
|
+
this.registry = registry; // ▲ v3: expose for run-start workflow validation (D4)
|
|
781
|
+
// Engine hook: resolve the run topology. v1 = resolveWorkflow + workspace
|
|
782
|
+
// fan-out forcing + the v1 stepper manifest; v2 = resolveGraph +
|
|
783
|
+
// buildGraphManifest. It yields the manifest the UI renders, the agent-key
|
|
784
|
+
// set the preflight and skills gates walk, and the workflow's id/name.
|
|
785
|
+
const topology = await this._resolveTopology(registry);
|
|
786
|
+
if (!topology?.manifest || !topology.agentKeys || !topology.workflow?.id) throw new Error('engine hook contract: _resolveTopology must return { manifest, agentKeys, workflow:{id,name} }');
|
|
787
|
+
// §9.4: hard-fail BEFORE the stepper is STAMPED / createPipeline / worktree
|
|
788
|
+
// (the manifest is built inside the hook, which tolerates unknown keys) —
|
|
789
|
+
// a missing agent key must never reach dispatch as an empty-prompt node.
|
|
790
|
+
this._preflightAgentKeys(topology.agentKeys);
|
|
791
|
+
this.state.stepper = topology.manifest;
|
|
792
|
+
this._emit('state', this.getState());
|
|
793
|
+
|
|
794
|
+
// 1) Load agent prompts + preflight tool detection (parallel; both safe).
|
|
795
|
+
this._bookend('preflight', 'start');
|
|
796
|
+
const [agentPrompts, tools, stepModels] = await Promise.all([
|
|
797
|
+
this._loadAgentPrompts(),
|
|
798
|
+
detectTools(this.projectDir),
|
|
799
|
+
resolveStepModels(this.projectDir, this.claude.model), // never throws
|
|
800
|
+
]);
|
|
801
|
+
this.agentPrompts = agentPrompts;
|
|
802
|
+
this.toolInstruction = tools.instruction || '';
|
|
803
|
+
this.state.tools = tools;
|
|
804
|
+
this.stepModels = stepModels;
|
|
805
|
+
await this._resolveGuardrails();
|
|
806
|
+
this._log(
|
|
807
|
+
'preflight',
|
|
808
|
+
'info',
|
|
809
|
+
tools.tool
|
|
810
|
+
? `Detected tool: ${tools.tool}${tools.kind ? ` (${tools.kind})` : ''}`
|
|
811
|
+
: 'No knowledge-graph tooling detected',
|
|
812
|
+
);
|
|
813
|
+
|
|
814
|
+
// 2) Resolve the task input through the source seam (sources.mjs) and create
|
|
815
|
+
// the pipeline directory + audit. Absent opts.source the legacy prompt/
|
|
816
|
+
// promptFile opts are wrapped into the equivalent descriptor — same text
|
|
817
|
+
// precedence as createPipeline's old inline resolution (non-empty inline
|
|
818
|
+
// prompt wins, else file), so feature-off prompt.md bytes and row values are
|
|
819
|
+
// identical. On a workspace run the pipeline is written to the WORKSPACE
|
|
820
|
+
// store (artifactPaths routes by workspaceKey) — all owned by createPipeline.
|
|
821
|
+
const source = this.opts.source
|
|
822
|
+
|| (typeof this.opts.prompt === 'string' && this.opts.prompt
|
|
823
|
+
? { type: 'prompt', prompt: this.opts.prompt }
|
|
824
|
+
: this.opts.promptFile
|
|
825
|
+
? { type: 'markdown', promptFile: this.opts.promptFile }
|
|
826
|
+
: { type: 'prompt', prompt: '' });
|
|
827
|
+
const input = await resolveTaskInput(source, { projectDir: this.projectDir });
|
|
828
|
+
this.pipeline = await createPipeline(this.projectDir, {
|
|
829
|
+
promptText: input.promptText,
|
|
830
|
+
// ?? keeps the legacy both-set corner byte-identical: inline prompt wins the
|
|
831
|
+
// text, but a passed promptFile is STILL copied verbatim into prompt.md.
|
|
832
|
+
promptFile: input.promptFile ?? this.opts.promptFile,
|
|
833
|
+
sourceType: source.type,
|
|
834
|
+
sourceMeta: input.sourceMeta || null,
|
|
835
|
+
extras: this.opts.extras,
|
|
836
|
+
title: this.opts.title,
|
|
837
|
+
guardrailsId: this.guardrailsId,
|
|
838
|
+
...(this.isWorkspace ? {
|
|
839
|
+
workspaceKey: this.workspaceKey,
|
|
840
|
+
workspaceId: this.workspace.id,
|
|
841
|
+
workspaceName: this.workspace.name,
|
|
842
|
+
workspaceDescription: this.workspace.description || '',
|
|
843
|
+
projects: this.members.map((m) => ({
|
|
844
|
+
projectKey: m.projectKey,
|
|
845
|
+
projectDir: m.projectDir,
|
|
846
|
+
projectName: m.projectName,
|
|
847
|
+
})),
|
|
848
|
+
} : {}),
|
|
849
|
+
});
|
|
850
|
+
this.state.id = this.pipeline.id;
|
|
851
|
+
this.state.pipelineDir = this.pipeline.dir;
|
|
852
|
+
this.logWriter.bind(this.pipeline.dir); // start persisting (flushes buffered preflight lines)
|
|
853
|
+
recordArtifact(this.pipeline.id, RUN_LOG_KIND, RUN_LOG_FILE); // index like prompt.md (sync; INSERT OR IGNORE)
|
|
854
|
+
// A11(b): carry the resolved prompt on the in-memory state too (createPipeline
|
|
855
|
+
// already INSERTs prompt and the curated UPSERT excludes it, so persistence is
|
|
856
|
+
// safe — this keeps the live state object self-consistent for any reader).
|
|
857
|
+
this.state.prompt = this.pipeline.promptText;
|
|
858
|
+
// Same reasoning for the run's guardrail selection: createPipeline INSERTed
|
|
859
|
+
// guardrails_id and the curated UPSERT excludes it (creation-immutable), so
|
|
860
|
+
// mirroring it onto the live state only keeps rowToState round-trips honest.
|
|
861
|
+
this.state.guardrailsId = this.guardrailsId;
|
|
862
|
+
// Workspace: mirror the §5.2 superset onto the live state and FREEZE the
|
|
863
|
+
// description now (read from the pipeline's frozen state.json snapshot, never
|
|
864
|
+
// re-read from workspaces.json), so later registry edits never alter this run.
|
|
865
|
+
if (this.isWorkspace) {
|
|
866
|
+
// Freeze from the on-disk snapshot createPipeline wrote (the capped,
|
|
867
|
+
// point-in-time copy) — never re-read from workspaces.json mid-run.
|
|
868
|
+
this.workspaceDescription = await readFile(
|
|
869
|
+
join(this.pipeline.dir, 'workspace-description.md'), 'utf8',
|
|
870
|
+
).catch(() => this.workspace.description || '');
|
|
871
|
+
this.state.target = 'workspace';
|
|
872
|
+
this.state.workspaceId = this.workspace.id;
|
|
873
|
+
this.state.workspaceKey = this.workspaceKey;
|
|
874
|
+
this.state.workspaceName = this.workspace.name;
|
|
875
|
+
this.state.workspaceDescription = this.workspaceDescription;
|
|
876
|
+
this.state.projectKeys = this.members.map((m) => m.projectKey);
|
|
877
|
+
this.state.projects = this.members.map((m) => ({
|
|
878
|
+
projectKey: m.projectKey,
|
|
879
|
+
projectDir: resolve(m.projectDir),
|
|
880
|
+
projectName: m.projectName,
|
|
881
|
+
}));
|
|
882
|
+
this.state.checkpointRefs = {};
|
|
883
|
+
this.state.branches = {};
|
|
884
|
+
}
|
|
885
|
+
if (!this.state.title) this.state.title = basename(this.pipeline.dir);
|
|
886
|
+
// The title set above (firstMeaningfulLine(prompt) or the dir basename) is
|
|
887
|
+
// PROVISIONAL: shown instantly. Kick off the real LLM title without blocking
|
|
888
|
+
// run start. Skip on a resumed run — it already carries the previously-generated
|
|
889
|
+
// row.title (loaded by resume()). this.resumeOpts (= this.opts.resume) is the
|
|
890
|
+
// resume signal; resume() never reaches this run() site anyway (belt-and-suspenders).
|
|
891
|
+
// The kickoff itself fires AFTER _setupRunRoot() below, so generateTitle's cwd
|
|
892
|
+
// can be this.runCwd (§2.1 row 3) — at this point runCwd is still null.
|
|
893
|
+
this.state.titleProvisional = true;
|
|
894
|
+
this.baseName = this._deriveBaseName(this.pipeline.promptText, this.state.title);
|
|
895
|
+
// Capture the date prefix ONCE so every plan -vN and the review file share
|
|
896
|
+
// the v1 date even if the run crosses midnight.
|
|
897
|
+
this.planDatePrefix = today();
|
|
898
|
+
// Persist the plan/review name linkage so a later delete can find the shared
|
|
899
|
+
// markdown exactly (state.artifacts is not persisted; names are the only link).
|
|
900
|
+
this.state.baseName = this.baseName;
|
|
901
|
+
this.state.datePrefix = this.planDatePrefix;
|
|
902
|
+
await this._persist();
|
|
903
|
+
this._startHeartbeat(); // claim ownership + begin liveness heartbeat (crash detection)
|
|
904
|
+
this._artifact('pipeline', this.pipeline.dir);
|
|
905
|
+
await appendAudit(this.pipeline.dir, `Pipeline created (id ${this.pipeline.id}).`);
|
|
906
|
+
if (tools.tool) {
|
|
907
|
+
await appendAudit(
|
|
908
|
+
this.pipeline.dir,
|
|
909
|
+
`Preflight: using **${tools.tool}**${tools.kind ? ` (${tools.kind})` : ''}.`,
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// 3) Ensure a git repo + checkpoint commit (per member on a workspace run).
|
|
914
|
+
if (this.isWorkspace) await this._ensureGitCheckpointAll();
|
|
915
|
+
else await this._ensureGitCheckpoint();
|
|
916
|
+
this._bookend('preflight', 'done');
|
|
917
|
+
this._checkAbort();
|
|
918
|
+
|
|
919
|
+
// 3b) Set up the run root + the per-pipeline worktree(s). All subsequent
|
|
920
|
+
// claude spawns cwd into this.runCwd (the run root on a detached workspace
|
|
921
|
+
// run, else the primary's worktree); per-member fan-out sub-agents work in
|
|
922
|
+
// this.workDirs. Artifacts route via the workspace store.
|
|
923
|
+
await this._setupRunRoot();
|
|
924
|
+
// The provisional title (firstMeaningfulLine(prompt) or the dir basename) is
|
|
925
|
+
// shown instantly; kick off the real LLM title without blocking run start, now
|
|
926
|
+
// that runCwd exists so no worca-cc process is started inside the user's live
|
|
927
|
+
// checkout (§2.1 row 3). Skip on a resumed run — it already carries the
|
|
928
|
+
// previously-generated row.title (loaded by resume()). this.resumeOpts (=
|
|
929
|
+
// this.opts.resume) is the resume signal; resume() never reaches this run()
|
|
930
|
+
// site anyway (belt-and-suspenders).
|
|
931
|
+
if (!this.resumeOpts) this._kickoffTitleGeneration();
|
|
932
|
+
this._checkAbort();
|
|
933
|
+
|
|
934
|
+
// 3c) Build the knowledge graph INSIDE each worktree so agents can query it.
|
|
935
|
+
if (this.isWorkspace) await this._buildWorktreeGraphAll();
|
|
936
|
+
else await this._buildWorktreeGraph();
|
|
937
|
+
this._checkAbort();
|
|
938
|
+
|
|
939
|
+
// 3d) Resolve + validate declared agent skills (hard gate, UNCHANGED in
|
|
940
|
+
// semantics), then assemble the run context for EVERY detached run —
|
|
941
|
+
// including the zero-declared-skills case, which is every shipped
|
|
942
|
+
// workflow today (`grep requiresSkills agents/` → zero hits).
|
|
943
|
+
const requiredSkills = collectRequiredSkills(this.registry, topology.agentKeys);
|
|
944
|
+
let resolvedSkills = new Map(); // ← HOISTED; empty Map on the default workflow
|
|
945
|
+
if (requiredSkills.length) {
|
|
946
|
+
const skillCtx = { repoRoot: REPO_ROOT, projectDir: this.projectDir, pluginDirs: pluginSkillDirs() };
|
|
947
|
+
resolvedSkills = validateSkills(requiredSkills, skillCtx); // throws => caught => run ends 'error'
|
|
948
|
+
if (this.runRootMode !== 'detached') {
|
|
949
|
+
// LEGACY delivery, byte-identical to today: inject ONLY into real isolated
|
|
950
|
+
// worktrees, never the main projectDir, so a copy can never pollute the
|
|
951
|
+
// user's working tree.
|
|
952
|
+
const candidates = this.isWorkspace ? [...this.workDirs.values()] : [this.workDir];
|
|
953
|
+
const worktrees = candidates.filter((d) => d && d !== this.projectDir);
|
|
954
|
+
const injected = await injectSkills(resolvedSkills, { targets: worktrees });
|
|
955
|
+
if (injected.length) {
|
|
956
|
+
await appendAudit(
|
|
957
|
+
this.pipeline.dir,
|
|
958
|
+
`Skills: injected ${injected.join(', ')} into ${worktrees.length} worktree(s).`,
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
this._checkAbort();
|
|
964
|
+
|
|
965
|
+
// 3e) Context assembly — UNCONDITIONAL on detached runs. Gated ONLY on the
|
|
966
|
+
// recorded mode, NEVER on requiredSkills.length (nesting it back under that
|
|
967
|
+
// guard would silently void R1(a)-(d) and R2 on every default pipeline while
|
|
968
|
+
// leaving npm test and both mock smokes green), and NOT gated on mock either:
|
|
969
|
+
// it is pure fs work whose outputs the smokes assert. Under detached,
|
|
970
|
+
// bundle/plugin delivery happens inside assembleRunContext's mount (§5.6
|
|
971
|
+
// entry class 3), so the legacy injectSkills branch above is correctly skipped.
|
|
972
|
+
// The assembly also emits §8.21's per-member "project sub-agents are not
|
|
973
|
+
// discoverable at a run-root cwd" warning (run log + run.json.warnings) and the
|
|
974
|
+
// matching roster note in the generated CLAUDE.md — derived there, from each
|
|
975
|
+
// member's worktree, so resume's re-assembly reproduces all three carriers
|
|
976
|
+
// instead of dropping them when it rewrites `warnings`.
|
|
977
|
+
if (this.runRootMode === 'detached') {
|
|
978
|
+
await this._assembleContext(resolvedSkills);
|
|
979
|
+
}
|
|
980
|
+
this._checkAbort();
|
|
981
|
+
|
|
982
|
+
// 4) (Clarify now runs as the first graph node — see _runClarifyNode.)
|
|
983
|
+
|
|
984
|
+
// 5) Dispatch the resolved workflow (already snapshotted into state.stepper
|
|
985
|
+
// at run start). Persist now that this.pipeline exists, and re-emit the
|
|
986
|
+
// full state (with pipelineDir) for any client that connected mid-preflight.
|
|
987
|
+
await this._persist();
|
|
988
|
+
this._emit('state', this.getState());
|
|
989
|
+
await appendAudit(this.pipeline.dir, `Workflow: **${topology.workflow.name}** (${topology.workflow.id}).`);
|
|
990
|
+
const dispatched = await this._engineRun({ resume: null });
|
|
991
|
+
this._checkAbort();
|
|
992
|
+
if (dispatched === 'paused') return await this._completePaused();
|
|
993
|
+
|
|
994
|
+
// 9) Done.
|
|
995
|
+
this._setStatus('done');
|
|
996
|
+
this.state.resumePoint = null; // finished rows are not resumable (clears the boundary trail)
|
|
997
|
+
this._bookend('done', 'done');
|
|
998
|
+
await this._persist();
|
|
999
|
+
await appendAudit(this.pipeline.dir, `Pipeline finished with status **done**.`);
|
|
1000
|
+
await this._buildResults(); // refs + worktree still live here
|
|
1001
|
+
await this._reportToSource(); // task-source write-back (never throws, spec §7.5)
|
|
1002
|
+
this._emit('done', { status: 'done', pipelineDir: this.pipeline.dir });
|
|
1003
|
+
return { status: 'done', pipelineDir: this.pipeline.dir };
|
|
1004
|
+
} catch (err) {
|
|
1005
|
+
if ((isPause(err) || this.state.status === 'pausing') && this.state.status !== 'stopped') {
|
|
1006
|
+
if (this.pipeline) {
|
|
1007
|
+
if (!this.state.resumePoint) {
|
|
1008
|
+
// Paused before the engine started (preflight/worktree): the engine
|
|
1009
|
+
// decides what a pre-dispatch resume point looks like.
|
|
1010
|
+
this.state.resumePoint = this._enginePrePausePoint();
|
|
1011
|
+
}
|
|
1012
|
+
return await this._completePaused();
|
|
1013
|
+
}
|
|
1014
|
+
// No pipeline yet: nothing to resume; treat as stopped.
|
|
1015
|
+
this._setStatus('stopped');
|
|
1016
|
+
this._emit('done', { status: 'stopped', pipelineDir: null });
|
|
1017
|
+
return { status: 'stopped', pipelineDir: null };
|
|
1018
|
+
}
|
|
1019
|
+
if (isAbort(err) || this.state.status === 'stopped') {
|
|
1020
|
+
this._setStatus('stopped');
|
|
1021
|
+
// Stopped runs are not resumable: never persist a resume point (e.g. one
|
|
1022
|
+
// _dispatch assigned before stop won the race) alongside a torn-down worktree.
|
|
1023
|
+
this.state.resumePoint = null;
|
|
1024
|
+
if (this.pipeline) {
|
|
1025
|
+
await this._persist().catch(() => {});
|
|
1026
|
+
await appendAudit(this.pipeline.dir, `Pipeline **stopped**.`).catch(() => {});
|
|
1027
|
+
// The diff artifact must survive a non-done terminal path too: the work done
|
|
1028
|
+
// up to this point IS committed onto the kept feature branch by the teardown
|
|
1029
|
+
// in the finally below, so History has to be able to show it. Safe HERE and
|
|
1030
|
+
// only here — the checkpoint refs and the worktree are still live until that
|
|
1031
|
+
// teardown runs. Best-effort by construction (its own try/catch logs a warn
|
|
1032
|
+
// and never rethrows), and a no-op when the run stopped before any checkpoint
|
|
1033
|
+
// existed. The terminal `done` event is emitted AFTER it so the History row never
|
|
1034
|
+
// paints as "no diff captured" for the tick before the artifact lands.
|
|
1035
|
+
await this._buildResults({ stage: true });
|
|
1036
|
+
await this._reportToSource(); // statusToResult('stopped') -> 'failed' (design PR12: no longer success-only)
|
|
1037
|
+
}
|
|
1038
|
+
this._emit('done', {
|
|
1039
|
+
status: 'stopped',
|
|
1040
|
+
pipelineDir: this.pipeline?.dir || null,
|
|
1041
|
+
});
|
|
1042
|
+
return { status: 'stopped', pipelineDir: this.pipeline?.dir || null };
|
|
1043
|
+
}
|
|
1044
|
+
this._setStatus('error');
|
|
1045
|
+
const message = err?.message || String(err);
|
|
1046
|
+
this._emit('error', { message });
|
|
1047
|
+
if (this.pipeline) {
|
|
1048
|
+
await this._persist().catch(() => {});
|
|
1049
|
+
await appendAudit(this.pipeline.dir, `Pipeline **error**: ${message}`).catch(() => {});
|
|
1050
|
+
// The diff artifact must survive a non-done terminal path too: the work done
|
|
1051
|
+
// up to this point IS committed onto the kept feature branch by the teardown
|
|
1052
|
+
// in the finally below, so History has to be able to show it. Safe HERE and
|
|
1053
|
+
// only here — the checkpoint refs and the worktree are still live until that
|
|
1054
|
+
// teardown runs. Best-effort by construction (its own try/catch logs a warn
|
|
1055
|
+
// and never rethrows), and a no-op when the run stopped before any checkpoint
|
|
1056
|
+
// existed. The terminal `done` event is emitted AFTER it so the History row never
|
|
1057
|
+
// paints as "no diff captured" for the tick before the artifact lands.
|
|
1058
|
+
await this._buildResults({ stage: true });
|
|
1059
|
+
await this._reportToSource(); // statusToResult('error') -> 'failed' (design PR12: no longer success-only)
|
|
1060
|
+
}
|
|
1061
|
+
this._emit('done', {
|
|
1062
|
+
status: 'error',
|
|
1063
|
+
pipelineDir: this.pipeline?.dir || null,
|
|
1064
|
+
});
|
|
1065
|
+
return { status: 'error', pipelineDir: this.pipeline?.dir || null, error: message };
|
|
1066
|
+
} 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
|
|
1069
|
+
// always kept (every member's, on a workspace run), only the disposable checkout
|
|
1070
|
+
// is removed. But NEVER on a pause: the checkout (with any uncommitted agent
|
|
1071
|
+
// work) and the run root are the things we resume into (§8.13).
|
|
1072
|
+
if (this.state.status !== 'paused' && this.state.status !== 'pausing') {
|
|
1073
|
+
await this._teardownRunRoot().catch(() => {});
|
|
1074
|
+
}
|
|
1075
|
+
await this.logWriter.close().catch(() => {}); // flush + stop timer (last, to capture teardown logs)
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* Continue a paused pipeline from its persisted resume point. Mirrors run()'s
|
|
1081
|
+
* shell but skips createPipeline / checkpoint / worktree / graph setup — those
|
|
1082
|
+
* artifacts exist from the original run. Resolves like run().
|
|
1083
|
+
*/
|
|
1084
|
+
async resume() {
|
|
1085
|
+
const saved = this.resumeOpts;
|
|
1086
|
+
if (!saved?.row || !saved?.resumePoint) throw new Error('resume(): no saved pipeline provided');
|
|
1087
|
+
const { row, resumePoint: rp, steps } = saved;
|
|
1088
|
+
if (row.status !== 'paused' && row.status !== 'interrupted') {
|
|
1089
|
+
throw new Error(`resume(): pipeline is "${row.status}", not resumable`);
|
|
1090
|
+
}
|
|
1091
|
+
// Defense in depth: an archived run's worktree/run root were reclaimed, so
|
|
1092
|
+
// resuming it would rebuild nothing and write into a reaped tree.
|
|
1093
|
+
if (row.archived_at) throw new Error('resume(): pipeline is archived');
|
|
1094
|
+
// Engine hook: rejects a resume point that is not this engine's, and yields
|
|
1095
|
+
// the engine-specific fields the shell below rehydrates from. It runs at dev's
|
|
1096
|
+
// version-gate position: before any state is rehydrated and OUTSIDE the try,
|
|
1097
|
+
// so a throw rejects resume() without touching the row. Awaited so an engine
|
|
1098
|
+
// may be async; v1's synchronous return is awaited unchanged.
|
|
1099
|
+
const rehydrated = await this._engineRehydrate(rp);
|
|
1100
|
+
if (!rehydrated || typeof rehydrated.audit !== 'string' || !Array.isArray(rehydrated.memberWorktrees)) throw new Error('engine hook contract: _engineRehydrate must return { checkpointRef, memberWorktrees:[], audit }');
|
|
1101
|
+
try {
|
|
1102
|
+
// ── rehydrate identity + state ──
|
|
1103
|
+
this.state.id = row.id;
|
|
1104
|
+
this.state.title = row.title;
|
|
1105
|
+
this.state.startedAt = row.started_at;
|
|
1106
|
+
this.state.prompt = row.prompt;
|
|
1107
|
+
this.state.stepper = safeParse(row.stepper);
|
|
1108
|
+
this.state.tools = safeParse(row.tools);
|
|
1109
|
+
this.state.branch = safeParse(row.branch);
|
|
1110
|
+
this.state.steps = (steps || []).map((s) => ({ ...s, runningSince: null }));
|
|
1111
|
+
this.baseName = row.base_name;
|
|
1112
|
+
this.planDatePrefix = row.date_prefix;
|
|
1113
|
+
this.pipeline = { id: row.id, dir: rp.pipelineDir, promptText: row.prompt || '' };
|
|
1114
|
+
this.state.pipelineDir = rp.pipelineDir;
|
|
1115
|
+
this.logWriter.bind(rp.pipelineDir);
|
|
1116
|
+
recordArtifact(row.id, RUN_LOG_KIND, RUN_LOG_FILE);
|
|
1117
|
+
this.stepModels = rp.stepModels || null;
|
|
1118
|
+
this.workflowId = rp.workflowId || this.workflowId;
|
|
1119
|
+
// Rehydrate the run's selection BEFORE re-resolving so resume enforces the
|
|
1120
|
+
// LATEST saved set definition (missing set -> warn + Permissive, inside
|
|
1121
|
+
// _resolveGuardrails). Legacy resume points without the field fall back to
|
|
1122
|
+
// the constructor default ('permissive'). Keep state in sync for re-persist.
|
|
1123
|
+
this.guardrailsId = rp.guardrailsId || this.guardrailsId;
|
|
1124
|
+
this.state.guardrailsId = this.guardrailsId;
|
|
1125
|
+
await this._resolveGuardrails();
|
|
1126
|
+
// Restore the EFFECTIVE instruction from the resume point — by dispatch time
|
|
1127
|
+
// run() has replaced the detect-time tools.instruction with the in-worktree
|
|
1128
|
+
// graph-build outcome (worktreeGraphInstruction() or ''). Falling back to
|
|
1129
|
+
// tools.instruction would tell resumed agents a graph exists that the original
|
|
1130
|
+
// run suppressed. (Fallback keeps old-shape resume points working.)
|
|
1131
|
+
this.toolInstruction = typeof rp.toolInstruction === 'string' ? rp.toolInstruction : (this.state.tools?.instruction || '');
|
|
1132
|
+
|
|
1133
|
+
// ── run-root mode: read the RECORDED value, never the live flag (§10) ──
|
|
1134
|
+
// Single-project rides state.branch.runRootMode (the pipelines.branch JSON
|
|
1135
|
+
// column); workspace rides workspace_meta.runRootMode (real only because of the
|
|
1136
|
+
// artifacts.mjs whitelist fold). Absent ⇒ 'legacy', correct for every
|
|
1137
|
+
// pre-change row. A run can therefore never be resumed into a mode it was not
|
|
1138
|
+
// started in, no matter when the default flips or rolls back.
|
|
1139
|
+
const meta = safeParse(row.workspace_meta);
|
|
1140
|
+
const recordedMode = this.isWorkspace
|
|
1141
|
+
? (meta?.runRootMode || 'legacy')
|
|
1142
|
+
: (this.state.branch?.runRootMode || 'legacy');
|
|
1143
|
+
this.runRootMode = recordedMode === 'detached' ? 'detached' : 'legacy';
|
|
1144
|
+
// Re-stamp BEFORE the first persist so a resumed workspace run re-persists the
|
|
1145
|
+
// pin rather than dropping it (toPipelineRow reads it off state every persist).
|
|
1146
|
+
this.state.runRootMode = this.runRootMode;
|
|
1147
|
+
/** The persisted manifest, read once on a detached resume (re-assembly below). */
|
|
1148
|
+
let resumeManifest = null;
|
|
1149
|
+
if (this.runRootMode === 'detached') {
|
|
1150
|
+
this.runRoot = join(worcaHome(), 'runs', row.id);
|
|
1151
|
+
// Rehydrate the §8.8 injected set from the manifest FIRST, so teardown still
|
|
1152
|
+
// excludes/rescues/cleans even if re-assembly is skipped or degrades; the
|
|
1153
|
+
// re-assembly result then overwrites it.
|
|
1154
|
+
resumeManifest = await readRunManifest(this.runRoot);
|
|
1155
|
+
if (resumeManifest?.injectedPaths && typeof resumeManifest.injectedPaths === 'object') {
|
|
1156
|
+
this.injectedPaths = resumeManifest.injectedPaths;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// ── worktree re-attach (single-project; workspace below) ──
|
|
1161
|
+
const wt = this.state.branch?.worktreeDir;
|
|
1162
|
+
if (wt && !existsSync(wt)) throw new Error(`worktree missing: ${wt} — cannot resume`);
|
|
1163
|
+
if (wt) {
|
|
1164
|
+
this.workDir = wt;
|
|
1165
|
+
this.branchInfo = {
|
|
1166
|
+
worktreeDir: wt,
|
|
1167
|
+
branch: this.state.branch.feature,
|
|
1168
|
+
sourceBranch: this.state.branch.source,
|
|
1169
|
+
reusedExisting: true,
|
|
1170
|
+
};
|
|
1171
|
+
if (!this.isWorkspace) {
|
|
1172
|
+
// Unified shapes must hold on resume too: one workDirs entry + one
|
|
1173
|
+
// checkpointRefs entry, so _buildResults / _reposCtx / _teardownRunRoot
|
|
1174
|
+
// read the same shape they do on a fresh run.
|
|
1175
|
+
const onlyKey = this.members[0]?.projectKey;
|
|
1176
|
+
if (onlyKey) {
|
|
1177
|
+
this.workDirs.set(onlyKey, wt);
|
|
1178
|
+
this.branchInfos.set(onlyKey, this.branchInfo);
|
|
1179
|
+
this.checkpointRefs[onlyKey] = rehydrated.checkpointRef;
|
|
1180
|
+
this.state.branches = { ...(this.state.branches || {}), [onlyKey]: { ...this.state.branch } };
|
|
1181
|
+
this.state.checkpointRefs = { ...this.checkpointRefs };
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
this.checkpointRef = rehydrated.checkpointRef;
|
|
1186
|
+
// §5.3: cwd for every spawn. Detached workspace runs start at the neutral run
|
|
1187
|
+
// root; everything else at the recorded worktree — identical to a legacy run
|
|
1188
|
+
// that never paused.
|
|
1189
|
+
this.runCwd = (this.runRootMode === 'detached' && this.isWorkspace)
|
|
1190
|
+
? this.runRoot
|
|
1191
|
+
: (wt || null);
|
|
1192
|
+
|
|
1193
|
+
// ── workspace rehydration (no-op on single-project) ──
|
|
1194
|
+
if (this.isWorkspace && meta) {
|
|
1195
|
+
this.workspaceDescription = meta.workspaceDescription || '';
|
|
1196
|
+
this.checkpointRefs = meta.checkpointRefs || {};
|
|
1197
|
+
for (const p of rehydrated.memberWorktrees) {
|
|
1198
|
+
if (p.projectKey && p.worktreeDir) {
|
|
1199
|
+
if (!existsSync(p.worktreeDir)) throw new Error(`worktree missing: ${p.worktreeDir} — cannot resume`);
|
|
1200
|
+
this.workDirs.set(p.projectKey, p.worktreeDir);
|
|
1201
|
+
this.toolInstructions.set(p.projectKey, p.graphInstruction || '');
|
|
1202
|
+
// Re-arm teardown: _teardownWorktreeAll returns immediately on an empty
|
|
1203
|
+
// branchInfos map, so without this a resumed workspace run reaching
|
|
1204
|
+
// done/stopped/error would leak every member worktree and never run
|
|
1205
|
+
// _commitWork (resumed work silently absent from the feature branches).
|
|
1206
|
+
// Shape mirrors createWorktree()'s result as registered by _setupRunRoot.
|
|
1207
|
+
this.branchInfos.set(p.projectKey, {
|
|
1208
|
+
worktreeDir: p.worktreeDir,
|
|
1209
|
+
branch: meta.branches?.[p.projectKey]?.feature,
|
|
1210
|
+
sourceBranch: meta.branches?.[p.projectKey]?.source,
|
|
1211
|
+
reusedExisting: true,
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
Object.assign(this.state, {
|
|
1216
|
+
target: 'workspace', workspaceId: meta.workspaceId, workspaceKey: this.workspaceKey,
|
|
1217
|
+
workspaceName: meta.workspaceName, workspaceDescription: this.workspaceDescription,
|
|
1218
|
+
projectKeys: meta.projectKeys || [], projects: meta.projects || [],
|
|
1219
|
+
checkpointRefs: this.checkpointRefs, branches: meta.branches || {},
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// ── prompts/registry (cheap, local) ──
|
|
1224
|
+
this.registry = loadAgentRegistry(this.agentsDir);
|
|
1225
|
+
this.agentPrompts = await this._loadAgentPrompts();
|
|
1226
|
+
|
|
1227
|
+
this.state.resumePoint = null; // consumed; cleared on the next persist
|
|
1228
|
+
this._setStatus('running');
|
|
1229
|
+
await this._persist();
|
|
1230
|
+
this._startHeartbeat();
|
|
1231
|
+
await appendAudit(this.pipeline.dir, rehydrated.audit);
|
|
1232
|
+
this._emit('state', this.getState());
|
|
1233
|
+
|
|
1234
|
+
// ── §5.2 detached resume: idempotent re-assembly (self-healing) ──
|
|
1235
|
+
// Only when the RECORDED mode is 'detached', and NEVER with a resolvedSkills
|
|
1236
|
+
// variable — that path does not exist here: resume never runs
|
|
1237
|
+
// collectRequiredSkills/validateSkills (it loads registry + channelDefs +
|
|
1238
|
+
// agentPrompts only, and a mid-run resume carries a frozen rp.plan). The
|
|
1239
|
+
// `name -> {source, path, requiredBy}` map persisted at first assembly is the
|
|
1240
|
+
// substitute. Assembly is a pure function of members + settings + graph
|
|
1241
|
+
// outcomes + that map, so a missing CLAUDE.md / mcp.json / skill mount
|
|
1242
|
+
// self-heals byte-identically. Workspace graph instructions were rehydrated
|
|
1243
|
+
// from the bus channel above; single-project runs leave the map empty exactly
|
|
1244
|
+
// as on a fresh run, and the generator tolerates a missing instruction per
|
|
1245
|
+
// member. A member real dir deleted while paused degrades per §8.20 — a
|
|
1246
|
+
// missing SOURCE never throws (a missing worktree still hard-fails, above).
|
|
1247
|
+
if (this.runRootMode === 'detached') {
|
|
1248
|
+
await this._assembleContext(resumeManifest?.skillResolutions ?? new Map());
|
|
1249
|
+
// AFTER the assembly: it rewrites run.json.warnings wholesale, so recording
|
|
1250
|
+
// this first would drop it from the durable ledger.
|
|
1251
|
+
if (!resumeManifest) {
|
|
1252
|
+
await this._recordRunWarning(
|
|
1253
|
+
'run.json was missing or unparseable, so the bundle/plugin skills this run mounted ' +
|
|
1254
|
+
'could not be restored to the skill mount; real-dir and root skills were re-mounted ' +
|
|
1255
|
+
'normally. An agent that declares `requiresSkills` may not find its skill.',
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const dispatched = await this._engineRun({ resume: rp, rehydrated });
|
|
1261
|
+
this._checkAbort();
|
|
1262
|
+
if (dispatched === 'paused') return await this._completePaused();
|
|
1263
|
+
|
|
1264
|
+
this._setStatus('done');
|
|
1265
|
+
this.state.resumePoint = null; // finished rows are not resumable (clears the boundary trail)
|
|
1266
|
+
this._bookend('done', 'done');
|
|
1267
|
+
await this._persist();
|
|
1268
|
+
await appendAudit(this.pipeline.dir, `Pipeline finished with status **done**.`);
|
|
1269
|
+
await this._buildResults(); // refs + worktree still live here
|
|
1270
|
+
await this._reportToSource(); // task-source write-back (never throws, spec §7.5)
|
|
1271
|
+
this._emit('done', { status: 'done', pipelineDir: this.pipeline.dir });
|
|
1272
|
+
return { status: 'done', pipelineDir: this.pipeline.dir };
|
|
1273
|
+
} catch (err) {
|
|
1274
|
+
if ((isPause(err) || this.state.status === 'pausing') && this.state.status !== 'stopped') {
|
|
1275
|
+
if (this.pipeline) {
|
|
1276
|
+
if (!this.state.resumePoint) this.state.resumePoint = rp; // re-arm the consumed point: a paused row must stay resumable
|
|
1277
|
+
return await this._completePaused();
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
if (isAbort(err) || this.state.status === 'stopped') {
|
|
1281
|
+
this._setStatus('stopped');
|
|
1282
|
+
// Stopped runs are not resumable: never persist a resume point alongside
|
|
1283
|
+
// a torn-down worktree (mirrors run()'s stopped branch).
|
|
1284
|
+
this.state.resumePoint = null;
|
|
1285
|
+
if (this.pipeline) {
|
|
1286
|
+
await this._persist().catch(() => {});
|
|
1287
|
+
await appendAudit(this.pipeline.dir, `Pipeline **stopped**.`).catch(() => {});
|
|
1288
|
+
// The diff artifact must survive a non-done terminal path too: the work done
|
|
1289
|
+
// up to this point IS committed onto the kept feature branch by the teardown
|
|
1290
|
+
// in the finally below, so History has to be able to show it. Safe HERE and
|
|
1291
|
+
// only here — the checkpoint refs and the worktree are still live until that
|
|
1292
|
+
// teardown runs. Best-effort by construction (its own try/catch logs a warn
|
|
1293
|
+
// and never rethrows), and a no-op when the run stopped before any checkpoint
|
|
1294
|
+
// existed. The terminal `done` event is emitted AFTER it so the History row never
|
|
1295
|
+
// paints as "no diff captured" for the tick before the artifact lands.
|
|
1296
|
+
await this._buildResults({ stage: true });
|
|
1297
|
+
await this._reportToSource(); // statusToResult('stopped') -> 'failed' (design PR12: no longer success-only)
|
|
1298
|
+
}
|
|
1299
|
+
this._emit('done', { status: 'stopped', pipelineDir: this.pipeline?.dir || null });
|
|
1300
|
+
return { status: 'stopped', pipelineDir: this.pipeline?.dir || null };
|
|
1301
|
+
}
|
|
1302
|
+
this._setStatus('error');
|
|
1303
|
+
const message = err?.message || String(err);
|
|
1304
|
+
this._emit('error', { message });
|
|
1305
|
+
if (this.pipeline) {
|
|
1306
|
+
await this._persist().catch(() => {});
|
|
1307
|
+
await appendAudit(this.pipeline.dir, `Pipeline **error**: ${message}`).catch(() => {});
|
|
1308
|
+
// The diff artifact must survive a non-done terminal path too: the work done
|
|
1309
|
+
// up to this point IS committed onto the kept feature branch by the teardown
|
|
1310
|
+
// in the finally below, so History has to be able to show it. Safe HERE and
|
|
1311
|
+
// only here — the checkpoint refs and the worktree are still live until that
|
|
1312
|
+
// teardown runs. Best-effort by construction (its own try/catch logs a warn
|
|
1313
|
+
// and never rethrows), and a no-op when the run stopped before any checkpoint
|
|
1314
|
+
// existed. The terminal `done` event is emitted AFTER it so the History row never
|
|
1315
|
+
// paints as "no diff captured" for the tick before the artifact lands.
|
|
1316
|
+
await this._buildResults({ stage: true });
|
|
1317
|
+
await this._reportToSource(); // statusToResult('error') -> 'failed' (design PR12: no longer success-only)
|
|
1318
|
+
}
|
|
1319
|
+
this._emit('done', { status: 'error', pipelineDir: this.pipeline?.dir || null });
|
|
1320
|
+
return { status: 'error', pipelineDir: this.pipeline?.dir || null, error: message };
|
|
1321
|
+
} finally {
|
|
1322
|
+
this._stopHeartbeat(); // clear timer + NULL owner columns (done/stopped/error/paused)
|
|
1323
|
+
// Same teardown as run()'s finally — wiring only run()'s would keep legacy
|
|
1324
|
+
// teardown on every detached run that finishes after a resume (including every
|
|
1325
|
+
// crash-interrupted run, §8.12's primary scenario): run root leaked until the
|
|
1326
|
+
// next boot, no stray scan, no injected-path cleanup.
|
|
1327
|
+
if (this.state.status !== 'paused' && this.state.status !== 'pausing') {
|
|
1328
|
+
await this._teardownRunRoot().catch(() => {});
|
|
1329
|
+
}
|
|
1330
|
+
await this.logWriter.close().catch(() => {}); // flush + stop timer (last, to capture teardown logs)
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/** Single-project branch resolution — VERBATIM _setupWorktree semantics.
|
|
1335
|
+
* Deliberately NOT _resolveMemberBranches, which would (a) suffix an explicit
|
|
1336
|
+
* feature with `-<projectName slug>` (breaking test/orchestrator-worktree.test.mjs,
|
|
1337
|
+
* 'explicit featureBranch is honored verbatim'), (b) derive suggested names from
|
|
1338
|
+
* `opts.title + projectName` (suggestBranchName is title-first, so derived names
|
|
1339
|
+
* would come from the project name instead of the prompt), and (c) silently swap
|
|
1340
|
+
* an invalid --source for the default branch, where createWorktree's M1 gate must
|
|
1341
|
+
* keep failing loudly. */
|
|
1342
|
+
async _resolveSingleBranches() {
|
|
1343
|
+
const source = this.branchOpts.source || (await resolveDefaultBranch(this.projectDir));
|
|
1344
|
+
const featureRaw = this.branchOpts.feature
|
|
1345
|
+
? sanitizeBranchName(this.branchOpts.feature)
|
|
1346
|
+
: suggestBranchName({ prompt: this.pipeline.promptText,
|
|
1347
|
+
title: this.opts.title || null,
|
|
1348
|
+
pipelineId: this.pipeline.id });
|
|
1349
|
+
return { source, featureRaw };
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* Set up the run root + every member worktree (§5.2 step 5). Replaces
|
|
1354
|
+
* _setupWorktree / _setupWorktreeAll with ONE path for both targets.
|
|
1355
|
+
*
|
|
1356
|
+
* Detached-only in this step: run-root/`repos/` creation, the baseDir/checkoutName
|
|
1357
|
+
* inputs to createWorktree, and the manifest write. EVERYTHING else — branch
|
|
1358
|
+
* resolution, workDirs/branchInfos/state.branches registration, the scalar
|
|
1359
|
+
* mirrors, the mode stamp, the persist + emit — runs identically in both modes.
|
|
1360
|
+
*/
|
|
1361
|
+
async _setupRunRoot() {
|
|
1362
|
+
this.state.branches = this.state.branches || {}; // belt-and-braces for resumed/legacy shapes
|
|
1363
|
+
this.runRootMode = runRootMode(); // §10 flag, read ONCE, here, per pipeline
|
|
1364
|
+
this.state.runRootMode = this.runRootMode; // top-level pin → workspace_meta (artifacts.mjs)
|
|
1365
|
+
const detached = this.runRootMode === 'detached';
|
|
1366
|
+
this.runRoot = detached ? join(worcaHome(), 'runs', this.pipeline.id) : null;
|
|
1367
|
+
const reposBase = detached ? join(this.runRoot, 'repos') : null;
|
|
1368
|
+
if (detached) await mkdir(reposBase, { recursive: true });
|
|
1369
|
+
|
|
1370
|
+
this._log('worktree', 'info', `Resolving source/feature branches for ${this.members.length} member(s)…`);
|
|
1371
|
+
// Settle EVERY member before propagating any failure — carried VERBATIM from
|
|
1372
|
+
// _setupWorktreeAll. mapWithCap is Promise.all: it rejects the instant one
|
|
1373
|
+
// member throws and would abandon an in-flight sibling whose worktree
|
|
1374
|
+
// materializes AFTER run()'s finally has snapshotted branchInfos — an orphaned
|
|
1375
|
+
// checkout on disk. Under legacy that orphan sits INSIDE the user's repo with
|
|
1376
|
+
// the legacy sweep disabled, i.e. permanent. The partial-setup test
|
|
1377
|
+
// (test/orchestrator-workspace.test.mjs) guards exactly this.
|
|
1378
|
+
const setupFailures = [];
|
|
1379
|
+
await mapWithCap(this.members, fanoutCap(), async (m) => {
|
|
1380
|
+
try {
|
|
1381
|
+
const { source, featureRaw } = this.isWorkspace
|
|
1382
|
+
? await this._resolveMemberBranches(m) // unchanged (member-suffixed names)
|
|
1383
|
+
: await this._resolveSingleBranches(); // single: today's exact semantics
|
|
1384
|
+
const info = await createWorktree({
|
|
1385
|
+
projectDir: resolve(m.projectDir), // the REAL dir: git runs here
|
|
1386
|
+
pipelineId: this.pipeline.id,
|
|
1387
|
+
// detached ⇒ <runRoot>/repos/<projectKey>, uniqueness from the run root.
|
|
1388
|
+
// legacy ⇒ both omitted, so worktree.mjs falls back to its retained
|
|
1389
|
+
// default <projectDir>/.worca-cc/worktrees/<pipelineId> (§10).
|
|
1390
|
+
...(detached ? { baseDir: reposBase, checkoutName: m.projectKey } : {}),
|
|
1391
|
+
sourceBranch: source,
|
|
1392
|
+
featureBranch: featureRaw,
|
|
1393
|
+
signal: this.abort.signal,
|
|
1394
|
+
});
|
|
1395
|
+
// Register EAGERLY (Map.set is synchronous) so teardown always sees it.
|
|
1396
|
+
this.workDirs.set(m.projectKey, info.worktreeDir);
|
|
1397
|
+
this.branchInfos.set(m.projectKey, info);
|
|
1398
|
+
this.state.branches[m.projectKey] = { source: info.sourceBranch, feature: info.branch,
|
|
1399
|
+
worktreeDir: info.worktreeDir,
|
|
1400
|
+
reusedExisting: info.reusedExisting };
|
|
1401
|
+
const reuseNote = info.reusedExisting ? ' (resumed existing branch)' : '';
|
|
1402
|
+
await appendAudit(this.pipeline.dir,
|
|
1403
|
+
`Worktree \`${m.projectKey}\`: \`${info.branch}\` (off \`${info.sourceBranch}\`)${reuseNote} at \`${info.worktreeDir}\`.`,
|
|
1404
|
+
).catch(() => {}); // per-member audit
|
|
1405
|
+
} catch (err) {
|
|
1406
|
+
setupFailures.push(err);
|
|
1407
|
+
}
|
|
1408
|
+
});
|
|
1409
|
+
if (setupFailures.length) {
|
|
1410
|
+
throw setupFailures[0] instanceof Error ? setupFailures[0] : new Error(String(setupFailures[0]));
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
const primary = this.members[0]; // members sorted by projectKey; single: the only one
|
|
1414
|
+
this.workDir = this.workDirs.get(primary.projectKey); // back-compat scalar (display, PR route)
|
|
1415
|
+
this.branchInfo = this.branchInfos.get(primary.projectKey);
|
|
1416
|
+
this.runCwd = (detached && this.isWorkspace)
|
|
1417
|
+
? this.runRoot // neutral cwd (§5.8)
|
|
1418
|
+
: this.workDirs.get(primary.projectKey); // single-project detached, or either mode under legacy
|
|
1419
|
+
if (!this.isWorkspace) {
|
|
1420
|
+
// Single: the mode pin rides state.branch (pipelines.branch column).
|
|
1421
|
+
this.state.branch = { ...this.state.branches[primary.projectKey], runRootMode: this.runRootMode };
|
|
1422
|
+
} else {
|
|
1423
|
+
// Workspace: the scalar mirror is KEPT for display/back-compat readers. NOTE
|
|
1424
|
+
// the precise consumer set: workspace pipeline-delete iterates state.branches
|
|
1425
|
+
// per member; it is the SINGLE-project delete path that reads state.branch.
|
|
1426
|
+
// The pin rides workspace_meta.runRootMode via this.state.runRootMode + the
|
|
1427
|
+
// artifacts.mjs whitelist delta.
|
|
1428
|
+
this.state.branch = { ...this.state.branches[primary.projectKey] };
|
|
1429
|
+
}
|
|
1430
|
+
// Minimal manifest, written HERE: the boot sweep and pipeline-delete need member
|
|
1431
|
+
// real dirs + worktree paths from the very first detached run, before any context
|
|
1432
|
+
// field exists. Legacy runs have no run root and therefore no manifest — the
|
|
1433
|
+
// sweeps fall back to the DB columns.
|
|
1434
|
+
if (detached) await writeRunManifest(this.runRoot, {
|
|
1435
|
+
pipelineId: this.pipeline.id,
|
|
1436
|
+
runRootMode: this.runRootMode,
|
|
1437
|
+
isWorkspace: this.isWorkspace,
|
|
1438
|
+
members: this.members.map((m) => ({
|
|
1439
|
+
projectKey: m.projectKey, projectName: m.projectName,
|
|
1440
|
+
projectDir: resolve(m.projectDir), // the REAL repo — what `git worktree remove` needs
|
|
1441
|
+
worktreeDir: this.workDirs.get(m.projectKey),
|
|
1442
|
+
})),
|
|
1443
|
+
});
|
|
1444
|
+
await this._persist();
|
|
1445
|
+
this._emit('state', this.getState());
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* Resolve THE run's guardrails: the per-run selected set (this.guardrailsId,
|
|
1450
|
+
* default 'permissive') IS the policy — member project configs are NOT read
|
|
1451
|
+
* (per-project guardrails were removed; one set applies uniformly to every
|
|
1452
|
+
* member). Built-ins resolve from GUARDRAIL_PRESETS at read time; user sets
|
|
1453
|
+
* from the store at read time. Called from run() AND resume() — resume
|
|
1454
|
+
* re-reads the set by id, so a set edited while paused is enforced at its
|
|
1455
|
+
* LATEST definition. A missing/deleted set fails OPEN to the Permissive
|
|
1456
|
+
* (empty) policy with a loud warn — never an abort.
|
|
1457
|
+
*/
|
|
1458
|
+
async _resolveGuardrails() {
|
|
1459
|
+
let set = await readGuardrailSet(this.guardrailsId || 'permissive');
|
|
1460
|
+
if (!set) {
|
|
1461
|
+
this._log('guardrails', 'warn',
|
|
1462
|
+
`guardrail set "${this.guardrailsId}" not found; running with the Permissive (empty) policy`);
|
|
1463
|
+
set = await readGuardrailSet('permissive'); // virtual built-in: always resolves
|
|
1464
|
+
}
|
|
1465
|
+
// One UNIFORM honor value for every member: the run set's honorProjectSettings
|
|
1466
|
+
// gates the per-member repo-settings deny lift. The map SHAPE is unchanged
|
|
1467
|
+
// (run-context.mjs's honorByKey consumer is untouched); only its values are
|
|
1468
|
+
// uniform now — there is no per-member saved preference anymore.
|
|
1469
|
+
const honor = set.settings.honorProjectSettings !== false;
|
|
1470
|
+
this.guardrailHonorByKey = new Map(this.members.map((m) => [m.projectKey, honor]));
|
|
1471
|
+
// unionGuardrails over the ONE-element list keeps the tested normalization
|
|
1472
|
+
// path (fresh arrays, de-dupe, a non-scrubbing set's dormant allowlist
|
|
1473
|
+
// drops — enforcement gates allowlist on envScrub anyway): the run's set is
|
|
1474
|
+
// the whole union. Its envAllowlist is NOT stripped — it IS the policy;
|
|
1475
|
+
// there is no member policy to relax against.
|
|
1476
|
+
this.guardrails = unionGuardrails([set.settings]);
|
|
1477
|
+
this.guardrailPermissionRules = guardrailsToPermissionRules(this.guardrails);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/**
|
|
1481
|
+
* §5.2 step 7 / §6 Phase 3: assemble the run context at the run root and wire its
|
|
1482
|
+
* outputs into every consumer. Called from run() (after the skills gate, with the
|
|
1483
|
+
* HOISTED resolutions) and from resume() (with the resolutions persisted in
|
|
1484
|
+
* run.json, since resume never re-runs collectRequiredSkills/validateSkills).
|
|
1485
|
+
*
|
|
1486
|
+
* Detached-only: every caller is already gated on the RECORDED mode. It is
|
|
1487
|
+
* deliberately NOT gated on requiredSkills.length or on mock mode — assembly is
|
|
1488
|
+
* pure fs work whose outputs the mock smokes assert, and nesting it under the
|
|
1489
|
+
* skills gate would silently void R1(a)-(d) and R2 on every default pipeline.
|
|
1490
|
+
* @param {Map<string,object>|object} resolvedSkills possibly EMPTY (the default workflow)
|
|
1491
|
+
*/
|
|
1492
|
+
async _assembleContext(resolvedSkills) {
|
|
1493
|
+
// Warnings this run root ALREADY reported (from the pre-pause segment of a
|
|
1494
|
+
// resumed run). Assembly is idempotent, so it re-derives the same lines every
|
|
1495
|
+
// time; re-logging them would double every context warning — including §8.21's —
|
|
1496
|
+
// in the run log at each resume. run.json is the cross-instance record, so it is
|
|
1497
|
+
// what "already reported" means (a resumed run is a NEW orchestrator object, so an
|
|
1498
|
+
// in-memory Set could not see the earlier segment). Read BEFORE the assembly,
|
|
1499
|
+
// which rewrites `warnings` wholesale.
|
|
1500
|
+
const alreadyReported = new Set(
|
|
1501
|
+
this.runRoot ? ((await readRunManifest(this.runRoot))?.warnings ?? []) : [],
|
|
1502
|
+
);
|
|
1503
|
+
const rc = await assembleRunContext({
|
|
1504
|
+
runRoot: this.runRoot,
|
|
1505
|
+
members: this.members.map((m) => ({
|
|
1506
|
+
...m,
|
|
1507
|
+
worktreeDir: this.workDirs.get(m.projectKey),
|
|
1508
|
+
// §5.4 requires the roster to carry each member's branch + checkpoint ref;
|
|
1509
|
+
// the generator omits either cell when it is absent (e.g. a resume whose
|
|
1510
|
+
// branchInfos were rehydrated without one).
|
|
1511
|
+
branch: this.branchInfos.get(m.projectKey)?.branch || null,
|
|
1512
|
+
checkpointRef: this.checkpointRefs?.[m.projectKey] || null,
|
|
1513
|
+
})),
|
|
1514
|
+
projectsRoot: getProjectsRoot(),
|
|
1515
|
+
isWorkspace: this.isWorkspace,
|
|
1516
|
+
requiredSkillResolutions: resolvedSkills, // possibly empty — a valid, common input
|
|
1517
|
+
graphInstructions: this.toolInstructions,
|
|
1518
|
+
homeDir: homedir(),
|
|
1519
|
+
honorByKey: this.guardrailHonorByKey,
|
|
1520
|
+
});
|
|
1521
|
+
this.runContext = rc;
|
|
1522
|
+
if (rc?.projectPermissions) {
|
|
1523
|
+
this.guardrailPermissionRules = mergePermissionRules(this.guardrailPermissionRules, rc.projectPermissions);
|
|
1524
|
+
}
|
|
1525
|
+
// Audit (spec bullet): the resolved effective policy, compact, into run.json.
|
|
1526
|
+
// Written HERE because runRoot exists only on detached runs and this is the
|
|
1527
|
+
// one site where this.guardrails and the FINAL (post-lift) rule set are both
|
|
1528
|
+
// in scope on run() AND resume(). updateRunManifest merges the patch
|
|
1529
|
+
// (run-manifest.mjs:79-82), and a resume re-writes the same values
|
|
1530
|
+
// idempotently. denyCount includes the lifted repo deny rules, whose exact
|
|
1531
|
+
// list Task 6 already persisted as run.json.projectPermissions. Legacy runs
|
|
1532
|
+
// have no run.json, so no audit record — run.json is a detached-run artifact.
|
|
1533
|
+
// guardrailsId names the selected set (id only — sets are mutable and resolve
|
|
1534
|
+
// by reference, so this is not a content snapshot).
|
|
1535
|
+
await updateRunManifest(this.runRoot, {
|
|
1536
|
+
guardrails: {
|
|
1537
|
+
envScrub: !!this.guardrails?.envScrub,
|
|
1538
|
+
denyCount: this.guardrailPermissionRules?.deny?.length || 0,
|
|
1539
|
+
protectedCount: this.guardrails?.protectedPaths?.length || 0,
|
|
1540
|
+
guardrailsId: this.guardrailsId,
|
|
1541
|
+
},
|
|
1542
|
+
});
|
|
1543
|
+
this.injectedPaths = rc.injectedPaths; // feeds _excludePathspecs / teardown / rescue (§8.8)
|
|
1544
|
+
this.mcpConfigPath = rc.mcpConfigPath;
|
|
1545
|
+
// V1-gated (§4.1 outcome table). Branch (a) PASSED on this CLI (server wildcard),
|
|
1546
|
+
// so one grant per merged server; the 'per-tool' / 'none' branches would leave
|
|
1547
|
+
// this empty and rely on the frontmatter union alone.
|
|
1548
|
+
this.mcpServerGrants = MCP_GRANT_MODE === 'server'
|
|
1549
|
+
? rc.mcpServerNames.map((s) => `mcp__${s}`)
|
|
1550
|
+
: [];
|
|
1551
|
+
// Durable per §5.2's ledger rules: the run log survives teardown, and the
|
|
1552
|
+
// warnings are already inside run.json (written by the assembly — which is also
|
|
1553
|
+
// what makes them survive a resume, since it rewrites the array from scratch).
|
|
1554
|
+
// Record-once semantics across a pause boundary: a line this run root already
|
|
1555
|
+
// reported is not repeated in the log.
|
|
1556
|
+
for (const w of rc.warnings) {
|
|
1557
|
+
if (alreadyReported.has(w)) continue;
|
|
1558
|
+
this._log('context', 'warn', w);
|
|
1559
|
+
}
|
|
1560
|
+
await appendAudit(this.pipeline.dir, renderContextAudit(rc)).catch(() => {});
|
|
1561
|
+
await this._recordCapabilities();
|
|
1562
|
+
return rc;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/**
|
|
1566
|
+
* §8.18 / gate V5: parse `claude --help` ONCE per run and assert `--mcp-config`.
|
|
1567
|
+
* On absence, degrade gracefully — skip the flag, warn loudly naming the required
|
|
1568
|
+
* version — rather than failing the run: R1(a)/(c) still hold via the cwd and
|
|
1569
|
+
* ancestor mechanisms, but R1(b) is degraded and is REPORTED as degraded. V5
|
|
1570
|
+
* passed on the development machine; this stays shipped as version-drift
|
|
1571
|
+
* insurance for other machines.
|
|
1572
|
+
*
|
|
1573
|
+
* Mock runs never spawn `claude`, so the probe is skipped there (it would add a
|
|
1574
|
+
* subprocess to every test for an answer no mock run can act on) and recorded as
|
|
1575
|
+
* unprobed.
|
|
1576
|
+
*/
|
|
1577
|
+
async _recordCapabilities() {
|
|
1578
|
+
if (!this.runRoot) return;
|
|
1579
|
+
if (this.claude.mock) {
|
|
1580
|
+
await updateRunManifest(this.runRoot, {
|
|
1581
|
+
capabilities: { mcpGrants: MCP_GRANT_MODE, mcpConfig: null, version: null, probed: false },
|
|
1582
|
+
});
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
const caps = await probeClaudeCapabilities(this.claude.bin);
|
|
1586
|
+
if (caps.version === null) {
|
|
1587
|
+
// No `claude --version` at all. The first node fails loudly anyway; when the
|
|
1588
|
+
// cause is the Windows npm shim, record the actionable reason NOW so the
|
|
1589
|
+
// run's warnings carry it instead of only a spawn ENOENT at the first node.
|
|
1590
|
+
const hint = explainUnspawnableClaude(this.claude.bin);
|
|
1591
|
+
if (hint) await this._recordRunWarning(hint);
|
|
1592
|
+
}
|
|
1593
|
+
if (!caps.mcpConfig && this.mcpConfigPath) {
|
|
1594
|
+
await this._recordRunWarning(
|
|
1595
|
+
`this \`claude\` build does not advertise --mcp-config (version ${caps.version || 'unknown'}); ` +
|
|
1596
|
+
'worca-cc needs >= 2.1.220 to deliver project/root MCP servers. Skipping the flag — ' +
|
|
1597
|
+
"R1(b) is DEGRADED for this run: the merged servers in mcp.json are NOT available to any agent.",
|
|
1598
|
+
);
|
|
1599
|
+
this.mcpConfigPath = null;
|
|
1600
|
+
this.mcpServerGrants = [];
|
|
1601
|
+
}
|
|
1602
|
+
await updateRunManifest(this.runRoot, {
|
|
1603
|
+
capabilities: { mcpGrants: MCP_GRANT_MODE, ...caps, probed: true },
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/**
|
|
1608
|
+
* Resolve the worktree source/feature branch pair for ONE member (D2). The named
|
|
1609
|
+
* source (run-level or per-member) is used only when it resolves to a real commit
|
|
1610
|
+
* IN THAT member's repo; otherwise the member's own default branch. The feature is
|
|
1611
|
+
* the run-level featureBranch suffixed with the project slug (so members never
|
|
1612
|
+
* collide on one branch name), or a suggested name when none was given.
|
|
1613
|
+
* @param {{projectDir,projectKey,projectName,branch?:{source?,feature?}}} m
|
|
1614
|
+
* @returns {Promise<{source:string, featureRaw:string}>}
|
|
1615
|
+
*/
|
|
1616
|
+
async _resolveMemberBranches(m) {
|
|
1617
|
+
const dir = resolve(m.projectDir);
|
|
1618
|
+
const named = (m.branch && m.branch.source) || this.branchOpts.source || null;
|
|
1619
|
+
const source = (named && (await isValidSourceRef(dir, named)))
|
|
1620
|
+
? named
|
|
1621
|
+
: await resolveDefaultBranch(dir);
|
|
1622
|
+
const feature = (m.branch && m.branch.feature) || this.branchOpts.feature || null;
|
|
1623
|
+
const featureRaw = feature
|
|
1624
|
+
? sanitizeBranchName(`${feature}-${slugify(m.projectName)}`)
|
|
1625
|
+
: suggestBranchName({
|
|
1626
|
+
prompt: this.pipeline.promptText,
|
|
1627
|
+
title: `${this.opts.title || ''} ${m.projectName}`.trim() || null,
|
|
1628
|
+
pipelineId: this.pipeline.id,
|
|
1629
|
+
});
|
|
1630
|
+
return { source, featureRaw };
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* Build a graphify AST graph INSIDE the worktree so agents (which run with
|
|
1635
|
+
* cwd=workDir) can query it. graphify-out/ is gitignored, so it never reaches
|
|
1636
|
+
* the reviewer diff, the kept-branch commit, or survives teardown.
|
|
1637
|
+
*
|
|
1638
|
+
* Fail-safe — never throws. Skipped when: mock mode (keeps `npm run smoke`
|
|
1639
|
+
* offline); no worktree was created; or the graphify binary is not on PATH.
|
|
1640
|
+
* On build failure/timeout the run proceeds with no graph instruction.
|
|
1641
|
+
*/
|
|
1642
|
+
async _buildWorktreeGraph() {
|
|
1643
|
+
if (this.claude.mock) return; // mock runs never use the graph (intentionally silent)
|
|
1644
|
+
if (this.workDir === this.projectDir) {
|
|
1645
|
+
this._log('graph', 'debug', 'No worktree (workDir===projectDir); skipping in-worktree graph build.');
|
|
1646
|
+
return; // building "in the worktree" would write into main
|
|
1647
|
+
}
|
|
1648
|
+
if (this.state.tools?.kind !== 'cli') {
|
|
1649
|
+
this.toolInstruction = '';
|
|
1650
|
+
this._log('graph', 'info', 'graphify CLI not on PATH; skipping in-worktree graph build');
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
this._log('graph', 'info', 'Building graphify graph in worktree (AST-only, no LLM)…');
|
|
1654
|
+
const res = await runGraphifyUpdate({
|
|
1655
|
+
dir: this.workDir,
|
|
1656
|
+
cwd: this.workDir,
|
|
1657
|
+
timeoutMs: this.graphBuildTimeoutMs,
|
|
1658
|
+
});
|
|
1659
|
+
if (res.ok) {
|
|
1660
|
+
this.toolInstruction = worktreeGraphInstruction();
|
|
1661
|
+
this._log('graph', 'info', 'graphify graph built in worktree.');
|
|
1662
|
+
await appendAudit(this.pipeline.dir, 'Preflight: built graphify graph in worktree (AST-only).').catch(() => {});
|
|
1663
|
+
} else {
|
|
1664
|
+
this.toolInstruction = '';
|
|
1665
|
+
this._log(
|
|
1666
|
+
'graph',
|
|
1667
|
+
'warn',
|
|
1668
|
+
`graphify build ${res.timedOut ? 'timed out' : 'failed'}; proceeding without graph grounding`
|
|
1669
|
+
+ errDetail(res),
|
|
1670
|
+
errStreamAttr(res?.stderr),
|
|
1671
|
+
);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
/**
|
|
1676
|
+
* Workspace graph builds (D4): build a graphify graph inside EACH member worktree
|
|
1677
|
+
* in parallel (cap 4), storing this.toolInstructions[projectKey]. Fail-safe per
|
|
1678
|
+
* §5.8: a member whose detectTools.kind !== 'cli' or whose build fails/times out
|
|
1679
|
+
* degrades to '' (source-reading) WITHOUT aborting the others. Skipped wholesale
|
|
1680
|
+
* in mock mode (keeps `npm run smoke` offline + deterministic), matching the
|
|
1681
|
+
* single-project _buildWorktreeGraph mock guard.
|
|
1682
|
+
*/
|
|
1683
|
+
async _buildWorktreeGraphAll() {
|
|
1684
|
+
if (this.claude.mock) return; // mock runs never use the graph (intentionally silent)
|
|
1685
|
+
const dirs = this.members.map((m) => resolve(m.projectDir));
|
|
1686
|
+
const toolsByDir = await detectToolsPerProject(dirs); // never throws
|
|
1687
|
+
await mapWithCap(this.members, 4, async (m) => {
|
|
1688
|
+
const workDir = this.workDirs.get(m.projectKey);
|
|
1689
|
+
const info = toolsByDir.get(resolve(m.projectDir));
|
|
1690
|
+
if (!workDir || workDir === resolve(m.projectDir)) {
|
|
1691
|
+
this.toolInstructions.set(m.projectKey, '');
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
if (info?.kind !== 'cli') {
|
|
1695
|
+
this.toolInstructions.set(m.projectKey, '');
|
|
1696
|
+
this._log('graph', 'info', `graphify CLI not on PATH for ${m.projectKey}; skipping graph build`);
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
const res = await runGraphifyUpdate({ dir: workDir, cwd: workDir, timeoutMs: this.graphBuildTimeoutMs });
|
|
1700
|
+
if (res.ok) {
|
|
1701
|
+
this.toolInstructions.set(m.projectKey, worktreeGraphInstruction());
|
|
1702
|
+
this._log('graph', 'info', `graphify graph built in ${m.projectKey} worktree.`);
|
|
1703
|
+
await appendAudit(this.pipeline.dir, `Preflight: built graphify graph for ${m.projectKey} (AST-only).`).catch(() => {});
|
|
1704
|
+
} else {
|
|
1705
|
+
this.toolInstructions.set(m.projectKey, '');
|
|
1706
|
+
this._log('graph', 'warn',
|
|
1707
|
+
`graphify build for ${m.projectKey} ${res.timedOut ? 'timed out' : 'failed'}; degrading to source-reading`
|
|
1708
|
+
+ errDetail(res),
|
|
1709
|
+
errStreamAttr(res?.stderr));
|
|
1710
|
+
}
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
/**
|
|
1715
|
+
* Tear down the per-pipeline worktree (C1). Retention policy:
|
|
1716
|
+
* - Remove the checkout and keep the feature branch after a successful (or
|
|
1717
|
+
* unnecessary) commit. If git status/add/commit fails, retain the checkout
|
|
1718
|
+
* so its uncommitted work remains recoverable.
|
|
1719
|
+
* Always force:true — agents have edited files, so the non-force path would
|
|
1720
|
+
* refuse and leak. Idempotent; safe to call when setup never ran.
|
|
1721
|
+
*/
|
|
1722
|
+
async _teardownWorktree() {
|
|
1723
|
+
const info = this.branchInfo;
|
|
1724
|
+
if (!info || !info.worktreeDir) return;
|
|
1725
|
+
this.branchInfo = null; // guard against a double teardown
|
|
1726
|
+
// Commit the agent's work onto the feature branch BEFORE removal. Without
|
|
1727
|
+
// this, removeWorktree(force:true) discards the working tree and the kept
|
|
1728
|
+
// branch carries no changes (the staging in _stageWorkingTree is intent-to-add
|
|
1729
|
+
// for the reviewer's diff only — it never creates a commit). On error/stop this
|
|
1730
|
+
// is what captures the partial work made up to that point.
|
|
1731
|
+
const commit = await this._commitWork(info);
|
|
1732
|
+
const retained = await this._recordCommitFailure(commit, { info, branchRecord: this.state.branch });
|
|
1733
|
+
if (retained) {
|
|
1734
|
+
await this._snapshotRetained(info);
|
|
1735
|
+
this.workDir = this.projectDir;
|
|
1736
|
+
await this._persist().catch(() => {});
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
// branch:null — the branch is always kept (done/error/stopped alike); only the
|
|
1740
|
+
// disposable checkout is removed.
|
|
1741
|
+
const res = await removeWorktree({
|
|
1742
|
+
projectDir: this.projectDir,
|
|
1743
|
+
worktreeDir: info.worktreeDir,
|
|
1744
|
+
branch: null,
|
|
1745
|
+
force: true,
|
|
1746
|
+
});
|
|
1747
|
+
for (const s of res.steps.filter((x) => !x.ok)) {
|
|
1748
|
+
this._log('worktree', 'warn', `teardown ${s.step} failed: ${s.stderr || 'unknown error'}`, errStreamAttr(s.stderr));
|
|
1749
|
+
}
|
|
1750
|
+
if (this.pipeline) {
|
|
1751
|
+
await appendAudit(
|
|
1752
|
+
this.pipeline.dir,
|
|
1753
|
+
`Worktree removed at \`${info.worktreeDir}\` (kept branch \`${info.branch}\`).`,
|
|
1754
|
+
).catch(() => {});
|
|
1755
|
+
}
|
|
1756
|
+
// Reflect the post-teardown reality in state for any late observer.
|
|
1757
|
+
if (this.state.branch) {
|
|
1758
|
+
this.state.branch.worktreeRemoved = true;
|
|
1759
|
+
this.state.branch.branchKept = true;
|
|
1760
|
+
}
|
|
1761
|
+
this.workDir = this.projectDir;
|
|
1762
|
+
await this._persist().catch(() => {});
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
/**
|
|
1766
|
+
* Workspace teardown (C1, N times): per member, commit its work onto its feature
|
|
1767
|
+
* branch (in its own repo), remove its checkout, and KEEP the branch — done,
|
|
1768
|
+
* error, or stopped alike. Each member's SHA + survival flags are recorded on
|
|
1769
|
+
* state.branches[projectKey]. Idempotent (guards against a double teardown by
|
|
1770
|
+
* clearing branchInfos); best-effort (never throws). Iterated serially so the
|
|
1771
|
+
* teardown commits don't contend on interleaved git index locks across repos.
|
|
1772
|
+
*/
|
|
1773
|
+
async _teardownWorktreeAll() {
|
|
1774
|
+
if (this.branchInfos.size === 0) return;
|
|
1775
|
+
const entries = [...this.branchInfos.entries()]; // [projectKey, info]
|
|
1776
|
+
this.branchInfos = new Map(); // guard against a double teardown
|
|
1777
|
+
let anyRetained = false;
|
|
1778
|
+
for (const [projectKey_, info] of entries) {
|
|
1779
|
+
if (!info || !info.worktreeDir) continue;
|
|
1780
|
+
const branchRecord = (this.state.branches && this.state.branches[projectKey_]) || null;
|
|
1781
|
+
const commit = await this._commitWork(info, branchRecord);
|
|
1782
|
+
if (await this._recordCommitFailure(commit, { key: projectKey_, info, branchRecord })) {
|
|
1783
|
+
anyRetained = true;
|
|
1784
|
+
await this._snapshotRetained(info, projectKey_);
|
|
1785
|
+
this.workDirs.delete(projectKey_);
|
|
1786
|
+
continue;
|
|
1787
|
+
}
|
|
1788
|
+
const res = await removeWorktree({
|
|
1789
|
+
projectDir: resolve(this.memberByKey.get(projectKey_)?.projectDir || this.projectDir),
|
|
1790
|
+
worktreeDir: info.worktreeDir,
|
|
1791
|
+
branch: null, // always keep the branch
|
|
1792
|
+
force: true,
|
|
1793
|
+
});
|
|
1794
|
+
for (const s of res.steps.filter((x) => !x.ok)) {
|
|
1795
|
+
this._log('worktree', 'warn', `teardown ${projectKey_} ${s.step} failed: ${s.stderr || 'unknown error'}`, errStreamAttr(s.stderr));
|
|
1796
|
+
}
|
|
1797
|
+
if (this.pipeline) {
|
|
1798
|
+
await appendAudit(
|
|
1799
|
+
this.pipeline.dir,
|
|
1800
|
+
`Worktree \`${projectKey_}\` removed at \`${info.worktreeDir}\` (kept branch \`${info.branch}\`).`,
|
|
1801
|
+
).catch(() => {});
|
|
1802
|
+
}
|
|
1803
|
+
if (branchRecord) {
|
|
1804
|
+
branchRecord.worktreeRemoved = true;
|
|
1805
|
+
branchRecord.branchKept = true;
|
|
1806
|
+
}
|
|
1807
|
+
this.workDirs.delete(projectKey_);
|
|
1808
|
+
}
|
|
1809
|
+
// Keep the scalar mirror coherent for late observers — but never claim a
|
|
1810
|
+
// retained checkout was removed (the detached twin guards the same way,
|
|
1811
|
+
// via !retainedMembers.length).
|
|
1812
|
+
if (this.state.branch && !anyRetained) {
|
|
1813
|
+
this.state.branch.worktreeRemoved = true;
|
|
1814
|
+
this.state.branch.branchKept = true;
|
|
1815
|
+
}
|
|
1816
|
+
this.branchInfo = null;
|
|
1817
|
+
this.workDir = this.projectDir;
|
|
1818
|
+
await this._persist().catch(() => {});
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
/**
|
|
1822
|
+
* The ONLY owner of normal-path teardown, wired into BOTH terminal `finally`
|
|
1823
|
+
* blocks (run()'s and resume()'s — the latter is an identical bare per-member
|
|
1824
|
+
* teardown today, so wiring only run()'s would keep legacy teardown on every
|
|
1825
|
+
* detached run finishing after a resume, i.e. every crash-interrupted run).
|
|
1826
|
+
* Still skipped entirely when the run paused (§8.13) — the caller guards.
|
|
1827
|
+
*
|
|
1828
|
+
* Under `legacy` this delegates to today's _teardownWorktree / _teardownWorktreeAll
|
|
1829
|
+
* verbatim and does nothing else. Under `detached`, per member, in NORMATIVE order:
|
|
1830
|
+
* 1. modified-mount rescue (§8.20) — read-only, so it survives any later failure
|
|
1831
|
+
* 2. strip every claudeMdSection fenced block (must precede the commit — that
|
|
1832
|
+
* file is deliberately NOT in the exclusion pathspecs)
|
|
1833
|
+
* 3. _commitWork with the §8.8 exclusion set (+ status recheck, hook retry)
|
|
1834
|
+
* 4. remove this worktree's remaining injected paths
|
|
1835
|
+
* 5. removeWorktree(force:true) — the branch is ALWAYS kept
|
|
1836
|
+
* then, at the run-root level: (6) the same rescue for run-root mounts, (7) the
|
|
1837
|
+
* §8.11 stray scan, (8) the run.json durability copy, (9) guarded rm -rf (§8.13).
|
|
1838
|
+
*/
|
|
1839
|
+
async _teardownRunRoot() {
|
|
1840
|
+
if (this.runRootMode !== 'detached') {
|
|
1841
|
+
if (this.isWorkspace) await this._teardownWorktreeAll();
|
|
1842
|
+
else await this._teardownWorktree();
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
const pipelineDir = this.pipeline?.dir || null;
|
|
1846
|
+
const entries = [...this.branchInfos.entries()]; // [projectKey, info]
|
|
1847
|
+
this.branchInfos = new Map(); // guard against a double teardown
|
|
1848
|
+
const retainedMembers = [];
|
|
1849
|
+
for (const [key, info] of entries) {
|
|
1850
|
+
if (!info || !info.worktreeDir) continue;
|
|
1851
|
+
const wt = info.worktreeDir;
|
|
1852
|
+
const injected = this.injectedPaths?.[key] ?? [];
|
|
1853
|
+
// (1) rescue FIRST — read-only, so a later step failing cannot lose the edit.
|
|
1854
|
+
const rescued = await rescueModifiedMounts({
|
|
1855
|
+
baseDir: wt, entries: injected, pipelineDir, scope: key, pipelineId: this.pipeline?.id,
|
|
1856
|
+
});
|
|
1857
|
+
for (const w of rescued) await this._recordRunWarning(w);
|
|
1858
|
+
// (2) strip the worca-cc-managed CLAUDE.md fence BEFORE the commit.
|
|
1859
|
+
for (const e of injected) {
|
|
1860
|
+
if (e?.kind !== 'claudeMdSection' || !e.path) continue;
|
|
1861
|
+
try {
|
|
1862
|
+
const file = join(wt, e.path);
|
|
1863
|
+
const before = await readFile(file, 'utf8');
|
|
1864
|
+
const after = stripClaudeMdFence(before, this.pipeline?.id);
|
|
1865
|
+
if (after !== before) await writeFile(file, after, 'utf8');
|
|
1866
|
+
} catch { /* best-effort: a missing file needs no strip */ }
|
|
1867
|
+
}
|
|
1868
|
+
// (3) commit onto the kept branch, excluding every injected path.
|
|
1869
|
+
// Single-project rows persist state.branch; workspace rows persist the
|
|
1870
|
+
// per-member map inside workspace_meta. Updating state.branches for a
|
|
1871
|
+
// single run would be in-memory-only on the DB round trip.
|
|
1872
|
+
const branchRecord = this.isWorkspace
|
|
1873
|
+
? ((this.state.branches && this.state.branches[key]) || null)
|
|
1874
|
+
: this.state.branch;
|
|
1875
|
+
const commit = await this._commitWork(
|
|
1876
|
+
info, branchRecord, { excludePathspecs: this._excludePathspecs(key) },
|
|
1877
|
+
);
|
|
1878
|
+
const retained = await this._recordCommitFailure(commit, { key, info, branchRecord });
|
|
1879
|
+
// (4) remove what worca-cc injected, so nothing can be committed dangling or
|
|
1880
|
+
// outlive the run root.
|
|
1881
|
+
await removeInjectedPaths(wt, injected);
|
|
1882
|
+
if (retained) {
|
|
1883
|
+
await this._snapshotRetained(info, key);
|
|
1884
|
+
retainedMembers.push({
|
|
1885
|
+
projectKey: key,
|
|
1886
|
+
worktreeDir: wt,
|
|
1887
|
+
branch: info.branch,
|
|
1888
|
+
step: commit.step,
|
|
1889
|
+
message: commit.message,
|
|
1890
|
+
at: branchRecord?.commitFailed?.at || new Date().toISOString(),
|
|
1891
|
+
});
|
|
1892
|
+
this.workDirs.delete(key);
|
|
1893
|
+
continue;
|
|
1894
|
+
}
|
|
1895
|
+
// (5) remove the checkout; the branch is always kept.
|
|
1896
|
+
const res = await removeWorktree({
|
|
1897
|
+
projectDir: resolve(this.memberByKey.get(key)?.projectDir || this.projectDir),
|
|
1898
|
+
worktreeDir: wt,
|
|
1899
|
+
branch: null,
|
|
1900
|
+
force: true,
|
|
1901
|
+
});
|
|
1902
|
+
for (const s of res.steps.filter((x) => !x.ok)) {
|
|
1903
|
+
this._log('worktree', 'warn', `teardown ${key} ${s.step} failed: ${s.stderr || 'unknown error'}`, errStreamAttr(s.stderr));
|
|
1904
|
+
}
|
|
1905
|
+
if (this.pipeline) {
|
|
1906
|
+
await appendAudit(
|
|
1907
|
+
this.pipeline.dir,
|
|
1908
|
+
`Worktree \`${key}\` removed at \`${wt}\` (kept branch \`${info.branch}\`).`,
|
|
1909
|
+
).catch(() => {});
|
|
1910
|
+
}
|
|
1911
|
+
if (branchRecord) {
|
|
1912
|
+
branchRecord.worktreeRemoved = true;
|
|
1913
|
+
branchRecord.branchKept = true;
|
|
1914
|
+
}
|
|
1915
|
+
this.workDirs.delete(key);
|
|
1916
|
+
}
|
|
1917
|
+
// Keep the scalar mirror coherent for late observers.
|
|
1918
|
+
if (this.state.branch && !retainedMembers.length) {
|
|
1919
|
+
this.state.branch.worktreeRemoved = true;
|
|
1920
|
+
this.state.branch.branchKept = true;
|
|
1921
|
+
}
|
|
1922
|
+
this.branchInfo = null;
|
|
1923
|
+
this.workDir = this.projectDir;
|
|
1924
|
+
|
|
1925
|
+
if (this.runRoot) {
|
|
1926
|
+
// (6) run-root mounts (the workspace skill mount) — `.claude/` is whitelisted
|
|
1927
|
+
// by the §8.11 known set, so only this rescue can catch edits inside it.
|
|
1928
|
+
const rootRescued = await rescueModifiedMounts({
|
|
1929
|
+
baseDir: this.runRoot, entries: this.injectedPaths?.runRoot ?? [], pipelineDir,
|
|
1930
|
+
scope: 'runRoot', pipelineId: this.pipeline?.id,
|
|
1931
|
+
});
|
|
1932
|
+
for (const w of rootRescued) await this._recordRunWarning(w);
|
|
1933
|
+
// (7) §8.11 stray scan — nothing outside the known set is silently lost.
|
|
1934
|
+
const strays = await scanStrayEntries({ runRoot: this.runRoot, pipelineDir });
|
|
1935
|
+
for (const w of strays) await this._recordRunWarning(w);
|
|
1936
|
+
// Persist the retention decision before copying the manifest. The copy is
|
|
1937
|
+
// the durable explanation after a normal teardown removes the run root.
|
|
1938
|
+
await updateRunManifest(this.runRoot, {
|
|
1939
|
+
retain: retainedMembers.length ? {
|
|
1940
|
+
reason: RETAIN_REASONS.COMMIT_FAILED,
|
|
1941
|
+
at: retainedMembers[0].at,
|
|
1942
|
+
members: retainedMembers,
|
|
1943
|
+
} : null,
|
|
1944
|
+
});
|
|
1945
|
+
// (8) §5.2 durable ledger: the run root is about to disappear.
|
|
1946
|
+
await copyRunManifestTo(this.runRoot, pipelineDir);
|
|
1947
|
+
// (9) guarded removal (§8.13).
|
|
1948
|
+
if (retainedMembers.length) {
|
|
1949
|
+
this._log('worktree', 'warn',
|
|
1950
|
+
`Run root retained at ${this.runRoot} because ${retainedMembers.length} worktree commit(s) failed.`);
|
|
1951
|
+
} else {
|
|
1952
|
+
const removal = await rmGuarded(this.runRoot, {
|
|
1953
|
+
worcaHome: worcaHome(), pipelineId: this.pipeline?.id,
|
|
1954
|
+
});
|
|
1955
|
+
if (removal.removed) {
|
|
1956
|
+
this._log('worktree', 'info', `Run root removed at ${this.runRoot}.`);
|
|
1957
|
+
} else {
|
|
1958
|
+
this._log('worktree', 'warn', `run root NOT removed: ${removal.reason}`);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
await this._persist().catch(() => {});
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
/**
|
|
1966
|
+
* Append a warning to BOTH durable sinks (§5.2's ledger rules): the run log (which
|
|
1967
|
+
* survives teardown inside the pipeline artifact dir) and `run.json.warnings` (the
|
|
1968
|
+
* live manifest, copied out before removal). Never throws.
|
|
1969
|
+
*/
|
|
1970
|
+
async _recordRunWarning(text, attr = null) {
|
|
1971
|
+
this._log('worktree', 'warn', text, attr);
|
|
1972
|
+
if (!this.runRoot) return;
|
|
1973
|
+
try {
|
|
1974
|
+
const cur = (await readRunManifest(this.runRoot)) || {};
|
|
1975
|
+
const warnings = Array.isArray(cur.warnings) ? cur.warnings : [];
|
|
1976
|
+
await updateRunManifest(this.runRoot, { warnings: [...warnings, text] });
|
|
1977
|
+
} catch { /* best-effort */ }
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
/**
|
|
1981
|
+
* Best-effort durable copy of the retained work, written the moment retention
|
|
1982
|
+
* is decided — a crash or manual deletion before an explicit discard must not
|
|
1983
|
+
* leave the checkout as the only copy. Failure (or a clean tree) keeps the
|
|
1984
|
+
* worktree as the source of truth (same failure class as the commit itself).
|
|
1985
|
+
*/
|
|
1986
|
+
async _snapshotRetained(info, key = null) {
|
|
1987
|
+
const pipelineDir = this.pipeline?.dir;
|
|
1988
|
+
if (!pipelineDir || !info?.worktreeDir) return;
|
|
1989
|
+
const name = retainedWorkPatchName(this.isWorkspace ? key : null);
|
|
1990
|
+
const snap = await snapshotWorktreePatch(info.worktreeDir, join(pipelineDir, name));
|
|
1991
|
+
if (snap.ok && snap.file) {
|
|
1992
|
+
recordArtifact(this.pipeline.id, 'retained-work-patch', name);
|
|
1993
|
+
this._log('git', 'info', `Retained-work recovery patch saved: ${name}`);
|
|
1994
|
+
} else if (snap.ok) {
|
|
1995
|
+
this._log('git', 'info', 'Retained-work snapshot skipped: nothing uncommitted to save.');
|
|
1996
|
+
} else {
|
|
1997
|
+
this._log('git', 'warn',
|
|
1998
|
+
`retained-work patch not saved (git ${snap.step}: ${snap.message}); the worktree is the only copy`,
|
|
1999
|
+
snap.fromStderr ? ERR_STREAM : null);
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
/**
|
|
2004
|
+
* Stamp a failed teardown commit on its persisted branch record and emit both
|
|
2005
|
+
* human-readable durable traces. Returns true when the caller must keep the
|
|
2006
|
+
* checkout containing the uncommitted work.
|
|
2007
|
+
*/
|
|
2008
|
+
async _recordCommitFailure(result, { key = null, info, branchRecord } = {}) {
|
|
2009
|
+
if (result?.ok !== false) return false;
|
|
2010
|
+
const message = result.message || `git ${result.step || 'commit'} failed`;
|
|
2011
|
+
const record = {
|
|
2012
|
+
code: RETAIN_REASONS.COMMIT_FAILED,
|
|
2013
|
+
step: result.step,
|
|
2014
|
+
message,
|
|
2015
|
+
at: new Date().toISOString(),
|
|
2016
|
+
};
|
|
2017
|
+
let target = branchRecord;
|
|
2018
|
+
if (!target) {
|
|
2019
|
+
// Synthesize the record: retention must ALWAYS be visible to
|
|
2020
|
+
// retainedWorkFor/archive/discard, not only to a human reading warnings.
|
|
2021
|
+
// branchRecord came FROM state.branches[key] / state.branch, so a null one
|
|
2022
|
+
// means that slot is empty — this never overwrites a non-null record.
|
|
2023
|
+
target = { feature: info?.branch || null, worktreeDir: info?.worktreeDir || null };
|
|
2024
|
+
if (this.isWorkspace && key != null) {
|
|
2025
|
+
this.state.branches[key] = target;
|
|
2026
|
+
} else {
|
|
2027
|
+
this.state.branch = target;
|
|
2028
|
+
}
|
|
2029
|
+
await this._recordRunWarning(
|
|
2030
|
+
`${key ? `${key}: ` : ''}commit failed at git ${result.step} (${message}) with no branch record; ` +
|
|
2031
|
+
`synthesized one for the retained worktree at ${info?.worktreeDir || '(unknown)'}`,
|
|
2032
|
+
result.fromStderr ? ERR_STREAM : null,
|
|
2033
|
+
);
|
|
2034
|
+
}
|
|
2035
|
+
target.commitFailed = record;
|
|
2036
|
+
target.worktreeRemoved = false;
|
|
2037
|
+
target.branchKept = true;
|
|
2038
|
+
const prefix = key ? `${key}: ` : '';
|
|
2039
|
+
this._log('git', 'warn',
|
|
2040
|
+
`${prefix}commit failed at git ${result.step} (${message}) — KEEPING the worktree at ${info?.worktreeDir}`,
|
|
2041
|
+
result.fromStderr ? ERR_STREAM : null);
|
|
2042
|
+
if (this.pipeline) {
|
|
2043
|
+
await appendAudit(this.pipeline.dir,
|
|
2044
|
+
`Commit FAILED for \`${info?.branch || '(unknown)'}\` at git ${result.step}: ${message}. ` +
|
|
2045
|
+
`Worktree RETAINED at \`${info?.worktreeDir || '(unknown)'}\`.`).catch(() => {});
|
|
2046
|
+
}
|
|
2047
|
+
// Persist NOW. The callers' later _persist() is best-effort/swallowed; the
|
|
2048
|
+
// retention stamp must not ride on it (F2's crash window). _persist() also
|
|
2049
|
+
// swallows internally, so call the writer directly to observe a real failure.
|
|
2050
|
+
try {
|
|
2051
|
+
await writeState(this.pipeline?.dir ?? null, this.state);
|
|
2052
|
+
} catch (e) {
|
|
2053
|
+
this._log('git', 'error',
|
|
2054
|
+
`retention stamp could not be persisted (${e?.message || e}); ` +
|
|
2055
|
+
'the run.json retain record is the only durable copy');
|
|
2056
|
+
}
|
|
2057
|
+
return true;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
/**
|
|
2061
|
+
* Commit every change in the worktree onto the feature branch so the kept
|
|
2062
|
+
* branch actually carries the agent's work after the worktree is removed.
|
|
2063
|
+
* Best-effort: never throws; returns a discriminated result. Skips
|
|
2064
|
+
* cleanly when the working tree is clean (no diff from the checkpoint), which
|
|
2065
|
+
* is the truthful "no change needed" outcome. Records the SHA on state.branch.
|
|
2066
|
+
* @param {{worktreeDir:string, branch:string}} info the branch being kept
|
|
2067
|
+
* @param {object} [branchRecord] the state branch object to stamp .commit onto
|
|
2068
|
+
* (defaults to the scalar this.state.branch; a workspace member passes its own
|
|
2069
|
+
* state.branches[projectKey] so per-member SHAs are recorded distinctly).
|
|
2070
|
+
* @param {{excludePathspecs?:string[]}} [opts] §8.8 exclusion set for this
|
|
2071
|
+
* worktree. With the DEFAULT empty array — every legacy run — the method keeps
|
|
2072
|
+
* today's bare `git add -A` byte-identically (§10 rollback contract).
|
|
2073
|
+
* @returns {Promise<{ok:true,committed:boolean,sha:string|null}|
|
|
2074
|
+
* {ok:false,step:'status'|'add'|'commit',message:string,fromStderr:boolean}>}
|
|
2075
|
+
* `fromStderr` records whether `message` embeds real stderr bytes (vs. the
|
|
2076
|
+
* `exit N` fallback), so the caller's warn can tag its provenance truthfully.
|
|
2077
|
+
*/
|
|
2078
|
+
async _commitWork(info, branchRecord = this.state.branch, { excludePathspecs = [] } = {}) {
|
|
2079
|
+
const cwd = info?.worktreeDir;
|
|
2080
|
+
if (!cwd) return { ok: true, committed: false, sha: null };
|
|
2081
|
+
// ignoreAbort on every call: teardown runs after stop/error has aborted the
|
|
2082
|
+
// signal, so binding it would no-op these commands and lose the partial work.
|
|
2083
|
+
const gitOpts = { cwd, ignoreAbort: true };
|
|
2084
|
+
const status = await this._git(['status', '--porcelain'], gitOpts);
|
|
2085
|
+
if (!status.ok) {
|
|
2086
|
+
if (!existsSync(cwd)) {
|
|
2087
|
+
// The checkout is gone: there is no work to retain, and stamping
|
|
2088
|
+
// commitFailed would create an unclearable phantom retention (F15).
|
|
2089
|
+
this._log('git', 'warn', `commit skipped: worktree missing at ${cwd}`);
|
|
2090
|
+
return { ok: true, committed: false, sha: null };
|
|
2091
|
+
}
|
|
2092
|
+
const message = status.stderr.trim() || `exit ${status.code}`;
|
|
2093
|
+
this._log('git', 'warn', `commit skipped: git status failed: ${message}`, errStreamAttr(status.stderr));
|
|
2094
|
+
return { ok: false, step: 'status', message, fromStderr: !!status.stderr.trim() };
|
|
2095
|
+
}
|
|
2096
|
+
if (!status.stdout.trim()) {
|
|
2097
|
+
this._log('git', 'info', 'No changes to commit (working tree clean).');
|
|
2098
|
+
return { ok: true, committed: false, sha: null };
|
|
2099
|
+
}
|
|
2100
|
+
const add = excludePathspecs.length
|
|
2101
|
+
? await this._git(['add', '-A', '--', '.', ...excludePathspecs], gitOpts)
|
|
2102
|
+
: await this._git(['add', '-A'], gitOpts);
|
|
2103
|
+
if (!add.ok) {
|
|
2104
|
+
const message = add.stderr.trim() || `exit ${add.code}`;
|
|
2105
|
+
this._log('git', 'warn', `commit skipped: git add failed: ${message}`, errStreamAttr(add.stderr));
|
|
2106
|
+
return { ok: false, step: 'add', message, fromStderr: !!add.stderr.trim() };
|
|
2107
|
+
}
|
|
2108
|
+
// §8.8 status recheck: with mounts present the porcelain gate above is never
|
|
2109
|
+
// clean, so a run whose agent changed nothing would attempt a commit that fails
|
|
2110
|
+
// with "nothing to commit". Re-check what actually got staged.
|
|
2111
|
+
if (excludePathspecs.length) {
|
|
2112
|
+
const staged = await this._git(['diff', '--cached', '--quiet'], gitOpts);
|
|
2113
|
+
if (staged.ok) { // exit 0 => nothing staged
|
|
2114
|
+
this._log('git', 'info', 'No changes to commit (working tree clean).');
|
|
2115
|
+
return { ok: true, committed: false, sha: null };
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
const title = this.state.title || this.baseName || 'changes';
|
|
2119
|
+
const msg = `worca: ${title}${this.pipeline ? `\n\nPipeline ${this.pipeline.id}` : ''}`;
|
|
2120
|
+
// Plain commit first (uses the repo's configured identity); fall back to a
|
|
2121
|
+
// local identity so a repo with no user.name/email still commits — mirrors
|
|
2122
|
+
// _ensureGitCheckpoint's belt-and-braces.
|
|
2123
|
+
let commit = await this._git(['commit', '-m', msg], gitOpts);
|
|
2124
|
+
if (!commit.ok) {
|
|
2125
|
+
commit = await this._git(
|
|
2126
|
+
['-c', 'user.email=orchestrator@local', '-c', 'user.name=orchestrator', 'commit', '-m', msg],
|
|
2127
|
+
gitOpts,
|
|
2128
|
+
);
|
|
2129
|
+
}
|
|
2130
|
+
if (!commit.ok && excludePathspecs.length) {
|
|
2131
|
+
// §8.8 (detached runs — the same scope as the exclusion set): a failing hook
|
|
2132
|
+
// must never silently delete an agent's work. Teardown removeWorktree(force:true)s
|
|
2133
|
+
// the checkout right after a successful commit, so this commit is the ONLY thing
|
|
2134
|
+
// that carries the work onto the kept branch. A diff artifact does now survive
|
|
2135
|
+
// every terminal path (run()/resume() build results on stopped and error too),
|
|
2136
|
+
// but that is a read-only snapshot in the store — not a branch to check out,
|
|
2137
|
+
// rebase or push. Detached worktrees make hook failure MORE likely (§8.1:
|
|
2138
|
+
// husky/lint-staged resolve through an ancestor node_modules today and do not
|
|
2139
|
+
// detached). Retry ONCE with hooks disabled for that invocation only, logging
|
|
2140
|
+
// both facts.
|
|
2141
|
+
const hookErr = commit.stderr.trim() || `exit ${commit.code}`;
|
|
2142
|
+
this._log('git', 'warn', `commit failed with hooks enabled: ${hookErr}`, errStreamAttr(commit.stderr));
|
|
2143
|
+
const retry = await this._git(
|
|
2144
|
+
['-c', 'core.hooksPath=', '-c', 'user.email=orchestrator@local', '-c', 'user.name=orchestrator',
|
|
2145
|
+
'commit', '-m', msg],
|
|
2146
|
+
gitOpts,
|
|
2147
|
+
);
|
|
2148
|
+
if (retry.ok) {
|
|
2149
|
+
this._log('git', 'warn', 'retried the commit with hooks BYPASSED (core.hooksPath=) so the agent work is not lost');
|
|
2150
|
+
await this._recordRunWarning(
|
|
2151
|
+
`commit hooks failed (${hookErr}); retried with hooks bypassed so the work was not lost.`,
|
|
2152
|
+
);
|
|
2153
|
+
commit = retry;
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
if (!commit.ok) {
|
|
2157
|
+
const message = commit.stderr.trim() || `exit ${commit.code}`;
|
|
2158
|
+
this._log('git', 'warn', `commit failed: ${message}`, errStreamAttr(commit.stderr));
|
|
2159
|
+
return { ok: false, step: 'commit', message, fromStderr: !!commit.stderr.trim() };
|
|
2160
|
+
}
|
|
2161
|
+
const ref = await this._git(['rev-parse', 'HEAD'], gitOpts);
|
|
2162
|
+
const sha = ref.ok ? ref.stdout.trim() : null;
|
|
2163
|
+
if (branchRecord) branchRecord.commit = sha;
|
|
2164
|
+
if (sha && this.pipeline) {
|
|
2165
|
+
await appendAudit(
|
|
2166
|
+
this.pipeline.dir,
|
|
2167
|
+
`Committed agent work to \`${info.branch}\` at \`${sha.slice(0, 10)}\`.`,
|
|
2168
|
+
).catch(() => {});
|
|
2169
|
+
}
|
|
2170
|
+
return { ok: true, committed: true, sha };
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
/**
|
|
2174
|
+
* §9.4 preflight gate: every workflow node key must resolve in the MERGED
|
|
2175
|
+
* registry (builtin+user+plugin) BEFORE any node executes. This deliberately
|
|
2176
|
+
* supersedes the silent empty-prompt degradation for ALL origins (it was a
|
|
2177
|
+
* bug, not a feature) — resolveWorkflow keeps `reg[key] || {}` for library
|
|
2178
|
+
* callers; runs are gated HERE, covering run() and resume(). The thrown plain
|
|
2179
|
+
* Error lands in the caller's catch => status 'error' + message; the
|
|
2180
|
+
* recoverable-error gate surfaces it cleanly.
|
|
2181
|
+
* @param {Iterable<string>} agentKeys the run's distinct agent keys, in launch order
|
|
2182
|
+
*/
|
|
2183
|
+
_preflightAgentKeys(agentKeys) {
|
|
2184
|
+
const reg = this.registry || {};
|
|
2185
|
+
const missing = [];
|
|
2186
|
+
const seen = new Set();
|
|
2187
|
+
for (const key of agentKeys || []) {
|
|
2188
|
+
if (!key || seen.has(key) || Object.hasOwn(reg, key)) continue;
|
|
2189
|
+
seen.add(key);
|
|
2190
|
+
const plugin = findDisabledPluginFor(key);
|
|
2191
|
+
missing.push(plugin
|
|
2192
|
+
? `agent "${key}" comes from disabled plugin "${plugin}" — enable it`
|
|
2193
|
+
: `agent "${key}" is not installed (removed plugin?)`);
|
|
2194
|
+
}
|
|
2195
|
+
if (missing.length) {
|
|
2196
|
+
throw new Error(
|
|
2197
|
+
`Preflight failed: ${missing.length} workflow agent key(s) do not resolve:\n` +
|
|
2198
|
+
missing.map((m) => ` - ${m}`).join('\n'),
|
|
2199
|
+
);
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
/** Step-boundary budget gate. Reads settings + DB FRESH each boundary so a
|
|
2204
|
+
* raised limit or a window reset takes effect at the next step (F9). */
|
|
2205
|
+
_checkCostLimits() {
|
|
2206
|
+
if (!this.pipeline?.id) return; // pre-createPipeline: nothing to meter
|
|
2207
|
+
const pipeLimit = pipelineCostLimitUsd();
|
|
2208
|
+
// resume() rehydrates state.steps but not state.totalCostUsd, so the row
|
|
2209
|
+
// total reads $0 until the first cost event of the resumed run. Take the
|
|
2210
|
+
// larger of the two so a resumed over-cap pipeline cannot run one free step.
|
|
2211
|
+
const spentHere = Math.max(this.state.totalCostUsd || 0, sumStepCosts(this.state.steps));
|
|
2212
|
+
if (pipeLimit != null && spentHere >= pipeLimit
|
|
2213
|
+
&& !readCostCapOverride(this.pipeline.id)) {
|
|
2214
|
+
this._pauseForCost('cost_pipeline',
|
|
2215
|
+
`pipeline cost limit reached ($${spentHere.toFixed(2)} >= $${pipeLimit.toFixed(2)})`);
|
|
2216
|
+
}
|
|
2217
|
+
const totalLimit = totalCostLimitUsd();
|
|
2218
|
+
if (totalLimit != null) {
|
|
2219
|
+
const period = costLimitResetPeriod();
|
|
2220
|
+
const spent = totalWindowSpendUsd(costWindowStart(new Date(), period).getTime());
|
|
2221
|
+
if (spent >= totalLimit) {
|
|
2222
|
+
this._pauseForCost('cost_total',
|
|
2223
|
+
`total cost limit reached ($${spent.toFixed(2)} >= $${totalLimit.toFixed(2)} this ${period === 'weekly' ? 'week' : 'month'})`);
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
/** Mirror of _pauseForLimit, but with a MACHINE-READABLE reason code
|
|
2229
|
+
* ('cost_pipeline' | 'cost_total') the UI switches on. Unlike _pauseForLimit
|
|
2230
|
+
* (whose caller throws), this throws itself — its caller is the boundary
|
|
2231
|
+
* gate, not a catch block. The audit line here is required: _completePaused
|
|
2232
|
+
* suppresses its generic audit whenever pauseReason is set. */
|
|
2233
|
+
_pauseForCost(code, detail) {
|
|
2234
|
+
if (!this.pauseReason) this.pauseReason = code;
|
|
2235
|
+
this._log('orchestrator', 'warn', `${detail} — pausing for manual resume`);
|
|
2236
|
+
appendAudit(this.pipeline.dir, `Pipeline **paused**: ${detail}.`).catch(() => {});
|
|
2237
|
+
this.pause();
|
|
2238
|
+
throw pauseErr();
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
/** Decide how to recover from a classified error. Auto mode: bounded backoff
|
|
2242
|
+
* then give up (and abort immediately if a pause fired during backoff, so a
|
|
2243
|
+
* pause is never followed by a wasted retry). Interactive: ONE shared prompt
|
|
2244
|
+
* per error class (same-class siblings await the same answer), and distinct
|
|
2245
|
+
* classes are serialized so only one recovery prompt is open at a time (the
|
|
2246
|
+
* gate holds a single pendingQuestion). Returns 'retry' | 'abort'. */
|
|
2247
|
+
async _recover({ node, cls, err, attempt }) {
|
|
2248
|
+
this._log(node.key, 'warn', `recoverable ${cls} error: ${err.message}`, err?.stream ? ERR_STREAM : null);
|
|
2249
|
+
await appendAudit(this.pipeline.dir, `Recoverable **${cls}** error on ${node.key}: ${firstLine(err.message)}`).catch(() => {});
|
|
2250
|
+
|
|
2251
|
+
if (this.auto) {
|
|
2252
|
+
if (attempt > RECOVERY_MAX_AUTO_ATTEMPTS) return 'abort';
|
|
2253
|
+
await this._backoff(attempt, this.pauseAbort.signal);
|
|
2254
|
+
// A pause during backoff must win: abort instead of retrying. The loop's
|
|
2255
|
+
// outer catch then re-classifies the thrown error under pauseRequested and
|
|
2256
|
+
// unwinds as a pause (the pauseAbort signal is aborted).
|
|
2257
|
+
if (this.pauseRequested || this.pauseAbort.signal.aborted) return 'abort';
|
|
2258
|
+
return 'retry';
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
this._recovery ||= new Map();
|
|
2262
|
+
if (!this._recovery.has(cls)) {
|
|
2263
|
+
const p = this._enqueueRecoveryPrompt(cls, firstLine(err.message))
|
|
2264
|
+
.finally(() => { if (this._recovery) this._recovery.delete(cls); });
|
|
2265
|
+
this._recovery.set(cls, p);
|
|
2266
|
+
}
|
|
2267
|
+
return this._recovery.get(cls);
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
/** Open a recovery prompt for one class, serialized behind any in-flight
|
|
2271
|
+
* recovery prompt (the question gate has a single pendingQuestion slot, so
|
|
2272
|
+
* distinct classes must queue — see the clarify answer). Returns 'retry'|'abort'. */
|
|
2273
|
+
_enqueueRecoveryPrompt(cls, message) {
|
|
2274
|
+
const run = () =>
|
|
2275
|
+
this._ask({
|
|
2276
|
+
id: `recovery-${cls}-${this._recoveryNonce()}`,
|
|
2277
|
+
kind: 'recovery',
|
|
2278
|
+
recovery: { cls, message },
|
|
2279
|
+
}).then((ans) => (ans && ans.decision === 'abort' ? 'abort' : 'retry'));
|
|
2280
|
+
return this._enqueueAsk(run);
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
/** Serialize an _ask-producing thunk behind any in-flight prompt (the gate
|
|
2284
|
+
* holds a single pendingQuestion slot; recovery AND step questions share
|
|
2285
|
+
* this tail so parallel nodes can never clobber each other's prompt). */
|
|
2286
|
+
_enqueueAsk(run) {
|
|
2287
|
+
const prev = this._askTail || Promise.resolve();
|
|
2288
|
+
const next = prev.then(run, run);
|
|
2289
|
+
this._askTail = next.catch(() => {}); // tail must never reject the chain
|
|
2290
|
+
return next;
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
/** Abort-aware backoff: base * 2^(attempt-1) ms, resolving early (and still
|
|
2294
|
+
* 'retry') if the pause-only signal fires so a pause is not delayed. */
|
|
2295
|
+
_backoff(attempt, signal) {
|
|
2296
|
+
const base = (() => {
|
|
2297
|
+
const n = Number(process.env.WORCA_RECOVERY_BACKOFF_MS);
|
|
2298
|
+
return Number.isFinite(n) && n >= 0 ? n : 1000;
|
|
2299
|
+
})();
|
|
2300
|
+
const ms = base * Math.pow(2, Math.max(0, attempt - 1));
|
|
2301
|
+
if (!ms) return Promise.resolve();
|
|
2302
|
+
return new Promise((res) => {
|
|
2303
|
+
const t = setTimeout(res, ms);
|
|
2304
|
+
t.unref?.();
|
|
2305
|
+
if (signal) {
|
|
2306
|
+
if (signal.aborted) { clearTimeout(t); res(); }
|
|
2307
|
+
else signal.addEventListener('abort', () => { clearTimeout(t); res(); }, { once: true });
|
|
2308
|
+
}
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
/** Monotonic id source for recovery prompts (no Date.now/random — replay-safe). */
|
|
2313
|
+
_recoveryNonce() {
|
|
2314
|
+
return ++this._recoverySeq;
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
/**
|
|
2318
|
+
* Build the read-only `workspace` metadata channel handle (the bus value for the
|
|
2319
|
+
* workspace channel): the frozen description + the member set with each member's
|
|
2320
|
+
* worktree dir, checkpoint ref, and per-project graph instruction. Seeded once by
|
|
2321
|
+
* _dispatch and never re-published (CONV-6). Members are in sorted-projectKey order.
|
|
2322
|
+
*/
|
|
2323
|
+
_workspaceChannel() {
|
|
2324
|
+
return {
|
|
2325
|
+
kind: 'metadata',
|
|
2326
|
+
workspaceDescription: this.workspaceDescription,
|
|
2327
|
+
projects: this.members.map((m) => ({
|
|
2328
|
+
projectKey: m.projectKey,
|
|
2329
|
+
projectName: m.projectName,
|
|
2330
|
+
worktreeDir: this.workDirs.get(m.projectKey),
|
|
2331
|
+
checkpointRef: this.checkpointRefs[m.projectKey],
|
|
2332
|
+
graphInstruction: this.toolInstructions.get(m.projectKey) || '',
|
|
2333
|
+
})),
|
|
2334
|
+
};
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
/**
|
|
2338
|
+
* The per-member roster every node ctx carries (§5.8). Absolute `dir` always;
|
|
2339
|
+
* `relDir` is a RENDER-ONLY token, emitted on detached runs only. `checkpointRef`
|
|
2340
|
+
* is populated in BOTH modes (the _ensureGitCheckpoint mirror). Single mode's
|
|
2341
|
+
* toolInstructions map is empty in both modes, so graphInstruction degrades to ''
|
|
2342
|
+
* — every renderer must tolerate that.
|
|
2343
|
+
*/
|
|
2344
|
+
_reposCtx() {
|
|
2345
|
+
return this.members.map((m) => ({
|
|
2346
|
+
projectKey: m.projectKey,
|
|
2347
|
+
projectName: m.projectName,
|
|
2348
|
+
dir: this.workDirs.get(m.projectKey) || null,
|
|
2349
|
+
relDir: this.runRootMode === 'detached' ? `repos/${m.projectKey}` : null, // render-only token
|
|
2350
|
+
checkpointRef: this.checkpointRefs[m.projectKey] || null,
|
|
2351
|
+
graphInstruction: this.toolInstructions.get(m.projectKey) || '',
|
|
2352
|
+
}));
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
/**
|
|
2356
|
+
* Emit a question and await its resolution. Honors auto-mode.
|
|
2357
|
+
* Freezes the active-time clock while blocked on the user (active-time-only).
|
|
2358
|
+
* @returns {Promise<any>} the answer payload
|
|
2359
|
+
*/
|
|
2360
|
+
async _ask({ id, kind, questions, issues, recovery, agent, nodeId, wireId, executionId, deliveryNo, holdNo }) {
|
|
2361
|
+
this._checkAbort();
|
|
2362
|
+
// No interactive prompt may OPEN on a pausing run. pause() rejects only the
|
|
2363
|
+
// prompt that is currently open; a queued ask (a parallel sibling's questions
|
|
2364
|
+
// or a recovery prompt behind the _askTail chain) would otherwise still fire
|
|
2365
|
+
// and emit a fresh 'question' on a pausing/paused run (stale gate in the UI,
|
|
2366
|
+
// readline prompt while the CLI exits). Unwind it as a pause instead — the
|
|
2367
|
+
// owning node marks 'paused', exactly like every other pause path.
|
|
2368
|
+
this._checkPause();
|
|
2369
|
+
|
|
2370
|
+
// Freeze the active-time clock(s) while we wait on the user (active-time-only).
|
|
2371
|
+
// EVERY running row, not just the first one found: concurrent executions are
|
|
2372
|
+
// normal on both engines, and a single-row freeze left the asking execution
|
|
2373
|
+
// counting the user's think time as active.
|
|
2374
|
+
const frozen = this._runningStepKeys();
|
|
2375
|
+
if (frozen.length) {
|
|
2376
|
+
for (const key of frozen) this._clockPause(key);
|
|
2377
|
+
this.state.totalActiveMs = sumStepActive(this.state.steps);
|
|
2378
|
+
this._emit('state', this.getState()); // UI freezes the live timer
|
|
2379
|
+
this._persist().catch(() => {});
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
this._emit('question', {
|
|
2383
|
+
id, kind, questions, issues, recovery, agent, nodeId,
|
|
2384
|
+
...(wireId != null ? { wireId } : {}), // v2 gates name their wire
|
|
2385
|
+
...(executionId != null ? { executionId } : {}), // v2 asks name their execution
|
|
2386
|
+
// A gate's CYCLE and hold ordinal. The id is opaque (a re-hold suffixes
|
|
2387
|
+
// `-h<holdNo>`), so every consumer — the CLI header, the monitor, the audit
|
|
2388
|
+
// trail — reads these fields instead of parsing the id (MAJ-11).
|
|
2389
|
+
...(deliveryNo != null ? { deliveryNo } : {}),
|
|
2390
|
+
...(holdNo != null ? { holdNo } : {}),
|
|
2391
|
+
});
|
|
2392
|
+
|
|
2393
|
+
try {
|
|
2394
|
+
if (this.auto) {
|
|
2395
|
+
if (kind === 'recovery') {
|
|
2396
|
+
// Auto mode handles recovery in _recover before ever calling _ask;
|
|
2397
|
+
// this is a defensive fallback so an auto run can never hang.
|
|
2398
|
+
return { decision: 'abort' };
|
|
2399
|
+
}
|
|
2400
|
+
if (kind === 'clarify' || kind === 'questions') {
|
|
2401
|
+
this._log('orchestrator', 'info', `auto-answering ${kind} ${id}`);
|
|
2402
|
+
return {
|
|
2403
|
+
answers: (questions || []).map((q) => ({
|
|
2404
|
+
id: q.id,
|
|
2405
|
+
choice: (q.options && q.options.find((o) => o && o.trim())) || 'auto',
|
|
2406
|
+
})),
|
|
2407
|
+
};
|
|
2408
|
+
}
|
|
2409
|
+
this._log('orchestrator', 'info', `auto-answering gate ${id} -> continue`);
|
|
2410
|
+
return { decision: 'continue' };
|
|
2411
|
+
}
|
|
2412
|
+
return await new Promise((resolveP, rejectP) => {
|
|
2413
|
+
this.pendingQuestion = { id, kind, resolve: resolveP, reject: rejectP };
|
|
2414
|
+
});
|
|
2415
|
+
} finally {
|
|
2416
|
+
// Resume only the rows that are STILL running AND only while the run has not
|
|
2417
|
+
// gone terminal. stop() sets status before rejecting the pending promise, so
|
|
2418
|
+
// on a stop-while-blocked we must NOT resume (the terminal _setStatus already
|
|
2419
|
+
// folded every clock). Gates fire after a step's 'done', so `frozen` is
|
|
2420
|
+
// usually empty there and nothing resumes anyway.
|
|
2421
|
+
//
|
|
2422
|
+
// The `status === 'start'` guard is load-bearing: a row that reached its
|
|
2423
|
+
// terminal marker WHILE the prompt was open was already clock-paused by that
|
|
2424
|
+
// marker, and resuming it would set runningSince on a finished step that
|
|
2425
|
+
// nothing will ever pause again.
|
|
2426
|
+
const stillRunning = ['stopped', 'error', 'pausing', 'paused'].includes(this.state.status)
|
|
2427
|
+
? []
|
|
2428
|
+
: frozen.filter((key) => this.state.steps.find((s) => s.key === key)?.status === 'start');
|
|
2429
|
+
if (stillRunning.length) {
|
|
2430
|
+
for (const key of stillRunning) this._clockResume(key);
|
|
2431
|
+
this._emit('state', this.getState());
|
|
2432
|
+
this._persist().catch(() => {});
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
/** List the user's attached files copied into <pipeline>/extras/ (basename + abs
|
|
2438
|
+
* path), sorted for deterministic seeded-file content. Empty when none were
|
|
2439
|
+
* attached or the dir is absent. */
|
|
2440
|
+
async _collectExtras() {
|
|
2441
|
+
try {
|
|
2442
|
+
const dir = join(this.pipeline.dir, 'extras');
|
|
2443
|
+
const names = (await readdir(dir)).sort();
|
|
2444
|
+
return names.map((name) => ({ name, path: join(dir, name) }));
|
|
2445
|
+
} catch {
|
|
2446
|
+
return [];
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
/**
|
|
2451
|
+
* Ensure `dir` is its OWN git repo with at least one commit, and return its
|
|
2452
|
+
* checkpoint ref (HEAD), or null when none could be established. Pure of state
|
|
2453
|
+
* writes — the caller wires checkpointRef(s)/state. Single-project and each
|
|
2454
|
+
* workspace member call this with their own dir (D3: never an enclosing repo).
|
|
2455
|
+
* @param {string} dir
|
|
2456
|
+
* @returns {Promise<string|null>}
|
|
2457
|
+
*/
|
|
2458
|
+
async _ensureGitCheckpointFor(dir) {
|
|
2459
|
+
// C2: `--is-inside-work-tree` is true even when dir merely sits *inside* an
|
|
2460
|
+
// enclosing repo (no .git of its own). Acting on that parent repo would
|
|
2461
|
+
// silently create worca-cc/* branches + checkpoint commits in the developer's
|
|
2462
|
+
// real repo. Require dir to BE the repo toplevel; if it isn't (no repo, or
|
|
2463
|
+
// only a parent repo), `git init` a dedicated repo here.
|
|
2464
|
+
const projReal = await realpath(dir).catch(() => resolve(dir));
|
|
2465
|
+
const top = await this._git(['rev-parse', '--show-toplevel'], { cwd: dir });
|
|
2466
|
+
let topReal = null;
|
|
2467
|
+
if (top.ok && top.stdout.trim()) {
|
|
2468
|
+
topReal = await realpath(top.stdout.trim()).catch(() => top.stdout.trim());
|
|
2469
|
+
}
|
|
2470
|
+
const isOwnRepo = topReal === projReal;
|
|
2471
|
+
if (!isOwnRepo) {
|
|
2472
|
+
if (topReal) {
|
|
2473
|
+
this._log(
|
|
2474
|
+
'git',
|
|
2475
|
+
'info',
|
|
2476
|
+
`${dir} is nested in repo ${topReal}; initializing a dedicated repo to isolate worktrees.`,
|
|
2477
|
+
);
|
|
2478
|
+
}
|
|
2479
|
+
await this._git(['init'], { cwd: dir });
|
|
2480
|
+
// Ensure an identity exists for the commit (local, non-destructive).
|
|
2481
|
+
await this._git(['config', 'user.email', 'orchestrator@local'], { cwd: dir });
|
|
2482
|
+
await this._git(['config', 'user.name', 'orchestrator'], { cwd: dir });
|
|
2483
|
+
}
|
|
2484
|
+
// Is there any commit yet?
|
|
2485
|
+
const head = await this._git(['rev-parse', 'HEAD'], { cwd: dir });
|
|
2486
|
+
if (!head.ok) {
|
|
2487
|
+
await this._git(['add', '-A'], { cwd: dir });
|
|
2488
|
+
const commit = await this._git([
|
|
2489
|
+
'-c',
|
|
2490
|
+
'user.email=orchestrator@local',
|
|
2491
|
+
'-c',
|
|
2492
|
+
'user.name=orchestrator',
|
|
2493
|
+
'commit',
|
|
2494
|
+
'--allow-empty',
|
|
2495
|
+
'-m',
|
|
2496
|
+
'orchestrator: initial checkpoint',
|
|
2497
|
+
], { cwd: dir });
|
|
2498
|
+
if (!commit.ok) {
|
|
2499
|
+
this._log('git', 'warn', `initial commit failed: ${commit.stderr.trim()}`, errStreamAttr(commit.stderr));
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
const ref = await this._git(['rev-parse', 'HEAD'], { cwd: dir });
|
|
2503
|
+
return ref.ok ? ref.stdout.trim() : null;
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
/**
|
|
2507
|
+
* Layer 1: build + persist the deterministic results view while the worktree(s)
|
|
2508
|
+
* and checkpoint refs are still live. Best-effort: never throws into run().
|
|
2509
|
+
*/
|
|
2510
|
+
async _buildResults({ stage = false } = {}) {
|
|
2511
|
+
if (!this.pipeline) return;
|
|
2512
|
+
try {
|
|
2513
|
+
// stage: the non-done terminal paths never reached the review loop's staging
|
|
2514
|
+
// (:2204, :2311), so `git add -A -N` has not run and the `git diff <checkpoint>`
|
|
2515
|
+
// below cannot see a file the agent CREATED — the kept branch would carry it
|
|
2516
|
+
// while the persisted patch showed nothing. ignoreAbort for the same reason
|
|
2517
|
+
// _commitWork pins it (:1804): stop() has already tripped this.abort, and a
|
|
2518
|
+
// bound signal kills the staging before git can touch the index.
|
|
2519
|
+
// INSIDE the try: the stopped path calls _buildResults from run()'s catch, so
|
|
2520
|
+
// anything that escaped here would reject run() itself.
|
|
2521
|
+
if (stage) await this._stageWorkingTree({ ignoreAbort: true });
|
|
2522
|
+
const reviews = readPipelineExtras(this.pipeline.id).reviews || [];
|
|
2523
|
+
// Unified iteration over workDirs + checkpointRefs — the ref map is filled in
|
|
2524
|
+
// BOTH modes (the _ensureGitCheckpoint mirror), so a single-project run reads
|
|
2525
|
+
// the same shape. The single-project OUTPUT shape stays byte-identical (one
|
|
2526
|
+
// results.json, one un-prefixed patch) via the members.length === 1 special case.
|
|
2527
|
+
const members = [];
|
|
2528
|
+
const patches = [];
|
|
2529
|
+
for (const [key, dir] of this.workDirs.entries()) {
|
|
2530
|
+
const base = this.checkpointRefs[key];
|
|
2531
|
+
if (!base) continue;
|
|
2532
|
+
// §8.8: the same exclusion set the commit uses, so results.json and
|
|
2533
|
+
// diff.patch agree with what _commitWork actually committed.
|
|
2534
|
+
const ex = this._excludePathspecs(key);
|
|
2535
|
+
const [ns, num, patch] = await Promise.all([
|
|
2536
|
+
diffNameStatus(dir, base, undefined, ex),
|
|
2537
|
+
diffNumstat(dir, base, undefined, ex),
|
|
2538
|
+
diffPatch(dir, base, undefined, ex),
|
|
2539
|
+
]);
|
|
2540
|
+
const results = assembleResults({ nameStatus: ns, numstat: num, reviews });
|
|
2541
|
+
members.push({ projectKey: key, results });
|
|
2542
|
+
patches.push({ key, patch, listed: ns.length > 0 });
|
|
2543
|
+
}
|
|
2544
|
+
if (!members.length) return;
|
|
2545
|
+
// Nothing changed under the checkpoint. Persisting here would index a 0-byte
|
|
2546
|
+
// diff-patch.patch plus an all-zero results.json, and every downstream
|
|
2547
|
+
// "does this run have a diff?" test is an EXISTENCE test, not an emptiness
|
|
2548
|
+
// one: /diff answers 200-empty instead of 404 (ui/server.mjs:1994 tests
|
|
2549
|
+
// `text == null`), /recovery-patch serves an empty attachment (:1690), the
|
|
2550
|
+
// comments routes report patchAvailable:false and then 409 every create (:1882),
|
|
2551
|
+
// and History detail opens on the Diff tab to render "(no files changed)"
|
|
2552
|
+
// (app.js:11110 tests `d.results`). Write nothing — absent IS the truth, and
|
|
2553
|
+
// it is the state the UI's empty state already describes.
|
|
2554
|
+
const noPatch = patches.every((p) => !p.patch);
|
|
2555
|
+
// An EMPTY patch while name-status lists changes is a failed `git diff`
|
|
2556
|
+
// spawn (diffPatch returns '' on error), not a clean tree — say so, and
|
|
2557
|
+
// still persist the results the other two diffs produced.
|
|
2558
|
+
const listed = patches.some((p) => p.listed);
|
|
2559
|
+
if (noPatch && listed) this._log('results', 'warn', 'diff patch is empty although name-status lists changes — git diff failed; results.json is persisted without a patch');
|
|
2560
|
+
// Stopped/error paths (`stage`) with nothing changed write nothing — absent IS
|
|
2561
|
+
// the truth (above). The DONE path always persists results.json: it carries
|
|
2562
|
+
// the review-derived keyThingsToCheck/blockingIssues that the task-source
|
|
2563
|
+
// write-back (sources.mjs) and History read, so a review-only / plan-only /
|
|
2564
|
+
// no-op run must not lose them (review of PR #376). The 0-byte
|
|
2565
|
+
// diff-patch.patch is still never written on any path.
|
|
2566
|
+
if (noPatch && stage && !listed) return;
|
|
2567
|
+
if (members.length === 1 && !this.isWorkspace) {
|
|
2568
|
+
await persistResults(this.pipeline.dir, members[0].results);
|
|
2569
|
+
if (!noPatch) await persistDiffPatch(this.pipeline.dir, patches[0].patch);
|
|
2570
|
+
} else {
|
|
2571
|
+
const perProject = buildPerProject(members);
|
|
2572
|
+
const results = { summary: rollupSummary(perProject), perProject };
|
|
2573
|
+
await persistResults(this.pipeline.dir, results);
|
|
2574
|
+
if (!noPatch) await persistDiffPatch(this.pipeline.dir, patches.map((p) => `# ${p.key}\n${p.patch}`).join('\n\n'));
|
|
2575
|
+
}
|
|
2576
|
+
} catch (err) {
|
|
2577
|
+
this._log('results', 'warn', `results build failed: ${err.message}`);
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
/**
|
|
2582
|
+
* Task-source write-back (spec §7.5): report the finished run to the plugin
|
|
2583
|
+
* source that produced it. Runs on EVERY terminal path and ALWAYS after
|
|
2584
|
+
* _buildResults() — done (statusToResult -> 'completed') and stopped/error alike
|
|
2585
|
+
* (-> 'failed'; chat-connectivity design PR12 closed the old success-only gap).
|
|
2586
|
+
* So the payload is the same SHAPE on all three: retryWriteback reads
|
|
2587
|
+
* results.json (sources.mjs:215), and a stopped/error run that persisted one now
|
|
2588
|
+
* carries the diffstat and "Key things to check" lines too. Only a run with
|
|
2589
|
+
* nothing to persist — no checkpoint, or an empty diff under it — falls back to
|
|
2590
|
+
* the thin status-only summary. NEVER throws and
|
|
2591
|
+
* never fails the run: a failure emits a warn `log` event and the results view
|
|
2592
|
+
* offers a manual retry via the same retryWriteback (Task 15 endpoint, Task 21
|
|
2593
|
+
* button). Prompt/markdown
|
|
2594
|
+
* runs skip inside retryWriteback before any work — feature-off runs pay
|
|
2595
|
+
* nothing here. Bounded by the shim's per-op timeout.
|
|
2596
|
+
*/
|
|
2597
|
+
async _reportToSource() {
|
|
2598
|
+
if (!this.pipeline) return;
|
|
2599
|
+
try {
|
|
2600
|
+
const outcome = await retryWriteback(this.pipeline.id);
|
|
2601
|
+
if (outcome?.ok === false) {
|
|
2602
|
+
this._log('writeback', 'warn', `task-source write-back failed: ${outcome.error} — use "Report result" in the results view to retry`);
|
|
2603
|
+
} else if (outcome?.ok && !outcome.skipped) {
|
|
2604
|
+
await appendAudit(this.pipeline.dir, 'Result reported back to the task source.').catch(() => {});
|
|
2605
|
+
}
|
|
2606
|
+
} catch (err) {
|
|
2607
|
+
this._log('writeback', 'warn', `task-source write-back failed: ${err?.message || err}`);
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
/** Single-project checkpoint: own repo + commit, record the scalar ref + state. */
|
|
2612
|
+
async _ensureGitCheckpoint() {
|
|
2613
|
+
this.checkpointRef = await this._ensureGitCheckpointFor(this.projectDir);
|
|
2614
|
+
this.state.checkpointRef = this.checkpointRef;
|
|
2615
|
+
// Mirror into the per-member map so the unified _buildResults / _reposCtx
|
|
2616
|
+
// iteration reads ONE shape in both modes. Without this the unified iteration
|
|
2617
|
+
// hits `if (!base) continue` and silently writes empty results/diff on every
|
|
2618
|
+
// single-project run (§5.2 step 4).
|
|
2619
|
+
const onlyKey = this.members[0]?.projectKey;
|
|
2620
|
+
if (onlyKey) {
|
|
2621
|
+
this.checkpointRefs[onlyKey] = this.checkpointRef;
|
|
2622
|
+
this.state.checkpointRefs = { ...this.checkpointRefs };
|
|
2623
|
+
}
|
|
2624
|
+
if (this.checkpointRef) {
|
|
2625
|
+
await appendAudit(
|
|
2626
|
+
this.pipeline.dir,
|
|
2627
|
+
`Git checkpoint at \`${this.checkpointRef.slice(0, 10)}\`.`,
|
|
2628
|
+
);
|
|
2629
|
+
} else {
|
|
2630
|
+
this._log('git', 'warn', 'No git checkpoint ref could be established (continuing).');
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
/**
|
|
2635
|
+
* Workspace checkpoint: run _ensureGitCheckpointFor once per member (serial —
|
|
2636
|
+
* git is cheap and serial avoids interleaved index locks), record
|
|
2637
|
+
* this.checkpointRefs[projectKey], mirror the scalar this.checkpointRef to the
|
|
2638
|
+
* primary, and write state.checkpointRefs (+ scalar). Members are iterated in
|
|
2639
|
+
* sorted-projectKey order so the primary is members[0].
|
|
2640
|
+
*/
|
|
2641
|
+
async _ensureGitCheckpointAll() {
|
|
2642
|
+
for (const m of this.members) {
|
|
2643
|
+
const ref = await this._ensureGitCheckpointFor(resolve(m.projectDir));
|
|
2644
|
+
this.checkpointRefs[m.projectKey] = ref;
|
|
2645
|
+
if (ref) {
|
|
2646
|
+
await appendAudit(
|
|
2647
|
+
this.pipeline.dir,
|
|
2648
|
+
`Git checkpoint for \`${m.projectKey}\` at \`${ref.slice(0, 10)}\`.`,
|
|
2649
|
+
).catch(() => {});
|
|
2650
|
+
} else {
|
|
2651
|
+
this._log('git', 'warn', `No git checkpoint ref for ${m.projectKey} (continuing).`);
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
const primaryKey = this.members[0]?.projectKey;
|
|
2655
|
+
this.checkpointRef = primaryKey ? this.checkpointRefs[primaryKey] : null;
|
|
2656
|
+
this.state.checkpointRef = this.checkpointRef;
|
|
2657
|
+
this.state.checkpointRefs = { ...this.checkpointRefs };
|
|
2658
|
+
await this._persist();
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
/**
|
|
2662
|
+
* Stage every change in the working tree with intent-to-add so that newly
|
|
2663
|
+
* created (untracked) files show up in a plain `git diff` for the reviewer.
|
|
2664
|
+
* Uses `git add -A -N`: it records intent-to-add for new paths (making their
|
|
2665
|
+
* content visible to `git diff`) without actually creating a commit, so the
|
|
2666
|
+
* checkpoint commit remains the single diff base. Best-effort; never throws.
|
|
2667
|
+
* `ignoreAbort` is for the terminal-path callers only (_buildResults on stop /
|
|
2668
|
+
* error): every in-run caller must stay killable by stop().
|
|
2669
|
+
* @param {{ignoreAbort?:boolean}} [opts]
|
|
2670
|
+
*/
|
|
2671
|
+
async _stageWorkingTree({ ignoreAbort = false } = {}) {
|
|
2672
|
+
// Stage EVERY member worktree (keyed — the pathspec lookup needs the projectKey)
|
|
2673
|
+
// so each per-project reviewer's `git diff` sees that project's agent edits.
|
|
2674
|
+
// Single-project runs have exactly one entry (populated in both modes). The
|
|
2675
|
+
// isWorkspace branch is gone: workDirs is the single shape. An empty map means
|
|
2676
|
+
// setup never ran, in which case staging must be a NO-OP — the old single arm
|
|
2677
|
+
// fell back to this.workDir, which pre-setup is the user's LIVE checkout.
|
|
2678
|
+
for (const [key, dir] of this.workDirs.entries()) {
|
|
2679
|
+
// §8.8: an empty exclusion set (every legacy run) reproduces today's argv
|
|
2680
|
+
// byte-identically — `--` with no trailing pathspec is a no-op for git add.
|
|
2681
|
+
const ex = this._excludePathspecs(key);
|
|
2682
|
+
const args = ex.length ? ['add', '-A', '-N', '--', '.', ...ex] : ['add', '-A', '-N'];
|
|
2683
|
+
const res = await this._git(args, { cwd: dir, ignoreAbort });
|
|
2684
|
+
if (!res.ok && res.stderr && res.stderr.trim()) {
|
|
2685
|
+
this._log('git', 'debug', `git add -A -N (${dir}): ${res.stderr.trim()}`, ERR_STREAM);
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
/**
|
|
2691
|
+
* §8.8: the ONE pathspec set that keeps worca-cc's injected paths out of the
|
|
2692
|
+
* commit, the reviewer's intent-to-add staging, and all three result diffs.
|
|
2693
|
+
* `kind:'claudeMdSection'` entries are deliberately EXCLUDED from the set — their
|
|
2694
|
+
* file is the user's tracked CLAUDE.md, and a blanket `:(exclude)CLAUDE.md` would
|
|
2695
|
+
* silently strip the agent's legitimate edits (teardown strips the fence instead).
|
|
2696
|
+
* Returns [] under legacy and through Phase 2 (this.injectedPaths is always {}),
|
|
2697
|
+
* which is what makes every legacy argv byte-identical.
|
|
2698
|
+
* @param {string} projectKey
|
|
2699
|
+
* @returns {string[]}
|
|
2700
|
+
*/
|
|
2701
|
+
_excludePathspecs(projectKey) {
|
|
2702
|
+
const entries = this.injectedPaths?.[projectKey] ?? [];
|
|
2703
|
+
if (!Array.isArray(entries) || !entries.length) return [];
|
|
2704
|
+
return entries
|
|
2705
|
+
.filter((e) => e && e.path && e.kind !== 'claudeMdSection')
|
|
2706
|
+
.map((e) => `:(exclude)${e.path}`);
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
/**
|
|
2710
|
+
* Run a git command in the project dir. Never throws; returns
|
|
2711
|
+
* { ok, code, stdout, stderr }. Honors the abort signal.
|
|
2712
|
+
*/
|
|
2713
|
+
_git(args, { cwd, ignoreAbort = false } = {}) {
|
|
2714
|
+
return new Promise((resolveP) => {
|
|
2715
|
+
let child;
|
|
2716
|
+
try {
|
|
2717
|
+
child = spawn('git', args, {
|
|
2718
|
+
cwd: cwd || this.projectDir,
|
|
2719
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2720
|
+
// ignoreAbort: teardown commits run AFTER the run is aborted (stop/error);
|
|
2721
|
+
// binding the aborted signal here would kill them instantly and leave the
|
|
2722
|
+
// kept branch empty. Cleanup git must outlive the abort.
|
|
2723
|
+
signal: ignoreAbort ? undefined : this.abort.signal,
|
|
2724
|
+
});
|
|
2725
|
+
} catch (err) {
|
|
2726
|
+
resolveP({ ok: false, code: -1, stdout: '', stderr: err.message });
|
|
2727
|
+
return;
|
|
2728
|
+
}
|
|
2729
|
+
let stdout = '';
|
|
2730
|
+
let stderr = '';
|
|
2731
|
+
child.stdout?.on('data', (d) => (stdout += d.toString()));
|
|
2732
|
+
child.stderr?.on('data', (d) => (stderr += d.toString()));
|
|
2733
|
+
child.on('error', (err) =>
|
|
2734
|
+
resolveP({ ok: false, code: -1, stdout, stderr: stderr || err.message }),
|
|
2735
|
+
);
|
|
2736
|
+
child.on('close', (code) => resolveP({ ok: code === 0, code: code ?? -1, stdout, stderr }));
|
|
2737
|
+
});
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
/** Bulk-load every registry agent's .md body keyed by agent key (fallback layer
|
|
2741
|
+
* for runners whose ctx has no node, e.g. the clarify pre-step; dispatched nodes
|
|
2742
|
+
* prefer node.agentPrompt via phases.resolveAgentBody). Registry-driven: built-in
|
|
2743
|
+
* AND user agents load from their own layer via meta.agentPath. */
|
|
2744
|
+
async _loadAgentPrompts() {
|
|
2745
|
+
const prompts = {};
|
|
2746
|
+
const registry = this.registry || loadAgentRegistry(this.agentsDir);
|
|
2747
|
+
for (const meta of Object.values(registry)) {
|
|
2748
|
+
if (!meta.agentPath) { prompts[meta.key] = ''; continue; }
|
|
2749
|
+
try {
|
|
2750
|
+
prompts[meta.key] = await readFile(meta.agentPath, 'utf8');
|
|
2751
|
+
} catch {
|
|
2752
|
+
prompts[meta.key] = ''; // missing agent file => empty body (fails safe)
|
|
2753
|
+
this._log('orchestrator', 'warn', `Agent prompt missing: ${rel(this.projectDir, meta.agentPath)}`);
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
return prompts;
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
async _writeClarifyAnswers(questions, answers) {
|
|
2760
|
+
// M1: clarify answers live ONLY in the clarify DB row (the authoritative store).
|
|
2761
|
+
// The dead FS clarify-answers.json (never read back; the single-round loop passes
|
|
2762
|
+
// prior answers in-memory) is gone. Enrich each answer with its question text so
|
|
2763
|
+
// the row + History UI render the full Q&A without a join.
|
|
2764
|
+
const byId = new Map(questions.map((q) => [q.id, q]));
|
|
2765
|
+
const enriched = answers.map((a) => ({
|
|
2766
|
+
id: a.id,
|
|
2767
|
+
question: byId.get(a.id)?.question || '',
|
|
2768
|
+
choice: a.choice,
|
|
2769
|
+
}));
|
|
2770
|
+
await writeClarify(this.pipeline.id, { answers: { answers: enriched } });
|
|
2771
|
+
return enriched;
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
_deriveBaseName(promptText, title) {
|
|
2775
|
+
const fromTitle = title && title !== basename(this.pipeline?.dir || '') ? title : '';
|
|
2776
|
+
const source = fromTitle || firstLine(promptText) || 'feature';
|
|
2777
|
+
return slugify(source).slice(0, 40) || 'feature';
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
_checkAbort() {
|
|
2781
|
+
if (this.abort.signal.aborted || this.state.status === 'stopped') {
|
|
2782
|
+
const err = new Error('stopped');
|
|
2783
|
+
err.name = 'AbortError';
|
|
2784
|
+
throw err;
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
_phase(phase, cycle, status, nodeId = null) {
|
|
2789
|
+
this.state.phase = phase;
|
|
2790
|
+
this.state.cycle = cycle;
|
|
2791
|
+
this._recordStep(phase, cycle, status, nodeId);
|
|
2792
|
+
this.state.updatedAt = new Date().toISOString();
|
|
2793
|
+
// No `phase` event: the v1 event vocabulary died with the v1 engine. The
|
|
2794
|
+
// state.phase/state.cycle SCALARS stay — they are harness-local (state
|
|
2795
|
+
// initialises phase:'idle', _recordCost falls back to them, and
|
|
2796
|
+
// test/run-harness-hooks pins the contract).
|
|
2797
|
+
this._emit('state', this.getState());
|
|
2798
|
+
// Persist on phase boundaries so history/audit stay fresh.
|
|
2799
|
+
this._persist().catch(() => {});
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
_recordStep(phase, cycle, status, nodeId = null) {
|
|
2803
|
+
const key = cycle ? `${phase}#${cycle}` : phase;
|
|
2804
|
+
const now = new Date().toISOString();
|
|
2805
|
+
let step = this.state.steps.find((s) => s.key === key);
|
|
2806
|
+
if (!step) {
|
|
2807
|
+
step = { key, phase, cycle, status, startedAt: now, updatedAt: now, activeMs: 0, runningSince: null };
|
|
2808
|
+
// Attribute this phase's figures to a stepper node (clarify -> the plan
|
|
2809
|
+
// node) so the UI buckets it onto that cell. Totals are derived as Σ steps,
|
|
2810
|
+
// so labelling a step changes attribution only — it adds no ms/cost.
|
|
2811
|
+
if (nodeId) step.nodeId = nodeId;
|
|
2812
|
+
this.state.steps.push(step);
|
|
2813
|
+
} else {
|
|
2814
|
+
step.status = status;
|
|
2815
|
+
step.updatedAt = now;
|
|
2816
|
+
// Idempotent: a later marker (e.g. 'done') passes no nodeId and must not
|
|
2817
|
+
// clear the tag set at 'start'; never clobber an existing tag.
|
|
2818
|
+
if (nodeId && !step.nodeId) step.nodeId = nodeId;
|
|
2819
|
+
}
|
|
2820
|
+
if (status === 'start') {
|
|
2821
|
+
this._clockPauseAll(); // close out any prior running step
|
|
2822
|
+
this._clockResume(key); // start this phase's active clock
|
|
2823
|
+
} else {
|
|
2824
|
+
this._clockPause(key); // 'done' (or any terminal marker): finalize
|
|
2825
|
+
}
|
|
2826
|
+
// Keep the derived total in lockstep with the per-step figures (mirrors cost).
|
|
2827
|
+
this.state.totalActiveMs = sumStepActive(this.state.steps);
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
/** Start (resume) the active-time clock for a step key, idempotently. */
|
|
2831
|
+
_clockResume(key) {
|
|
2832
|
+
const step = this.state.steps.find((s) => s.key === key);
|
|
2833
|
+
if (step && step.runningSince == null) step.runningSince = Date.now();
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
/** Pause a step's clock, folding the elapsed run into activeMs. No-op if idle. */
|
|
2837
|
+
_clockPause(key) {
|
|
2838
|
+
const step = this.state.steps.find((s) => s.key === key);
|
|
2839
|
+
if (!step || step.runningSince == null) return;
|
|
2840
|
+
step.activeMs = (step.activeMs || 0) + Math.max(0, Date.now() - step.runningSince);
|
|
2841
|
+
step.runningSince = null;
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
/** Pause every running step (defensive: only one runs at a time normally). */
|
|
2845
|
+
_clockPauseAll() {
|
|
2846
|
+
for (const s of this.state.steps) {
|
|
2847
|
+
if (s.runningSince != null) this._clockPause(s.key);
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
/** Keys of every step whose clock is currently running. v1's sequential path has
|
|
2852
|
+
* at most one; a parallel step group and every v2 run have one per in-flight
|
|
2853
|
+
* execution. */
|
|
2854
|
+
_runningStepKeys() {
|
|
2855
|
+
return this.state.steps.filter((s) => s.runningSince != null).map((s) => s.key);
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
/** Live total = finalized activeMs (sumStepActive) + the running tail. Test/diagnostic. */
|
|
2859
|
+
liveActiveMs() {
|
|
2860
|
+
const now = Date.now();
|
|
2861
|
+
let sum = 0;
|
|
2862
|
+
for (const s of this.state.steps) {
|
|
2863
|
+
sum += (s.activeMs || 0) + (s.runningSince != null ? Math.max(0, now - s.runningSince) : 0);
|
|
2864
|
+
}
|
|
2865
|
+
return sum;
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
_setStatus(status) {
|
|
2869
|
+
this.state.status = status;
|
|
2870
|
+
if (status === 'done' || status === 'stopped' || status === 'error' || status === 'paused') {
|
|
2871
|
+
this._clockPauseAll();
|
|
2872
|
+
this.state.totalActiveMs = sumStepActive(this.state.steps);
|
|
2873
|
+
}
|
|
2874
|
+
this.state.updatedAt = new Date().toISOString();
|
|
2875
|
+
this._emit('state', this.getState());
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
_log(source, level, text, attr = null) {
|
|
2879
|
+
const evt = { source, level, text, ts: new Date().toISOString() };
|
|
2880
|
+
if (attr) {
|
|
2881
|
+
if (attr.nodeId != null) evt.nodeId = attr.nodeId;
|
|
2882
|
+
if (attr.executionId != null) evt.executionId = attr.executionId; // v2 (§5.7 / §8 log filter)
|
|
2883
|
+
if (attr.stepIndex != null) evt.stepIndex = attr.stepIndex;
|
|
2884
|
+
if (attr.cycle != null) evt.cycle = attr.cycle;
|
|
2885
|
+
if (attr.sub) evt.sub = true; // drives sub-agent web styling
|
|
2886
|
+
// Origin channel of the text: 'err' when it came from a subprocess's
|
|
2887
|
+
// stderr (agent CLI, git, graphify). Provenance, not severity — the level
|
|
2888
|
+
// says how bad it is, this says where it came from.
|
|
2889
|
+
if (attr.stream) evt.stream = attr.stream;
|
|
2890
|
+
}
|
|
2891
|
+
this._emit('log', evt);
|
|
2892
|
+
this.logWriter.push(evt); // persist the full stream (buffered; flushed on a timer)
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
/**
|
|
2896
|
+
* @param {string} kind
|
|
2897
|
+
* @param {string} path
|
|
2898
|
+
* @param {{nodeId?:string, executionId?:string, port?:string|null}|null} [attr]
|
|
2899
|
+
* v2 attribution (§5.7). Omitted keys are omitted from the event, so every
|
|
2900
|
+
* 2-arg v1 call emits the byte-identical `{kind, path}` payload it always did.
|
|
2901
|
+
*/
|
|
2902
|
+
_artifact(kind, path, attr = null) {
|
|
2903
|
+
const evt = { kind, path };
|
|
2904
|
+
if (attr) {
|
|
2905
|
+
if (attr.nodeId != null) evt.nodeId = attr.nodeId;
|
|
2906
|
+
if (attr.executionId != null) evt.executionId = attr.executionId;
|
|
2907
|
+
if (attr.port != null) evt.port = attr.port;
|
|
2908
|
+
}
|
|
2909
|
+
this._emit('artifact', evt);
|
|
2910
|
+
// Phase 3.9: ALSO index FS markdown/extra paths so pipeline-delete (Task 3.13)
|
|
2911
|
+
// can unlink the EXACT files later (best-effort; never blocks a run). Skip the
|
|
2912
|
+
// synthetic 'pipeline'/'clarify' kinds (clarify lives in the clarify table;
|
|
2913
|
+
// 'pipeline' is the dir itself). plan/review markdown live under
|
|
2914
|
+
// <store>/<key>/{plans,reviews} (store-root-relative); checklist/webui live in
|
|
2915
|
+
// the pipeline dir (dir-relative).
|
|
2916
|
+
if (!this.pipeline || !path || kind === 'pipeline' || kind === 'clarify' || kind === 'questions') return;
|
|
2917
|
+
let relPath = null;
|
|
2918
|
+
const pdir = this.pipeline.dir;
|
|
2919
|
+
if (path.startsWith(pdir + sep)) {
|
|
2920
|
+
relPath = relative(pdir, path); // dir-relative (checklist, webui)
|
|
2921
|
+
} else {
|
|
2922
|
+
const root = this.isWorkspace
|
|
2923
|
+
? workspaceStorePath(this.workspaceKey)
|
|
2924
|
+
: projectStorePath(projectKey(this.projectDir));
|
|
2925
|
+
if (path.startsWith(root + sep)) relPath = relative(root, path); // store-rel (plan/review)
|
|
2926
|
+
}
|
|
2927
|
+
// Indexed with '/' on every OS: the row is a store-layout key, not a native
|
|
2928
|
+
// path (pipeline-delete re-roots 'plans/…' / 'reviews/…' under the store),
|
|
2929
|
+
// so a Windows-native 'reviews\\x.md' would silently miss that re-rooting.
|
|
2930
|
+
if (relPath) recordArtifact(this.pipeline.id, kind, relPath.split(sep).join('/'));
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
/** Translate a low-level claude/mock event into a pipeline 'log' event. */
|
|
2934
|
+
_onAgentEvent(role, e, attr = null) {
|
|
2935
|
+
if (!e) return;
|
|
2936
|
+
// Sub-agent telemetry (feature-detected, gated by WORCA_SUBAGENT_HOOKS). A
|
|
2937
|
+
// surfaced PostToolUse:Agent hook-event carries the parent tool_use_id +
|
|
2938
|
+
// tool_response telemetry; enrich the matching record's columns, keyed by
|
|
2939
|
+
// tool_use_id (the canonical key — never agent_id). Returns early: a hook
|
|
2940
|
+
// event has no human text and no cost to attribute.
|
|
2941
|
+
if (e.type === 'hook-event') {
|
|
2942
|
+
this._recordSubAgentTelemetry(e.raw);
|
|
2943
|
+
return;
|
|
2944
|
+
}
|
|
2945
|
+
// Pause/Resume: stamp the claude session id on the step that spawned it, and
|
|
2946
|
+
// persist eagerly — a later pause (or even a crash) must find it in the DB.
|
|
2947
|
+
if (e.type === 'session' && typeof e.sessionId === 'string') {
|
|
2948
|
+
const key = attr?.stepKey;
|
|
2949
|
+
const step = key ? this.state.steps.find((s) => s.key === key) : null;
|
|
2950
|
+
if (step && step.sessionId !== e.sessionId) {
|
|
2951
|
+
step.sessionId = e.sessionId;
|
|
2952
|
+
this._persist().catch(() => {});
|
|
2953
|
+
}
|
|
2954
|
+
return;
|
|
2955
|
+
}
|
|
2956
|
+
// Agent stderr (`stream:'err'`), one framed line per event. Handled HERE,
|
|
2957
|
+
// beside the other envelope guards, because a stderr event carries no `raw`:
|
|
2958
|
+
// routing it through the cost block and the five lifecycle reducers below
|
|
2959
|
+
// only to have each no-op is noise. It is always main-stream (stderr has no
|
|
2960
|
+
// parent_tool_use_id), so the source is the plain role and `sub` is never set.
|
|
2961
|
+
//
|
|
2962
|
+
// Level is `warn`, not `error`: what actually lands here is mostly 429/529
|
|
2963
|
+
// retry text and subprocess chatter. Genuine failures arrive as a `result`
|
|
2964
|
+
// event with is_error on STDOUT — see the non-zero-exit path in
|
|
2965
|
+
// claude-runner.mjs — and are logged at `error` by the node failure handler.
|
|
2966
|
+
if (e.type === 'stderr') {
|
|
2967
|
+
const text = (e.text || '').trim();
|
|
2968
|
+
if (text) this._log(role, 'warn', text, { ...attr, stream: 'err' });
|
|
2969
|
+
return;
|
|
2970
|
+
}
|
|
2971
|
+
// Capture actual spend before anything returns early. The runner tags the
|
|
2972
|
+
// terminal stream-json `result` with costUsd (Claude's total_cost_usd; 0 in
|
|
2973
|
+
// mock). Fall back to raw.total_cost_usd defensively. e.raw may be a string
|
|
2974
|
+
// (non-JSON line) — `.type` on it is just undefined, so this never throws.
|
|
2975
|
+
// `e.costUsd != null` keeps a genuine 0 (which `!= null` is true for).
|
|
2976
|
+
const isResult = !!(e.raw && typeof e.raw === 'object' && e.raw.type === 'result');
|
|
2977
|
+
const rawCost = e.costUsd != null
|
|
2978
|
+
? Number(e.costUsd)
|
|
2979
|
+
: (isResult ? Number(e.raw.total_cost_usd ?? e.raw.cost_usd) : NaN);
|
|
2980
|
+
// A per-model cost override (config.mjs) wins over the CLI's own figure — so a
|
|
2981
|
+
// CLI that prices an on-prem/proxied model by name can't inflate the ledger.
|
|
2982
|
+
// With no override this is `rawCost` unchanged (default behavior preserved).
|
|
2983
|
+
//
|
|
2984
|
+
// Gated on `isResult` — NOT merely on attr.model. Every stream frame reaches
|
|
2985
|
+
// here, and only the terminal `result` carries cost; on the others rawCost is
|
|
2986
|
+
// NaN and falls through untouched today. A {free} override answers 0 for any
|
|
2987
|
+
// input, so resolving unconditionally would turn each of those into a real $0
|
|
2988
|
+
// and fire _recordCost — a full writeState + 'state' broadcast — per FRAME
|
|
2989
|
+
// instead of once per node. Looked up ONCE and shared with observeModelCost
|
|
2990
|
+
// below: modelCostConfig re-reads settings.json on every call.
|
|
2991
|
+
const costCfg = isResult && attr?.model ? modelCostConfig(attr.model) : null;
|
|
2992
|
+
const cost = costCfg
|
|
2993
|
+
? resolveModelCost(attr.model, rawCost, e.raw.usage, costCfg)
|
|
2994
|
+
: rawCost;
|
|
2995
|
+
if (Number.isFinite(cost)) this._recordCost(cost, attr?.stepKey);
|
|
2996
|
+
else if (isResult && !this.claude.mock) {
|
|
2997
|
+
// A {perMtok} model prices from tokens alone, so a result with no usage is
|
|
2998
|
+
// unpriceable (NaN) — say so plainly rather than blaming a missing cost field.
|
|
2999
|
+
this._log('orchestrator', 'warn', costCfg?.perMtok
|
|
3000
|
+
? `model "${attr.model}" is priced per-Mtok but the result carried no token usage — this step's spend is unaccounted`
|
|
3001
|
+
: 'result event carried no cost estimate (total_cost_usd absent)', attr);
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
// §4.6 cost-reliability observation: only terminal result events of REAL
|
|
3005
|
+
// runs, only for the dispatched model (attr.model — the legacy role path
|
|
3006
|
+
// carries no attr and is skipped), and only env-routed models inside
|
|
3007
|
+
// observeModelCost. One warning per model per run; the observation itself
|
|
3008
|
+
// is derived state and must never fail the run.
|
|
3009
|
+
if (isResult && !this.claude.mock && attr?.model) {
|
|
3010
|
+
try {
|
|
3011
|
+
const verdict = observeModelCost(attr.model, Number.isFinite(cost) ? cost : null, e.raw.usage, costCfg);
|
|
3012
|
+
if (verdict === 'flagged' && !(this._costUnreliableWarned ||= new Set()).has(attr.model)) {
|
|
3013
|
+
this._costUnreliableWarned.add(attr.model);
|
|
3014
|
+
this._log('orchestrator', 'warn',
|
|
3015
|
+
`model "${attr.model}" reported no cost despite token usage (custom endpoint) — USD budget enforcement cannot see this spend`, attr);
|
|
3016
|
+
}
|
|
3017
|
+
} catch { /* derived state — never fail the run over it */ }
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
// Sub-agent attribution. A child (Task/Agent) event carries parent_tool_use_id
|
|
3021
|
+
// = the id of the parent's Task tool_use block; main-agent events carry null/
|
|
3022
|
+
// absent. parent_tool_use_id is a TOP-LEVEL stream-json field; the message-
|
|
3023
|
+
// nested read is defensive. On a string `raw`, both reads yield undefined.
|
|
3024
|
+
const subId = e.raw?.parent_tool_use_id ?? e.raw?.message?.parent_tool_use_id ?? null;
|
|
3025
|
+
|
|
3026
|
+
// Learn Task/Agent descriptions from MAIN-agent events (subId == null) so the
|
|
3027
|
+
// child events below can be labeled by what their sub-agent was asked to do.
|
|
3028
|
+
if (subId == null) {
|
|
3029
|
+
registerSubAgents(e.raw, this._subAgentLabels);
|
|
3030
|
+
// Lifecycle: a NEW Task/Agent tool_use on the MAIN stream = a sub-agent spawn.
|
|
3031
|
+
// Needs `attr` to pin nodeId/stepIndex/cycle/stepKey; the clarify pre-step
|
|
3032
|
+
// (attr === null) carries no node, so it is logged but not lifecycle-tracked.
|
|
3033
|
+
if (attr) this._recordSubAgentSpawns(e.raw, attr);
|
|
3034
|
+
// Finish: a tool_result on the MAIN stream whose tool_use_id is a tracked
|
|
3035
|
+
// sub-agent → finished/error. These `user` envelopes were previously dropped.
|
|
3036
|
+
this._recordSubAgentFinishes(e.raw);
|
|
3037
|
+
// Background-agent completion: the system/task_notification frame arrives
|
|
3038
|
+
// on the main stream long after the launch-ack tool_result.
|
|
3039
|
+
this._recordAsyncTaskClose(e.raw);
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
// Capture named-skill / MCP-tool usage for the Sub-agents dropdown pills
|
|
3043
|
+
// (main agent -> its step; sub-agent -> its record). Independent of the
|
|
3044
|
+
// text/tool log branches below (it runs BEFORE the `if (text) return`), so a
|
|
3045
|
+
// mixed text+tool_use turn is still caught.
|
|
3046
|
+
this._recordSkills(e.raw, subId, attr);
|
|
3047
|
+
// Count graphify CLI invocations (Bash only) per agent / sub-agent. Bash-only
|
|
3048
|
+
// by design: the graphify skill runs the CLI itself, so counting the Skill tool
|
|
3049
|
+
// too would double-count; the bash invocation is the ground truth and also
|
|
3050
|
+
// catches direct CLI use with no skill.
|
|
3051
|
+
this._recordGraphify(e.raw, subId, attr);
|
|
3052
|
+
|
|
3053
|
+
// Display source: parent role for main events; "role ▸ label" for sub-agent
|
|
3054
|
+
// events. `sub` drives the indented/dimmed web styling.
|
|
3055
|
+
let source = role;
|
|
3056
|
+
let sub = false;
|
|
3057
|
+
if (subId != null) {
|
|
3058
|
+
let label = this._subAgentLabels.get(subId);
|
|
3059
|
+
if (!label) {
|
|
3060
|
+
label = `sub-agent-${++this._subAgentFallbackSeq}`;
|
|
3061
|
+
this._subAgentLabels.set(subId, label); // stamp so the ordinal stays stable for this id
|
|
3062
|
+
}
|
|
3063
|
+
source = `${role} ▸ ${label}`;
|
|
3064
|
+
sub = true;
|
|
3065
|
+
}
|
|
3066
|
+
// Preserve the step attribution (nodeId/stepIndex/cycle) carried by attr so a
|
|
3067
|
+
// sub-agent line stays pinned to the right pipeline step/cycle in the UI; just
|
|
3068
|
+
// add `sub`. {...null} === {}, so attr === null (the clarify pre-step) is safe.
|
|
3069
|
+
const logAttr = sub ? { ...attr, sub: true } : attr;
|
|
3070
|
+
|
|
3071
|
+
// Human-readable assistant text (if any). NO early return: a single
|
|
3072
|
+
// assistant turn can carry BOTH a text block and tool_use blocks — fall
|
|
3073
|
+
// through so each tool call is logged too. A text-only turn has no
|
|
3074
|
+
// tool_use/tool_result blocks, so the loops below are empty and its output
|
|
3075
|
+
// is identical to the pre-change path.
|
|
3076
|
+
const text = (e.text || '').trim();
|
|
3077
|
+
if (text) this._log(source, 'info', text, logAttr);
|
|
3078
|
+
|
|
3079
|
+
// The `system`/init event has no text and no tool blocks — surface the
|
|
3080
|
+
// model (parity with worca's `[init] model=<model>`) instead of dropping it.
|
|
3081
|
+
if (e.raw && e.raw.type === 'system' && e.raw.subtype === 'init') {
|
|
3082
|
+
this._log(source, 'debug', `[init] model=${e.raw.model || '?'}`, logAttr);
|
|
3083
|
+
// §4.7: stamp the session's ACTUAL model on the step (mirrors the
|
|
3084
|
+
// sessionId stamp above) so the UI can resolve the "default" caption to
|
|
3085
|
+
// a concrete name. Display-only; sub-agent events never carry init.
|
|
3086
|
+
const step = !sub && e.raw.model && attr?.stepKey
|
|
3087
|
+
? this.state.steps.find((s) => s.key === attr.stepKey) : null;
|
|
3088
|
+
if (step && step.modelUsed !== e.raw.model) {
|
|
3089
|
+
step.modelUsed = e.raw.model;
|
|
3090
|
+
this._persist().catch(() => {});
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
// Concrete tool calls the agent made this turn (assistant.tool_use blocks).
|
|
3095
|
+
for (const call of describeToolUses(e.raw, this.projectDir)) {
|
|
3096
|
+
this._log(source, 'debug', `→ ${call}`, logAttr);
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
// Tool-result outcomes (`user`-envelope + child tool_result blocks).
|
|
3100
|
+
// ADDITIVE ONLY — _recordSubAgentFinishes (above) still owns sub-agent
|
|
3101
|
+
// lifecycle state; this loop never mutates state, it only logs.
|
|
3102
|
+
for (const line of describeToolResults(e.raw)) {
|
|
3103
|
+
this._log(source, 'debug', `← ${line}`, logAttr);
|
|
3104
|
+
}
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
/**
|
|
3108
|
+
* Lifecycle spawn reducer: for every NEW Task/Agent tool_use block in a
|
|
3109
|
+
* MAIN-stream event, push a `running` sub-agent record (attributed to the
|
|
3110
|
+
* step via `attr`), mirror it to the sub_agents table, and emit a `spawn`
|
|
3111
|
+
* delta. Idempotent per tool_use id (re-seen ids are skipped). `attr` is
|
|
3112
|
+
* required (the caller only invokes this when a node is in scope).
|
|
3113
|
+
*/
|
|
3114
|
+
_recordSubAgentSpawns(raw, attr) {
|
|
3115
|
+
const content = raw?.message?.content;
|
|
3116
|
+
if (!Array.isArray(content)) return;
|
|
3117
|
+
for (const c of content) {
|
|
3118
|
+
if (c?.type !== 'tool_use' || (c.name !== 'Task' && c.name !== 'Agent') || !c.id) continue;
|
|
3119
|
+
if (this.state.subAgents.some((s) => s.id === c.id)) continue; // idempotent
|
|
3120
|
+
const label = this._subAgentLabels.get(c.id) || clip(c.input?.description || c.input?.prompt, SUBAGENT_LABEL_MAX);
|
|
3121
|
+
const rec = {
|
|
3122
|
+
id: c.id,
|
|
3123
|
+
label: label || null,
|
|
3124
|
+
nodeId: attr.nodeId ?? null,
|
|
3125
|
+
uiPhase: attr.uiPhase ?? null,
|
|
3126
|
+
stepIndex: attr.stepIndex ?? null,
|
|
3127
|
+
cycle: attr.cycle ?? null,
|
|
3128
|
+
stepKey: attr.stepKey ?? null,
|
|
3129
|
+
status: 'running',
|
|
3130
|
+
startedAt: new Date().toISOString(),
|
|
3131
|
+
finishedAt: null,
|
|
3132
|
+
subagentType: c.input?.subagent_type ?? null,
|
|
3133
|
+
// In-memory only (no column): lets _recordSubAgentTelemetry price this
|
|
3134
|
+
// child. A sub-agent runs on the PARENT node's endpoint, so the parent's
|
|
3135
|
+
// model is the right price — UNLESS the Task input names a model that
|
|
3136
|
+
// itself carries an explicit override, which then governs the child.
|
|
3137
|
+
// A bare alias ('haiku') with no catalog entry is not one, so it keeps
|
|
3138
|
+
// the parent's rather than silently reverting to the CLI's figure.
|
|
3139
|
+
model: subAgentCostModel(c.input?.model, attr.model),
|
|
3140
|
+
// PERSISTED (sub_agents.run_model): the model this child actually ran on —
|
|
3141
|
+
// the alias its Task call named (the sub-agent model directive asks for an
|
|
3142
|
+
// explicit one on every call), else the parent node's model, which is what
|
|
3143
|
+
// a child with no `model` inherits. KNOWN GAP: an agent definition's own
|
|
3144
|
+
// `model:` frontmatter outranks an omitted param and is invisible in the
|
|
3145
|
+
// stream, so such a child records the parent's model. Deliberately NOT
|
|
3146
|
+
// `model` above: that one is the PRICING model, which can differ for an
|
|
3147
|
+
// explicit alias carrying its own catalog cost entry.
|
|
3148
|
+
runModel: (typeof c.input?.model === 'string' && c.input.model.trim())
|
|
3149
|
+
? c.input.model.trim()
|
|
3150
|
+
: (attr.model ?? null),
|
|
3151
|
+
};
|
|
3152
|
+
this.state.subAgents.push(rec);
|
|
3153
|
+
this._upsertSubAgent(rec);
|
|
3154
|
+
this._subAgentTransition('spawn', rec);
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
/**
|
|
3159
|
+
* Lifecycle finish reducer: scan a MAIN-stream event's content for a
|
|
3160
|
+
* tool_result whose tool_use_id is a tracked sub-agent. Set status =
|
|
3161
|
+
* is_error ? 'error' : 'finished' and stamp finishedAt, but ONLY while the
|
|
3162
|
+
* record is still 'running' (a late/duplicate tool_result must not flip a
|
|
3163
|
+
* terminal record back or re-emit). Mirrors to the table + emits a `finish`
|
|
3164
|
+
* delta. The finish envelope is `{type:'user', message:{content:[{type:
|
|
3165
|
+
* 'tool_result', tool_use_id, is_error?:true}]}}` — previously dropped.
|
|
3166
|
+
* A background launch ack (frame-level `tool_use_result.isAsync`/
|
|
3167
|
+
* `status:'async_launched'`) is NOT a finish; `_recordAsyncTaskClose` owns
|
|
3168
|
+
* that close.
|
|
3169
|
+
*/
|
|
3170
|
+
_recordSubAgentFinishes(raw) {
|
|
3171
|
+
const content = raw?.message?.content;
|
|
3172
|
+
if (!Array.isArray(content)) return;
|
|
3173
|
+
// Probed (claude 2.1.251, 2026-08-31; ask/events.mjs saw the same shape on
|
|
3174
|
+
// 2.1.239): the user tool_result frame carries a TOP-LEVEL `tool_use_result`
|
|
3175
|
+
// object. Background mode marks it {isAsync:true, status:'async_launched'} —
|
|
3176
|
+
// that tool_result is only a LAUNCH ACK; the real completion arrives later
|
|
3177
|
+
// as a system/task_notification frame (_recordAsyncTaskClose). One frame per
|
|
3178
|
+
// tool_result in practice, so applying the frame's object to each block is
|
|
3179
|
+
// safe (ask/events.mjs makes the same assumption).
|
|
3180
|
+
const tur = raw?.tool_use_result;
|
|
3181
|
+
const obj = tur && typeof tur === 'object' && !Array.isArray(tur) ? tur : null;
|
|
3182
|
+
const isAck = !!obj && (obj.isAsync === true || obj.status === 'async_launched');
|
|
3183
|
+
for (const b of content) {
|
|
3184
|
+
if (b?.type !== 'tool_result' || !b.tool_use_id) continue;
|
|
3185
|
+
const rec = this.state.subAgents.find((s) => s.id === b.tool_use_id);
|
|
3186
|
+
if (!rec || rec.status !== 'running') continue; // unknown id or already terminal
|
|
3187
|
+
if (isAck) {
|
|
3188
|
+
// Launch ack — still running in the background; task_notification (or
|
|
3189
|
+
// the execution backstop) closes it. resolvedModel closes a spawn-time
|
|
3190
|
+
// gap: an agent definition's `model:` frontmatter is invisible in the
|
|
3191
|
+
// Task input, so a record with no runModel learns it here. Never
|
|
3192
|
+
// overwrites a spawn-set alias (the UI pill renders the value verbatim).
|
|
3193
|
+
if (rec.runModel == null && typeof obj.resolvedModel === 'string' && obj.resolvedModel) {
|
|
3194
|
+
rec.runModel = obj.resolvedModel;
|
|
3195
|
+
this._upsertSubAgent(rec);
|
|
3196
|
+
this._subAgentTransition('update', rec);
|
|
3197
|
+
}
|
|
3198
|
+
continue;
|
|
3199
|
+
}
|
|
3200
|
+
if (obj) {
|
|
3201
|
+
// Foreground completion telemetry — the same durationMs/tokens fields the
|
|
3202
|
+
// gated PostToolUse hook fills; tool_use_result carries no cost. With
|
|
3203
|
+
// WORCA_SUBAGENT_HOOKS on, _recordSubAgentTelemetry may re-write these
|
|
3204
|
+
// after the finish (it does not gate on status): last writer wins, and
|
|
3205
|
+
// both sources quote the same CLI figures — deliberate, not a race to fix.
|
|
3206
|
+
if (Number.isFinite(Number(obj.totalDurationMs))) rec.durationMs = Number(obj.totalDurationMs);
|
|
3207
|
+
if (Number.isFinite(Number(obj.totalTokens))) rec.tokens = Number(obj.totalTokens);
|
|
3208
|
+
if (rec.runModel == null && typeof obj.resolvedModel === 'string' && obj.resolvedModel) rec.runModel = obj.resolvedModel;
|
|
3209
|
+
}
|
|
3210
|
+
rec.status = b.is_error ? 'error' : 'finished';
|
|
3211
|
+
rec.finishedAt = new Date().toISOString();
|
|
3212
|
+
this._upsertSubAgent(rec);
|
|
3213
|
+
this._subAgentTransition('finish', rec);
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3217
|
+
/**
|
|
3218
|
+
* Background sub-agent completion. Probed (claude 2.1.251, 2026-08-31): when a
|
|
3219
|
+
* backgrounded Task/Agent stops, the MAIN stream emits
|
|
3220
|
+
* {type:'system', subtype:'task_notification', task_id, tool_use_id,
|
|
3221
|
+
* status:'completed'|…, output_file, summary, usage?}
|
|
3222
|
+
* — the one stop marker keyed by tool_use_id (task_started / task_updated /
|
|
3223
|
+
* background_tasks_changed frames surround it and are ignored). A resumable
|
|
3224
|
+
* agent may notify more than once for the same task; the status!=='running'
|
|
3225
|
+
* guard makes repeats no-ops. Anything but status==='completed' closes as
|
|
3226
|
+
* 'error'. finishedAt = arrival time (observed ≤30ms after the agent stops).
|
|
3227
|
+
* usage.{duration_ms,total_tokens} rode along on the 2.1.239 capture
|
|
3228
|
+
* (test/fixtures/ask/task-subagent.jsonl:41) but is OPTIONAL — without it,
|
|
3229
|
+
* durationMs stays null and the UI's timestamp fallback is real wall time
|
|
3230
|
+
* for an async agent. No cost figure exists here; costUsd stays hook-gated.
|
|
3231
|
+
*/
|
|
3232
|
+
_recordAsyncTaskClose(raw) {
|
|
3233
|
+
if (raw?.type !== 'system' || raw?.subtype !== 'task_notification' || !raw.tool_use_id) return;
|
|
3234
|
+
const rec = this.state.subAgents.find((s) => s.id === raw.tool_use_id);
|
|
3235
|
+
if (!rec || rec.status !== 'running') return;
|
|
3236
|
+
const u = raw.usage;
|
|
3237
|
+
if (u && typeof u === 'object' && !Array.isArray(u)) {
|
|
3238
|
+
if (Number.isFinite(Number(u.duration_ms))) rec.durationMs = Number(u.duration_ms);
|
|
3239
|
+
if (Number.isFinite(Number(u.total_tokens))) rec.tokens = Number(u.total_tokens);
|
|
3240
|
+
}
|
|
3241
|
+
rec.status = raw.status === 'completed' ? 'finished' : 'error';
|
|
3242
|
+
rec.finishedAt = new Date().toISOString();
|
|
3243
|
+
this._upsertSubAgent(rec);
|
|
3244
|
+
this._subAgentTransition('finish', rec);
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3247
|
+
/**
|
|
3248
|
+
* Record skills / MCP-tools used in one agent event. Routes by parent_tool_use_id:
|
|
3249
|
+
* a MAIN-agent turn (subId == null) attributes to its pipeline step (by stepKey);
|
|
3250
|
+
* a sub-agent turn (subId != null) attributes to the spawned record (id === subId).
|
|
3251
|
+
* Grows a deduped, capped `skills` array and emits a delta + persists ONLY when the
|
|
3252
|
+
* set actually changed. No-op when there is nothing to attribute to (e.g. the
|
|
3253
|
+
* clarify pre-step has no step; a child event seen before its spawn).
|
|
3254
|
+
*/
|
|
3255
|
+
_recordSkills(raw, subId, attr) {
|
|
3256
|
+
const labels = extractSkillLabels(raw);
|
|
3257
|
+
if (!labels.length) return;
|
|
3258
|
+
if (subId == null) {
|
|
3259
|
+
const key = attr?.stepKey;
|
|
3260
|
+
const step = key ? this.state.steps.find((s) => s.key === key) : null;
|
|
3261
|
+
if (!step) return;
|
|
3262
|
+
const merged = mergeSkills(step.skills, labels);
|
|
3263
|
+
if (!merged) return;
|
|
3264
|
+
step.skills = merged;
|
|
3265
|
+
this._emit('stepskills', {
|
|
3266
|
+
stepKey: step.key,
|
|
3267
|
+
nodeId: step.nodeId ?? null,
|
|
3268
|
+
cycle: step.cycle ?? null,
|
|
3269
|
+
skills: merged,
|
|
3270
|
+
ts: new Date().toISOString(),
|
|
3271
|
+
});
|
|
3272
|
+
this._persist().catch(() => {}); // mirrors _recordCost: per-step skills survive a reload
|
|
3273
|
+
} else {
|
|
3274
|
+
const rec = this.state.subAgents.find((s) => s.id === subId);
|
|
3275
|
+
if (!rec) return;
|
|
3276
|
+
const merged = mergeSkills(rec.skills, labels);
|
|
3277
|
+
if (!merged) return;
|
|
3278
|
+
rec.skills = merged;
|
|
3279
|
+
this._upsertSubAgent(rec);
|
|
3280
|
+
this._subAgentTransition('update', rec);
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
|
|
3284
|
+
/**
|
|
3285
|
+
* Count graphify CLI invocations (Bash only) in one agent event and add them to
|
|
3286
|
+
* the running total. Routes exactly like _recordSkills: a MAIN-agent turn
|
|
3287
|
+
* (subId == null) accrues onto its pipeline step (by stepKey) and emits a
|
|
3288
|
+
* `stepgraphify` delta; a sub-agent turn accrues onto the spawned record and
|
|
3289
|
+
* emits a `subagent` update. No-op when the event invoked graphify zero times or
|
|
3290
|
+
* there is nothing to attribute to (clarify pre-step; child seen before spawn).
|
|
3291
|
+
*/
|
|
3292
|
+
_recordGraphify(raw, subId, attr) {
|
|
3293
|
+
const n = countGraphifyBashCalls(raw);
|
|
3294
|
+
if (!n) return;
|
|
3295
|
+
if (subId == null) {
|
|
3296
|
+
const key = attr?.stepKey;
|
|
3297
|
+
const step = key ? this.state.steps.find((s) => s.key === key) : null;
|
|
3298
|
+
if (!step) return;
|
|
3299
|
+
step.graphifyCount = (step.graphifyCount ?? 0) + n;
|
|
3300
|
+
this._emit('stepgraphify', {
|
|
3301
|
+
stepKey: step.key,
|
|
3302
|
+
nodeId: step.nodeId ?? null,
|
|
3303
|
+
cycle: step.cycle ?? null,
|
|
3304
|
+
graphifyCount: step.graphifyCount,
|
|
3305
|
+
ts: new Date().toISOString(),
|
|
3306
|
+
});
|
|
3307
|
+
this._persist().catch(() => {}); // mirrors _recordSkills: survives a reload
|
|
3308
|
+
} else {
|
|
3309
|
+
const rec = this.state.subAgents.find((s) => s.id === subId);
|
|
3310
|
+
if (!rec) return;
|
|
3311
|
+
rec.graphifyCount = (rec.graphifyCount ?? 0) + n;
|
|
3312
|
+
this._upsertSubAgent(rec);
|
|
3313
|
+
this._subAgentTransition('update', rec);
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3317
|
+
/** Best-effort mirror of a sub-agent record to the sub_agents table. Guarded
|
|
3318
|
+
* exactly like _persist/_artifact: no pipeline → in-memory only (unit ctx). */
|
|
3319
|
+
_upsertSubAgent(rec) {
|
|
3320
|
+
if (!this.pipeline) return;
|
|
3321
|
+
try { upsertSubAgent(this.pipeline.id, rec); } catch { /* best-effort */ }
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
/** Emit a hybrid `subagent` delta. The full `state` snapshot remains the
|
|
3325
|
+
* reconcile/late-join source of truth (it carries subAgents). */
|
|
3326
|
+
_subAgentTransition(transition, rec) {
|
|
3327
|
+
this._emit('subagent', {
|
|
3328
|
+
transition,
|
|
3329
|
+
id: rec.id,
|
|
3330
|
+
label: rec.label ?? null,
|
|
3331
|
+
nodeId: rec.nodeId ?? null,
|
|
3332
|
+
uiPhase: rec.uiPhase ?? null,
|
|
3333
|
+
stepKey: rec.stepKey ?? null,
|
|
3334
|
+
stepIndex: rec.stepIndex ?? null,
|
|
3335
|
+
cycle: rec.cycle ?? null,
|
|
3336
|
+
status: rec.status,
|
|
3337
|
+
...(rec.durationMs != null ? { durationMs: rec.durationMs } : {}),
|
|
3338
|
+
...(rec.tokens != null ? { tokens: rec.tokens } : {}),
|
|
3339
|
+
...(rec.costUsd != null ? { costUsd: rec.costUsd } : {}),
|
|
3340
|
+
...(Array.isArray(rec.skills) ? { skills: rec.skills } : {}),
|
|
3341
|
+
...(rec.subagentType != null ? { subagentType: rec.subagentType } : {}),
|
|
3342
|
+
...(rec.graphifyCount != null ? { graphifyCount: rec.graphifyCount } : {}),
|
|
3343
|
+
// The model pill's live feed: without this the Running view paints no pill
|
|
3344
|
+
// until the next full state snapshot replaces r.subAgents.
|
|
3345
|
+
...(rec.runModel != null ? { runModel: rec.runModel } : {}),
|
|
3346
|
+
ts: new Date().toISOString(),
|
|
3347
|
+
});
|
|
3348
|
+
}
|
|
3349
|
+
|
|
3350
|
+
/**
|
|
3351
|
+
* Telemetry enrichment from a surfaced PostToolUse:Agent hook-event. Reads the
|
|
3352
|
+
* parent tool_use_id + tool_response.{totalDurationMs,totalTokens,usage} and
|
|
3353
|
+
* fills the matching sub-agent record's durationMs/tokens/costUsd (only those
|
|
3354
|
+
* present), mirrors to the table, and emits an `update` delta. No-op for an
|
|
3355
|
+
* unknown id or a non-Agent hook. Strictly additive — the baseline lifecycle
|
|
3356
|
+
* needs none of this.
|
|
3357
|
+
*/
|
|
3358
|
+
_recordSubAgentTelemetry(raw) {
|
|
3359
|
+
const id = raw?.tool_use_id ?? raw?.tool_response?.tool_use_id ?? null;
|
|
3360
|
+
if (!id) return;
|
|
3361
|
+
const rec = this.state.subAgents.find((s) => s.id === id);
|
|
3362
|
+
if (!rec) return;
|
|
3363
|
+
const tr = raw?.tool_response || {};
|
|
3364
|
+
if (Number.isFinite(Number(tr.totalDurationMs))) rec.durationMs = Number(tr.totalDurationMs);
|
|
3365
|
+
if (Number.isFinite(Number(tr.totalTokens))) rec.tokens = Number(tr.totalTokens);
|
|
3366
|
+
const cost = tr.usage?.cost_usd ?? tr.usage?.total_cost_usd ?? tr.cost_usd;
|
|
3367
|
+
if (Number.isFinite(Number(cost))) {
|
|
3368
|
+
// Apply the same per-model cost override as the node result path, so a
|
|
3369
|
+
// sub-agent of a free/priced model doesn't display the CLI's fabricated
|
|
3370
|
+
// figure. rec.model is set at spawn (see subAgentCostModel); absent (e.g.
|
|
3371
|
+
// after a resume, which rebuilds records from the table) → the CLI value
|
|
3372
|
+
// stands. A {perMtok} model with unpriceable usage yields NaN — leave the
|
|
3373
|
+
// row's cost UNSET rather than write a made-up figure into the display.
|
|
3374
|
+
const resolved = rec.model ? resolveModelCost(rec.model, Number(cost), tr.usage) : Number(cost);
|
|
3375
|
+
if (Number.isFinite(resolved)) rec.costUsd = resolved;
|
|
3376
|
+
}
|
|
3377
|
+
this._upsertSubAgent(rec);
|
|
3378
|
+
this._subAgentTransition('update', rec);
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
|
|
3382
|
+
|
|
3383
|
+
|
|
3384
|
+
/**
|
|
3385
|
+
* Attribute a dollar cost to the step currently executing and roll it into
|
|
3386
|
+
* the pipeline total. The active step is identified by the live (phase,cycle)
|
|
3387
|
+
* — the SAME key _recordStep uses — because a `result` event always arrives
|
|
3388
|
+
* between that phase's 'start' and 'done' markers. Records the figure even when
|
|
3389
|
+
* it is 0 (so mock runs DISPLAY a truthful $0.00 rather than a blank); only
|
|
3390
|
+
* NaN/negative are ignored. Multiple results on one step accumulate. Emits a
|
|
3391
|
+
* 'state' snapshot so a live UI updates, and persists so history (state.json)
|
|
3392
|
+
* carries the figure.
|
|
3393
|
+
* @param {number} costUsd
|
|
3394
|
+
*/
|
|
3395
|
+
_recordCost(costUsd, stepKey = null) {
|
|
3396
|
+
if (!Number.isFinite(costUsd) || costUsd < 0) return;
|
|
3397
|
+
const key = stepKey
|
|
3398
|
+
|| (this.state.cycle ? `${this.state.phase}#${this.state.cycle}` : this.state.phase);
|
|
3399
|
+
const step = this.state.steps.find((s) => s.key === key);
|
|
3400
|
+
if (step) step.costUsd = roundUsd((step.costUsd || 0) + costUsd);
|
|
3401
|
+
// Derive the pipeline total from the per-step figures so it ALWAYS equals
|
|
3402
|
+
// their sum. Keeping a separate running total and rounding it on every add
|
|
3403
|
+
// drifts from Σ steps (e.g. 0.00005 + 0.00015 gave total 0.0003 vs Σ 0.0002).
|
|
3404
|
+
this.state.totalCostUsd = sumStepCosts(this.state.steps);
|
|
3405
|
+
// Append-only spend ledger (windowed budget accounting). Best-effort:
|
|
3406
|
+
// accounting must never kill a run; ledger and state share the same DB,
|
|
3407
|
+
// so failures co-occur with the _persist catch below anyway.
|
|
3408
|
+
if (costUsd > 0 && this.pipeline?.id) {
|
|
3409
|
+
try { recordCostDelta({ pipelineId: this.pipeline.id, stepKey: key, amountUsd: costUsd }); }
|
|
3410
|
+
catch (err) { this._log('orchestrator', 'warn', `cost ledger write failed: ${err?.message || err}`); }
|
|
3411
|
+
}
|
|
3412
|
+
this.state.updatedAt = new Date().toISOString();
|
|
3413
|
+
this._emit('state', this.getState());
|
|
3414
|
+
this._persist().catch(() => {});
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
_emit(event, payload) {
|
|
3418
|
+
try {
|
|
3419
|
+
this.emit(event, payload);
|
|
3420
|
+
} catch {
|
|
3421
|
+
/* never let a listener crash the state machine */
|
|
3422
|
+
}
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
/**
|
|
3426
|
+
* Options for the title-generation spawn. The title call is the one claude
|
|
3427
|
+
* process a RUN starts outside runOpts, so it must mirror the run's claude
|
|
3428
|
+
* policy (bin, mock, env scrub) rather than inherit runClaude's PATH/env
|
|
3429
|
+
* defaults — a run built with claude:{mock:true} spawned the developer's REAL
|
|
3430
|
+
* binary 157x per `npm test` until 2026-08-30. Exposed as a method so the
|
|
3431
|
+
* plumbing is unit-testable (ESM imports cannot be spied).
|
|
3432
|
+
*/
|
|
3433
|
+
_titleGenOpts() {
|
|
3434
|
+
return {
|
|
3435
|
+
// §2.1 row 3: fire-and-forget title generation was the one remaining worca-cc
|
|
3436
|
+
// process started inside the user's LIVE checkout. Once a run root exists
|
|
3437
|
+
// there is no reason for it. The kickoff site moved to just after
|
|
3438
|
+
// _setupRunRoot() so runCwd is populated here.
|
|
3439
|
+
cwd: this.runCwd ?? this.projectDir,
|
|
3440
|
+
signal: this.abort.signal,
|
|
3441
|
+
bin: this.claude.bin,
|
|
3442
|
+
mock: this.claude.mock,
|
|
3443
|
+
// Same env policy as the pipeline nodes. Both undefined on an unconfigured
|
|
3444
|
+
// project ⇒ byte-identical spawn env (legacy parity).
|
|
3445
|
+
envScrub: this.guardrails?.envScrub || undefined,
|
|
3446
|
+
envAllowlist: this.guardrails?.envScrub ? this.guardrails.envAllowlist : undefined,
|
|
3447
|
+
};
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
/**
|
|
3451
|
+
* Fire-and-forget: generate a concise LLM title and, when ready, persist + broadcast it.
|
|
3452
|
+
* The promise is stored on this._titlePromise for test determinism but is NEVER awaited
|
|
3453
|
+
* by run() (must not delay the run). Aborts with the run via this.abort.signal.
|
|
3454
|
+
*/
|
|
3455
|
+
_kickoffTitleGeneration() {
|
|
3456
|
+
const prompt = this.pipeline?.promptText || this.opts.prompt || '';
|
|
3457
|
+
const id = this.pipeline?.id;
|
|
3458
|
+
if (!prompt || !id) { this._titlePromise = Promise.resolve(); return; }
|
|
3459
|
+
this._titlePromise = Promise.resolve()
|
|
3460
|
+
.then(() => generateTitle(prompt, this._titleGenOpts()))
|
|
3461
|
+
.then((real) => {
|
|
3462
|
+
if (!real || real === this.state.title) return; // empty / unchanged → keep provisional
|
|
3463
|
+
if (this.abort.signal.aborted) return;
|
|
3464
|
+
this.state.title = real;
|
|
3465
|
+
this.state.titleProvisional = false;
|
|
3466
|
+
this.state.updatedAt = new Date().toISOString();
|
|
3467
|
+
updatePipelineTitle(id, real); // persist (dedicated UPDATE)
|
|
3468
|
+
// Carry pipelineId: the client run model has no pipeline id; History patch needs it.
|
|
3469
|
+
this._emit('title', { title: real, provisional: false, pipelineId: id }); // live broadcast
|
|
3470
|
+
})
|
|
3471
|
+
.catch(() => { /* generateTitle already swallows; this is a final backstop */ });
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3474
|
+
async _persist() {
|
|
3475
|
+
if (!this.pipeline) return;
|
|
3476
|
+
try {
|
|
3477
|
+
await writeState(this.pipeline.dir, this.state);
|
|
3478
|
+
} catch {
|
|
3479
|
+
/* persistence is best-effort */
|
|
3480
|
+
}
|
|
3481
|
+
}
|
|
3482
|
+
|
|
3483
|
+
/** Begin owning this run's row: stamp pid/host + start the heartbeat timer. Idempotent. */
|
|
3484
|
+
_startHeartbeat() {
|
|
3485
|
+
if (!this.pipeline?.id) return;
|
|
3486
|
+
claimPipelineOwnership(this.pipeline.id);
|
|
3487
|
+
if (this._heartbeatTimer) return;
|
|
3488
|
+
this._heartbeatTimer = setInterval(() => {
|
|
3489
|
+
try { touchHeartbeat(this.pipeline.id); } catch { /* best-effort */ }
|
|
3490
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
3491
|
+
this._heartbeatTimer.unref?.(); // never hold the process open
|
|
3492
|
+
}
|
|
3493
|
+
|
|
3494
|
+
/** Stop heartbeating and drop ownership (terminal/paused). Safe to call repeatedly. */
|
|
3495
|
+
_stopHeartbeat() {
|
|
3496
|
+
if (this._heartbeatTimer) { clearInterval(this._heartbeatTimer); this._heartbeatTimer = null; }
|
|
3497
|
+
if (this.pipeline?.id) clearPipelineOwnership(this.pipeline.id);
|
|
3498
|
+
}
|
|
3499
|
+
|
|
3500
|
+
/** Terminal bookkeeping for a pause: persist the resume point + paused status. */
|
|
3501
|
+
async _completePaused() {
|
|
3502
|
+
this._setStatus('paused');
|
|
3503
|
+
await this._persist();
|
|
3504
|
+
// A plain manual pause has no reason; only a limit-pause records one (audited
|
|
3505
|
+
// already at the pause site, so don't double-log it here).
|
|
3506
|
+
if (!this.pauseReason) await appendAudit(this.pipeline.dir, `Pipeline **paused**.`).catch(() => {});
|
|
3507
|
+
this._emit('done', { status: 'paused', pipelineDir: this.pipeline.dir, reason: this.pauseReason || null });
|
|
3508
|
+
return { status: 'paused', pipelineDir: this.pipeline.dir, reason: this.pauseReason || null };
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
// ── engine hooks ─────────────────────────────────────────────────────────────
|
|
3512
|
+
// The harness is engine-agnostic; everything an engine decides sits behind
|
|
3513
|
+
// these six seams. The base throws so a half-built engine fails loudly at the
|
|
3514
|
+
// seam instead of running a half-configured pipeline.
|
|
3515
|
+
|
|
3516
|
+
/** Resolve the run's topology from the merged registry.
|
|
3517
|
+
* @param {Record<string,object>} _registry loadAgentRegistry() output
|
|
3518
|
+
* @returns {Promise<{manifest:object, agentKeys:Set<string>, workflow:{id:string,name:string}}>}
|
|
3519
|
+
* All three fields are REQUIRED; the shell throws a named 'engine hook
|
|
3520
|
+
* contract' error when one is missing. manifest -> state.stepper (the UI
|
|
3521
|
+
* snapshot); agentKeys -> the §9.4 preflight gate + the skills gate;
|
|
3522
|
+
* workflow -> the run's audit line. */
|
|
3523
|
+
async _resolveTopology(_registry) { throw new Error('engine hook not implemented: _resolveTopology'); }
|
|
3524
|
+
|
|
3525
|
+
/** Run the pipeline to completion or to a pause.
|
|
3526
|
+
* @param {{resume?:object|null, rehydrated?:object|null}} _args resume point + _engineRehydrate's bag
|
|
3527
|
+
* @returns {Promise<'done'|'paused'>} */
|
|
3528
|
+
async _engineRun(_args) { throw new Error('engine hook not implemented: _engineRun'); }
|
|
3529
|
+
|
|
3530
|
+
/** The resume point recorded when a pause unwinds BEFORE the engine started
|
|
3531
|
+
* (preflight/worktree). @returns {object} */
|
|
3532
|
+
_enginePrePausePoint() { throw new Error('engine hook not implemented: _enginePrePausePoint'); }
|
|
3533
|
+
|
|
3534
|
+
/** Read the engine-specific parts of a resume point; throws when the point is
|
|
3535
|
+
* not this engine's. Called at the position of dev's version gate: BEFORE the
|
|
3536
|
+
* shell has rehydrated any state (state.*, pipeline, logWriter, stepModels,
|
|
3537
|
+
* workflowId, guardrails are NOT restored yet) and OUTSIDE the shell's try —
|
|
3538
|
+
* a throw here rejects resume() without touching the row. Keep it pure: read
|
|
3539
|
+
* rp, decide whether the point is yours, return the bag. Engine restoration
|
|
3540
|
+
* that needs state/registry/pipeline (manifest adoption, prompt hydration,
|
|
3541
|
+
* the §9.4 re-preflight) belongs in _engineRun({resume, rehydrated}), which
|
|
3542
|
+
* runs inside the try after everything is restored — exactly where v1 does
|
|
3543
|
+
* its re-preflight. May be async: the shell awaits this call.
|
|
3544
|
+
* @param {object} _rp
|
|
3545
|
+
* @returns {{checkpointRef:string|null,
|
|
3546
|
+
* memberWorktrees:Array<{projectKey:string, worktreeDir:string, graphInstruction:string}>,
|
|
3547
|
+
* plan?:object|null, audit:string}} audit is REQUIRED — the shell
|
|
3548
|
+
* writes it verbatim as the resume audit line. */
|
|
3549
|
+
_engineRehydrate(_rp) { throw new Error('engine hook not implemented: _engineRehydrate'); }
|
|
3550
|
+
|
|
3551
|
+
/** Preflight/Done are ledger rows like any other execution: keyed
|
|
3552
|
+
* `x:<name>:1`, agentKey null, excluded from progress and execution counts by
|
|
3553
|
+
* the readers (run-decor's ledgerRows, cli/render's summary). */
|
|
3554
|
+
_bookend(name, status) {
|
|
3555
|
+
const executionId = `x:${name}:1`;
|
|
3556
|
+
// _recordStep keys on `cycle ? phase#cycle : phase`, so pass cycle 0 to get
|
|
3557
|
+
// the executionId VERBATIM as the ledger key, then stamp the exec columns.
|
|
3558
|
+
// `executionId` is NOT optional: artifacts.mjs persists execution_id from it,
|
|
3559
|
+
// and without it a REHYDRATED run stops filtering the bookends.
|
|
3560
|
+
this._recordStep(executionId, 0, status, name);
|
|
3561
|
+
const row = this.state.steps.find((s) => s.key === executionId);
|
|
3562
|
+
if (row) {
|
|
3563
|
+
Object.assign(row, {
|
|
3564
|
+
executionId, nodeId: name, phase: null, cycle: 1, kind: 'cycle', ordinal: 1,
|
|
3565
|
+
agentKey: null, stepIndex: null, trigger: { wireIds: [], freshPorts: [] },
|
|
3566
|
+
});
|
|
3567
|
+
}
|
|
3568
|
+
this.state.updatedAt = new Date().toISOString();
|
|
3569
|
+
this._emit('exec', {
|
|
3570
|
+
nodeId: name, executionId, kind: 'cycle', ordinal: 1, status,
|
|
3571
|
+
agentKey: null, trigger: { wireIds: [], freshPorts: [] },
|
|
3572
|
+
});
|
|
3573
|
+
this._emit('state', this.getState());
|
|
3574
|
+
this._persist().catch(() => {});
|
|
3575
|
+
}
|
|
3576
|
+
|
|
3577
|
+
/** Constructor seam for the v1 runner registry (v1 only; the graph engine
|
|
3578
|
+
* injects its runners through the executor). Called from the constructor at
|
|
3579
|
+
* the exact position the assignment had. */
|
|
3580
|
+
_initRunners(_opts) { /* base: no runner registry */ }
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
/** TEST-ONLY: the skill-label helpers `test/skill-capture.test.mjs` pins. They
|
|
3584
|
+
* are harness code, so they outlive the v1 engine that used to re-export them. */
|
|
3585
|
+
export const _testing = { SKILLS_MAX, skillLabel, mergeSkills };
|