@dtlabs/ai-toolkit 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/bin/cli.js +371 -2
- package/docs/task-executor-bootstrap.md +195 -0
- package/docs/task-executor-cli.md +319 -0
- package/docs/workflow-control-plane.md +95 -0
- package/lib/execution-ledger.js +186 -4
- package/lib/task-executor/claude-process.js +563 -0
- package/lib/task-executor/git.js +1362 -0
- package/lib/task-executor/index.js +3729 -0
- package/lib/task-executor/ownership.js +590 -0
- package/lib/task-executor/plan.js +725 -0
- package/lib/task-executor/store.js +1164 -0
- package/lib/workflow-artifacts.js +102 -0
- package/lib/workflow-control.js +219 -0
- package/lib/workflow-runner.js +39 -0
- package/package.json +1 -1
- package/src/claude/agents/gaia-intervention-documentation-standard.md +9 -9
- package/src/claude/skills/assess-codebase/SKILL.md +9 -103
- package/src/claude/skills/implement-feature/SKILL.md +74 -159
- package/src/claude/workflows/am-phase1.js +43 -294
- package/src/claude/workflows/am-phase2.js +10 -164
- package/src/claude/workflows/pm-phase1.js +45 -358
- package/src/claude/workflows/pm-phase2.js +50 -571
- package/src/claude/workflows/pm-phase3.js +5 -824
|
@@ -0,0 +1,3729 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Task-executor module skeleton (FTR-018, INFRA-TASK-BE-03).
|
|
4
|
+
//
|
|
5
|
+
// Public interface for the deterministic task executor: the seven commands
|
|
6
|
+
// from the CLI contract (FTR-018-Tech-Spec.md section 10) — execute
|
|
7
|
+
// (backing the `start` CLI command), status, diagnose, stop, reconcile,
|
|
8
|
+
// resume, replan.
|
|
9
|
+
//
|
|
10
|
+
// This module wires the real state/ledger modules (store.js,
|
|
11
|
+
// INFRA-TASK-BE-01; execution-ledger.js finalizeActivity, INFRA-TASK-BE-02)
|
|
12
|
+
// together with placeholder modules for the pieces that later Work
|
|
13
|
+
// Breakdown tasks implement (ownership.js, plan.js, claude-process.js,
|
|
14
|
+
// git.js — each documented with the task that will replace its stub body).
|
|
15
|
+
// Every public function below currently throws NOT_IMPLEMENTED pointing at
|
|
16
|
+
// the task that implements its real body: this task (INFRA-TASK-BE-03)
|
|
17
|
+
// only establishes the module skeleton and public interface, per its
|
|
18
|
+
// Work Breakdown outcome.
|
|
19
|
+
//
|
|
20
|
+
// dispatchTaskAttempt (US-03-TASK-BE-03), runVerifications (US-04-TASK-BE-01),
|
|
21
|
+
// runReview (US-04-TASK-BE-02) and finalizeTaskCheckpoint (US-05-TASK-BE-05),
|
|
22
|
+
// all added below, are the exceptions: each is a new addition, not one of
|
|
23
|
+
// the seven skeleton commands above, and each has a real body. Together they
|
|
24
|
+
// are the single-task dispatch/verify/review/checkpoint primitives the real
|
|
25
|
+
// run loop (execute(), US-07-TASK-BE-01, still NOT_IMPLEMENTED) will call
|
|
26
|
+
// once per ready task — see each function's own doc comment for its design.
|
|
27
|
+
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
const crypto = require('crypto');
|
|
31
|
+
const { spawn, spawnSync } = require('child_process');
|
|
32
|
+
|
|
33
|
+
const store = require('./store');
|
|
34
|
+
const ownership = require('./ownership');
|
|
35
|
+
const plan = require('./plan');
|
|
36
|
+
const claudeProcess = require('./claude-process');
|
|
37
|
+
const git = require('./git');
|
|
38
|
+
const ledger = require('../execution-ledger');
|
|
39
|
+
const agentRegistry = require('../agent-registry');
|
|
40
|
+
|
|
41
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
42
|
+
|
|
43
|
+
// ── worker-liveness process tagging (US-06-TASK-BE-02) ──────────────────────
|
|
44
|
+
//
|
|
45
|
+
// Generalizes the E-02 evidence-spike's PROVEN "intent-first + reconcile-by-
|
|
46
|
+
// tag" technique (see internal_docs/features/FTR-018-deterministic-task-
|
|
47
|
+
// execution-checkpoints-and-resume/evidence/E-02-process-supervision/
|
|
48
|
+
// harness-registration-window.js): every dispatched worker's argv carries a
|
|
49
|
+
// unique, discoverable tag; a coordinator that crashes and later comes back
|
|
50
|
+
// (reconcile/resume) can positively confirm whether that exact worker is
|
|
51
|
+
// still alive by querying the OS for a live process whose command line
|
|
52
|
+
// contains the tag — never by trusting its own possibly-stale in-memory
|
|
53
|
+
// belief about what it dispatched. dispatchTaskAttempt (US-03-TASK-BE-03,
|
|
54
|
+
// below) is the writer side: it generates the tag and durably persists it
|
|
55
|
+
// into attempt.processIdentity BEFORE spawning. _findLiveTaggedProcess below
|
|
56
|
+
// is the reader side, used by reconcile/resume's evidence evaluation
|
|
57
|
+
// (US-06-TASK-BE-02) to answer "is the worker that owns this tag still
|
|
58
|
+
// running right now".
|
|
59
|
+
const PROCESS_TAG_PREFIX = 'ai-toolkit-task';
|
|
60
|
+
|
|
61
|
+
function _buildProcessTag(runId, taskId, attemptNumber) {
|
|
62
|
+
return PROCESS_TAG_PREFIX + ':' + runId + ':' + taskId + ':' + attemptNumber;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Default basename of the real, qualified Claude CLI executable this module
|
|
66
|
+
// spawns in production (Windows: claude.exe) — the actual spawned executable
|
|
67
|
+
// dispatchTaskAttempt invokes, NOT node.exe (the harness spike's own fake
|
|
68
|
+
// workers were plain Node scripts, which is why its filter used
|
|
69
|
+
// "Name='node.exe'"; this module's real, production spawn target is the
|
|
70
|
+
// Claude CLI itself, so the filter must match ITS basename instead).
|
|
71
|
+
// Callers (reconcile/resume/evaluateTaskEvidence) may override this default
|
|
72
|
+
// via an explicit exeBasename — tests do so unconditionally, since they
|
|
73
|
+
// spawn a real, short-lived Node child process (never a real claude.exe) as
|
|
74
|
+
// the tagged "worker" under test, per this task's own testing constraint.
|
|
75
|
+
const DEFAULT_TAGGED_PROCESS_EXE_BASENAME = 'claude.exe';
|
|
76
|
+
|
|
77
|
+
// Real, positive-evidence-only liveness query for a tagged process, using
|
|
78
|
+
// the exact E-02-qualified Win32_Process/CIM technique proven in the Gate-1
|
|
79
|
+
// evidence spike, adapted from `Name='node.exe'` to the caller-supplied
|
|
80
|
+
// `exeBasename` (the real executable dispatchTaskAttempt actually spawned):
|
|
81
|
+
// Get-CimInstance Win32_Process -Filter "Name='<exeBasename>'" |
|
|
82
|
+
// Where-Object { $_.CommandLine -like '*<tag>*' } |
|
|
83
|
+
// Select-Object -ExpandProperty ProcessId
|
|
84
|
+
// Same shell:false-at-the-Node-level, single-PowerShell-argv-string spawn
|
|
85
|
+
// convention already used by ownership.js's own _queryProcessStartTimeSync
|
|
86
|
+
// and claude-process.js's tree-kill helper (the PowerShell command string
|
|
87
|
+
// itself is unavoidably one argument; nothing here is built by concatenating
|
|
88
|
+
// untrusted input into a shell command outside that one, already-established
|
|
89
|
+
// pattern).
|
|
90
|
+
//
|
|
91
|
+
// Windows-only (Gate-1 E-02 qualification: "no implicit cross-platform
|
|
92
|
+
// guarantee") — mirrors ownership.js's checkOwnerLiveness discipline exactly:
|
|
93
|
+
// only ever returns positive evidence, never coerces an inconclusive result
|
|
94
|
+
// into either extreme.
|
|
95
|
+
//
|
|
96
|
+
// Returns exactly one of:
|
|
97
|
+
// { status: 'found-alive', pids: number[], reason } — one or more
|
|
98
|
+
// live processes named `exeBasename` whose command line contains `tag`.
|
|
99
|
+
// { status: 'confirmed-not-found', pids: [], reason } — the OS
|
|
100
|
+
// positively confirms no such process exists (positive absence
|
|
101
|
+
// evidence, not merely "we didn't look").
|
|
102
|
+
// { status: 'unknown', pids: [], reason } — non-Windows,
|
|
103
|
+
// invalid input, or the OS query itself failed/returned an untrusted
|
|
104
|
+
// result. Callers MUST treat this exactly like 'found-alive' for any
|
|
105
|
+
// "is it safe to touch this attempt" decision — never as evidence of
|
|
106
|
+
// death.
|
|
107
|
+
function _findLiveTaggedProcess(tag, exeBasename) {
|
|
108
|
+
if (!IS_WINDOWS) {
|
|
109
|
+
return {
|
|
110
|
+
status: 'unknown',
|
|
111
|
+
pids: [],
|
|
112
|
+
reason:
|
|
113
|
+
'process liveness checking is only qualified on Windows (E-02 evidence spike); ' +
|
|
114
|
+
'this platform ("' + process.platform + '") has no qualified liveness mechanism yet',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (typeof tag !== 'string' || tag.length === 0) {
|
|
118
|
+
return { status: 'unknown', pids: [], reason: '_findLiveTaggedProcess: tag must be a non-empty string' };
|
|
119
|
+
}
|
|
120
|
+
if (typeof exeBasename !== 'string' || exeBasename.length === 0) {
|
|
121
|
+
return { status: 'unknown', pids: [], reason: '_findLiveTaggedProcess: exeBasename must be a non-empty string' };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let result;
|
|
125
|
+
try {
|
|
126
|
+
result = spawnSync(
|
|
127
|
+
'powershell',
|
|
128
|
+
[
|
|
129
|
+
'-NoProfile',
|
|
130
|
+
'-NonInteractive',
|
|
131
|
+
'-Command',
|
|
132
|
+
"Get-CimInstance Win32_Process -Filter \"Name='" + exeBasename + "'\" | " +
|
|
133
|
+
"Where-Object { $_.CommandLine -like '*" + tag + "*' } | " +
|
|
134
|
+
'Select-Object -ExpandProperty ProcessId',
|
|
135
|
+
],
|
|
136
|
+
{ encoding: 'utf8', windowsHide: true }
|
|
137
|
+
);
|
|
138
|
+
} catch (_) {
|
|
139
|
+
return { status: 'unknown', pids: [], reason: '_findLiveTaggedProcess: failed to spawn the OS process query' };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!result || result.error || result.status !== 0 || typeof result.stdout !== 'string') {
|
|
143
|
+
return {
|
|
144
|
+
status: 'unknown',
|
|
145
|
+
pids: [],
|
|
146
|
+
reason: '_findLiveTaggedProcess: the OS process query failed or returned an untrusted result',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const pids = result.stdout
|
|
151
|
+
.split(/\r?\n/)
|
|
152
|
+
.map(function (s) { return s.trim(); })
|
|
153
|
+
.filter(Boolean)
|
|
154
|
+
.map(Number)
|
|
155
|
+
.filter(function (n) { return Number.isInteger(n) && n > 0; });
|
|
156
|
+
|
|
157
|
+
if (pids.length > 0) {
|
|
158
|
+
return {
|
|
159
|
+
status: 'found-alive',
|
|
160
|
+
pids: pids,
|
|
161
|
+
reason:
|
|
162
|
+
'found ' + pids.length + ' live process(es) named "' + exeBasename + '" whose command line ' +
|
|
163
|
+
'matches tag "' + tag + '" (confirmed via OS process query)',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
status: 'confirmed-not-found',
|
|
168
|
+
pids: [],
|
|
169
|
+
reason:
|
|
170
|
+
'no live process named "' + exeBasename + '" with a command line matching tag "' + tag + '" was found ' +
|
|
171
|
+
'(positive absence evidence, confirmed via OS process query)',
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Tree-kill for stop()'s 'immediate' mode, given only a bare PID discovered
|
|
176
|
+
// via _findLiveTaggedProcess — NOT claude-process.js's own
|
|
177
|
+
// _killProcessTreeSync(child), which requires a live ChildProcess handle this
|
|
178
|
+
// process spawned itself. stop() has no such handle: the worker it is
|
|
179
|
+
// terminating may belong to a completely different coordinator process (the
|
|
180
|
+
// one that originally called dispatchTaskAttempt), discovered only through
|
|
181
|
+
// the OS-level tag query. Same E-02-qualified technique otherwise
|
|
182
|
+
// (`taskkill /PID <pid> /T /F` on Windows; best-effort SIGKILL by PID,
|
|
183
|
+
// reported 'unknown', elsewhere) — duplicated rather than exported from
|
|
184
|
+
// claude-process.js for the same reason _killVerificationProcessTreeSync
|
|
185
|
+
// above already duplicates it: a different trust/lifetime domain, kept
|
|
186
|
+
// independently readable.
|
|
187
|
+
function _killProcessTreeByPidSync(pid) {
|
|
188
|
+
if (IS_WINDOWS) {
|
|
189
|
+
const res = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], {
|
|
190
|
+
stdio: 'ignore',
|
|
191
|
+
windowsHide: true,
|
|
192
|
+
});
|
|
193
|
+
return !res.error && res.status === 0;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
process.kill(pid, 'SIGKILL');
|
|
197
|
+
} catch (_) {
|
|
198
|
+
// best-effort only
|
|
199
|
+
}
|
|
200
|
+
return 'unknown';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function _sleep(ms) {
|
|
204
|
+
return new Promise(function (resolve) { setTimeout(resolve, ms); });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Bounded poll for stop()'s 'immediate' mode: after issuing the kill above,
|
|
208
|
+
// re-queries _findLiveTaggedProcess up to `maxAttempts` times (a handful of
|
|
209
|
+
// short waits, never an unbounded loop) until the OS positively confirms the
|
|
210
|
+
// tagged process is gone. Returns true ONLY on an explicit
|
|
211
|
+
// 'confirmed-not-found' result — 'found-alive' (kill did not yet take
|
|
212
|
+
// effect) and 'unknown' (inconclusive OS query) both resolve to false, per
|
|
213
|
+
// this module's existing discipline of never coercing an inconclusive result
|
|
214
|
+
// into a positive claim (mirrors _findLiveTaggedProcess's own contract).
|
|
215
|
+
async function _awaitConfirmedTermination(tag, exeBasename, opts) {
|
|
216
|
+
opts = opts || {};
|
|
217
|
+
const maxAttempts = Number.isInteger(opts.maxAttempts) && opts.maxAttempts > 0 ? opts.maxAttempts : 5;
|
|
218
|
+
const intervalMs = Number.isInteger(opts.intervalMs) && opts.intervalMs > 0 ? opts.intervalMs : 200;
|
|
219
|
+
|
|
220
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
221
|
+
const check = _findLiveTaggedProcess(tag, exeBasename);
|
|
222
|
+
if (check.status === 'confirmed-not-found') {
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
if (attempt < maxAttempts - 1) {
|
|
226
|
+
await _sleep(intervalMs);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Bounded per-stream output ceiling for verification commands (10 MiB).
|
|
233
|
+
// Unlike claude-process.js's spawnClaudeAgent (which kills the process tree
|
|
234
|
+
// on overflow, because an LLM agent's output is open-ended and unbounded-by-
|
|
235
|
+
// design), verification commands are finite, deterministic invocations
|
|
236
|
+
// (npm test, grep, test -f, ...) — bounding here only guards memory against
|
|
237
|
+
// a pathological command, not runaway control loss. So on overflow this
|
|
238
|
+
// module simply stops buffering further chunks and marks the stream
|
|
239
|
+
// truncated; it lets the command run to its real exit code rather than
|
|
240
|
+
// killing it, which keeps the recorded exitCode meaningful.
|
|
241
|
+
const DEFAULT_MAX_VERIFICATION_OUTPUT_BYTES = 10 * 1024 * 1024;
|
|
242
|
+
|
|
243
|
+
function _notImplemented(fnName, task) {
|
|
244
|
+
const err = new Error(
|
|
245
|
+
fnName + ' is NOT_IMPLEMENTED — see Work Breakdown task ' + task
|
|
246
|
+
);
|
|
247
|
+
err.code = 'NOT_IMPLEMENTED';
|
|
248
|
+
err.task = task;
|
|
249
|
+
throw err;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function _err(code, message) {
|
|
253
|
+
const e = new Error(message);
|
|
254
|
+
e.code = code;
|
|
255
|
+
return e;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function _requireNonEmptyString(value, label) {
|
|
259
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
260
|
+
throw _err('DISPATCH_VALIDATION_ERROR', label + ' must be a non-empty string');
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Unlike _requireNonEmptyString, an empty string is valid here (a no-op
|
|
265
|
+
// task's diff is legitimately empty per Tech-Spec section 8: "No-op tasks:
|
|
266
|
+
// require verifications and review proving outcome at the current
|
|
267
|
+
// baseline"). Only non-string values (missing/undefined/wrong type) fail.
|
|
268
|
+
function _requireString(value, label) {
|
|
269
|
+
if (typeof value !== 'string') {
|
|
270
|
+
throw _err('DISPATCH_VALIDATION_ERROR', label + ' must be a string');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Canonical agent ID for the review-solution agent — the same string
|
|
275
|
+
// lib/agent-registry.js's AGENT_TYPE_TO_CANONICAL_ID['review-solution'] and
|
|
276
|
+
// its AGENT_RECORDS entry both use to resolve/dispatch this agent everywhere
|
|
277
|
+
// else in the toolkit. runReview hardcodes it (rather than accepting it as a
|
|
278
|
+
// caller-supplied parameter like dispatchTaskAttempt's generic args.agentId)
|
|
279
|
+
// because this function is specific to one agent, not a generic dispatch
|
|
280
|
+
// primitive for any domain agent.
|
|
281
|
+
const REVIEW_AGENT_ID = 'gaia.agent.review.solution';
|
|
282
|
+
|
|
283
|
+
// Deterministic default prompt built from the plan task's own fields (id,
|
|
284
|
+
// title, outcome, domain, dependsOn, acceptanceCriteria, verificationCommands
|
|
285
|
+
// — see plan.js's parseMarkdown/mapPhases for this shape). This task's scope
|
|
286
|
+
// is dispatch orchestration, not prompt authoring; the prompt is assembled
|
|
287
|
+
// here (rather than requiring every caller to build one) purely so
|
|
288
|
+
// dispatchTaskAttempt is callable with only the plan task object, per its
|
|
289
|
+
// documented signature.
|
|
290
|
+
function _buildTaskPrompt(task) {
|
|
291
|
+
const lines = [
|
|
292
|
+
'Task ID: ' + task.id,
|
|
293
|
+
'Title: ' + (task.title || ''),
|
|
294
|
+
'Domain: ' + (task.domain || ''),
|
|
295
|
+
'Outcome: ' + (task.outcome || ''),
|
|
296
|
+
];
|
|
297
|
+
if (Array.isArray(task.dependsOn) && task.dependsOn.length > 0) {
|
|
298
|
+
lines.push('Depends on: ' + task.dependsOn.join(', '));
|
|
299
|
+
}
|
|
300
|
+
if (Array.isArray(task.acceptanceCriteria) && task.acceptanceCriteria.length > 0) {
|
|
301
|
+
lines.push('Acceptance criteria: ' + task.acceptanceCriteria.join(', '));
|
|
302
|
+
}
|
|
303
|
+
if (Array.isArray(task.verificationCommands) && task.verificationCommands.length > 0) {
|
|
304
|
+
lines.push('Verification commands:');
|
|
305
|
+
task.verificationCommands.forEach(function (cmd) { lines.push(' ' + cmd); });
|
|
306
|
+
}
|
|
307
|
+
return lines.join('\n') + '\n';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Dispatches exactly one implementation agent invocation for one task
|
|
312
|
+
* attempt (US-03-TASK-BE-03; completes US-03 "dispatch one implementation
|
|
313
|
+
* agent per task per attempt"). This is the single-task dispatch primitive
|
|
314
|
+
* the real run loop (US-07-TASK-BE-01, execute()) will call once per ready
|
|
315
|
+
* task — it does not itself iterate the ready queue, and it does not
|
|
316
|
+
* acquire/reclaim the repo-wide execution lease (that is the run-start/
|
|
317
|
+
* resume responsibility of US-07/US-06); it only CHECKS that the caller
|
|
318
|
+
* already holds it.
|
|
319
|
+
*
|
|
320
|
+
* Ordering guarantees (fail-closed at every step, in order):
|
|
321
|
+
* 1. Ownership check — ownership.readLease(executionRoot) must return a
|
|
322
|
+
* lease whose runId matches `runId`. No lease, or a lease held by a
|
|
323
|
+
* different runId, throws NO_LOCK_HELD before anything else happens.
|
|
324
|
+
* 2. Identity verification — claude-process.verifyAgentIdentity({
|
|
325
|
+
* projectDir, agentId }) must succeed. Any failure (AGENT_NOT_VERIFIED,
|
|
326
|
+
* DEFINITION_HASH_MISMATCH) propagates unmodified — dispatch never
|
|
327
|
+
* swallows or rewraps it.
|
|
328
|
+
* 3. Persist-before-invoke (AC-01) — the attempt is recorded via
|
|
329
|
+
* store.js's addAttempt (brand-new attempt: stage 'prepared', written,
|
|
330
|
+
* then updated to 'dispatching', written again) or updateAttempt alone
|
|
331
|
+
* (an attempt numbered `attemptNumber` already exists in state — e.g. a
|
|
332
|
+
* resumed dispatch: stage moves directly to 'dispatching'), and TWO
|
|
333
|
+
* ledger activities for this attempt are opened via execution-ledger.js's
|
|
334
|
+
* open()/computeOperationId — both complete BEFORE spawnClaudeAgent is
|
|
335
|
+
* ever called:
|
|
336
|
+
* - 'implementation' (kind 'implementation'): this one dispatch
|
|
337
|
+
* invocation. Closed as soon as its result is durably received
|
|
338
|
+
* (US-04-TASK-BE-03's persistVerificationReviewOutcome path, via
|
|
339
|
+
* runVerifications/runReview — not by this function).
|
|
340
|
+
* - 'task' (kind 'task'): spans the WHOLE per-task-attempt lifecycle
|
|
341
|
+
* (Tech-Spec section 6: "kinds: task, implementation,
|
|
342
|
+
* verification-<index>, review ... The task activity carries null
|
|
343
|
+
* token usage (reason: control-only) and closes after checkpoint
|
|
344
|
+
* and, for N>1, verified integration"). Opened here (dispatch is the
|
|
345
|
+
* first point at which this attempt becomes active) but ONLY closed
|
|
346
|
+
* by finalizeTaskCheckpoint (US-05-TASK-BE-05, below) — never by
|
|
347
|
+
* this function.
|
|
348
|
+
* 4. Only once 1-3 have succeeded: claude-process.spawnClaudeAgent(...) is
|
|
349
|
+
* called with the verified identity's nativeName wired in as the
|
|
350
|
+
* --agent selector and the task's prompt fed via stdin.
|
|
351
|
+
* 5. Returns a structured result combining the raw spawn result with the
|
|
352
|
+
* run/task/attempt identifiers used. No verification/review/checkpoint
|
|
353
|
+
* happens here (US-04/US-05) — scope ends at "dispatch happened and its
|
|
354
|
+
* raw result was captured".
|
|
355
|
+
*
|
|
356
|
+
* Ledger placement: the per-run ledger file lives alongside the run's other
|
|
357
|
+
* artifacts, at store._executionPaths(executionRoot, runId).runDir, using
|
|
358
|
+
* `runId` itself as the file-name prefix (i.e.
|
|
359
|
+
* <executionRoot>/runs/<runId>/<runId>-token-ledger.json) — this is
|
|
360
|
+
* deliberately separate from any feature-delivery-pipeline ledger (e.g.
|
|
361
|
+
* FTR-018-token-ledger.json), which tracks the cost of building the
|
|
362
|
+
* executor, not the cost of tasks the executor itself dispatches at
|
|
363
|
+
* runtime. The ledger agent keys follow Tech-Spec section 6 exactly:
|
|
364
|
+
* `executor:<runId>:<taskId>:implementation` and
|
|
365
|
+
* `executor:<runId>:<taskId>:task`.
|
|
366
|
+
*
|
|
367
|
+
* @param {object} args
|
|
368
|
+
* @param {string} args.executionRoot - Absolute path to
|
|
369
|
+
* <common-git-dir>/ai-toolkit/execution.
|
|
370
|
+
* @param {string} args.runId - UUID of the run that must currently hold the
|
|
371
|
+
* repo-wide execution lease.
|
|
372
|
+
* @param {object} args.task - Plan task object (plan.js shape): at least
|
|
373
|
+
* `id`; `title`/`outcome`/`domain`/`dependsOn`/`acceptanceCriteria`/
|
|
374
|
+
* `verificationCommands` are used to build the default prompt when
|
|
375
|
+
* `args.prompt` is not supplied.
|
|
376
|
+
* @param {number} args.attemptNumber - Positive integer attempt number for
|
|
377
|
+
* this dispatch. Must equal the task's next attempt number
|
|
378
|
+
* (task.attempts.length + 1 in current state) when no attempt numbered
|
|
379
|
+
* `attemptNumber` already exists, or match an existing attempt's number
|
|
380
|
+
* for a resumed dispatch.
|
|
381
|
+
* @param {string} args.claudePath - Absolute path to the qualified Claude
|
|
382
|
+
* CLI executable (forwarded to spawnClaudeAgent).
|
|
383
|
+
* @param {string} args.agentId - Canonical agent ID to verify and dispatch
|
|
384
|
+
* (forwarded to verifyAgentIdentity; its resolved nativeName is what is
|
|
385
|
+
* actually passed as the --agent selector).
|
|
386
|
+
* @param {string} args.projectDir - Project root used both for identity
|
|
387
|
+
* verification (--project) and as the spawned process's cwd.
|
|
388
|
+
* @param {number} [args.taskTimeoutMs] - Optional per-task timeout budget,
|
|
389
|
+
* forwarded to spawnClaudeAgent.
|
|
390
|
+
* @param {string} [args.prompt] - Explicit prompt override. When omitted, a
|
|
391
|
+
* deterministic default is built from `args.task`'s own fields.
|
|
392
|
+
* @param {string[]} [args.spawnArgs] - Test-only override of the full CLI
|
|
393
|
+
* args array forwarded to spawnClaudeAgent. Defaults to the production
|
|
394
|
+
* shape (`--print --output-format json --agent <nativeName>`). Tests use
|
|
395
|
+
* this to substitute the fake-claude-cli fixture's own invocation shape
|
|
396
|
+
* (see tests/fixtures/fake-claude-cli.js) while still exercising a real
|
|
397
|
+
* subprocess spawn end-to-end. Either way (default or override), the
|
|
398
|
+
* attempt's processIdentity tag (US-06-TASK-BE-02) is appended as one
|
|
399
|
+
* extra trailing argv element before spawning — see the inline comment at
|
|
400
|
+
* the actual spawnArgs assignment below for the documented residual risk
|
|
401
|
+
* this carries against the real (not-yet-exercised) Claude CLI.
|
|
402
|
+
* @returns {Promise<{
|
|
403
|
+
* runId: string, taskId: string, attemptNumber: number,
|
|
404
|
+
* agentId: string, nativeAgentName: string,
|
|
405
|
+
* ledgerOperationId: string, processIdentity: string, spawnResult: object
|
|
406
|
+
* }>}
|
|
407
|
+
* @throws {Error} NO_LOCK_HELD, AGENT_NOT_VERIFIED, DEFINITION_HASH_MISMATCH,
|
|
408
|
+
* ATTEMPT_NUMBER_MISMATCH, STATE_NOT_FOUND, STATE_TASK_NOT_FOUND, or any
|
|
409
|
+
* error surfaced by spawnClaudeAgent (e.g. CLAUDE_EXECUTABLE_NOT_FOUND,
|
|
410
|
+
* CLAUDE_SPAWN_FAILED).
|
|
411
|
+
*/
|
|
412
|
+
async function dispatchTaskAttempt(args) {
|
|
413
|
+
args = args || {};
|
|
414
|
+
|
|
415
|
+
_requireNonEmptyString(args.executionRoot, 'dispatchTaskAttempt: args.executionRoot');
|
|
416
|
+
_requireNonEmptyString(args.runId, 'dispatchTaskAttempt: args.runId');
|
|
417
|
+
_requireNonEmptyString(args.claudePath, 'dispatchTaskAttempt: args.claudePath');
|
|
418
|
+
_requireNonEmptyString(args.agentId, 'dispatchTaskAttempt: args.agentId');
|
|
419
|
+
_requireNonEmptyString(args.projectDir, 'dispatchTaskAttempt: args.projectDir');
|
|
420
|
+
if (args.task == null || typeof args.task !== 'object') {
|
|
421
|
+
throw _err('DISPATCH_VALIDATION_ERROR', 'dispatchTaskAttempt: args.task must be an object');
|
|
422
|
+
}
|
|
423
|
+
_requireNonEmptyString(args.task.id, 'dispatchTaskAttempt: args.task.id');
|
|
424
|
+
if (!Number.isInteger(args.attemptNumber) || args.attemptNumber < 1) {
|
|
425
|
+
throw _err('DISPATCH_VALIDATION_ERROR', 'dispatchTaskAttempt: args.attemptNumber must be a positive integer');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const executionRoot = args.executionRoot;
|
|
429
|
+
const runId = args.runId;
|
|
430
|
+
const task = args.task;
|
|
431
|
+
const attemptNumber = args.attemptNumber;
|
|
432
|
+
|
|
433
|
+
// 1. Ownership check — never acquire/reclaim here, only verify the caller
|
|
434
|
+
// already holds the lease it claims to hold.
|
|
435
|
+
const lease = ownership.readLease(executionRoot);
|
|
436
|
+
if (!lease || lease.runId !== runId) {
|
|
437
|
+
throw _err(
|
|
438
|
+
'NO_LOCK_HELD',
|
|
439
|
+
'dispatchTaskAttempt: no repo-wide execution lease held by runId "' + runId + '" at ' +
|
|
440
|
+
executionRoot + (lease ? ' (currently held by runId "' + lease.runId + '")' : ' (no lease held)')
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// 2. Identity verification — propagate failures unmodified.
|
|
445
|
+
const identity = claudeProcess.verifyAgentIdentity({
|
|
446
|
+
projectDir: args.projectDir,
|
|
447
|
+
agentId: args.agentId,
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// 3. Persist-before-invoke: attempt identity, then ledger activity.
|
|
451
|
+
//
|
|
452
|
+
// processIdentity (US-06-TASK-BE-02): a unique, discoverable per-attempt
|
|
453
|
+
// tag (see the "worker-liveness process tagging" file comment above
|
|
454
|
+
// IS_WINDOWS) is generated here and written into the attempt's
|
|
455
|
+
// processIdentity field in the SAME updateAttempt/writeState call that
|
|
456
|
+
// advances the stage to 'dispatching' — i.e. durably on disk BEFORE
|
|
457
|
+
// spawnClaudeAgent is ever invoked below. This ordering is the whole
|
|
458
|
+
// point: a coordinator crash between this write and the actual spawn (or
|
|
459
|
+
// between the spawn and any later write) still leaves a discoverable,
|
|
460
|
+
// already-persisted trail that reconcile/resume's evidence evaluation can
|
|
461
|
+
// positively confirm alive or dead later, with no dependency on any
|
|
462
|
+
// in-memory state the crashed coordinator held.
|
|
463
|
+
const processIdentity = _buildProcessTag(runId, task.id, attemptNumber);
|
|
464
|
+
|
|
465
|
+
let state = store.readState(executionRoot, runId);
|
|
466
|
+
const stateTaskEntry = state.tasks[task.id];
|
|
467
|
+
const existingAttempt = stateTaskEntry
|
|
468
|
+
? stateTaskEntry.attempts.find(function (a) { return a.number === attemptNumber; })
|
|
469
|
+
: undefined;
|
|
470
|
+
|
|
471
|
+
if (existingAttempt) {
|
|
472
|
+
state = state.updateAttempt(task.id, attemptNumber, { stage: 'dispatching', processIdentity: processIdentity });
|
|
473
|
+
state = store.writeState(executionRoot, runId, state);
|
|
474
|
+
} else {
|
|
475
|
+
const expectedNextNumber = stateTaskEntry ? stateTaskEntry.attempts.length + 1 : 1;
|
|
476
|
+
if (attemptNumber !== expectedNextNumber) {
|
|
477
|
+
throw _err(
|
|
478
|
+
'ATTEMPT_NUMBER_MISMATCH',
|
|
479
|
+
'dispatchTaskAttempt: task "' + task.id + '" expected next attempt number ' +
|
|
480
|
+
expectedNextNumber + ', got ' + attemptNumber
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
state = state.addAttempt(task.id, { stage: 'prepared' });
|
|
484
|
+
state = store.writeState(executionRoot, runId, state);
|
|
485
|
+
state = state.updateAttempt(task.id, attemptNumber, { stage: 'dispatching', processIdentity: processIdentity });
|
|
486
|
+
state = store.writeState(executionRoot, runId, state);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const ledgerPaths = store._executionPaths(executionRoot, runId);
|
|
490
|
+
const ledgerAgentKey = 'executor:' + runId + ':' + task.id + ':implementation';
|
|
491
|
+
const ledgerMetadata = {
|
|
492
|
+
agentId: identity.agentId,
|
|
493
|
+
nativeAgentName: identity.nativeName,
|
|
494
|
+
platform: process.platform,
|
|
495
|
+
toolkitVersion: identity.toolkitVersion,
|
|
496
|
+
resolutionScope: identity.scope,
|
|
497
|
+
definitionHash: identity.sha256,
|
|
498
|
+
};
|
|
499
|
+
const ledgerOpenResult = ledger.open(
|
|
500
|
+
ledgerPaths.runDir,
|
|
501
|
+
runId,
|
|
502
|
+
ledgerAgentKey,
|
|
503
|
+
'implementation',
|
|
504
|
+
null,
|
|
505
|
+
attemptNumber,
|
|
506
|
+
ledgerMetadata
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
// Task-kind ledger activity (Tech-Spec section 6) — opened together with
|
|
510
|
+
// 'implementation' above, but spans the whole task-attempt lifecycle: it
|
|
511
|
+
// is deliberately left open here and only ever closed by
|
|
512
|
+
// finalizeTaskCheckpoint (US-05-TASK-BE-05) once the checkpoint SHA has
|
|
513
|
+
// been registered and verified reachable on the feature branch. Opened
|
|
514
|
+
// AFTER 'implementation' so ledgerOpenResult (and every existing caller
|
|
515
|
+
// that reads the first ledger entry written for this attempt) keeps
|
|
516
|
+
// referring to the 'implementation' activity unchanged.
|
|
517
|
+
const ledgerTaskAgentKey = 'executor:' + runId + ':' + task.id + ':task';
|
|
518
|
+
ledger.open(
|
|
519
|
+
ledgerPaths.runDir,
|
|
520
|
+
runId,
|
|
521
|
+
ledgerTaskAgentKey,
|
|
522
|
+
'task',
|
|
523
|
+
null,
|
|
524
|
+
attemptNumber,
|
|
525
|
+
ledgerMetadata
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
// 4. Only now: dispatch the verified agent.
|
|
529
|
+
const prompt = typeof args.prompt === 'string' ? args.prompt : _buildTaskPrompt(task);
|
|
530
|
+
const baseSpawnArgs = Array.isArray(args.spawnArgs)
|
|
531
|
+
? args.spawnArgs
|
|
532
|
+
: ['--print', '--output-format', 'json', '--agent', identity.nativeName];
|
|
533
|
+
// Append the same processIdentity tag already persisted above as one extra
|
|
534
|
+
// trailing argv element, so it appears verbatim in the spawned process's
|
|
535
|
+
// own command line — this is what makes _findLiveTaggedProcess's later OS
|
|
536
|
+
// query able to find it (see the file-level "worker-liveness process
|
|
537
|
+
// tagging" comment above IS_WINDOWS).
|
|
538
|
+
//
|
|
539
|
+
// RESIDUAL RISK (documented, not blocking): this appends an extra argv
|
|
540
|
+
// element to whatever args the real Claude CLI would otherwise receive.
|
|
541
|
+
// Nothing in this codebase's E-00/E-01/E-02 evidence dossier (see
|
|
542
|
+
// FTR-018-Tech-Spec.md section 3's evidence table) documents a claude.exe
|
|
543
|
+
// CLI flag reference that conclusively confirms an unrecognized trailing
|
|
544
|
+
// positional argv element is safe/ignored by the real executable — every
|
|
545
|
+
// spawn in this test suite (and in this codebase to date) targets
|
|
546
|
+
// tests/fixtures/fake-claude-cli.js, never real claude.exe. The
|
|
547
|
+
// real-Claude-runtime test (US-09-TASK-TEST-02) is separately gated and
|
|
548
|
+
// not yet authorized, so this cannot be empirically validated here. Flagged
|
|
549
|
+
// for verification once that real-runtime test is authorized.
|
|
550
|
+
const spawnArgs = baseSpawnArgs.concat([processIdentity]);
|
|
551
|
+
const spawnResult = await claudeProcess.spawnClaudeAgent({
|
|
552
|
+
claudePath: args.claudePath,
|
|
553
|
+
args: spawnArgs,
|
|
554
|
+
prompt: prompt,
|
|
555
|
+
cwd: args.projectDir,
|
|
556
|
+
taskTimeoutMs: args.taskTimeoutMs,
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// 5. Raw capture only — no verification/review/checkpoint here.
|
|
560
|
+
return {
|
|
561
|
+
runId: runId,
|
|
562
|
+
taskId: task.id,
|
|
563
|
+
attemptNumber: attemptNumber,
|
|
564
|
+
agentId: identity.agentId,
|
|
565
|
+
nativeAgentName: identity.nativeName,
|
|
566
|
+
ledgerOperationId: ledgerOpenResult.operation_id,
|
|
567
|
+
processIdentity: processIdentity,
|
|
568
|
+
spawnResult: spawnResult,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Deterministic review prompt: task context, the exact diff bytes (verbatim,
|
|
573
|
+
// never re-escaped or reformatted — Tech-Spec section 8: "unexpected tracked
|
|
574
|
+
// changes invalidate the reviewed diff", so the review agent must see
|
|
575
|
+
// exactly what will be checkpointed), and the review criteria the diff must
|
|
576
|
+
// satisfy. There is no separate "review criteria" field in the plan.js task
|
|
577
|
+
// shape (see plan.js's parseMarkdown/mapPhases) — Tech-Spec section 7's
|
|
578
|
+
// binding rule is "review passed=true with no blocking finding" evaluated
|
|
579
|
+
// against the task's own approved acceptance/verification contract, so the
|
|
580
|
+
// task's acceptanceCriteria and verificationCommands are exactly what this
|
|
581
|
+
// function feeds as the criteria the diff must satisfy.
|
|
582
|
+
function _buildReviewPrompt(task, diff) {
|
|
583
|
+
const lines = [
|
|
584
|
+
'Task ID: ' + task.id,
|
|
585
|
+
'Title: ' + (task.title || ''),
|
|
586
|
+
'Domain: ' + (task.domain || ''),
|
|
587
|
+
'Outcome: ' + (task.outcome || ''),
|
|
588
|
+
];
|
|
589
|
+
if (task.groupingRationale) {
|
|
590
|
+
lines.push('Grouping rationale: ' + task.groupingRationale);
|
|
591
|
+
}
|
|
592
|
+
if (Array.isArray(task.acceptanceCriteria) && task.acceptanceCriteria.length > 0) {
|
|
593
|
+
lines.push('Acceptance criteria: ' + task.acceptanceCriteria.join(', '));
|
|
594
|
+
}
|
|
595
|
+
lines.push('');
|
|
596
|
+
lines.push('Review criteria (the diff below must satisfy ALL of these):');
|
|
597
|
+
if (Array.isArray(task.acceptanceCriteria) && task.acceptanceCriteria.length > 0) {
|
|
598
|
+
task.acceptanceCriteria.forEach(function (ac) { lines.push(' - Acceptance criterion: ' + ac); });
|
|
599
|
+
}
|
|
600
|
+
if (Array.isArray(task.verificationCommands) && task.verificationCommands.length > 0) {
|
|
601
|
+
task.verificationCommands.forEach(function (cmd) { lines.push(' - Verification command must pass: ' + cmd); });
|
|
602
|
+
}
|
|
603
|
+
if (
|
|
604
|
+
(!Array.isArray(task.acceptanceCriteria) || task.acceptanceCriteria.length === 0) &&
|
|
605
|
+
(!Array.isArray(task.verificationCommands) || task.verificationCommands.length === 0)
|
|
606
|
+
) {
|
|
607
|
+
lines.push(' (none declared on the task)');
|
|
608
|
+
}
|
|
609
|
+
lines.push('');
|
|
610
|
+
lines.push('--- BEGIN DIFF (exact, verbatim) ---');
|
|
611
|
+
lines.push(diff);
|
|
612
|
+
lines.push('--- END DIFF ---');
|
|
613
|
+
return lines.join('\n') + '\n';
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Parses whatever structured verdict it can out of the review-solution
|
|
617
|
+
// agent's reply. That agent's own definition (src/claude/agents/
|
|
618
|
+
// gaia-review-solution.md, "## Output") specifies a fixed human-readable text
|
|
619
|
+
// template — "Verdict: PASS / FAIL", "CRITICAL (blocks merge): ..." — not a
|
|
620
|
+
// JSON contract; there is no machine schema published for it. This function
|
|
621
|
+
// is therefore a best-effort pattern match over that documented template,
|
|
622
|
+
// applied to the CLI's own `result.result` field — spawnClaudeAgent's parsed
|
|
623
|
+
// --output-format json envelope; see tests/fixtures/fake-claude-cli.js's
|
|
624
|
+
// 'echo-json' mode for the shape it mimics: { is_error, result: <agent reply
|
|
625
|
+
// text> }. When the reply does not match the template (missing/malformed
|
|
626
|
+
// envelope, or free text that doesn't follow it), `passed` is reported as
|
|
627
|
+
// `null` rather than guessed — this function never fabricates a pass.
|
|
628
|
+
// Authoritative pass/fail enforcement against the Tech-Spec's binding rule
|
|
629
|
+
// ("review passed=true with no blocking finding") is intentionally left to
|
|
630
|
+
// US-04-TASK-BE-03 (closing / combined verification+review outcome
|
|
631
|
+
// persistence), which is free to treat a non-matching or null reply as a
|
|
632
|
+
// failure — this function only reports what it can read.
|
|
633
|
+
function _extractReviewVerdict(spawnResult) {
|
|
634
|
+
const text = spawnResult && spawnResult.result && typeof spawnResult.result.result === 'string'
|
|
635
|
+
? spawnResult.result.result
|
|
636
|
+
: null;
|
|
637
|
+
|
|
638
|
+
if (text === null) {
|
|
639
|
+
return { passed: null, criticalFindings: [] };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const verdictMatch = text.match(/Verdict:\s*(PASS|FAIL)/i);
|
|
643
|
+
const passed = verdictMatch ? verdictMatch[1].toUpperCase() === 'PASS' : null;
|
|
644
|
+
|
|
645
|
+
const criticalFindings = [];
|
|
646
|
+
const criticalSectionMatch = text.match(
|
|
647
|
+
/CRITICAL \(blocks merge\):([\s\S]*?)(?:\n\s*\n\s*WARNING \(should fix\):|\n\s*\n\s*INFO \(improvements\):|$)/i
|
|
648
|
+
);
|
|
649
|
+
if (criticalSectionMatch) {
|
|
650
|
+
criticalSectionMatch[1].split('\n').forEach(function (line) {
|
|
651
|
+
const trimmed = line.trim();
|
|
652
|
+
if (trimmed.length > 0 && trimmed.toLowerCase() !== 'none') {
|
|
653
|
+
criticalFindings.push(trimmed);
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return { passed: passed, criticalFindings: criticalFindings };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Dispatches exactly one review-solution agent invocation for one task
|
|
663
|
+
* attempt (US-04-TASK-BE-02; completes the "dispatch review agent with diff
|
|
664
|
+
* context" half of US-04). Mirrors dispatchTaskAttempt's fail-closed
|
|
665
|
+
* ordering discipline exactly, adapted for the review-solution agent instead
|
|
666
|
+
* of a developer agent:
|
|
667
|
+
* 1. Ownership check — ownership.readLease(executionRoot) must return a
|
|
668
|
+
* lease whose runId matches `runId`. No lease, or a lease held by a
|
|
669
|
+
* different runId, throws NO_LOCK_HELD before anything else happens —
|
|
670
|
+
* no agent (implementation or review) is ever dispatched without the
|
|
671
|
+
* caller demonstrably holding the repo-wide execution lease it claims.
|
|
672
|
+
* 2. Identity verification — claude-process.verifyAgentIdentity({
|
|
673
|
+
* projectDir, agentId: REVIEW_AGENT_ID }) must succeed (Tech-Spec
|
|
674
|
+
* section 7: "Before EACH implementation and review invocation:
|
|
675
|
+
* operational resolveAgent..."). Any failure (AGENT_NOT_VERIFIED,
|
|
676
|
+
* DEFINITION_HASH_MISMATCH) propagates unmodified.
|
|
677
|
+
* 3. Persist-before-invoke — the ledger 'review' activity for this attempt
|
|
678
|
+
* is opened via execution-ledger.js's open()/computeOperationId BEFORE
|
|
679
|
+
* spawnClaudeAgent is ever called. Unlike dispatchTaskAttempt, this does
|
|
680
|
+
* NOT add/update a state attempt: the attempt already exists from the
|
|
681
|
+
* implementation stage, and recording its 'reviewed' stage or any
|
|
682
|
+
* pass/fail outcome is US-04-TASK-BE-03's job (closing / combined
|
|
683
|
+
* verification+review outcome persistence), not this function's — this
|
|
684
|
+
* function only opens the ledger activity.
|
|
685
|
+
* 4. Only once 1-3 have succeeded: claude-process.spawnClaudeAgent(...) is
|
|
686
|
+
* called with the verified identity's nativeName wired in as the
|
|
687
|
+
* --agent selector, and a prompt containing the task's context, the
|
|
688
|
+
* exact diff (verbatim, via stdin — never argv), and the review
|
|
689
|
+
* criteria (the task's own acceptanceCriteria/verificationCommands).
|
|
690
|
+
* 5. Returns a structured result combining the raw spawn result with a
|
|
691
|
+
* best-effort parsed verdict (see _extractReviewVerdict) and the
|
|
692
|
+
* run/task/attempt identifiers used. No outcome persistence happens
|
|
693
|
+
* here — scope ends at "review dispatched and its raw result captured".
|
|
694
|
+
*
|
|
695
|
+
* Ledger placement: same per-run ledger file dispatchTaskAttempt uses
|
|
696
|
+
* (<executionRoot>/runs/<runId>/<runId>-token-ledger.json). The ledger agent
|
|
697
|
+
* key follows Tech-Spec section 6 exactly: `executor:<runId>:<taskId>:review`
|
|
698
|
+
* (kind `review`, per section 6's kinds list: task, implementation,
|
|
699
|
+
* verification-<index>, review).
|
|
700
|
+
*
|
|
701
|
+
* @param {object} args
|
|
702
|
+
* @param {string} args.executionRoot - Absolute path to
|
|
703
|
+
* <common-git-dir>/ai-toolkit/execution.
|
|
704
|
+
* @param {string} args.runId - UUID of the run that must currently hold the
|
|
705
|
+
* repo-wide execution lease.
|
|
706
|
+
* @param {object} args.task - Plan task object (plan.js shape): at least
|
|
707
|
+
* `id`; `title`/`outcome`/`domain`/`groupingRationale`/`acceptanceCriteria`/
|
|
708
|
+
* `verificationCommands` are used to build the review prompt.
|
|
709
|
+
* @param {number} args.attemptNumber - Positive integer attempt number this
|
|
710
|
+
* review belongs to (the attempt already dispatched/verified earlier).
|
|
711
|
+
* @param {string} args.claudePath - Absolute path to the qualified Claude
|
|
712
|
+
* CLI executable (forwarded to spawnClaudeAgent).
|
|
713
|
+
* @param {string} args.projectDir - Project root used both for identity
|
|
714
|
+
* verification (--project) and as the spawned process's cwd.
|
|
715
|
+
* @param {string} args.diff - Exact diff text (e.g. `git diff` output) the
|
|
716
|
+
* review must evaluate against the review criteria. This function does not
|
|
717
|
+
* compute the diff itself — that is the caller's responsibility. An empty
|
|
718
|
+
* string is valid (a no-op task's diff is legitimately empty).
|
|
719
|
+
* @param {number} [args.taskTimeoutMs] - Optional per-task timeout budget,
|
|
720
|
+
* forwarded to spawnClaudeAgent.
|
|
721
|
+
* @param {string[]} [args.spawnArgs] - Test-only override of the full CLI
|
|
722
|
+
* args array forwarded to spawnClaudeAgent, exactly like dispatchTaskAttempt's
|
|
723
|
+
* own args.spawnArgs (see tests/fixtures/fake-claude-cli.js).
|
|
724
|
+
* @returns {Promise<{
|
|
725
|
+
* runId: string, taskId: string, attemptNumber: number,
|
|
726
|
+
* agentId: string, nativeAgentName: string,
|
|
727
|
+
* ledgerOperationId: string, spawnResult: object,
|
|
728
|
+
* reviewPassed: (boolean|null), criticalFindings: string[]
|
|
729
|
+
* }>}
|
|
730
|
+
* @throws {Error} NO_LOCK_HELD, AGENT_NOT_VERIFIED, DEFINITION_HASH_MISMATCH,
|
|
731
|
+
* DISPATCH_VALIDATION_ERROR, or any error surfaced by spawnClaudeAgent
|
|
732
|
+
* (e.g. CLAUDE_EXECUTABLE_NOT_FOUND, CLAUDE_SPAWN_FAILED).
|
|
733
|
+
*/
|
|
734
|
+
async function runReview(args) {
|
|
735
|
+
args = args || {};
|
|
736
|
+
|
|
737
|
+
_requireNonEmptyString(args.executionRoot, 'runReview: args.executionRoot');
|
|
738
|
+
_requireNonEmptyString(args.runId, 'runReview: args.runId');
|
|
739
|
+
_requireNonEmptyString(args.claudePath, 'runReview: args.claudePath');
|
|
740
|
+
_requireNonEmptyString(args.projectDir, 'runReview: args.projectDir');
|
|
741
|
+
if (args.task == null || typeof args.task !== 'object') {
|
|
742
|
+
throw _err('DISPATCH_VALIDATION_ERROR', 'runReview: args.task must be an object');
|
|
743
|
+
}
|
|
744
|
+
_requireNonEmptyString(args.task.id, 'runReview: args.task.id');
|
|
745
|
+
if (!Number.isInteger(args.attemptNumber) || args.attemptNumber < 1) {
|
|
746
|
+
throw _err('DISPATCH_VALIDATION_ERROR', 'runReview: args.attemptNumber must be a positive integer');
|
|
747
|
+
}
|
|
748
|
+
_requireString(args.diff, 'runReview: args.diff');
|
|
749
|
+
|
|
750
|
+
const executionRoot = args.executionRoot;
|
|
751
|
+
const runId = args.runId;
|
|
752
|
+
const task = args.task;
|
|
753
|
+
const attemptNumber = args.attemptNumber;
|
|
754
|
+
const diff = args.diff;
|
|
755
|
+
|
|
756
|
+
// 1. Ownership check — never acquire/reclaim here, only verify the caller
|
|
757
|
+
// already holds it (same precondition dispatchTaskAttempt enforces).
|
|
758
|
+
const lease = ownership.readLease(executionRoot);
|
|
759
|
+
if (!lease || lease.runId !== runId) {
|
|
760
|
+
throw _err(
|
|
761
|
+
'NO_LOCK_HELD',
|
|
762
|
+
'runReview: no repo-wide execution lease held by runId "' + runId + '" at ' +
|
|
763
|
+
executionRoot + (lease ? ' (currently held by runId "' + lease.runId + '")' : ' (no lease held)')
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// 2. Identity verification — propagate failures unmodified.
|
|
768
|
+
const identity = claudeProcess.verifyAgentIdentity({
|
|
769
|
+
projectDir: args.projectDir,
|
|
770
|
+
agentId: REVIEW_AGENT_ID,
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
// 3. Persist-before-invoke: open the ledger 'review' activity only (no
|
|
774
|
+
// state attempt mutation — see doc comment above).
|
|
775
|
+
const ledgerPaths = store._executionPaths(executionRoot, runId);
|
|
776
|
+
const ledgerAgentKey = 'executor:' + runId + ':' + task.id + ':review';
|
|
777
|
+
const ledgerMetadata = {
|
|
778
|
+
agentId: identity.agentId,
|
|
779
|
+
nativeAgentName: identity.nativeName,
|
|
780
|
+
platform: process.platform,
|
|
781
|
+
toolkitVersion: identity.toolkitVersion,
|
|
782
|
+
resolutionScope: identity.scope,
|
|
783
|
+
definitionHash: identity.sha256,
|
|
784
|
+
};
|
|
785
|
+
const ledgerOpenResult = ledger.open(
|
|
786
|
+
ledgerPaths.runDir,
|
|
787
|
+
runId,
|
|
788
|
+
ledgerAgentKey,
|
|
789
|
+
'review',
|
|
790
|
+
null,
|
|
791
|
+
attemptNumber,
|
|
792
|
+
ledgerMetadata
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
// 4. Only now: dispatch the verified review agent.
|
|
796
|
+
const prompt = _buildReviewPrompt(task, diff);
|
|
797
|
+
const spawnArgs = Array.isArray(args.spawnArgs)
|
|
798
|
+
? args.spawnArgs
|
|
799
|
+
: ['--print', '--output-format', 'json', '--agent', identity.nativeName];
|
|
800
|
+
const spawnResult = await claudeProcess.spawnClaudeAgent({
|
|
801
|
+
claudePath: args.claudePath,
|
|
802
|
+
args: spawnArgs,
|
|
803
|
+
prompt: prompt,
|
|
804
|
+
cwd: args.projectDir,
|
|
805
|
+
taskTimeoutMs: args.taskTimeoutMs,
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// 5. Raw capture + best-effort verdict extraction — no outcome persistence
|
|
809
|
+
// here (US-04-TASK-BE-03 owns closing this out).
|
|
810
|
+
const verdict = _extractReviewVerdict(spawnResult);
|
|
811
|
+
|
|
812
|
+
return {
|
|
813
|
+
runId: runId,
|
|
814
|
+
taskId: task.id,
|
|
815
|
+
attemptNumber: attemptNumber,
|
|
816
|
+
agentId: identity.agentId,
|
|
817
|
+
nativeAgentName: identity.nativeName,
|
|
818
|
+
ledgerOperationId: ledgerOpenResult.operation_id,
|
|
819
|
+
spawnResult: spawnResult,
|
|
820
|
+
reviewPassed: verdict.passed,
|
|
821
|
+
criticalFindings: verdict.criticalFindings,
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Tree-kill for a verification command that exceeded its optional
|
|
826
|
+
// commandTimeoutMs. Duplicates (rather than imports) claude-process.js's
|
|
827
|
+
// E-02-qualified `taskkill /PID <pid> /T /F` technique: that helper is not
|
|
828
|
+
// exported (claude-process.js's public surface is intentionally limited to
|
|
829
|
+
// spawnClaudeAgent/verifyAgentIdentity), and verification commands are a
|
|
830
|
+
// different trust/lifetime domain from Claude subprocess supervision, so
|
|
831
|
+
// duplicating this small helper here keeps the two modules independently
|
|
832
|
+
// readable rather than forcing a cross-module export for one shared branch.
|
|
833
|
+
function _killVerificationProcessTreeSync(child) {
|
|
834
|
+
if (IS_WINDOWS) {
|
|
835
|
+
const res = spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
|
|
836
|
+
stdio: 'ignore',
|
|
837
|
+
windowsHide: true,
|
|
838
|
+
});
|
|
839
|
+
return !res.error && res.status === 0;
|
|
840
|
+
}
|
|
841
|
+
try {
|
|
842
|
+
child.kill('SIGKILL');
|
|
843
|
+
} catch (_) {
|
|
844
|
+
// best-effort only
|
|
845
|
+
}
|
|
846
|
+
return 'unknown';
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Runs one verification command line as a real Bash command (see
|
|
850
|
+
// runVerifications' doc comment for why Bash + shell:false, rather than
|
|
851
|
+
// shell:true, is the correct choice here). Resolves — never rejects — with a
|
|
852
|
+
// result object describing exactly what happened, including the case where
|
|
853
|
+
// the command could not even be spawned (e.g. no `bash` on PATH): that case
|
|
854
|
+
// is reported via `spawnError` rather than thrown, so a missing shell is
|
|
855
|
+
// handled the same way as any other command failure (AC: "the code should
|
|
856
|
+
// not crash uncaught").
|
|
857
|
+
function _runVerificationCommand(command, cwd, timeoutMs) {
|
|
858
|
+
return new Promise((resolve) => {
|
|
859
|
+
const startedAt = new Date();
|
|
860
|
+
const startedHr = process.hrtime.bigint();
|
|
861
|
+
let settled = false;
|
|
862
|
+
let timedOut = false;
|
|
863
|
+
let timeoutHandle = null;
|
|
864
|
+
|
|
865
|
+
function clearTimer() {
|
|
866
|
+
if (timeoutHandle) {
|
|
867
|
+
clearTimeout(timeoutHandle);
|
|
868
|
+
timeoutHandle = null;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function finish(fields) {
|
|
873
|
+
if (settled) return;
|
|
874
|
+
settled = true;
|
|
875
|
+
clearTimer();
|
|
876
|
+
const endedAt = new Date();
|
|
877
|
+
const durationMs = Number(process.hrtime.bigint() - startedHr) / 1e6;
|
|
878
|
+
resolve(Object.assign(
|
|
879
|
+
{
|
|
880
|
+
command: command,
|
|
881
|
+
startedAt: startedAt.toISOString(),
|
|
882
|
+
endedAt: endedAt.toISOString(),
|
|
883
|
+
durationMs: durationMs,
|
|
884
|
+
exitCode: null,
|
|
885
|
+
signal: null,
|
|
886
|
+
stdout: '',
|
|
887
|
+
stderr: '',
|
|
888
|
+
stdoutTruncated: false,
|
|
889
|
+
stderrTruncated: false,
|
|
890
|
+
timedOut: timedOut,
|
|
891
|
+
spawnError: null,
|
|
892
|
+
},
|
|
893
|
+
fields
|
|
894
|
+
));
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
let child;
|
|
898
|
+
try {
|
|
899
|
+
child = spawn('bash', ['-c', command], {
|
|
900
|
+
cwd: cwd,
|
|
901
|
+
shell: false, // never true: shell:true on Windows silently runs cmd.exe, not Bash
|
|
902
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
903
|
+
windowsHide: true,
|
|
904
|
+
});
|
|
905
|
+
} catch (err) {
|
|
906
|
+
finish({ spawnError: 'failed to spawn bash: ' + err.message });
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const stdoutChunks = [];
|
|
911
|
+
const stderrChunks = [];
|
|
912
|
+
let stdoutBytes = 0;
|
|
913
|
+
let stderrBytes = 0;
|
|
914
|
+
let stdoutTruncated = false;
|
|
915
|
+
let stderrTruncated = false;
|
|
916
|
+
|
|
917
|
+
child.stdout.on('data', (chunk) => {
|
|
918
|
+
if (stdoutTruncated) return;
|
|
919
|
+
stdoutBytes += chunk.length;
|
|
920
|
+
if (stdoutBytes > DEFAULT_MAX_VERIFICATION_OUTPUT_BYTES) {
|
|
921
|
+
stdoutTruncated = true;
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
stdoutChunks.push(chunk);
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
child.stderr.on('data', (chunk) => {
|
|
928
|
+
if (stderrTruncated) return;
|
|
929
|
+
stderrBytes += chunk.length;
|
|
930
|
+
if (stderrBytes > DEFAULT_MAX_VERIFICATION_OUTPUT_BYTES) {
|
|
931
|
+
stderrTruncated = true;
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
stderrChunks.push(chunk);
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
child.on('error', (err) => {
|
|
938
|
+
// Covers "bash not found on PATH" (ENOENT) and other un-spawnable
|
|
939
|
+
// cases — Tech-Spec section 8's "unsupported shell fails preflight"
|
|
940
|
+
// is satisfied here: the very first verification command fails
|
|
941
|
+
// closed with a clear reason instead of crashing the coordinator.
|
|
942
|
+
finish({ spawnError: 'bash command failed to run: ' + err.message });
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
child.on('close', (code, signal) => {
|
|
946
|
+
finish({
|
|
947
|
+
exitCode: code === undefined ? null : code,
|
|
948
|
+
signal: signal === undefined ? null : signal,
|
|
949
|
+
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
|
|
950
|
+
stderr: Buffer.concat(stderrChunks).toString('utf8'),
|
|
951
|
+
stdoutTruncated: stdoutTruncated,
|
|
952
|
+
stderrTruncated: stderrTruncated,
|
|
953
|
+
});
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
if (timeoutMs !== null && timeoutMs !== undefined) {
|
|
957
|
+
timeoutHandle = setTimeout(() => {
|
|
958
|
+
if (settled) return;
|
|
959
|
+
timedOut = true;
|
|
960
|
+
_killVerificationProcessTreeSync(child);
|
|
961
|
+
}, timeoutMs);
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Executes one task attempt's APPROVED verification commands — the fenced
|
|
968
|
+
* command blocks parsed losslessly out of the Work Breakdown by plan.js's
|
|
969
|
+
* parseMarkdown (task.verificationCommands) — as real shell command lines
|
|
970
|
+
* (US-04-TASK-BE-01). Tech-Spec section 8: "Verification commands come from
|
|
971
|
+
* approved fenced blocks, preserve exact bytes and declared shell semantics
|
|
972
|
+
* ... Execute independently and require each exit 0; generic npm test
|
|
973
|
+
* success cannot hide an individual failure."
|
|
974
|
+
*
|
|
975
|
+
* Why Bash + shell:false here, unlike claude-process.js's spawnClaudeAgent
|
|
976
|
+
* (shell:false, argv array, no shell at all): spawnClaudeAgent spawns an LLM
|
|
977
|
+
* agent whose prompt content is potentially adversarial natural-language
|
|
978
|
+
* input, so it deliberately avoids a shell entirely to rule out any
|
|
979
|
+
* metacharacter-injection risk. Verification commands are the opposite trust
|
|
980
|
+
* case — they are authored by an approved planning agent, embedded verbatim
|
|
981
|
+
* in the Work Breakdown, and only ever reach this function after a human has
|
|
982
|
+
* approved them at Gate 2 (reviewed, static, versioned text, never raw
|
|
983
|
+
* end-user input). They also legitimately depend on real shell features the
|
|
984
|
+
* Work Breakdown already uses verbatim: pipes, `grep -E 'a|b'`, `test -f`,
|
|
985
|
+
* `||`, redirects like `2>/dev/null` (see this very feature's own Work
|
|
986
|
+
* Breakdown command `npm pack --dry-run 2>/dev/null | grep -E '...'`).
|
|
987
|
+
* Running these without a shell would silently break every command that
|
|
988
|
+
* isn't a single bare argv invocation.
|
|
989
|
+
*
|
|
990
|
+
* This function still spawns with shell:false and an explicit argv array —
|
|
991
|
+
* it just names `bash` as the executable and `-c <command>` as its argv,
|
|
992
|
+
* rather than passing Node's own shell:true (which on Windows launches
|
|
993
|
+
* cmd.exe, not Bash, and would silently reinterpret Unix-only syntax like
|
|
994
|
+
* `test -f`, single-quoted `grep` patterns, or `2>/dev/null`). This is
|
|
995
|
+
* exactly Tech-Spec section 8's requirement: "Existing Bash commands run
|
|
996
|
+
* under an explicitly available Bash, not silently converted to PowerShell
|
|
997
|
+
* ... Unsupported shell fails preflight" — spawning Bash directly is what
|
|
998
|
+
* makes that hold on every platform, Windows included. A missing/unspawnable
|
|
999
|
+
* Bash is not treated as a special preflight step; it surfaces as the first
|
|
1000
|
+
* command's `spawnError`, which fails that command exactly like a non-zero
|
|
1001
|
+
* exit (see _runVerificationCommand).
|
|
1002
|
+
*
|
|
1003
|
+
* Fail-fast: commands run strictly in the order given. The first command
|
|
1004
|
+
* that exits non-zero (or cannot be spawned at all) stops the loop —
|
|
1005
|
+
* subsequent commands are never run for this attempt. This matches the
|
|
1006
|
+
* outcome's "requires each command exit 0" and Tech-Spec's "require each
|
|
1007
|
+
* exit 0; generic npm test success cannot hide an individual failure."
|
|
1008
|
+
*
|
|
1009
|
+
* Persistence: every command's result — including a failing or unspawnable
|
|
1010
|
+
* one — is written as an immutable receipt via store.writeReceipt (the
|
|
1011
|
+
* existing INFRA-TASK-BE-01 primitive; no new persistence primitive was
|
|
1012
|
+
* needed here) BEFORE the loop moves on to the next command, using id
|
|
1013
|
+
* `<taskId>-attempt<attemptNumber>-verification-<index>`. This is a stronger
|
|
1014
|
+
* guarantee than the outcome's literal wording ("persists each result before
|
|
1015
|
+
* proceeding to review" — review is the separate US-04-TASK-BE-02 stage),
|
|
1016
|
+
* but per-command persist-as-you-go is the simplest correct design: it costs
|
|
1017
|
+
* nothing extra, and it means a crash between two verification commands
|
|
1018
|
+
* still leaves every already-completed command's evidence durable on disk,
|
|
1019
|
+
* not just whatever happened to finish before the later review stage began.
|
|
1020
|
+
*
|
|
1021
|
+
* Empty verificationCommands is a trivial pass (nothing to verify) — it is
|
|
1022
|
+
* never treated as a failure or as "unverified".
|
|
1023
|
+
*
|
|
1024
|
+
* Timeout: Tech-Spec section 8 does not mandate a timeout for verification
|
|
1025
|
+
* commands (unlike claude-process.js's taskTimeoutMs for agent dispatch), so
|
|
1026
|
+
* `args.commandTimeoutMs` is optional and defaults to no timeout — a caller
|
|
1027
|
+
* (the future run loop, US-07/US-08) may supply one if it wants to bound a
|
|
1028
|
+
* hanging verification command. When supplied and exceeded, the process
|
|
1029
|
+
* tree is killed using the same E-02-qualified technique claude-process.js
|
|
1030
|
+
* uses (see _killVerificationProcessTreeSync).
|
|
1031
|
+
*
|
|
1032
|
+
* @param {object} args
|
|
1033
|
+
* @param {string} args.executionRoot - Absolute path to
|
|
1034
|
+
* <common-git-dir>/ai-toolkit/execution (forwarded to store.writeReceipt).
|
|
1035
|
+
* @param {string} args.runId - UUID of the current run (forwarded to
|
|
1036
|
+
* store.writeReceipt).
|
|
1037
|
+
* @param {object} args.task - Plan task object (plan.js shape): at least
|
|
1038
|
+
* `id`; `verificationCommands` (array of exact command-line strings) is
|
|
1039
|
+
* the field this function executes. Missing/non-array is treated as no
|
|
1040
|
+
* commands to verify.
|
|
1041
|
+
* @param {number} args.attemptNumber - Positive integer attempt number this
|
|
1042
|
+
* verification run belongs to (used in receipt ids).
|
|
1043
|
+
* @param {string} [args.cwd] - Working directory each command runs in.
|
|
1044
|
+
* Defaults to `process.cwd()`.
|
|
1045
|
+
* @param {number} [args.commandTimeoutMs] - Optional per-command timeout
|
|
1046
|
+
* budget; see "Timeout" above. No default (no timeout) when omitted.
|
|
1047
|
+
* @returns {Promise<{
|
|
1048
|
+
* taskId: string, attemptNumber: number, passed: boolean,
|
|
1049
|
+
* results: Array<{ command: string, exitCode: ?number, stdout: string,
|
|
1050
|
+
* stderr: string, durationMs: number, passed: boolean }>
|
|
1051
|
+
* }>} `results` holds every command run up to and including the first
|
|
1052
|
+
* failure (or all commands, if every one passed).
|
|
1053
|
+
* @throws {Error} code DISPATCH_VALIDATION_ERROR on malformed input (the same
|
|
1054
|
+
* shared validation-error code _requireNonEmptyString already uses for
|
|
1055
|
+
* dispatchTaskAttempt's argument checks above); any
|
|
1056
|
+
* error store.writeReceipt itself throws (e.g. RECEIPT_CONFLICT if this
|
|
1057
|
+
* exact attempt/index was already recorded with different content)
|
|
1058
|
+
* propagates unmodified.
|
|
1059
|
+
*/
|
|
1060
|
+
async function runVerifications(args) {
|
|
1061
|
+
args = args || {};
|
|
1062
|
+
|
|
1063
|
+
_requireNonEmptyString(args.executionRoot, 'runVerifications: args.executionRoot');
|
|
1064
|
+
_requireNonEmptyString(args.runId, 'runVerifications: args.runId');
|
|
1065
|
+
if (args.task == null || typeof args.task !== 'object') {
|
|
1066
|
+
throw _err('DISPATCH_VALIDATION_ERROR', 'runVerifications: args.task must be an object');
|
|
1067
|
+
}
|
|
1068
|
+
_requireNonEmptyString(args.task.id, 'runVerifications: args.task.id');
|
|
1069
|
+
if (!Number.isInteger(args.attemptNumber) || args.attemptNumber < 1) {
|
|
1070
|
+
throw _err('DISPATCH_VALIDATION_ERROR', 'runVerifications: args.attemptNumber must be a positive integer');
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const executionRoot = args.executionRoot;
|
|
1074
|
+
const runId = args.runId;
|
|
1075
|
+
const task = args.task;
|
|
1076
|
+
const attemptNumber = args.attemptNumber;
|
|
1077
|
+
const cwd = args.cwd === undefined ? process.cwd() : args.cwd;
|
|
1078
|
+
const commandTimeoutMs = args.commandTimeoutMs === undefined ? null : args.commandTimeoutMs;
|
|
1079
|
+
|
|
1080
|
+
const commands = Array.isArray(task.verificationCommands) ? task.verificationCommands : [];
|
|
1081
|
+
|
|
1082
|
+
const results = [];
|
|
1083
|
+
for (let index = 0; index < commands.length; index++) {
|
|
1084
|
+
const command = commands[index];
|
|
1085
|
+
const outcome = await _runVerificationCommand(command, cwd, commandTimeoutMs);
|
|
1086
|
+
const passed = outcome.spawnError === null && outcome.exitCode === 0;
|
|
1087
|
+
|
|
1088
|
+
const receiptId = task.id + '-attempt' + attemptNumber + '-verification-' + index;
|
|
1089
|
+
store.writeReceipt(executionRoot, runId, receiptId, {
|
|
1090
|
+
taskId: task.id,
|
|
1091
|
+
attemptNumber: attemptNumber,
|
|
1092
|
+
index: index,
|
|
1093
|
+
command: command,
|
|
1094
|
+
exitCode: outcome.exitCode,
|
|
1095
|
+
signal: outcome.signal,
|
|
1096
|
+
stdout: outcome.stdout,
|
|
1097
|
+
stderr: outcome.stderr,
|
|
1098
|
+
stdoutTruncated: outcome.stdoutTruncated,
|
|
1099
|
+
stderrTruncated: outcome.stderrTruncated,
|
|
1100
|
+
durationMs: outcome.durationMs,
|
|
1101
|
+
startedAt: outcome.startedAt,
|
|
1102
|
+
endedAt: outcome.endedAt,
|
|
1103
|
+
timedOut: outcome.timedOut,
|
|
1104
|
+
spawnError: outcome.spawnError,
|
|
1105
|
+
passed: passed,
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
results.push({
|
|
1109
|
+
command: command,
|
|
1110
|
+
exitCode: outcome.exitCode,
|
|
1111
|
+
stdout: outcome.stdout,
|
|
1112
|
+
stderr: outcome.stderr,
|
|
1113
|
+
durationMs: outcome.durationMs,
|
|
1114
|
+
passed: passed,
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
if (!passed) {
|
|
1118
|
+
return { taskId: task.id, attemptNumber: attemptNumber, passed: false, results: results };
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
return { taskId: task.id, attemptNumber: attemptNumber, passed: true, results: results };
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// 40-character hex SHA-1, case-insensitive — same pattern store.js's
|
|
1126
|
+
// registerCommitSHA and git.js's createTaskCommit already use; kept as its
|
|
1127
|
+
// own local constant here (no shared export exists for it) rather than
|
|
1128
|
+
// reaching into either module's private scope.
|
|
1129
|
+
const SHA_PATTERN = /^[0-9a-f]{40}$/i;
|
|
1130
|
+
|
|
1131
|
+
// Real git reachability check: is `sha` an ancestor of (i.e. reachable by
|
|
1132
|
+
// walking parent links from) whatever `ref` currently resolves to?
|
|
1133
|
+
//
|
|
1134
|
+
// `git merge-base --is-ancestor <sha> <ref>` is used rather than
|
|
1135
|
+
// `git rev-list <ref> | grep <sha>` (enumerate every reachable commit and
|
|
1136
|
+
// text-search it) for two reasons: (1) correctness — rev-list | grep is a
|
|
1137
|
+
// substring match that can false-positive on a SHA that is merely a prefix
|
|
1138
|
+
// of another, and requires shelling a pipe, which this codebase's git
|
|
1139
|
+
// helpers deliberately avoid (shell:false, argv array, no shell
|
|
1140
|
+
// interpolation — see git.js's _runGitSync); merge-base needs no pipe at
|
|
1141
|
+
// all, one argv-only spawnSync call. (2) efficiency — merge-base is the
|
|
1142
|
+
// git-native primitive for exactly this question and can use the commit
|
|
1143
|
+
// graph/generation numbers to answer in effectively O(log history) rather
|
|
1144
|
+
// than materializing and scanning the full reachable set, which matters
|
|
1145
|
+
// once a feature branch has a non-trivial history. Exit code contract per
|
|
1146
|
+
// git-merge-base(1): 0 = is an ancestor (reachable), 1 = is not, anything
|
|
1147
|
+
// else = the query itself was invalid (bad SHA/ref) and is surfaced as a
|
|
1148
|
+
// real error rather than silently treated as "not reachable".
|
|
1149
|
+
//
|
|
1150
|
+
// Duplicated here (not imported from git.js) because git.js has no exported
|
|
1151
|
+
// generic git-spawn helper — its own _runGitSync/_runGitOrThrow are module-
|
|
1152
|
+
// private, and exporting one purely for this one call would widen git.js's
|
|
1153
|
+
// public surface for a single caller. index.js already imports spawnSync
|
|
1154
|
+
// from child_process (dispatchTaskAttempt's sibling helpers use it too), so
|
|
1155
|
+
// this duplicates only the minimal spawn call, in the same shell:false,
|
|
1156
|
+
// argv-array, windowsHide style git.js itself uses.
|
|
1157
|
+
function _isCommitReachableOnRef(cwd, sha, ref) {
|
|
1158
|
+
const res = spawnSync('git', ['merge-base', '--is-ancestor', sha, ref], {
|
|
1159
|
+
cwd: cwd,
|
|
1160
|
+
shell: false,
|
|
1161
|
+
encoding: 'utf8',
|
|
1162
|
+
windowsHide: true,
|
|
1163
|
+
});
|
|
1164
|
+
if (res.error) {
|
|
1165
|
+
throw _err(
|
|
1166
|
+
'GIT_SPAWN_FAILED',
|
|
1167
|
+
'finalizeTaskCheckpoint: failed to spawn "git merge-base --is-ancestor ' + sha + ' ' + ref + '": ' +
|
|
1168
|
+
res.error.message
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
if (res.status === 0) return true;
|
|
1172
|
+
if (res.status === 1) return false;
|
|
1173
|
+
throw _err(
|
|
1174
|
+
'GIT_MERGE_BASE_FAILED',
|
|
1175
|
+
'finalizeTaskCheckpoint: "git merge-base --is-ancestor ' + sha + ' ' + ref + '" in ' + cwd +
|
|
1176
|
+
' failed unexpectedly (exit ' + res.status + '): ' + (res.stderr || '').trim()
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Finalizes one task attempt's checkpoint (US-05-TASK-BE-05; completes US-05
|
|
1182
|
+
* "checkpoint, stage, commit, and register SHA"). Runs only after
|
|
1183
|
+
* createTaskCommit (US-05-TASK-BE-03, git.js) has produced a commit and
|
|
1184
|
+
* store.js's registerCommitSHA (US-05-TASK-BE-04) has already durably
|
|
1185
|
+
* recorded it — this function does not commit or register anything itself,
|
|
1186
|
+
* it reconciles and closes out what was already recorded:
|
|
1187
|
+
*
|
|
1188
|
+
* 1. Read the attempt's registered SHA. The attempt must already be at
|
|
1189
|
+
* stage 'committed' (the stage registerCommitSHA leaves it at) — if it
|
|
1190
|
+
* is not, fails closed with TASK_NOT_COMMITTED before touching Git or
|
|
1191
|
+
* the ledger. This is the literal "commit/ledger reconciliation" guard:
|
|
1192
|
+
* a checkpoint can never be finalized on the strength of a chat/agent
|
|
1193
|
+
* self-report alone, only on a durably persisted, staged transition.
|
|
1194
|
+
* 2. Verify the registered SHA is REACHABLE on `taskRef` right now, via a
|
|
1195
|
+
* real `git merge-base --is-ancestor` call (see
|
|
1196
|
+
* _isCommitReachableOnRef above for why this call and not a
|
|
1197
|
+
* rev-list-and-grep). A trailer or a bare `git show` success is
|
|
1198
|
+
* explicitly insufficient (Tech-Spec section 8: "A trailer alone or
|
|
1199
|
+
* git show success is insufficient") — ancestry is the only thing this
|
|
1200
|
+
* function trusts. Fails closed with SHA_NOT_ON_BRANCH if the SHA is
|
|
1201
|
+
* not an ancestor of `taskRef`.
|
|
1202
|
+
* 3. Mark the task's overall status 'checkpointed' via store.js's
|
|
1203
|
+
* setTaskStatus (existing TASK_STATUSES enum value — not a new status).
|
|
1204
|
+
* 4. Close the ledger's 'task'-kind activity — the one dispatchTaskAttempt
|
|
1205
|
+
* (US-03-TASK-BE-03) opens at dispatch time and deliberately never
|
|
1206
|
+
* closes — via execution-ledger.js's finalizeActivity, status 'done',
|
|
1207
|
+
* tokens null with reason 'control-only' (Tech-Spec section 6: "The
|
|
1208
|
+
* task activity carries null token usage (reason: control-only)"),
|
|
1209
|
+
* using the exact same operation_id/agent-key dispatchTaskAttempt used
|
|
1210
|
+
* to open it (`executor:<runId>:<taskId>:task`, same attemptNumber).
|
|
1211
|
+
*
|
|
1212
|
+
* Scope boundary (explicit — do not extend past this without a later Work
|
|
1213
|
+
* Breakdown task): this function implements the N=1 case only, where
|
|
1214
|
+
* "closing after checkpoint" IS the terminal task-activity closure
|
|
1215
|
+
* (Tech-Spec section 6: "closes after checkpoint and, for N>1, verified
|
|
1216
|
+
* integration"; section 8: "N=1: task commit is on feature branch and
|
|
1217
|
+
* integratedSha=originalSha after verification"). For maxConcurrency N>1,
|
|
1218
|
+
* an attempt's commit lands on a private technical branch first and is only
|
|
1219
|
+
* later cherry-picked onto the feature branch by a separate, serialized
|
|
1220
|
+
* integration step (US-08-TASK-BE-03/04, not yet implemented) — closing the
|
|
1221
|
+
* task activity here, before that integration has happened and been
|
|
1222
|
+
* verified, would violate the Tech-Spec's own closing rule for N>1. This
|
|
1223
|
+
* function does not know or enforce N=1 vs N>1 itself (no execute()/run
|
|
1224
|
+
* loop exists yet to drive either path, per US-07/US-08); it is the future
|
|
1225
|
+
* N>1 call site's responsibility to call this function only once
|
|
1226
|
+
* integration is verified (or to route through a different, integration-
|
|
1227
|
+
* aware finalization step once US-08-TASK-BE-03/04 land), not to call it
|
|
1228
|
+
* straight after createTaskCommit's original commit the way the current N=1
|
|
1229
|
+
* call site does.
|
|
1230
|
+
*
|
|
1231
|
+
* Idempotency: calling this twice for the same already-checkpointed attempt
|
|
1232
|
+
* is safe. Step 1's precondition (attempt.stage === 'committed') still holds
|
|
1233
|
+
* on a second call — nothing in this function advances attempt.stage past
|
|
1234
|
+
* 'committed' — so the reachability check simply re-verifies (cheap, no
|
|
1235
|
+
* side effect), store.setTaskStatus('checkpointed') is a harmless repeat
|
|
1236
|
+
* assignment, and finalizeActivity's own replay contract (identical
|
|
1237
|
+
* status/tokens/reason/completedAt returns the existing entry unchanged; is
|
|
1238
|
+
* exact-operation_id-match, so a second call finds the entry finalizeActivity
|
|
1239
|
+
* would find are conflicting instead of duplicated) makes the ledger close
|
|
1240
|
+
* idempotent without any extra bookkeeping in this function.
|
|
1241
|
+
*
|
|
1242
|
+
* @param {object} args
|
|
1243
|
+
* @param {string} args.executionRoot - Absolute path to
|
|
1244
|
+
* <common-git-dir>/ai-toolkit/execution.
|
|
1245
|
+
* @param {string} args.runId - UUID of the run.
|
|
1246
|
+
* @param {string} args.taskId - Task ID whose attempt is being finalized.
|
|
1247
|
+
* @param {number} args.attemptNumber - Positive integer attempt number to
|
|
1248
|
+
* finalize (must match the attempt registerCommitSHA already advanced to
|
|
1249
|
+
* 'committed').
|
|
1250
|
+
* @param {string} args.projectDir - Absolute path to the Git worktree/repo
|
|
1251
|
+
* the reachability check runs in (the same repo createTaskCommit committed
|
|
1252
|
+
* into).
|
|
1253
|
+
* @param {string} args.taskRef - The feature/task ref the registered SHA
|
|
1254
|
+
* must be reachable from (N=1: the feature branch itself, per Tech-Spec
|
|
1255
|
+
* section 8).
|
|
1256
|
+
* @returns {{taskId: string, attemptNumber: number, taskStatus: 'checkpointed',
|
|
1257
|
+
* originalSha: string, taskRef: string, ledgerOperationId: string}}
|
|
1258
|
+
* @throws {Error} DISPATCH_VALIDATION_ERROR for malformed input;
|
|
1259
|
+
* STATE_NOT_FOUND / STATE_TASK_NOT_FOUND / STATE_ATTEMPT_NOT_FOUND when the
|
|
1260
|
+
* target does not exist; TASK_NOT_COMMITTED when the attempt has not yet
|
|
1261
|
+
* reached stage 'committed'; SHA_NOT_ON_BRANCH when the registered SHA is
|
|
1262
|
+
* not reachable from `taskRef`; GIT_SPAWN_FAILED / GIT_MERGE_BASE_FAILED
|
|
1263
|
+
* for a git invocation problem; ACTIVITY_NOT_FOUND / TERMINAL_CONFLICT
|
|
1264
|
+
* propagated unmodified from execution-ledger.js's finalizeActivity.
|
|
1265
|
+
*/
|
|
1266
|
+
function finalizeTaskCheckpoint(args) {
|
|
1267
|
+
args = args || {};
|
|
1268
|
+
|
|
1269
|
+
_requireNonEmptyString(args.executionRoot, 'finalizeTaskCheckpoint: args.executionRoot');
|
|
1270
|
+
_requireNonEmptyString(args.runId, 'finalizeTaskCheckpoint: args.runId');
|
|
1271
|
+
_requireNonEmptyString(args.taskId, 'finalizeTaskCheckpoint: args.taskId');
|
|
1272
|
+
_requireNonEmptyString(args.projectDir, 'finalizeTaskCheckpoint: args.projectDir');
|
|
1273
|
+
_requireNonEmptyString(args.taskRef, 'finalizeTaskCheckpoint: args.taskRef');
|
|
1274
|
+
if (!Number.isInteger(args.attemptNumber) || args.attemptNumber < 1) {
|
|
1275
|
+
throw _err('DISPATCH_VALIDATION_ERROR', 'finalizeTaskCheckpoint: args.attemptNumber must be a positive integer');
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const executionRoot = args.executionRoot;
|
|
1279
|
+
const runId = args.runId;
|
|
1280
|
+
const taskId = args.taskId;
|
|
1281
|
+
const attemptNumber = args.attemptNumber;
|
|
1282
|
+
const projectDir = args.projectDir;
|
|
1283
|
+
const taskRef = args.taskRef;
|
|
1284
|
+
|
|
1285
|
+
// 1. Read the registered SHA — the attempt must already be 'committed'.
|
|
1286
|
+
let state = store.readState(executionRoot, runId);
|
|
1287
|
+
const task = state.tasks[taskId];
|
|
1288
|
+
if (!task) {
|
|
1289
|
+
throw _err('STATE_TASK_NOT_FOUND', 'finalizeTaskCheckpoint: task "' + taskId + '" not found');
|
|
1290
|
+
}
|
|
1291
|
+
const attempt = task.attempts.find(function (a) { return a.number === attemptNumber; });
|
|
1292
|
+
if (!attempt) {
|
|
1293
|
+
throw _err(
|
|
1294
|
+
'STATE_ATTEMPT_NOT_FOUND',
|
|
1295
|
+
'finalizeTaskCheckpoint: attempt ' + attemptNumber + ' not found for task "' + taskId + '"'
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
if (attempt.stage !== 'committed' || typeof attempt.originalSha !== 'string' || !SHA_PATTERN.test(attempt.originalSha)) {
|
|
1299
|
+
throw _err(
|
|
1300
|
+
'TASK_NOT_COMMITTED',
|
|
1301
|
+
'finalizeTaskCheckpoint: attempt ' + attemptNumber + ' of task "' + taskId + '" is at stage "' + attempt.stage +
|
|
1302
|
+
'" and cannot be checkpointed — a checkpoint may only be finalized once the commit SHA has been ' +
|
|
1303
|
+
'registered (expected stage "committed" with a valid originalSha)'
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
const commitSha = attempt.originalSha;
|
|
1307
|
+
|
|
1308
|
+
// 2. Real git reachability check — never trust the trailer/SHA alone.
|
|
1309
|
+
const reachable = _isCommitReachableOnRef(projectDir, commitSha, taskRef);
|
|
1310
|
+
if (!reachable) {
|
|
1311
|
+
throw _err(
|
|
1312
|
+
'SHA_NOT_ON_BRANCH',
|
|
1313
|
+
'finalizeTaskCheckpoint: registered commit ' + commitSha + ' for attempt ' + attemptNumber + ' of task "' +
|
|
1314
|
+
taskId + '" is not reachable from "' + taskRef + '" — refusing to finalize the checkpoint; nothing changed'
|
|
1315
|
+
);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// 3. Mark the task's overall status 'checkpointed' (existing TASK_STATUSES
|
|
1319
|
+
// value — see store.js).
|
|
1320
|
+
state = state.setTaskStatus(taskId, 'checkpointed');
|
|
1321
|
+
state = store.writeState(executionRoot, runId, state);
|
|
1322
|
+
|
|
1323
|
+
// 4. Close the ledger's 'task'-kind activity dispatchTaskAttempt opened
|
|
1324
|
+
// and deliberately left open — same operation_id/agent-key/attempt it was
|
|
1325
|
+
// opened with.
|
|
1326
|
+
const ledgerPaths = store._executionPaths(executionRoot, runId);
|
|
1327
|
+
const ledgerTaskAgentKey = 'executor:' + runId + ':' + taskId + ':task';
|
|
1328
|
+
const finalizeResult = ledger.finalizeActivity(ledgerPaths.runDir, runId, ledgerTaskAgentKey, attemptNumber, {
|
|
1329
|
+
status: 'done',
|
|
1330
|
+
tokens: null,
|
|
1331
|
+
reason: 'control-only',
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
return {
|
|
1335
|
+
taskId: taskId,
|
|
1336
|
+
attemptNumber: attemptNumber,
|
|
1337
|
+
taskStatus: 'checkpointed',
|
|
1338
|
+
originalSha: commitSha,
|
|
1339
|
+
taskRef: taskRef,
|
|
1340
|
+
ledgerOperationId: finalizeResult.operation_id,
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// ── reconcile/resume evidence classification (US-06-TASK-BE-01/02) ─────────
|
|
1345
|
+
//
|
|
1346
|
+
// Classifies each task's current evidence against the Tech-Spec section 9
|
|
1347
|
+
// edge-cases table and applies an idempotent repair for the rows that have
|
|
1348
|
+
// one (registerCommitSHA, finalizeTaskCheckpoint).
|
|
1349
|
+
//
|
|
1350
|
+
// US-06-TASK-BE-02 completes the two rows US-06-TASK-BE-01 left as an
|
|
1351
|
+
// honest partial (see its own historical comment, preserved in git history):
|
|
1352
|
+
// - "Worker still live or unknown" / "Dead worker, partial diff": now a
|
|
1353
|
+
// REAL check, via the processIdentity tag dispatchTaskAttempt persists
|
|
1354
|
+
// before spawning and _findLiveTaggedProcess's OS-level query (see the
|
|
1355
|
+
// "worker-liveness process tagging" file comment above IS_WINDOWS) —
|
|
1356
|
+
// _classifyLiveOrDeadWorkerAttempt below. An attempt with no recorded
|
|
1357
|
+
// processIdentity (predates this feature, or dispatch never reached the
|
|
1358
|
+
// persist step) is still reported honestly as liveness-unknown — never
|
|
1359
|
+
// silently treated as dead, never replaced. Only a positive
|
|
1360
|
+
// confirmed-not-found result, together with the retry policy still
|
|
1361
|
+
// permitting a new attempt, ever reports 'dead-worker'.
|
|
1362
|
+
// - "Failed verification/review ... within retry policy": full retry-
|
|
1363
|
+
// policy attempt-count enforcement ("one initial implementation + one
|
|
1364
|
+
// automatic rework, two total per task ... a task beyond policy is
|
|
1365
|
+
// blocked: replan-required") is now applied — _classifyFailedAttempt
|
|
1366
|
+
// below.
|
|
1367
|
+
// Also NOT implemented: the Tech-Spec's full "Recovery of missing SHA"
|
|
1368
|
+
// search ("search reachable commits from recorded task/feature refs; match
|
|
1369
|
+
// exact trailers, parent, full tree and path inventory ... one exact
|
|
1370
|
+
// candidate is recoverable ... multiple/incompatible candidates block").
|
|
1371
|
+
// That is a full scan over every commit reachable from taskRef. This module
|
|
1372
|
+
// only inspects the CURRENT TIP of taskRef as the sole recovery candidate
|
|
1373
|
+
// (reusing finalizeTaskCheckpoint's own merge-base-adjacent, single-commit
|
|
1374
|
+
// inspection style) — a narrower, real, working check consistent with
|
|
1375
|
+
// createTaskCommit's own post-commit tree/parent/trailer reinspection
|
|
1376
|
+
// pattern (lib/task-executor/git.js), not a reachable-set-wide scan. A tip
|
|
1377
|
+
// that does not exactly match this attempt's persisted checkpoint intent is
|
|
1378
|
+
// reported as blocked (ambiguous), never guessed at.
|
|
1379
|
+
//
|
|
1380
|
+
// "Reconcile/resume require no live competing coordinator" (Tech-Spec
|
|
1381
|
+
// section 9) IS enforced, by _ensureNoLiveCompetingCoordinator below, called
|
|
1382
|
+
// at the very start of both reconcile() and resume() — before any repair
|
|
1383
|
+
// (registerCommitSHA/finalizeTaskCheckpoint). See that function's own doc
|
|
1384
|
+
// comment for the exact decision table. Neither function acquires or
|
|
1385
|
+
// reclaims the repo-wide execution lease itself — only verifies no OTHER
|
|
1386
|
+
// live coordinator holds it.
|
|
1387
|
+
|
|
1388
|
+
// Narrow, tip-only commit inspection — NOT a reachable-set scan (see file
|
|
1389
|
+
// comment above). Duplicates (rather than imports) the minimal tree/parent/
|
|
1390
|
+
// trailer parsing git.js's createTaskCommit already does privately, in the
|
|
1391
|
+
// same shell:false/argv-array/windowsHide spawn style already used
|
|
1392
|
+
// throughout this file (e.g. _isCommitReachableOnRef above). Returns null
|
|
1393
|
+
// (never throws) when `sha` cannot be read as a commit object.
|
|
1394
|
+
function _readCommitObjectSummary(cwd, sha) {
|
|
1395
|
+
const res = spawnSync('git', ['cat-file', '-p', sha], {
|
|
1396
|
+
cwd: cwd,
|
|
1397
|
+
shell: false,
|
|
1398
|
+
encoding: 'utf8',
|
|
1399
|
+
windowsHide: true,
|
|
1400
|
+
});
|
|
1401
|
+
if (res.error || res.status !== 0) return null;
|
|
1402
|
+
|
|
1403
|
+
const lines = res.stdout.split('\n');
|
|
1404
|
+
let tree = null;
|
|
1405
|
+
const parents = [];
|
|
1406
|
+
let i = 0;
|
|
1407
|
+
for (; i < lines.length; i += 1) {
|
|
1408
|
+
const line = lines[i];
|
|
1409
|
+
if (line === '') { i += 1; break; }
|
|
1410
|
+
if (line.indexOf('tree ') === 0) {
|
|
1411
|
+
tree = line.slice(5).trim();
|
|
1412
|
+
} else if (line.indexOf('parent ') === 0) {
|
|
1413
|
+
parents.push(line.slice(7).trim());
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
const message = lines.slice(i).join('\n');
|
|
1417
|
+
const trimmed = message.replace(/\s+$/, '');
|
|
1418
|
+
const paragraphs = trimmed.split(/\n\n+/);
|
|
1419
|
+
const lastParagraph = paragraphs[paragraphs.length - 1] || '';
|
|
1420
|
+
const trailers = {};
|
|
1421
|
+
lastParagraph.split('\n').forEach(function (line) {
|
|
1422
|
+
const idx = line.indexOf(': ');
|
|
1423
|
+
if (idx === -1) return;
|
|
1424
|
+
trailers[line.slice(0, idx)] = line.slice(idx + 2);
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
return { tree: tree, parents: parents, trailers: trailers };
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// "Corrupted/modified state" (AC-12, US-06-TASK-BE-02): a completed task
|
|
1431
|
+
// (checkpointed/integrated) or an already-'committed' attempt awaiting only
|
|
1432
|
+
// checkpoint finalization whose last dispatched attempt's TAGGED WORKER
|
|
1433
|
+
// PROCESS IS STILL ALIVE right now is an internally inconsistent evidence
|
|
1434
|
+
// set. dispatchTaskAttempt only ever returns once spawnClaudeAgent's promise
|
|
1435
|
+
// resolves — i.e. after that process has fully exited — so a genuinely
|
|
1436
|
+
// finished attempt's worker should already be gone; a live match here means
|
|
1437
|
+
// either the tag was reused/corrupted, or a stale/orphaned process is still
|
|
1438
|
+
// running under the identity this run believes is done and checkpointed.
|
|
1439
|
+
// Detected ONLY from POSITIVE liveness evidence (found-alive): 'unknown' and
|
|
1440
|
+
// 'confirmed-not-found' are the expected, non-corrupted outcomes for a
|
|
1441
|
+
// genuinely finished attempt's worker (already exited, or liveness simply
|
|
1442
|
+
// can't be checked on this platform) and are never themselves treated as
|
|
1443
|
+
// evidence of corruption — mirrors this whole file's "never guess" discipline.
|
|
1444
|
+
function _isCompletedAttemptWorkerUnexpectedlyAlive(ctx, attempt) {
|
|
1445
|
+
if (typeof attempt.processIdentity !== 'string' || attempt.processIdentity.length === 0) {
|
|
1446
|
+
return false;
|
|
1447
|
+
}
|
|
1448
|
+
const liveness = _findLiveTaggedProcess(attempt.processIdentity, ctx.exeBasename);
|
|
1449
|
+
return liveness.status === 'found-alive';
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// "Integrated commit + valid intent and receipts" / "Completed task but
|
|
1453
|
+
// unreachable/mismatched commit" / "Corrupted/missing evidence" (for an
|
|
1454
|
+
// already-checkpointed/integrated task). Read-only — never repairs a
|
|
1455
|
+
// completed task; a task that already reached 'checkpointed'/'integrated'
|
|
1456
|
+
// with bad evidence is reported blocked, not silently reopened.
|
|
1457
|
+
function _classifyCompletedTask(ctx, taskId, attempt) {
|
|
1458
|
+
if (_isCompletedAttemptWorkerUnexpectedlyAlive(ctx, attempt)) {
|
|
1459
|
+
return { classification: 'blocked', action: 'corrupted-or-modified-state' };
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
if (typeof attempt.originalSha !== 'string' || !SHA_PATTERN.test(attempt.originalSha)) {
|
|
1463
|
+
return { classification: 'blocked', action: 'corrupted-or-missing-evidence' };
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
let reachable;
|
|
1467
|
+
try {
|
|
1468
|
+
reachable = _isCommitReachableOnRef(ctx.projectDir, attempt.originalSha, ctx.taskRef);
|
|
1469
|
+
} catch (_err) {
|
|
1470
|
+
return { classification: 'blocked', action: 'corrupted-or-missing-evidence' };
|
|
1471
|
+
}
|
|
1472
|
+
if (!reachable) {
|
|
1473
|
+
return { classification: 'blocked', action: 'unreachable-or-mismatched-commit' };
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
if (!Array.isArray(attempt.intentIds) || attempt.intentIds.length === 0) {
|
|
1477
|
+
return { classification: 'blocked', action: 'corrupted-or-missing-evidence' };
|
|
1478
|
+
}
|
|
1479
|
+
try {
|
|
1480
|
+
attempt.intentIds.forEach(function (id) { store.readIntent(ctx.executionRoot, ctx.runId, id); });
|
|
1481
|
+
store.readReceipt(ctx.executionRoot, ctx.runId, taskId + '-attempt' + attempt.number + '-outcome');
|
|
1482
|
+
} catch (_err) {
|
|
1483
|
+
return { classification: 'blocked', action: 'corrupted-or-missing-evidence' };
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
return { classification: 'up-to-date', action: 'keep-completion' };
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// "Integration commit/ref exists but state missing" (N=1 shape: attempt
|
|
1490
|
+
// already 'committed', task overall status not yet advanced past it).
|
|
1491
|
+
// Repair: finalizeTaskCheckpoint — an already-existing, already-idempotent
|
|
1492
|
+
// primitive (US-05-TASK-BE-05) — is invoked directly; this function does not
|
|
1493
|
+
// duplicate its reachability/ledger logic.
|
|
1494
|
+
function _classifyCommittedAttempt(ctx, taskId, attempt) {
|
|
1495
|
+
if (_isCompletedAttemptWorkerUnexpectedlyAlive(ctx, attempt)) {
|
|
1496
|
+
return { classification: 'blocked', action: 'corrupted-or-modified-state' };
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
if (typeof attempt.originalSha !== 'string' || !SHA_PATTERN.test(attempt.originalSha)) {
|
|
1500
|
+
return { classification: 'blocked', action: 'corrupted-or-missing-evidence' };
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
let reachable;
|
|
1504
|
+
try {
|
|
1505
|
+
reachable = _isCommitReachableOnRef(ctx.projectDir, attempt.originalSha, ctx.taskRef);
|
|
1506
|
+
} catch (_err) {
|
|
1507
|
+
return { classification: 'blocked', action: 'corrupted-or-missing-evidence' };
|
|
1508
|
+
}
|
|
1509
|
+
if (!reachable) {
|
|
1510
|
+
return { classification: 'blocked', action: 'unreachable-or-mismatched-commit' };
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
try {
|
|
1514
|
+
finalizeTaskCheckpoint({
|
|
1515
|
+
executionRoot: ctx.executionRoot,
|
|
1516
|
+
runId: ctx.runId,
|
|
1517
|
+
taskId: taskId,
|
|
1518
|
+
attemptNumber: attempt.number,
|
|
1519
|
+
projectDir: ctx.projectDir,
|
|
1520
|
+
taskRef: ctx.taskRef,
|
|
1521
|
+
});
|
|
1522
|
+
} catch (err) {
|
|
1523
|
+
return { classification: 'blocked', action: 'checkpoint-finalization-failed:' + (err.code || 'UNKNOWN') };
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
return { classification: 'reconciled', action: 'checkpoint-finalized', repairAction: 'finalize-checkpoint' };
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// "Commit exists but SHA not recorded" (attempt at 'checkpoint-prepared',
|
|
1530
|
+
// no originalSha registered yet) vs the case where no commit has landed at
|
|
1531
|
+
// all yet. See the file-level scope comment above: only taskRef's CURRENT
|
|
1532
|
+
// TIP is inspected as the sole recovery candidate — this is intentionally
|
|
1533
|
+
// narrower than the Tech-Spec's full reachable-commit-set scan.
|
|
1534
|
+
function _classifyCheckpointPreparedAttempt(ctx, taskId, attempt) {
|
|
1535
|
+
const checkpointIntentId = taskId + '-attempt' + attempt.number + '-checkpoint';
|
|
1536
|
+
const intentIds = Array.isArray(attempt.intentIds) ? attempt.intentIds : [];
|
|
1537
|
+
if (intentIds.indexOf(checkpointIntentId) === -1) {
|
|
1538
|
+
return { classification: 'blocked', action: 'corrupted-or-missing-evidence' };
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
let intent;
|
|
1542
|
+
try {
|
|
1543
|
+
intent = store.readIntent(ctx.executionRoot, ctx.runId, checkpointIntentId);
|
|
1544
|
+
} catch (_err) {
|
|
1545
|
+
return { classification: 'blocked', action: 'corrupted-or-missing-evidence' };
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
const tipRes = spawnSync('git', ['rev-parse', '--verify', ctx.taskRef + '^{commit}'], {
|
|
1549
|
+
cwd: ctx.projectDir,
|
|
1550
|
+
shell: false,
|
|
1551
|
+
encoding: 'utf8',
|
|
1552
|
+
windowsHide: true,
|
|
1553
|
+
});
|
|
1554
|
+
if (tipRes.error || tipRes.status !== 0) {
|
|
1555
|
+
return { classification: 'blocked', action: 'corrupted-or-missing-evidence' };
|
|
1556
|
+
}
|
|
1557
|
+
const tip = tipRes.stdout.trim();
|
|
1558
|
+
|
|
1559
|
+
if (tip === intent.parentSha) {
|
|
1560
|
+
// taskRef has not moved since the checkpoint intent was persisted — no
|
|
1561
|
+
// commit has landed yet. Reconcile never creates commits; this only
|
|
1562
|
+
// reports the classification.
|
|
1563
|
+
return { classification: 'checkpoint-prepared-no-commit', action: 'needs-commit-creation' };
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
const candidate = _readCommitObjectSummary(ctx.projectDir, tip);
|
|
1567
|
+
const trailerKeys = intent.trailers ? Object.keys(intent.trailers) : [];
|
|
1568
|
+
const matches = candidate !== null &&
|
|
1569
|
+
candidate.parents.length === 1 &&
|
|
1570
|
+
candidate.parents[0] === intent.parentSha &&
|
|
1571
|
+
candidate.tree === intent.expectedTreeSha &&
|
|
1572
|
+
trailerKeys.length > 0 &&
|
|
1573
|
+
trailerKeys.every(function (key) { return candidate.trailers[key] === intent.trailers[key]; });
|
|
1574
|
+
|
|
1575
|
+
if (!matches) {
|
|
1576
|
+
// taskRef moved, but the tip does not exactly match this attempt's own
|
|
1577
|
+
// persisted intent — an ambiguous/incompatible candidate. Per Tech-Spec
|
|
1578
|
+
// ("multiple/incompatible candidates block"): block, never guess.
|
|
1579
|
+
return { classification: 'blocked', action: 'unreachable-or-mismatched-commit' };
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
try {
|
|
1583
|
+
store.registerCommitSHA(ctx.executionRoot, ctx.runId, taskId, attempt.number, tip, { sequential: true });
|
|
1584
|
+
} catch (err) {
|
|
1585
|
+
return { classification: 'blocked', action: 'sha-registration-failed:' + (err.code || 'UNKNOWN') };
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
return { classification: 'reconciled', action: 'commit-sha-registered', repairAction: 'register-commit-sha' };
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// "Worker still live or unknown" / "Dead worker, partial diff" (Tech-Spec
|
|
1592
|
+
// section 9, US-06-TASK-BE-02). Real check via the processIdentity tag
|
|
1593
|
+
// dispatchTaskAttempt persists before spawning (see the "worker-liveness
|
|
1594
|
+
// process tagging" file comment above IS_WINDOWS) and _findLiveTaggedProcess's
|
|
1595
|
+
// OS-level query.
|
|
1596
|
+
function _classifyLiveOrDeadWorkerAttempt(ctx, taskId, task, attempt) {
|
|
1597
|
+
if (typeof attempt.processIdentity !== 'string' || attempt.processIdentity.length === 0) {
|
|
1598
|
+
// No tag recorded at all (an attempt that predates this feature, or one
|
|
1599
|
+
// whose dispatch never reached the persist-before-invoke step) — cannot
|
|
1600
|
+
// be checked. Same honest-partial result US-06-TASK-BE-01 already
|
|
1601
|
+
// reported unconditionally for every attempt at these stages.
|
|
1602
|
+
return { classification: 'worker-live-or-unknown', action: 'no-replacement-diagnose' };
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
const liveness = _findLiveTaggedProcess(attempt.processIdentity, ctx.exeBasename);
|
|
1606
|
+
|
|
1607
|
+
if (liveness.status === 'found-alive') {
|
|
1608
|
+
// Worker confirmed live: never touch, never dispatch a replacement — the
|
|
1609
|
+
// same safe behavior US-06-TASK-BE-01 already applied unconditionally,
|
|
1610
|
+
// now backed by a real, positive OS confirmation instead of an assumption.
|
|
1611
|
+
return { classification: 'worker-live', action: 'no-replacement-diagnose-or-pause' };
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
if (liveness.status === 'unknown') {
|
|
1615
|
+
// Inconclusive (non-Windows, or the OS query itself failed/untrusted) —
|
|
1616
|
+
// never coerce into either extreme; refuse to touch, exactly like a
|
|
1617
|
+
// confirmed-live worker.
|
|
1618
|
+
return { classification: 'worker-live-or-unknown', action: 'no-replacement-diagnose' };
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// confirmed-not-found: positive absence evidence — the tagged worker
|
|
1622
|
+
// process genuinely no longer exists. Subject to the retry policy
|
|
1623
|
+
// (Tech-Spec section 9: "one initial implementation + one automatic
|
|
1624
|
+
// rework, two total per task ... a task beyond policy is blocked:
|
|
1625
|
+
// replan-required") — this attempt itself already consumed one of the (at
|
|
1626
|
+
// most two) real implementation invocations task.attempts.length counts
|
|
1627
|
+
// (see _classifyFailedAttempt's own comment for why that count needs no
|
|
1628
|
+
// extra filtering).
|
|
1629
|
+
if (task.attempts.length >= 2) {
|
|
1630
|
+
return { classification: 'blocked', action: 'replan-required' };
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// "Dead worker, partial diff" -> "Preserve and attribute; new attempt
|
|
1634
|
+
// within retry policy". Whatever partial evidence this attempt already
|
|
1635
|
+
// recorded (a verification/review outcome, or even a commit that somehow
|
|
1636
|
+
// landed despite the worker dying) is preserved as-is — nothing here
|
|
1637
|
+
// discards or rewrites it; hasPartialEvidence is reported for visibility
|
|
1638
|
+
// only, the action is the same either way (a new attempt is needed, subject
|
|
1639
|
+
// to the retry policy just enforced above).
|
|
1640
|
+
const hasPartialEvidence =
|
|
1641
|
+
(Array.isArray(attempt.verificationRefs) && attempt.verificationRefs.length > 0) ||
|
|
1642
|
+
(Array.isArray(attempt.reviewRefs) && attempt.reviewRefs.length > 0) ||
|
|
1643
|
+
(typeof attempt.originalSha === 'string' && attempt.originalSha.length > 0);
|
|
1644
|
+
|
|
1645
|
+
return {
|
|
1646
|
+
classification: 'dead-worker',
|
|
1647
|
+
action: 'preserve-and-attribute-new-attempt-within-retry-policy',
|
|
1648
|
+
hasPartialEvidence: hasPartialEvidence,
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// "Failed verification/review" (Tech-Spec section 9, US-06-TASK-BE-02): full
|
|
1653
|
+
// retry-policy attempt-count enforcement. "One initial implementation + one
|
|
1654
|
+
// automatic rework (two total per task). Review rejections and
|
|
1655
|
+
// implementation failures consume attempts when new implementation is
|
|
1656
|
+
// invoked. Resuming verification, ledger projection or an existing commit
|
|
1657
|
+
// does not. A task beyond policy is blocked: replan-required."
|
|
1658
|
+
//
|
|
1659
|
+
// task.attempts.length is exactly the count of real implementation
|
|
1660
|
+
// invocations recorded for this task: store.js's addAttempt is only ever
|
|
1661
|
+
// called from dispatchTaskAttempt (US-03-TASK-BE-03) — nothing in this
|
|
1662
|
+
// codebase calls it merely to resume verification, project the ledger, or
|
|
1663
|
+
// re-inspect an existing commit. So this count already matches the
|
|
1664
|
+
// Tech-Spec's own "resuming ... does not [consume attempts]" rule with no
|
|
1665
|
+
// extra filtering needed: a task whose attempts.length is already 2 has used
|
|
1666
|
+
// both the initial implementation and its one automatic rework, so a third
|
|
1667
|
+
// attempt is beyond policy.
|
|
1668
|
+
function _classifyFailedAttempt(task) {
|
|
1669
|
+
if (task.attempts.length >= 2) {
|
|
1670
|
+
return { classification: 'blocked', action: 'replan-required' };
|
|
1671
|
+
}
|
|
1672
|
+
return { classification: 'failed', action: 'needs-new-attempt-subject-to-retry-policy' };
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// Single per-task classification dispatch. Never touches a task that has
|
|
1676
|
+
// never been dispatched (status 'pending' with zero attempts) — reconcile
|
|
1677
|
+
// "does not dispatch" (Tech-Spec section 9).
|
|
1678
|
+
function _classifyTask(ctx, taskId, task) {
|
|
1679
|
+
if (task.status === 'skipped') {
|
|
1680
|
+
return { classification: 'skipped', action: 'none' };
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
if (task.status === 'checkpointed' || task.status === 'integrated') {
|
|
1684
|
+
const attempt = task.attempts[task.attempts.length - 1];
|
|
1685
|
+
if (!attempt) {
|
|
1686
|
+
return { classification: 'blocked', action: 'corrupted-or-missing-evidence' };
|
|
1687
|
+
}
|
|
1688
|
+
return _classifyCompletedTask(ctx, taskId, attempt);
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
if (task.status === 'blocked') {
|
|
1692
|
+
return { classification: 'blocked', action: 'already-blocked' };
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
if (!Array.isArray(task.attempts) || task.attempts.length === 0) {
|
|
1696
|
+
return { classification: 'not-started', action: 'none' };
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
const attempt = task.attempts[task.attempts.length - 1];
|
|
1700
|
+
switch (attempt.stage) {
|
|
1701
|
+
case 'committed':
|
|
1702
|
+
return _classifyCommittedAttempt(ctx, taskId, attempt);
|
|
1703
|
+
case 'checkpoint-prepared':
|
|
1704
|
+
return _classifyCheckpointPreparedAttempt(ctx, taskId, attempt);
|
|
1705
|
+
case 'reviewed':
|
|
1706
|
+
// "Review passed, no commit" -> recheck unchanged tree then finish
|
|
1707
|
+
// checkpoint. Recomputing/re-verifying the tree is out of scope here
|
|
1708
|
+
// (it needs the same inputs persistCheckpointIntent/stageTaskFiles
|
|
1709
|
+
// need, which reconcile is not given); this reports the classification
|
|
1710
|
+
// only.
|
|
1711
|
+
return { classification: 'reviewed-no-checkpoint', action: 'needs-checkpoint-completion' };
|
|
1712
|
+
case 'verified':
|
|
1713
|
+
// Real, reachable persisted stage only between the two writes
|
|
1714
|
+
// persistVerificationReviewOutcome makes when verification passes
|
|
1715
|
+
// (store.js) — evidence of a passed verification without a recorded
|
|
1716
|
+
// review outcome yet.
|
|
1717
|
+
return { classification: 'verified-no-review-outcome', action: 'needs-review' };
|
|
1718
|
+
case 'implementation-recorded':
|
|
1719
|
+
// "Implementation result persisted" -> continue verification, not
|
|
1720
|
+
// implementation.
|
|
1721
|
+
return { classification: 'implementation-recorded', action: 'needs-verification' };
|
|
1722
|
+
case 'prepared':
|
|
1723
|
+
case 'dispatching':
|
|
1724
|
+
case 'running':
|
|
1725
|
+
case 'interrupted':
|
|
1726
|
+
return _classifyLiveOrDeadWorkerAttempt(ctx, taskId, task, attempt);
|
|
1727
|
+
case 'failed':
|
|
1728
|
+
return _classifyFailedAttempt(task);
|
|
1729
|
+
default:
|
|
1730
|
+
return { classification: 'blocked', action: 'corrupted-or-missing-evidence' };
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// ── execute() main loop (US-07-TASK-BE-01) ──────────────────────────────────
|
|
1735
|
+
//
|
|
1736
|
+
// Sequential (maxConcurrency=1) run loop: derives the executor's run identity
|
|
1737
|
+
// from the approved feature's own Work Breakdown, acquires the repo-wide
|
|
1738
|
+
// execution lease for a brand-new run, creates the initial run state, then
|
|
1739
|
+
// drives the ready queue to completion one task at a time. Each ready task's
|
|
1740
|
+
// slot is reserved (task status 'active') through dispatch -> verify ->
|
|
1741
|
+
// review -> checkpoint -> commit, and released only once the task reaches a
|
|
1742
|
+
// terminal status ('checkpointed' or 'blocked') — before the next ready task
|
|
1743
|
+
// is ever considered. N>1 concurrent slots is explicitly out of scope here
|
|
1744
|
+
// (US-08).
|
|
1745
|
+
|
|
1746
|
+
// One initial implementation + one automatic rework, two total per task
|
|
1747
|
+
// (Tech-Spec section 9) — the same retry-policy rule US-06-TASK-BE-02's
|
|
1748
|
+
// _classifyFailedAttempt/_classifyLiveOrDeadWorkerAttempt already enforce via
|
|
1749
|
+
// a literal `>= 2` check; named here so execute()'s own enforcement of the
|
|
1750
|
+
// identical rule reads as the same policy, not a coincidentally-matching
|
|
1751
|
+
// magic number.
|
|
1752
|
+
const MAX_ATTEMPTS_PER_TASK = 2;
|
|
1753
|
+
|
|
1754
|
+
function _isBeyondRetryPolicy(state, taskId) {
|
|
1755
|
+
return state.tasks[taskId].attempts.length >= MAX_ATTEMPTS_PER_TASK;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
// Resolves the absolute common Git directory for `projectDir` THROUGH Git
|
|
1759
|
+
// (Tech-Spec section 5: "Discover absolute worktree root and common Git
|
|
1760
|
+
// directory through Git, not root/.git string concatenation") — never
|
|
1761
|
+
// hand-built by string-concatenating ".git" onto the project path, which
|
|
1762
|
+
// would be wrong for a linked worktree. `git rev-parse --git-common-dir` may
|
|
1763
|
+
// return a path relative to `projectDir` depending on the installed Git
|
|
1764
|
+
// version, so the result is resolved to absolute via `path.resolve` against
|
|
1765
|
+
// `projectDir` — that resolution step is the only non-Git part of this
|
|
1766
|
+
// function; the directory identity itself always comes from Git.
|
|
1767
|
+
function _resolveCommonGitDir(projectDir) {
|
|
1768
|
+
const res = spawnSync('git', ['rev-parse', '--git-common-dir'], {
|
|
1769
|
+
cwd: projectDir, shell: false, encoding: 'utf8', windowsHide: true,
|
|
1770
|
+
});
|
|
1771
|
+
if (res.error || res.status !== 0) {
|
|
1772
|
+
throw _err(
|
|
1773
|
+
'GIT_SPAWN_FAILED',
|
|
1774
|
+
'execute: failed to resolve the common Git directory for "' + projectDir + '": ' +
|
|
1775
|
+
(res.error ? res.error.message : (res.stderr || '').trim())
|
|
1776
|
+
);
|
|
1777
|
+
}
|
|
1778
|
+
return path.resolve(projectDir, res.stdout.trim());
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
function _resolveRefTip(cwd, ref, label) {
|
|
1782
|
+
const res = spawnSync('git', ['rev-parse', '--verify', ref + '^{commit}'], {
|
|
1783
|
+
cwd: cwd, shell: false, encoding: 'utf8', windowsHide: true,
|
|
1784
|
+
});
|
|
1785
|
+
if (res.error || res.status !== 0) {
|
|
1786
|
+
throw _err(
|
|
1787
|
+
'GIT_REF_RESOLUTION_FAILED',
|
|
1788
|
+
'execute: failed to resolve ' + label + ' "' + ref + '" in ' + cwd + ': ' +
|
|
1789
|
+
(res.error ? res.error.message : (res.stderr || '').trim())
|
|
1790
|
+
);
|
|
1791
|
+
}
|
|
1792
|
+
return res.stdout.trim();
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
// Derives taskRef as the CURRENT checked-out branch (Tech-Spec's N=1 model:
|
|
1796
|
+
// "task commit is on feature branch") via `git rev-parse --abbrev-ref HEAD` —
|
|
1797
|
+
// used only when the caller (execute()) does not supply an explicit
|
|
1798
|
+
// args.taskRef override. A detached HEAD has no branch to derive a ref from,
|
|
1799
|
+
// so it fails closed rather than guessing.
|
|
1800
|
+
function _currentBranchRef(projectDir) {
|
|
1801
|
+
const res = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
1802
|
+
cwd: projectDir, shell: false, encoding: 'utf8', windowsHide: true,
|
|
1803
|
+
});
|
|
1804
|
+
if (res.error || res.status !== 0) {
|
|
1805
|
+
throw _err(
|
|
1806
|
+
'GIT_REF_RESOLUTION_FAILED',
|
|
1807
|
+
'execute: failed to resolve the current branch for "' + projectDir + '": ' +
|
|
1808
|
+
(res.error ? res.error.message : (res.stderr || '').trim())
|
|
1809
|
+
);
|
|
1810
|
+
}
|
|
1811
|
+
const name = res.stdout.trim();
|
|
1812
|
+
if (name === 'HEAD') {
|
|
1813
|
+
throw _err(
|
|
1814
|
+
'EXECUTE_VALIDATION_ERROR',
|
|
1815
|
+
'execute: "' + projectDir + '" has a detached HEAD — a task/feature ref cannot be derived automatically; ' +
|
|
1816
|
+
'check out a branch first, or pass args.taskRef explicitly'
|
|
1817
|
+
);
|
|
1818
|
+
}
|
|
1819
|
+
return 'refs/heads/' + name;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
function _buildTaskCommitMessage(task) {
|
|
1823
|
+
if (task.commit && typeof task.commit.type === 'string' && typeof task.commit.subject === 'string') {
|
|
1824
|
+
const scope = typeof task.commit.scope === 'string' && task.commit.scope.length > 0
|
|
1825
|
+
? '(' + task.commit.scope + ')'
|
|
1826
|
+
: '';
|
|
1827
|
+
return task.commit.type + scope + ': ' + task.commit.subject;
|
|
1828
|
+
}
|
|
1829
|
+
return 'chore: ' + task.id;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
// Real, side-effect-free diff for the review agent (Tech-Spec section 8:
|
|
1833
|
+
// "the review must see exactly what will be checkpointed" / "unexpected
|
|
1834
|
+
// tracked changes invalidate the reviewed diff"). Built per enumerated
|
|
1835
|
+
// changedPaths entry (the SAME array git.detectChangedPaths just produced,
|
|
1836
|
+
// and that will later be staged/committed unchanged) rather than a single
|
|
1837
|
+
// blanket `git diff`, specifically so this function never mutates the Git
|
|
1838
|
+
// index (no `git add -N`/intent-to-add trick, which WOULD mutate it) —
|
|
1839
|
+
// stageTaskFiles's own before/after index snapshot (US-05-TASK-BE-02) must
|
|
1840
|
+
// still see the exact pre-staging state this function ran against.
|
|
1841
|
+
// - 'delete'/'modify' entries: plain `git diff -- <path>` (tracked file,
|
|
1842
|
+
// real exit 0 either way).
|
|
1843
|
+
// - 'add' entries: `git diff --no-index -- /dev/null <path>` — the
|
|
1844
|
+
// documented git idiom for diffing an untracked file against "nothing"
|
|
1845
|
+
// without ever touching the index; per git-diff(1), --no-index exits 1
|
|
1846
|
+
// when a difference is found (the expected, common case here), which is
|
|
1847
|
+
// NOT an error and must not be treated as one.
|
|
1848
|
+
function _computeReviewDiff(cwd, changedPaths) {
|
|
1849
|
+
const parts = changedPaths.map(function (entry) {
|
|
1850
|
+
let res;
|
|
1851
|
+
if (entry.changeType === 'add') {
|
|
1852
|
+
res = spawnSync('git', ['diff', '--no-color', '--no-index', '--', '/dev/null', entry.path], {
|
|
1853
|
+
cwd: cwd, shell: false, encoding: 'utf8', windowsHide: true,
|
|
1854
|
+
});
|
|
1855
|
+
if (res.error || (res.status !== 0 && res.status !== 1)) {
|
|
1856
|
+
throw _err(
|
|
1857
|
+
'REVIEW_DIFF_FAILED',
|
|
1858
|
+
'_computeReviewDiff: "git diff --no-index" for added path "' + entry.path + '" failed: ' +
|
|
1859
|
+
(res.error ? res.error.message : (res.stderr || '').trim())
|
|
1860
|
+
);
|
|
1861
|
+
}
|
|
1862
|
+
} else {
|
|
1863
|
+
res = spawnSync('git', ['diff', '--no-color', '--', entry.path], {
|
|
1864
|
+
cwd: cwd, shell: false, encoding: 'utf8', windowsHide: true,
|
|
1865
|
+
});
|
|
1866
|
+
if (res.error || res.status !== 0) {
|
|
1867
|
+
throw _err(
|
|
1868
|
+
'REVIEW_DIFF_FAILED',
|
|
1869
|
+
'_computeReviewDiff: "git diff" for path "' + entry.path + '" (changeType "' + entry.changeType +
|
|
1870
|
+
'") failed: ' + (res.error ? res.error.message : (res.stderr || '').trim())
|
|
1871
|
+
);
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
return res.stdout;
|
|
1875
|
+
});
|
|
1876
|
+
return parts.join('\n');
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// Drives ONE ready task through dispatch -> verify -> review -> checkpoint,
|
|
1880
|
+
// retrying (a NEW attempt on the SAME task) within the retry policy on a
|
|
1881
|
+
// failure, and only returning once the task has reached a terminal status
|
|
1882
|
+
// ('checkpointed' or 'blocked') — i.e. once its reserved slot can safely be
|
|
1883
|
+
// released back to the outer ready-queue loop in execute().
|
|
1884
|
+
async function _runTaskToResolution(ctx) {
|
|
1885
|
+
const executionRoot = ctx.executionRoot;
|
|
1886
|
+
const runId = ctx.runId;
|
|
1887
|
+
const projectDir = ctx.projectDir;
|
|
1888
|
+
const taskRef = ctx.taskRef;
|
|
1889
|
+
const featureRef = ctx.featureRef;
|
|
1890
|
+
const planDigest = ctx.planDigest;
|
|
1891
|
+
const task = ctx.task;
|
|
1892
|
+
const taskId = task.id;
|
|
1893
|
+
const agentId = agentRegistry.resolveWorkBreakdownAgentType(task.agentType).canonicalId;
|
|
1894
|
+
|
|
1895
|
+
for (;;) {
|
|
1896
|
+
let state = store.readState(executionRoot, runId);
|
|
1897
|
+
state = state.setTaskStatus(taskId, 'active');
|
|
1898
|
+
state = store.writeState(executionRoot, runId, state);
|
|
1899
|
+
|
|
1900
|
+
const attemptNumber = state.tasks[taskId].attempts.length + 1;
|
|
1901
|
+
|
|
1902
|
+
let dispatchResult;
|
|
1903
|
+
try {
|
|
1904
|
+
dispatchResult = await dispatchTaskAttempt({
|
|
1905
|
+
executionRoot: executionRoot,
|
|
1906
|
+
runId: runId,
|
|
1907
|
+
task: task,
|
|
1908
|
+
attemptNumber: attemptNumber,
|
|
1909
|
+
claudePath: ctx.claudePath,
|
|
1910
|
+
agentId: agentId,
|
|
1911
|
+
projectDir: projectDir,
|
|
1912
|
+
taskTimeoutMs: ctx.taskTimeoutMs,
|
|
1913
|
+
spawnArgs: ctx.implementationSpawnArgs,
|
|
1914
|
+
});
|
|
1915
|
+
} catch (dispatchErr) {
|
|
1916
|
+
state = store.readState(executionRoot, runId);
|
|
1917
|
+
const attemptRecord = state.tasks[taskId].attempts.find(function (a) { return a.number === attemptNumber; });
|
|
1918
|
+
if (!attemptRecord) {
|
|
1919
|
+
// Failed BEFORE persist-before-invoke (e.g. NO_LOCK_HELD,
|
|
1920
|
+
// AGENT_NOT_VERIFIED, DEFINITION_HASH_MISMATCH, a systemic/config
|
|
1921
|
+
// problem, not a per-task transient failure). Never consume a task
|
|
1922
|
+
// attempt for an environment problem outside the task itself —
|
|
1923
|
+
// propagate so execute() itself fails (the lease is still released
|
|
1924
|
+
// via its own try/finally).
|
|
1925
|
+
throw dispatchErr;
|
|
1926
|
+
}
|
|
1927
|
+
// Genuine spawn-level failure (spawnClaudeAgent itself threw) AFTER
|
|
1928
|
+
// the attempt was already durably recorded — this DOES consume the
|
|
1929
|
+
// attempt, exactly like a verification/review failure does.
|
|
1930
|
+
state = state.updateAttempt(taskId, attemptNumber, {
|
|
1931
|
+
stage: 'failed',
|
|
1932
|
+
terminalReason: 'dispatch-error:' + (dispatchErr.code || 'UNKNOWN'),
|
|
1933
|
+
});
|
|
1934
|
+
state = store.writeState(executionRoot, runId, state);
|
|
1935
|
+
if (_isBeyondRetryPolicy(state, taskId)) {
|
|
1936
|
+
state = state.setTaskStatus(taskId, 'blocked');
|
|
1937
|
+
store.writeState(executionRoot, runId, state);
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
if (state.runStatus === 'stopping') {
|
|
1941
|
+
// A stop request was persisted while this attempt was in flight
|
|
1942
|
+
// (Tech-Spec section 7: "immediate: request termination ... await
|
|
1943
|
+
// confirmed terminal state" — stop work on this task now, not after
|
|
1944
|
+
// one more retry attempt). Still within retry policy, so this is NOT
|
|
1945
|
+
// 'blocked' (that status means "retry policy exhausted", which is
|
|
1946
|
+
// not what happened here) — leave the attempt's/task's status as
|
|
1947
|
+
// already recorded ('failed'/'active') and return without dispatching
|
|
1948
|
+
// attempt 2. execute()'s own outer loop observes 'stopping' on its
|
|
1949
|
+
// very next iteration and sets the final runStatus to 'paused'.
|
|
1950
|
+
return;
|
|
1951
|
+
}
|
|
1952
|
+
continue;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
void dispatchResult; // raw spawn capture only — not otherwise consulted here.
|
|
1956
|
+
|
|
1957
|
+
const verification = await runVerifications({
|
|
1958
|
+
executionRoot: executionRoot, runId: runId, task: task, attemptNumber: attemptNumber,
|
|
1959
|
+
cwd: projectDir, commandTimeoutMs: ctx.taskTimeoutMs,
|
|
1960
|
+
});
|
|
1961
|
+
|
|
1962
|
+
let review = null;
|
|
1963
|
+
let changedPaths = null;
|
|
1964
|
+
if (verification.passed) {
|
|
1965
|
+
// Detected once per attempt (Gate-1 binding constraint #2's mandatory
|
|
1966
|
+
// negative test relies on this: a self-reported "success" that wrote
|
|
1967
|
+
// nothing real is caught here/by verification, never trusted alone).
|
|
1968
|
+
changedPaths = git.detectChangedPaths(projectDir);
|
|
1969
|
+
const diff = _computeReviewDiff(projectDir, changedPaths);
|
|
1970
|
+
review = await runReview({
|
|
1971
|
+
executionRoot: executionRoot, runId: runId, task: task, attemptNumber: attemptNumber,
|
|
1972
|
+
claudePath: ctx.claudePath, projectDir: projectDir, diff: diff, taskTimeoutMs: ctx.taskTimeoutMs,
|
|
1973
|
+
spawnArgs: ctx.reviewSpawnArgs,
|
|
1974
|
+
});
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
const outcome = store.persistVerificationReviewOutcome(executionRoot, runId, taskId, attemptNumber, verification, review);
|
|
1978
|
+
|
|
1979
|
+
if (outcome.stage !== 'reviewed') {
|
|
1980
|
+
state = store.readState(executionRoot, runId);
|
|
1981
|
+
if (_isBeyondRetryPolicy(state, taskId)) {
|
|
1982
|
+
state = state.setTaskStatus(taskId, 'blocked');
|
|
1983
|
+
store.writeState(executionRoot, runId, state);
|
|
1984
|
+
return;
|
|
1985
|
+
}
|
|
1986
|
+
if (state.runStatus === 'stopping') {
|
|
1987
|
+
// Same stop-awareness as the dispatch-error retry site above: a stop
|
|
1988
|
+
// request was persisted while this attempt's verification/review was
|
|
1989
|
+
// in flight (or just after). Still within retry policy — return
|
|
1990
|
+
// without dispatching another attempt rather than forcing 'blocked',
|
|
1991
|
+
// and let execute()'s outer loop settle the run to 'paused'.
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
continue;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
// Checkpoint: stage first (US-05-TASK-BE-02) so its OWN write-tree
|
|
1998
|
+
// result becomes the checkpoint intent's expectedTreeSha, THEN persist
|
|
1999
|
+
// the intent (US-05-TASK-BE-01) with that now-known treeSha, THEN commit
|
|
2000
|
+
// (US-05-TASK-BE-03) against the exact intent just persisted, THEN
|
|
2001
|
+
// register the SHA (US-05-TASK-BE-04, store.js) sequentially — this run
|
|
2002
|
+
// is always N=1 — THEN finalize (US-05-TASK-BE-05).
|
|
2003
|
+
const stagingResult = git.stageTaskFiles(projectDir, changedPaths);
|
|
2004
|
+
const parentSha = _resolveRefTip(projectDir, taskRef, 'taskRef');
|
|
2005
|
+
|
|
2006
|
+
const verificationReceiptIds = verification.results.map(function (_result, index) {
|
|
2007
|
+
return taskId + '-attempt' + attemptNumber + '-verification-' + index;
|
|
2008
|
+
});
|
|
2009
|
+
|
|
2010
|
+
state = store.readState(executionRoot, runId);
|
|
2011
|
+
const persisted = git.persistCheckpointIntent(executionRoot, runId, {
|
|
2012
|
+
featureId: state.featureId,
|
|
2013
|
+
runId: runId,
|
|
2014
|
+
taskId: taskId,
|
|
2015
|
+
attemptNumber: attemptNumber,
|
|
2016
|
+
planDigest: planDigest,
|
|
2017
|
+
featureRef: featureRef,
|
|
2018
|
+
taskRef: taskRef,
|
|
2019
|
+
parentSha: parentSha,
|
|
2020
|
+
expectedTreeSha: stagingResult.treeSha,
|
|
2021
|
+
changedPaths: changedPaths,
|
|
2022
|
+
verificationReceiptIds: verificationReceiptIds,
|
|
2023
|
+
reviewReceiptId: outcome.reviewReceiptId,
|
|
2024
|
+
outcomeReceiptId: outcome.outcomeReceiptId,
|
|
2025
|
+
commitMessage: _buildTaskCommitMessage(task),
|
|
2026
|
+
});
|
|
2027
|
+
|
|
2028
|
+
const commitResult = git.createTaskCommit(projectDir, persisted.intent, stagingResult);
|
|
2029
|
+
|
|
2030
|
+
store.registerCommitSHA(executionRoot, runId, taskId, attemptNumber, commitResult.commitSha, { sequential: true });
|
|
2031
|
+
|
|
2032
|
+
finalizeTaskCheckpoint({
|
|
2033
|
+
executionRoot: executionRoot, runId: runId, taskId: taskId, attemptNumber: attemptNumber,
|
|
2034
|
+
projectDir: projectDir, taskRef: taskRef,
|
|
2035
|
+
});
|
|
2036
|
+
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
// ── createTaskWorktree (US-08-TASK-BE-01) ───────────────────────────────────
|
|
2042
|
+
//
|
|
2043
|
+
// Creates and manages one isolated Git worktree for one task attempt — the
|
|
2044
|
+
// standalone primitive later US-08 tasks (N-limit concurrency/slot
|
|
2045
|
+
// reservation, US-08-TASK-BE-02; serial cherry-pick integration,
|
|
2046
|
+
// US-08-TASK-BE-03) will build on. This function does NOT dispatch any
|
|
2047
|
+
// agent and does NOT stage/commit anything — it only creates/manages the
|
|
2048
|
+
// worktree directory itself. It is NOT wired into execute()'s existing
|
|
2049
|
+
// sequential main loop above, which stays N=1/single-worktree-free per
|
|
2050
|
+
// US-07-TASK-BE-01's explicit scope; execute() never calls this function.
|
|
2051
|
+
//
|
|
2052
|
+
// Path convention: <executionRoot>/runs/<runId>/worktrees/<taskId>-attempt
|
|
2053
|
+
// <attemptNumber> — reuses store.js's own run-owned root layout
|
|
2054
|
+
// (store._executionPaths: runs/<runId>/{state.json,receipts/,intents/}, see
|
|
2055
|
+
// that module's file-header comment) rather than inventing a new root.
|
|
2056
|
+
// Tech-Spec section 8 requires only "an external worktree path under a
|
|
2057
|
+
// validated run-owned root; no reused slot names" for N>1 attempts — it does
|
|
2058
|
+
// not mandate a more precise shape — so extending store.js's existing runDir
|
|
2059
|
+
// convention is the natural, non-duplicating choice. taskId+attemptNumber
|
|
2060
|
+
// together are already the unique identity of an attempt throughout this
|
|
2061
|
+
// module (see _buildProcessTag, addAttempt's own `id: taskId + '#' +
|
|
2062
|
+
// number`), so this path can never collide across tasks or attempts within
|
|
2063
|
+
// one run.
|
|
2064
|
+
//
|
|
2065
|
+
// Branch convention: Tech-Spec section 8 DOES mandate an exact branch name
|
|
2066
|
+
// for N>1 attempts — "each attempt has unique branch
|
|
2067
|
+
// ai-toolkit/<runId>/<taskId>/<attempt>" — so createTaskWorktree creates
|
|
2068
|
+
// exactly that branch (`git worktree add -b <branch> <path> <baseSha>`),
|
|
2069
|
+
// never a detached HEAD, so later cherry-pick/integration (US-08-TASK-BE-03)
|
|
2070
|
+
// has a real ref to read the attempt's commit(s) from.
|
|
2071
|
+
const WORKTREE_BRANCH_PREFIX = 'ai-toolkit';
|
|
2072
|
+
|
|
2073
|
+
function _worktreeFail(field, reason) {
|
|
2074
|
+
throw _err('WORKTREE_VALIDATION_ERROR', 'createTaskWorktree: ' + field + ' ' + reason);
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
// Local string validator (rather than the shared _requireNonEmptyString,
|
|
2078
|
+
// which is hardcoded to DISPATCH_VALIDATION_ERROR for the dispatch-family
|
|
2079
|
+
// functions above) so every malformed-input error this function throws
|
|
2080
|
+
// carries the same, single WORKTREE_VALIDATION_ERROR code.
|
|
2081
|
+
function _requireWorktreeString(value, field) {
|
|
2082
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
2083
|
+
_worktreeFail(field, 'must be a non-empty string');
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
// Shared spawn helper for every git invocation in this section — same house
|
|
2088
|
+
// style as every other git.js/index.js spawn site: shell:false (argv array,
|
|
2089
|
+
// no shell interpolation/globbing), encoding utf8, windowsHide.
|
|
2090
|
+
function _runGitForWorktree(cwd, args) {
|
|
2091
|
+
return spawnSync('git', args, { cwd: cwd, shell: false, encoding: 'utf8', windowsHide: true });
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
// Parses `git worktree list --porcelain` into the absolute, resolved list of
|
|
2095
|
+
// every worktree path Git currently knows about for this repo (the main
|
|
2096
|
+
// worktree first, then every linked one) — the ground truth this function
|
|
2097
|
+
// cross-checks a newly requested path against, rather than trusting its own
|
|
2098
|
+
// computed path never to collide.
|
|
2099
|
+
function _listWorktreePaths(projectDir) {
|
|
2100
|
+
const res = _runGitForWorktree(projectDir, ['worktree', 'list', '--porcelain']);
|
|
2101
|
+
if (res.error || res.status !== 0) {
|
|
2102
|
+
throw _err(
|
|
2103
|
+
'GIT_WORKTREE_LIST_FAILED',
|
|
2104
|
+
'createTaskWorktree: failed to list existing worktrees for "' + projectDir + '": ' +
|
|
2105
|
+
(res.error ? res.error.message : (res.stderr || '').trim())
|
|
2106
|
+
);
|
|
2107
|
+
}
|
|
2108
|
+
const paths = [];
|
|
2109
|
+
(res.stdout || '').split(/\r?\n/).forEach(function (line) {
|
|
2110
|
+
if (line.indexOf('worktree ') === 0) {
|
|
2111
|
+
paths.push(path.resolve(line.slice('worktree '.length).trim()));
|
|
2112
|
+
}
|
|
2113
|
+
});
|
|
2114
|
+
return paths;
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
// Canonicalizes `p` for COMPARISON purposes only (never for the path
|
|
2118
|
+
// actually handed to git argv or returned to callers — those stay the
|
|
2119
|
+
// plain, deterministic path.resolve() form). This exists because on
|
|
2120
|
+
// Windows, a path built from os.tmpdir()/fs.mkdtempSync can be an 8.3
|
|
2121
|
+
// short-name alias (e.g. "...\TOMADA~1\...") while Git reports the SAME
|
|
2122
|
+
// directory using its long form (e.g. "...\Tomada D\...") in
|
|
2123
|
+
// `git worktree list --porcelain` — proven by direct experiment, not
|
|
2124
|
+
// assumed: plain fs.realpathSync does NOT resolve this alias on this
|
|
2125
|
+
// platform, but fs.realpathSync.native (the libuv-backed, OS-level call)
|
|
2126
|
+
// does. Without canonicalizing both sides before comparison, every
|
|
2127
|
+
// collision/independence check below would silently never match, and every
|
|
2128
|
+
// "same path" check would silently never fire.
|
|
2129
|
+
// For a path that does not exist yet (the target worktree path, before
|
|
2130
|
+
// creation), realpath.native has nothing to resolve — so this walks up to
|
|
2131
|
+
// the deepest EXISTING ancestor, canonicalizes that, and re-appends the
|
|
2132
|
+
// not-yet-existing suffix unchanged.
|
|
2133
|
+
function _canonicalizePathForCompare(p) {
|
|
2134
|
+
const resolved = path.resolve(p);
|
|
2135
|
+
let existing = resolved;
|
|
2136
|
+
const suffix = [];
|
|
2137
|
+
for (;;) {
|
|
2138
|
+
try {
|
|
2139
|
+
const real = fs.realpathSync.native(existing);
|
|
2140
|
+
return suffix.length > 0 ? path.join.apply(path, [real].concat(suffix)) : real;
|
|
2141
|
+
} catch (_) {
|
|
2142
|
+
const parent = path.dirname(existing);
|
|
2143
|
+
if (parent === existing) {
|
|
2144
|
+
return resolved; // reached the filesystem root; nothing existed to canonicalize against
|
|
2145
|
+
}
|
|
2146
|
+
suffix.unshift(path.basename(existing));
|
|
2147
|
+
existing = parent;
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
function _samePath(a, b) {
|
|
2153
|
+
return _canonicalizePathForCompare(a) === _canonicalizePathForCompare(b);
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// True when `b` is exactly `a`, or is nested one or more levels inside `a` —
|
|
2157
|
+
// used both directions (new path inside an existing worktree, or an
|
|
2158
|
+
// existing worktree inside the new path) so createTaskWorktree fails closed
|
|
2159
|
+
// on ANY overlap rather than only the more obvious direction. Compares
|
|
2160
|
+
// canonicalized forms (see _canonicalizePathForCompare) so Windows
|
|
2161
|
+
// short/long path aliasing can never hide a real collision or manufacture a
|
|
2162
|
+
// false one.
|
|
2163
|
+
function _pathsOverlap(a, b) {
|
|
2164
|
+
const realA = _canonicalizePathForCompare(a);
|
|
2165
|
+
const realB = _canonicalizePathForCompare(b);
|
|
2166
|
+
if (realA === realB) return true;
|
|
2167
|
+
const rel = path.relative(realA, realB);
|
|
2168
|
+
return rel !== '' && rel !== '.' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
/**
|
|
2172
|
+
* Creates one isolated Git worktree for one task attempt, under a run-owned
|
|
2173
|
+
* root, and returns its path plus a cleanup callback. See the file comment
|
|
2174
|
+
* immediately above for the path/branch conventions this follows.
|
|
2175
|
+
*
|
|
2176
|
+
* Independence is validated BOTH before and after creation:
|
|
2177
|
+
* - Before: the computed target path is cross-checked against every path
|
|
2178
|
+
* `git worktree list --porcelain` reports (main worktree included) for
|
|
2179
|
+
* exact collision and for nesting in either direction. Any overlap fails
|
|
2180
|
+
* closed with WORKTREE_COLLISION before `git worktree add` ever runs.
|
|
2181
|
+
* - After: the path Git actually registers must include the exact
|
|
2182
|
+
* requested path (WORKTREE_INDEPENDENCE_CHECK_FAILED otherwise), and its
|
|
2183
|
+
* real (symlink-resolved) directory must differ from the main
|
|
2184
|
+
* worktree's real directory — guards against a path that Git or the
|
|
2185
|
+
* filesystem silently aliased back onto the main checkout.
|
|
2186
|
+
*
|
|
2187
|
+
* @param {object} args
|
|
2188
|
+
* @param {string} args.projectDir - Absolute path to the main Git worktree
|
|
2189
|
+
* (the project root `git worktree add` is run from).
|
|
2190
|
+
* @param {string} args.executionRoot - Absolute path to
|
|
2191
|
+
* <common-git-dir>/ai-toolkit/execution (same root store.js's
|
|
2192
|
+
* _executionPaths resolves runs/<runId>/... under).
|
|
2193
|
+
* @param {string} args.runId - UUID of the current run.
|
|
2194
|
+
* @param {string} args.taskId - Task identifier this worktree is for.
|
|
2195
|
+
* @param {number} args.attemptNumber - Positive integer attempt number this
|
|
2196
|
+
* worktree is for.
|
|
2197
|
+
* @param {string} args.baseSha - Commit-ish (full SHA, or any ref Git can
|
|
2198
|
+
* resolve) the new attempt branch is created from.
|
|
2199
|
+
* @returns {{ worktreePath: string, branch: string, cleanup: Function }}
|
|
2200
|
+
* `cleanup` is synchronous, idempotent, and safe to call even if the
|
|
2201
|
+
* worktree directory was already removed by hand — see its own inline
|
|
2202
|
+
* comment below for exactly what "safe" means here.
|
|
2203
|
+
* @throws {Error} WORKTREE_VALIDATION_ERROR for malformed input;
|
|
2204
|
+
* GIT_WORKTREE_LIST_FAILED if `git worktree list` cannot be run;
|
|
2205
|
+
* WORKTREE_COLLISION if the computed path collides with or is nested
|
|
2206
|
+
* inside/around an existing worktree, or already exists on disk;
|
|
2207
|
+
* GIT_WORKTREE_ADD_FAILED if `git worktree add` itself fails;
|
|
2208
|
+
* WORKTREE_INDEPENDENCE_CHECK_FAILED if post-creation verification does
|
|
2209
|
+
* not confirm a genuinely separate, registered worktree.
|
|
2210
|
+
*/
|
|
2211
|
+
function createTaskWorktree(args) {
|
|
2212
|
+
args = args || {};
|
|
2213
|
+
_requireWorktreeString(args.projectDir, 'args.projectDir');
|
|
2214
|
+
_requireWorktreeString(args.executionRoot, 'args.executionRoot');
|
|
2215
|
+
_requireWorktreeString(args.runId, 'args.runId');
|
|
2216
|
+
_requireWorktreeString(args.taskId, 'args.taskId');
|
|
2217
|
+
_requireWorktreeString(args.baseSha, 'args.baseSha');
|
|
2218
|
+
if (!Number.isInteger(args.attemptNumber) || args.attemptNumber < 1) {
|
|
2219
|
+
_worktreeFail('args.attemptNumber', 'must be a positive integer');
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
const projectDir = path.resolve(args.projectDir);
|
|
2223
|
+
const executionRoot = args.executionRoot;
|
|
2224
|
+
const runId = args.runId;
|
|
2225
|
+
const taskId = args.taskId;
|
|
2226
|
+
const attemptNumber = args.attemptNumber;
|
|
2227
|
+
const baseSha = args.baseSha;
|
|
2228
|
+
|
|
2229
|
+
const runDir = store._executionPaths(executionRoot, runId).runDir;
|
|
2230
|
+
const worktreesRoot = path.join(runDir, 'worktrees');
|
|
2231
|
+
const worktreePath = path.resolve(path.join(worktreesRoot, taskId + '-attempt' + attemptNumber));
|
|
2232
|
+
const branch = WORKTREE_BRANCH_PREFIX + '/' + runId + '/' + taskId + '/' + attemptNumber;
|
|
2233
|
+
|
|
2234
|
+
if (fs.existsSync(worktreePath)) {
|
|
2235
|
+
throw _err(
|
|
2236
|
+
'WORKTREE_COLLISION',
|
|
2237
|
+
'createTaskWorktree: target path "' + worktreePath + '" already exists on disk — refusing to overwrite it'
|
|
2238
|
+
);
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
// The run-owned root (executionRoot, hence worktreePath) lives under the
|
|
2242
|
+
// repo's common Git directory (Tech-Spec section 5 / store.js's own
|
|
2243
|
+
// convention: <common-git-dir>/ai-toolkit/execution) — administrative Git
|
|
2244
|
+
// storage that is, BY GIT'S OWN DESIGN, excluded from every worktree's
|
|
2245
|
+
// tracked file tree (no `git status`/diff in ANY worktree ever reports
|
|
2246
|
+
// into another worktree's .git internals). A worktree path that lands
|
|
2247
|
+
// under commonDir is therefore never actually "inside" any worktree's
|
|
2248
|
+
// checked-out content, even though it is filesystem-nested under the main
|
|
2249
|
+
// worktree's own directory for a standard (non `--separate-git-dir`)
|
|
2250
|
+
// repo — so nesting under commonDir specifically is exempted from the
|
|
2251
|
+
// collision check below. Anything else — a path nested inside a
|
|
2252
|
+
// worktree's real tracked directory, or identical to one — still fails
|
|
2253
|
+
// closed.
|
|
2254
|
+
const commonDir = path.resolve(_resolveCommonGitDir(projectDir));
|
|
2255
|
+
const worktreePathIsUnderCommonDir = _samePath(commonDir, worktreePath) || _pathsOverlap(commonDir, worktreePath);
|
|
2256
|
+
|
|
2257
|
+
// Pre-creation independence check against every worktree Git already
|
|
2258
|
+
// knows about, main included — fail closed on any real overlap rather
|
|
2259
|
+
// than letting `git worktree add` decide (it would happily create a
|
|
2260
|
+
// worktree nested inside another one's tracked directory).
|
|
2261
|
+
const knownPaths = _listWorktreePaths(projectDir);
|
|
2262
|
+
if (!knownPaths.some(function (p) { return _samePath(p, projectDir); })) {
|
|
2263
|
+
knownPaths.push(projectDir);
|
|
2264
|
+
}
|
|
2265
|
+
knownPaths.forEach(function (knownPath) {
|
|
2266
|
+
if (_samePath(knownPath, worktreePath)) {
|
|
2267
|
+
throw _err(
|
|
2268
|
+
'WORKTREE_COLLISION',
|
|
2269
|
+
'createTaskWorktree: requested worktree path "' + worktreePath + '" is identical to the existing ' +
|
|
2270
|
+
'worktree "' + knownPath + '" — refusing to create a non-independent worktree'
|
|
2271
|
+
);
|
|
2272
|
+
}
|
|
2273
|
+
if (_pathsOverlap(worktreePath, knownPath)) {
|
|
2274
|
+
// The new path would be an ANCESTOR of an existing worktree — always a
|
|
2275
|
+
// real collision (it would swallow that worktree's tracked directory),
|
|
2276
|
+
// regardless of commonDir.
|
|
2277
|
+
throw _err(
|
|
2278
|
+
'WORKTREE_COLLISION',
|
|
2279
|
+
'createTaskWorktree: requested worktree path "' + worktreePath + '" would contain the existing ' +
|
|
2280
|
+
'worktree "' + knownPath + '" — refusing to create a non-independent worktree'
|
|
2281
|
+
);
|
|
2282
|
+
}
|
|
2283
|
+
if (_pathsOverlap(knownPath, worktreePath)) {
|
|
2284
|
+
// The new path is nested inside knownPath's directory. Exempt only
|
|
2285
|
+
// when that nesting is entirely explained by commonDir sitting inside
|
|
2286
|
+
// knownPath (i.e., the overlap is administrative Git storage, not
|
|
2287
|
+
// knownPath's own tracked content).
|
|
2288
|
+
const exemptViaCommonDir = worktreePathIsUnderCommonDir &&
|
|
2289
|
+
(_samePath(knownPath, commonDir) || _pathsOverlap(knownPath, commonDir));
|
|
2290
|
+
if (!exemptViaCommonDir) {
|
|
2291
|
+
throw _err(
|
|
2292
|
+
'WORKTREE_COLLISION',
|
|
2293
|
+
'createTaskWorktree: requested worktree path "' + worktreePath + '" is nested inside the existing ' +
|
|
2294
|
+
'worktree "' + knownPath + '" — refusing to create a non-independent worktree'
|
|
2295
|
+
);
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
});
|
|
2299
|
+
|
|
2300
|
+
fs.mkdirSync(worktreesRoot, { recursive: true });
|
|
2301
|
+
|
|
2302
|
+
const addRes = _runGitForWorktree(projectDir, ['worktree', 'add', '-b', branch, worktreePath, baseSha]);
|
|
2303
|
+
if (addRes.error || addRes.status !== 0) {
|
|
2304
|
+
throw _err(
|
|
2305
|
+
'GIT_WORKTREE_ADD_FAILED',
|
|
2306
|
+
'createTaskWorktree: "git worktree add -b ' + branch + ' ' + worktreePath + ' ' + baseSha + '" failed: ' +
|
|
2307
|
+
(addRes.error ? addRes.error.message : (addRes.stderr || '').trim())
|
|
2308
|
+
);
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
// Post-creation independence verification: the path Git actually
|
|
2312
|
+
// registered must be exactly the one requested (not silently normalized
|
|
2313
|
+
// or aliased to something else), and its real, symlink-resolved directory
|
|
2314
|
+
// must differ from the main worktree's.
|
|
2315
|
+
const postPaths = _listWorktreePaths(projectDir);
|
|
2316
|
+
if (!postPaths.some(function (p) { return _samePath(p, worktreePath); })) {
|
|
2317
|
+
throw _err(
|
|
2318
|
+
'WORKTREE_INDEPENDENCE_CHECK_FAILED',
|
|
2319
|
+
'createTaskWorktree: "git worktree add" reported success but "' + worktreePath + '" is not listed by ' +
|
|
2320
|
+
'"git worktree list --porcelain" afterward — refusing to trust an unconfirmed worktree'
|
|
2321
|
+
);
|
|
2322
|
+
}
|
|
2323
|
+
let realWorktreePath;
|
|
2324
|
+
let realProjectDir;
|
|
2325
|
+
try {
|
|
2326
|
+
realWorktreePath = fs.realpathSync.native(worktreePath);
|
|
2327
|
+
realProjectDir = fs.realpathSync.native(projectDir);
|
|
2328
|
+
} catch (err) {
|
|
2329
|
+
throw _err(
|
|
2330
|
+
'WORKTREE_INDEPENDENCE_CHECK_FAILED',
|
|
2331
|
+
'createTaskWorktree: failed to resolve real paths for independence verification: ' + err.message
|
|
2332
|
+
);
|
|
2333
|
+
}
|
|
2334
|
+
if (realWorktreePath === realProjectDir) {
|
|
2335
|
+
throw _err(
|
|
2336
|
+
'WORKTREE_INDEPENDENCE_CHECK_FAILED',
|
|
2337
|
+
'createTaskWorktree: the created worktree resolves to the SAME real directory as the main worktree "' +
|
|
2338
|
+
projectDir + '" — refusing an alias rather than a genuinely independent checkout'
|
|
2339
|
+
);
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
let cleaned = false;
|
|
2343
|
+
|
|
2344
|
+
// Removes the worktree directory (`git worktree remove --force`, never the
|
|
2345
|
+
// plain/"safe" variant — deliberate: by the time this primitive's caller
|
|
2346
|
+
// is done with an attempt's worktree, the attempt's real output is already
|
|
2347
|
+
// checkpointed as a commit on `branch` (Tech-Spec section 8: "commit
|
|
2348
|
+
// remains on technical ref until integration"), so any leftover
|
|
2349
|
+
// uncommitted/untracked worktree state is disposable, never the sole copy
|
|
2350
|
+
// of anything that matters. A plain `git worktree remove` refuses on any
|
|
2351
|
+
// uncommitted change, which would leave a slot permanently stuck for a
|
|
2352
|
+
// reason this primitive does not need to honor here).
|
|
2353
|
+
//
|
|
2354
|
+
// Safe to call more than once (no-op after the first successful removal),
|
|
2355
|
+
// and safe to call after the worktree directory was already removed by
|
|
2356
|
+
// hand: this is confirmed by RE-QUERYING `git worktree list --porcelain`
|
|
2357
|
+
// after a failed/no-op remove attempt — only a remove that fails AND still
|
|
2358
|
+
// leaves the path registered is treated as a real failure (e.g. genuinely
|
|
2359
|
+
// locked, or blocked by something other than "it's already gone").
|
|
2360
|
+
function cleanup() {
|
|
2361
|
+
if (cleaned) return;
|
|
2362
|
+
|
|
2363
|
+
const removeRes = _runGitForWorktree(projectDir, ['worktree', 'remove', '--force', worktreePath]);
|
|
2364
|
+
if (!removeRes.error && removeRes.status === 0) {
|
|
2365
|
+
cleaned = true;
|
|
2366
|
+
return;
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
const stillRegistered = _listWorktreePaths(projectDir).some(function (p) { return _samePath(p, worktreePath); });
|
|
2370
|
+
if (!stillRegistered) {
|
|
2371
|
+
cleaned = true; // already gone (manually removed, or already cleaned up) — safe no-op
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
throw _err(
|
|
2376
|
+
'GIT_WORKTREE_REMOVE_FAILED',
|
|
2377
|
+
'createTaskWorktree.cleanup: "git worktree remove --force ' + worktreePath + '" failed and the worktree ' +
|
|
2378
|
+
'is still registered: ' + (removeRes.error ? removeRes.error.message : (removeRes.stderr || '').trim())
|
|
2379
|
+
);
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
return { worktreePath: worktreePath, branch: branch, cleanup: cleanup };
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
// ── createSlotPool (US-08-TASK-BE-02) ───────────────────────────────────────
|
|
2386
|
+
//
|
|
2387
|
+
// N-limit concurrency control / slot reservation — the second standalone
|
|
2388
|
+
// US-08 primitive, alongside createTaskWorktree (US-08-TASK-BE-01) above.
|
|
2389
|
+
// Like createTaskWorktree, this is NOT wired into execute()'s existing
|
|
2390
|
+
// sequential main loop (US-07-TASK-BE-01, which stays N=1/unchanged);
|
|
2391
|
+
// execute() never calls this function. Real N>1 dispatch integration is
|
|
2392
|
+
// US-08-TASK-BE-03 (serial integration) and US-08-TASK-BE-04 (SHA tracking) —
|
|
2393
|
+
// both later, separate tasks.
|
|
2394
|
+
//
|
|
2395
|
+
// Tech-Spec's binding rule for N (section 7): "N counts reserved task slots
|
|
2396
|
+
// through verification/review/commit/integration; a full slot is released
|
|
2397
|
+
// only when consolidated or safely stopped. This bounds unintegrated work as
|
|
2398
|
+
// well."
|
|
2399
|
+
//
|
|
2400
|
+
// SCOPE BOUNDARY — read before wiring this into any future orchestrator:
|
|
2401
|
+
// this primitive tracks ONLY the count/identity of currently held slot
|
|
2402
|
+
// tokens against a fixed capacity N. It has no idea what a "task", an
|
|
2403
|
+
// "attempt", "verification", "review", "checkpoint", or "integration" is —
|
|
2404
|
+
// those concepts do not appear anywhere in this section. The Tech-Spec rule
|
|
2405
|
+
// above is an OPERATIONAL DISCIPLINE the CALLER must follow, not something
|
|
2406
|
+
// this primitive enforces or even observes: a future execute()-level
|
|
2407
|
+
// orchestrator is responsible for calling tryAcquire() at dispatch time and
|
|
2408
|
+
// holding the returned token through the ENTIRE dispatch → verify → review →
|
|
2409
|
+
// checkpoint → commit → integration lifecycle for that task attempt, calling
|
|
2410
|
+
// release() only once that whole lifecycle reaches a terminal outcome
|
|
2411
|
+
// (successful integration, or a safe-stop/terminal-failure path that gives
|
|
2412
|
+
// up on the attempt for good). If a future caller released the slot early
|
|
2413
|
+
// (e.g. right after dispatch, before verification/review/integration
|
|
2414
|
+
// complete), this primitive would have no way to know that was wrong — it
|
|
2415
|
+
// would simply see one more free unit of capacity and hand it out to a new
|
|
2416
|
+
// acquire. Enforcing "released only after integration verified" as a system
|
|
2417
|
+
// property requires the future orchestrator to hold the token op-for-op
|
|
2418
|
+
// across that whole lifecycle; this primitive only supplies the fail-closed
|
|
2419
|
+
// counting/identity mechanism that discipline is built on top of.
|
|
2420
|
+
//
|
|
2421
|
+
// maxConcurrency validation deliberately mirrors execute()'s own
|
|
2422
|
+
// "args.maxConcurrency must be a positive safe integer" check (Number.isInteger
|
|
2423
|
+
// + Number.isSafeInteger + >= 1) for consistency of error shape across the
|
|
2424
|
+
// module — but NOT execute()'s separate "only 1 is supported, reject any
|
|
2425
|
+
// other value, never downgrade" policy: that reject/never-downgrade decision
|
|
2426
|
+
// is about what execute() itself currently implements, not a constraint
|
|
2427
|
+
// createSlotPool needs to know about or duplicate. This primitive accepts
|
|
2428
|
+
// any valid positive-safe-integer N — enforcing which N values a future
|
|
2429
|
+
// orchestrator is allowed to request stays entirely execute()'s call.
|
|
2430
|
+
function createSlotPool(maxConcurrency) {
|
|
2431
|
+
if (!Number.isInteger(maxConcurrency) || !Number.isSafeInteger(maxConcurrency) || maxConcurrency < 1) {
|
|
2432
|
+
throw _err('SLOT_POOL_VALIDATION_ERROR', 'createSlotPool: maxConcurrency must be a positive safe integer');
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
// Held-slot identity set, keyed by an opaque token string this pool itself
|
|
2436
|
+
// mints and hands back from tryAcquire(). Using an explicit per-slot token
|
|
2437
|
+
// (rather than a bare counter) is what makes double-release and
|
|
2438
|
+
// bogus-release detectable: release() can check "is this EXACT token
|
|
2439
|
+
// currently held" instead of merely "is the counter above zero" — a caller
|
|
2440
|
+
// that fabricates a token, reuses one already released, or passes a token
|
|
2441
|
+
// from a different pool entirely is rejected the same way a genuine
|
|
2442
|
+
// double-release is (SLOT_NOT_HELD), never silently accepted.
|
|
2443
|
+
const heldTokens = new Set();
|
|
2444
|
+
let nextTokenSeq = 1;
|
|
2445
|
+
|
|
2446
|
+
// Fails closed: returns null (never throws, never blocks) the instant all
|
|
2447
|
+
// maxConcurrency slots are already held — per this task's own documented
|
|
2448
|
+
// choice of "returns null" over "throws SLOT_POOL_EXHAUSTED", since a
|
|
2449
|
+
// future caller driving a ready-queue loop naturally wants a plain falsy
|
|
2450
|
+
// "no capacity right now, try another task or wait" signal here rather
|
|
2451
|
+
// than an exception for what is an entirely expected, non-error condition
|
|
2452
|
+
// (a full pool is the normal steady state of a busy run, not a fault).
|
|
2453
|
+
function tryAcquire() {
|
|
2454
|
+
if (heldTokens.size >= maxConcurrency) {
|
|
2455
|
+
return null;
|
|
2456
|
+
}
|
|
2457
|
+
const token = 'slot-' + (nextTokenSeq++);
|
|
2458
|
+
heldTokens.add(token);
|
|
2459
|
+
return token;
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
// Releases exactly one previously acquired, still-held token. Guards
|
|
2463
|
+
// against double-release and bogus/unknown tokens by construction (Set
|
|
2464
|
+
// membership check) rather than trusting the caller: an already-released
|
|
2465
|
+
// token, a fabricated string, or a token from a different createSlotPool()
|
|
2466
|
+
// instance all fail the same way (SLOT_NOT_HELD) instead of silently
|
|
2467
|
+
// decrementing a bare counter below zero or accepting a release that was
|
|
2468
|
+
// never actually a held slot.
|
|
2469
|
+
function release(token) {
|
|
2470
|
+
if (typeof token !== 'string' || !heldTokens.has(token)) {
|
|
2471
|
+
throw _err(
|
|
2472
|
+
'SLOT_NOT_HELD',
|
|
2473
|
+
'createSlotPool.release: "' + token + '" is not a currently held slot in this pool ' +
|
|
2474
|
+
'(never acquired, already released, or acquired from a different pool)'
|
|
2475
|
+
);
|
|
2476
|
+
}
|
|
2477
|
+
heldTokens.delete(token);
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
// Read-only inspection for tests/diagnostics — neither call ever mutates
|
|
2481
|
+
// pool state; both simply report the CURRENT snapshot at call time (there
|
|
2482
|
+
// is no cached/stale value to go wrong, since heldTokens.size is read
|
|
2483
|
+
// fresh on every call).
|
|
2484
|
+
function activeCount() {
|
|
2485
|
+
return heldTokens.size;
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
function remainingCapacity() {
|
|
2489
|
+
return maxConcurrency - heldTokens.size;
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
return {
|
|
2493
|
+
tryAcquire: tryAcquire,
|
|
2494
|
+
release: release,
|
|
2495
|
+
activeCount: activeCount,
|
|
2496
|
+
remainingCapacity: remainingCapacity,
|
|
2497
|
+
maxConcurrency: maxConcurrency,
|
|
2498
|
+
};
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
/**
|
|
2502
|
+
* `start` command real implementation (US-07-TASK-BE-01): sequential
|
|
2503
|
+
* (maxConcurrency=1) main loop with ready-queue dispatch. Validates
|
|
2504
|
+
* args/config, acquires the repo-wide execution lease for a brand-new run,
|
|
2505
|
+
* creates an immutable initial state from the feature's OWN approved plan
|
|
2506
|
+
* (this feature's own Work-Breakdown.md/.csv, read from disk via the same
|
|
2507
|
+
* _deriveFeatureDir/_derivePrefix convention replan() already uses), and
|
|
2508
|
+
* drives dispatch/verification/review/checkpoint to completion or block.
|
|
2509
|
+
*
|
|
2510
|
+
* Only maxConcurrency=1 is implemented here — any other requested value is
|
|
2511
|
+
* REJECTED (UNSUPPORTED_CONCURRENCY), never silently downgraded (Tech-Spec
|
|
2512
|
+
* section 10: "Requested N=effective N; if host cannot support it, reject,
|
|
2513
|
+
* never downgrade"). N>1 concurrent slots is US-08 scope.
|
|
2514
|
+
*
|
|
2515
|
+
* Main-loop design (see file-level "execute() main loop" comment above and
|
|
2516
|
+
* _runTaskToResolution for the per-task detail):
|
|
2517
|
+
* 1. Compute the ready queue (plan.computeReadyQueue) once, up front, over
|
|
2518
|
+
* the immutable plan snapshot — this is the full stable dispatch order
|
|
2519
|
+
* the run will use whenever nothing is actually blocked (see
|
|
2520
|
+
* computeReadyQueue's own doc comment for why one static pass suffices).
|
|
2521
|
+
* 2. Repeatedly: re-read state, find the FIRST task (in that stable order)
|
|
2522
|
+
* that is still 'pending' AND whose dependencies are all
|
|
2523
|
+
* 'checkpointed'/'integrated'. Dispatch it via _runTaskToResolution,
|
|
2524
|
+
* which reserves its slot (status 'active') through dispatch, verify,
|
|
2525
|
+
* review, checkpoint and commit — retrying a NEW attempt on the SAME
|
|
2526
|
+
* task within the retry policy on failure, or marking it 'blocked' once
|
|
2527
|
+
* the policy is exhausted — before this loop ever considers another
|
|
2528
|
+
* task. This is the literal "one task slot" invariant (AC-06).
|
|
2529
|
+
* 3. Stop (never spin) once no task is both non-terminal and ready — e.g.
|
|
2530
|
+
* every remaining task is pending on a dependency that is itself
|
|
2531
|
+
* blocked. The final run status reflects this (see below).
|
|
2532
|
+
*
|
|
2533
|
+
* Final runStatus: 'completed' if every task reached
|
|
2534
|
+
* checkpointed/integrated/skipped; 'blocked' otherwise (at least one task is
|
|
2535
|
+
* blocked, or pending on a dependency that never resolved).
|
|
2536
|
+
*
|
|
2537
|
+
* The repo-wide execution lease is released in a try/finally, so it is
|
|
2538
|
+
* released even if the loop exits via an uncaught error (e.g. a
|
|
2539
|
+
* DEFINITION_HASH_MISMATCH / NO_LOCK_HELD systemic failure propagated by
|
|
2540
|
+
* _runTaskToResolution) — this run never leaves the lease dangling on the
|
|
2541
|
+
* failure path it itself created.
|
|
2542
|
+
*
|
|
2543
|
+
* @param {object} args
|
|
2544
|
+
* @param {string} args.project - Absolute or project-relative path used to
|
|
2545
|
+
* resolve the repo root and common Git directory (also used as `cwd` for
|
|
2546
|
+
* every dispatch/verification/git operation).
|
|
2547
|
+
* @param {string} args.feature - Path to the feature's feature.md, used to
|
|
2548
|
+
* derive PREFIX and locate this feature's OWN Work-Breakdown.md/.csv.
|
|
2549
|
+
* @param {number} [args.maxConcurrency=1] - Requested task slot count N.
|
|
2550
|
+
* Must be a positive safe integer; only 1 is supported by this task — any
|
|
2551
|
+
* other value is rejected with UNSUPPORTED_CONCURRENCY, never downgraded.
|
|
2552
|
+
* @param {string} [args.claudePath] - Explicit path to the qualified Claude
|
|
2553
|
+
* CLI executable, forwarded to every dispatch/review invocation.
|
|
2554
|
+
* @param {number} [args.taskTimeoutMs] - Per-task timeout budget, forwarded
|
|
2555
|
+
* to dispatch/verification/review.
|
|
2556
|
+
* @param {number} [args.agentBudgetUsd] - Per-run agent spend budget,
|
|
2557
|
+
* persisted into state.config unchanged (not itself enforced here).
|
|
2558
|
+
* @param {string} [args.taskRef] - Explicit override for the feature/task ref
|
|
2559
|
+
* commits land on. When omitted, derived from the CURRENT checked-out
|
|
2560
|
+
* branch of args.project via `git rev-parse --abbrev-ref HEAD`.
|
|
2561
|
+
* @param {string[]} [args.implementationSpawnArgs] - Test-only override of
|
|
2562
|
+
* the full CLI args array forwarded to every dispatchTaskAttempt call (see
|
|
2563
|
+
* dispatchTaskAttempt's own args.spawnArgs) — tests use this to substitute
|
|
2564
|
+
* tests/fixtures/fake-claude-cli.js's own invocation shape while still
|
|
2565
|
+
* exercising a real subprocess spawn end-to-end. Omitted in production.
|
|
2566
|
+
* @param {string[]} [args.reviewSpawnArgs] - Test-only override of the full
|
|
2567
|
+
* CLI args array forwarded to every runReview call (see runReview's own
|
|
2568
|
+
* args.spawnArgs). Omitted in production.
|
|
2569
|
+
* @returns {Promise<object>} Versioned result object:
|
|
2570
|
+
* { protocolVersion, runId, runStatus, tasks: [{ taskId, status }] }.
|
|
2571
|
+
* @throws {Error} DISPATCH_VALIDATION_ERROR for malformed maxConcurrency;
|
|
2572
|
+
* UNSUPPORTED_CONCURRENCY for any requested maxConcurrency other than 1;
|
|
2573
|
+
* EXECUTE_VALIDATION_ERROR when a PREFIX cannot be derived or HEAD is
|
|
2574
|
+
* detached with no explicit args.taskRef; PLAN_NOT_FOUND when this
|
|
2575
|
+
* feature's own Work-Breakdown.md/.csv cannot be read; PLAN_PARSE_ERROR
|
|
2576
|
+
* propagated unmodified from plan.createPlanSnapshot; LEASE_HELD when a
|
|
2577
|
+
* repo-wide execution lease is already held; any error propagated
|
|
2578
|
+
* unmodified from dispatchTaskAttempt/runVerifications/runReview/git.js/
|
|
2579
|
+
* store.js for a systemic (non-task-attempt) failure.
|
|
2580
|
+
*/
|
|
2581
|
+
async function execute(args) {
|
|
2582
|
+
// Platform qualification guard (US-08-TASK-BE-05) — the single most
|
|
2583
|
+
// fundamental precondition of this whole function, checked before ANY
|
|
2584
|
+
// other validation, before deriving executionRoot, before touching the
|
|
2585
|
+
// plan/snapshot, before acquiring the ownership lease, and before creating
|
|
2586
|
+
// any worktree. Checked dynamically against the live process.platform
|
|
2587
|
+
// (never a module-load-time-cached constant like this file's own
|
|
2588
|
+
// IS_WINDOWS above) so it reflects the real, current platform at call
|
|
2589
|
+
// time. Fail-closed, no partial/"best effort" execution on any other
|
|
2590
|
+
// platform: this feature's own Gate-1 approval (binding constraint #3)
|
|
2591
|
+
// states "E-02 is Windows-only qualified. Other platforms require
|
|
2592
|
+
// dedicated proofs before their execution is enabled; no implicit
|
|
2593
|
+
// cross-platform guarantee." E-02 is the process-supervision evidence
|
|
2594
|
+
// dossier (tree-kill via taskkill, tag-based liveness scanning via
|
|
2595
|
+
// Win32_Process CIM queries) that this executor's crash-recovery/stop/
|
|
2596
|
+
// worker-liveness machinery (ownership.js's checkOwnerLiveness, this
|
|
2597
|
+
// file's own _findLiveTaggedProcess, stop()'s immediate-mode kill) depends
|
|
2598
|
+
// on — none of it is proven correct on non-Windows platforms.
|
|
2599
|
+
if (process.platform !== 'win32') {
|
|
2600
|
+
throw _err(
|
|
2601
|
+
'PLATFORM_NOT_QUALIFIED',
|
|
2602
|
+
'execute: platform "' + process.platform + '" is not qualified to run the task executor — the E-02 ' +
|
|
2603
|
+
'process-supervision qualification (tree-kill via taskkill, tag-based liveness scanning via ' +
|
|
2604
|
+
'Win32_Process CIM queries) that this executor\'s crash-recovery/stop/worker-liveness machinery ' +
|
|
2605
|
+
'depends on is proven for Windows only. Other platforms require dedicated proofs before their ' +
|
|
2606
|
+
'execution is enabled; refusing to proceed (no partial or best-effort execution).'
|
|
2607
|
+
);
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
args = args || {};
|
|
2611
|
+
_requireNonEmptyString(args.project, 'execute: args.project');
|
|
2612
|
+
_requireNonEmptyString(args.feature, 'execute: args.feature');
|
|
2613
|
+
|
|
2614
|
+
const maxConcurrency = args.maxConcurrency === undefined ? 1 : args.maxConcurrency;
|
|
2615
|
+
if (!Number.isInteger(maxConcurrency) || !Number.isSafeInteger(maxConcurrency) || maxConcurrency < 1) {
|
|
2616
|
+
throw _err('DISPATCH_VALIDATION_ERROR', 'execute: args.maxConcurrency must be a positive safe integer');
|
|
2617
|
+
}
|
|
2618
|
+
if (maxConcurrency !== 1) {
|
|
2619
|
+
throw _err(
|
|
2620
|
+
'UNSUPPORTED_CONCURRENCY',
|
|
2621
|
+
'execute: requested maxConcurrency ' + maxConcurrency + ' is not supported yet — only maxConcurrency=1 is ' +
|
|
2622
|
+
'implemented (US-07-TASK-BE-01); N>1 concurrent slots is US-08 scope. Refusing to silently downgrade ' +
|
|
2623
|
+
'(Tech-Spec section 10: "Requested N=effective N; if host cannot support it, reject, never downgrade").'
|
|
2624
|
+
);
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
const projectDir = args.project;
|
|
2628
|
+
|
|
2629
|
+
const featureDir = _deriveFeatureDir(args.feature);
|
|
2630
|
+
const prefix = _derivePrefix(featureDir);
|
|
2631
|
+
if (!prefix) {
|
|
2632
|
+
throw _err(
|
|
2633
|
+
'EXECUTE_VALIDATION_ERROR',
|
|
2634
|
+
'execute: could not derive a PREFIX (e.g. "FTR-018") from feature directory "' + featureDir + '"'
|
|
2635
|
+
);
|
|
2636
|
+
}
|
|
2637
|
+
const mdPath = path.join(featureDir, prefix + '-Work-Breakdown.md');
|
|
2638
|
+
const csvPath = path.join(featureDir, prefix + '-Work-Breakdown.csv');
|
|
2639
|
+
let md;
|
|
2640
|
+
let csv;
|
|
2641
|
+
try {
|
|
2642
|
+
md = fs.readFileSync(mdPath, 'utf8');
|
|
2643
|
+
csv = fs.readFileSync(csvPath, 'utf8');
|
|
2644
|
+
} catch (err) {
|
|
2645
|
+
throw _err(
|
|
2646
|
+
'PLAN_NOT_FOUND',
|
|
2647
|
+
'execute: Work Breakdown MD/CSV not found at "' + mdPath + '" / "' + csvPath + '": ' + err.message
|
|
2648
|
+
);
|
|
2649
|
+
}
|
|
2650
|
+
// Propagates PLAN_PARSE_ERROR unmodified — a malformed/unapproved plan
|
|
2651
|
+
// never silently starts a run.
|
|
2652
|
+
const snapshot = plan.createPlanSnapshot(md, csv);
|
|
2653
|
+
|
|
2654
|
+
const commonDir = _resolveCommonGitDir(projectDir);
|
|
2655
|
+
const executionRoot = path.join(commonDir, 'ai-toolkit', 'execution');
|
|
2656
|
+
|
|
2657
|
+
const runId = crypto.randomUUID();
|
|
2658
|
+
// Fail closed if already held — this is a brand-new run; reclaiming a
|
|
2659
|
+
// stale lease is an explicit reconcile/resume/replan decision, never an
|
|
2660
|
+
// implicit part of starting a fresh execute() call.
|
|
2661
|
+
const lease = ownership.acquireLease(executionRoot, runId);
|
|
2662
|
+
|
|
2663
|
+
try {
|
|
2664
|
+
const taskRef = typeof args.taskRef === 'string' && args.taskRef.length > 0
|
|
2665
|
+
? args.taskRef
|
|
2666
|
+
: _currentBranchRef(projectDir);
|
|
2667
|
+
const featureRef = taskRef;
|
|
2668
|
+
const baseSha = _resolveRefTip(projectDir, 'HEAD', 'HEAD');
|
|
2669
|
+
|
|
2670
|
+
let state = new store.State({
|
|
2671
|
+
runId: runId,
|
|
2672
|
+
featureId: prefix,
|
|
2673
|
+
repo: projectDir,
|
|
2674
|
+
commonDir: commonDir,
|
|
2675
|
+
featureRef: featureRef,
|
|
2676
|
+
baseSha: baseSha,
|
|
2677
|
+
planDigest: snapshot.planDigest,
|
|
2678
|
+
config: {
|
|
2679
|
+
maxConcurrency: 1,
|
|
2680
|
+
claudePath: args.claudePath,
|
|
2681
|
+
taskTimeoutMs: args.taskTimeoutMs,
|
|
2682
|
+
agentBudgetUsd: args.agentBudgetUsd,
|
|
2683
|
+
},
|
|
2684
|
+
runStatus: 'running',
|
|
2685
|
+
});
|
|
2686
|
+
|
|
2687
|
+
snapshot.tasks.forEach(function (task) {
|
|
2688
|
+
state = state.addTask(task.id, { dependencies: task.dependsOn });
|
|
2689
|
+
});
|
|
2690
|
+
store.writeState(executionRoot, runId, state);
|
|
2691
|
+
|
|
2692
|
+
const tasksById = new Map(snapshot.tasks.map(function (t) { return [t.id, t]; }));
|
|
2693
|
+
const orderedTaskIds = plan.computeReadyQueue(snapshot).map(function (t) { return t.id; });
|
|
2694
|
+
|
|
2695
|
+
// US-07-TASK-BE-02 loop-cooperation flag: stop()'s graceful mode is
|
|
2696
|
+
// documented as "block new dispatch" — this loop is the only thing that
|
|
2697
|
+
// can actually enforce that block, since stop() itself never touches a
|
|
2698
|
+
// task in flight. Set only when this loop observes runStatus 'stopping'
|
|
2699
|
+
// at its own natural iteration boundary (the re-read below, which
|
|
2700
|
+
// already happens once per completed/blocked task — never mid-task);
|
|
2701
|
+
// used after the loop to report 'paused' instead of running the normal
|
|
2702
|
+
// completed/blocked classification against a run that still has
|
|
2703
|
+
// legitimately-pending (never dispatched) tasks.
|
|
2704
|
+
let stopRequested = false;
|
|
2705
|
+
|
|
2706
|
+
for (;;) {
|
|
2707
|
+
state = store.readState(executionRoot, runId);
|
|
2708
|
+
|
|
2709
|
+
if (state.runStatus === 'stopping') {
|
|
2710
|
+
// A stop request (either mode) was persisted by a concurrent stop()
|
|
2711
|
+
// call. This is the exact "natural iteration boundary" the request
|
|
2712
|
+
// waits for: the previous task (if any) already reached a terminal
|
|
2713
|
+
// outcome (checkpointed/blocked) via the prior loop iteration's
|
|
2714
|
+
// await below, and no task is currently reserved by this loop. Do
|
|
2715
|
+
// not dispatch the next ready task — leave it 'pending' — and stop.
|
|
2716
|
+
stopRequested = true;
|
|
2717
|
+
break;
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
const nonTerminalIds = orderedTaskIds.filter(function (id) {
|
|
2721
|
+
const st = state.tasks[id].status;
|
|
2722
|
+
return st !== 'checkpointed' && st !== 'integrated' && st !== 'skipped' && st !== 'blocked';
|
|
2723
|
+
});
|
|
2724
|
+
if (nonTerminalIds.length === 0) break;
|
|
2725
|
+
|
|
2726
|
+
const readyTaskId = orderedTaskIds.find(function (id) {
|
|
2727
|
+
const t = state.tasks[id];
|
|
2728
|
+
if (t.status !== 'pending') return false;
|
|
2729
|
+
return t.dependencies.every(function (depId) {
|
|
2730
|
+
const dep = state.tasks[depId];
|
|
2731
|
+
return dep && (dep.status === 'checkpointed' || dep.status === 'integrated');
|
|
2732
|
+
});
|
|
2733
|
+
});
|
|
2734
|
+
|
|
2735
|
+
if (!readyTaskId) {
|
|
2736
|
+
// Nothing more can proceed right now (e.g. every remaining task is
|
|
2737
|
+
// pending on a dependency that is itself blocked) — stop, do not
|
|
2738
|
+
// spin. Reflected in the final run status below.
|
|
2739
|
+
break;
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
await _runTaskToResolution({
|
|
2743
|
+
executionRoot: executionRoot,
|
|
2744
|
+
runId: runId,
|
|
2745
|
+
projectDir: projectDir,
|
|
2746
|
+
taskRef: taskRef,
|
|
2747
|
+
featureRef: featureRef,
|
|
2748
|
+
planDigest: snapshot.planDigest,
|
|
2749
|
+
task: tasksById.get(readyTaskId),
|
|
2750
|
+
claudePath: args.claudePath,
|
|
2751
|
+
taskTimeoutMs: args.taskTimeoutMs,
|
|
2752
|
+
implementationSpawnArgs: args.implementationSpawnArgs,
|
|
2753
|
+
reviewSpawnArgs: args.reviewSpawnArgs,
|
|
2754
|
+
});
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
state = store.readState(executionRoot, runId);
|
|
2758
|
+
let finalRunStatus;
|
|
2759
|
+
if (stopRequested) {
|
|
2760
|
+
// Tech-Spec section 10: "Stopping a run returns accepted request
|
|
2761
|
+
// state; status confirms when it actually paused." 'paused' (already
|
|
2762
|
+
// part of RUN_STATUSES) is the correct terminal-for-this-loop status
|
|
2763
|
+
// here — unlike the completed/blocked branch below, a stop-truncated
|
|
2764
|
+
// run legitimately still has 'pending' tasks that were never attempted
|
|
2765
|
+
// and are not "blocked" in the retry-policy/dependency sense.
|
|
2766
|
+
finalRunStatus = 'paused';
|
|
2767
|
+
} else {
|
|
2768
|
+
const allTerminalSuccess = Object.keys(state.tasks).every(function (id) {
|
|
2769
|
+
const st = state.tasks[id].status;
|
|
2770
|
+
return st === 'checkpointed' || st === 'integrated' || st === 'skipped';
|
|
2771
|
+
});
|
|
2772
|
+
finalRunStatus = allTerminalSuccess ? 'completed' : 'blocked';
|
|
2773
|
+
}
|
|
2774
|
+
state = state.setRunStatus(finalRunStatus);
|
|
2775
|
+
state = store.writeState(executionRoot, runId, state);
|
|
2776
|
+
|
|
2777
|
+
return {
|
|
2778
|
+
protocolVersion: store.PROTOCOL_VERSION,
|
|
2779
|
+
runId: runId,
|
|
2780
|
+
runStatus: finalRunStatus,
|
|
2781
|
+
tasks: Object.keys(state.tasks).sort().map(function (id) {
|
|
2782
|
+
return { taskId: id, status: state.tasks[id].status };
|
|
2783
|
+
}),
|
|
2784
|
+
};
|
|
2785
|
+
} finally {
|
|
2786
|
+
try {
|
|
2787
|
+
ownership.releaseLease(executionRoot, runId, lease.nonce);
|
|
2788
|
+
} catch (_releaseErr) {
|
|
2789
|
+
// Best-effort: never mask the loop's own result/error because the
|
|
2790
|
+
// lease could not be released (e.g. it was already gone).
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
/**
|
|
2796
|
+
* `status` command. Read-only summary of a run: counts of tasks per
|
|
2797
|
+
* status, current run status, and total cost as reported by the ledger
|
|
2798
|
+
* (the sole cost source). Never mutates state.
|
|
2799
|
+
*
|
|
2800
|
+
* Real implementation: US-06-TASK-BE-01 (loads persisted state/evidence and
|
|
2801
|
+
* classifies task status per the edge-cases table this summary is built
|
|
2802
|
+
* from).
|
|
2803
|
+
*
|
|
2804
|
+
* @param {object} args
|
|
2805
|
+
* @param {string} args.project - Absolute or project-relative repo path.
|
|
2806
|
+
* @param {string} args.runId - UUID of the run to inspect.
|
|
2807
|
+
* @returns {Promise<object>} Versioned result object:
|
|
2808
|
+
* { protocolVersion, runId, runStatus,
|
|
2809
|
+
* taskCounts: { pending, active, checkpointed, integrated, skipped, blocked },
|
|
2810
|
+
* totalTokens, totalCostUsd }.
|
|
2811
|
+
*/
|
|
2812
|
+
async function status(args) {
|
|
2813
|
+
return _notImplemented('status', 'US-06-TASK-BE-01');
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
/**
|
|
2817
|
+
* `diagnose` command. Read-only detailed evidence dump for a run:
|
|
2818
|
+
* per-task/attempt evidence (checkpoint, commit, review, diff, worker
|
|
2819
|
+
* liveness), ownership/lease state, and repair suggestions. Never mutates
|
|
2820
|
+
* state.
|
|
2821
|
+
*
|
|
2822
|
+
* Real implementation: US-06-TASK-BE-02 (evidence evaluation per the
|
|
2823
|
+
* edge-cases table: no-reimplementation, reconcile-SHA,
|
|
2824
|
+
* recheck-verification, preserve-diff, or block).
|
|
2825
|
+
*
|
|
2826
|
+
* @param {object} args
|
|
2827
|
+
* @param {string} args.project - Absolute or project-relative repo path.
|
|
2828
|
+
* @param {string} args.runId - UUID of the run to inspect.
|
|
2829
|
+
* @returns {Promise<object>} Versioned result object:
|
|
2830
|
+
* { protocolVersion, runId, ownership: {...},
|
|
2831
|
+
* tasks: [{ taskId, attempts: [...], evidence: {...}, suggestedAction }] }.
|
|
2832
|
+
*/
|
|
2833
|
+
async function diagnose(args) {
|
|
2834
|
+
return _notImplemented('diagnose', 'US-06-TASK-BE-02');
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
/**
|
|
2838
|
+
* `stop` command (US-07-TASK-BE-02). Persists a stop request for a running
|
|
2839
|
+
* coordinator and acknowledges receipt of the request ONLY — this function
|
|
2840
|
+
* never itself claims the run has actually stopped. `status`/`diagnose`
|
|
2841
|
+
* (both still NOT_IMPLEMENTED — US-06-TASK-BE-01/02, out of scope here)
|
|
2842
|
+
* confirm that once the coordinator's own loop observes the request.
|
|
2843
|
+
*
|
|
2844
|
+
* Modes:
|
|
2845
|
+
* - 'graceful' (default): persists the request only. execute()'s own main
|
|
2846
|
+
* loop (see its inline comment at the top-of-loop runStatus check, above)
|
|
2847
|
+
* is the ONLY thing that can actually "block new dispatch" — it observes
|
|
2848
|
+
* runStatus 'stopping' at its next natural iteration boundary (after its
|
|
2849
|
+
* current task reaches checkpointed/blocked) and stops there, leaving
|
|
2850
|
+
* every remaining task 'pending'. This function does nothing further for
|
|
2851
|
+
* 'graceful': collecting already-running results and checkpointing only
|
|
2852
|
+
* when evidence is already sufficient (Tech-Spec section 7) is the
|
|
2853
|
+
* RUNNING coordinator's job when it next observes the request, not this
|
|
2854
|
+
* call's — this call only persists the request.
|
|
2855
|
+
* - 'immediate': ADDITIONALLY attempts real termination of every attempt
|
|
2856
|
+
* whose stage is 'dispatching' or 'running' (i.e. potentially mid-flight
|
|
2857
|
+
* right now) with a recorded processIdentity: finds it via the same
|
|
2858
|
+
* tag-scan technique already used elsewhere in this module
|
|
2859
|
+
* (_findLiveTaggedProcess, US-06-TASK-BE-02), and if found alive (or
|
|
2860
|
+
* liveness unknown), terminates it via the same E-02-qualified tree-kill
|
|
2861
|
+
* technique used elsewhere, adapted to a bare PID
|
|
2862
|
+
* (_killProcessTreeByPidSync — needed because, unlike
|
|
2863
|
+
* claude-process.js's spawnClaudeAgent, this function never held a
|
|
2864
|
+
* ChildProcess handle for a worker it did not itself spawn: the worker
|
|
2865
|
+
* may belong to an entirely different coordinator process). It then
|
|
2866
|
+
* polls _findLiveTaggedProcess with a bounded number of short waits
|
|
2867
|
+
* (_awaitConfirmedTermination) before returning. If termination cannot
|
|
2868
|
+
* be positively confirmed within that bound, this function still
|
|
2869
|
+
* acknowledges the request as persisted, but reports
|
|
2870
|
+
* `terminationConfirmed: false` — it never claims a termination it
|
|
2871
|
+
* cannot verify (the same discipline _findLiveTaggedProcess itself
|
|
2872
|
+
* already enforces for 'unknown').
|
|
2873
|
+
*
|
|
2874
|
+
* Idempotency (exact rule): if the run's CURRENT runStatus is already
|
|
2875
|
+
* 'stopping', or is settled — 'completed'/'blocked'/'superseded', or
|
|
2876
|
+
* 'paused' (execute()'s own loop already fully drained a prior stop request
|
|
2877
|
+
* — see its final-status computation) — this call is a pure no-op: nothing
|
|
2878
|
+
* is re-persisted (no generation bump) and NO mode-specific action runs (no
|
|
2879
|
+
* re-kill attempt, and no escalation if this call's mode differs from
|
|
2880
|
+
* whatever was originally requested — a second stop() call never upgrades a
|
|
2881
|
+
* prior 'graceful' request to 'immediate'; only the first accepted request
|
|
2882
|
+
* for a run ever performs mode-specific work). The acknowledged `mode` in
|
|
2883
|
+
* the idempotent-'stopping' case reflects the ORIGINALLY recorded mode
|
|
2884
|
+
* (state.config.stopRequest.mode), not necessarily this call's own mode.
|
|
2885
|
+
*
|
|
2886
|
+
* @param {object} args
|
|
2887
|
+
* @param {string} args.project - Absolute or project-relative repo path
|
|
2888
|
+
* (resolved to the common Git directory / execution root exactly like
|
|
2889
|
+
* execute() does).
|
|
2890
|
+
* @param {string} args.runId - UUID of the run to stop.
|
|
2891
|
+
* @param {'graceful'|'immediate'} [args.mode='graceful'] - Stop mode.
|
|
2892
|
+
* @param {string} [args.exeBasename] - Test-only override of the real
|
|
2893
|
+
* spawned executable's basename to match for 'immediate' mode's worker
|
|
2894
|
+
* termination (_findLiveTaggedProcess) — same override reconcile()/
|
|
2895
|
+
* resume() already accept via ctx.exeBasename. Defaults to
|
|
2896
|
+
* DEFAULT_TAGGED_PROCESS_EXE_BASENAME ('claude.exe') in production.
|
|
2897
|
+
* @returns {Promise<object>} Versioned result object:
|
|
2898
|
+
* { protocolVersion, runId, requestAccepted: true, mode,
|
|
2899
|
+
* idempotent?: true,
|
|
2900
|
+
* terminationConfirmed?: boolean,
|
|
2901
|
+
* killAttempts?: Array<{taskId, attemptNumber, processIdentity, confirmed: boolean}> }
|
|
2902
|
+
* `terminationConfirmed`/`killAttempts` are present only for a
|
|
2903
|
+
* newly-accepted 'immediate' request (never for 'graceful', never for an
|
|
2904
|
+
* idempotent replay).
|
|
2905
|
+
* @throws {Error} STOP_VALIDATION_ERROR for a malformed args.project/args.
|
|
2906
|
+
* runId/args.mode; any error store.readState throws (STATE_NOT_FOUND,
|
|
2907
|
+
* STATE_CORRUPTED) propagates unmodified — a corrupted/missing run is
|
|
2908
|
+
* never silently defaulted.
|
|
2909
|
+
*/
|
|
2910
|
+
async function stop(args) {
|
|
2911
|
+
args = args || {};
|
|
2912
|
+
_requireNonEmptyString(args.project, 'stop: args.project');
|
|
2913
|
+
_requireNonEmptyString(args.runId, 'stop: args.runId');
|
|
2914
|
+
|
|
2915
|
+
const mode = args.mode === undefined ? 'graceful' : args.mode;
|
|
2916
|
+
if (mode !== 'graceful' && mode !== 'immediate') {
|
|
2917
|
+
throw _err(
|
|
2918
|
+
'STOP_VALIDATION_ERROR',
|
|
2919
|
+
'stop: args.mode must be "graceful" or "immediate", got ' + JSON.stringify(args.mode)
|
|
2920
|
+
);
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
const projectDir = args.project;
|
|
2924
|
+
const runId = args.runId;
|
|
2925
|
+
const commonDir = _resolveCommonGitDir(projectDir);
|
|
2926
|
+
const executionRoot = path.join(commonDir, 'ai-toolkit', 'execution');
|
|
2927
|
+
|
|
2928
|
+
// Propagates STATE_NOT_FOUND / STATE_CORRUPTED unmodified — never
|
|
2929
|
+
// fabricates an empty/default run for a corrupted/missing one.
|
|
2930
|
+
let state = store.readState(executionRoot, runId);
|
|
2931
|
+
|
|
2932
|
+
// Idempotency (see doc comment above for the exact rule and rationale for
|
|
2933
|
+
// including 'paused' alongside the terminal statuses).
|
|
2934
|
+
const SETTLED_OR_STOPPING_RUN_STATUSES = ['stopping', 'completed', 'blocked', 'superseded', 'paused'];
|
|
2935
|
+
if (SETTLED_OR_STOPPING_RUN_STATUSES.indexOf(state.runStatus) !== -1) {
|
|
2936
|
+
const recordedMode = state.config && state.config.stopRequest && state.config.stopRequest.mode;
|
|
2937
|
+
return {
|
|
2938
|
+
protocolVersion: store.PROTOCOL_VERSION,
|
|
2939
|
+
runId: runId,
|
|
2940
|
+
requestAccepted: true,
|
|
2941
|
+
mode: recordedMode || mode,
|
|
2942
|
+
idempotent: true,
|
|
2943
|
+
};
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
// Persist the stop request: one write, both the runStatus transition and
|
|
2947
|
+
// the requested mode (store.js's State#requestStop — see its own doc
|
|
2948
|
+
// comment for why `config` was chosen over a new top-level field).
|
|
2949
|
+
state = state.requestStop(mode);
|
|
2950
|
+
state = store.writeState(executionRoot, runId, state);
|
|
2951
|
+
|
|
2952
|
+
if (mode === 'graceful') {
|
|
2953
|
+
// That's it — the already-running execute() loop enforces the block
|
|
2954
|
+
// (see execute()'s own inline comment at its loop-top runStatus check).
|
|
2955
|
+
return {
|
|
2956
|
+
protocolVersion: store.PROTOCOL_VERSION,
|
|
2957
|
+
runId: runId,
|
|
2958
|
+
requestAccepted: true,
|
|
2959
|
+
mode: mode,
|
|
2960
|
+
};
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
// mode === 'immediate': additionally attempt real termination of every
|
|
2964
|
+
// attempt that is potentially mid-flight right now.
|
|
2965
|
+
const exeBasename = typeof args.exeBasename === 'string' && args.exeBasename.length > 0
|
|
2966
|
+
? args.exeBasename
|
|
2967
|
+
: DEFAULT_TAGGED_PROCESS_EXE_BASENAME;
|
|
2968
|
+
|
|
2969
|
+
const inFlightStages = ['dispatching', 'running'];
|
|
2970
|
+
const candidates = [];
|
|
2971
|
+
Object.keys(state.tasks).sort().forEach(function (taskId) {
|
|
2972
|
+
const task = state.tasks[taskId];
|
|
2973
|
+
(Array.isArray(task.attempts) ? task.attempts : []).forEach(function (attempt) {
|
|
2974
|
+
if (inFlightStages.indexOf(attempt.stage) === -1) return;
|
|
2975
|
+
if (typeof attempt.processIdentity !== 'string' || attempt.processIdentity.length === 0) return;
|
|
2976
|
+
candidates.push({ taskId: taskId, attemptNumber: attempt.number, processIdentity: attempt.processIdentity });
|
|
2977
|
+
});
|
|
2978
|
+
});
|
|
2979
|
+
|
|
2980
|
+
const killAttempts = [];
|
|
2981
|
+
let terminationConfirmed = true; // vacuously true when there is nothing to kill
|
|
2982
|
+
|
|
2983
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
2984
|
+
const candidate = candidates[i];
|
|
2985
|
+
const liveness = _findLiveTaggedProcess(candidate.processIdentity, exeBasename);
|
|
2986
|
+
|
|
2987
|
+
if (liveness.status === 'confirmed-not-found') {
|
|
2988
|
+
killAttempts.push(Object.assign({}, candidate, { confirmed: true }));
|
|
2989
|
+
continue;
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
// 'found-alive': kill it now. 'unknown': never coerced into either
|
|
2993
|
+
// extreme up front — no kill is attempted without positive evidence,
|
|
2994
|
+
// but the poll below still gets a chance to observe a confirmed-dead
|
|
2995
|
+
// result before this attempt is reported unconfirmed.
|
|
2996
|
+
if (liveness.status === 'found-alive') {
|
|
2997
|
+
for (let p = 0; p < liveness.pids.length; p++) {
|
|
2998
|
+
_killProcessTreeByPidSync(liveness.pids[p]);
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
const confirmed = await _awaitConfirmedTermination(candidate.processIdentity, exeBasename);
|
|
3003
|
+
killAttempts.push(Object.assign({}, candidate, { confirmed: confirmed }));
|
|
3004
|
+
if (!confirmed) {
|
|
3005
|
+
terminationConfirmed = false;
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
return {
|
|
3010
|
+
protocolVersion: store.PROTOCOL_VERSION,
|
|
3011
|
+
runId: runId,
|
|
3012
|
+
requestAccepted: true,
|
|
3013
|
+
mode: mode,
|
|
3014
|
+
terminationConfirmed: terminationConfirmed,
|
|
3015
|
+
killAttempts: killAttempts,
|
|
3016
|
+
};
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
/**
|
|
3020
|
+
* Enforces Tech-Spec section 9's "reconcile/resume require no live competing
|
|
3021
|
+
* coordinator" precondition. Called at the very start of both reconcile()
|
|
3022
|
+
* and resume(), before any repair (registerCommitSHA/finalizeTaskCheckpoint)
|
|
3023
|
+
* — fails closed, so no partial repair is ever applied while a different,
|
|
3024
|
+
* live coordinator might also be acting on the same run.
|
|
3025
|
+
*
|
|
3026
|
+
* Decision table:
|
|
3027
|
+
* - No lease held at all: nothing to compete with — proceed.
|
|
3028
|
+
* - Lease held by the SAME `runId` as the caller: this is the caller
|
|
3029
|
+
* reconciling its own run (e.g. as part of its own resume flow, whether
|
|
3030
|
+
* called before or after it re-acquires the lease) — proceed.
|
|
3031
|
+
* - Lease held by a DIFFERENT runId: consult
|
|
3032
|
+
* ownership.checkOwnerLiveness(lease).
|
|
3033
|
+
* - alive === true: a different coordinator is actively live — refuse
|
|
3034
|
+
* with COMPETING_COORDINATOR_LIVE.
|
|
3035
|
+
* - alive === 'unknown': never treat unknown as safe-to-proceed (the
|
|
3036
|
+
* same invariant enforced everywhere else in this codebase, e.g.
|
|
3037
|
+
* ownership.reclaimLease's own LEASE_LIVENESS_UNKNOWN) — refuse with
|
|
3038
|
+
* COMPETING_COORDINATOR_LIVENESS_UNKNOWN.
|
|
3039
|
+
* - alive === false (confirmed dead): proceed. reconcile/resume are
|
|
3040
|
+
* read-mostly + idempotent-repair operations, not a reclaim of the
|
|
3041
|
+
* lease itself — none of their classification/repair work requires
|
|
3042
|
+
* actually holding the lease, so a confirmed-dead old coordinator's
|
|
3043
|
+
* still-present lease file is not a blocker here. This function
|
|
3044
|
+
* deliberately does NOT call ownership.reclaimLease — reclaiming the
|
|
3045
|
+
* lease is a separate, explicit operator/coordinator decision made
|
|
3046
|
+
* elsewhere, not an implicit side effect of reconciling evidence.
|
|
3047
|
+
*
|
|
3048
|
+
* @param {string} executionRoot
|
|
3049
|
+
* @param {string} runId - The calling run's own runId (args.runId).
|
|
3050
|
+
* @param {string} callerName - 'reconcile' or 'resume', used only to make
|
|
3051
|
+
* the thrown error message identify which command refused to proceed.
|
|
3052
|
+
* @throws {Error} COMPETING_COORDINATOR_LIVE, COMPETING_COORDINATOR_LIVENESS_UNKNOWN
|
|
3053
|
+
*/
|
|
3054
|
+
function _ensureNoLiveCompetingCoordinator(executionRoot, runId, callerName) {
|
|
3055
|
+
const lease = ownership.readLease(executionRoot);
|
|
3056
|
+
if (!lease || lease.runId === runId) {
|
|
3057
|
+
return;
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
const liveness = ownership.checkOwnerLiveness(lease);
|
|
3061
|
+
if (liveness.alive === true) {
|
|
3062
|
+
throw _err(
|
|
3063
|
+
'COMPETING_COORDINATOR_LIVE',
|
|
3064
|
+
callerName + ': refusing to proceed — a different coordinator (runId "' + lease.runId +
|
|
3065
|
+
'", host ' + lease.host + ', pid ' + lease.pid + ') currently holds the repo-wide execution ' +
|
|
3066
|
+
'lease at ' + executionRoot + ' and is confirmed live: ' + liveness.reason
|
|
3067
|
+
);
|
|
3068
|
+
}
|
|
3069
|
+
if (liveness.alive !== false) {
|
|
3070
|
+
throw _err(
|
|
3071
|
+
'COMPETING_COORDINATOR_LIVENESS_UNKNOWN',
|
|
3072
|
+
callerName + ': refusing to proceed — a different coordinator (runId "' + lease.runId +
|
|
3073
|
+
'", host ' + lease.host + ', pid ' + lease.pid + ') currently holds the repo-wide execution ' +
|
|
3074
|
+
'lease at ' + executionRoot + ' and its liveness could not be conclusively determined: ' +
|
|
3075
|
+
liveness.reason
|
|
3076
|
+
);
|
|
3077
|
+
}
|
|
3078
|
+
// liveness.alive === false (confirmed dead): fall through and proceed.
|
|
3079
|
+
// Never reclaim the lease here — see doc comment above.
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
/**
|
|
3083
|
+
* `reconcile` command (US-06-TASK-BE-01). Loads persisted state and
|
|
3084
|
+
* evidence (receipts, intents, registered commit SHAs) and classifies every
|
|
3085
|
+
* task's current attempt per the Tech-Spec section 9 edge-cases table (see
|
|
3086
|
+
* the file-level "reconcile/resume evidence classification" comment above
|
|
3087
|
+
* _classifyTask for exactly which rows are fully covered here versus
|
|
3088
|
+
* explicitly deferred to US-06-TASK-BE-02). Applies ONLY two kinds of
|
|
3089
|
+
* idempotent repair, both reusing an already-existing primitive:
|
|
3090
|
+
* - registerCommitSHA, when a checkpoint-prepared attempt's taskRef tip is
|
|
3091
|
+
* found to be an exact match for its own persisted checkpoint intent
|
|
3092
|
+
* (narrow, tip-only recovery — see scope comment above);
|
|
3093
|
+
* - finalizeTaskCheckpoint, when a committed attempt's registered SHA is
|
|
3094
|
+
* confirmed reachable on taskRef but the task's overall status was not
|
|
3095
|
+
* yet advanced to 'checkpointed'.
|
|
3096
|
+
* Never dispatches new work, never touches a task that was never started
|
|
3097
|
+
* (status 'pending' with zero attempts), never reinterprets approvals, never
|
|
3098
|
+
* cleans resources. If `args.planDigest` is supplied and does not match the
|
|
3099
|
+
* run's own recorded state.planDigest, every task is reported blocked
|
|
3100
|
+
* ('blocked-plan-changed') and no repair is attempted for any task (Tech-Spec:
|
|
3101
|
+
* "Plan/config changed -> Block until approved successor plan").
|
|
3102
|
+
*
|
|
3103
|
+
* Enforces "no live competing coordinator" (Tech-Spec section 9) via
|
|
3104
|
+
* _ensureNoLiveCompetingCoordinator, called first — before store.readState,
|
|
3105
|
+
* before any classification, before any repair. See that function's own doc
|
|
3106
|
+
* comment for the exact decision table (no lease / same runId / different-
|
|
3107
|
+
* but-dead runId all proceed; different-and-live-or-unknown refuses).
|
|
3108
|
+
*
|
|
3109
|
+
* @param {object} args
|
|
3110
|
+
* @param {string} args.executionRoot - Absolute path to
|
|
3111
|
+
* <common-git-dir>/ai-toolkit/execution.
|
|
3112
|
+
* @param {string} args.runId - UUID of the run to reconcile.
|
|
3113
|
+
* @param {string} args.projectDir - Absolute path to the Git worktree/repo
|
|
3114
|
+
* the commit-reachability checks run in.
|
|
3115
|
+
* @param {string} args.taskRef - The feature/task ref every task's commits
|
|
3116
|
+
* are checked against (N=1 model only — see file-level scope comment).
|
|
3117
|
+
* @param {string} [args.planDigest] - The currently-approved plan's digest
|
|
3118
|
+
* (e.g. from plan.createPlanSnapshot). When supplied and it differs from
|
|
3119
|
+
* the run's recorded planDigest, every task is reported blocked and no
|
|
3120
|
+
* repair is attempted.
|
|
3121
|
+
* @param {string} [args.exeBasename] - Basename of the real spawned
|
|
3122
|
+
* executable to match against for worker-liveness checks
|
|
3123
|
+
* (_findLiveTaggedProcess, US-06-TASK-BE-02). Defaults to
|
|
3124
|
+
* DEFAULT_TAGGED_PROCESS_EXE_BASENAME ('claude.exe'); tests override this
|
|
3125
|
+
* to whatever real, short-lived child process they spawn under test.
|
|
3126
|
+
* @returns {Promise<object>} Versioned result object:
|
|
3127
|
+
* { protocolVersion, runId, repairsApplied: [{ taskId, action }],
|
|
3128
|
+
* classifications: [{ taskId, classification, action }] } — the last
|
|
3129
|
+
* field is additive (beyond the documented minimum shape above) so callers
|
|
3130
|
+
* get the full per-task classification, not just applied repairs.
|
|
3131
|
+
* @throws {Error} DISPATCH_VALIDATION_ERROR for malformed input;
|
|
3132
|
+
* COMPETING_COORDINATOR_LIVE / COMPETING_COORDINATOR_LIVENESS_UNKNOWN when
|
|
3133
|
+
* a different, live-or-unknown-liveness coordinator holds the repo-wide
|
|
3134
|
+
* execution lease; any error store.readState throws (STATE_NOT_FOUND,
|
|
3135
|
+
* STATE_CORRUPTED) propagates unmodified — a corrupted/missing run is
|
|
3136
|
+
* never silently defaulted.
|
|
3137
|
+
*/
|
|
3138
|
+
async function reconcile(args) {
|
|
3139
|
+
args = args || {};
|
|
3140
|
+
_requireNonEmptyString(args.executionRoot, 'reconcile: args.executionRoot');
|
|
3141
|
+
_requireNonEmptyString(args.runId, 'reconcile: args.runId');
|
|
3142
|
+
_requireNonEmptyString(args.projectDir, 'reconcile: args.projectDir');
|
|
3143
|
+
_requireNonEmptyString(args.taskRef, 'reconcile: args.taskRef');
|
|
3144
|
+
if (args.planDigest !== undefined && args.planDigest !== null) {
|
|
3145
|
+
_requireNonEmptyString(args.planDigest, 'reconcile: args.planDigest');
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
const executionRoot = args.executionRoot;
|
|
3149
|
+
const runId = args.runId;
|
|
3150
|
+
|
|
3151
|
+
// Fail closed BEFORE any repair — see _ensureNoLiveCompetingCoordinator's
|
|
3152
|
+
// own doc comment for the exact decision table.
|
|
3153
|
+
_ensureNoLiveCompetingCoordinator(executionRoot, runId, 'reconcile');
|
|
3154
|
+
|
|
3155
|
+
const ctx = {
|
|
3156
|
+
executionRoot: executionRoot,
|
|
3157
|
+
runId: runId,
|
|
3158
|
+
projectDir: args.projectDir,
|
|
3159
|
+
taskRef: args.taskRef,
|
|
3160
|
+
// US-06-TASK-BE-02: basename of the real spawned executable to match
|
|
3161
|
+
// against when checking worker liveness (_findLiveTaggedProcess). Tests
|
|
3162
|
+
// override this to the real, short-lived child process they spawn under
|
|
3163
|
+
// test (e.g. 'node.exe') — see the DEFAULT_TAGGED_PROCESS_EXE_BASENAME
|
|
3164
|
+
// file comment above IS_WINDOWS for why production defaults to
|
|
3165
|
+
// 'claude.exe' rather than 'node.exe'.
|
|
3166
|
+
exeBasename: typeof args.exeBasename === 'string' && args.exeBasename.length > 0
|
|
3167
|
+
? args.exeBasename
|
|
3168
|
+
: DEFAULT_TAGGED_PROCESS_EXE_BASENAME,
|
|
3169
|
+
};
|
|
3170
|
+
|
|
3171
|
+
// Propagates STATE_NOT_FOUND / STATE_CORRUPTED unmodified — never
|
|
3172
|
+
// fabricates an empty run for a corrupted/missing one.
|
|
3173
|
+
const state = store.readState(executionRoot, runId);
|
|
3174
|
+
|
|
3175
|
+
const planChanged = typeof args.planDigest === 'string' && args.planDigest !== state.planDigest;
|
|
3176
|
+
|
|
3177
|
+
const repairsApplied = [];
|
|
3178
|
+
const classifications = [];
|
|
3179
|
+
|
|
3180
|
+
Object.keys(state.tasks).sort().forEach(function (taskId) {
|
|
3181
|
+
if (planChanged) {
|
|
3182
|
+
classifications.push({ taskId: taskId, classification: 'blocked', action: 'blocked-plan-changed' });
|
|
3183
|
+
return;
|
|
3184
|
+
}
|
|
3185
|
+
const task = state.tasks[taskId];
|
|
3186
|
+
const result = _classifyTask(ctx, taskId, task);
|
|
3187
|
+
classifications.push({ taskId: taskId, classification: result.classification, action: result.action });
|
|
3188
|
+
if (result.repairAction) {
|
|
3189
|
+
repairsApplied.push({ taskId: taskId, action: result.repairAction });
|
|
3190
|
+
}
|
|
3191
|
+
});
|
|
3192
|
+
|
|
3193
|
+
return {
|
|
3194
|
+
protocolVersion: store.PROTOCOL_VERSION,
|
|
3195
|
+
runId: runId,
|
|
3196
|
+
repairsApplied: repairsApplied,
|
|
3197
|
+
classifications: classifications,
|
|
3198
|
+
};
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3201
|
+
/**
|
|
3202
|
+
* `resume` command (US-06-TASK-BE-01). Enforces "no live competing
|
|
3203
|
+
* coordinator" itself (see _ensureNoLiveCompetingCoordinator) before doing
|
|
3204
|
+
* anything else, then calls reconcile() (which enforces the same precondition
|
|
3205
|
+
* again — cheap and read-only, and keeps reconcile correct as a standalone
|
|
3206
|
+
* entry point too) to apply its idempotent repairs, then re-reads state and
|
|
3207
|
+
* reports, for every task, its current status plus the next-safe-action
|
|
3208
|
+
* classification reconcile already computed. Does NOT dispatch new work
|
|
3209
|
+
* itself — no run loop exists yet (the real dispatch loop is
|
|
3210
|
+
* US-07-TASK-BE-01, execute()); resume's job here ends at "reconcile, then
|
|
3211
|
+
* report what is safe to continue".
|
|
3212
|
+
*
|
|
3213
|
+
* @param {object} args - same shape as reconcile's args (forwarded as-is).
|
|
3214
|
+
* @param {string} args.executionRoot
|
|
3215
|
+
* @param {string} args.runId
|
|
3216
|
+
* @param {string} args.projectDir
|
|
3217
|
+
* @param {string} args.taskRef
|
|
3218
|
+
* @param {string} [args.planDigest]
|
|
3219
|
+
* @returns {Promise<object>} Versioned result object:
|
|
3220
|
+
* { protocolVersion, runId, runStatus, tasks: [{ taskId, status,
|
|
3221
|
+
* nextAction }] } — `nextAction` is additive (beyond the documented
|
|
3222
|
+
* minimum `{ taskId, status }` shape above), taken from reconcile's own
|
|
3223
|
+
* per-task classification.
|
|
3224
|
+
* @throws {Error} Same as reconcile — propagated unmodified, including
|
|
3225
|
+
* COMPETING_COORDINATOR_LIVE / COMPETING_COORDINATOR_LIVENESS_UNKNOWN.
|
|
3226
|
+
*/
|
|
3227
|
+
async function resume(args) {
|
|
3228
|
+
args = args || {};
|
|
3229
|
+
_requireNonEmptyString(args.executionRoot, 'resume: args.executionRoot');
|
|
3230
|
+
_requireNonEmptyString(args.runId, 'resume: args.runId');
|
|
3231
|
+
_requireNonEmptyString(args.projectDir, 'resume: args.projectDir');
|
|
3232
|
+
_requireNonEmptyString(args.taskRef, 'resume: args.taskRef');
|
|
3233
|
+
|
|
3234
|
+
// Fail closed BEFORE any repair — same precondition reconcile() enforces
|
|
3235
|
+
// (and will enforce again just below); checked here too so resume fails
|
|
3236
|
+
// closed even if its own delegation to reconcile ever changes.
|
|
3237
|
+
_ensureNoLiveCompetingCoordinator(args.executionRoot, args.runId, 'resume');
|
|
3238
|
+
|
|
3239
|
+
const reconcileResult = await reconcile(args);
|
|
3240
|
+
|
|
3241
|
+
const state = store.readState(args.executionRoot, args.runId);
|
|
3242
|
+
|
|
3243
|
+
const classificationByTaskId = {};
|
|
3244
|
+
reconcileResult.classifications.forEach(function (c) { classificationByTaskId[c.taskId] = c; });
|
|
3245
|
+
|
|
3246
|
+
const tasks = Object.keys(state.tasks).sort().map(function (taskId) {
|
|
3247
|
+
const task = state.tasks[taskId];
|
|
3248
|
+
const classification = classificationByTaskId[taskId];
|
|
3249
|
+
return {
|
|
3250
|
+
taskId: taskId,
|
|
3251
|
+
status: task.status,
|
|
3252
|
+
nextAction: classification ? classification.action : 'none',
|
|
3253
|
+
};
|
|
3254
|
+
});
|
|
3255
|
+
|
|
3256
|
+
return {
|
|
3257
|
+
protocolVersion: store.PROTOCOL_VERSION,
|
|
3258
|
+
runId: args.runId,
|
|
3259
|
+
runStatus: state.runStatus,
|
|
3260
|
+
tasks: tasks,
|
|
3261
|
+
repairsApplied: reconcileResult.repairsApplied,
|
|
3262
|
+
};
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
/**
|
|
3266
|
+
* Evaluates ONE task's persisted evidence (checkpoint, commit, review, diff,
|
|
3267
|
+
* worker liveness) and returns the classification/action reconcile()/
|
|
3268
|
+
* resume() already compute per task, for a single task in isolation
|
|
3269
|
+
* (US-06-TASK-BE-02; completes the Work Breakdown outcome "evaluates
|
|
3270
|
+
* persisted evidence ... and determines: no-reimplementation, reconcile-SHA,
|
|
3271
|
+
* recheck-verification, preserve-diff, or block"). Built directly on top of
|
|
3272
|
+
* reconcile()'s own per-task dispatch (_classifyTask and its helpers) rather
|
|
3273
|
+
* than duplicating any evidence-reading logic — this function's whole
|
|
3274
|
+
* contribution over calling reconcile() for the same run is scoping the
|
|
3275
|
+
* question to one task and returning its full classification result object
|
|
3276
|
+
* (including additive fields like hasPartialEvidence, not just the
|
|
3277
|
+
* {taskId, classification, action} triple reconcile()'s own summary keeps).
|
|
3278
|
+
*
|
|
3279
|
+
* Read-only for the specific case evaluated here EXCEPT for the same two
|
|
3280
|
+
* idempotent repairs reconcile() itself may apply (registerCommitSHA,
|
|
3281
|
+
* finalizeTaskCheckpoint) when this task's evidence matches one of those two
|
|
3282
|
+
* rows — this function does not introduce a new "read-only" mode separate
|
|
3283
|
+
* from reconcile()'s own documented repair behavior.
|
|
3284
|
+
*
|
|
3285
|
+
* Does NOT enforce "no live competing coordinator" itself — a single-task
|
|
3286
|
+
* evidence read is intentionally lighter-weight than reconcile()/resume()'s
|
|
3287
|
+
* own repo-wide precondition; callers that need that guarantee should call
|
|
3288
|
+
* reconcile()/resume() instead, or add it themselves before calling this
|
|
3289
|
+
* function repeatedly in a loop.
|
|
3290
|
+
*
|
|
3291
|
+
* @param {object} args
|
|
3292
|
+
* @param {string} args.executionRoot - Absolute path to
|
|
3293
|
+
* <common-git-dir>/ai-toolkit/execution.
|
|
3294
|
+
* @param {string} args.runId - UUID of the run to evaluate.
|
|
3295
|
+
* @param {object} args.task - Plan task object (plan.js shape): at least
|
|
3296
|
+
* `id`, matching a task already recorded in the run's state.
|
|
3297
|
+
* @param {string} args.projectDir - Absolute path to the Git worktree/repo
|
|
3298
|
+
* the commit-reachability checks run in.
|
|
3299
|
+
* @param {string} args.taskRef - The feature/task ref this task's commits
|
|
3300
|
+
* are checked against (N=1 model only — see file-level scope comment above
|
|
3301
|
+
* the reconcile/resume evidence classification section).
|
|
3302
|
+
* @param {string} [args.exeBasename] - Basename of the real spawned
|
|
3303
|
+
* executable to match against for worker-liveness checks. Defaults to
|
|
3304
|
+
* DEFAULT_TAGGED_PROCESS_EXE_BASENAME ('claude.exe').
|
|
3305
|
+
* @returns {Promise<{taskId: string, classification: string, action: string,
|
|
3306
|
+
* repairAction?: string, hasPartialEvidence?: boolean}>}
|
|
3307
|
+
* @throws {Error} DISPATCH_VALIDATION_ERROR for malformed input;
|
|
3308
|
+
* STATE_TASK_NOT_FOUND when the task is not recorded in the run's state;
|
|
3309
|
+
* any error store.readState throws (STATE_NOT_FOUND, STATE_CORRUPTED)
|
|
3310
|
+
* propagates unmodified.
|
|
3311
|
+
*/
|
|
3312
|
+
async function evaluateTaskEvidence(args) {
|
|
3313
|
+
args = args || {};
|
|
3314
|
+
_requireNonEmptyString(args.executionRoot, 'evaluateTaskEvidence: args.executionRoot');
|
|
3315
|
+
_requireNonEmptyString(args.runId, 'evaluateTaskEvidence: args.runId');
|
|
3316
|
+
_requireNonEmptyString(args.projectDir, 'evaluateTaskEvidence: args.projectDir');
|
|
3317
|
+
_requireNonEmptyString(args.taskRef, 'evaluateTaskEvidence: args.taskRef');
|
|
3318
|
+
if (args.task == null || typeof args.task !== 'object') {
|
|
3319
|
+
throw _err('DISPATCH_VALIDATION_ERROR', 'evaluateTaskEvidence: args.task must be an object');
|
|
3320
|
+
}
|
|
3321
|
+
_requireNonEmptyString(args.task.id, 'evaluateTaskEvidence: args.task.id');
|
|
3322
|
+
|
|
3323
|
+
const executionRoot = args.executionRoot;
|
|
3324
|
+
const runId = args.runId;
|
|
3325
|
+
const taskId = args.task.id;
|
|
3326
|
+
|
|
3327
|
+
const state = store.readState(executionRoot, runId);
|
|
3328
|
+
const task = state.tasks[taskId];
|
|
3329
|
+
if (!task) {
|
|
3330
|
+
throw _err('STATE_TASK_NOT_FOUND', 'evaluateTaskEvidence: task "' + taskId + '" not found');
|
|
3331
|
+
}
|
|
3332
|
+
|
|
3333
|
+
const ctx = {
|
|
3334
|
+
executionRoot: executionRoot,
|
|
3335
|
+
runId: runId,
|
|
3336
|
+
projectDir: args.projectDir,
|
|
3337
|
+
taskRef: args.taskRef,
|
|
3338
|
+
exeBasename: typeof args.exeBasename === 'string' && args.exeBasename.length > 0
|
|
3339
|
+
? args.exeBasename
|
|
3340
|
+
: DEFAULT_TAGGED_PROCESS_EXE_BASENAME,
|
|
3341
|
+
};
|
|
3342
|
+
|
|
3343
|
+
const result = _classifyTask(ctx, taskId, task);
|
|
3344
|
+
return Object.assign({ taskId: taskId }, result);
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3347
|
+
// ── replan (US-06-TASK-BE-03) ────────────────────────────────────────────────
|
|
3348
|
+
//
|
|
3349
|
+
// Derives the successor feature's directory from its feature.md path, using
|
|
3350
|
+
// the EXACT same separator-agnostic convention src/claude/workflows/
|
|
3351
|
+
// pm-phase2.js's own deriveFeatureDir uses: feature directory = parent of the
|
|
3352
|
+
// feature.md path; strip a trailing "<sep>feature.md" tail (case-insensitive,
|
|
3353
|
+
// POSIX "/" or Windows "\"), or just a trailing separator if the path is
|
|
3354
|
+
// already a directory. Duplicated here rather than imported — pm-phase2.js is
|
|
3355
|
+
// a Claude Code Workflow script (run by the workflow runtime, not a
|
|
3356
|
+
// requirable Node module; see AGENTS.md's "Workflow script pattern"), so this
|
|
3357
|
+
// library module cannot depend on it. Keeping the exact same regex is the
|
|
3358
|
+
// point: both places must agree on where a feature's artifacts live.
|
|
3359
|
+
function _deriveFeatureDir(featurePath) {
|
|
3360
|
+
return /[/\\]feature\.md$/i.test(featurePath)
|
|
3361
|
+
? featurePath.replace(/[/\\]feature\.md$/i, '')
|
|
3362
|
+
: featurePath.replace(/[/\\]+$/, '');
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
// Same convention pm-phase2.js uses: PREFIX is the first "<UPPER>-<digits>"
|
|
3366
|
+
// substring found in the feature directory name (e.g. "FTR-018", "ASSESS-001").
|
|
3367
|
+
function _derivePrefix(featureDir) {
|
|
3368
|
+
const m = featureDir.match(/([A-Z]+-\d+)/);
|
|
3369
|
+
return m ? m[1] : null;
|
|
3370
|
+
}
|
|
3371
|
+
|
|
3372
|
+
// Minimal, deliberately non-generic Markdown check: does a case-sensitive
|
|
3373
|
+
// "## Gate 2" heading appear anywhere in the successor's Approvals.md?
|
|
3374
|
+
//
|
|
3375
|
+
// This codebase has no existing generic Markdown-section-reader/parser to
|
|
3376
|
+
// reuse for this: every other "verify Gate 2 is present in Approvals.md"
|
|
3377
|
+
// pre-condition in this repo (e.g. src/claude/skills/implement-feature/
|
|
3378
|
+
// SKILL.md Step 4/6, AGENTS.md's "Approval Gate Protocol") is carried out by
|
|
3379
|
+
// an LLM agent reading the file in the main loop, not by a JS utility — there
|
|
3380
|
+
// is nothing to import. plan.js's own section extraction
|
|
3381
|
+
// (_extractTaskDetailsSection) is specific to the "## Task Details" section's
|
|
3382
|
+
// very different internal structure (task anchors, fenced verification
|
|
3383
|
+
// blocks) and would be a wrong-shaped tool bent to a one-line question here.
|
|
3384
|
+
// Writing a full generic Markdown-section parser purely to answer "did a
|
|
3385
|
+
// '## Gate 2' heading ever get appended to this file" would be
|
|
3386
|
+
// over-engineering for what this function actually needs. `^## Gate 2`
|
|
3387
|
+
// anchored to a line start (multiline, case-sensitive — no 'i' flag) is the
|
|
3388
|
+
// literal heading text this codebase's own Approvals.md template always
|
|
3389
|
+
// writes verbatim (see this feature's own FTR-018-Approvals.md: "## Gate 2 —
|
|
3390
|
+
// Work Breakdown Approval").
|
|
3391
|
+
const _GATE_2_HEADING_RE = /^## Gate 2\b/m;
|
|
3392
|
+
|
|
3393
|
+
function _hasGate2Approval(approvalsText) {
|
|
3394
|
+
return _GATE_2_HEADING_RE.test(approvalsText);
|
|
3395
|
+
}
|
|
3396
|
+
|
|
3397
|
+
/**
|
|
3398
|
+
* `replan` command (US-06-TASK-BE-03; completes US-06 "resume from persisted
|
|
3399
|
+
* evidence and reconcile without re-execution" for the branch-off case: an
|
|
3400
|
+
* original run that cannot simply resume because its plan itself must
|
|
3401
|
+
* change). Tech-Spec section 9 (Gate-1-binding, carried into this Work
|
|
3402
|
+
* Breakdown): "Replan does not grant approval. First produce a proposed
|
|
3403
|
+
* successor plan and human decision outside the executor. Then replan
|
|
3404
|
+
* command validates new Gate 2/revision evidence and records old->new digest
|
|
3405
|
+
* and task mapping. Original run becomes superseded only after no live
|
|
3406
|
+
* workers and evidence retained. Carry completion only for unchanged
|
|
3407
|
+
* task/context with reachable verified checkpoints and explicit approved
|
|
3408
|
+
* mapping; otherwise require a new task identity. No data deletion,
|
|
3409
|
+
* automatic branch rewrite or reset of failed-attempt counters to evade
|
|
3410
|
+
* policy."
|
|
3411
|
+
*
|
|
3412
|
+
* This function grants NO approval itself (the successor's Gate 2 approval
|
|
3413
|
+
* must already exist on disk, produced by the normal implement-feature main-
|
|
3414
|
+
* loop gate protocol — see AGENTS.md's "Approval Gate Protocol" — entirely
|
|
3415
|
+
* outside this executor) and does NOT dispatch, start, or touch the
|
|
3416
|
+
* successor run in any way: its job ends at validating + recording the
|
|
3417
|
+
* mapping the caller already decided, and superseding the original run.
|
|
3418
|
+
* `taskMapping` is an explicit, caller-supplied old->new task ID mapping —
|
|
3419
|
+
* this function never invents one.
|
|
3420
|
+
*
|
|
3421
|
+
* Ordering, fail-closed at every step, in order (no step above ever mutates
|
|
3422
|
+
* anything once a later step can still fail):
|
|
3423
|
+
* 1. Successor Gate 2 / revision approval evidence must exist on disk (a
|
|
3424
|
+
* "## Gate 2" heading in the successor's Approvals.md — see
|
|
3425
|
+
* _hasGate2Approval above for why this minimal check, not a generic
|
|
3426
|
+
* parser). Missing/unreadable Approvals.md or no such heading:
|
|
3427
|
+
* SUCCESSOR_NOT_APPROVED, before anything else is read.
|
|
3428
|
+
* 2. The successor's plan snapshot is built via plan.createPlanSnapshot
|
|
3429
|
+
* from its REAL Work-Breakdown.md/.csv files on disk (US-01-TASK-BE-04,
|
|
3430
|
+
* already implemented) — this both proves the successor plan parses/
|
|
3431
|
+
* validates cleanly and yields its planDigest. Missing MD/CSV files:
|
|
3432
|
+
* SUCCESSOR_PLAN_NOT_FOUND. A malformed MD/CSV: the underlying
|
|
3433
|
+
* PLAN_PARSE_ERROR propagates unmodified.
|
|
3434
|
+
* 3. The ORIGINAL run's state is read (store.readState — STATE_NOT_FOUND /
|
|
3435
|
+
* STATE_CORRUPTED propagate unmodified; a corrupted/missing original
|
|
3436
|
+
* run is never silently defaulted).
|
|
3437
|
+
* 4. Every taskMapping entry is validated against BOTH sides: oldTaskId
|
|
3438
|
+
* must exist in the original run's state.tasks; newTaskId must exist in
|
|
3439
|
+
* the successor snapshot's tasks. Any entry referencing an unknown ID on
|
|
3440
|
+
* either side fails the WHOLE call closed with
|
|
3441
|
+
* REPLAN_UNKNOWN_TASK_MAPPING, before any mutation and before any
|
|
3442
|
+
* carry-completion eligibility is even computed.
|
|
3443
|
+
* 5. For each validated pair, carry-completion eligibility is computed —
|
|
3444
|
+
* read-only, never touches the old task's own evidence either way. A
|
|
3445
|
+
* pair is eligible ONLY when the OLD task's status is already
|
|
3446
|
+
* 'checkpointed' or 'integrated' AND its last attempt's registered
|
|
3447
|
+
* originalSha is a valid SHA reachable on `taskRef` right now (reusing
|
|
3448
|
+
* this same file's own _isCommitReachableOnRef — the identical
|
|
3449
|
+
* merge-base-based check finalizeTaskCheckpoint/reconcile already use;
|
|
3450
|
+
* not duplicated as a new helper). Anything else (wrong status, no
|
|
3451
|
+
* attempt, no/invalid SHA, unreachable, or the reachability check itself
|
|
3452
|
+
* failing) is recorded with carriesCompletion=false — "otherwise require
|
|
3453
|
+
* a new task identity" — never guessed into true.
|
|
3454
|
+
* 6. "No live workers" gate: EVERY task/attempt in the ORIGINAL run (not
|
|
3455
|
+
* just the mapped ones) whose attempt carries a recorded
|
|
3456
|
+
* processIdentity is checked via US-06-TASK-BE-02's own
|
|
3457
|
+
* _findLiveTaggedProcess. Any 'found-alive' OR 'unknown' result refuses
|
|
3458
|
+
* the whole call closed with LIVE_WORKER_BLOCKS_REPLAN — 'unknown' is
|
|
3459
|
+
* never treated as safe, mirroring _ensureNoLiveCompetingCoordinator's
|
|
3460
|
+
* and _classifyLiveOrDeadWorkerAttempt's identical "never assume dead"
|
|
3461
|
+
* discipline elsewhere in this file. Only proceeds once every checked
|
|
3462
|
+
* attempt is 'confirmed-not-found', or has no processIdentity at all
|
|
3463
|
+
* (nothing to check).
|
|
3464
|
+
* 7. Only once 1-6 have all succeeded: the old->new plan digest mapping
|
|
3465
|
+
* (including each pair's carriesCompletion flag) is persisted as a
|
|
3466
|
+
* durable, immutable record via store.js's existing writeIntent
|
|
3467
|
+
* primitive — reused rather than inventing a new persistence primitive,
|
|
3468
|
+
* the same immutable-record-by-id mechanism checkpoint intents already
|
|
3469
|
+
* use (see store.js's persistCheckpointIntent-adjacent writeIntent). A
|
|
3470
|
+
* single fixed id ("replan") is used per original run: at most one
|
|
3471
|
+
* supersession decision is ever recorded for a given run; a replayed
|
|
3472
|
+
* call with IDENTICAL inputs is an idempotent no-op (writeIntent's own
|
|
3473
|
+
* identical-content replay rule), while a conflicting second call
|
|
3474
|
+
* (different successor/mapping) fails closed with INTENT_CONFLICT
|
|
3475
|
+
* rather than silently overwriting the first decision. This record is
|
|
3476
|
+
* written BEFORE the run status changes below, so a conflicting replay
|
|
3477
|
+
* never leaves the original run superseded without its mapping record
|
|
3478
|
+
* durably alongside it.
|
|
3479
|
+
* 8. Only after the record is durably persisted: the original run's status
|
|
3480
|
+
* is set to 'superseded' (store.js's existing RUN_STATUSES value — not a
|
|
3481
|
+
* new status) via State.setRunStatus + store.writeState. Nothing about
|
|
3482
|
+
* any task's own attempts/evidence is deleted, rewritten, or reset —
|
|
3483
|
+
* this function never touches task/attempt records at all, only the
|
|
3484
|
+
* run-level runStatus field and the new immutable replan record.
|
|
3485
|
+
*
|
|
3486
|
+
* Does NOT dispatch, start, or touch the successor run in any way (per its
|
|
3487
|
+
* own scope — see the module-level file comment's original stub doc, which
|
|
3488
|
+
* this replaces).
|
|
3489
|
+
*
|
|
3490
|
+
* @param {object} args
|
|
3491
|
+
* @param {string} args.executionRoot - Absolute path to
|
|
3492
|
+
* <common-git-dir>/ai-toolkit/execution.
|
|
3493
|
+
* @param {string} args.runId - UUID of the original run being replanned;
|
|
3494
|
+
* must already hold a valid state.json (STATE_NOT_FOUND otherwise).
|
|
3495
|
+
* @param {string} args.projectDir - Absolute path to the Git worktree/repo
|
|
3496
|
+
* the commit-reachability checks (step 5) run in.
|
|
3497
|
+
* @param {string} args.taskRef - The feature/task ref carry-completion
|
|
3498
|
+
* eligibility's reachability check runs against (N=1 model only — same
|
|
3499
|
+
* scope boundary as reconcile()/finalizeTaskCheckpoint()).
|
|
3500
|
+
* @param {string} args.feature - Path to the SUCCESSOR feature's feature.md,
|
|
3501
|
+
* used to derive its PREFIX/directory (same convention as
|
|
3502
|
+
* src/claude/workflows/pm-phase2.js) and locate its Approvals.md and
|
|
3503
|
+
* Work-Breakdown.md/.csv.
|
|
3504
|
+
* @param {Array<{oldTaskId: string, newTaskId: string}>} args.taskMapping -
|
|
3505
|
+
* Caller-supplied explicit old->new task ID mapping (the human/caller's own
|
|
3506
|
+
* decision — this function validates and records it, never invents one).
|
|
3507
|
+
* @param {string} [args.exeBasename] - Basename of the real spawned
|
|
3508
|
+
* executable to match against for worker-liveness checks
|
|
3509
|
+
* (_findLiveTaggedProcess). Defaults to DEFAULT_TAGGED_PROCESS_EXE_BASENAME
|
|
3510
|
+
* ('claude.exe'); tests override this to whatever real, short-lived child
|
|
3511
|
+
* process they spawn under test.
|
|
3512
|
+
* @returns {Promise<{protocolVersion: number, originalRunId: string,
|
|
3513
|
+
* successorPlanDigest: string,
|
|
3514
|
+
* taskMapping: Array<{oldTaskId: string, newTaskId: string,
|
|
3515
|
+
* carriesCompletion: boolean}>}>}
|
|
3516
|
+
* @throws {Error} DISPATCH_VALIDATION_ERROR for malformed input;
|
|
3517
|
+
* SUCCESSOR_NOT_APPROVED when the successor's Approvals.md is missing,
|
|
3518
|
+
* unreadable, or has no "## Gate 2" section; SUCCESSOR_PLAN_NOT_FOUND when
|
|
3519
|
+
* the successor's Work-Breakdown.md/.csv cannot be read; the underlying
|
|
3520
|
+
* PLAN_PARSE_ERROR when the successor plan itself is malformed;
|
|
3521
|
+
* REPLAN_UNKNOWN_TASK_MAPPING when any mapping entry references an unknown
|
|
3522
|
+
* ID on either side; LIVE_WORKER_BLOCKS_REPLAN when any original-run
|
|
3523
|
+
* attempt's worker liveness is not confirmed-not-found;
|
|
3524
|
+
* STATE_NOT_FOUND / STATE_CORRUPTED propagated unmodified from
|
|
3525
|
+
* store.readState; INTENT_CONFLICT propagated unmodified from
|
|
3526
|
+
* store.writeIntent when a conflicting replan record already exists for
|
|
3527
|
+
* this run.
|
|
3528
|
+
*/
|
|
3529
|
+
async function replan(args) {
|
|
3530
|
+
args = args || {};
|
|
3531
|
+
|
|
3532
|
+
_requireNonEmptyString(args.executionRoot, 'replan: args.executionRoot');
|
|
3533
|
+
_requireNonEmptyString(args.runId, 'replan: args.runId');
|
|
3534
|
+
_requireNonEmptyString(args.projectDir, 'replan: args.projectDir');
|
|
3535
|
+
_requireNonEmptyString(args.taskRef, 'replan: args.taskRef');
|
|
3536
|
+
_requireNonEmptyString(args.feature, 'replan: args.feature');
|
|
3537
|
+
if (!Array.isArray(args.taskMapping)) {
|
|
3538
|
+
throw _err('DISPATCH_VALIDATION_ERROR', 'replan: args.taskMapping must be an array');
|
|
3539
|
+
}
|
|
3540
|
+
args.taskMapping.forEach(function (entry, idx) {
|
|
3541
|
+
if (
|
|
3542
|
+
entry == null || typeof entry !== 'object' ||
|
|
3543
|
+
typeof entry.oldTaskId !== 'string' || entry.oldTaskId.length === 0 ||
|
|
3544
|
+
typeof entry.newTaskId !== 'string' || entry.newTaskId.length === 0
|
|
3545
|
+
) {
|
|
3546
|
+
throw _err(
|
|
3547
|
+
'DISPATCH_VALIDATION_ERROR',
|
|
3548
|
+
'replan: args.taskMapping[' + idx + '] must be an object with non-empty string "oldTaskId"/"newTaskId"'
|
|
3549
|
+
);
|
|
3550
|
+
}
|
|
3551
|
+
});
|
|
3552
|
+
|
|
3553
|
+
const executionRoot = args.executionRoot;
|
|
3554
|
+
const runId = args.runId;
|
|
3555
|
+
const projectDir = args.projectDir;
|
|
3556
|
+
const taskRef = args.taskRef;
|
|
3557
|
+
const exeBasename = typeof args.exeBasename === 'string' && args.exeBasename.length > 0
|
|
3558
|
+
? args.exeBasename
|
|
3559
|
+
: DEFAULT_TAGGED_PROCESS_EXE_BASENAME;
|
|
3560
|
+
|
|
3561
|
+
// 1. Successor Gate 2 / revision approval evidence — fail closed FIRST,
|
|
3562
|
+
// before reading anything about the original run or the successor's plan
|
|
3563
|
+
// files.
|
|
3564
|
+
const successorFeatureDir = _deriveFeatureDir(args.feature);
|
|
3565
|
+
const successorPrefix = _derivePrefix(successorFeatureDir);
|
|
3566
|
+
if (!successorPrefix) {
|
|
3567
|
+
throw _err(
|
|
3568
|
+
'SUCCESSOR_NOT_APPROVED',
|
|
3569
|
+
'replan: could not derive a PREFIX (e.g. "FTR-018") from successor feature directory "' +
|
|
3570
|
+
successorFeatureDir + '" — refusing to treat an unidentifiable feature as approved'
|
|
3571
|
+
);
|
|
3572
|
+
}
|
|
3573
|
+
const successorApprovalsPath = path.join(successorFeatureDir, successorPrefix + '-Approvals.md');
|
|
3574
|
+
let successorApprovalsText;
|
|
3575
|
+
try {
|
|
3576
|
+
successorApprovalsText = fs.readFileSync(successorApprovalsPath, 'utf8');
|
|
3577
|
+
} catch (err) {
|
|
3578
|
+
throw _err(
|
|
3579
|
+
'SUCCESSOR_NOT_APPROVED',
|
|
3580
|
+
'replan: successor Approvals.md not found or unreadable at "' + successorApprovalsPath +
|
|
3581
|
+
'" — refusing to replan onto an unapproved successor: ' + err.message
|
|
3582
|
+
);
|
|
3583
|
+
}
|
|
3584
|
+
if (!_hasGate2Approval(successorApprovalsText)) {
|
|
3585
|
+
throw _err(
|
|
3586
|
+
'SUCCESSOR_NOT_APPROVED',
|
|
3587
|
+
'replan: successor Approvals.md at "' + successorApprovalsPath +
|
|
3588
|
+
'" has no "## Gate 2" approval section — refusing to replan onto an unapproved successor plan'
|
|
3589
|
+
);
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
// 2. Successor plan snapshot — real Work-Breakdown.md/.csv on disk, same
|
|
3593
|
+
// parser pipeline every other run uses (plan.createPlanSnapshot). Its own
|
|
3594
|
+
// PLAN_PARSE_ERROR propagates unmodified for a malformed successor plan.
|
|
3595
|
+
const successorMdPath = path.join(successorFeatureDir, successorPrefix + '-Work-Breakdown.md');
|
|
3596
|
+
const successorCsvPath = path.join(successorFeatureDir, successorPrefix + '-Work-Breakdown.csv');
|
|
3597
|
+
let successorMd;
|
|
3598
|
+
let successorCsv;
|
|
3599
|
+
try {
|
|
3600
|
+
successorMd = fs.readFileSync(successorMdPath, 'utf8');
|
|
3601
|
+
successorCsv = fs.readFileSync(successorCsvPath, 'utf8');
|
|
3602
|
+
} catch (err) {
|
|
3603
|
+
throw _err(
|
|
3604
|
+
'SUCCESSOR_PLAN_NOT_FOUND',
|
|
3605
|
+
'replan: successor Work Breakdown MD/CSV not found at "' + successorMdPath + '" / "' +
|
|
3606
|
+
successorCsvPath + '": ' + err.message
|
|
3607
|
+
);
|
|
3608
|
+
}
|
|
3609
|
+
const successorSnapshot = plan.createPlanSnapshot(successorMd, successorCsv);
|
|
3610
|
+
const successorTaskIds = new Set(successorSnapshot.tasks.map(function (t) { return t.id; }));
|
|
3611
|
+
|
|
3612
|
+
// 3. Load the ORIGINAL run's state — STATE_NOT_FOUND/STATE_CORRUPTED
|
|
3613
|
+
// propagate unmodified.
|
|
3614
|
+
const state = store.readState(executionRoot, runId);
|
|
3615
|
+
|
|
3616
|
+
// 4. Validate the caller-supplied taskMapping against BOTH sides before
|
|
3617
|
+
// touching anything — fail closed on the first unknown ID found.
|
|
3618
|
+
args.taskMapping.forEach(function (entry) {
|
|
3619
|
+
if (!Object.prototype.hasOwnProperty.call(state.tasks, entry.oldTaskId)) {
|
|
3620
|
+
throw _err(
|
|
3621
|
+
'REPLAN_UNKNOWN_TASK_MAPPING',
|
|
3622
|
+
'replan: taskMapping references oldTaskId "' + entry.oldTaskId +
|
|
3623
|
+
'", which does not exist in original run "' + runId + '"'
|
|
3624
|
+
);
|
|
3625
|
+
}
|
|
3626
|
+
if (!successorTaskIds.has(entry.newTaskId)) {
|
|
3627
|
+
throw _err(
|
|
3628
|
+
'REPLAN_UNKNOWN_TASK_MAPPING',
|
|
3629
|
+
'replan: taskMapping references newTaskId "' + entry.newTaskId +
|
|
3630
|
+
'", which does not exist in the successor plan snapshot (' + successorMdPath + ')'
|
|
3631
|
+
);
|
|
3632
|
+
}
|
|
3633
|
+
});
|
|
3634
|
+
|
|
3635
|
+
// 5. Carry-completion eligibility per mapped pair — read-only, never
|
|
3636
|
+
// touches the old task's own evidence either way regardless of outcome.
|
|
3637
|
+
const taskMappingResult = args.taskMapping.map(function (entry) {
|
|
3638
|
+
const oldTask = state.tasks[entry.oldTaskId];
|
|
3639
|
+
let carriesCompletion = false;
|
|
3640
|
+
if (oldTask.status === 'checkpointed' || oldTask.status === 'integrated') {
|
|
3641
|
+
const lastAttempt = Array.isArray(oldTask.attempts) && oldTask.attempts.length > 0
|
|
3642
|
+
? oldTask.attempts[oldTask.attempts.length - 1]
|
|
3643
|
+
: null;
|
|
3644
|
+
if (lastAttempt && typeof lastAttempt.originalSha === 'string' && SHA_PATTERN.test(lastAttempt.originalSha)) {
|
|
3645
|
+
try {
|
|
3646
|
+
carriesCompletion = _isCommitReachableOnRef(projectDir, lastAttempt.originalSha, taskRef);
|
|
3647
|
+
} catch (_reachErr) {
|
|
3648
|
+
carriesCompletion = false; // never guess — an unverifiable check is never eligible
|
|
3649
|
+
}
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
return { oldTaskId: entry.oldTaskId, newTaskId: entry.newTaskId, carriesCompletion: carriesCompletion };
|
|
3653
|
+
});
|
|
3654
|
+
|
|
3655
|
+
// 6. "No live workers" gate over EVERY task/attempt in the ORIGINAL run
|
|
3656
|
+
// (not just the mapped ones) — reuses US-06-TASK-BE-02's own
|
|
3657
|
+
// _findLiveTaggedProcess exactly like reconcile()'s own worker-liveness
|
|
3658
|
+
// classification. 'unknown' blocks exactly like 'found-alive' — never
|
|
3659
|
+
// assume dead.
|
|
3660
|
+
Object.keys(state.tasks).sort().forEach(function (taskId) {
|
|
3661
|
+
const task = state.tasks[taskId];
|
|
3662
|
+
(Array.isArray(task.attempts) ? task.attempts : []).forEach(function (attempt) {
|
|
3663
|
+
if (typeof attempt.processIdentity !== 'string' || attempt.processIdentity.length === 0) {
|
|
3664
|
+
return; // nothing recorded to check
|
|
3665
|
+
}
|
|
3666
|
+
const liveness = _findLiveTaggedProcess(attempt.processIdentity, exeBasename);
|
|
3667
|
+
if (liveness.status !== 'confirmed-not-found') {
|
|
3668
|
+
throw _err(
|
|
3669
|
+
'LIVE_WORKER_BLOCKS_REPLAN',
|
|
3670
|
+
'replan: refusing to supersede run "' + runId + '" — task "' + taskId + '" attempt ' +
|
|
3671
|
+
attempt.number + '\'s worker liveness is "' + liveness.status + '" (' + liveness.reason +
|
|
3672
|
+
'); a run may only be superseded once every worker is confirmed not running'
|
|
3673
|
+
);
|
|
3674
|
+
}
|
|
3675
|
+
});
|
|
3676
|
+
});
|
|
3677
|
+
|
|
3678
|
+
// 7. Persist the durable old->new mapping record BEFORE changing run
|
|
3679
|
+
// status, so a conflicting replay (INTENT_CONFLICT) never leaves the
|
|
3680
|
+
// original run superseded without its mapping record durably alongside it.
|
|
3681
|
+
// Deliberately no wall-clock timestamp field here (unlike, say, a receipt's
|
|
3682
|
+
// startedAt/endedAt): store.js's _writeImmutableRecord treats a replay as
|
|
3683
|
+
// idempotent only when its content is byte-for-byte identical to what is
|
|
3684
|
+
// already recorded (see git.js's own persistCheckpointIntent, which
|
|
3685
|
+
// likewise carries no timestamp in its intent payload) — a fresh
|
|
3686
|
+
// Date().toISOString() on every call would make even a genuine, intentional
|
|
3687
|
+
// replay of the exact same replan decision always look like a conflicting
|
|
3688
|
+
// second decision.
|
|
3689
|
+
const replanRecord = {
|
|
3690
|
+
protocolVersion: store.PROTOCOL_VERSION,
|
|
3691
|
+
originalRunId: runId,
|
|
3692
|
+
successorFeaturePath: args.feature,
|
|
3693
|
+
successorPlanDigest: successorSnapshot.planDigest,
|
|
3694
|
+
taskMapping: taskMappingResult,
|
|
3695
|
+
};
|
|
3696
|
+
store.writeIntent(executionRoot, runId, 'replan', replanRecord);
|
|
3697
|
+
|
|
3698
|
+
// 8. Only now: mark the original run superseded. Task/attempt evidence is
|
|
3699
|
+
// never touched by this function, in either direction.
|
|
3700
|
+
const supersededState = state.setRunStatus('superseded');
|
|
3701
|
+
store.writeState(executionRoot, runId, supersededState);
|
|
3702
|
+
|
|
3703
|
+
return {
|
|
3704
|
+
protocolVersion: store.PROTOCOL_VERSION,
|
|
3705
|
+
originalRunId: runId,
|
|
3706
|
+
successorPlanDigest: successorSnapshot.planDigest,
|
|
3707
|
+
taskMapping: taskMappingResult,
|
|
3708
|
+
};
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3711
|
+
module.exports = {
|
|
3712
|
+
execute,
|
|
3713
|
+
status,
|
|
3714
|
+
diagnose,
|
|
3715
|
+
stop,
|
|
3716
|
+
reconcile,
|
|
3717
|
+
resume,
|
|
3718
|
+
replan,
|
|
3719
|
+
dispatchTaskAttempt,
|
|
3720
|
+
runVerifications,
|
|
3721
|
+
runReview,
|
|
3722
|
+
finalizeTaskCheckpoint,
|
|
3723
|
+
evaluateTaskEvidence,
|
|
3724
|
+
createTaskWorktree,
|
|
3725
|
+
createSlotPool,
|
|
3726
|
+
DEFAULT_TAGGED_PROCESS_EXE_BASENAME,
|
|
3727
|
+
_buildProcessTag,
|
|
3728
|
+
_findLiveTaggedProcess,
|
|
3729
|
+
};
|