@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
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { resolve, relative } from 'node:path';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { performance } from 'node:perf_hooks';
|
|
5
|
+
import { readAutonomyPolicy, previewAutonomy, revokeAutonomyGrant } from '../autonomy.mjs';
|
|
6
|
+
import { beginDelivery } from '../delivery.mjs';
|
|
7
|
+
import { finishActiveSession } from './work.mjs';
|
|
8
|
+
import { autonomyPaths, autonomyFiles, autonomyDigest, autonomyError, readAutonomyFile,
|
|
9
|
+
writeAutonomyRecord, withAutonomyLock, readAutonomyRepositoryState, readAutonomySnapshot } from './autonomy-workspace.mjs';
|
|
10
|
+
import { withIntentMutation, acquireIntentOwnership, assertIntentOwnership, releaseIntentOwnership } from './intent-ownership.mjs';
|
|
11
|
+
import { readIntentMutationSnapshot, recoverCompletedAutonomyOperation } from './autonomy-operations.mjs';
|
|
12
|
+
import { prepareAutonomyPhase, invokePhaseProposal, acceptPhaseProposal } from './autonomy-workers.mjs';
|
|
13
|
+
import { preparePhaseProvider, verifyPhaseProviderConformance } from './provider-adapters.mjs';
|
|
14
|
+
import { preflightAfkRun, startAfkRun, resumeAfkRun, pauseAfkRun, cancelAfkRun, afkRunStatus } from './afk-conductor.mjs';
|
|
15
|
+
|
|
16
|
+
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
17
|
+
const digestPattern = /^sha256:[a-f0-9]{64}$/;
|
|
18
|
+
const terminal = new Set(['completed', 'cancelled', 'revoked', 'expired', 'blocked']);
|
|
19
|
+
const statuses = new Set([...terminal, 'starting', 'running', 'paused', 'awaiting-human', 'cancel-requested', 'recovery-required']);
|
|
20
|
+
const live = new Map();
|
|
21
|
+
const failureCode = error => /^(?:autonomy|phase)-[a-z0-9-]+$/.test(error?.code ?? '') ? error.code : 'autonomy-operation-failed';
|
|
22
|
+
function inputKeys(input, allowed) {
|
|
23
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || Object.getPrototypeOf(input) !== Object.prototype
|
|
24
|
+
|| Object.keys(input).some(key => !allowed.includes(key))) autonomyError('autonomy-invalid-input', 400);
|
|
25
|
+
}
|
|
26
|
+
function named(value) { return typeof value === 'string' && value.trim() === value && value.length > 0 && value.length <= 120 && !/[\x00-\x1f\x7f]/.test(value); }
|
|
27
|
+
function pathsFor(root, id) {
|
|
28
|
+
if (id !== undefined && !uuid.test(id)) autonomyError('autonomy-run-id-invalid', 400);
|
|
29
|
+
// Runtime control is durable across projection rebuilds, but is not source
|
|
30
|
+
// code or authority. Canonical effects retain their separate SPECS receipts.
|
|
31
|
+
const paths = autonomyPaths(root), runsRoot = resolve(paths.controlRoot, 'supervisor/runs');
|
|
32
|
+
return { ...paths, runsRoot, runRoot: id ? resolve(runsRoot, id) : null };
|
|
33
|
+
}
|
|
34
|
+
function record(paths, path, body) { const value = { ...body, digest: autonomyDigest(body) }; writeAutonomyRecord(paths.projectRoot, path, value); return value; }
|
|
35
|
+
function readRecord(paths, path) {
|
|
36
|
+
let value;
|
|
37
|
+
try { value = JSON.parse(readAutonomyFile(paths.projectRoot, path)); }
|
|
38
|
+
catch { autonomyError('autonomy-run-evidence-invalid'); }
|
|
39
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) autonomyError('autonomy-run-evidence-invalid');
|
|
40
|
+
const { digest, ...body } = value;
|
|
41
|
+
if (digest !== autonomyDigest(body)) autonomyError('autonomy-run-evidence-invalid');
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
function readRun(root, id) {
|
|
45
|
+
const paths = pathsFor(root, id), files = autonomyFiles(paths.projectRoot, resolve(paths.runRoot, 'revisions'));
|
|
46
|
+
if (!files.length) autonomyError('autonomy-run-not-found', 404);
|
|
47
|
+
if (files.length > 4096) autonomyError('autonomy-run-evidence-limit');
|
|
48
|
+
const records = files.map(path => ({ path, value: readRecord(paths, path) })).sort((a, b) => a.value.revision - b.value.revision);
|
|
49
|
+
records.forEach(({ path, value }, index) => {
|
|
50
|
+
inputKeys(value, ['schema', 'id', 'revision', 'previousDigest', 'projectIdentity', 'mode', 'provider', 'grantDigest', 'status',
|
|
51
|
+
'desiredState', 'startedAt', 'updatedAt', 'lastClock', 'counters', 'owner', 'current', 'lastResult', 'questions', 'code',
|
|
52
|
+
'cancellation', 'executionStopped', 'requiresHuman', 'ownedFiles', 'repositoryIdentity', 'digest']);
|
|
53
|
+
if (value.schema !== 'ewai.autonomy-run/v1' || value.id !== id || value.revision !== index + 1
|
|
54
|
+
|| value.previousDigest !== (records[index - 1]?.value.digest ?? null)
|
|
55
|
+
|| value.projectIdentity !== autonomyDigest(paths.projectRoot)
|
|
56
|
+
|| !path.endsWith(`/${value.revision}-${value.digest.slice(7)}.json`)) autonomyError('autonomy-run-evidence-invalid');
|
|
57
|
+
if (!statuses.has(value.status) || !['once', 'service'].includes(value.mode) || !['running', 'paused', 'cancelled', 'revoked'].includes(value.desiredState)
|
|
58
|
+
|| !digestPattern.test(value.grantDigest ?? '') || !['claude', 'codex', 'antigravity'].includes(value.provider)
|
|
59
|
+
|| !Number.isSafeInteger(value.owner?.pid) || value.owner.pid < 1 || !uuid.test(value.owner?.instance ?? '')
|
|
60
|
+
|| !Number.isSafeInteger(value.lastClock) || !Number.isFinite(Date.parse(value.startedAt)) || !Number.isFinite(Date.parse(value.updatedAt))
|
|
61
|
+
|| !value.counters || Object.keys(value.counters).sort().join(',') !== 'actions,elapsedMs,providerAttempts,reviewCycles'
|
|
62
|
+
|| Object.values(value.counters).some(count => !Number.isSafeInteger(count) || count < 0)
|
|
63
|
+
|| typeof value.executionStopped !== 'boolean' || typeof value.requiresHuman !== 'boolean'
|
|
64
|
+
|| !Array.isArray(value.questions) || value.questions.length > 100
|
|
65
|
+
|| !Array.isArray(value.ownedFiles) || value.ownedFiles.length > 4096
|
|
66
|
+
|| value.ownedFiles.some(file => typeof file.path !== 'string' || file.path.startsWith('/') || file.path.split('/').includes('..') || !digestPattern.test(file.digest ?? ''))
|
|
67
|
+
|| value.repositoryIdentity !== null && !digestPattern.test(value.repositoryIdentity ?? '')) autonomyError('autonomy-run-evidence-invalid');
|
|
68
|
+
const intentId = value => typeof value === 'string' && /^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
|
|
69
|
+
if (value.current !== null) {
|
|
70
|
+
inputKeys(value.current, ['intentId', 'phase', 'action', 'sortKey', 'reasons', 'operationId', 'workerOperationId', 'afkRunId']);
|
|
71
|
+
if (!intentId(value.current.intentId) || !['begin-harness', 'prepare-phase', 'afk-build'].includes(value.current.action)
|
|
72
|
+
|| !uuid.test(value.current.operationId ?? '')) autonomyError('autonomy-run-evidence-invalid');
|
|
73
|
+
}
|
|
74
|
+
if (value.lastResult !== null) {
|
|
75
|
+
inputKeys(value.lastResult, ['status', 'operationId', 'intentId', 'phase', 'phaseCompleted', 'afkRunId']);
|
|
76
|
+
if (!intentId(value.lastResult.intentId) || !['harness-started', 'draft-staged', 'afk-settled', 'reconciled'].includes(value.lastResult.status)) autonomyError('autonomy-run-evidence-invalid');
|
|
77
|
+
}
|
|
78
|
+
for (const q of value.questions) {
|
|
79
|
+
inputKeys(q, ['id', 'intentId', 'phase', 'owner', 'sourceRevision', 'codes', 'authority']);
|
|
80
|
+
if (!/^[a-f0-9]{64}$/.test(q.id ?? '') || !intentId(q.intentId) || !named(q.owner) || q.authority !== 'none'
|
|
81
|
+
|| !digestPattern.test(q.sourceRevision ?? '') || !Array.isArray(q.codes) || q.codes.some(code => !/^[a-z0-9-]{1,100}$/.test(code))) autonomyError('autonomy-run-evidence-invalid');
|
|
82
|
+
}
|
|
83
|
+
if (index && (value.grantDigest !== records[0].value.grantDigest || value.mode !== records[0].value.mode
|
|
84
|
+
|| Object.keys(value.counters).some(key => value.counters[key] < records[index - 1].value.counters[key]))) autonomyError('autonomy-run-history-invalid');
|
|
85
|
+
});
|
|
86
|
+
return records.at(-1).value;
|
|
87
|
+
}
|
|
88
|
+
function listRuns(root) {
|
|
89
|
+
const paths = pathsFor(root), ids = new Set(autonomyFiles(paths.projectRoot, paths.runsRoot).map(path => path.slice(paths.runsRoot.length + 1).split('/')[0]));
|
|
90
|
+
if (ids.size > 256) autonomyError('autonomy-run-evidence-limit');
|
|
91
|
+
return [...ids].map(id => readRun(root, id));
|
|
92
|
+
}
|
|
93
|
+
function save(paths, previous, changes) {
|
|
94
|
+
const { digest: oldDigest, ...old } = previous;
|
|
95
|
+
const body = { ...old, ...changes, revision: previous.revision + 1, previousDigest: oldDigest,
|
|
96
|
+
updatedAt: new Date().toISOString() };
|
|
97
|
+
return record(paths, resolve(paths.runRoot, 'revisions', `${body.revision}-${autonomyDigest(body).slice(7)}.json`), body);
|
|
98
|
+
}
|
|
99
|
+
function update(root, id, callback) {
|
|
100
|
+
const paths = pathsFor(root, id);
|
|
101
|
+
return withAutonomyLock(paths, () => { const current = readRun(root, id); return save(paths, current, callback(current)); });
|
|
102
|
+
}
|
|
103
|
+
function safeRun(run) {
|
|
104
|
+
return { schema: run.schema, id: run.id, revision: run.revision, digest: run.digest, mode: run.mode, status: run.status,
|
|
105
|
+
desiredState: run.desiredState, grantDigest: run.grantDigest, provider: run.provider, startedAt: run.startedAt,
|
|
106
|
+
updatedAt: run.updatedAt, counters: run.counters, usage: { providerReported: null, hardMoneyCap: false },
|
|
107
|
+
current: run.current, lastResult: run.lastResult, questions: run.questions, code: run.code,
|
|
108
|
+
cancellation: run.cancellation, executionStopped: run.executionStopped, requiresHuman: run.requiresHuman,
|
|
109
|
+
lifetime: { kind: run.mode, ownerProcessId: run.owner.pid, ownerProcessAlive: ownerAlive(run) },
|
|
110
|
+
authority: 'bounded-grant-only', phaseCompleted: false };
|
|
111
|
+
}
|
|
112
|
+
function ownerAlive(run) { try { process.kill(run.owner.pid, 0); return true; } catch (error) { return error.code !== 'ESRCH'; } }
|
|
113
|
+
export function readAutonomyRun(root, id) {
|
|
114
|
+
const run = readRun(root, id), safe = safeRun(run);
|
|
115
|
+
return !terminal.has(run.status) && ['starting', 'running', 'cancel-requested'].includes(run.status) && !ownerAlive(run)
|
|
116
|
+
? { ...safe, status: 'recovery-required', requiresHuman: true, code: 'autonomy-owner-lost' } : safe;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function currentGrant(root, expected) {
|
|
120
|
+
const policy = readAutonomyPolicy(root);
|
|
121
|
+
if (policy.status !== 'current' || policy.grant?.digest !== expected) autonomyError(`autonomy-grant-${policy.status === 'current' ? 'stale' : policy.status}`);
|
|
122
|
+
if (Date.now() < Date.parse(policy.grant.approvedAt)) autonomyError('autonomy-clock-rollback');
|
|
123
|
+
const paths = autonomyPaths(root);
|
|
124
|
+
if (autonomyDigest(readAutonomyFile(paths.projectRoot, paths.configPath)) !== policy.grant.configDigest) autonomyError('autonomy-grant-policy-changed');
|
|
125
|
+
return policy.grant;
|
|
126
|
+
}
|
|
127
|
+
function createRun(root, input, mode) {
|
|
128
|
+
inputKeys(input, mode === 'service' ? ['expectedDigest', 'provider', 'confirmed'] : ['expectedDigest', 'provider']);
|
|
129
|
+
if (!digestPattern.test(input.expectedDigest) || mode === 'service' && input.confirmed !== true) autonomyError('autonomy-run-confirmation-required');
|
|
130
|
+
const paths = pathsFor(root);
|
|
131
|
+
return withAutonomyLock(paths, () => {
|
|
132
|
+
const grant = currentGrant(root, input.expectedDigest);
|
|
133
|
+
if (!grant.scope.providers.includes(input.provider)) autonomyError('autonomy-provider-not-permitted');
|
|
134
|
+
if (listRuns(root).some(run => !terminal.has(run.status))) autonomyError('autonomy-supervisor-busy');
|
|
135
|
+
const id = randomUUID(), at = Date.now();
|
|
136
|
+
const run = { schema: 'ewai.autonomy-run/v1', id, revision: 1, previousDigest: null,
|
|
137
|
+
projectIdentity: autonomyDigest(paths.projectRoot), mode, provider: input.provider, grantDigest: grant.digest,
|
|
138
|
+
status: 'starting', desiredState: 'running', startedAt: new Date(at).toISOString(), updatedAt: new Date(at).toISOString(),
|
|
139
|
+
lastClock: at, counters: { elapsedMs: 0, providerAttempts: 0, reviewCycles: 0, actions: 0 },
|
|
140
|
+
owner: { pid: process.pid, instance: randomUUID() }, current: null, lastResult: null, questions: [],
|
|
141
|
+
code: null, cancellation: null, executionStopped: true, requiresHuman: false };
|
|
142
|
+
run.ownedFiles = []; run.repositoryIdentity = null;
|
|
143
|
+
return record(paths, resolve(paths.runsRoot, id, 'revisions', `1-${autonomyDigest(run).slice(7)}.json`), run);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
function budget(root, run, elapsed = run.counters.elapsedMs) {
|
|
147
|
+
const grant = currentGrant(root, run.grantDigest), prior = listRuns(root).filter(other => other.id !== run.id && other.grantDigest === run.grantDigest);
|
|
148
|
+
const used = prior.reduce((sum, other) => ({ elapsedMs: sum.elapsedMs + other.counters.elapsedMs,
|
|
149
|
+
providerAttempts: sum.providerAttempts + other.counters.providerAttempts }), { elapsedMs: elapsed, providerAttempts: run.counters.providerAttempts });
|
|
150
|
+
if (Date.now() < Math.max(run.lastClock, ...prior.map(other => other.lastClock))) autonomyError('autonomy-clock-rollback');
|
|
151
|
+
if (used.elapsedMs >= grant.scope.limits.maxRuntimeMs) autonomyError('autonomy-runtime-exhausted');
|
|
152
|
+
return { grant, remainingMs: grant.scope.limits.maxRuntimeMs - used.elapsedMs,
|
|
153
|
+
remainingAttempts: grant.scope.limits.maxAttempts - used.providerAttempts };
|
|
154
|
+
}
|
|
155
|
+
function repositoryReady(root, run) {
|
|
156
|
+
const repository = readAutonomyRepositoryState(root, run.ownedFiles);
|
|
157
|
+
if (repository.status !== 'ready') autonomyError(repository.reasons[0]);
|
|
158
|
+
if (run.repositoryIdentity && run.repositoryIdentity !== repository.digest) autonomyError('autonomy-repository-changed');
|
|
159
|
+
return repository;
|
|
160
|
+
}
|
|
161
|
+
function question(root, run, candidate, codes) {
|
|
162
|
+
const paths = pathsFor(root, run.id), snapshot = readIntentMutationSnapshot(root, candidate.intentId), grant = readAutonomyPolicy(root).grant;
|
|
163
|
+
const body = { schema: 'ewai.autonomy-question/v1', intentId: candidate.intentId, phase: candidate.phase,
|
|
164
|
+
grantDigest: run.grantDigest, sourceRevision: snapshot.digest, owner: grant.approvedBy,
|
|
165
|
+
codes: [...new Set(codes)].sort(), authority: 'none' };
|
|
166
|
+
const id = autonomyDigest(body).slice(7);
|
|
167
|
+
record(paths, resolve(paths.authorityRoot, 'supervisor/questions', `${id}.json`), { ...body, id });
|
|
168
|
+
return { id, intentId: body.intentId, phase: body.phase, owner: body.owner, sourceRevision: body.sourceRevision, codes: body.codes, authority: 'none' };
|
|
169
|
+
}
|
|
170
|
+
export function answerAutonomyQuestion(root, input) {
|
|
171
|
+
inputKeys(input, ['runId', 'questionId', 'expectedRevision', 'answeredBy', 'answer']);
|
|
172
|
+
if (!/^[a-f0-9]{64}$/.test(input.questionId ?? '') || !named(input.answeredBy)
|
|
173
|
+
|| typeof input.answer !== 'string' || !input.answer.trim() || Buffer.byteLength(input.answer) > 16384) autonomyError('autonomy-answer-invalid', 400);
|
|
174
|
+
const paths = pathsFor(root, input.runId);
|
|
175
|
+
return withAutonomyLock(paths, () => {
|
|
176
|
+
const run = readRun(root, input.runId);
|
|
177
|
+
if (run.revision !== input.expectedRevision) autonomyError('autonomy-run-stale');
|
|
178
|
+
const q = run.questions.find(value => value.id === input.questionId);
|
|
179
|
+
if (!q || q.owner !== input.answeredBy) autonomyError('autonomy-question-owner-required');
|
|
180
|
+
if (readIntentMutationSnapshot(root, q.intentId).digest !== q.sourceRevision) autonomyError('autonomy-question-stale');
|
|
181
|
+
const body = { schema: 'ewai.autonomy-answer/v1', questionId: q.id, sourceRevision: q.sourceRevision,
|
|
182
|
+
answeredBy: input.answeredBy, answer: input.answer, recordedAt: new Date().toISOString(), authority: 'none' };
|
|
183
|
+
const result = record(paths, resolve(paths.authorityRoot, 'supervisor/answers', q.id, `${autonomyDigest(body).slice(7)}.json`), body);
|
|
184
|
+
return { status: 'recorded', questionId: q.id, digest: result.digest, authority: 'none', approvalRecorded: false };
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function controlAutonomyRun(root, input) {
|
|
189
|
+
inputKeys(input, ['runId', 'action', 'expectedRevision', 'confirmed', 'revokedBy']);
|
|
190
|
+
if (!['pause', 'cancel', 'resume', 'revoke', 'recover'].includes(input.action) || !Number.isSafeInteger(input.expectedRevision)) autonomyError('autonomy-control-invalid', 400);
|
|
191
|
+
const paths = pathsFor(root, input.runId);
|
|
192
|
+
// Grant revocation owns its own authority lock; never nest that lock.
|
|
193
|
+
if (input.action === 'revoke') {
|
|
194
|
+
const run = readRun(root, input.runId);
|
|
195
|
+
if (run.revision !== input.expectedRevision) autonomyError('autonomy-run-stale');
|
|
196
|
+
revokeAutonomyGrant(root, { expectedDigest: run.grantDigest, confirmed: input.confirmed, revokedBy: input.revokedBy });
|
|
197
|
+
}
|
|
198
|
+
const result = withAutonomyLock(paths, () => {
|
|
199
|
+
const run = readRun(root, input.runId);
|
|
200
|
+
if (input.action !== 'revoke' && run.revision !== input.expectedRevision) autonomyError('autonomy-run-stale');
|
|
201
|
+
if (input.action === 'revoke' && terminal.has(run.status)) return run;
|
|
202
|
+
if (terminal.has(run.status)) autonomyError('autonomy-run-terminal');
|
|
203
|
+
if (input.action === 'recover') {
|
|
204
|
+
if (live.has(`${paths.projectRoot}:${run.id}`) || ownerAlive(run)) autonomyError('autonomy-owner-still-active');
|
|
205
|
+
const operation = run.current;
|
|
206
|
+
// Parent death is not proof that a provider or its descendants stopped.
|
|
207
|
+
// Only a matching canonical receipt for synchronous begin is conclusive.
|
|
208
|
+
const recovered = operation?.action === 'begin-harness'
|
|
209
|
+
? recoverCompletedAutonomyOperation(root, operation.operationId, { intentId: operation.intentId, grantDigest: run.grantDigest, action: operation.action }) : null;
|
|
210
|
+
const complete = recovered?.status === 'completed';
|
|
211
|
+
return save(paths, run, { status: complete ? 'completed' : 'recovery-required', requiresHuman: !complete,
|
|
212
|
+
executionStopped: complete, current: complete ? null : run.current, code: complete ? null : 'autonomy-execution-unknown',
|
|
213
|
+
lastResult: complete ? { status: 'reconciled', operationId: operation.operationId, intentId: operation.intentId } : run.lastResult,
|
|
214
|
+
counters: { ...run.counters, elapsedMs: run.counters.elapsedMs + Math.max(0, Date.now() - run.lastClock) }, lastClock: Math.max(Date.now(), run.lastClock) });
|
|
215
|
+
}
|
|
216
|
+
if (input.action === 'resume') {
|
|
217
|
+
if (!['paused', 'awaiting-human'].includes(run.status) || run.executionStopped !== true
|
|
218
|
+
|| live.has(`${paths.projectRoot}:${run.id}`) || run.owner.pid !== process.pid && ownerAlive(run)) autonomyError('autonomy-recovery-required');
|
|
219
|
+
budget(root, run);
|
|
220
|
+
if (run.questions.some(q => readIntentMutationSnapshot(root, q.intentId).digest === q.sourceRevision)) autonomyError('autonomy-canonical-decision-required');
|
|
221
|
+
const repository = readAutonomyRepositoryState(root);
|
|
222
|
+
if (repository.status !== 'ready') autonomyError(repository.reasons[0]);
|
|
223
|
+
return save(paths, run, { status: 'starting', desiredState: 'running', owner: { pid: process.pid, instance: randomUUID() },
|
|
224
|
+
repositoryIdentity: repository.digest, ownedFiles: [], lastClock: Date.now(), questions: [], code: null });
|
|
225
|
+
}
|
|
226
|
+
const desiredState = input.action === 'pause' ? 'paused' : input.action === 'cancel' ? 'cancelled' : 'revoked';
|
|
227
|
+
const status = run.executionStopped ? desiredState : input.action === 'pause' ? 'running' : 'cancel-requested';
|
|
228
|
+
return save(paths, run, { desiredState, status, cancellation: input.action === 'pause' ? run.cancellation
|
|
229
|
+
: { status: run.executionStopped ? 'confirmed' : 'requested', requestedAt: new Date().toISOString() } });
|
|
230
|
+
});
|
|
231
|
+
const active = live.get(`${paths.projectRoot}:${input.runId}`);
|
|
232
|
+
if (['cancel', 'revoke'].includes(input.action)) active?.controller?.abort();
|
|
233
|
+
const afkId = active?.afkId ?? result.current?.afkRunId;
|
|
234
|
+
if (afkId && ['pause', 'cancel', 'revoke'].includes(input.action)) {
|
|
235
|
+
try {
|
|
236
|
+
const afk = afkRunStatus(root, afkId);
|
|
237
|
+
if (afk.autonomy?.runId !== result.id || afk.autonomy?.grantDigest !== result.grantDigest) autonomyError('autonomy-afk-owner-mismatch');
|
|
238
|
+
if (input.action === 'pause') pauseAfkRun(root, afkId);
|
|
239
|
+
else cancelAfkRun(root, afkId);
|
|
240
|
+
} catch (error) { autonomyError(failureCode(error)); }
|
|
241
|
+
}
|
|
242
|
+
if (input.action === 'resume') return result.mode === 'service' ? launchService(root, result) : executeAutonomyRun(root, result.id);
|
|
243
|
+
return safeRun(result);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function assertBuild(root, candidate, initial = false) {
|
|
247
|
+
const paths = autonomyPaths(root), directory = resolve(paths.buildRoot, candidate.intentId.split('/')[1]);
|
|
248
|
+
const state = JSON.parse(readAutonomyFile(paths.projectRoot, resolve(directory, 'delivery-state.json')));
|
|
249
|
+
const approval = JSON.parse(readAutonomyFile(paths.projectRoot, resolve(directory, 'gates/build/build-approval.json')));
|
|
250
|
+
if (approval.schema !== 'ewai.build-approval/v1' || approval.decision !== 'approved' || !named(approval.approvedBy)
|
|
251
|
+
|| !approval.scope || state.approvals?.build?.approvedAt !== approval.approvedAt
|
|
252
|
+
|| state.approvals?.build?.approvedBy !== approval.approvedBy || state.approvals?.build?.scope !== approval.scope) autonomyError('autonomy-build-approval-stale');
|
|
253
|
+
const item = readAutonomySnapshot(root).items.find(item => item.intentId === candidate.intentId);
|
|
254
|
+
if (!item?.accepted || !item.execution.valid || !item.execution.actions.enterBuild.permitted || initial && !item.execution.actions.acquireTask.permitted) autonomyError('autonomy-build-not-ready');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function executeAutonomyRun(root, id) {
|
|
258
|
+
const initial = readRun(root, id), paths = pathsFor(root, id), key = `${paths.projectRoot}:${id}`;
|
|
259
|
+
if (initial.status !== 'starting' || initial.owner.pid !== process.pid || live.has(key)) autonomyError('autonomy-run-owner-required');
|
|
260
|
+
const active = { controller: null, afkId: null, executionStopped: true, proposal: null, operationDeadline: null }, start = performance.now(), initialElapsed = initial.counters.elapsedMs;
|
|
261
|
+
live.set(key, active);
|
|
262
|
+
const elapsed = () => initialElapsed + Math.max(0, performance.now() - start, Date.now() - initial.lastClock);
|
|
263
|
+
const check = () => {
|
|
264
|
+
const run = readRun(root, id);
|
|
265
|
+
if (run.owner.instance !== initial.owner.instance || run.owner.pid !== process.pid) autonomyError('autonomy-run-owner-stale');
|
|
266
|
+
if (['cancelled', 'revoked'].includes(run.desiredState)) { active.controller?.abort(); autonomyError(`autonomy-run-${run.desiredState}`); }
|
|
267
|
+
if (active.operationDeadline !== null && performance.now() >= active.operationDeadline) { active.controller?.abort(); autonomyError('autonomy-operation-timeout'); }
|
|
268
|
+
return { run, ...budget(root, run, elapsed()) };
|
|
269
|
+
};
|
|
270
|
+
const settle = (changes = {}) => update(root, id, run => ({ ...changes, counters: { ...run.counters, elapsedMs: Math.ceil(elapsed()) }, lastClock: Math.max(run.lastClock, Date.now()) }));
|
|
271
|
+
let timer;
|
|
272
|
+
try {
|
|
273
|
+
settle({ status: 'running' });
|
|
274
|
+
while (true) {
|
|
275
|
+
const { run, grant, remainingMs, remainingAttempts } = check();
|
|
276
|
+
if (run.desiredState === 'paused') return safeRun(settle({ status: 'paused', executionStopped: true }));
|
|
277
|
+
const preview = previewAutonomy(root), candidate = run.current?.afkRunId ? run.current : preview.executable[0];
|
|
278
|
+
if (!candidate) {
|
|
279
|
+
const pending = [...preview.humanDecisions, ...preview.blocked].find(item => grant.scope.intentIds.includes(item.intentId));
|
|
280
|
+
const questions = pending ? [question(root, run, pending, pending.reasons.map(reason => reason.code))] : [];
|
|
281
|
+
return safeRun(settle({ status: questions.length ? 'awaiting-human' : 'completed', questions, requiresHuman: questions.length > 0, executionStopped: true }));
|
|
282
|
+
}
|
|
283
|
+
const repository = repositoryReady(root, run);
|
|
284
|
+
const operationId = candidate.afkRunId ? candidate.operationId : randomUUID();
|
|
285
|
+
settle({ current: { ...candidate, operationId }, executionStopped: false, repositoryIdentity: repository.digest });
|
|
286
|
+
active.controller = new AbortController();
|
|
287
|
+
active.operationDeadline = performance.now() + Math.min(remainingMs, grant.scope.limits.maxOperationMs);
|
|
288
|
+
timer = setInterval(() => { try { check(); } catch { active.controller?.abort(); } }, 50);
|
|
289
|
+
let result;
|
|
290
|
+
if (candidate.action === 'begin-harness') {
|
|
291
|
+
const predecessor = readIntentMutationSnapshot(root, candidate.intentId).digest;
|
|
292
|
+
check();
|
|
293
|
+
const mutation = withIntentMutation(root, candidate.intentId, { id: operationId, action: 'begin-harness', provider: run.provider,
|
|
294
|
+
grantDigest: grant.digest, expectedPredecessor: predecessor }, () => {
|
|
295
|
+
check();
|
|
296
|
+
const begun = beginDelivery(root, candidate.intentId.split('/')[1], { tool: run.provider });
|
|
297
|
+
finishActiveSession(root, candidate.intentId, { ownerId: begun.run.id, tool: run.provider, summary: 'Autonomous bounded harness start complete.' });
|
|
298
|
+
return { status: 'harness-started', operationId, intentId: candidate.intentId, phaseCompleted: false };
|
|
299
|
+
});
|
|
300
|
+
result = mutation.result;
|
|
301
|
+
const operationFiles = autonomyFiles(paths.projectRoot, resolve(paths.authorityRoot, 'operations', operationId));
|
|
302
|
+
update(root, id, value => ({ ownedFiles: [...new Map([...value.ownedFiles, ...mutation.receipt.result.files,
|
|
303
|
+
...operationFiles.map(path => ({ path: relative(paths.projectRoot, path), digest: autonomyDigest(readAutonomyFile(paths.projectRoot, path)) }))]
|
|
304
|
+
.map(file => [file.path, file])).values()] }));
|
|
305
|
+
} else if (candidate.action === 'prepare-phase') {
|
|
306
|
+
if (remainingAttempts < 1) autonomyError('autonomy-attempts-exhausted');
|
|
307
|
+
if (remainingMs < grant.scope.limits.maxOperationMs) autonomyError('autonomy-runtime-reservation-unavailable');
|
|
308
|
+
const adapter = preparePhaseProvider(run.provider);
|
|
309
|
+
if (adapter.status === 'unavailable') autonomyError(adapter.code);
|
|
310
|
+
active.executionStopped = false;
|
|
311
|
+
const verified = await verifyPhaseProviderConformance(adapter, { signal: active.controller.signal,
|
|
312
|
+
timeoutMs: Math.max(100, Math.min(600000, Math.floor(active.operationDeadline - performance.now()))) });
|
|
313
|
+
active.executionStopped = verified.executionStopped !== false;
|
|
314
|
+
if (verified.status !== 'verified') autonomyError(verified.code ?? 'autonomy-provider-unavailable');
|
|
315
|
+
check(); repositoryReady(root, readRun(root, id));
|
|
316
|
+
const contract = prepareAutonomyPhase(root, candidate.intentId, candidate.phase);
|
|
317
|
+
if (contract.status !== 'prepared') autonomyError(contract.code);
|
|
318
|
+
update(root, id, current => ({ current: { ...current.current, workerOperationId: contract.operationId },
|
|
319
|
+
counters: { ...current.counters, providerAttempts: current.counters.providerAttempts + 1 } }));
|
|
320
|
+
active.executionStopped = false;
|
|
321
|
+
const proposed = await invokePhaseProposal(contract, adapter, { signal: active.controller.signal });
|
|
322
|
+
active.executionStopped = proposed.executionStopped !== false;
|
|
323
|
+
if (proposed.status === 'draft-ready') active.proposal = { operationId: contract.operationId, result: proposed };
|
|
324
|
+
if (proposed.executionStopped === false) autonomyError('autonomy-execution-unknown');
|
|
325
|
+
check();
|
|
326
|
+
if (proposed.status !== 'draft-ready') autonomyError(proposed.code ?? 'autonomy-phase-not-ready');
|
|
327
|
+
// Worker drafts are isolated evidence, not writes into application code.
|
|
328
|
+
const workerFiles = autonomyFiles(paths.projectRoot, resolve(paths.authorityRoot, 'phase-workers', contract.operationId))
|
|
329
|
+
.map(path => ({ path: relative(paths.projectRoot, path), digest: autonomyDigest(readAutonomyFile(paths.projectRoot, path)) }));
|
|
330
|
+
if (!uuid.test(proposed.reservationOperationId ?? '')) autonomyError('autonomy-reservation-evidence-missing');
|
|
331
|
+
workerFiles.push(...autonomyFiles(paths.projectRoot, resolve(paths.authorityRoot, 'operations', proposed.reservationOperationId))
|
|
332
|
+
.map(path => ({ path: relative(paths.projectRoot, path), digest: autonomyDigest(readAutonomyFile(paths.projectRoot, path)) })));
|
|
333
|
+
const acceptanceRun = readRun(root, id);
|
|
334
|
+
repositoryReady(root, { ...acceptanceRun, ownedFiles: [...acceptanceRun.ownedFiles, ...workerFiles] });
|
|
335
|
+
result = acceptPhaseProposal(root, contract.operationId, proposed);
|
|
336
|
+
active.proposal = null;
|
|
337
|
+
if (result.status !== 'draft-staged') autonomyError(result.code ?? 'autonomy-phase-not-accepted');
|
|
338
|
+
const questions = [question(root, readRun(root, id), candidate, result.questions.map(q => q.code))];
|
|
339
|
+
update(root, id, value => ({ counters: { ...value.counters, actions: value.counters.actions + 1 } }));
|
|
340
|
+
return safeRun(settle({ status: readRun(root, id).desiredState === 'paused' ? 'paused' : 'awaiting-human', current: null, lastResult: { status: result.status,
|
|
341
|
+
operationId: result.operationId, intentId: result.intentId, phase: result.phase }, questions, executionStopped: true, requiresHuman: true }));
|
|
342
|
+
} else if (candidate.action === 'afk-build') {
|
|
343
|
+
if (!grant.scope.actions.includes('afk-build') || !grant.scope.intentIds.includes(candidate.intentId)) autonomyError('autonomy-afk-scope-invalid');
|
|
344
|
+
assertBuild(root, candidate, !candidate.afkRunId);
|
|
345
|
+
const preflight = preflightAfkRun(root, candidate.intentId.split('/')[1], { provider: run.provider, maxParallel: 1, ignoreRunId: candidate.afkRunId });
|
|
346
|
+
if (preflight.status !== 'ready') autonomyError('autonomy-afk-preflight-blocked');
|
|
347
|
+
if (preflight.readyTasks.length && remainingAttempts < 2) autonomyError('autonomy-attempts-exhausted');
|
|
348
|
+
const ownership = acquireIntentOwnership(root, candidate.intentId, { ownerId: `supervisor-${id}`, durationMs: Math.min(86400000, Math.ceil(remainingMs) + 60000) });
|
|
349
|
+
try {
|
|
350
|
+
const options = { provider: run.provider, maxParallel: 1, foreground: true,
|
|
351
|
+
timeoutMs: Math.min(grant.scope.limits.maxOperationMs, Math.floor(remainingMs)), autonomy: { runId: id, grantDigest: grant.digest },
|
|
352
|
+
dependencies: { authority: ({ stage, mode, transitions }) => {
|
|
353
|
+
const current = check(); assertIntentOwnership(root, ownership); assertBuild(root, candidate);
|
|
354
|
+
if (stage === 'worktree' && current.run.desiredState === 'paused') autonomyError('autonomy-run-paused');
|
|
355
|
+
if (stage === 'integrate' || stage === 'dispatch' && mode !== 'command') {
|
|
356
|
+
repositoryReady(root, { ...current.run, ownedFiles: [], repositoryIdentity: active.afkRepository.digest });
|
|
357
|
+
}
|
|
358
|
+
if (stage === 'integrated') {
|
|
359
|
+
const records = active.afkRepository.records.map(record => ({ ...record }));
|
|
360
|
+
for (const change of transitions) {
|
|
361
|
+
const record = records.find(record => record.root === relative(paths.projectRoot, change.root));
|
|
362
|
+
if (!record || record.identity !== `${change.before}\n${change.branch}\n`) autonomyError('autonomy-repository-changed');
|
|
363
|
+
record.identity = `${change.after}\n${change.branch}\n`;
|
|
364
|
+
}
|
|
365
|
+
active.afkRepository = repositoryReady(root, { ...current.run, ownedFiles: [], repositoryIdentity: autonomyDigest(records) });
|
|
366
|
+
update(root, id, () => ({ repositoryIdentity: active.afkRepository.digest }));
|
|
367
|
+
}
|
|
368
|
+
if (stage === 'dispatch' && mode !== 'command') {
|
|
369
|
+
if (current.remainingAttempts < (mode === 'implementation' ? 2 : 1)) autonomyError('autonomy-attempts-exhausted');
|
|
370
|
+
update(root, id, value => ({ counters: { ...value.counters, providerAttempts: value.counters.providerAttempts + 1,
|
|
371
|
+
reviewCycles: value.counters.reviewCycles + (mode === 'review' ? 1 : 0) } }));
|
|
372
|
+
}
|
|
373
|
+
}, onRun: afk => {
|
|
374
|
+
active.afkId = afk.id;
|
|
375
|
+
active.afkRepository = repositoryReady(root, { ...readRun(root, id), repositoryIdentity: null, ownedFiles: [] });
|
|
376
|
+
const control = update(root, id, value => ({ current: { ...value.current, afkRunId: afk.id } }));
|
|
377
|
+
if (control.desiredState === 'paused') pauseAfkRun(root, afk.id);
|
|
378
|
+
else if (['cancelled', 'revoked'].includes(control.desiredState)) cancelAfkRun(root, afk.id);
|
|
379
|
+
active.executionStopped = false;
|
|
380
|
+
} } };
|
|
381
|
+
result = await (candidate.afkRunId ? resumeAfkRun(root, candidate.afkRunId, options)
|
|
382
|
+
: startAfkRun(root, candidate.intentId.split('/')[1], options));
|
|
383
|
+
active.executionStopped = result.executionStopped === true;
|
|
384
|
+
} finally { if (active.executionStopped) try { releaseIntentOwnership(root, ownership); } catch {} }
|
|
385
|
+
if (result.executionStopped !== true) autonomyError('autonomy-execution-unknown');
|
|
386
|
+
check();
|
|
387
|
+
if (!['completed', 'paused'].includes(result.status)) autonomyError('autonomy-afk-blocked');
|
|
388
|
+
if (result.status === 'paused') return safeRun(settle({ status: 'paused', executionStopped: true, requiresHuman: false }));
|
|
389
|
+
result = { status: 'afk-settled', afkRunId: result.id, intentId: candidate.intentId };
|
|
390
|
+
} else autonomyError('autonomy-action-unavailable');
|
|
391
|
+
clearInterval(timer); timer = null; active.controller = null; active.afkId = null; active.operationDeadline = null;
|
|
392
|
+
const settled = settle({ current: null, lastResult: result, executionStopped: true });
|
|
393
|
+
update(root, id, value => ({ counters: { ...value.counters, actions: value.counters.actions + 1 } }));
|
|
394
|
+
if (initial.mode === 'once') return safeRun(settle({ status: settled.desiredState === 'paused' ? 'paused' : 'completed' }));
|
|
395
|
+
}
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (active.proposal) {
|
|
398
|
+
// Revoked, expired, cancelled or stale proposals must release their local
|
|
399
|
+
// acceptance capability without publishing an assessment.
|
|
400
|
+
active.controller.abort();
|
|
401
|
+
acceptPhaseProposal(root, active.proposal.operationId, active.proposal.result);
|
|
402
|
+
}
|
|
403
|
+
const run = readRun(root, id), policy = readAutonomyPolicy(root);
|
|
404
|
+
const cancelled = ['cancelled', 'revoked'].includes(run.desiredState);
|
|
405
|
+
const status = !active.executionStopped ? 'recovery-required' : cancelled ? run.desiredState : error.code === 'autonomy-run-paused' ? 'paused'
|
|
406
|
+
: policy.status === 'expired' ? 'expired' : policy.status === 'revoked' ? 'revoked' : 'blocked';
|
|
407
|
+
return safeRun(settle({ status, code: failureCode(error), current: active.executionStopped ? null : run.current, executionStopped: active.executionStopped,
|
|
408
|
+
requiresHuman: !cancelled || !active.executionStopped, cancellation: cancelled ? { ...run.cancellation, status: active.executionStopped ? 'confirmed' : 'unknown' } : run.cancellation }));
|
|
409
|
+
} finally { clearInterval(timer); live.delete(key); }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export async function runAutonomyOnce(root, input) { const run = createRun(root, input, 'once'); return executeAutonomyRun(root, run.id); }
|
|
413
|
+
export function startAutonomyService(root, input) {
|
|
414
|
+
return launchService(root, createRun(root, input, 'service'));
|
|
415
|
+
}
|
|
416
|
+
function launchService(root, run) {
|
|
417
|
+
const paths = pathsFor(root, run.id);
|
|
418
|
+
const child = spawn(process.execPath, [new URL('../autonomy-worker.mjs', import.meta.url).pathname, '--project', paths.projectRoot, '--run', run.id],
|
|
419
|
+
{ cwd: paths.projectRoot, detached: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'] });
|
|
420
|
+
const unknown = () => { try { update(root, run.id, () => ({ status: 'recovery-required', code: 'autonomy-service-start-unknown', requiresHuman: true, executionStopped: false })); } catch {} };
|
|
421
|
+
child.once('error', unknown);
|
|
422
|
+
if (!Number.isSafeInteger(child.pid)) { unknown(); return safeRun(readRun(root, run.id)); }
|
|
423
|
+
const started = update(root, run.id, current => ({ owner: { ...current.owner, pid: child.pid } }));
|
|
424
|
+
child.send({ start: true }, error => { if (error) unknown(); if (child.connected) child.disconnect(); }); child.unref();
|
|
425
|
+
return safeRun(started);
|
|
426
|
+
}
|