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