@thebackstoryis/engineering-with-ai 0.2.9 → 0.3.0-beta.2
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/Docs/README.md +2 -0
- package/Docs/autonomous-intent-delivery.md +37 -0
- package/Docs/guide-catalogue.md +2 -0
- package/Docs/quality/persona-driven-test-scenarios.md +6 -0
- package/README.md +17 -0
- package/config/autonomy-worker.schema.json +70 -0
- package/config/autonomy.schema.json +25 -0
- package/package.json +3 -3
- package/public/app.js +7 -0
- package/public/autonomy.js +470 -0
- package/public/index.html +26 -0
- package/public/styles.css +64 -0
- package/skills-src/ewai-autonomy/SKILL.md +18 -0
- package/skills-src/ewai-autonomy/agents/openai.yaml +4 -0
- package/skills-src/ewai-deliver/SKILL.md +2 -0
- package/skills-src/ewai-test-scenarios/references/scenario-contract.md +6 -0
- package/src/autonomy-phase-contracts.mjs +88 -0
- package/src/autonomy-worker.mjs +15 -0
- package/src/autonomy.mjs +199 -0
- package/src/checkin.mjs +30 -6
- package/src/cli.mjs +67 -0
- package/src/delivery-gates.mjs +8 -1
- package/src/delivery.mjs +74 -3
- package/src/design-system-application.mjs +28 -0
- package/src/runtime/afk-conductor.mjs +191 -71
- package/src/runtime/autonomy-operations.mjs +250 -0
- package/src/runtime/autonomy-supervisor.mjs +426 -0
- package/src/runtime/autonomy-workers.mjs +333 -0
- package/src/runtime/autonomy-workspace.mjs +310 -0
- package/src/runtime/dashboard-actions.mjs +69 -0
- package/src/runtime/dashboard-server.mjs +25 -0
- package/src/runtime/database.mjs +25 -6
- package/src/runtime/intent-ownership.mjs +272 -0
- package/src/runtime/mcp-server.mjs +38 -0
- package/src/runtime/provider-adapters.mjs +365 -7
- package/src/runtime/work.mjs +21 -5
- package/src/test-scenarios.mjs +173 -20
|
@@ -26,6 +26,9 @@ const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
|
26
26
|
const workerPath = resolve(moduleDir, '../afk-worker.mjs');
|
|
27
27
|
const runSchema = 'ewai.afk-run/v1';
|
|
28
28
|
const terminalStates = new Set(['completed', 'cancelled']);
|
|
29
|
+
const runningControls = new Map();
|
|
30
|
+
const authorityChecks = new WeakMap();
|
|
31
|
+
const controlKey = (root, id) => `${resolve(root)}:${id}`;
|
|
29
32
|
|
|
30
33
|
function git(root, args, options = {}) {
|
|
31
34
|
return execFileSync('git', args, {
|
|
@@ -68,12 +71,13 @@ function requireRun(projectRoot, runId) {
|
|
|
68
71
|
return { paths, run };
|
|
69
72
|
}
|
|
70
73
|
|
|
71
|
-
function saveRun(paths, run) {
|
|
74
|
+
function saveRun(paths, run, resumeFrom = null) {
|
|
72
75
|
if (existsSync(paths.runPath)) {
|
|
73
76
|
const current = JSON.parse(readFileSync(paths.runPath, 'utf8'));
|
|
74
|
-
if (
|
|
77
|
+
if (resumeFrom !== null && JSON.stringify(current) !== resumeFrom) throw new Error('AFK control changed before resume; refresh its current state.');
|
|
78
|
+
if (resumeFrom === null && current.id === run.id && current.desiredState !== 'running' && run.desiredState === 'running') {
|
|
75
79
|
run.desiredState = current.desiredState;
|
|
76
|
-
if (current.desiredState === 'cancelled') run.status = '
|
|
80
|
+
if (current.desiredState === 'cancelled') run.status = 'cancel-requested';
|
|
77
81
|
}
|
|
78
82
|
}
|
|
79
83
|
run.updatedAt = now();
|
|
@@ -321,6 +325,15 @@ function spawnWorker(projectRoot, runId) {
|
|
|
321
325
|
}
|
|
322
326
|
|
|
323
327
|
export function startAfkRun(projectRoot, slug, options = {}) {
|
|
328
|
+
if (options.autonomy !== undefined) {
|
|
329
|
+
const value = options.autonomy;
|
|
330
|
+
if (!value || Object.getPrototypeOf(value) !== Object.prototype || Object.keys(value).length !== 2
|
|
331
|
+
|| !/^[a-f0-9-]{36}$/.test(value.runId ?? '') || !/^sha256:[a-f0-9]{64}$/.test(value.grantDigest ?? '')
|
|
332
|
+
|| options.foreground !== true || typeof options.dependencies?.authority !== 'function') {
|
|
333
|
+
throw new Error('Autonomous AFK requires its bounded foreground authority context.');
|
|
334
|
+
}
|
|
335
|
+
options.dependencies.authority({ stage: 'prepare', mode: 'command' });
|
|
336
|
+
}
|
|
324
337
|
const root = resolve(projectRoot);
|
|
325
338
|
const lockParent = resolve(root, '.ewai-pipeline/afk/locks');
|
|
326
339
|
const lockPath = resolve(lockParent, `${slug}.lock`);
|
|
@@ -335,6 +348,7 @@ export function startAfkRun(projectRoot, slug, options = {}) {
|
|
|
335
348
|
if (preflight.status !== 'ready') throw new Error(`AFK preflight blocked: ${preflight.failures.join(' ')}`);
|
|
336
349
|
const indexed = syncIntentIndex(preflight.projectRoot).intents.find((intent) => intent.slug === slug);
|
|
337
350
|
if (!indexed) throw new Error(`Unknown intent slug: ${slug}`);
|
|
351
|
+
options.dependencies?.authority?.({ stage: 'prepare', mode: 'command' });
|
|
338
352
|
prepareParentBranches(preflight.topology);
|
|
339
353
|
const runId = randomUUID();
|
|
340
354
|
const paths = pathsFor(preflight.projectRoot, runId);
|
|
@@ -359,8 +373,11 @@ export function startAfkRun(projectRoot, slug, options = {}) {
|
|
|
359
373
|
activeWorkers: [],
|
|
360
374
|
tasks: {},
|
|
361
375
|
messages: [],
|
|
376
|
+
executionStopped: true,
|
|
377
|
+
...(options.autonomy ? { autonomy: { ...options.autonomy } } : {}),
|
|
362
378
|
};
|
|
363
379
|
saveRun(paths, run);
|
|
380
|
+
options.dependencies?.onRun?.(run);
|
|
364
381
|
event(preflight.projectRoot, run, 'Preparing unattended Build execution.', `${preflight.readyTasks.length} task(s) ready; maximum parallelism ${run.maxParallel}.`);
|
|
365
382
|
if (options.foreground === true) return executeAfkRun(preflight.projectRoot, runId, options.dependencies);
|
|
366
383
|
run.pid = spawnWorker(preflight.projectRoot, runId);
|
|
@@ -396,29 +413,28 @@ export function pauseAfkRun(projectRoot, runId) {
|
|
|
396
413
|
return saveRun(paths, run);
|
|
397
414
|
}
|
|
398
415
|
|
|
399
|
-
function stopWorkers(run) {
|
|
400
|
-
for (const worker of run.activeWorkers ?? []) {
|
|
401
|
-
if (processAlive(worker.pid)) {
|
|
402
|
-
try { process.kill(worker.pid, 'SIGTERM'); } catch { /* already gone */ }
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
416
|
export function cancelAfkRun(projectRoot, runId) {
|
|
408
417
|
const { paths, run } = requireRun(projectRoot, runId);
|
|
409
418
|
if (terminalStates.has(run.status)) return run;
|
|
410
419
|
run.desiredState = 'cancelled';
|
|
411
|
-
run.status = '
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
420
|
+
run.status = processAlive(run.pid) ? 'cancel-requested' : 'cancel-unknown';
|
|
421
|
+
run.cancellation = { status: run.status === 'cancel-requested' ? 'requested' : 'unknown', requestedAt: now() };
|
|
422
|
+
// Only the process that owns the child handle may signal it. Another local
|
|
423
|
+
// caller persists the request for that owner's bounded control poll.
|
|
424
|
+
const saved = saveRun(paths, run);
|
|
425
|
+
for (const controller of runningControls.get(controlKey(projectRoot, runId)) ?? []) controller.abort();
|
|
426
|
+
event(paths.projectRoot, run, 'Unattended Build cancellation requested.', 'Termination is not yet confirmed; work and leases are retained.', 'blocked');
|
|
427
|
+
return saved;
|
|
417
428
|
}
|
|
418
429
|
|
|
419
|
-
export function resumeAfkRun(projectRoot, runId) {
|
|
430
|
+
export function resumeAfkRun(projectRoot, runId, options = {}) {
|
|
420
431
|
const { paths, run } = requireRun(projectRoot, runId);
|
|
432
|
+
const resumeFrom = JSON.stringify(run);
|
|
421
433
|
if (run.status === 'completed' || run.status === 'cancelled') throw new Error(`AFK run is ${run.status} and cannot resume.`);
|
|
434
|
+
if (run.desiredState === 'cancelled' || run.executionStopped !== true) throw new Error('AFK execution needs a verified recovery decision before resuming.');
|
|
435
|
+
if (run.autonomy && (run.status !== 'paused' || run.activeWorkers?.length || options.foreground !== true
|
|
436
|
+
|| options.autonomy?.runId !== run.autonomy.runId || options.autonomy?.grantDigest !== run.autonomy.grantDigest
|
|
437
|
+
|| typeof options.dependencies?.authority !== 'function')) throw new Error('Autonomous AFK resume requires the same bounded foreground authority context.');
|
|
422
438
|
if (processAlive(run.pid)) throw new Error('AFK conductor is already running.');
|
|
423
439
|
const preflight = preflightAfkRun(paths.projectRoot, run.slug, {
|
|
424
440
|
provider: run.providers.join(','),
|
|
@@ -426,12 +442,19 @@ export function resumeAfkRun(projectRoot, runId) {
|
|
|
426
442
|
ignoreRunId: run.id,
|
|
427
443
|
});
|
|
428
444
|
if (preflight.status !== 'ready') throw new Error(`AFK recovery preflight blocked: ${preflight.failures.join(' ')}`);
|
|
429
|
-
|
|
430
|
-
abandonExecutionLeasesForRun(paths.projectRoot, run.id, { outcome: 'conductor-recovered' });
|
|
445
|
+
if (run.autonomy) options.dependencies.authority({ stage: 'prepare', mode: 'command' });
|
|
446
|
+
else abandonExecutionLeasesForRun(paths.projectRoot, run.id, { outcome: 'conductor-recovered' });
|
|
431
447
|
run.desiredState = 'running';
|
|
432
448
|
run.status = 'running';
|
|
433
449
|
run.topology = preflight.topology;
|
|
434
450
|
run.activeWorkers = [];
|
|
451
|
+
if (run.autonomy) {
|
|
452
|
+
run.timeoutMs = Math.min(run.timeoutMs, options.timeoutMs);
|
|
453
|
+
if (!Number.isFinite(run.timeoutMs) || run.timeoutMs < 1) throw new Error('Autonomous AFK resume requires a bounded timeout.');
|
|
454
|
+
run.pid = null; saveRun(paths, run, resumeFrom); options.dependencies.onRun?.(run);
|
|
455
|
+
return executeAfkRun(paths.projectRoot, run.id, options.dependencies);
|
|
456
|
+
}
|
|
457
|
+
run.pid = null; saveRun(paths, run, resumeFrom);
|
|
435
458
|
run.pid = spawnWorker(paths.projectRoot, run.id);
|
|
436
459
|
event(paths.projectRoot, run, 'Recovering unattended Build execution.', 'Abandoned leases were released; task evidence will be revalidated before continuing.');
|
|
437
460
|
return saveRun(paths, run);
|
|
@@ -596,20 +619,52 @@ function taskState(run, task, update = {}) {
|
|
|
596
619
|
run.tasks[task.id] = { ...(run.tasks[task.id] ?? {}), id: task.id, ...update, updatedAt: now() };
|
|
597
620
|
}
|
|
598
621
|
|
|
599
|
-
function
|
|
600
|
-
|
|
601
|
-
|
|
622
|
+
async function invokeControlled(projectRoot, run, task, invocation, invoke = invokeProvider) {
|
|
623
|
+
const paths = pathsFor(projectRoot, run.id), controller = new AbortController();
|
|
624
|
+
const key = controlKey(projectRoot, run.id), controls = runningControls.get(key) ?? new Set();
|
|
625
|
+
controls.add(controller); runningControls.set(key, controls);
|
|
626
|
+
let authorityError = null;
|
|
627
|
+
const guard = () => {
|
|
628
|
+
try {
|
|
629
|
+
if (requireRun(projectRoot, run.id).run.desiredState === 'cancelled') controller.abort();
|
|
630
|
+
authorityChecks.get(run)?.({ stage: 'check', mode: invocation.mode ?? 'command' });
|
|
631
|
+
}
|
|
632
|
+
catch (error) { authorityError = error; controller.abort(); }
|
|
633
|
+
};
|
|
634
|
+
let timer;
|
|
602
635
|
try {
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
});
|
|
636
|
+
guard();
|
|
637
|
+
if (authorityError) throw authorityError;
|
|
638
|
+
authorityChecks.get(run)?.({ stage: 'dispatch', mode: invocation.mode ?? 'command' });
|
|
639
|
+
// Persist uncertainty before the launch. A dead conductor must not leave
|
|
640
|
+
// an old true flag that lets resume abandon a still-running worker's lease.
|
|
641
|
+
run.executionStopped = false;
|
|
642
|
+
saveRun(paths, run);
|
|
643
|
+
timer = setInterval(guard, 50);
|
|
644
|
+
const result = await invoke(invocation, { signal: controller.signal, onStart(pid) {
|
|
645
|
+
taskState(run, task, { pid });
|
|
646
|
+
run.activeWorkers = [...(run.activeWorkers ?? []).filter(worker => worker.taskId !== task.id), { taskId: task.id, pid }];
|
|
647
|
+
saveRun(paths, run);
|
|
648
|
+
} });
|
|
649
|
+
guard();
|
|
650
|
+
if (result.executionStopped === true) run.activeWorkers = (run.activeWorkers ?? []).filter(worker => worker.taskId !== task.id);
|
|
651
|
+
else run.unconfirmedExecution = true;
|
|
652
|
+
if (authorityError) throw authorityError;
|
|
653
|
+
if (run.unconfirmedExecution) throw new Error('AFK execution is not confirmed stopped; recovery is required.');
|
|
654
|
+
run.executionStopped = (run.activeWorkers ?? []).length === 0;
|
|
655
|
+
saveRun(paths, run);
|
|
656
|
+
return result;
|
|
606
657
|
} catch (error) {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
658
|
+
if ((run.activeWorkers ?? []).some(worker => worker.taskId === task.id)) run.unconfirmedExecution = true;
|
|
659
|
+
throw error;
|
|
660
|
+
} finally { clearInterval(timer); controls.delete(controller); if (!controls.size) runningControls.delete(key); }
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function executeCommand(command, cwd, outputPath, timeoutMs, projectRoot, run, task) {
|
|
664
|
+
const result = await invokeControlled(projectRoot, run, task, { provider: 'command', command: '/bin/sh',
|
|
665
|
+
args: ['-lc', command], cwd, prompt: '', timeoutMs, logPath: outputPath });
|
|
666
|
+
if (result.cancelled || result.timedOut || requireRun(projectRoot, run.id).run.desiredState === 'cancelled') throw new Error('AFK command stopped before completion.');
|
|
667
|
+
return { command, exitCode: result.exitCode, outputPath };
|
|
613
668
|
}
|
|
614
669
|
|
|
615
670
|
function writeTaskEvidence(projectRoot, run, result, postMerge) {
|
|
@@ -695,6 +750,7 @@ No declared stop condition occurred.
|
|
|
695
750
|
Automated task and integration evidence passed. Human QA remains governed by the later EWAI delivery gate.
|
|
696
751
|
`;
|
|
697
752
|
writeFileSync(reportPath, report, 'utf8');
|
|
753
|
+
return [reportPath, evidencePath, reviewPath, ...commands.map(command => resolve(delivery.deliveryRoot, command.output_path))];
|
|
698
754
|
}
|
|
699
755
|
|
|
700
756
|
async function implementTask(projectRoot, run, task, provider, dependencies = {}) {
|
|
@@ -720,14 +776,15 @@ async function implementTask(projectRoot, run, task, provider, dependencies = {}
|
|
|
720
776
|
});
|
|
721
777
|
saveRun(paths, run);
|
|
722
778
|
event(projectRoot, run, `Preparing isolated worktree for ${task.id}.`, task.name);
|
|
779
|
+
authorityChecks.get(run)?.({ stage: 'worktree', mode: 'command' });
|
|
723
780
|
mkdirSync(dirname(worktree), { recursive: true });
|
|
724
781
|
git(repository.root, ['worktree', 'add', '-b', actualBranch, worktree, repository.parentBranch]);
|
|
725
782
|
const baseHead = git(worktree, ['rev-parse', 'HEAD']);
|
|
726
|
-
const red = executeCommand(
|
|
783
|
+
const red = await executeCommand(
|
|
727
784
|
task.red_green_refactor.red_command,
|
|
728
785
|
worktree,
|
|
729
786
|
resolve(logRoot, 'red.txt'),
|
|
730
|
-
run.timeoutMs,
|
|
787
|
+
run.timeoutMs, projectRoot, run, task,
|
|
731
788
|
);
|
|
732
789
|
red.stage = 'red';
|
|
733
790
|
if (red.exitCode === 0) throw new Error(`${task.id} red command already passes; the task contract is stale and must be reconciled.`);
|
|
@@ -742,14 +799,7 @@ async function implementTask(projectRoot, run, task, provider, dependencies = {}
|
|
|
742
799
|
mode: 'implementation',
|
|
743
800
|
});
|
|
744
801
|
invocation.logPath = resolve(logRoot, 'implementation.log');
|
|
745
|
-
const result = await
|
|
746
|
-
onStart(pid) {
|
|
747
|
-
taskState(run, task, { pid });
|
|
748
|
-
run.activeWorkers = [...(run.activeWorkers ?? []).filter((item) => item.taskId !== task.id), { taskId: task.id, pid }];
|
|
749
|
-
saveRun(paths, run);
|
|
750
|
-
},
|
|
751
|
-
});
|
|
752
|
-
run.activeWorkers = (run.activeWorkers ?? []).filter((item) => item.taskId !== task.id);
|
|
802
|
+
const result = await invokeControlled(projectRoot, run, task, invocation, invoke);
|
|
753
803
|
if (requireRun(projectRoot, run.id).run.desiredState === 'cancelled') {
|
|
754
804
|
throw new Error('AFK run was cancelled while the task agent was active.');
|
|
755
805
|
}
|
|
@@ -762,19 +812,19 @@ async function implementTask(projectRoot, run, task, provider, dependencies = {}
|
|
|
762
812
|
token: lease.token,
|
|
763
813
|
durationMs: Math.min(86_400_000, run.timeoutMs + 10 * 60 * 1000),
|
|
764
814
|
});
|
|
765
|
-
const green = executeCommand(
|
|
815
|
+
const green = await executeCommand(
|
|
766
816
|
task.red_green_refactor.green_command,
|
|
767
817
|
worktree,
|
|
768
818
|
resolve(logRoot, 'green.txt'),
|
|
769
|
-
run.timeoutMs,
|
|
819
|
+
run.timeoutMs, projectRoot, run, task,
|
|
770
820
|
);
|
|
771
821
|
green.stage = 'green';
|
|
772
822
|
if (green.exitCode !== 0) throw new Error(`${task.id} green command failed after implementation.`);
|
|
773
|
-
const refactor = executeCommand(
|
|
823
|
+
const refactor = await executeCommand(
|
|
774
824
|
task.red_green_refactor.green_command,
|
|
775
825
|
worktree,
|
|
776
826
|
resolve(logRoot, 'refactor.txt'),
|
|
777
|
-
run.timeoutMs,
|
|
827
|
+
run.timeoutMs, projectRoot, run, task,
|
|
778
828
|
);
|
|
779
829
|
refactor.stage = 'refactor';
|
|
780
830
|
if (refactor.exitCode !== 0) throw new Error(`${task.id} refactor verification failed.`);
|
|
@@ -785,7 +835,8 @@ async function implementTask(projectRoot, run, task, provider, dependencies = {}
|
|
|
785
835
|
taskState(run, task, { status: 'checking-scope', changedFiles: changed });
|
|
786
836
|
saveRun(paths, run);
|
|
787
837
|
event(projectRoot, run, `Checking implementation scope for ${task.id}.`, `${changed.length} changed path(s) in ${repository.name}.`);
|
|
788
|
-
|
|
838
|
+
authorityChecks.get(run)?.({ stage: 'accept', mode: 'implementation' });
|
|
839
|
+
git(worktree, ['add', '--', ...changed]);
|
|
789
840
|
git(worktree, ['commit', '-m', `feat(${run.slug}): implement ${task.id}`]);
|
|
790
841
|
const implementationCommit = git(worktree, ['rev-parse', 'HEAD']);
|
|
791
842
|
|
|
@@ -798,20 +849,17 @@ async function implementTask(projectRoot, run, task, provider, dependencies = {}
|
|
|
798
849
|
mode: 'review',
|
|
799
850
|
});
|
|
800
851
|
reviewInvocation.logPath = resolve(logRoot, 'review.log');
|
|
801
|
-
const review = await
|
|
802
|
-
onStart(pid) {
|
|
803
|
-
taskState(run, task, { pid, status: 'reviewing' });
|
|
804
|
-
run.activeWorkers = [{ taskId: task.id, pid }];
|
|
805
|
-
saveRun(paths, run);
|
|
806
|
-
},
|
|
807
|
-
});
|
|
808
|
-
run.activeWorkers = [];
|
|
852
|
+
const review = await invokeControlled(projectRoot, run, task, reviewInvocation, invoke);
|
|
809
853
|
if (requireRun(projectRoot, run.id).run.desiredState === 'cancelled') {
|
|
810
854
|
throw new Error('AFK run was cancelled during fresh-context review.');
|
|
811
855
|
}
|
|
812
856
|
if (review.timedOut || review.exitCode !== 0 || !/^VERDICT:\s*PASS\s*$/im.test(review.output)) {
|
|
813
857
|
throw new Error(`Fresh-context review did not pass; see ${relative(projectRoot, review.logPath)}.`);
|
|
814
858
|
}
|
|
859
|
+
authorityChecks.get(run)?.({ stage: 'accept', mode: 'review' });
|
|
860
|
+
if (git(worktree, ['rev-parse', 'HEAD']) !== implementationCommit || gitDirty(worktree)) {
|
|
861
|
+
throw new Error('Fresh-context review changed the inspected implementation.');
|
|
862
|
+
}
|
|
815
863
|
heartbeatExecutionLease(projectRoot, lease.id, {
|
|
816
864
|
token: lease.token,
|
|
817
865
|
durationMs: Math.min(86_400_000, run.timeoutMs + 10 * 60 * 1000),
|
|
@@ -830,18 +878,19 @@ async function implementTask(projectRoot, run, task, provider, dependencies = {}
|
|
|
830
878
|
if (control.desiredState === 'cancelled') {
|
|
831
879
|
return { task, cancelled: true, error };
|
|
832
880
|
}
|
|
833
|
-
if (lease) {
|
|
881
|
+
if (lease && !run.unconfirmedExecution) {
|
|
834
882
|
try { releaseExecutionLease(projectRoot, lease.id, { token: lease.token, outcome: 'blocked' }); } catch { /* recovery handles it */ }
|
|
835
883
|
}
|
|
836
884
|
taskState(run, task, { status: 'blocked', error: error.message });
|
|
837
|
-
run.activeWorkers = (run.activeWorkers ?? []).filter((item) => item.taskId !== task.id);
|
|
885
|
+
if (!run.unconfirmedExecution) run.activeWorkers = (run.activeWorkers ?? []).filter((item) => item.taskId !== task.id);
|
|
838
886
|
saveRun(paths, run);
|
|
839
887
|
event(projectRoot, run, `${task.id} needs attention.`, error.message, 'blocked', true);
|
|
840
888
|
return { task, error };
|
|
841
889
|
}
|
|
842
890
|
}
|
|
843
891
|
|
|
844
|
-
function integrateTask(projectRoot, run, result) {
|
|
892
|
+
async function integrateTask(projectRoot, run, result) {
|
|
893
|
+
authorityChecks.get(run)?.({ stage: 'integrate', mode: 'command' });
|
|
845
894
|
const { task, lease } = result;
|
|
846
895
|
const repository = result.repository;
|
|
847
896
|
const specsRepository = run.topology.repositories.find((candidate) => candidate.name === run.topology.specsRepository);
|
|
@@ -858,7 +907,24 @@ function integrateTask(projectRoot, run, result) {
|
|
|
858
907
|
rmSync(evidenceBackup, { recursive: true, force: true });
|
|
859
908
|
cpSync(taskEvidenceRoot, evidenceBackup, { recursive: true });
|
|
860
909
|
}
|
|
861
|
-
let codeCommitted = false;
|
|
910
|
+
let codeCommitted = false, integrationConflict = false, mergeStarted = false;
|
|
911
|
+
const baselines = new Map(), ownedEvidence = new Map();
|
|
912
|
+
const assertIntegrationState = (staged = false) => {
|
|
913
|
+
for (const [root, baseline] of baselines) {
|
|
914
|
+
const owned = path => ownedEvidence.has(resolve(root, path))
|
|
915
|
+
&& sha256(readFileSync(resolve(root, path))) === ownedEvidence.get(resolve(root, path));
|
|
916
|
+
const stagedDelta = git(root, ['diff', '--cached', '--name-only', '-z', baseline.tree]).split('\0').filter(Boolean);
|
|
917
|
+
const workingDelta = git(root, ['diff', '--name-only', '-z']).split('\0').filter(Boolean);
|
|
918
|
+
const untracked = git(root, ['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean);
|
|
919
|
+
if (git(root, ['rev-parse', 'HEAD']) !== baseline.head || git(root, ['branch', '--show-current']) !== baseline.branch
|
|
920
|
+
|| stagedDelta.some(path => !staged || !owned(path) || sha256(execFileSync('git', ['show', `:${path}`],
|
|
921
|
+
{ cwd: root, stdio: ['ignore', 'pipe', 'pipe'] })) !== ownedEvidence.get(resolve(root, path)))
|
|
922
|
+
|| [...workingDelta, ...untracked].some(path => !owned(path))) {
|
|
923
|
+
integrationConflict = true;
|
|
924
|
+
throw new Error('Repository changed outside the owned integration; preserve the checkout for review.');
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
};
|
|
862
928
|
try {
|
|
863
929
|
heartbeatExecutionLease(projectRoot, lease.id, {
|
|
864
930
|
token: lease.token,
|
|
@@ -868,27 +934,61 @@ function integrateTask(projectRoot, run, result) {
|
|
|
868
934
|
if (currentBranch !== repository.parentBranch) {
|
|
869
935
|
throw new Error(`Repository ${repository.name} moved to ${currentBranch || '<detached>'}; expected ${repository.parentBranch}.`);
|
|
870
936
|
}
|
|
937
|
+
for (const root of new Set([repository.root, specsRepository.root])) {
|
|
938
|
+
if (gitDirty(root)) { integrationConflict = true; throw new Error('Repository must still be clean before integration.'); }
|
|
939
|
+
baselines.set(root, { head: git(root, ['rev-parse', 'HEAD']), branch: git(root, ['branch', '--show-current']), tree: git(root, ['write-tree']) });
|
|
940
|
+
}
|
|
871
941
|
git(repository.root, ['merge', '--no-ff', '--no-commit', result.actualBranch]);
|
|
942
|
+
mergeStarted = true;
|
|
943
|
+
baselines.get(repository.root).tree = git(repository.root, ['write-tree']);
|
|
872
944
|
const postMerge = [];
|
|
873
945
|
for (const [index, command] of (task.merge?.post_merge_checks ?? []).entries()) {
|
|
874
946
|
const outputPath = resolve(delivery.deliveryRoot, 'tasks', task.id, 'evidence', `post-merge-${index + 1}.txt`);
|
|
875
|
-
const check = executeCommand(command, repository.root, outputPath, run.timeoutMs);
|
|
947
|
+
const check = await executeCommand(command, repository.root, outputPath, run.timeoutMs, projectRoot, run, task);
|
|
876
948
|
const evidencePath = relative(delivery.deliveryRoot, outputPath).replaceAll('\\', '/');
|
|
877
949
|
postMerge.push({ command, exit_code: check.exitCode, output_path: evidencePath, output_sha256: sha256(readFileSync(outputPath)) });
|
|
950
|
+
ownedEvidence.set(realpathSync(outputPath), postMerge.at(-1).output_sha256);
|
|
878
951
|
if (check.exitCode !== 0) throw new Error(`Post-merge check failed: ${command}`);
|
|
879
952
|
}
|
|
880
|
-
|
|
953
|
+
authorityChecks.get(run)?.({ stage: 'accept', mode: 'command' });
|
|
954
|
+
assertIntegrationState();
|
|
955
|
+
for (const path of writeTaskEvidence(projectRoot, run, result, postMerge)) ownedEvidence.set(realpathSync(path), sha256(readFileSync(path)));
|
|
881
956
|
const validated = validateTaskReport(deliveryPaths(projectRoot, run.slug).deliveryRoot, task);
|
|
882
957
|
if (validated.status !== 'complete') throw new Error(`Merged task evidence is invalid: ${validated.errors.join('; ')}`);
|
|
883
|
-
|
|
958
|
+
assertIntegrationState();
|
|
959
|
+
const evidencePaths = [...ownedEvidence.keys()].map(path => relative(specsRepository.root, path));
|
|
960
|
+
git(specsRepository.root, ['add', '--', ...evidencePaths]);
|
|
961
|
+
assertIntegrationState(true);
|
|
962
|
+
const expectedTrees = new Map([...baselines.keys()].map(root => [root, git(root, ['write-tree'])]));
|
|
963
|
+
const taskCommit = git(repository.root, ['rev-parse', result.actualBranch]);
|
|
884
964
|
git(repository.root, ['commit', '-m', `merge(${run.slug}): integrate ${task.id}`]);
|
|
885
965
|
const codeMergeCommit = git(repository.root, ['rev-parse', 'HEAD']);
|
|
886
966
|
codeCommitted = true;
|
|
887
967
|
if (repository.root !== specsRepository.root) {
|
|
888
|
-
|
|
968
|
+
assertIntegrationStateForEvidence();
|
|
889
969
|
git(specsRepository.root, ['commit', '-m', `docs(${run.slug}): record ${task.id} delivery evidence`]);
|
|
890
970
|
}
|
|
971
|
+
function assertIntegrationStateForEvidence() {
|
|
972
|
+
const baseline = baselines.get(specsRepository.root);
|
|
973
|
+
if (git(specsRepository.root, ['rev-parse', 'HEAD']) !== baseline.head
|
|
974
|
+
|| git(specsRepository.root, ['branch', '--show-current']) !== baseline.branch
|
|
975
|
+
|| git(specsRepository.root, ['write-tree']) !== expectedTrees.get(specsRepository.root)
|
|
976
|
+
|| git(specsRepository.root, ['diff', '--name-only']) || git(specsRepository.root, ['ls-files', '--others', '--exclude-standard'])) {
|
|
977
|
+
throw new Error('Evidence repository changed during owned integration; preserve it for review.');
|
|
978
|
+
}
|
|
979
|
+
}
|
|
891
980
|
const evidenceCommit = git(specsRepository.root, ['rev-parse', 'HEAD']);
|
|
981
|
+
const transitions = [...baselines].map(([root, baseline]) => ({ root, before: baseline.head, branch: baseline.branch,
|
|
982
|
+
after: root === repository.root ? codeMergeCommit : evidenceCommit }));
|
|
983
|
+
for (const transition of transitions) {
|
|
984
|
+
if (git(transition.root, ['rev-parse', 'HEAD']) !== transition.after || git(transition.root, ['rev-parse', 'HEAD^1']) !== transition.before
|
|
985
|
+
|| git(transition.root, ['rev-parse', 'HEAD^{tree}']) !== expectedTrees.get(transition.root)
|
|
986
|
+
|| git(transition.root, ['branch', '--show-current']) !== transition.branch || gitDirty(transition.root)
|
|
987
|
+
|| transition.root === repository.root && git(transition.root, ['rev-parse', 'HEAD^2']) !== taskCommit) {
|
|
988
|
+
throw new Error('Owned integration commit changed unexpectedly; preserve the checkout for review.');
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
authorityChecks.get(run)?.({ stage: 'integrated', mode: 'command', transitions });
|
|
892
992
|
releaseExecutionLease(projectRoot, lease.id, { token: lease.token, outcome: 'completed' });
|
|
893
993
|
taskState(run, task, {
|
|
894
994
|
status: 'completed', mergeCommit: codeMergeCommit, evidenceCommit,
|
|
@@ -899,12 +999,13 @@ function integrateTask(projectRoot, run, result) {
|
|
|
899
999
|
rmSync(evidenceBackup, { recursive: true, force: true });
|
|
900
1000
|
try { git(repository.root, ['worktree', 'remove', '--force', result.worktree]); } catch { /* retain for diagnosis */ }
|
|
901
1001
|
} catch (error) {
|
|
902
|
-
if (!codeCommitted) {
|
|
1002
|
+
try { if (!codeCommitted) assertIntegrationState(true); } catch { integrationConflict = true; }
|
|
1003
|
+
if (mergeStarted && !codeCommitted && !run.unconfirmedExecution && !integrationConflict) {
|
|
903
1004
|
try { git(repository.root, ['merge', '--abort']); } catch { /* no merge to abort */ }
|
|
904
1005
|
rmSync(taskEvidenceRoot, { recursive: true, force: true });
|
|
905
1006
|
if (hadTaskEvidence) cpSync(evidenceBackup, taskEvidenceRoot, { recursive: true });
|
|
906
1007
|
}
|
|
907
|
-
try { releaseExecutionLease(projectRoot, lease.id, { token: lease.token, outcome: 'blocked' }); } catch { /* recovery handles it */ }
|
|
1008
|
+
if (!run.unconfirmedExecution) try { releaseExecutionLease(projectRoot, lease.id, { token: lease.token, outcome: 'blocked' }); } catch { /* recovery handles it */ }
|
|
908
1009
|
taskState(run, task, { status: 'blocked', error: error.message });
|
|
909
1010
|
saveRun(paths, run);
|
|
910
1011
|
event(projectRoot, run, `${task.id} integration blocked.`, error.message, 'blocked', true);
|
|
@@ -914,16 +1015,24 @@ function integrateTask(projectRoot, run, result) {
|
|
|
914
1015
|
|
|
915
1016
|
export async function executeAfkRun(projectRoot, runId, dependencies = {}) {
|
|
916
1017
|
const { paths, run } = requireRun(projectRoot, runId);
|
|
1018
|
+
if (run.desiredState !== 'running' && run.executionStopped === true && !(run.activeWorkers ?? []).length) {
|
|
1019
|
+
run.status = run.desiredState === 'paused' ? 'paused' : 'cancelled'; run.pid = null;
|
|
1020
|
+
if (run.desiredState === 'cancelled') run.cancellation = { ...run.cancellation, status: 'confirmed', settledAt: now() };
|
|
1021
|
+
return saveRun(paths, run);
|
|
1022
|
+
}
|
|
917
1023
|
run.pid = process.pid;
|
|
918
1024
|
run.status = 'running';
|
|
919
1025
|
saveRun(paths, run);
|
|
1026
|
+
if (dependencies.authority) authorityChecks.set(run, dependencies.authority);
|
|
920
1027
|
try {
|
|
1028
|
+
if (run.autonomy && !authorityChecks.has(run)) throw new Error('Autonomous AFK run requires its original live authority context.');
|
|
921
1029
|
while (run.desiredState === 'running') {
|
|
922
1030
|
const graph = readGraph(paths.projectRoot, run.slug);
|
|
923
1031
|
const check = validateTaskGraph(deliveryPaths(paths.projectRoot, run.slug).deliveryRoot, { slug: run.slug });
|
|
924
1032
|
if (check.status !== 'pass') throw new Error(`Task graph became invalid: ${check.errors.map((item) => item.message).join('; ')}`);
|
|
925
1033
|
if (check.completedTasks.length === graph.tasks.length) {
|
|
926
1034
|
run.status = 'completed';
|
|
1035
|
+
run.executionStopped = !run.unconfirmedExecution;
|
|
927
1036
|
run.pid = null;
|
|
928
1037
|
event(paths.projectRoot, run, 'All AFK Build tasks are integrated.', 'EWAI Build remains open for its canonical phase gate and subsequent lifecycle stages.');
|
|
929
1038
|
return saveRun(paths, run);
|
|
@@ -939,9 +1048,14 @@ export async function executeAfkRun(projectRoot, runId, dependencies = {}) {
|
|
|
939
1048
|
const control = requireRun(paths.projectRoot, run.id).run;
|
|
940
1049
|
run.desiredState = control.desiredState;
|
|
941
1050
|
if (run.desiredState === 'cancelled') {
|
|
942
|
-
run.status = 'cancelled';
|
|
1051
|
+
run.status = run.unconfirmedExecution ? 'cancel-unknown' : 'cancelled';
|
|
1052
|
+
run.cancellation = { ...control.cancellation, status: run.unconfirmedExecution ? 'unknown' : 'confirmed', settledAt: now() };
|
|
1053
|
+
run.executionStopped = !run.unconfirmedExecution;
|
|
943
1054
|
run.pid = null;
|
|
944
|
-
run.
|
|
1055
|
+
if (!run.unconfirmedExecution) {
|
|
1056
|
+
abandonExecutionLeasesForRun(paths.projectRoot, run.id, { outcome: 'conductor-cancelled' });
|
|
1057
|
+
run.activeWorkers = [];
|
|
1058
|
+
}
|
|
945
1059
|
return saveRun(paths, run);
|
|
946
1060
|
}
|
|
947
1061
|
const failed = results.find((result) => result.error);
|
|
@@ -949,12 +1063,13 @@ export async function executeAfkRun(projectRoot, runId, dependencies = {}) {
|
|
|
949
1063
|
const order = waveDefinition?.merge_order ?? wave.map((task) => task.id);
|
|
950
1064
|
for (const id of order) {
|
|
951
1065
|
const result = results.find((candidate) => candidate.task.id === id);
|
|
952
|
-
if (result) integrateTask(paths.projectRoot, run, result);
|
|
1066
|
+
if (result) await integrateTask(paths.projectRoot, run, result);
|
|
953
1067
|
}
|
|
954
1068
|
const refreshed = requireRun(paths.projectRoot, run.id).run;
|
|
955
1069
|
run.desiredState = refreshed.desiredState;
|
|
956
1070
|
if (run.desiredState === 'paused') {
|
|
957
1071
|
run.status = 'paused';
|
|
1072
|
+
run.executionStopped = !run.unconfirmedExecution;
|
|
958
1073
|
run.pid = null;
|
|
959
1074
|
event(paths.projectRoot, run, 'Unattended Build safely paused.', 'The active wave finished and no new task was leased.');
|
|
960
1075
|
return saveRun(paths, run);
|
|
@@ -962,12 +1077,17 @@ export async function executeAfkRun(projectRoot, runId, dependencies = {}) {
|
|
|
962
1077
|
}
|
|
963
1078
|
return saveRun(paths, run);
|
|
964
1079
|
} catch (error) {
|
|
965
|
-
|
|
1080
|
+
const control = requireRun(paths.projectRoot, run.id).run;
|
|
1081
|
+
run.desiredState = control.desiredState;
|
|
1082
|
+
run.status = control.desiredState === 'cancelled' ? run.unconfirmedExecution ? 'cancel-unknown' : 'cancelled' : 'blocked';
|
|
1083
|
+
if (control.desiredState === 'cancelled') run.cancellation = { ...control.cancellation,
|
|
1084
|
+
status: run.unconfirmedExecution ? 'unknown' : 'confirmed', settledAt: now() };
|
|
966
1085
|
run.pid = null;
|
|
967
1086
|
run.messages.push({ at: now(), message: error.message });
|
|
968
|
-
|
|
1087
|
+
run.executionStopped = !run.unconfirmedExecution;
|
|
1088
|
+
if (!run.unconfirmedExecution) abandonExecutionLeasesForRun(paths.projectRoot, run.id, { outcome: 'conductor-blocked' });
|
|
969
1089
|
event(paths.projectRoot, run, 'Unattended Build is blocked.', error.message, 'blocked', true);
|
|
970
1090
|
saveRun(paths, run);
|
|
971
1091
|
return run;
|
|
972
|
-
}
|
|
1092
|
+
} finally { authorityChecks.delete(run); }
|
|
973
1093
|
}
|