@dtlabs/ai-toolkit 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -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 +102 -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 +225 -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/agents/gaia-validate-feature-docs.md +31 -28
- 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,590 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Ownership/lease module (FTR-018).
|
|
4
|
+
//
|
|
5
|
+
// Repo-wide execution lease protocol (US-02-TASK-BE-01): acquireLease,
|
|
6
|
+
// releaseLease, readLease. owner.json is the single exclusive-coordinator
|
|
7
|
+
// marker for an execution root (<absolute-common-git-dir>/ai-toolkit/
|
|
8
|
+
// execution/owner.json, per FTR-018-Tech-Spec.md section 5). Unlike
|
|
9
|
+
// runs/<runId>/state.json, this file is not keyed by runId: at most one
|
|
10
|
+
// lease may exist for the whole repo at any time, regardless of which
|
|
11
|
+
// feature/run is trying to acquire it.
|
|
12
|
+
//
|
|
13
|
+
// Stale lock detection via process liveness (checkOwnerLiveness) is
|
|
14
|
+
// US-02-TASK-BE-02 — implemented below. It reuses the E-02 evidence-spike
|
|
15
|
+
// technique verified in
|
|
16
|
+
// internal_docs/features/FTR-018-deterministic-task-execution-checkpoints-and-resume/evidence/E-02-process-supervision/:
|
|
17
|
+
// worker identity is `host:pid:startTime` (never bare pid — a pid can be
|
|
18
|
+
// recycled by the OS for an unrelated process, so start-time is the
|
|
19
|
+
// anti-reuse guard), and Windows liveness is confirmed via a
|
|
20
|
+
// `Get-CimInstance Win32_Process` query matching pid AND exact start time.
|
|
21
|
+
// checkOwnerLiveness only ever reports positive evidence: 'alive: false'
|
|
22
|
+
// requires a confirmed-dead process query result, never age/staleness
|
|
23
|
+
// alone; anything inconclusive (host mismatch, non-Windows, unreadable
|
|
24
|
+
// start time, failed OS query) is reported as 'alive: unknown' and must
|
|
25
|
+
// never be treated as a reclaim signal.
|
|
26
|
+
//
|
|
27
|
+
// The atomic compare-and-swap ownership guard used by actual reclaim
|
|
28
|
+
// (createOwnershipGuard, US-02-TASK-BE-03) is implemented below. It guards a
|
|
29
|
+
// multi-step transition (read lease -> check liveness -> reclaim) with an
|
|
30
|
+
// exclusive `mkdir` on <executionRoot>/ownership-guard (per
|
|
31
|
+
// FTR-018-Tech-Spec.md section 5), the same atomicity spirit as the 'wx'
|
|
32
|
+
// exclusive-create pattern used for the lease file itself, but for a
|
|
33
|
+
// transition rather than a single file write. reclaimLease only ever
|
|
34
|
+
// recovers a lease that checkOwnerLiveness has positively confirmed dead
|
|
35
|
+
// (alive === false) — 'unknown' and true are both refused, per the Gate-1
|
|
36
|
+
// binding constraint carried into this Work Breakdown ("no implicit
|
|
37
|
+
// cross-platform guarantee", "never invent missing values"). Full stale
|
|
38
|
+
// recovery (cross-checking all registered workers per
|
|
39
|
+
// FTR-018-Tech-Spec.md section 5) is out of scope here; that belongs to the
|
|
40
|
+
// worker-registration tasks in US-03.
|
|
41
|
+
|
|
42
|
+
const fs = require('fs');
|
|
43
|
+
const os = require('os');
|
|
44
|
+
const path = require('path');
|
|
45
|
+
const crypto = require('crypto');
|
|
46
|
+
const { spawnSync } = require('child_process');
|
|
47
|
+
|
|
48
|
+
function _err(code, message) {
|
|
49
|
+
const e = new Error(message);
|
|
50
|
+
e.code = code;
|
|
51
|
+
return e;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function _requireNonEmptyString(value, label) {
|
|
55
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
56
|
+
throw _err('LEASE_VALIDATION_ERROR', label + ' must be a non-empty string');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function _leaseFilePath(executionRoot) {
|
|
61
|
+
return path.join(executionRoot, 'owner.json');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const GUARD_DIR_NAME = 'ownership-guard';
|
|
65
|
+
|
|
66
|
+
function _guardDirPath(executionRoot) {
|
|
67
|
+
return path.join(executionRoot, GUARD_DIR_NAME);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function _ensureDirSync(dir) {
|
|
71
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function _fsyncDirSync(dir) {
|
|
75
|
+
let dfd;
|
|
76
|
+
try {
|
|
77
|
+
dfd = fs.openSync(dir, 'r');
|
|
78
|
+
fs.fsyncSync(dfd);
|
|
79
|
+
} catch (_) {
|
|
80
|
+
// Directory-handle fsync is not supported on every platform/filesystem
|
|
81
|
+
// (notably Windows). Best-effort only.
|
|
82
|
+
} finally {
|
|
83
|
+
if (dfd !== undefined) {
|
|
84
|
+
try { fs.closeSync(dfd); } catch (_) {}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Exclusive create: fails with EEXIST if the file already exists. This is
|
|
90
|
+
// the atomic primitive that prevents two concurrent acquireLease() calls
|
|
91
|
+
// from both succeeding — unlike store.js's temp-file-then-rename pattern
|
|
92
|
+
// (which makes *replacing* a file atomic), a lock needs the *first create*
|
|
93
|
+
// itself to be exclusive, which is exactly what the 'wx' flag guarantees.
|
|
94
|
+
function _createExclusiveSync(filePath, contents) {
|
|
95
|
+
const fd = fs.openSync(filePath, 'wx');
|
|
96
|
+
try {
|
|
97
|
+
fs.writeSync(fd, contents);
|
|
98
|
+
fs.fsyncSync(fd);
|
|
99
|
+
} finally {
|
|
100
|
+
fs.closeSync(fd);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Same-directory exclusive temp file, complete write, flush, close, rename,
|
|
105
|
+
// directory flush where supported. Mirrors the atomic-write protocol used by
|
|
106
|
+
// store.js's _atomicWriteFileSync — used here to atomically *replace* an
|
|
107
|
+
// existing lease file (reclaim), as opposed to _createExclusiveSync above
|
|
108
|
+
// which atomically creates a lease file that must not already exist.
|
|
109
|
+
function _atomicReplaceFileSync(filePath, contents) {
|
|
110
|
+
const dir = path.dirname(filePath);
|
|
111
|
+
const tmpName = '.' + path.basename(filePath) + '.tmp-' + process.pid + '-' +
|
|
112
|
+
Date.now() + '-' + crypto.randomBytes(4).toString('hex');
|
|
113
|
+
const tmpPath = path.join(dir, tmpName);
|
|
114
|
+
|
|
115
|
+
let fd;
|
|
116
|
+
try {
|
|
117
|
+
fd = fs.openSync(tmpPath, 'w');
|
|
118
|
+
fs.writeSync(fd, contents);
|
|
119
|
+
fs.fsyncSync(fd);
|
|
120
|
+
fs.closeSync(fd);
|
|
121
|
+
fd = undefined;
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (fd !== undefined) {
|
|
124
|
+
try { fs.closeSync(fd); } catch (_) {}
|
|
125
|
+
}
|
|
126
|
+
try { fs.unlinkSync(tmpPath); } catch (_) {}
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
fs.renameSync(tmpPath, filePath);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
try { fs.unlinkSync(tmpPath); } catch (_) {}
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
_fsyncDirSync(dir);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function _isWindows() {
|
|
141
|
+
return process.platform === 'win32';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Queries the OS for the current creation time of `pid`, using the same
|
|
145
|
+
// technique verified in the E-02 evidence spike (Windows-qualified only —
|
|
146
|
+
// see harness-treekill-dedup.js / harness-registration-window.js):
|
|
147
|
+
// `Get-CimInstance Win32_Process` filtered by ProcessId, reading
|
|
148
|
+
// CreationDate as a file-time-UTC integer so two captures of the same
|
|
149
|
+
// still-running process compare exactly equal.
|
|
150
|
+
//
|
|
151
|
+
// Returns:
|
|
152
|
+
// - { found: true, startTime: '<fileTimeUtc digits>' } — pid exists, start time read
|
|
153
|
+
// - { found: false } — OS confirms no such pid (positive dead evidence)
|
|
154
|
+
// - null — query is not trustworthy (non-Windows, pid not a
|
|
155
|
+
// safe integer, powershell unavailable/failed, or unexpected output) — callers MUST treat this as
|
|
156
|
+
// inconclusive, never as evidence of death.
|
|
157
|
+
function _queryProcessStartTimeSync(pid) {
|
|
158
|
+
if (!_isWindows()) return null;
|
|
159
|
+
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return null;
|
|
160
|
+
|
|
161
|
+
let result;
|
|
162
|
+
try {
|
|
163
|
+
result = spawnSync(
|
|
164
|
+
'powershell',
|
|
165
|
+
[
|
|
166
|
+
'-NoProfile',
|
|
167
|
+
'-NonInteractive',
|
|
168
|
+
'-Command',
|
|
169
|
+
'$p = Get-CimInstance Win32_Process -Filter "ProcessId=' + pid + '" -ErrorAction SilentlyContinue; ' +
|
|
170
|
+
'if ($p) { $p.CreationDate.ToFileTimeUtc() } else { "NOTFOUND" }',
|
|
171
|
+
],
|
|
172
|
+
{ encoding: 'utf8', windowsHide: true }
|
|
173
|
+
);
|
|
174
|
+
} catch (_) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!result || result.error || result.status !== 0 || typeof result.stdout !== 'string') {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const out = result.stdout.trim();
|
|
183
|
+
if (out === 'NOTFOUND') return { found: false };
|
|
184
|
+
if (/^-?\d+$/.test(out)) return { found: true, startTime: out };
|
|
185
|
+
return null; // unexpected output shape — cannot be trusted either way
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function _readLeaseFileSync(filePath) {
|
|
189
|
+
let raw;
|
|
190
|
+
try {
|
|
191
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (err.code === 'ENOENT') return null;
|
|
194
|
+
throw err;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
return JSON.parse(raw);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
throw _err('LEASE_CORRUPTED', 'lease file at ' + filePath + ' is corrupted: ' + err.message);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Acquires the single repo-wide execution lease for `executionRoot`.
|
|
205
|
+
*
|
|
206
|
+
* Fails closed with LEASE_HELD if a lease already exists — this task does
|
|
207
|
+
* not implement stale-lease recovery (see US-02-TASK-BE-02/03); a second
|
|
208
|
+
* coordinator must stop with a structured diagnosis rather than duplicate
|
|
209
|
+
* work (AC-08).
|
|
210
|
+
*
|
|
211
|
+
* @param {string} executionRoot - Absolute path to
|
|
212
|
+
* <common-git-dir>/ai-toolkit/execution.
|
|
213
|
+
* @param {string} runId - UUID of the run requesting ownership.
|
|
214
|
+
* @param {object} [opts]
|
|
215
|
+
* @param {string} [opts.host] - Override for host identity (defaults to
|
|
216
|
+
* os.hostname()); exposed for tests, real callers should omit it.
|
|
217
|
+
* @param {number} [opts.pid] - Override for process identity (defaults to
|
|
218
|
+
* process.pid); exposed for tests, real callers should omit it.
|
|
219
|
+
* @returns {{nonce:string, host:string, pid:number, runId:string,
|
|
220
|
+
* generation:number, acquiredAt:string, startTime:?string}}
|
|
221
|
+
*
|
|
222
|
+
* NOTE (US-02-TASK-BE-02 schema extension): `startTime` is captured here so
|
|
223
|
+
* checkOwnerLiveness can later confirm this exact process (not a
|
|
224
|
+
* pid-recycled impostor) is still the lease holder — see the E-02 evidence
|
|
225
|
+
* spike technique referenced at the top of this file. It is a Windows
|
|
226
|
+
* file-time-UTC string on Windows, or `null` when start time could not be
|
|
227
|
+
* captured (non-Windows platform, or the OS query failed) — a `null`
|
|
228
|
+
* startTime makes checkOwnerLiveness return 'unknown' rather than 'alive',
|
|
229
|
+
* never 'dead'. This field is new and pre-implementation-only (no released
|
|
230
|
+
* lease files exist yet to be backward-compatible with).
|
|
231
|
+
*/
|
|
232
|
+
function acquireLease(executionRoot, runId, opts) {
|
|
233
|
+
_requireNonEmptyString(executionRoot, 'acquireLease: executionRoot');
|
|
234
|
+
_requireNonEmptyString(runId, 'acquireLease: runId');
|
|
235
|
+
opts = opts || {};
|
|
236
|
+
|
|
237
|
+
_ensureDirSync(executionRoot);
|
|
238
|
+
const filePath = _leaseFilePath(executionRoot);
|
|
239
|
+
|
|
240
|
+
const leasePid = opts.pid != null ? opts.pid : process.pid;
|
|
241
|
+
const startTimeProbe = _queryProcessStartTimeSync(leasePid);
|
|
242
|
+
|
|
243
|
+
const lease = {
|
|
244
|
+
nonce: crypto.randomBytes(16).toString('hex'),
|
|
245
|
+
host: opts.host || os.hostname(),
|
|
246
|
+
pid: leasePid,
|
|
247
|
+
runId: runId,
|
|
248
|
+
generation: 0,
|
|
249
|
+
acquiredAt: new Date().toISOString(),
|
|
250
|
+
startTime: startTimeProbe && startTimeProbe.found ? startTimeProbe.startTime : null,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
_createExclusiveSync(filePath, JSON.stringify(lease, null, 2));
|
|
255
|
+
} catch (err) {
|
|
256
|
+
if (err.code === 'EEXIST') {
|
|
257
|
+
const existing = _readLeaseFileSync(filePath);
|
|
258
|
+
const e = _err(
|
|
259
|
+
'LEASE_HELD',
|
|
260
|
+
'acquireLease: a repo-wide execution lease is already held' +
|
|
261
|
+
(existing
|
|
262
|
+
? ' by runId "' + existing.runId + '" (host ' + existing.host + ', pid ' + existing.pid + ')'
|
|
263
|
+
: ' but the existing lease file could not be read')
|
|
264
|
+
);
|
|
265
|
+
e.existingLease = existing;
|
|
266
|
+
throw e;
|
|
267
|
+
}
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
_fsyncDirSync(executionRoot);
|
|
272
|
+
|
|
273
|
+
const readBack = _readLeaseFileSync(filePath);
|
|
274
|
+
if (!readBack || readBack.nonce !== lease.nonce) {
|
|
275
|
+
throw _err(
|
|
276
|
+
'LEASE_READBACK_MISMATCH',
|
|
277
|
+
'acquireLease: readback verification failed for runId "' + runId + '"'
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return readBack;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Releases the repo-wide execution lease, but only if the caller's identity
|
|
285
|
+
* (runId + nonce, both as returned by acquireLease) matches the lease
|
|
286
|
+
* currently on disk. Never removes a lease belonging to a different holder
|
|
287
|
+
* — normal entrants must not unlink another owner's lease.
|
|
288
|
+
*
|
|
289
|
+
* @param {string} executionRoot
|
|
290
|
+
* @param {string} runId
|
|
291
|
+
* @param {string} nonce
|
|
292
|
+
* @returns {true}
|
|
293
|
+
*/
|
|
294
|
+
function releaseLease(executionRoot, runId, nonce) {
|
|
295
|
+
_requireNonEmptyString(executionRoot, 'releaseLease: executionRoot');
|
|
296
|
+
_requireNonEmptyString(runId, 'releaseLease: runId');
|
|
297
|
+
_requireNonEmptyString(nonce, 'releaseLease: nonce');
|
|
298
|
+
|
|
299
|
+
const filePath = _leaseFilePath(executionRoot);
|
|
300
|
+
const existing = _readLeaseFileSync(filePath);
|
|
301
|
+
|
|
302
|
+
if (!existing) {
|
|
303
|
+
throw _err('LEASE_NOT_FOUND', 'releaseLease: no lease found at ' + filePath);
|
|
304
|
+
}
|
|
305
|
+
if (existing.runId !== runId || existing.nonce !== nonce) {
|
|
306
|
+
throw _err(
|
|
307
|
+
'LEASE_NOT_OWNER',
|
|
308
|
+
'releaseLease: caller (runId "' + runId + '") does not hold the current lease ' +
|
|
309
|
+
'(held by runId "' + existing.runId + '")'
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
fs.unlinkSync(filePath);
|
|
314
|
+
_fsyncDirSync(executionRoot);
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Read-only accessor: returns the current lease contents, or null if no
|
|
320
|
+
* lease is held. Never mutates state.
|
|
321
|
+
*
|
|
322
|
+
* @param {string} executionRoot
|
|
323
|
+
* @returns {?object}
|
|
324
|
+
*/
|
|
325
|
+
function readLease(executionRoot) {
|
|
326
|
+
_requireNonEmptyString(executionRoot, 'readLease: executionRoot');
|
|
327
|
+
return _readLeaseFileSync(_leaseFilePath(executionRoot));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Creates an ownership guard bound to `executionRoot`: a handle providing an
|
|
332
|
+
* exclusive, atomic "transition" primitive over the SAME executionRoot the
|
|
333
|
+
* lease lives in (<executionRoot>/ownership-guard, per
|
|
334
|
+
* FTR-018-Tech-Spec.md section 5). Any multi-step ownership transition
|
|
335
|
+
* (read lease -> check liveness -> reclaim) must run inside guard.transition()
|
|
336
|
+
* so no two transitions can interleave.
|
|
337
|
+
*
|
|
338
|
+
* The exclusivity primitive is `fs.mkdirSync` with no `{recursive: true}` on
|
|
339
|
+
* the guard directory: mkdir on an already-existing path throws EEXIST
|
|
340
|
+
* atomically (same spirit as this module's 'wx' exclusive-create pattern for
|
|
341
|
+
* the lease file, but guarding a transition rather than a single write).
|
|
342
|
+
*
|
|
343
|
+
* @param {string} executionRoot
|
|
344
|
+
* @returns {{transition: function(function(): *): *}}
|
|
345
|
+
*/
|
|
346
|
+
function createOwnershipGuard(executionRoot) {
|
|
347
|
+
_requireNonEmptyString(executionRoot, 'createOwnershipGuard: executionRoot');
|
|
348
|
+
const guardDir = _guardDirPath(executionRoot);
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
/**
|
|
352
|
+
* Runs `fn` under the guard's exclusive lock. Fails closed with
|
|
353
|
+
* GUARD_HELD (without calling `fn`) if another transition already holds
|
|
354
|
+
* the guard — this failure is never swallowed. The guard directory is
|
|
355
|
+
* always released (best-effort rmdir) in a `finally`, including when
|
|
356
|
+
* `fn` throws, so a crash mid-transition never leaves the lock behind
|
|
357
|
+
* forever; the rmdir itself is wrapped so a cleanup failure can never
|
|
358
|
+
* mask fn's own result/error.
|
|
359
|
+
*
|
|
360
|
+
* @param {function(): *} fn
|
|
361
|
+
* @returns {*} fn's return value
|
|
362
|
+
*/
|
|
363
|
+
transition(fn) {
|
|
364
|
+
_ensureDirSync(executionRoot);
|
|
365
|
+
try {
|
|
366
|
+
fs.mkdirSync(guardDir);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
if (err.code === 'EEXIST') {
|
|
369
|
+
throw _err(
|
|
370
|
+
'GUARD_HELD',
|
|
371
|
+
'createOwnershipGuard: another ownership transition is already in progress at ' + guardDir
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
throw err;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
return fn();
|
|
379
|
+
} finally {
|
|
380
|
+
try {
|
|
381
|
+
fs.rmdirSync(guardDir);
|
|
382
|
+
} catch (_) {
|
|
383
|
+
// Best-effort cleanup only, mirroring _fsyncDirSync's discipline
|
|
384
|
+
// elsewhere in this file: a cleanup failure here must never mask
|
|
385
|
+
// fn's own return value or thrown error.
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Reclaims the repo-wide execution lease for `newRunId`, but only when the
|
|
394
|
+
* current holder (if any) is positively confirmed dead by
|
|
395
|
+
* checkOwnerLiveness. The whole read -> check -> write sequence runs inside
|
|
396
|
+
* a single createOwnershipGuard(executionRoot).transition() call so no other
|
|
397
|
+
* reclaim/acquire/release attempt can interleave with it.
|
|
398
|
+
*
|
|
399
|
+
* Behavior:
|
|
400
|
+
* 1. No lease currently held: this degenerates into a normal first
|
|
401
|
+
* acquisition — delegates to acquireLease(executionRoot, newRunId,
|
|
402
|
+
* opts), still performed inside the guard's exclusive lock.
|
|
403
|
+
* 2. A lease exists: checkOwnerLiveness(existingLease) is consulted.
|
|
404
|
+
* - alive === false (confirmed dead): proceed to reclaim (step 3).
|
|
405
|
+
* - alive === true: throw LEASE_STILL_LIVE — never reclaim a live
|
|
406
|
+
* owner.
|
|
407
|
+
* - alive === 'unknown': throw LEASE_LIVENESS_UNKNOWN — never reclaim
|
|
408
|
+
* on an inconclusive check (host mismatch, non-Windows, missing
|
|
409
|
+
* startTime, failed OS query); age alone is never sufficient
|
|
410
|
+
* evidence.
|
|
411
|
+
* 3. Confirmed dead: atomically replaces the lease file with a new lease
|
|
412
|
+
* for `newRunId`. generation is the old lease's generation + 1 (0 if
|
|
413
|
+
* the existing lease had no valid non-negative-integer generation —
|
|
414
|
+
* the simplest rule that still always increases monotonically from
|
|
415
|
+
* whatever was last recorded).
|
|
416
|
+
* 4. Reads the new lease back and verifies its nonce matches what was
|
|
417
|
+
* just written (same discipline as acquireLease), throwing
|
|
418
|
+
* LEASE_READBACK_MISMATCH if it does not.
|
|
419
|
+
*
|
|
420
|
+
* @param {string} executionRoot
|
|
421
|
+
* @param {string} newRunId - UUID of the run requesting reclaim.
|
|
422
|
+
* @param {object} [opts]
|
|
423
|
+
* @param {string} [opts.host] - Override for host identity (tests only).
|
|
424
|
+
* @param {number} [opts.pid] - Override for process identity (tests only).
|
|
425
|
+
* @returns {object} the new lease, as read back from disk.
|
|
426
|
+
*/
|
|
427
|
+
function reclaimLease(executionRoot, newRunId, opts) {
|
|
428
|
+
_requireNonEmptyString(executionRoot, 'reclaimLease: executionRoot');
|
|
429
|
+
_requireNonEmptyString(newRunId, 'reclaimLease: newRunId');
|
|
430
|
+
opts = opts || {};
|
|
431
|
+
|
|
432
|
+
const guard = createOwnershipGuard(executionRoot);
|
|
433
|
+
|
|
434
|
+
return guard.transition(function () {
|
|
435
|
+
const filePath = _leaseFilePath(executionRoot);
|
|
436
|
+
const existing = _readLeaseFileSync(filePath);
|
|
437
|
+
|
|
438
|
+
if (!existing) {
|
|
439
|
+
return acquireLease(executionRoot, newRunId, opts);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const liveness = checkOwnerLiveness(existing, opts);
|
|
443
|
+
if (liveness.alive !== false) {
|
|
444
|
+
const code = liveness.alive === true ? 'LEASE_STILL_LIVE' : 'LEASE_LIVENESS_UNKNOWN';
|
|
445
|
+
throw _err(
|
|
446
|
+
code,
|
|
447
|
+
'reclaimLease: refusing to reclaim lease held by runId "' + existing.runId + '" ' +
|
|
448
|
+
'(host ' + existing.host + ', pid ' + existing.pid + ') — ' + liveness.reason
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const nextGeneration = Number.isInteger(existing.generation) && existing.generation >= 0
|
|
453
|
+
? existing.generation + 1
|
|
454
|
+
: 0;
|
|
455
|
+
|
|
456
|
+
const newPid = opts.pid != null ? opts.pid : process.pid;
|
|
457
|
+
const startTimeProbe = _queryProcessStartTimeSync(newPid);
|
|
458
|
+
|
|
459
|
+
const newLease = {
|
|
460
|
+
nonce: crypto.randomBytes(16).toString('hex'),
|
|
461
|
+
host: opts.host || os.hostname(),
|
|
462
|
+
pid: newPid,
|
|
463
|
+
runId: newRunId,
|
|
464
|
+
generation: nextGeneration,
|
|
465
|
+
acquiredAt: new Date().toISOString(),
|
|
466
|
+
startTime: startTimeProbe && startTimeProbe.found ? startTimeProbe.startTime : null,
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
_atomicReplaceFileSync(filePath, JSON.stringify(newLease, null, 2));
|
|
470
|
+
_fsyncDirSync(executionRoot);
|
|
471
|
+
|
|
472
|
+
const readBack = _readLeaseFileSync(filePath);
|
|
473
|
+
if (!readBack || readBack.nonce !== newLease.nonce) {
|
|
474
|
+
throw _err(
|
|
475
|
+
'LEASE_READBACK_MISMATCH',
|
|
476
|
+
'reclaimLease: readback verification failed for runId "' + newRunId + '"'
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
return readBack;
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Determines whether the process that recorded `lease` (as returned by
|
|
485
|
+
* acquireLease/readLease) is still the same live process, using the
|
|
486
|
+
* host:pid:startTime identity technique proven in the E-02 evidence spike
|
|
487
|
+
* (see the file-level comment above and
|
|
488
|
+
* internal_docs/features/FTR-018-deterministic-task-execution-checkpoints-and-resume/evidence/E-02-process-supervision/).
|
|
489
|
+
*
|
|
490
|
+
* This function NEVER infers death from age/staleness — only from a
|
|
491
|
+
* positive OS process-query result. Any condition that prevents a
|
|
492
|
+
* conclusive check (remote host, unsupported platform, missing startTime,
|
|
493
|
+
* a failed/untrusted OS query) yields `alive: 'unknown'`, which callers
|
|
494
|
+
* (reclaimLease, US-02-TASK-BE-03) must treat as "do not recover" — the
|
|
495
|
+
* same as `alive: true`. Only `alive: false` may ever justify recovery.
|
|
496
|
+
*
|
|
497
|
+
* Windows-only real check (per the Gate-1 E-02 qualification): on any
|
|
498
|
+
* other `process.platform`, this always returns `alive: 'unknown'` with an
|
|
499
|
+
* explanatory reason instead of attempting an unproven POSIX check.
|
|
500
|
+
*
|
|
501
|
+
* @param {{host:string, pid:number, startTime:?string}} lease - Lease
|
|
502
|
+
* object as returned by acquireLease/readLease.
|
|
503
|
+
* @param {object} [opts]
|
|
504
|
+
* @param {string} [opts.host] - Override for the current host identity
|
|
505
|
+
* (defaults to os.hostname()); exposed for tests.
|
|
506
|
+
* @returns {{alive: true|false|'unknown', reason: string}}
|
|
507
|
+
*/
|
|
508
|
+
function checkOwnerLiveness(lease, opts) {
|
|
509
|
+
opts = opts || {};
|
|
510
|
+
|
|
511
|
+
if (!lease || typeof lease !== 'object') {
|
|
512
|
+
throw _err('LEASE_VALIDATION_ERROR', 'checkOwnerLiveness: lease must be a non-empty object');
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const currentHost = opts.host || os.hostname();
|
|
516
|
+
|
|
517
|
+
if (lease.host !== currentHost) {
|
|
518
|
+
return {
|
|
519
|
+
alive: 'unknown',
|
|
520
|
+
reason:
|
|
521
|
+
'lease host "' + lease.host + '" does not match current host "' + currentHost +
|
|
522
|
+
'"; a remote host\'s processes cannot be queried locally',
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (!_isWindows()) {
|
|
527
|
+
return {
|
|
528
|
+
alive: 'unknown',
|
|
529
|
+
reason:
|
|
530
|
+
'process liveness checking is only qualified on Windows (E-02 evidence spike); ' +
|
|
531
|
+
'this platform ("' + process.platform + '") has no qualified liveness mechanism yet',
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (typeof lease.pid !== 'number' || !Number.isInteger(lease.pid) || lease.pid <= 0) {
|
|
536
|
+
return { alive: 'unknown', reason: 'lease.pid is missing or not a valid process id; cannot query process identity' };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (typeof lease.startTime !== 'string' || lease.startTime.length === 0) {
|
|
540
|
+
return {
|
|
541
|
+
alive: 'unknown',
|
|
542
|
+
reason:
|
|
543
|
+
'lease has no recorded startTime (captured before this identity check existed, or capture ' +
|
|
544
|
+
'failed at acquireLease time); a bare pid is not a safe identity check because pids can be ' +
|
|
545
|
+
'recycled by the OS, so liveness cannot be conclusively determined',
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const probe = _queryProcessStartTimeSync(lease.pid);
|
|
550
|
+
|
|
551
|
+
if (probe === null) {
|
|
552
|
+
return {
|
|
553
|
+
alive: 'unknown',
|
|
554
|
+
reason: 'the OS process query failed or returned an untrusted result; liveness cannot be conclusively determined',
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (probe.found === false) {
|
|
559
|
+
return {
|
|
560
|
+
alive: false,
|
|
561
|
+
reason: 'no process with pid ' + lease.pid + ' exists on host "' + currentHost + '" (confirmed via OS process query)',
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (probe.startTime === lease.startTime) {
|
|
566
|
+
return {
|
|
567
|
+
alive: true,
|
|
568
|
+
reason:
|
|
569
|
+
'pid ' + lease.pid + ' is running on host "' + currentHost + '" with a start time matching the ' +
|
|
570
|
+
'recorded lease (host:pid:startTime identity confirmed)',
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return {
|
|
575
|
+
alive: false,
|
|
576
|
+
reason:
|
|
577
|
+
'pid ' + lease.pid + ' exists but its current start time (' + probe.startTime + ') differs from the ' +
|
|
578
|
+
'recorded lease start time (' + lease.startTime + '); this is a different process reusing a recycled ' +
|
|
579
|
+
'pid, confirmed via OS process query',
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
module.exports = {
|
|
584
|
+
acquireLease,
|
|
585
|
+
releaseLease,
|
|
586
|
+
readLease,
|
|
587
|
+
reclaimLease,
|
|
588
|
+
checkOwnerLiveness,
|
|
589
|
+
createOwnershipGuard,
|
|
590
|
+
};
|