bmad-plus 0.14.0 → 0.16.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 +52 -0
- package/README.md +34 -15
- package/SECURITY.md +71 -0
- package/THIRD-PARTY-LICENSES.md +349 -0
- package/osint-agent-package/README.md +1 -1
- package/package.json +9 -3
- package/readme-international/README.de.md +14 -8
- package/readme-international/README.es.md +15 -9
- package/readme-international/README.fr.md +14 -8
- package/src/bmad-plus/agents/agent-architect-dev/SKILL.md +11 -13
- package/src/bmad-plus/agents/agent-orchestrator/SKILL.md +147 -8
- package/src/bmad-plus/agents/agent-quality/SKILL.md +41 -11
- package/src/bmad-plus/data/role-triggers.yaml +19 -0
- package/src/bmad-plus/module-help.csv +1 -0
- package/src/bmad-plus/module.yaml +1 -0
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/create-story.md +3 -1
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-story-checklist.md +2 -0
- package/src/bmad-plus/packs/pack-dev-studio/categories/implementation/dev-story.md +5 -1
- package/src/bmad-plus/packs/pack-memory/README.md +29 -4
- package/src/bmad-plus/packs/pack-memory/memory-orchestrator.md +21 -1
- package/src/bmad-plus/packs/pack-memory/shared/karpathy-guardrails.md +3 -3
- package/src/bmad-plus/packs/pack-memory/shared/memory-protocol.md +27 -3
- package/src/bmad-plus/packs/pack-memory/zecher-agent.md +18 -2
- package/src/bmad-plus/skills/bmad-plus-autopilot/SKILL.md +47 -10
- package/src/bmad-plus/skills/bmad-plus-parallel/SKILL.md +17 -3
- package/src/bmad-plus/skills/bmad-plus-sync/SKILL.md +76 -67
- package/src/bmad-plus/skills/bmad-plus-uat/SKILL.md +144 -0
- package/src/bmad-plus/skills/bmad-plus-uat/ref/uat-results.schema.json +60 -0
- package/src/bmad-plus/skills/bmad-plus-uat/ref/uat-spec.schema.json +121 -0
- package/src/bmad-plus/skills/bmad-plus-uat/ref/uat-triage.schema.json +60 -0
- package/src/bmad-plus/skills/bmad-plus-uat/template/page.html +552 -0
- package/src/bmad-plus/skills/bmad-plus-uat/template/strings.json +362 -0
- package/tools/build/check-install-contract.js +202 -4
- package/tools/build/generate.js +16 -0
- package/tools/build/generated-adapters/.codex/AGENTS.md +1 -1
- package/tools/build/generated-adapters/.cursor/rules/bmad-plus.mdc +1 -1
- package/tools/build/generated-adapters/.opencode/AGENTS.md +1 -1
- package/tools/build/generated-adapters/AGENTS.md +1 -1
- package/tools/build/generated-adapters/CLAUDE.md +1 -1
- package/tools/build/generated-adapters/CONVENTIONS.md +1 -1
- package/tools/build/generated-adapters/GEMINI.md +1 -1
- package/tools/cli/bmad-plus-cli.js +15 -12
- package/tools/cli/commands/doctor.js +1 -0
- package/tools/cli/commands/install.js +21 -2
- package/tools/cli/commands/memory-journal-cmd.js +119 -19
- package/tools/cli/commands/nexus.js +111 -0
- package/tools/cli/commands/uat.js +389 -0
- package/tools/cli/lib/README-memory-journal.md +19 -8
- package/tools/cli/lib/installation-health.js +6 -0
- package/tools/cli/lib/memory-journal.js +0 -0
- package/tools/cli/lib/memory-outcomes.js +293 -0
- package/tools/cli/lib/memory-store.js +139 -0
- package/tools/cli/lib/nexus-process.js +377 -0
- package/tools/cli/lib/nexus.js +1532 -0
- package/tools/cli/lib/pack-copy.js +39 -11
- package/tools/cli/lib/packs.js +17 -3
- package/tools/cli/lib/uat.js +869 -0
- package/tools/maintain/upstream-candidate.js +456 -0
- package/tools/release/publication-content.js +3 -1
- package/tools/release/supply-chain.js +282 -0
|
@@ -0,0 +1,1532 @@
|
|
|
1
|
+
/** Durable host-managed coordination. Worker claims never constitute check evidence. */
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
7
|
+
const { safeTarget } = require('../../build/generate-adapters');
|
|
8
|
+
const processBackend = require('./nexus-process');
|
|
9
|
+
const FRAMEWORK_VERSION = require('../../../package.json').version;
|
|
10
|
+
|
|
11
|
+
const BASE = '.bmad-plus/nexus';
|
|
12
|
+
const LOCK = BASE + '/writer.lock';
|
|
13
|
+
const RECOVERY_LOCK = BASE + '/recovery.lock';
|
|
14
|
+
const MAX_FILE_BYTES = 16 * 1024 * 1024;
|
|
15
|
+
const MAX_STATE_BYTES = 16 * 1024 * 1024;
|
|
16
|
+
const MAX_OUTPUT_BYTES = 64 * 1024;
|
|
17
|
+
const CHECK_ENVIRONMENT_KEYS = ['PATH', 'SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP', 'TMPDIR'];
|
|
18
|
+
const WINDOWS_ENVIRONMENT_KEYS = [
|
|
19
|
+
'HOMEDRIVE',
|
|
20
|
+
'HOMEPATH',
|
|
21
|
+
'LOGONSERVER',
|
|
22
|
+
'SYSTEMDRIVE',
|
|
23
|
+
'USERDOMAIN',
|
|
24
|
+
'USERNAME',
|
|
25
|
+
'USERPROFILE',
|
|
26
|
+
];
|
|
27
|
+
const CHECK_ENVIRONMENT_POLICY = 'minimal-local-v1';
|
|
28
|
+
const now = () => new Date().toISOString();
|
|
29
|
+
const hash = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
30
|
+
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
31
|
+
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
32
|
+
const fail = (message) => {
|
|
33
|
+
throw new Error('Nexus: ' + message);
|
|
34
|
+
};
|
|
35
|
+
const inScope = (file, scope) =>
|
|
36
|
+
file.toLowerCase() === scope.toLowerCase() ||
|
|
37
|
+
file.toLowerCase().startsWith(scope.toLowerCase() + '/');
|
|
38
|
+
const overlap = (a, b) => inScope(a, b) || inScope(b, a);
|
|
39
|
+
|
|
40
|
+
function checkEnvironment() {
|
|
41
|
+
const values = {};
|
|
42
|
+
const inherited = Object.keys(process.env).sort();
|
|
43
|
+
// libuv supplies these Windows identity variables even when env omits them.
|
|
44
|
+
// Capture their explicit non-secret values so receipts match the child.
|
|
45
|
+
const keys = [
|
|
46
|
+
...CHECK_ENVIRONMENT_KEYS,
|
|
47
|
+
...(process.platform === 'win32' ? WINDOWS_ENVIRONMENT_KEYS : []),
|
|
48
|
+
];
|
|
49
|
+
for (const key of keys) {
|
|
50
|
+
// Normalize key spelling ourselves: Windows variables are case-insensitive,
|
|
51
|
+
// and an injection must not survive by changing NODE_OPTIONS's casing.
|
|
52
|
+
const source =
|
|
53
|
+
inherited.find((name) => name === key) ??
|
|
54
|
+
inherited.find((name) => name.toUpperCase() === key);
|
|
55
|
+
if (source !== undefined) values[key] = process.env[source];
|
|
56
|
+
}
|
|
57
|
+
Object.assign(values, {
|
|
58
|
+
LANG: 'C',
|
|
59
|
+
LC_ALL: 'C',
|
|
60
|
+
TZ: 'UTC',
|
|
61
|
+
PYTHONNOUSERSITE: '1',
|
|
62
|
+
PYTHONSAFEPATH: '1',
|
|
63
|
+
PYTHONDONTWRITEBYTECODE: '1',
|
|
64
|
+
// Node otherwise propagates this variable even with an explicit env object.
|
|
65
|
+
NODE_V8_COVERAGE: '',
|
|
66
|
+
});
|
|
67
|
+
const sorted = Object.fromEntries(
|
|
68
|
+
Object.entries(values).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
69
|
+
);
|
|
70
|
+
return { policy: CHECK_ENVIRONMENT_POLICY, values: sorted, sha256: hash(JSON.stringify(sorted)) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function id(value, label = 'ID') {
|
|
74
|
+
if (typeof value !== 'string' || !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(value))
|
|
75
|
+
fail(label + ' must use 1-64 lowercase letters, numbers, _ or -.');
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function text(value, label, limit = 8000) {
|
|
80
|
+
if (typeof value !== 'string' || !value.trim() || value.length > limit || value.includes('\0'))
|
|
81
|
+
fail(label + ' must be a nonempty bounded string.');
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function relative(value, allowDot = false) {
|
|
86
|
+
if (allowDot && value === '.') return value;
|
|
87
|
+
text(value, 'Relative path', 500);
|
|
88
|
+
if (
|
|
89
|
+
/[\\:<>"|?*]/.test(value) ||
|
|
90
|
+
[...value].some((char) => char.charCodeAt(0) < 32) ||
|
|
91
|
+
path.posix.isAbsolute(value) ||
|
|
92
|
+
path.win32.isAbsolute(value) ||
|
|
93
|
+
path.posix.normalize(value) !== value ||
|
|
94
|
+
value === '..' ||
|
|
95
|
+
value.startsWith('../')
|
|
96
|
+
)
|
|
97
|
+
fail('Unsafe relative path: ' + value);
|
|
98
|
+
if (
|
|
99
|
+
value
|
|
100
|
+
.split('/')
|
|
101
|
+
.some(
|
|
102
|
+
(part) =>
|
|
103
|
+
!part ||
|
|
104
|
+
part === '.' ||
|
|
105
|
+
/[. ]$/.test(part) ||
|
|
106
|
+
/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
fail('Nonportable path: ' + value);
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function rootOf(directory) {
|
|
114
|
+
const root = path.resolve(directory);
|
|
115
|
+
// safeTarget checks every existing ancestor, including the project itself.
|
|
116
|
+
safeTarget(root, BASE + '/.probe');
|
|
117
|
+
if (!fs.statSync(root).isDirectory()) fail('Project directory is not a directory.');
|
|
118
|
+
return fs.realpathSync.native(root);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function directoryAt(root, file) {
|
|
122
|
+
relative(file, true);
|
|
123
|
+
const target = file === '.' ? root : path.join(root, file);
|
|
124
|
+
safeTarget(root, (file === '.' ? '' : file + '/') + '.nexus-directory-probe');
|
|
125
|
+
if (!fs.statSync(target).isDirectory()) fail('Check cwd is not a directory: ' + file);
|
|
126
|
+
return target;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function fileHash(root, file, required = true) {
|
|
130
|
+
relative(file);
|
|
131
|
+
const target = safeTarget(root, file);
|
|
132
|
+
if (!fs.existsSync(target)) {
|
|
133
|
+
if (required) fail('Required file is missing: ' + file);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
if (fs.statSync(target).size > MAX_FILE_BYTES)
|
|
137
|
+
fail('File exceeds the 16 MiB snapshot limit: ' + file);
|
|
138
|
+
return hash(fs.readFileSync(target));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function filesSnapshot(root, files, required = true) {
|
|
142
|
+
return Object.fromEntries(
|
|
143
|
+
[...new Set(files)].sort().map((file) => [file, fileHash(root, file, required)])
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function scopeSnapshot(root, scopes) {
|
|
148
|
+
const files = [];
|
|
149
|
+
function walk(file) {
|
|
150
|
+
const target = path.join(root, file);
|
|
151
|
+
let stat;
|
|
152
|
+
try {
|
|
153
|
+
stat = fs.lstatSync(target);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (error.code === 'ENOENT') return;
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
if (stat.isSymbolicLink()) fail('Scope contains a symlink or junction: ' + file);
|
|
159
|
+
if (stat.isDirectory()) {
|
|
160
|
+
directoryAt(root, file);
|
|
161
|
+
for (const name of fs.readdirSync(target).sort()) walk(file + '/' + name);
|
|
162
|
+
} else if (stat.isFile()) {
|
|
163
|
+
files.push(file);
|
|
164
|
+
if (files.length > 10000) fail('Scope exceeds the 10000 file snapshot limit.');
|
|
165
|
+
} else fail('Scope contains a non-regular file: ' + file);
|
|
166
|
+
}
|
|
167
|
+
for (const scope of scopes) {
|
|
168
|
+
const target = path.join(root, scope);
|
|
169
|
+
if (fs.existsSync(target) && fs.lstatSync(target).isFile()) {
|
|
170
|
+
safeTarget(root, scope);
|
|
171
|
+
files.push(scope);
|
|
172
|
+
} else {
|
|
173
|
+
safeTarget(root, scope + '/.nexus-scope-probe');
|
|
174
|
+
walk(scope);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return filesSnapshot(root, files);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function revision(root) {
|
|
181
|
+
const env = checkEnvironment().values;
|
|
182
|
+
const top = spawnSync('git', ['-C', root, 'rev-parse', '--show-toplevel'], {
|
|
183
|
+
env,
|
|
184
|
+
encoding: 'utf8',
|
|
185
|
+
windowsHide: true,
|
|
186
|
+
timeout: 5000,
|
|
187
|
+
maxBuffer: 4096,
|
|
188
|
+
});
|
|
189
|
+
if (top.status !== 0) return null;
|
|
190
|
+
const repository = fs.realpathSync.native(top.stdout.trim());
|
|
191
|
+
if (
|
|
192
|
+
process.platform === 'win32'
|
|
193
|
+
? repository.toLowerCase() !== root.toLowerCase()
|
|
194
|
+
: repository !== root
|
|
195
|
+
)
|
|
196
|
+
return null;
|
|
197
|
+
const result = spawnSync('git', ['-C', root, 'rev-parse', 'HEAD'], {
|
|
198
|
+
env,
|
|
199
|
+
encoding: 'utf8',
|
|
200
|
+
windowsHide: true,
|
|
201
|
+
timeout: 5000,
|
|
202
|
+
maxBuffer: 4096,
|
|
203
|
+
});
|
|
204
|
+
return result.status === 0 && /^[a-f0-9]{40,64}$/.test(result.stdout.trim())
|
|
205
|
+
? result.stdout.trim()
|
|
206
|
+
: null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function atomicWrite(root, file, value) {
|
|
210
|
+
const target = safeTarget(root, file);
|
|
211
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
212
|
+
const temporaryFile = file + '.' + crypto.randomUUID() + '.tmp';
|
|
213
|
+
const temporary = safeTarget(root, temporaryFile);
|
|
214
|
+
let fd;
|
|
215
|
+
try {
|
|
216
|
+
fd = fs.openSync(temporary, 'wx', 0o600);
|
|
217
|
+
fs.writeFileSync(fd, value);
|
|
218
|
+
fs.fsyncSync(fd);
|
|
219
|
+
fs.closeSync(fd);
|
|
220
|
+
fd = undefined;
|
|
221
|
+
safeTarget(root, file);
|
|
222
|
+
fs.renameSync(temporary, target);
|
|
223
|
+
} finally {
|
|
224
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
225
|
+
if (fs.existsSync(safeTarget(root, temporaryFile))) fs.unlinkSync(temporary);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function lockSnapshot(root, file = LOCK) {
|
|
230
|
+
const target = safeTarget(root, file);
|
|
231
|
+
if (!fs.existsSync(target)) return null;
|
|
232
|
+
const bytes = fs.readFileSync(target);
|
|
233
|
+
let owner = null;
|
|
234
|
+
try {
|
|
235
|
+
owner = JSON.parse(bytes.toString('utf8'));
|
|
236
|
+
} catch {
|
|
237
|
+
/* Interrupted lock creation remains explicit. */
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
hash: hash(bytes),
|
|
241
|
+
owner,
|
|
242
|
+
recovery: 'Inspect the owner; recover only after its process has stopped.',
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function withLock(root, operation, file = LOCK) {
|
|
247
|
+
const assertRecoveryIdle = () => {
|
|
248
|
+
if (file === LOCK && fs.existsSync(safeTarget(root, RECOVERY_LOCK)))
|
|
249
|
+
fail(
|
|
250
|
+
'Lock recovery is active or interrupted; inspect recovery.lock and its owner before proceeding.'
|
|
251
|
+
);
|
|
252
|
+
};
|
|
253
|
+
assertRecoveryIdle();
|
|
254
|
+
const target = safeTarget(root, file);
|
|
255
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
256
|
+
safeTarget(root, file);
|
|
257
|
+
let fd;
|
|
258
|
+
try {
|
|
259
|
+
fd = fs.openSync(target, 'wx', 0o600);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (error.code === 'EEXIST')
|
|
262
|
+
fail(
|
|
263
|
+
file === RECOVERY_LOCK
|
|
264
|
+
? 'Another recovery owns recovery.lock; inspect its owner and effects before any manual recovery.'
|
|
265
|
+
: 'Another mutation owns writer.lock; inspect before explicit recovery.'
|
|
266
|
+
);
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
const owner = {
|
|
270
|
+
token: crypto.randomUUID(),
|
|
271
|
+
pid: process.pid,
|
|
272
|
+
hostname: os.hostname(),
|
|
273
|
+
createdAt: now(),
|
|
274
|
+
};
|
|
275
|
+
try {
|
|
276
|
+
fs.writeFileSync(fd, JSON.stringify(owner));
|
|
277
|
+
fs.fsyncSync(fd);
|
|
278
|
+
fs.closeSync(fd);
|
|
279
|
+
fd = undefined;
|
|
280
|
+
// A recovery may begin after the first check. Publish our live ownership
|
|
281
|
+
// before this second check so that it cannot be mistaken for a stale writer.
|
|
282
|
+
assertRecoveryIdle();
|
|
283
|
+
return operation();
|
|
284
|
+
} finally {
|
|
285
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
286
|
+
const current = lockSnapshot(root, file);
|
|
287
|
+
if (current?.owner?.token === owner.token) fs.unlinkSync(safeTarget(root, file));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function recoverLock(directory, { expectedHash, note, ownerStopped } = {}) {
|
|
292
|
+
const root = rootOf(directory);
|
|
293
|
+
text(note, 'Recovery observation');
|
|
294
|
+
return withLock(
|
|
295
|
+
root,
|
|
296
|
+
() => {
|
|
297
|
+
const observed = lockSnapshot(root);
|
|
298
|
+
if (!observed || observed.hash !== expectedHash || ownerStopped !== true)
|
|
299
|
+
fail('Exact lock hash and an explicit stopped-owner observation are required.');
|
|
300
|
+
if (
|
|
301
|
+
observed.owner?.hostname === os.hostname() &&
|
|
302
|
+
Number.isInteger(observed.owner.pid) &&
|
|
303
|
+
observed.owner.pid > 0
|
|
304
|
+
) {
|
|
305
|
+
try {
|
|
306
|
+
process.kill(observed.owner.pid, 0);
|
|
307
|
+
fail('Lock owner process is still alive; no lock was removed.');
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (error.code !== 'ESRCH') throw error;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// Keep the exact previous lock and observation as a receipt before removing it.
|
|
313
|
+
const receipt = {
|
|
314
|
+
schemaVersion: 1,
|
|
315
|
+
observed,
|
|
316
|
+
note,
|
|
317
|
+
ownerStopped: true,
|
|
318
|
+
recoveredAt: now(),
|
|
319
|
+
evidenceClass: 'operator-observation',
|
|
320
|
+
};
|
|
321
|
+
atomicWrite(
|
|
322
|
+
root,
|
|
323
|
+
BASE + '/lock-recovery/' + crypto.randomUUID() + '.json',
|
|
324
|
+
JSON.stringify(receipt, null, 2) + '\n'
|
|
325
|
+
);
|
|
326
|
+
if (lockSnapshot(root)?.hash !== expectedHash) fail('Lock changed during recovery.');
|
|
327
|
+
fs.unlinkSync(safeTarget(root, LOCK));
|
|
328
|
+
return receipt;
|
|
329
|
+
},
|
|
330
|
+
RECOVERY_LOCK
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function runFile(runId) {
|
|
335
|
+
return BASE + '/runs/' + id(runId, 'Run ID') + '.json';
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function saveRun(root, run) {
|
|
339
|
+
run.updatedAt = now();
|
|
340
|
+
run.sequence += 1;
|
|
341
|
+
const payload = JSON.stringify(run);
|
|
342
|
+
if (Buffer.byteLength(payload) > MAX_STATE_BYTES) fail('Run exceeds the 16 MiB state limit.');
|
|
343
|
+
atomicWrite(
|
|
344
|
+
root,
|
|
345
|
+
runFile(run.id),
|
|
346
|
+
JSON.stringify({ schemaVersion: 1, sha256: hash(payload), run }, null, 2) + '\n'
|
|
347
|
+
);
|
|
348
|
+
return clone(run);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function loadRun(root, runId) {
|
|
352
|
+
const target = safeTarget(root, runFile(runId));
|
|
353
|
+
if (fs.statSync(target).size > MAX_STATE_BYTES * 2) fail('Run state exceeds the size limit.');
|
|
354
|
+
let envelope;
|
|
355
|
+
try {
|
|
356
|
+
envelope = JSON.parse(fs.readFileSync(target, 'utf8'));
|
|
357
|
+
} catch {
|
|
358
|
+
fail('Corrupt run state; original bytes were preserved.');
|
|
359
|
+
}
|
|
360
|
+
const run = envelope?.run;
|
|
361
|
+
if (
|
|
362
|
+
envelope.schemaVersion !== 1 ||
|
|
363
|
+
!run ||
|
|
364
|
+
envelope.sha256 !== hash(JSON.stringify(run)) ||
|
|
365
|
+
run.schemaVersion !== 1 ||
|
|
366
|
+
run.id !== runId ||
|
|
367
|
+
run.project !== root ||
|
|
368
|
+
!Number.isInteger(run.sequence) ||
|
|
369
|
+
!Array.isArray(run.tasks)
|
|
370
|
+
)
|
|
371
|
+
fail('Corrupt or foreign run state; original bytes were preserved.');
|
|
372
|
+
// Recheck the immutable plan's shape, and reject malformed lifecycle records.
|
|
373
|
+
if (
|
|
374
|
+
run.planHash !== hash(JSON.stringify(run.plan)) ||
|
|
375
|
+
!same(
|
|
376
|
+
run.tasks.map((task) => task.id),
|
|
377
|
+
run.plan.tasks.map((task) => task.id)
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
fail('Run plan integrity mismatch.');
|
|
381
|
+
const attempts = new Set();
|
|
382
|
+
for (const task of run.tasks) {
|
|
383
|
+
if (
|
|
384
|
+
!Array.isArray(task.attempts) ||
|
|
385
|
+
!['pending', 'accepted', 'rejected'].includes(task.integration)
|
|
386
|
+
)
|
|
387
|
+
fail('Invalid task lifecycle record.');
|
|
388
|
+
for (const attempt of task.attempts) {
|
|
389
|
+
if (
|
|
390
|
+
typeof attempt.id !== 'string' ||
|
|
391
|
+
attempts.has(attempt.id) ||
|
|
392
|
+
attempt.taskId !== task.id ||
|
|
393
|
+
attempt.runId !== run.id ||
|
|
394
|
+
!Number.isInteger(attempt.verificationRounds) ||
|
|
395
|
+
attempt.verificationRounds < 0 ||
|
|
396
|
+
!Array.isArray(attempt.verificationHistory) ||
|
|
397
|
+
!Array.isArray(attempt.reconciliations) ||
|
|
398
|
+
!['running', 'blocked', 'ambiguous', 'completed', 'failed', 'cancelled'].includes(
|
|
399
|
+
attempt.execution
|
|
400
|
+
) ||
|
|
401
|
+
!['pending', 'running', 'passed', 'failed', 'ambiguous'].includes(
|
|
402
|
+
attempt.verification.status
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
fail('Invalid attempt lifecycle record.');
|
|
406
|
+
attempts.add(attempt.id);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return run;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function stringList(value, label, { paths = false, empty = true } = {}) {
|
|
413
|
+
if (!Array.isArray(value) || (!empty && !value.length) || value.length > 1000)
|
|
414
|
+
fail(label + ' must be a bounded array.');
|
|
415
|
+
const values = value.map((item) => (paths ? relative(item) : text(item, label)));
|
|
416
|
+
if (new Set(values.map((item) => item.toLowerCase())).size !== values.length)
|
|
417
|
+
fail('Duplicate ' + label + '.');
|
|
418
|
+
return values;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function executable(command) {
|
|
422
|
+
if (command === 'node') command = process.execPath;
|
|
423
|
+
if (typeof command !== 'string' || !path.isAbsolute(command))
|
|
424
|
+
fail('Check command must be node or an absolute executable path.');
|
|
425
|
+
const real = fs.realpathSync.native(command);
|
|
426
|
+
if (!fs.statSync(real).isFile()) fail('Check command is not a regular executable.');
|
|
427
|
+
return { command: real, commandHash: hash(fs.readFileSync(real)) };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function normalizePlan(root, plan) {
|
|
431
|
+
if (!plan || typeof plan !== 'object' || Array.isArray(plan))
|
|
432
|
+
fail('A JSON plan object is required.');
|
|
433
|
+
id(plan.id, 'Run ID');
|
|
434
|
+
if (!Array.isArray(plan.tasks) || !plan.tasks.length || plan.tasks.length > 200)
|
|
435
|
+
fail('A plan needs 1-200 tasks.');
|
|
436
|
+
const maxParallel = plan.maxParallel ?? 1;
|
|
437
|
+
if (!Number.isInteger(maxParallel) || maxParallel < 1 || maxParallel > 32)
|
|
438
|
+
fail('maxParallel must be between 1 and 32.');
|
|
439
|
+
const config = plan.config ?? {};
|
|
440
|
+
if (
|
|
441
|
+
!config ||
|
|
442
|
+
typeof config !== 'object' ||
|
|
443
|
+
Array.isArray(config) ||
|
|
444
|
+
Object.entries(config).some(
|
|
445
|
+
([key, value]) =>
|
|
446
|
+
/password|secret|token|credential|api.?key/i.test(key) ||
|
|
447
|
+
!['string', 'number', 'boolean'].includes(typeof value)
|
|
448
|
+
)
|
|
449
|
+
)
|
|
450
|
+
fail('config accepts non-secret primitive settings only.');
|
|
451
|
+
const tasks = plan.tasks.map((task) => {
|
|
452
|
+
id(task.id, 'Task ID');
|
|
453
|
+
const scope = stringList(task.scope, 'write scope', { paths: true, empty: false });
|
|
454
|
+
for (const item of scope) {
|
|
455
|
+
if (overlap(item, BASE) || overlap(item, '.git'))
|
|
456
|
+
fail('Runtime state and .git cannot be a worker write scope.');
|
|
457
|
+
// Inspect a scope through a child only when it is a directory or absent.
|
|
458
|
+
const target = path.join(root, item);
|
|
459
|
+
if (fs.existsSync(target) && fs.lstatSync(target).isFile()) safeTarget(root, item);
|
|
460
|
+
else safeTarget(root, item + '/.nexus-scope-probe');
|
|
461
|
+
}
|
|
462
|
+
const artifacts = stringList(task.artifacts, 'artifacts', { paths: true, empty: false });
|
|
463
|
+
for (const file of artifacts)
|
|
464
|
+
if (!scope.some((item) => inScope(file, item)))
|
|
465
|
+
fail('Artifact is outside its task write scope: ' + file);
|
|
466
|
+
const resources = stringList(task.resources ?? [], 'resources', { paths: true });
|
|
467
|
+
const execution = processBackend.normalizeExecution(root, task.execution, {
|
|
468
|
+
fail,
|
|
469
|
+
relative,
|
|
470
|
+
directoryAt,
|
|
471
|
+
executable,
|
|
472
|
+
stringList,
|
|
473
|
+
});
|
|
474
|
+
if (execution)
|
|
475
|
+
resources.push(...execution.resources.filter((file) => !resources.includes(file)));
|
|
476
|
+
if (!Array.isArray(task.checks) || !task.checks.length || task.checks.length > 30)
|
|
477
|
+
fail('Each task needs 1-30 explicit verification checks.');
|
|
478
|
+
const checks = task.checks.map((check) => {
|
|
479
|
+
id(check.id, 'Check ID');
|
|
480
|
+
if (
|
|
481
|
+
!Array.isArray(check.args) ||
|
|
482
|
+
check.args.length > 100 ||
|
|
483
|
+
check.args.some((arg) => typeof arg !== 'string' || arg.length > 8000 || arg.includes('\0'))
|
|
484
|
+
)
|
|
485
|
+
fail('Check args must be a bounded literal argument array.');
|
|
486
|
+
const cwd = check.cwd ?? '.';
|
|
487
|
+
directoryAt(root, cwd);
|
|
488
|
+
const checkResources = stringList(check.resources ?? [], 'check resources', { paths: true });
|
|
489
|
+
const allowed = [...resources, ...checkResources];
|
|
490
|
+
for (const argument of check.args) {
|
|
491
|
+
if (!argument || argument.startsWith('-')) continue;
|
|
492
|
+
const absolute = path.resolve(root, cwd, argument);
|
|
493
|
+
const candidate = path.relative(root, absolute).split(path.sep).join('/');
|
|
494
|
+
if (
|
|
495
|
+
fs.existsSync(absolute) &&
|
|
496
|
+
fs.statSync(absolute).isFile() &&
|
|
497
|
+
!allowed.includes(candidate)
|
|
498
|
+
)
|
|
499
|
+
fail('File argument must be declared as a protected resource: ' + candidate);
|
|
500
|
+
}
|
|
501
|
+
const timeoutMs = check.timeoutMs ?? 30000;
|
|
502
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 10 || timeoutMs > 300000)
|
|
503
|
+
fail('Check timeoutMs must be between 10 and 300000.');
|
|
504
|
+
return {
|
|
505
|
+
id: check.id,
|
|
506
|
+
...executable(check.command),
|
|
507
|
+
args: [...check.args],
|
|
508
|
+
cwd,
|
|
509
|
+
timeoutMs,
|
|
510
|
+
resources: checkResources,
|
|
511
|
+
};
|
|
512
|
+
});
|
|
513
|
+
if (new Set(checks.map((check) => check.id)).size !== checks.length)
|
|
514
|
+
fail('Duplicate check IDs.');
|
|
515
|
+
const maxAttempts = task.maxAttempts ?? 2;
|
|
516
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 10)
|
|
517
|
+
fail('maxAttempts must be between 1 and 10.');
|
|
518
|
+
if (task.idempotent !== undefined && typeof task.idempotent !== 'boolean')
|
|
519
|
+
fail('idempotent must be boolean.');
|
|
520
|
+
const deadlineMs = task.deadlineMs ?? 3600000;
|
|
521
|
+
if (!Number.isInteger(deadlineMs) || deadlineMs < 100 || deadlineMs > 86400000)
|
|
522
|
+
fail('deadlineMs must be between 100 and 86400000.');
|
|
523
|
+
const acceptance =
|
|
524
|
+
task.acceptance === undefined
|
|
525
|
+
? checks.map((check) => 'Check ' + check.id + ' passes on the current artifacts.')
|
|
526
|
+
: stringList(
|
|
527
|
+
Array.isArray(task.acceptance) ? task.acceptance : [task.acceptance],
|
|
528
|
+
'acceptance criteria',
|
|
529
|
+
{ empty: false }
|
|
530
|
+
);
|
|
531
|
+
return {
|
|
532
|
+
id: task.id,
|
|
533
|
+
objective: text(task.objective, 'Objective'),
|
|
534
|
+
dependsOn: stringList(task.dependsOn ?? [], 'dependencies').map((item) => id(item)),
|
|
535
|
+
scope,
|
|
536
|
+
artifacts,
|
|
537
|
+
resources,
|
|
538
|
+
checks,
|
|
539
|
+
maxAttempts,
|
|
540
|
+
idempotent: task.idempotent === true,
|
|
541
|
+
deadlineMs,
|
|
542
|
+
acceptance,
|
|
543
|
+
authorizations: stringList(task.authorizations ?? [], 'existing authorizations'),
|
|
544
|
+
...(execution ? { execution } : {}),
|
|
545
|
+
};
|
|
546
|
+
});
|
|
547
|
+
const ids = new Set(tasks.map((task) => task.id));
|
|
548
|
+
if (ids.size !== tasks.length) fail('Duplicate task IDs.');
|
|
549
|
+
const visited = new Set();
|
|
550
|
+
const visiting = new Set();
|
|
551
|
+
function visit(task) {
|
|
552
|
+
if (visiting.has(task.id)) fail('Dependency cycle at ' + task.id);
|
|
553
|
+
if (visited.has(task.id)) return;
|
|
554
|
+
visiting.add(task.id);
|
|
555
|
+
for (const dep of task.dependsOn) {
|
|
556
|
+
if (!ids.has(dep)) fail('Unknown dependency: ' + dep);
|
|
557
|
+
visit(tasks.find((item) => item.id === dep));
|
|
558
|
+
}
|
|
559
|
+
visiting.delete(task.id);
|
|
560
|
+
visited.add(task.id);
|
|
561
|
+
}
|
|
562
|
+
for (const task of tasks) visit(task);
|
|
563
|
+
for (let i = 0; i < tasks.length; i++)
|
|
564
|
+
for (let j = i + 1; j < tasks.length; j++)
|
|
565
|
+
if (tasks[i].scope.some((a) => tasks[j].scope.some((b) => overlap(a, b))))
|
|
566
|
+
fail('Overlapping exclusive write scopes: ' + tasks[i].id + ' and ' + tasks[j].id);
|
|
567
|
+
const allScopes = tasks.flatMap((task) => task.scope);
|
|
568
|
+
for (const task of tasks) {
|
|
569
|
+
const resources = [...task.resources, ...task.checks.flatMap((check) => check.resources)];
|
|
570
|
+
for (const file of resources)
|
|
571
|
+
if (allScopes.some((scope) => inScope(file, scope)))
|
|
572
|
+
fail('Verifier/resource lies in a worker write scope: ' + file);
|
|
573
|
+
for (const check of task.checks) {
|
|
574
|
+
const relativeCommand = path.relative(root, check.command).split(path.sep).join('/');
|
|
575
|
+
if (allScopes.some((scope) => inScope(relativeCommand, scope)))
|
|
576
|
+
fail('Verifier executable lies in a worker write scope.');
|
|
577
|
+
}
|
|
578
|
+
if (task.execution) {
|
|
579
|
+
const execution = task.execution;
|
|
580
|
+
const relativeCommand = path.relative(root, execution.command).split(path.sep).join('/');
|
|
581
|
+
if (allScopes.some((scope) => inScope(relativeCommand, scope)))
|
|
582
|
+
fail('Worker executable lies in a worker write scope.');
|
|
583
|
+
for (const argument of execution.args) {
|
|
584
|
+
if (!argument || argument.startsWith('-')) continue;
|
|
585
|
+
const absolute = path.resolve(root, execution.cwd, argument);
|
|
586
|
+
const candidate = path.relative(root, absolute).split(path.sep).join('/');
|
|
587
|
+
if (
|
|
588
|
+
fs.existsSync(absolute) &&
|
|
589
|
+
fs.statSync(absolute).isFile() &&
|
|
590
|
+
!resources.includes(candidate)
|
|
591
|
+
)
|
|
592
|
+
fail('Execution file argument must be a protected resource: ' + candidate);
|
|
593
|
+
}
|
|
594
|
+
processBackend.inputBytes(root, execution, safeTarget);
|
|
595
|
+
}
|
|
596
|
+
task.resourceHashes = filesSnapshot(root, resources);
|
|
597
|
+
scopeSnapshot(root, task.scope);
|
|
598
|
+
}
|
|
599
|
+
return { id: plan.id, maxParallel, config: clone(config), tasks };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function createRun(directory, plan) {
|
|
603
|
+
const root = rootOf(directory);
|
|
604
|
+
const normalized = normalizePlan(root, plan);
|
|
605
|
+
return withLock(root, () => {
|
|
606
|
+
if (fs.existsSync(safeTarget(root, runFile(normalized.id))))
|
|
607
|
+
fail('Run already exists; inspect it instead.');
|
|
608
|
+
return saveRun(root, {
|
|
609
|
+
schemaVersion: 1,
|
|
610
|
+
id: normalized.id,
|
|
611
|
+
project: root,
|
|
612
|
+
frameworkVersion: FRAMEWORK_VERSION,
|
|
613
|
+
createdAt: now(),
|
|
614
|
+
updatedAt: now(),
|
|
615
|
+
sequence: 0,
|
|
616
|
+
planHash: hash(JSON.stringify(normalized)),
|
|
617
|
+
plan: normalized,
|
|
618
|
+
tasks: normalized.tasks.map((task) => ({
|
|
619
|
+
id: task.id,
|
|
620
|
+
integration: 'pending',
|
|
621
|
+
attempts: [],
|
|
622
|
+
})),
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function current(task) {
|
|
628
|
+
return task.attempts.at(-1);
|
|
629
|
+
}
|
|
630
|
+
function active(task) {
|
|
631
|
+
const attempt = current(task);
|
|
632
|
+
return Boolean(
|
|
633
|
+
attempt &&
|
|
634
|
+
(['running', 'blocked', 'ambiguous'].includes(attempt.execution) ||
|
|
635
|
+
attempt.verification.status === 'running' ||
|
|
636
|
+
attempt.verification.status === 'ambiguous' ||
|
|
637
|
+
attempt.cancellation.status === 'requested')
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
function taskOf(run, taskId) {
|
|
641
|
+
id(taskId, 'Task ID');
|
|
642
|
+
const task = run.tasks.find((item) => item.id === taskId);
|
|
643
|
+
if (!task) fail('Unknown task: ' + taskId);
|
|
644
|
+
return task;
|
|
645
|
+
}
|
|
646
|
+
function definition(run, taskId) {
|
|
647
|
+
return run.plan.tasks.find((item) => item.id === taskId);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function inspectRun(directory, runId) {
|
|
651
|
+
const root = rootOf(directory);
|
|
652
|
+
if (runId === undefined) {
|
|
653
|
+
const directory = path.join(root, BASE, 'runs');
|
|
654
|
+
safeTarget(root, BASE + '/runs/.inventory-probe');
|
|
655
|
+
const runIds = fs.existsSync(directory)
|
|
656
|
+
? fs
|
|
657
|
+
.readdirSync(directory)
|
|
658
|
+
.filter((file) => /^[a-z0-9][a-z0-9_-]{0,63}\.json$/.test(file))
|
|
659
|
+
.map((file) => file.slice(0, -5))
|
|
660
|
+
.sort()
|
|
661
|
+
: [];
|
|
662
|
+
return {
|
|
663
|
+
schemaVersion: 1,
|
|
664
|
+
project: root,
|
|
665
|
+
frameworkVersion: FRAMEWORK_VERSION,
|
|
666
|
+
runIds,
|
|
667
|
+
lock: lockSnapshot(root),
|
|
668
|
+
recoveryLock: lockSnapshot(root, RECOVERY_LOCK),
|
|
669
|
+
hint: 'Run IDs are an inventory, not validated outcomes; inspect an individual run for current evidence.',
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
const run = loadRun(root, runId);
|
|
673
|
+
const verifiedDependencies = new Set();
|
|
674
|
+
const observations = run.tasks.map((task) => {
|
|
675
|
+
const attempt = current(task);
|
|
676
|
+
let hint =
|
|
677
|
+
task.integration === 'accepted'
|
|
678
|
+
? 'Accepted; no git operation was performed.'
|
|
679
|
+
: 'Start when dependencies have current accepted evidence.';
|
|
680
|
+
if (attempt) {
|
|
681
|
+
if (active(task))
|
|
682
|
+
hint =
|
|
683
|
+
'Inspect this exact backend and attempt. Reconcile ambiguous or stopped work before retry; no automatic relaunch or termination.';
|
|
684
|
+
else if (attempt.verification.status === 'passed')
|
|
685
|
+
hint = 'Accept only after rechecking current artifacts and resources.';
|
|
686
|
+
else if (attempt.execution === 'completed' && attempt.verification.status === 'pending')
|
|
687
|
+
hint = 'Run the configured verification checks.';
|
|
688
|
+
else hint = 'Review the terminal record and retry policy; a retry creates a new attempt.';
|
|
689
|
+
if (
|
|
690
|
+
attempt.backend.kind === 'process' &&
|
|
691
|
+
attempt.process?.status === 'finished' &&
|
|
692
|
+
!attempt.process.collectedAt
|
|
693
|
+
)
|
|
694
|
+
hint =
|
|
695
|
+
attempt.process.receipt?.outcome === 'completed'
|
|
696
|
+
? 'An owned process receipt is ready. Collect this exact attempt, then verify its current artifacts.'
|
|
697
|
+
: 'Inspect and collect the owned process receipt. Resolve blocked or ambiguous effects before a new attempt; completion is not established.';
|
|
698
|
+
}
|
|
699
|
+
let resourcesCurrent = true;
|
|
700
|
+
let evidenceEligible = false;
|
|
701
|
+
let staleReason = null;
|
|
702
|
+
try {
|
|
703
|
+
protectedSnapshot(root, definition(run, task.id));
|
|
704
|
+
} catch (error) {
|
|
705
|
+
resourcesCurrent = false;
|
|
706
|
+
staleReason = error.message;
|
|
707
|
+
}
|
|
708
|
+
if (attempt?.verification.status === 'passed') {
|
|
709
|
+
try {
|
|
710
|
+
evidenceCurrent(root, run, task, verifiedDependencies);
|
|
711
|
+
evidenceEligible = true;
|
|
712
|
+
} catch (error) {
|
|
713
|
+
staleReason = error.message;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
if (staleReason)
|
|
717
|
+
hint = 'Recorded state is historical; current inputs/evidence are stale: ' + staleReason;
|
|
718
|
+
return {
|
|
719
|
+
taskId: task.id,
|
|
720
|
+
attemptId: attempt?.id ?? null,
|
|
721
|
+
overdue: Boolean(attempt && active(task) && Date.now() > Date.parse(attempt.deadline)),
|
|
722
|
+
resourcesCurrent,
|
|
723
|
+
evidenceEligible,
|
|
724
|
+
stale: Boolean(staleReason),
|
|
725
|
+
staleReason,
|
|
726
|
+
...(attempt?.backend.kind === 'process'
|
|
727
|
+
? {
|
|
728
|
+
process: {
|
|
729
|
+
status: attempt.process?.status ?? 'launch-unacknowledged',
|
|
730
|
+
supervisor: attempt.process?.owner ?? null,
|
|
731
|
+
lastHeartbeat: attempt.process?.heartbeatAt ?? null,
|
|
732
|
+
heartbeatStale:
|
|
733
|
+
!attempt.process?.heartbeatAt ||
|
|
734
|
+
Date.now() - Date.parse(attempt.process.heartbeatAt) > 5000,
|
|
735
|
+
childPid: attempt.process?.childPid ?? null,
|
|
736
|
+
receiptReady: Boolean(attempt.process?.receipt && !attempt.process.collectedAt),
|
|
737
|
+
liveIdentityEstablished: false,
|
|
738
|
+
hint: 'A stored PID or fresh heartbeat is an observation, not authority to terminate or relaunch.',
|
|
739
|
+
},
|
|
740
|
+
}
|
|
741
|
+
: {}),
|
|
742
|
+
hint,
|
|
743
|
+
};
|
|
744
|
+
});
|
|
745
|
+
return {
|
|
746
|
+
...run,
|
|
747
|
+
observations,
|
|
748
|
+
lock: lockSnapshot(root),
|
|
749
|
+
recoveryLock: lockSnapshot(root, RECOVERY_LOCK),
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function mutate(directory, runId, operation) {
|
|
754
|
+
const root = rootOf(directory);
|
|
755
|
+
return withLock(root, () => {
|
|
756
|
+
const run = loadRun(root, runId);
|
|
757
|
+
operation(root, run);
|
|
758
|
+
return saveRun(root, run);
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function backendIdentity(value) {
|
|
763
|
+
if (value?.kind === 'process') {
|
|
764
|
+
if (!Number.isInteger(value.ownerPid) || value.ownerPid < 1)
|
|
765
|
+
fail('Process backend needs its actual supervisor PID.');
|
|
766
|
+
return {
|
|
767
|
+
kind: 'process',
|
|
768
|
+
id: text(value.id, 'Process owner handle', 200),
|
|
769
|
+
sessionId: text(value.sessionId, 'Process session', 200),
|
|
770
|
+
hostname: text(value.hostname, 'Process host', 200),
|
|
771
|
+
ownerPid: value.ownerPid,
|
|
772
|
+
capabilities: {
|
|
773
|
+
launch: true,
|
|
774
|
+
terminate: 'owner-direct-child',
|
|
775
|
+
inspect: 'durable-process-observation',
|
|
776
|
+
},
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
if (!value || value.kind !== 'host') fail('Only host and process backends are implemented.');
|
|
780
|
+
return {
|
|
781
|
+
kind: 'host',
|
|
782
|
+
id: text(value.id, 'Backend handle', 200),
|
|
783
|
+
sessionId: text(value.sessionId, 'Backend session', 200),
|
|
784
|
+
capabilities: { launch: false, terminate: false, inspect: 'host-observation' },
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function boundAttempt(task, input) {
|
|
789
|
+
const attempt = current(task);
|
|
790
|
+
if (!attempt || input?.attemptId !== attempt.id) fail('Stale or missing attempt identity.');
|
|
791
|
+
if (!same(attempt.backend, backendIdentity(input.backend)))
|
|
792
|
+
fail('Backend handle/session does not own this attempt.');
|
|
793
|
+
return attempt;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function protectedSnapshot(root, def) {
|
|
797
|
+
const resources = filesSnapshot(root, Object.keys(def.resourceHashes));
|
|
798
|
+
if (!same(resources, def.resourceHashes))
|
|
799
|
+
fail('Protected verifier or agent resource changed since the plan was created.');
|
|
800
|
+
for (const check of def.checks) {
|
|
801
|
+
directoryAt(root, check.cwd);
|
|
802
|
+
if (executable(check.command).commandHash !== check.commandHash)
|
|
803
|
+
fail('Verifier executable changed since plan creation.');
|
|
804
|
+
}
|
|
805
|
+
if (def.execution) {
|
|
806
|
+
directoryAt(root, def.execution.cwd);
|
|
807
|
+
if (executable(def.execution.command).commandHash !== def.execution.commandHash)
|
|
808
|
+
fail('Worker executable changed since plan creation.');
|
|
809
|
+
}
|
|
810
|
+
return resources;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function evidenceCurrent(root, run, task, verified = new Set(), ancestors = new Set()) {
|
|
814
|
+
if (ancestors.has(task.id)) fail('Dependency cycle at ' + task.id);
|
|
815
|
+
if (verified.has(task.id)) return;
|
|
816
|
+
const attempt = current(task);
|
|
817
|
+
if (
|
|
818
|
+
!attempt ||
|
|
819
|
+
attempt.verification.status !== 'passed' ||
|
|
820
|
+
attempt.verification.attemptId !== attempt.id ||
|
|
821
|
+
attempt.execution !== 'completed' ||
|
|
822
|
+
attempt.cancellation.status === 'requested'
|
|
823
|
+
)
|
|
824
|
+
fail('Current attempt has no eligible passed verification.');
|
|
825
|
+
const environment = attempt.verification.environment;
|
|
826
|
+
if (
|
|
827
|
+
!environment ||
|
|
828
|
+
environment.policy !== CHECK_ENVIRONMENT_POLICY ||
|
|
829
|
+
environment.sha256 !== hash(JSON.stringify(environment.values))
|
|
830
|
+
)
|
|
831
|
+
fail('Verification environment is missing or obsolete; fresh evidence is required.');
|
|
832
|
+
const def = definition(run, task.id);
|
|
833
|
+
protectedSnapshot(root, def);
|
|
834
|
+
const observed = scopeSnapshot(root, def.scope);
|
|
835
|
+
if (
|
|
836
|
+
!same(observed, attempt.verification.scopeHashes) ||
|
|
837
|
+
!same(filesSnapshot(root, def.artifacts), attempt.verification.artifactHashes)
|
|
838
|
+
)
|
|
839
|
+
fail('Stale evidence: checked artifacts or write scope changed.');
|
|
840
|
+
if (
|
|
841
|
+
!same(
|
|
842
|
+
attempt.verification.checks.map((check) => check.id),
|
|
843
|
+
def.checks.map((check) => check.id)
|
|
844
|
+
) ||
|
|
845
|
+
attempt.verification.checks.some(
|
|
846
|
+
(check) =>
|
|
847
|
+
check.exitCode !== 0 ||
|
|
848
|
+
check.error ||
|
|
849
|
+
check.signal ||
|
|
850
|
+
check.attemptId !== attempt.id ||
|
|
851
|
+
!same(check.environment, environment)
|
|
852
|
+
)
|
|
853
|
+
)
|
|
854
|
+
fail('Verification receipt is incomplete.');
|
|
855
|
+
const dependencyAncestors = new Set(ancestors).add(task.id);
|
|
856
|
+
for (const dependencyId of def.dependsOn) {
|
|
857
|
+
const dependency = taskOf(run, dependencyId);
|
|
858
|
+
if (dependency.integration !== 'accepted')
|
|
859
|
+
fail('Dependency has not been accepted: ' + dependencyId);
|
|
860
|
+
evidenceCurrent(root, run, dependency, verified, dependencyAncestors);
|
|
861
|
+
}
|
|
862
|
+
verified.add(task.id);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function assertCapacity(root, run, def) {
|
|
866
|
+
if (run.tasks.filter(active).length >= run.plan.maxParallel)
|
|
867
|
+
fail('Run capacity is occupied until active work is reconciled.');
|
|
868
|
+
const runsDirectory = directoryAt(root, BASE + '/runs');
|
|
869
|
+
for (const file of fs.readdirSync(runsDirectory).filter((file) => file.endsWith('.json'))) {
|
|
870
|
+
const other = loadRun(root, file.slice(0, -5));
|
|
871
|
+
for (const task of other.tasks.filter(active))
|
|
872
|
+
if (definition(other, task.id).scope.some((a) => def.scope.some((b) => overlap(a, b))))
|
|
873
|
+
fail('Write scope is owned by active attempt: ' + other.id + '/' + task.id);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function begin(root, run, task, options, retry) {
|
|
878
|
+
const def = definition(run, task.id);
|
|
879
|
+
const previous = current(task);
|
|
880
|
+
if (task.integration === 'accepted') fail('Accepted tasks cannot be restarted.');
|
|
881
|
+
if (retry) {
|
|
882
|
+
if (
|
|
883
|
+
!previous ||
|
|
884
|
+
active(task) ||
|
|
885
|
+
!(
|
|
886
|
+
previous.execution === 'failed' ||
|
|
887
|
+
previous.execution === 'cancelled' ||
|
|
888
|
+
previous.verification.status === 'failed'
|
|
889
|
+
)
|
|
890
|
+
)
|
|
891
|
+
fail('Retry requires a reconciled failed/cancelled attempt or failed verification.');
|
|
892
|
+
if (!def.idempotent && previous.reconciliation?.retrySafe !== true)
|
|
893
|
+
fail('Non-idempotent retry requires an explicit retry-safe reconciliation.');
|
|
894
|
+
} else if (previous) fail('Task already has an attempt; inspect or explicitly retry.');
|
|
895
|
+
if (task.attempts.length >= def.maxAttempts) fail('Retry limit reached.');
|
|
896
|
+
const verifiedDependencies = new Set();
|
|
897
|
+
for (const dependencyId of def.dependsOn) {
|
|
898
|
+
const dependency = taskOf(run, dependencyId);
|
|
899
|
+
if (dependency.integration !== 'accepted')
|
|
900
|
+
fail('Dependency has not been accepted: ' + dependencyId);
|
|
901
|
+
evidenceCurrent(root, run, dependency, verifiedDependencies);
|
|
902
|
+
}
|
|
903
|
+
assertCapacity(root, run, def);
|
|
904
|
+
const resources = protectedSnapshot(root, def);
|
|
905
|
+
const backend = backendIdentity(options.backend);
|
|
906
|
+
const startedAt = now();
|
|
907
|
+
task.integration = 'pending';
|
|
908
|
+
task.attempts.push({
|
|
909
|
+
id: crypto.randomUUID(),
|
|
910
|
+
runId: run.id,
|
|
911
|
+
taskId: task.id,
|
|
912
|
+
parentAttemptId: previous?.id ?? null,
|
|
913
|
+
backend,
|
|
914
|
+
execution: 'running',
|
|
915
|
+
verification: { status: 'pending', checks: [] },
|
|
916
|
+
verificationRounds: 0,
|
|
917
|
+
verificationHistory: [],
|
|
918
|
+
reconciliations: [],
|
|
919
|
+
cancellation: { status: 'none' },
|
|
920
|
+
startedAt,
|
|
921
|
+
observedAt: startedAt,
|
|
922
|
+
endedAt: null,
|
|
923
|
+
deadline: new Date(Date.now() + def.deadlineMs).toISOString(),
|
|
924
|
+
baseline: {
|
|
925
|
+
repository: root,
|
|
926
|
+
revision: revision(root),
|
|
927
|
+
framework: {
|
|
928
|
+
version: FRAMEWORK_VERSION,
|
|
929
|
+
runtimeHash: hash(fs.readFileSync(__filename)),
|
|
930
|
+
installManifestHash: fileHash(root, '_bmad/.bmad-plus-install.json', false),
|
|
931
|
+
nodeVersion: process.version,
|
|
932
|
+
platform: process.platform,
|
|
933
|
+
arch: process.arch,
|
|
934
|
+
},
|
|
935
|
+
scopeHashes: scopeSnapshot(root, def.scope),
|
|
936
|
+
artifactHashes: filesSnapshot(root, def.artifacts, false),
|
|
937
|
+
resourceHashes: resources,
|
|
938
|
+
config: clone(run.plan.config),
|
|
939
|
+
},
|
|
940
|
+
authorizations: [...def.authorizations],
|
|
941
|
+
observations: [],
|
|
942
|
+
uncheckedScope:
|
|
943
|
+
'External effects, undeclared dependencies and files outside the declared scope are not sandboxed or independently observed.',
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function startTask(directory, runId, taskId, options = {}) {
|
|
948
|
+
return mutate(directory, runId, (root, run) => {
|
|
949
|
+
if (definition(run, taskId).execution || options.backend?.kind !== 'host')
|
|
950
|
+
fail('Executable plans use launch; start requires a host-owned plan and host identity.');
|
|
951
|
+
begin(root, run, taskOf(run, taskId), options, false);
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
function retryTask(directory, runId, taskId, options = {}) {
|
|
955
|
+
return mutate(directory, runId, (root, run) => {
|
|
956
|
+
if (definition(run, taskId).execution || options.backend?.kind !== 'host')
|
|
957
|
+
fail(
|
|
958
|
+
'Executable plans retry through launch with retry: true; retry requires a host identity.'
|
|
959
|
+
);
|
|
960
|
+
begin(root, run, taskOf(run, taskId), options, true);
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function recordAttempt(directory, runId, taskId, input = {}) {
|
|
965
|
+
return mutate(directory, runId, (root, run) => {
|
|
966
|
+
const task = taskOf(run, taskId);
|
|
967
|
+
const attempt = boundAttempt(task, input);
|
|
968
|
+
if (attempt.backend.kind === 'process')
|
|
969
|
+
fail('Process attempts collect their owned receipt; worker observations cannot replace it.');
|
|
970
|
+
if (
|
|
971
|
+
!['running', 'blocked'].includes(attempt.execution) ||
|
|
972
|
+
attempt.cancellation.status !== 'none'
|
|
973
|
+
)
|
|
974
|
+
fail('This attempt requires reconciliation, not a late worker acknowledgement.');
|
|
975
|
+
if (!['completed', 'failed', 'blocked', 'ambiguous'].includes(input.outcome))
|
|
976
|
+
fail('Unsupported worker outcome.');
|
|
977
|
+
if (input.exitCode !== undefined && !Number.isInteger(input.exitCode))
|
|
978
|
+
fail('exitCode must be an integer.');
|
|
979
|
+
if (input.outcome === 'completed' && input.exitCode !== undefined && input.exitCode !== 0)
|
|
980
|
+
fail('Nonzero exit cannot be a completed outcome.');
|
|
981
|
+
const observation = {
|
|
982
|
+
at: now(),
|
|
983
|
+
outcome: input.outcome,
|
|
984
|
+
summary: text(input.summary, 'Worker observation'),
|
|
985
|
+
exitCode: input.exitCode ?? null,
|
|
986
|
+
evidenceClass: 'host-report',
|
|
987
|
+
independentlyVerified: false,
|
|
988
|
+
};
|
|
989
|
+
attempt.observations.push(observation);
|
|
990
|
+
attempt.execution = input.outcome;
|
|
991
|
+
attempt.observedAt = observation.at;
|
|
992
|
+
if (['completed', 'failed'].includes(input.outcome)) {
|
|
993
|
+
attempt.endedAt = observation.at;
|
|
994
|
+
attempt.resultHashes = scopeSnapshot(root, definition(run, taskId).scope);
|
|
995
|
+
}
|
|
996
|
+
if (input.outcome === 'blocked') attempt.blockedRequest = observation.summary;
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function reconcileAttempt(directory, runId, taskId, input = {}) {
|
|
1001
|
+
return mutate(directory, runId, (root, run) => {
|
|
1002
|
+
const task = taskOf(run, taskId);
|
|
1003
|
+
const attempt = boundAttempt(task, input);
|
|
1004
|
+
if (task.integration === 'accepted')
|
|
1005
|
+
fail('Accepted work cannot be rewritten through reconciliation.');
|
|
1006
|
+
if (!['completed', 'failed', 'cancelled'].includes(input.outcome))
|
|
1007
|
+
fail('Reconciliation must establish completed, failed or cancelled work.');
|
|
1008
|
+
if (input.ownerStopped !== true)
|
|
1009
|
+
fail(
|
|
1010
|
+
'Reconciliation requires an explicit ownerStopped observation; timeouts alone are insufficient.'
|
|
1011
|
+
);
|
|
1012
|
+
if (
|
|
1013
|
+
attempt.backend.kind === 'process' &&
|
|
1014
|
+
attempt.process?.status !== 'finished' &&
|
|
1015
|
+
attempt.backend.hostname === os.hostname()
|
|
1016
|
+
) {
|
|
1017
|
+
try {
|
|
1018
|
+
// A live PID blocks an unsafe takeover even if it may have been reused.
|
|
1019
|
+
// Only the original spawn handle may send a termination signal.
|
|
1020
|
+
process.kill(attempt.backend.ownerPid, 0);
|
|
1021
|
+
fail(
|
|
1022
|
+
'Process supervisor may still be alive; request cancellation and inspect its actual operation.'
|
|
1023
|
+
);
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
if (error.code !== 'ESRCH') throw error;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
if (
|
|
1029
|
+
attempt.backend.kind === 'process' &&
|
|
1030
|
+
attempt.backend.hostname === os.hostname() &&
|
|
1031
|
+
!attempt.process?.receipt?.directChildExited &&
|
|
1032
|
+
Number.isInteger(attempt.process?.childPid)
|
|
1033
|
+
) {
|
|
1034
|
+
try {
|
|
1035
|
+
process.kill(attempt.process.childPid, 0);
|
|
1036
|
+
fail(
|
|
1037
|
+
'The original child may still be alive; inspect its effects before stopped-owner reconciliation.'
|
|
1038
|
+
);
|
|
1039
|
+
} catch (error) {
|
|
1040
|
+
if (error.code !== 'ESRCH') throw error;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
const verifierOwner = attempt.verification.owner;
|
|
1044
|
+
if (attempt.verification.status === 'running' && verifierOwner?.hostname === os.hostname()) {
|
|
1045
|
+
try {
|
|
1046
|
+
process.kill(verifierOwner.pid, 0);
|
|
1047
|
+
fail('Verification process is still alive; request cancellation and wait for it to stop.');
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
if (error.code !== 'ESRCH') throw error;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
const observedHashes = scopeSnapshot(root, definition(run, taskId).scope);
|
|
1053
|
+
if (input.outcome === 'completed' && attempt.verification.status === 'failed')
|
|
1054
|
+
fail('Failed verification requires a new attempt; reconciliation cannot reset it.');
|
|
1055
|
+
if (
|
|
1056
|
+
input.outcome === 'completed' &&
|
|
1057
|
+
attempt.resultHashes &&
|
|
1058
|
+
!same(observedHashes, attempt.resultHashes)
|
|
1059
|
+
)
|
|
1060
|
+
fail('Recorded result changed; reconcile failure and retry with a new attempt.');
|
|
1061
|
+
const reconciliation = {
|
|
1062
|
+
at: now(),
|
|
1063
|
+
summary: text(input.summary, 'Reconciliation observation'),
|
|
1064
|
+
outcome: input.outcome,
|
|
1065
|
+
ownerStopped: true,
|
|
1066
|
+
retrySafe: input.retrySafe === true,
|
|
1067
|
+
observedHashes,
|
|
1068
|
+
evidenceClass: 'operator-observation',
|
|
1069
|
+
independentlyVerified: false,
|
|
1070
|
+
};
|
|
1071
|
+
attempt.reconciliation = reconciliation;
|
|
1072
|
+
attempt.reconciliations.push(clone(reconciliation));
|
|
1073
|
+
attempt.execution = input.outcome;
|
|
1074
|
+
attempt.observedAt = reconciliation.at;
|
|
1075
|
+
attempt.endedAt = reconciliation.at;
|
|
1076
|
+
if (attempt.verification.status === 'running' || attempt.verification.status === 'ambiguous')
|
|
1077
|
+
attempt.verification.status = 'ambiguous';
|
|
1078
|
+
// An interrupted verification never becomes evidence; an explicit stopped-owner
|
|
1079
|
+
// observation permits a fresh verify without inheriting its partial checks.
|
|
1080
|
+
if (attempt.verification.status === 'ambiguous') {
|
|
1081
|
+
attempt.verificationHistory.push(clone(attempt.verification));
|
|
1082
|
+
attempt.verification = {
|
|
1083
|
+
status: 'pending',
|
|
1084
|
+
checks: [],
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
if (attempt.cancellation.status === 'requested' || input.outcome === 'cancelled')
|
|
1088
|
+
attempt.cancellation = {
|
|
1089
|
+
...attempt.cancellation,
|
|
1090
|
+
status: 'reconciled',
|
|
1091
|
+
reconciledAt: now(),
|
|
1092
|
+
resolvedOutcome: input.outcome,
|
|
1093
|
+
};
|
|
1094
|
+
attempt.resultHashes ||= observedHashes;
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function cancelTask(directory, runId, taskId, input = {}) {
|
|
1099
|
+
return mutate(directory, runId, (_root, run) => {
|
|
1100
|
+
const task = taskOf(run, taskId);
|
|
1101
|
+
const attempt = current(task);
|
|
1102
|
+
if (!attempt || task.integration === 'accepted')
|
|
1103
|
+
fail('Only an unaccepted existing attempt can be cancelled.');
|
|
1104
|
+
if (attempt.cancellation.status !== 'none')
|
|
1105
|
+
fail('Cancellation is already recorded; inspect and reconcile it.');
|
|
1106
|
+
if (attempt.backend.kind === 'process') boundAttempt(task, input);
|
|
1107
|
+
attempt.cancellation = {
|
|
1108
|
+
status: 'requested',
|
|
1109
|
+
requestedAt: now(),
|
|
1110
|
+
reason: text(input.reason, 'Cancellation reason'),
|
|
1111
|
+
termination:
|
|
1112
|
+
attempt.backend.kind === 'process'
|
|
1113
|
+
? 'requested-from-original-owner'
|
|
1114
|
+
: 'unresolved-host-owned',
|
|
1115
|
+
processesSignalled: [],
|
|
1116
|
+
};
|
|
1117
|
+
if (['running', 'blocked'].includes(attempt.execution)) attempt.execution = 'ambiguous';
|
|
1118
|
+
// A separate client never owns a spawn handle. The original supervisor polls this request.
|
|
1119
|
+
attempt.observedAt = now();
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
async function launchTask(directory, runId, taskId, options = {}) {
|
|
1124
|
+
if (
|
|
1125
|
+
Object.keys(options).some((key) => key !== 'retry') ||
|
|
1126
|
+
(options.retry !== undefined && typeof options.retry !== 'boolean')
|
|
1127
|
+
)
|
|
1128
|
+
fail('launch accepts only an optional boolean retry; executable settings belong in the plan.');
|
|
1129
|
+
const backend = backendIdentity({
|
|
1130
|
+
kind: 'process',
|
|
1131
|
+
id: crypto.randomUUID(),
|
|
1132
|
+
sessionId: crypto.randomUUID(),
|
|
1133
|
+
hostname: os.hostname(),
|
|
1134
|
+
ownerPid: process.pid,
|
|
1135
|
+
});
|
|
1136
|
+
const prepared = mutate(directory, runId, (root, run) => {
|
|
1137
|
+
const def = definition(run, taskId);
|
|
1138
|
+
if (!def?.execution) fail('launch needs a plan with an explicit execution contract.');
|
|
1139
|
+
begin(root, run, taskOf(run, taskId), { backend }, options.retry === true);
|
|
1140
|
+
const attempt = current(taskOf(run, taskId));
|
|
1141
|
+
attempt.process = {
|
|
1142
|
+
status: 'starting',
|
|
1143
|
+
owner: clone(backend),
|
|
1144
|
+
childPid: null,
|
|
1145
|
+
heartbeatAt: now(),
|
|
1146
|
+
collectedAt: null,
|
|
1147
|
+
runtimeHash: hash(fs.readFileSync(require.resolve('./nexus-process'))),
|
|
1148
|
+
};
|
|
1149
|
+
});
|
|
1150
|
+
const root = rootOf(directory);
|
|
1151
|
+
const def = definition(prepared, taskId);
|
|
1152
|
+
const attempt = current(taskOf(prepared, taskId));
|
|
1153
|
+
const binding = { attemptId: attempt.id, backend };
|
|
1154
|
+
function owned(run) {
|
|
1155
|
+
const activeAttempt = boundAttempt(taskOf(run, taskId), binding);
|
|
1156
|
+
if (
|
|
1157
|
+
!['starting', 'running'].includes(activeAttempt.process?.status) ||
|
|
1158
|
+
!['running', 'ambiguous'].includes(activeAttempt.execution)
|
|
1159
|
+
)
|
|
1160
|
+
fail('Original process no longer owns the active operation.');
|
|
1161
|
+
return activeAttempt;
|
|
1162
|
+
}
|
|
1163
|
+
let receipt;
|
|
1164
|
+
try {
|
|
1165
|
+
protectedSnapshot(root, def);
|
|
1166
|
+
const input = processBackend.inputBytes(root, def.execution, safeTarget);
|
|
1167
|
+
const environment = processBackend.executionEnvironment(def.execution, checkEnvironment());
|
|
1168
|
+
receipt = await processBackend.runOwnedProcess({
|
|
1169
|
+
root,
|
|
1170
|
+
execution: def.execution,
|
|
1171
|
+
input,
|
|
1172
|
+
environment,
|
|
1173
|
+
deadline: Date.parse(attempt.deadline),
|
|
1174
|
+
callbacks: {
|
|
1175
|
+
spawned: (pid) =>
|
|
1176
|
+
mutate(root, runId, (_root, run) => {
|
|
1177
|
+
const activeAttempt = owned(run);
|
|
1178
|
+
activeAttempt.process.status = 'running';
|
|
1179
|
+
activeAttempt.process.childPid = pid;
|
|
1180
|
+
activeAttempt.process.heartbeatAt = now();
|
|
1181
|
+
}),
|
|
1182
|
+
poll: () => {
|
|
1183
|
+
const activeAttempt = owned(loadRun(root, runId));
|
|
1184
|
+
return activeAttempt.cancellation.status === 'requested'
|
|
1185
|
+
? 'cancellation-requested'
|
|
1186
|
+
: null;
|
|
1187
|
+
},
|
|
1188
|
+
heartbeat: () =>
|
|
1189
|
+
mutate(root, runId, (_root, run) => {
|
|
1190
|
+
const activeAttempt = owned(run);
|
|
1191
|
+
activeAttempt.process.heartbeatAt = now();
|
|
1192
|
+
}),
|
|
1193
|
+
},
|
|
1194
|
+
});
|
|
1195
|
+
Object.assign(receipt, {
|
|
1196
|
+
schemaVersion: 1,
|
|
1197
|
+
attemptId: attempt.id,
|
|
1198
|
+
backend,
|
|
1199
|
+
planHash: prepared.planHash,
|
|
1200
|
+
execution: clone(def.execution),
|
|
1201
|
+
inputSha256: hash(input),
|
|
1202
|
+
inputBytes: input.length,
|
|
1203
|
+
environment: environment.receipt,
|
|
1204
|
+
evidenceClass: 'owned-process-result',
|
|
1205
|
+
independentlyVerified: false,
|
|
1206
|
+
});
|
|
1207
|
+
try {
|
|
1208
|
+
protectedSnapshot(root, def);
|
|
1209
|
+
receipt.resultHashes = scopeSnapshot(root, def.scope);
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
receipt.outcome = 'ambiguous';
|
|
1212
|
+
receipt.snapshotError = error.message;
|
|
1213
|
+
}
|
|
1214
|
+
} catch (error) {
|
|
1215
|
+
receipt = {
|
|
1216
|
+
schemaVersion: 1,
|
|
1217
|
+
attemptId: attempt.id,
|
|
1218
|
+
backend,
|
|
1219
|
+
planHash: prepared.planHash,
|
|
1220
|
+
outcome: 'ambiguous',
|
|
1221
|
+
error: error.message,
|
|
1222
|
+
endedAt: now(),
|
|
1223
|
+
evidenceClass: 'launch-interrupted',
|
|
1224
|
+
independentlyVerified: false,
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
return mutate(root, runId, (_root, run) => {
|
|
1228
|
+
const activeAttempt = owned(run);
|
|
1229
|
+
activeAttempt.process.status = 'finished';
|
|
1230
|
+
activeAttempt.process.receipt = receipt;
|
|
1231
|
+
activeAttempt.process.receiptHash = hash(JSON.stringify(receipt));
|
|
1232
|
+
activeAttempt.process.heartbeatAt = now();
|
|
1233
|
+
activeAttempt.observedAt = now();
|
|
1234
|
+
if (receipt.outcome === 'ambiguous') activeAttempt.execution = 'ambiguous';
|
|
1235
|
+
if (activeAttempt.cancellation.status === 'requested') {
|
|
1236
|
+
activeAttempt.cancellation.termination = receipt.directChildExited
|
|
1237
|
+
? 'direct-child-exited-descendants-unobserved'
|
|
1238
|
+
: 'unresolved';
|
|
1239
|
+
activeAttempt.cancellation.processesSignalled = receipt.directChildSignalled
|
|
1240
|
+
? [activeAttempt.process.childPid]
|
|
1241
|
+
: [];
|
|
1242
|
+
}
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function collectTask(directory, runId, taskId, input = {}) {
|
|
1247
|
+
return mutate(directory, runId, (root, run) => {
|
|
1248
|
+
const task = taskOf(run, taskId);
|
|
1249
|
+
const attempt = boundAttempt(task, input);
|
|
1250
|
+
if (attempt.backend.kind !== 'process') fail('collect requires an owned process attempt.');
|
|
1251
|
+
const operation = attempt.process;
|
|
1252
|
+
const receipt = operation?.receipt;
|
|
1253
|
+
if (operation?.status !== 'finished' || !receipt)
|
|
1254
|
+
fail('Process receipt is not ready; inspect the original owner.');
|
|
1255
|
+
if (operation.collectedAt) fail('This process receipt was already collected.');
|
|
1256
|
+
if (
|
|
1257
|
+
operation.receiptHash !== hash(JSON.stringify(receipt)) ||
|
|
1258
|
+
receipt.attemptId !== attempt.id ||
|
|
1259
|
+
!same(receipt.backend, attempt.backend) ||
|
|
1260
|
+
receipt.planHash !== run.planHash
|
|
1261
|
+
)
|
|
1262
|
+
fail('Foreign or corrupt process receipt.');
|
|
1263
|
+
if (attempt.reconciliations.length)
|
|
1264
|
+
fail('A reconciled attempt cannot be overwritten by a late process receipt.');
|
|
1265
|
+
if (receipt.outcome === 'completed') {
|
|
1266
|
+
if (
|
|
1267
|
+
attempt.cancellation.status !== 'none' ||
|
|
1268
|
+
receipt.exitCode !== 0 ||
|
|
1269
|
+
receipt.error ||
|
|
1270
|
+
!receipt.directChildExited ||
|
|
1271
|
+
!receipt.streamsClosed
|
|
1272
|
+
)
|
|
1273
|
+
fail('Ambiguous execution cannot be collected as completed.');
|
|
1274
|
+
protectedSnapshot(root, definition(run, taskId));
|
|
1275
|
+
if (!same(scopeSnapshot(root, definition(run, taskId).scope), receipt.resultHashes))
|
|
1276
|
+
fail('Process result changed before collection; reconcile and create a fresh attempt.');
|
|
1277
|
+
}
|
|
1278
|
+
operation.collectedAt = now();
|
|
1279
|
+
attempt.execution = attempt.cancellation.status === 'requested' ? 'ambiguous' : receipt.outcome;
|
|
1280
|
+
attempt.observedAt = now();
|
|
1281
|
+
if (['completed', 'failed'].includes(attempt.execution)) attempt.endedAt = receipt.endedAt;
|
|
1282
|
+
if (receipt.resultHashes) attempt.resultHashes = clone(receipt.resultHashes);
|
|
1283
|
+
if (attempt.execution === 'blocked')
|
|
1284
|
+
attempt.blockedRequest =
|
|
1285
|
+
'Executable reported a blocked operation; inspect the receipt and existing authorization. No approval is inferred.';
|
|
1286
|
+
attempt.observations.push({
|
|
1287
|
+
at: now(),
|
|
1288
|
+
outcome: attempt.execution,
|
|
1289
|
+
evidenceClass: 'owned-process-result',
|
|
1290
|
+
receiptHash: operation.receiptHash,
|
|
1291
|
+
independentlyVerified: false,
|
|
1292
|
+
summary: 'Collected the exact foreground supervisor result. Verification remains separate.',
|
|
1293
|
+
});
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function runCheck(root, check, attemptId, environment) {
|
|
1298
|
+
const startedAt = now();
|
|
1299
|
+
return new Promise((resolve) => {
|
|
1300
|
+
let child;
|
|
1301
|
+
let timer;
|
|
1302
|
+
let settled = false;
|
|
1303
|
+
let timedOut = false;
|
|
1304
|
+
let outputBytes = 0;
|
|
1305
|
+
let stdout = '';
|
|
1306
|
+
let stderr = '';
|
|
1307
|
+
let truncated = false;
|
|
1308
|
+
let spawnError = null;
|
|
1309
|
+
function append(kind, buffer) {
|
|
1310
|
+
const remaining = MAX_OUTPUT_BYTES - outputBytes;
|
|
1311
|
+
if (buffer.length > remaining) truncated = true;
|
|
1312
|
+
const selected = buffer.subarray(0, Math.max(0, remaining));
|
|
1313
|
+
outputBytes += selected.length;
|
|
1314
|
+
if (kind === 'stdout') stdout += selected.toString('utf8');
|
|
1315
|
+
else stderr += selected.toString('utf8');
|
|
1316
|
+
}
|
|
1317
|
+
function finish(exitCode, signal) {
|
|
1318
|
+
if (settled) return;
|
|
1319
|
+
settled = true;
|
|
1320
|
+
clearTimeout(timer);
|
|
1321
|
+
resolve({
|
|
1322
|
+
id: check.id,
|
|
1323
|
+
attemptId,
|
|
1324
|
+
command: check.command,
|
|
1325
|
+
commandHash: check.commandHash,
|
|
1326
|
+
args: check.args,
|
|
1327
|
+
cwd: check.cwd,
|
|
1328
|
+
timeoutMs: check.timeoutMs,
|
|
1329
|
+
environment: clone(environment),
|
|
1330
|
+
startedAt,
|
|
1331
|
+
endedAt: now(),
|
|
1332
|
+
exitCode,
|
|
1333
|
+
signal,
|
|
1334
|
+
error: spawnError,
|
|
1335
|
+
timedOut,
|
|
1336
|
+
stdout,
|
|
1337
|
+
stderr,
|
|
1338
|
+
outputTruncated: truncated,
|
|
1339
|
+
outputHash: hash(stdout + '\0' + stderr),
|
|
1340
|
+
evidenceClass: 'runtime-command',
|
|
1341
|
+
termination: timedOut
|
|
1342
|
+
? 'owned-direct-child-only; descendant effects require inspection'
|
|
1343
|
+
: null,
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
try {
|
|
1347
|
+
child = spawn(check.command, check.args, {
|
|
1348
|
+
cwd: directoryAt(root, check.cwd),
|
|
1349
|
+
env: environment.values,
|
|
1350
|
+
shell: false,
|
|
1351
|
+
windowsHide: true,
|
|
1352
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1353
|
+
});
|
|
1354
|
+
child.stdout.on('data', (chunk) => append('stdout', chunk));
|
|
1355
|
+
child.stderr.on('data', (chunk) => append('stderr', chunk));
|
|
1356
|
+
child.on('error', (error) => {
|
|
1357
|
+
spawnError = error.message;
|
|
1358
|
+
finish(null, null);
|
|
1359
|
+
});
|
|
1360
|
+
child.on('close', finish);
|
|
1361
|
+
timer = setTimeout(() => {
|
|
1362
|
+
timedOut = true;
|
|
1363
|
+
// Signal only the child object created here; there is no PID lookup or
|
|
1364
|
+
// tree-wide kill. Descendants are outside this first backend contract.
|
|
1365
|
+
child.kill('SIGKILL');
|
|
1366
|
+
child.stdout.destroy();
|
|
1367
|
+
child.stderr.destroy();
|
|
1368
|
+
finish(null, 'SIGKILL');
|
|
1369
|
+
}, check.timeoutMs);
|
|
1370
|
+
} catch (error) {
|
|
1371
|
+
spawnError = error.message;
|
|
1372
|
+
finish(null, null);
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
async function verifyTask(directory, runId, taskId) {
|
|
1378
|
+
const verificationId = crypto.randomUUID();
|
|
1379
|
+
const environment = checkEnvironment();
|
|
1380
|
+
const prepared = mutate(directory, runId, (root, run) => {
|
|
1381
|
+
const task = taskOf(run, taskId);
|
|
1382
|
+
const attempt = current(task);
|
|
1383
|
+
const def = definition(run, taskId);
|
|
1384
|
+
if (
|
|
1385
|
+
!attempt ||
|
|
1386
|
+
attempt.execution !== 'completed' ||
|
|
1387
|
+
attempt.cancellation.status === 'requested' ||
|
|
1388
|
+
task.integration === 'accepted'
|
|
1389
|
+
)
|
|
1390
|
+
fail('Verification requires completed, unaccepted, reconciled work.');
|
|
1391
|
+
if (attempt.verification.status !== 'pending')
|
|
1392
|
+
fail('Verification already exists; inspect/reconcile or retry before executing again.');
|
|
1393
|
+
if (attempt.verificationRounds >= def.maxAttempts)
|
|
1394
|
+
fail('Verification restart limit reached; reconcile and create a new attempt.');
|
|
1395
|
+
protectedSnapshot(root, def);
|
|
1396
|
+
const scopeHashes = scopeSnapshot(root, def.scope);
|
|
1397
|
+
if (!same(scopeHashes, attempt.resultHashes))
|
|
1398
|
+
fail('Artifacts changed after the worker result; reconcile the current result first.');
|
|
1399
|
+
attempt.verificationRounds += 1;
|
|
1400
|
+
attempt.verification = {
|
|
1401
|
+
id: verificationId,
|
|
1402
|
+
attemptId: attempt.id,
|
|
1403
|
+
status: 'running',
|
|
1404
|
+
startedAt: now(),
|
|
1405
|
+
frameworkVersion: FRAMEWORK_VERSION,
|
|
1406
|
+
environment: clone(environment),
|
|
1407
|
+
owner: { pid: process.pid, hostname: os.hostname() },
|
|
1408
|
+
revision: revision(root),
|
|
1409
|
+
scopeHashes,
|
|
1410
|
+
artifactHashes: filesSnapshot(root, def.artifacts),
|
|
1411
|
+
resourceHashes: clone(def.resourceHashes),
|
|
1412
|
+
checks: [],
|
|
1413
|
+
};
|
|
1414
|
+
});
|
|
1415
|
+
const root = rootOf(directory);
|
|
1416
|
+
const def = definition(prepared, taskId);
|
|
1417
|
+
const attemptId = current(taskOf(prepared, taskId)).id;
|
|
1418
|
+
for (const check of def.checks) {
|
|
1419
|
+
const before = loadRun(root, runId);
|
|
1420
|
+
const activeAttempt = current(taskOf(before, taskId));
|
|
1421
|
+
if (
|
|
1422
|
+
activeAttempt.id !== attemptId ||
|
|
1423
|
+
activeAttempt.verification.id !== verificationId ||
|
|
1424
|
+
activeAttempt.verification.status !== 'running'
|
|
1425
|
+
)
|
|
1426
|
+
fail('Verification ownership changed; no further command was launched.');
|
|
1427
|
+
if (activeAttempt.cancellation.status === 'requested') break;
|
|
1428
|
+
let receipt;
|
|
1429
|
+
try {
|
|
1430
|
+
protectedSnapshot(root, def);
|
|
1431
|
+
if (!same(scopeSnapshot(root, def.scope), activeAttempt.verification.scopeHashes))
|
|
1432
|
+
fail('Checked files changed before the next verification command.');
|
|
1433
|
+
receipt = await runCheck(root, check, attemptId, environment);
|
|
1434
|
+
receipt.artifactHashes = clone(activeAttempt.verification.artifactHashes);
|
|
1435
|
+
receipt.scopeSnapshotHash = hash(JSON.stringify(activeAttempt.verification.scopeHashes));
|
|
1436
|
+
} catch (error) {
|
|
1437
|
+
receipt = {
|
|
1438
|
+
id: check.id,
|
|
1439
|
+
attemptId,
|
|
1440
|
+
exitCode: null,
|
|
1441
|
+
error: error.message,
|
|
1442
|
+
evidenceClass: 'runtime-preflight',
|
|
1443
|
+
startedAt: now(),
|
|
1444
|
+
endedAt: now(),
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
const saved = mutate(root, runId, (_root, run) => {
|
|
1448
|
+
const attempt = current(taskOf(run, taskId));
|
|
1449
|
+
if (
|
|
1450
|
+
attempt.id !== attemptId ||
|
|
1451
|
+
attempt.verification.id !== verificationId ||
|
|
1452
|
+
attempt.verification.status !== 'running'
|
|
1453
|
+
)
|
|
1454
|
+
fail('Stale verification result was rejected.');
|
|
1455
|
+
attempt.verification.checks.push(receipt);
|
|
1456
|
+
});
|
|
1457
|
+
if (
|
|
1458
|
+
receipt.exitCode !== 0 ||
|
|
1459
|
+
receipt.error ||
|
|
1460
|
+
receipt.signal ||
|
|
1461
|
+
current(taskOf(saved, taskId)).cancellation.status === 'requested'
|
|
1462
|
+
)
|
|
1463
|
+
break;
|
|
1464
|
+
}
|
|
1465
|
+
return mutate(root, runId, (_root, run) => {
|
|
1466
|
+
const task = taskOf(run, taskId);
|
|
1467
|
+
const attempt = current(task);
|
|
1468
|
+
if (
|
|
1469
|
+
attempt.id !== attemptId ||
|
|
1470
|
+
attempt.verification.id !== verificationId ||
|
|
1471
|
+
attempt.verification.status !== 'running'
|
|
1472
|
+
)
|
|
1473
|
+
fail('Stale verification finalization was rejected.');
|
|
1474
|
+
let problem = null;
|
|
1475
|
+
try {
|
|
1476
|
+
protectedSnapshot(root, def);
|
|
1477
|
+
if (
|
|
1478
|
+
!same(scopeSnapshot(root, def.scope), attempt.verification.scopeHashes) ||
|
|
1479
|
+
!same(filesSnapshot(root, def.artifacts), attempt.verification.artifactHashes)
|
|
1480
|
+
)
|
|
1481
|
+
problem = 'Checked files changed during verification.';
|
|
1482
|
+
} catch (error) {
|
|
1483
|
+
problem = error.message;
|
|
1484
|
+
}
|
|
1485
|
+
const checks = attempt.verification.checks;
|
|
1486
|
+
const uncertain =
|
|
1487
|
+
checks.some((check) => check.timedOut) || attempt.cancellation.status === 'requested';
|
|
1488
|
+
const passed =
|
|
1489
|
+
!problem &&
|
|
1490
|
+
!uncertain &&
|
|
1491
|
+
checks.length === def.checks.length &&
|
|
1492
|
+
checks.every((check) => check.exitCode === 0 && !check.error && !check.signal);
|
|
1493
|
+
attempt.verification.status = uncertain ? 'ambiguous' : passed ? 'passed' : 'failed';
|
|
1494
|
+
attempt.verification.problem = problem;
|
|
1495
|
+
attempt.verification.endedAt = now();
|
|
1496
|
+
if (!passed) task.integration = 'rejected';
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
function acceptTask(directory, runId, taskId) {
|
|
1501
|
+
return mutate(directory, runId, (root, run) => {
|
|
1502
|
+
const task = taskOf(run, taskId);
|
|
1503
|
+
if (task.integration === 'accepted') fail('Task is already accepted.');
|
|
1504
|
+
evidenceCurrent(root, run, task);
|
|
1505
|
+
task.integration = 'accepted';
|
|
1506
|
+
task.acceptedAt = now();
|
|
1507
|
+
task.integrationReceipt = {
|
|
1508
|
+
attemptId: current(task).id,
|
|
1509
|
+
artifactHashes: clone(current(task).verification.artifactHashes),
|
|
1510
|
+
action: 'accept-current-workspace',
|
|
1511
|
+
gitOperation: null,
|
|
1512
|
+
};
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
module.exports = {
|
|
1517
|
+
createRun,
|
|
1518
|
+
inspectRun,
|
|
1519
|
+
startTask,
|
|
1520
|
+
recordAttempt,
|
|
1521
|
+
reconcileAttempt,
|
|
1522
|
+
verifyTask,
|
|
1523
|
+
retryTask,
|
|
1524
|
+
cancelTask,
|
|
1525
|
+
acceptTask,
|
|
1526
|
+
recoverLock,
|
|
1527
|
+
launchTask,
|
|
1528
|
+
collectTask,
|
|
1529
|
+
BASE,
|
|
1530
|
+
LOCK,
|
|
1531
|
+
RECOVERY_LOCK,
|
|
1532
|
+
};
|