@awak-app/simy-cli 0.7.11 → 0.7.13
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/package.json +1 -1
- package/src/agent.js +50 -8
- package/src/auto-update.js +32 -9
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +108 -11
- package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +326 -0
- package/src/codex-plugins/simy/hooks/update-dispatch-identity.mjs +78 -0
- package/src/codex-plugins/simy/hooks/update-state-store.mjs +73 -0
- package/src/delivery-hooks/supervisor.js +39 -10
- package/src/desktop-host.js +54 -8
- package/src/desktop-status.js +1 -1
- package/src/direct-executor.js +104 -4
- package/src/local-task.js +14 -3
- package/src/runner.js +19 -4
- package/src/session-supervision/claude-sessions.js +57 -5
- package/src/session-supervision/codex-app-server.js +20 -8
- package/src/sqm/claude-plugin.js +5 -0
- package/src/sqm/review-worker.js +15 -6
- package/src/update-detachment/claude-observation.js +33 -0
- package/src/update-detachment/codex-context.js +27 -0
- package/src/update-detachment/coordinator.js +117 -0
- package/src/update-detachment/dispatch-gate.js +1 -0
- package/src/update-detachment/ledger-adapter.js +62 -0
- package/src/update-detachment/ledger-reader.js +42 -0
- package/src/update-detachment/provenance.js +1 -0
- package/src/update-detachment/provider-adapters.js +33 -0
- package/src/update-detachment/reconcile.js +105 -0
- package/src/update-detachment/reconciliation-adapter.js +13 -0
- package/src/update-detachment/runtime.js +319 -0
- package/src/update-detachment/state-store.js +1 -0
- package/src/update-detachment/worker-entry.js +145 -0
- package/src/update-detachment/worker-ledger-runtime.js +34 -0
- package/src/update-detachment/worker-ledger.js +138 -0
- package/src/update-detachment/worker-storage.js +29 -0
- package/src/update-detachment/worker.js +167 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { mkdirSync, realpathSync, readFileSync, readdirSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { registerDispatchProvenance, normalizeDispatchIdentity } from './update-dispatch-identity.mjs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
6
|
+
import { readState, createStateStore, atomicSave, withLock, acquireLock, fail, digest, isMap, CONNECTION_STATES } from './update-state-store.mjs';
|
|
7
|
+
|
|
8
|
+
export const DISPATCH_PROTOCOL = 3;
|
|
9
|
+
export const DISPATCH_MODULE_DIGEST = createHash('sha256').update(readFileSync(new URL(import.meta.url))).digest('hex');
|
|
10
|
+
export const detachmentRoot = () => resolve(process.env.SIMY_HOME?.trim() || join(homedir(), '.simy'));
|
|
11
|
+
export const rootFromDelivery = root => basename(resolve(root)) === 'delivery-hooks' ? dirname(resolve(root)) : resolve(root);
|
|
12
|
+
const path = root => join(resolve(root), 'update-detachment', 'gate.json');
|
|
13
|
+
function read(root) {
|
|
14
|
+
const state = readState(path(root), { versions: [1, 2] }) || { schemaVersion: 1, revision: 0, generation: 0, connection: 'connected', attempts: {}, receipts: {}, events: {} };
|
|
15
|
+
if (!Number.isSafeInteger(state.generation) || !Number.isSafeInteger(state.revision) || !CONNECTION_STATES.includes(state.connection) || !['attempts', 'receipts', 'events'].every(key => isMap(state[key])) || (state.hookOwners !== undefined && !isMap(state.hookOwners)) || !Object.values(state.attempts).every(attempt => isMap(attempt) && typeof attempt.id === 'string' && Number.isSafeInteger(attempt.generation) && ['intent','sending','sent','provider_identified','result_saved','confirmed'].includes(attempt.phase))) fail('invalid_state');
|
|
16
|
+
if ((state.schemaVersion === 2 && ![2, 3].includes(state.minimumDispatchProtocol)) || (state.minimumDispatchProtocol !== undefined && ![2, 3].includes(state.minimumDispatchProtocol))) fail('writer_protocol_incompatible');
|
|
17
|
+
validateTaskFences(state, root);
|
|
18
|
+
return state;
|
|
19
|
+
}
|
|
20
|
+
const owners = new Map();
|
|
21
|
+
export function captureDispatchOwner(root = detachmentRoot()) {
|
|
22
|
+
mkdirSync(root, { recursive: true, mode: 0o700 }); root = realpathSync(root); const state = read(root);
|
|
23
|
+
if (!owners.has(root)) owners.set(root, Object.freeze({ root, generation: state.generation, protocol: DISPATCH_PROTOCOL }));
|
|
24
|
+
return owners.get(root);
|
|
25
|
+
}
|
|
26
|
+
export function readDispatchGate(root = detachmentRoot()) { return read(root); }
|
|
27
|
+
export function transitionConnection({ root = detachmentRoot(), expectedRevision, connection, eventId }) {
|
|
28
|
+
return withLock(path(root), () => {
|
|
29
|
+
const state = read(root);
|
|
30
|
+
if (eventId && state.events[digest(eventId)]) return state;
|
|
31
|
+
if (state.revision !== expectedRevision) fail('revision_conflict');
|
|
32
|
+
const allowed = { connected: ['detaching'], ready: ['connected', 'detaching'], detaching: ['detached', 'recovery_required'], detached: ['reconciling'], reconciling: ['ready', 'recovery_required'], recovery_required: ['reconciling', 'detaching'] };
|
|
33
|
+
if (!allowed[state.connection]?.includes(connection)) fail('invalid_transition');
|
|
34
|
+
if (connection === 'detaching') { state.generation++; state.schemaVersion = 2; state.minimumDispatchProtocol = DISPATCH_PROTOCOL; }
|
|
35
|
+
state.connection = connection; state.revision++;
|
|
36
|
+
if (eventId) state.events[digest(eventId)] = { revision: state.revision };
|
|
37
|
+
atomicSave(path(root), state); return state;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function applyReceipt(state, { attemptId, receiptId, providerExecutionId = null, resultRef = null }) {
|
|
41
|
+
if (![attemptId, receiptId].every(value => typeof value === 'string' && value.length > 0 && value.length <= 1024) || [providerExecutionId, resultRef].some(value => value !== null && (typeof value !== 'string' || !value || value.length > 2048))) fail('receipt_invalid');
|
|
42
|
+
const attempt = state.attempts[digest(attemptId)]; if (!attempt) fail('attempt_unknown');
|
|
43
|
+
const key = digest(receiptId), prior = state.receipts[key];
|
|
44
|
+
if (prior) { if (prior.attemptId !== attemptId || prior.providerExecutionId !== providerExecutionId || prior.resultRef !== resultRef) fail('receipt_conflict'); return false; }
|
|
45
|
+
state.receipts[key] = { attemptId, providerExecutionId, resultRef, generation: attempt.generation };
|
|
46
|
+
if (attempt.phase !== 'confirmed' && (resultRef || attempt.phase !== 'result_saved')) attempt.phase = resultRef ? 'result_saved' : 'provider_identified';
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
export function recordDispatchReceipt({ root = detachmentRoot(), ...receipt }) {
|
|
50
|
+
return withLock(path(root), () => {
|
|
51
|
+
const state = read(root);
|
|
52
|
+
if (applyReceipt(state, receipt)) { state.revision++; atomicSave(path(root), state); }
|
|
53
|
+
return state;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export function queueDispatchReceipt({ root = detachmentRoot(), ...receipt }) {
|
|
57
|
+
// Validate without mutating the live ledger before persisting intake.
|
|
58
|
+
applyReceipt(structuredClone(read(root)), receipt);
|
|
59
|
+
const item = { schemaVersion: 1, kind: 'receipt', ...receipt };
|
|
60
|
+
atomicSave(join(root, 'update-detachment', 'intake', `${digest(item)}.json`), item);
|
|
61
|
+
try { return flushDispatchIntake(root); } catch (error) { if (error.code !== 'state_busy') throw error; return null; }
|
|
62
|
+
}
|
|
63
|
+
export function confirmDispatch({ root = detachmentRoot(), attemptId }) {
|
|
64
|
+
return withLock(path(root), () => { const state = read(root), attempt = state.attempts[digest(attemptId)];
|
|
65
|
+
if (!attempt || !Object.values(state.receipts).some(item => item.attemptId === attemptId && item.resultRef)) fail('result_required');
|
|
66
|
+
attempt.phase = 'confirmed'; state.revision++; atomicSave(path(root), state); return state; });
|
|
67
|
+
}
|
|
68
|
+
export function dispatchWithFence(owner, { taskId, attemptId = randomUUID(), identity = null, provenance = null }, send) {
|
|
69
|
+
if (!owner || typeof send !== 'function' || typeof taskId !== 'string' || !taskId || typeof attemptId !== 'string' || !attemptId) fail('dispatch_identity_required');
|
|
70
|
+
return withLock(path(owner.root), () => {
|
|
71
|
+
const state = read(owner.root);
|
|
72
|
+
if (!['connected', 'ready'].includes(state.connection)) fail('dispatch_detached');
|
|
73
|
+
if (state.minimumDispatchProtocol && (owner.protocol !== DISPATCH_PROTOCOL || owner.protocol < state.minimumDispatchProtocol)) fail('writer_protocol_incompatible');
|
|
74
|
+
if (owner.generation !== state.generation) fail('owner_fenced');
|
|
75
|
+
if (provenance?.identity) identity = registerDispatchProvenance(provenance);
|
|
76
|
+
else if (identity) identity = normalizeDispatchIdentity(identity);
|
|
77
|
+
if (identity && identity.root !== resolve(owner.root)) fail('identity_root_mismatch');
|
|
78
|
+
assertTaskAdmissionState(state, { taskId, identity });
|
|
79
|
+
const key = digest(attemptId); if (Object.hasOwn(state.attempts, key)) fail('attempt_already_recorded');
|
|
80
|
+
state.attempts[key] = { id: attemptId, taskHash: digest(taskId), identityHash: identity ? digest(identity) : null, ...(provenance ? { provenance: { taskId: provenance.taskId, recordRef: provenance.recordRef, context: provenance.context, providerSession: provenance.providerSession }, identityStatus: identity ? 'bound' : provenance.context.account && provenance.context.device ? 'awaiting_provider_session' : 'unavailable' } : {}), generation: state.generation, phase: 'intent' };
|
|
81
|
+
state.revision++; atomicSave(path(owner.root), state);
|
|
82
|
+
state.attempts[key].phase = 'sending'; state.revision++; atomicSave(path(owner.root), state);
|
|
83
|
+
// Initiation must be synchronous: no await may separate this check and the
|
|
84
|
+
// transport write/spawn. Promise completion belongs to the result intake.
|
|
85
|
+
const value = send(attemptId);
|
|
86
|
+
return value;
|
|
87
|
+
}, { preserveResult: true });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Hook state evaluation and stdout emission share the update barrier. Lifecycle
|
|
91
|
+
// intake continues while detached, but it must not consume continuation budgets.
|
|
92
|
+
export async function fenceHook(owner, taskId, evaluate, emit = null, beforeEmit = () => true, dispatchContext = () => null) {
|
|
93
|
+
const release = acquireLock(path(owner.root));
|
|
94
|
+
try {
|
|
95
|
+
const state = read(owner.root);
|
|
96
|
+
const initialContext = dispatchContext();
|
|
97
|
+
let admitted = true;
|
|
98
|
+
try { assertTaskAdmissionState(state, { taskId: initialContext?.taskId || taskId, identity: initialContext?.identity || initialContext?.provenance?.identity }); }
|
|
99
|
+
catch (error) { if (!['task_admission_blocked', 'task_admission_identity_required'].includes(error.code)) throw error; admitted = false; }
|
|
100
|
+
const allowed = admitted && ['connected', 'ready'].includes(state.connection) && owner.generation === state.generation && (!state.minimumDispatchProtocol || owner.protocol === DISPATCH_PROTOCOL);
|
|
101
|
+
const output = await evaluate(!allowed);
|
|
102
|
+
if (!allowed) { if (emit) emit({}); return {}; }
|
|
103
|
+
if (output?.decision === 'block') {
|
|
104
|
+
const context = dispatchContext(), attemptId = context?.attemptId || randomUUID();
|
|
105
|
+
const identity = context?.provenance?.identity ? registerDispatchProvenance(context.provenance) : null;
|
|
106
|
+
assertTaskAdmissionState(state, { taskId: context?.taskId || taskId, identity });
|
|
107
|
+
if (state.attempts[digest(attemptId)]) fail('attempt_already_recorded');
|
|
108
|
+
state.attempts[digest(attemptId)] = { id: attemptId, taskHash: digest(context?.taskId || taskId), identityHash: identity ? digest(identity) : null, generation: state.generation, phase: 'sending' };
|
|
109
|
+
state.revision++; atomicSave(path(owner.root), state);
|
|
110
|
+
}
|
|
111
|
+
if (!beforeEmit()) { if (emit) emit({}); return {}; }
|
|
112
|
+
if (emit) emit(output);
|
|
113
|
+
return output;
|
|
114
|
+
} finally { release(); }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The provider remains live while hook invocations launch new Node processes.
|
|
118
|
+
// Bind its session to the first observed generation durably, not to hook PID.
|
|
119
|
+
export function captureHookOwner(root, provider, session, writer = null) {
|
|
120
|
+
return withLock(path(root), () => {
|
|
121
|
+
const state = read(root), key = digest([provider, session]);
|
|
122
|
+
state.hookOwners ||= {};
|
|
123
|
+
if (!Object.hasOwn(state.hookOwners, key)) {
|
|
124
|
+
state.hookOwners[key] = state.generation; state.revision++; atomicSave(path(root), state);
|
|
125
|
+
}
|
|
126
|
+
if (writer?.pid > 1 && writer.identity) {
|
|
127
|
+
state.writerCapabilities ||= {};
|
|
128
|
+
state.writerCapabilities[key] = { protocol: DISPATCH_PROTOCOL, pid: writer.pid, processIdentity: writer.identity, moduleDigest: DISPATCH_MODULE_DIGEST, generation: state.hookOwners[key] };
|
|
129
|
+
state.revision++; atomicSave(path(root), state);
|
|
130
|
+
}
|
|
131
|
+
return { root: realpathSync(root), generation: state.hookOwners[key], protocol: DISPATCH_PROTOCOL };
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
export function adoptHookOwner({ root = detachmentRoot(), provider, session, expectedRevision }) {
|
|
135
|
+
return withLock(path(root), () => {
|
|
136
|
+
const state = read(root);
|
|
137
|
+
if (state.revision !== expectedRevision) fail('revision_conflict');
|
|
138
|
+
if (state.connection !== 'reconciling') fail('reconciliation_required');
|
|
139
|
+
state.hookOwners ||= {}; state.hookOwners[digest([provider, session])] = state.generation;
|
|
140
|
+
state.revision++; atomicSave(path(root), state); return state;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Bind only an actual Provider-reported session; never substitute a task ID.
|
|
145
|
+
function applyIdentityIntake(state, item) {
|
|
146
|
+
const attempt = state.attempts[digest(item.attemptId)];
|
|
147
|
+
if (!attempt?.provenance) fail('provenance_unavailable');
|
|
148
|
+
if (attempt.provenance.providerSession && attempt.provenance.providerSession !== item.providerSession) fail('provider_session_mismatch');
|
|
149
|
+
const identity = normalizeDispatchIdentity({ ...attempt.provenance.context, session: item.providerSession });
|
|
150
|
+
if (attempt.identityHash && attempt.identityHash !== digest(identity)) fail('provider_session_mismatch');
|
|
151
|
+
registerDispatchProvenance(attempt.provenance, item.providerSession);
|
|
152
|
+
attempt.identityHash = digest(identity); attempt.identityStatus = 'bound';
|
|
153
|
+
const key = digest(`identity:${item.attemptId}:${item.providerExecutionId}`);
|
|
154
|
+
state.receipts[key] = { attemptId: item.attemptId, providerExecutionId: item.providerExecutionId, resultRef: null, generation: attempt.generation };
|
|
155
|
+
if (!['result_saved', 'confirmed'].includes(attempt.phase)) attempt.phase = 'provider_identified';
|
|
156
|
+
return identity;
|
|
157
|
+
}
|
|
158
|
+
export function flushDispatchIntake(root = detachmentRoot()) {
|
|
159
|
+
const directory = join(root, 'update-detachment', 'intake');
|
|
160
|
+
return withLock(path(root), () => {
|
|
161
|
+
const state = read(root); let names;
|
|
162
|
+
try { names = readdirSync(directory); } catch (error) { if (error.code === 'ENOENT') return state; throw error; }
|
|
163
|
+
const applied = [];
|
|
164
|
+
for (const name of names) {
|
|
165
|
+
if (!/^[a-f0-9]{64}\.json$/.test(name)) continue;
|
|
166
|
+
const item = readState(join(directory, name));
|
|
167
|
+
if (item?.kind === 'receipt') applyReceipt(state, item); else applyIdentityIntake(state, item);
|
|
168
|
+
applied.push(name);
|
|
169
|
+
}
|
|
170
|
+
if (applied.length) { state.revision++; atomicSave(path(root), state); for (const name of applied) unlinkSync(join(directory, name)); }
|
|
171
|
+
return state;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
export function bindDispatchIdentity({ root = detachmentRoot(), attemptId, providerSession, providerExecutionId = providerSession }) {
|
|
175
|
+
const state = read(root), attempt = state.attempts[digest(attemptId)];
|
|
176
|
+
if (!attempt?.provenance) fail('provenance_unavailable');
|
|
177
|
+
if (attempt.provenance.providerSession && attempt.provenance.providerSession !== providerSession) fail('provider_session_mismatch');
|
|
178
|
+
const identity = normalizeDispatchIdentity({ ...attempt.provenance.context, session: providerSession });
|
|
179
|
+
if (attempt.identityHash && attempt.identityHash !== digest(identity)) fail('provider_session_mismatch');
|
|
180
|
+
const item = { schemaVersion: 1, attemptId, providerSession, providerExecutionId };
|
|
181
|
+
// Intake has its own durable file; an async hook holding the send lock must
|
|
182
|
+
// not lose already observed provider evidence or the running child handle.
|
|
183
|
+
atomicSave(join(root, 'update-detachment', 'intake', `${digest(item)}.json`), item);
|
|
184
|
+
try { flushDispatchIntake(root); } catch (error) { if (error.code !== 'state_busy') throw error; }
|
|
185
|
+
return identity;
|
|
186
|
+
}
|
|
187
|
+
export function observeDispatchEvent({ root = detachmentRoot(), attemptId, provider, event, onError = () => {} }) {
|
|
188
|
+
try {
|
|
189
|
+
const providerSession = provider === 'codex' && event?.type === 'thread.started' ? event.thread_id :
|
|
190
|
+
provider === 'claude' && ['system', 'result'].includes(event?.type) ? event.session_id : null;
|
|
191
|
+
if (!providerSession) return null;
|
|
192
|
+
const attempt = read(root).attempts[digest(attemptId)];
|
|
193
|
+
if (!attempt?.provenance?.context.account || !attempt.provenance.context.device) return null;
|
|
194
|
+
return bindDispatchIdentity({ root, attemptId, providerSession });
|
|
195
|
+
} catch (error) { onError(error); return null; }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Callers must enumerate every live writer from actual discovery; absence of a
|
|
199
|
+
// writer report is not proof that old hooks or a rollback binary are safe.
|
|
200
|
+
function validateWriters(state, writers) {
|
|
201
|
+
if (!Array.isArray(writers)) fail('writer_inventory_required');
|
|
202
|
+
for (const writer of writers) {
|
|
203
|
+
if (!writer || !['codex', 'claude'].includes(writer.provider) || !writer.session) fail('writer_identity_required');
|
|
204
|
+
const key = digest([writer.provider, writer.session]);
|
|
205
|
+
const report = state.writerCapabilities?.[key];
|
|
206
|
+
if (!writer?.processIdentity || !Number.isSafeInteger(writer.pid) || !report || report.protocol !== DISPATCH_PROTOCOL || report.pid !== writer.pid || report.processIdentity !== writer.processIdentity || report.moduleDigest !== writer.moduleDigest) fail('writer_protocol_unverified');
|
|
207
|
+
}
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
export function assertWriterCompatibility({ root = detachmentRoot(), writers }) { return validateWriters(read(root), writers); }
|
|
211
|
+
export function recordWriterCapability({ root = detachmentRoot(), provider, session, pid, processIdentity, moduleDigest }) {
|
|
212
|
+
if (!['codex', 'claude'].includes(provider) || !session || !Number.isSafeInteger(pid) || pid < 2 || !processIdentity || !moduleDigest) fail('writer_identity_required');
|
|
213
|
+
return withLock(path(root), () => {
|
|
214
|
+
const state = read(root); state.writerCapabilities ||= {};
|
|
215
|
+
state.writerCapabilities[digest([provider, session])] = { protocol: DISPATCH_PROTOCOL, pid, processIdentity, moduleDigest, generation: state.generation };
|
|
216
|
+
state.revision++; atomicSave(path(root), state); return state;
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
export function prepareUpgradeFence({ root = detachmentRoot(), expectedRevision, writers }) {
|
|
220
|
+
return withLock(path(root), () => {
|
|
221
|
+
const state = read(root); validateWriters(state, writers);
|
|
222
|
+
if (state.revision !== expectedRevision) fail('revision_conflict');
|
|
223
|
+
if (!['connected', 'ready', 'recovery_required'].includes(state.connection)) fail('invalid_transition');
|
|
224
|
+
state.generation++; state.schemaVersion = 2; state.minimumDispatchProtocol = DISPATCH_PROTOCOL;
|
|
225
|
+
state.connection = 'detaching'; state.revision++; atomicSave(path(root), state); return state;
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const boundedText = value => typeof value === 'string' && value.trim().length > 0 && value.length <= 2048;
|
|
230
|
+
const exactKeys = (value, keys) => isMap(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
|
231
|
+
const fenceKey = (identity, taskId) => digest([digest(identity), digest(taskId)]);
|
|
232
|
+
const REASONS = ['provider_policy_unknown', 'provider_model_unknown', 'provider_identity_unverified'];
|
|
233
|
+
function storedFenceIdentity(value, root) {
|
|
234
|
+
if (!exactKeys(value, ['root', 'account', 'origin', 'device', 'provider', 'session', 'worktree']) || !['root', 'account', 'origin', 'device', 'session', 'worktree'].every(key => boundedText(value[key])) || !['codex', 'claude'].includes(value.provider) || value.root !== realpathSync(root) || resolve(value.worktree) !== value.worktree) fail('task_fence_invalid');
|
|
235
|
+
let url; try { url = new URL(value.origin); } catch { fail('task_fence_invalid'); }
|
|
236
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.origin !== value.origin) fail('task_fence_invalid');
|
|
237
|
+
// Archived worktrees remain valid historical identities; reading the ledger must not require them to exist.
|
|
238
|
+
return { root: value.root, account: value.account, origin: value.origin, device: value.device, provider: value.provider, session: value.session, worktree: value.worktree };
|
|
239
|
+
}
|
|
240
|
+
function admissionWriterMatches(state, item) {
|
|
241
|
+
const writer = state.writerCapabilities?.[digest([item.identity.provider, item.identity.session])], proof = item.admission;
|
|
242
|
+
return writer?.protocol === DISPATCH_PROTOCOL && writer.generation === state.generation && writer.pid === proof?.pid && writer.processIdentity === proof?.processIdentity && writer.moduleDigest === proof?.moduleDigest;
|
|
243
|
+
}
|
|
244
|
+
function validateTaskFences(state, root) {
|
|
245
|
+
if (state.taskFences === undefined) return;
|
|
246
|
+
if (state.minimumDispatchProtocol !== 3 || !exactKeys(state.taskFences, ['version', 'records']) || state.taskFences.version !== 1 || !isMap(state.taskFences.records)) fail('task_fence_invalid');
|
|
247
|
+
for (const [key, item] of Object.entries(state.taskFences.records)) {
|
|
248
|
+
if (!exactKeys(item, ['version', 'identity', 'identityHash', 'taskId', 'taskHash', 'generation', 'revision', 'status', 'reason', 'createdAt', 'admission']) || item.version !== 1 || !boundedText(item.taskId) || !Number.isSafeInteger(item.generation) || item.generation < 0 || item.generation > state.generation || !Number.isSafeInteger(item.revision) || item.revision < 1 || !['blocked', 'admitted'].includes(item.status) || !Number.isSafeInteger(item.createdAt) || !exactKeys(item.reason, ['code', 'evidenceRef']) || !REASONS.includes(item.reason.code) || !boundedText(item.reason.evidenceRef) || !exactKeys(item.identity, ['root', 'account', 'origin', 'device', 'provider', 'session', 'worktree']) || item.identity.root !== realpathSync(root)) fail('task_fence_invalid');
|
|
249
|
+
const identity = storedFenceIdentity(item.identity, root);
|
|
250
|
+
if (digest(identity) !== item.identityHash || digest(item.taskId) !== item.taskHash || key !== fenceKey(identity, item.taskId)) fail('task_fence_invalid');
|
|
251
|
+
if (item.status === 'blocked' ? item.admission !== null : !validObservation(item.admission, item, state, false)) fail('task_fence_invalid');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function assertTaskAdmissionState(state, { taskId, identity = null }) {
|
|
255
|
+
const records = state.taskFences?.records || {};
|
|
256
|
+
if (!identity) {
|
|
257
|
+
if (Object.values(records).some(item => item.taskId === taskId)) fail('task_admission_identity_required');
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
const item = records[fenceKey(identity, taskId)];
|
|
261
|
+
if (item && (item.status !== 'admitted' || item.generation !== state.generation || !admissionWriterMatches(state, item))) {
|
|
262
|
+
const error = new Error('Original task needs fresh verified provider policy and model admission.');
|
|
263
|
+
error.code = 'task_admission_blocked'; error.reasonCode = item.reason.code; throw error;
|
|
264
|
+
}
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
export function assertTaskAdmission(owner, { taskId, identity = null, provenance = null }) {
|
|
268
|
+
if (!owner || !boundedText(taskId)) fail('dispatch_identity_required');
|
|
269
|
+
identity = identity ? normalizeDispatchIdentity(identity) : provenance?.identity || null;
|
|
270
|
+
if (identity && identity.root !== owner.root) fail('identity_root_mismatch');
|
|
271
|
+
const state = read(owner.root);
|
|
272
|
+
if (owner.generation !== state.generation || owner.protocol !== DISPATCH_PROTOCOL || !['connected', 'ready'].includes(state.connection)) fail('owner_fenced');
|
|
273
|
+
return assertTaskAdmissionState(state, { taskId, identity });
|
|
274
|
+
}
|
|
275
|
+
export function readTaskAdmission({ root = detachmentRoot(), identity, taskId }) {
|
|
276
|
+
identity = storedFenceIdentity(identity, root);
|
|
277
|
+
if (identity.root !== realpathSync(root)) fail('identity_root_mismatch');
|
|
278
|
+
const state = read(root), record = state.taskFences?.records[fenceKey(identity, taskId)] || null;
|
|
279
|
+
return { revision: state.revision, generation: state.generation, record, canResume: ['connected', 'ready'].includes(state.connection) && (!record || record.status === 'admitted' && record.generation === state.generation && admissionWriterMatches(state, record)) };
|
|
280
|
+
}
|
|
281
|
+
function checkAdmissionOwner(state, owner, identity, taskId, expectedRevision) {
|
|
282
|
+
if (!owner || owner.root !== identity.root || owner.protocol !== DISPATCH_PROTOCOL || owner.generation !== state.generation) fail('owner_fenced');
|
|
283
|
+
if (!boundedText(taskId) || state.revision !== expectedRevision) fail('revision_conflict');
|
|
284
|
+
}
|
|
285
|
+
export function setTaskAdmissionFence({ owner, identity, taskId, expectedRevision, expectedFenceRevision = null, reason, writers }) {
|
|
286
|
+
identity = normalizeDispatchIdentity(identity);
|
|
287
|
+
return withLock(path(identity.root), () => {
|
|
288
|
+
const state = read(identity.root); checkAdmissionOwner(state, owner, identity, taskId, expectedRevision);
|
|
289
|
+
validateWriters(state, writers);
|
|
290
|
+
const registration = createStateStore(identity).read().tasks[digest(taskId)];
|
|
291
|
+
if (!registration?.identity || digest(registration.identity) !== digest(identity)) fail('task_admission_unregistered');
|
|
292
|
+
if (!exactKeys(reason, ['code', 'evidenceRef']) || !REASONS.includes(reason.code) || !boundedText(reason.evidenceRef)) fail('task_fence_reason_invalid');
|
|
293
|
+
const key = fenceKey(identity, taskId), previous = state.taskFences?.records[key];
|
|
294
|
+
if ((previous?.revision ?? null) !== expectedFenceRevision) fail('task_fence_revision_conflict');
|
|
295
|
+
state.taskFences ||= { version: 1, records: {} };
|
|
296
|
+
state.taskFences.records[key] = { version: 1, identity, identityHash: digest(identity), taskId, taskHash: digest(taskId), generation: state.generation, revision: (previous?.revision || 0) + 1, status: 'blocked', reason: { ...reason }, createdAt: Date.now(), admission: null };
|
|
297
|
+
// v2 writers validate minimumDispatchProtocol and cannot ignore taskFences.
|
|
298
|
+
state.schemaVersion = 2; state.minimumDispatchProtocol = 3; state.revision++;
|
|
299
|
+
atomicSave(path(identity.root), state); return state;
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
const OBSERVATION_KEYS = ['source', 'reference', 'observedAt', 'providerModel', 'permissionPolicyHash', 'pid', 'processIdentity', 'moduleDigest', 'identityHash', 'taskHash', 'generation', 'fenceRevision', 'nonce'];
|
|
303
|
+
function validObservation(observation, item, state, fresh) {
|
|
304
|
+
if (!exactKeys(observation, OBSERVATION_KEYS) || !['provider_observation', 'explicit_permission_confirmation'].includes(observation.source) || !['reference', 'providerModel', 'processIdentity', 'moduleDigest', 'nonce'].every(key => boundedText(observation[key])) || !/^[a-f0-9]{64}$/.test(observation.permissionPolicyHash) || !Number.isSafeInteger(observation.pid) || observation.pid < 2 || !Number.isSafeInteger(observation.observedAt) || observation.identityHash !== item.identityHash || observation.taskHash !== item.taskHash || observation.generation !== item.generation || observation.fenceRevision !== item.revision - (item.status === 'admitted' ? 1 : 0)) return false;
|
|
305
|
+
if (!fresh) return true;
|
|
306
|
+
const writer = state.writerCapabilities?.[digest([item.identity.provider, item.identity.session])];
|
|
307
|
+
return observation.observedAt >= item.createdAt && Date.now() - observation.observedAt >= 0 && Date.now() - observation.observedAt <= 30_000 && writer?.protocol === DISPATCH_PROTOCOL && writer.generation === state.generation && writer.pid === observation.pid && writer.processIdentity === observation.processIdentity && writer.moduleDigest === observation.moduleDigest;
|
|
308
|
+
}
|
|
309
|
+
export async function admitTaskDispatch({ owner, identity, taskId, expectedRevision, expectedFenceRevision, verifyObservation }) {
|
|
310
|
+
identity = normalizeDispatchIdentity(identity);
|
|
311
|
+
if (typeof verifyObservation !== 'function') fail('task_admission_verifier_required');
|
|
312
|
+
const release = acquireLock(path(identity.root));
|
|
313
|
+
try {
|
|
314
|
+
const state = read(identity.root); checkAdmissionOwner(state, owner, identity, taskId, expectedRevision);
|
|
315
|
+
const item = state.taskFences?.records[fenceKey(identity, taskId)];
|
|
316
|
+
if (!item || item.status !== 'blocked' || item.revision !== expectedFenceRevision || item.generation !== state.generation) fail('task_fence_revision_conflict');
|
|
317
|
+
const nonce = randomUUID(); let timer;
|
|
318
|
+
const observation = await Promise.race([
|
|
319
|
+
Promise.resolve().then(() => verifyObservation(Object.freeze({ identity, taskId, generation: state.generation, fenceRevision: item.revision, nonce }))),
|
|
320
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(Object.assign(new Error('Provider admission observation timed out.'), { code: 'task_admission_observation_timeout' })), 2000); }),
|
|
321
|
+
]).finally(() => clearTimeout(timer));
|
|
322
|
+
if (!validObservation(observation, item, state, true) || observation.nonce !== nonce) fail('task_admission_observation_invalid');
|
|
323
|
+
item.status = 'admitted'; item.revision++; item.admission = { ...observation }; state.revision++;
|
|
324
|
+
atomicSave(path(identity.root), state); return state;
|
|
325
|
+
} finally { release(); }
|
|
326
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readFileSync, realpathSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import { createStateStore, digest, fail, readState } from './update-state-store.mjs';
|
|
5
|
+
|
|
6
|
+
const text = value => typeof value === 'string' && value.length > 0 && value.length <= 2048 ? value : null;
|
|
7
|
+
export function normalizeDispatchIdentity(value) {
|
|
8
|
+
if (!value || !['codex', 'claude'].includes(value.provider) || !['root', 'account', 'origin', 'device', 'session', 'worktree'].every(key => text(value[key]))) fail('identity_required');
|
|
9
|
+
const url = new URL(value.origin);
|
|
10
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || !['', '/'].includes(url.pathname)) fail('identity_origin_invalid');
|
|
11
|
+
mkdirSync(value.root, { recursive: true, mode: 0o700 });
|
|
12
|
+
// Stable, whitelist-only ordering. No token, prompt, model or authority is copied.
|
|
13
|
+
return Object.freeze({ root: realpathSync(value.root), account: value.account, origin: url.origin,
|
|
14
|
+
device: value.device, provider: value.provider, session: value.session, worktree: realpathSync(value.worktree) });
|
|
15
|
+
}
|
|
16
|
+
function accountMetadata(root, origin) {
|
|
17
|
+
const hash = createHash('sha256').update(origin).digest('hex');
|
|
18
|
+
for (const file of [join(root, 'sessions', `${hash}.json`), join(root, 'session.json')]) {
|
|
19
|
+
let session;
|
|
20
|
+
try { session = JSON.parse(readFileSync(file, 'utf8')); }
|
|
21
|
+
catch (error) { if (error.code === 'ENOENT') continue; fail('account_metadata_unreadable'); }
|
|
22
|
+
if (session?.api_origin === origin) return session;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
export function captureDispatchProvenance({ root, provider, providerSession = null, worktree, accountSession,
|
|
27
|
+
origin = accountSession?.api_origin || process.env.SIMY_WEB_ORIGIN || process.env.SIMY_API_ORIGIN || 'https://app.simy.one', taskId, recordRef }) {
|
|
28
|
+
if (!text(root) || !text(worktree) || !text(taskId) || !text(recordRef) || !['codex', 'claude'].includes(provider)) fail('provenance_invalid');
|
|
29
|
+
const url = new URL(origin);
|
|
30
|
+
if (url.username || url.password || url.search || url.hash || !['', '/'].includes(url.pathname) || !['http:', 'https:'].includes(url.protocol)) fail('identity_origin_invalid');
|
|
31
|
+
mkdirSync(root, { recursive: true, mode: 0o700 }); root = realpathSync(root);
|
|
32
|
+
const source = accountSession === undefined ? accountMetadata(root, url.origin) : accountSession;
|
|
33
|
+
if (source?.api_origin && new URL(source.api_origin).origin !== url.origin) fail('account_origin_mismatch');
|
|
34
|
+
const context = Object.freeze({ root, account: text(source?.account_id) || text(source?.auth_user_id),
|
|
35
|
+
origin: url.origin, device: text(source?.device_id), provider, worktree: realpathSync(worktree) });
|
|
36
|
+
const identity = providerSession && context.account && context.device ? normalizeDispatchIdentity({ ...context, session: providerSession }) : null;
|
|
37
|
+
return Object.freeze({ taskId, recordRef, context, providerSession, identity });
|
|
38
|
+
}
|
|
39
|
+
export function registerDispatchProvenance(provenance, providerSession = provenance?.providerSession) {
|
|
40
|
+
if (!provenance || !providerSession || !provenance.context.account || !provenance.context.device) fail('identity_unavailable');
|
|
41
|
+
if (provenance.providerSession && provenance.providerSession !== providerSession) fail('provider_session_mismatch');
|
|
42
|
+
const identity = normalizeDispatchIdentity({ ...provenance.context, session: providerSession });
|
|
43
|
+
createStateStore(identity).registerTask({ taskId: provenance.taskId, recordRef: provenance.recordRef, identity });
|
|
44
|
+
return identity;
|
|
45
|
+
}
|
|
46
|
+
// A retained registration describes a past canonical scope; it does not grant
|
|
47
|
+
// execution admission. Its worktree may have been archived since registration.
|
|
48
|
+
function retainedRegistrationIdentity(value, root) {
|
|
49
|
+
const keys = ['root', 'account', 'origin', 'device', 'provider', 'session', 'worktree'];
|
|
50
|
+
if (!value || Object.keys(value).length !== keys.length || !keys.every(key => text(value[key])) ||
|
|
51
|
+
!['codex', 'claude'].includes(value.provider) || value.root !== realpathSync(root) ||
|
|
52
|
+
!isAbsolute(value.worktree) || resolve(value.worktree) !== value.worktree) fail('registration_corrupt');
|
|
53
|
+
let url;
|
|
54
|
+
try { url = new URL(value.origin); } catch { fail('registration_corrupt'); }
|
|
55
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash ||
|
|
56
|
+
!['', '/'].includes(url.pathname) || url.origin !== value.origin) fail('registration_corrupt');
|
|
57
|
+
return Object.freeze({ root: value.root, account: value.account, origin: value.origin,
|
|
58
|
+
device: value.device, provider: value.provider, session: value.session, worktree: value.worktree });
|
|
59
|
+
}
|
|
60
|
+
export function listDispatchRegistrations(root, { taskId = null, retained = false } = {}) {
|
|
61
|
+
if (taskId !== null && !text(taskId)) fail('registration_task_invalid');
|
|
62
|
+
const directory = join(resolve(root), 'update-detachment', 'scopes'); let names;
|
|
63
|
+
try { names = readdirSync(directory); } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
64
|
+
const registrations = [];
|
|
65
|
+
for (const name of names) {
|
|
66
|
+
if (!/^[a-f0-9]{64}\.json$/.test(name)) continue;
|
|
67
|
+
const state = readState(join(directory, name));
|
|
68
|
+
for (const [key, task] of Object.entries(state?.tasks || {})) {
|
|
69
|
+
if (taskId !== null && task.taskId !== taskId) continue;
|
|
70
|
+
if (!task.identity || !task.taskId) continue; // Older hash-only references stay unresolved.
|
|
71
|
+
const identity = retained ? retainedRegistrationIdentity(task.identity, root) : normalizeDispatchIdentity(task.identity);
|
|
72
|
+
const store = createStateStore(identity);
|
|
73
|
+
if (store.identityHash !== state.identityHash || digest(task.taskId) !== key) fail('registration_corrupt');
|
|
74
|
+
registrations.push({ identity, taskId: task.taskId, recordRef: task.recordRef });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return registrations;
|
|
78
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readFileSync, openSync, writeFileSync, fsyncSync, closeSync, renameSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { resolve, join, dirname } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const SCHEMA_VERSION = 1;
|
|
6
|
+
export const CONNECTION_STATES = ['connected', 'detaching', 'detached', 'reconciling', 'ready', 'recovery_required'];
|
|
7
|
+
export function fail(code) { const error = new Error(`Update detachment: ${code}`); error.code = code; throw error; }
|
|
8
|
+
export const isMap = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
9
|
+
export const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
10
|
+
export function readState(file, { versions = [SCHEMA_VERSION] } = {}) {
|
|
11
|
+
try { const state = JSON.parse(readFileSync(file, 'utf8')); if (!versions.includes(state.schemaVersion)) fail('unknown_schema'); return state; }
|
|
12
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
13
|
+
}
|
|
14
|
+
export function atomicSave(file, state) {
|
|
15
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
16
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
17
|
+
const fd = openSync(temporary, 'wx', 0o600);
|
|
18
|
+
try {
|
|
19
|
+
try { writeFileSync(fd, `${JSON.stringify(state)}\n`); fsyncSync(fd); } finally { closeSync(fd); }
|
|
20
|
+
renameSync(temporary, file);
|
|
21
|
+
// Node cannot fsync directory handles on Windows. Preserve file fsync and
|
|
22
|
+
// atomic rename there, matching coding-run-store; POSIX directory durability
|
|
23
|
+
// remains mandatory and every POSIX IO error still propagates.
|
|
24
|
+
if (process.platform !== 'win32') {
|
|
25
|
+
const directory = openSync(dirname(file), 'r');
|
|
26
|
+
try { fsyncSync(directory); } finally { closeSync(directory); }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
finally { try { unlinkSync(temporary); } catch (error) { if (error.code !== 'ENOENT') throw error; } }
|
|
30
|
+
}
|
|
31
|
+
// A crashed owner leaves the lock in place. Never steal a lock using a timeout
|
|
32
|
+
// or a PID alone (PID reuse); recovery requires an operator-verified boundary.
|
|
33
|
+
export function acquireLock(file) {
|
|
34
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
35
|
+
const token = randomUUID();
|
|
36
|
+
let fd; try { fd = openSync(`${file}.lock`, 'wx', 0o600); } catch (error) { if (error.code === 'EEXIST') fail('state_busy'); throw error; }
|
|
37
|
+
try { writeFileSync(fd, JSON.stringify({ pid: process.pid, token })); fsyncSync(fd); }
|
|
38
|
+
catch (error) { closeSync(fd); unlinkSync(`${file}.lock`); throw error; }
|
|
39
|
+
let released = false;
|
|
40
|
+
return () => {
|
|
41
|
+
if (released) return; released = true; closeSync(fd);
|
|
42
|
+
let owner; try { owner = JSON.parse(readFileSync(`${file}.lock`, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return; throw error; }
|
|
43
|
+
if (owner.token === token) unlinkSync(`${file}.lock`);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function withLock(file, operation, { preserveResult = false } = {}) {
|
|
47
|
+
const release = acquireLock(file); let completed = false;
|
|
48
|
+
try { const result = operation(); completed = true; return result; }
|
|
49
|
+
finally { try { release(); } catch (error) { if (!completed || !preserveResult) throw error; } }
|
|
50
|
+
}
|
|
51
|
+
export function createStateStore({ root, account, origin, device, provider, session, worktree }) {
|
|
52
|
+
if (![root, account, origin, device, provider, session, worktree].every(value => typeof value === 'string' && value.length > 0)) fail('identity_required');
|
|
53
|
+
if (!['codex', 'claude'].includes(provider)) fail('provider_invalid');
|
|
54
|
+
const identityHash = digest([resolve(root), account, new URL(origin).origin, device, provider, session, resolve(worktree)]);
|
|
55
|
+
const file = join(resolve(root), 'update-detachment', 'scopes', `${identityHash}.json`);
|
|
56
|
+
const initial = () => ({ schemaVersion: 1, identityHash, revision: 0, generation: 0, connection: 'connected', tasks: {}, attempts: {}, receipts: {}, events: {} });
|
|
57
|
+
const read = () => { const state = readState(file) || initial(); if (state.identityHash !== identityHash || !Number.isSafeInteger(state.revision) || !Number.isSafeInteger(state.generation) || !CONNECTION_STATES.includes(state.connection) || !['tasks', 'attempts', 'receipts', 'events'].every(key => isMap(state[key]))) fail('invalid_state'); return state; };
|
|
58
|
+
const transact = (expectedRevision, change) => withLock(file, () => {
|
|
59
|
+
const state = read(); if (state.revision !== expectedRevision) fail('revision_conflict');
|
|
60
|
+
const next = change(structuredClone(state));
|
|
61
|
+
if (!next || next.identityHash !== identityHash || next.schemaVersion !== 1 || !CONNECTION_STATES.includes(next.connection) || !Number.isSafeInteger(next.generation) || next.generation < state.generation) fail('invalid_transition');
|
|
62
|
+
next.revision = state.revision + 1; atomicSave(file, next); return next;
|
|
63
|
+
});
|
|
64
|
+
const registerTask = ({ taskId, recordRef, identity = null }) => {
|
|
65
|
+
if (![taskId, recordRef].every(value => typeof value === 'string' && value.length > 0 && value.length <= 2048)) fail('task_reference_invalid');
|
|
66
|
+
return withLock(file, () => {
|
|
67
|
+
const state = read(), key = digest(taskId);
|
|
68
|
+
if (Object.hasOwn(state.tasks, key)) { if (state.tasks[key].recordRef !== recordRef || identity && state.tasks[key].identity && digest(state.tasks[key].identity) !== digest(identity)) fail('task_identity_conflict'); if (!identity || state.tasks[key].identity) return state; }
|
|
69
|
+
state.tasks[key] = { recordRef, taskId, ...(identity ? { identity } : {}) }; state.revision++; atomicSave(file, state); return state;
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
return { file, identityHash, read, transact, registerTask };
|
|
73
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { captureDispatchProvenance } from '../update-detachment/provenance.js';
|
|
2
|
+
import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, queueDispatchReceipt, rootFromDelivery, observeDispatchEvent } from '../update-detachment/dispatch-gate.js';
|
|
1
3
|
import { safeCheckpointDiagnostic } from './checkpoint-diagnostic.js';
|
|
2
4
|
import { recordHandoffStage } from '../codex-plugins/simy/hooks/consistency-handoff.mjs';
|
|
3
5
|
import { retainControllerEvidence } from '../codex-plugins/simy/hooks/purpose-evidence.mjs';
|
|
@@ -13,7 +15,7 @@ import { requireProviderLink } from '../provider-links.js';
|
|
|
13
15
|
import { spawn } from 'node:child_process';
|
|
14
16
|
import { randomUUID } from 'node:crypto';
|
|
15
17
|
import { dirname } from 'node:path';
|
|
16
|
-
import { canExecuteAutonomousExternal, automaticContinuationAllowed, settlePurposeVerification, assessedCheckpointContract, checkpointDelivery, decideStop, deliveryProgressContext, deliveryStop, listRecords, readRecord, managedBudgetReason, notifyState, processIdentity, sharesTaskWorkspace, taskDirectory, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
18
|
+
import { hasPendingUserStop, recordPath, canExecuteAutonomousExternal, automaticContinuationAllowed, settlePurposeVerification, assessedCheckpointContract, checkpointDelivery, decideStop, deliveryProgressContext, deliveryStop, listRecords, readRecord, managedBudgetReason, notifyState, processIdentity, sharesTaskWorkspace, taskDirectory, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
17
19
|
import { resolveBackendExecutable } from '../backend-executable.js';
|
|
18
20
|
import { executeCodexAppServer } from '../session-supervision/codex-app-server.js';
|
|
19
21
|
import { acquireWorkspaceLease, leaseOwnerDead } from '../session-supervision/workspace-lease.js';
|
|
@@ -50,6 +52,7 @@ async function assertActiveLease(root, provider, session, leaseId, signal, curre
|
|
|
50
52
|
if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
|
|
51
53
|
await requireProviderLink({ root, provider });
|
|
52
54
|
const current = await readRecord(root, provider, session);
|
|
55
|
+
if (hasPendingUserStop(root, current)) throw new Error('User stopped this provider task.');
|
|
53
56
|
if (signal?.aborted || current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) {
|
|
54
57
|
throw new Error('Managed recovery lease changed before provider dispatch.');
|
|
55
58
|
}
|
|
@@ -83,12 +86,15 @@ export function parseAgentCheckpoint(output, expectedSession) {
|
|
|
83
86
|
// The provider CLI is a new process. It is used only for an exact Codex UUID
|
|
84
87
|
// enrolled for this recovery channel after a corroborated process exit.
|
|
85
88
|
export async function executeCodexResume(record, { signal, root, leaseId, executable = resolveBackendExecutable, currentTime = Date.now, onActivity = () => {}, beforeDispatch = async () => {}, leaseMonitored = false } = {}) {
|
|
89
|
+
const dispatchOwner = captureDispatchOwner(rootFromDelivery(root));
|
|
86
90
|
if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
|
|
91
|
+
const run = record.run;
|
|
92
|
+
const provenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: record.provider, providerSession: record.session, worktree: taskDirectory(record), taskId: run.managed.taskId, recordRef: recordPath(root, record.provider, record.session) });
|
|
93
|
+
assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance });
|
|
87
94
|
const binary = await executable('codex');
|
|
88
95
|
if (!binary) throw new Error('Codex executable unavailable.');
|
|
89
96
|
await assertActiveLease(root, record.provider, record.session, leaseId, signal, currentTime);
|
|
90
|
-
const
|
|
91
|
-
const context = { taskId: run.managed.taskId, session: record.session, sessionCwd: record.cwd, executionCwd: taskDirectory(record), objective: run.objective, nextStep: run.nextStep,
|
|
97
|
+
const context = { taskId: run.managed.taskId, session: record.session, sessionCwd: record.cwd, executionCwd: taskDirectory(record), objective: run.objective, nextStep: run.nextStep,
|
|
92
98
|
lastDependencyResolution: run.managed.lastDependencyResolution,
|
|
93
99
|
...(run.managed.upgradedFrom ? { adoptedLoop: run.managed.upgradedFrom, priorCheckpoint: run.checkpoint } : {}),
|
|
94
100
|
remainingWork: run.managed.remainingWork, acceptanceCriteria: run.managed.acceptanceCriteria, proposedAction: run.managed.proposedAction,
|
|
@@ -102,14 +108,15 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
|
|
|
102
108
|
if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
|
|
103
109
|
|
|
104
110
|
return new Promise((resolve, reject) => {
|
|
105
|
-
const child = spawn(binary, args, { cwd: record
|
|
106
|
-
env: { ...process.env, SIMY_HOME: dirname(root) } });
|
|
111
|
+
const child = dispatchWithFence(dispatchOwner, { taskId: run.managed?.taskId || record.session, attemptId: leaseId, provenance }, () => { if (hasPendingUserStop(root, record)) throw new Error('User stopped this provider task.'); return spawn(binary, args, { cwd: taskDirectory(record), stdio: ['ignore', 'pipe', 'pipe'], signal,
|
|
112
|
+
env: { ...process.env, SIMY_HOME: dirname(root) } }); });
|
|
107
113
|
let error = '', spawnError = null, pending = '', lastMessage = null;
|
|
108
114
|
const startedSessions = new Set();
|
|
109
115
|
const consumeLine = line => {
|
|
110
116
|
if (Buffer.byteLength(line) > MAX_OUTPUT) return;
|
|
111
117
|
try {
|
|
112
118
|
const event = JSON.parse(line);
|
|
119
|
+
observeDispatchEvent({ root: dispatchOwner.root, attemptId: leaseId, provider: record.provider, event });
|
|
113
120
|
if (event.type === 'thread.started' && typeof event.thread_id === 'string') startedSessions.add(event.thread_id);
|
|
114
121
|
if (event.type === 'item.completed' && event.item?.type === 'agent_message' && typeof event.item.text === 'string') lastMessage = event.item.text;
|
|
115
122
|
} catch { /* Other provider events are ignored. */ }
|
|
@@ -123,7 +130,7 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
|
|
|
123
130
|
};
|
|
124
131
|
signal?.addEventListener('abort', terminate, { once: true });
|
|
125
132
|
const stopWatch = leaseMonitored ? () => {} : watchLease(root, record.provider, record.session, current => {
|
|
126
|
-
if (current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) terminate();
|
|
133
|
+
if (hasPendingUserStop(root, current) || current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) terminate();
|
|
127
134
|
}, terminate);
|
|
128
135
|
child.stdout.on('data', chunk => {
|
|
129
136
|
onActivity();
|
|
@@ -138,7 +145,7 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
|
|
|
138
145
|
cleanup(); if (pending) consumeLine(pending);
|
|
139
146
|
const output = [...startedSessions].map(thread_id => JSON.stringify({ type: 'thread.started', thread_id })).join('\n') + '\n' +
|
|
140
147
|
(lastMessage === null ? '' : JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: lastMessage } }));
|
|
141
|
-
if (spawnError) reject(spawnError); else resolve({ code, output, error });
|
|
148
|
+
if (spawnError) reject(spawnError); else resolve({ code, dispatchAttemptId: leaseId, output, error });
|
|
142
149
|
});
|
|
143
150
|
});
|
|
144
151
|
}
|
|
@@ -159,6 +166,13 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
159
166
|
record = await recoverOutcomeAuditLease(record, { root, inspectProcess });
|
|
160
167
|
let run = record.run;
|
|
161
168
|
if (!run?.managed) continue;
|
|
169
|
+
if (hasPendingUserStop(root, record)) {
|
|
170
|
+
await updateRecord(root, record.provider, record.session, current => {
|
|
171
|
+
if (hasPendingUserStop(root, current)) { current.run.state = 'stopped'; current.run.reason = 'User interrupted the provider.'; current.run.updatedAt = now; }
|
|
172
|
+
return current;
|
|
173
|
+
});
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
162
176
|
// The audit must retain a stable target until its result is settled, even
|
|
163
177
|
// when no token ceiling is configured. Its own timeout/owner recovery
|
|
164
178
|
// releases this lease; a worker never takes it over.
|
|
@@ -169,7 +183,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
169
183
|
// A live/unknown owner is never taken over merely because time passed.
|
|
170
184
|
if (!ownerDead && (owner || claimNow - run.managed.resumeLease?.claimedAt <= 5 * 60_000)) continue;
|
|
171
185
|
await updateRecord(root, record.provider, record.session, current => {
|
|
172
|
-
if (current?.run?.state !== 'auto_resuming' || current.run.managed.resumeLease?.id !== run.managed.resumeLease?.id) return current;
|
|
186
|
+
if (hasPendingUserStop(root, current) || current?.run?.state !== 'auto_resuming' || current.run.managed.resumeLease?.id !== run.managed.resumeLease?.id) return current;
|
|
173
187
|
current.run.state = 'recovery_required'; current.run.recoveryCause = 'interrupted_owner'; current.run.managed.autoRecovery = false;
|
|
174
188
|
recordRecoveryIncident(current.run, { code: 'interrupted_owner', subject: current.run.managed.executionAttempt?.id || '', now: claimNow });
|
|
175
189
|
recordRecoveryStage(current.run, 'interrupted', { now: claimNow, attemptId: current.run.managed.executionAttempt?.id });
|
|
@@ -268,7 +282,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
268
282
|
if (signal?.aborted) controller.abort();
|
|
269
283
|
else signal?.addEventListener('abort', abort, { once: true });
|
|
270
284
|
const stopWatch = watchLease(root, record.provider, record.session, current => {
|
|
271
|
-
if (current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId ||
|
|
285
|
+
if (hasPendingUserStop(root, current) || current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId ||
|
|
272
286
|
managedBudgetReason(current.run, currentTime(), { checkProgress: false }) || !waitingForTool && currentTime() - lastActivityAt >= 5 * 60_000) controller.abort();
|
|
273
287
|
}, () => controller.abort());
|
|
274
288
|
try {
|
|
@@ -381,6 +395,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
381
395
|
if (result.verification) await settlePurposeVerification({ root, provider: record.provider, session: record.session, expectedLease: leaseId, review: result.verification, verifySnapshot: current => reviewWorkspaceIdentity(current, root, result.verification, { signal: controller.signal }), now: Date.now() });
|
|
382
396
|
else await checkpointDelivery({ root, provider: record.provider, session: record.session, cwd: record.cwd,
|
|
383
397
|
checkpoint: result.checkpoint || parseAgentCheckpoint(result.output, record.session), expectedLease: leaseId, toolEvidence: result.toolEvidence || [], sourceContext: result.sourceContext, controllerEvidence: result.controllerEvidence, now: Date.now() });
|
|
398
|
+
if (result.dispatchAttemptId) queueDispatchReceipt({ root: rootFromDelivery(root), attemptId: result.dispatchAttemptId, receiptId: `checkpoint:${leaseId}`, resultRef: `delivery-record:${record.provider}:${record.session}:${leaseId}` });
|
|
384
399
|
await updateRecord(root, record.provider, record.session, current => {
|
|
385
400
|
if (current?.run?.managed?.executionAttempt?.id !== leaseId) return current;
|
|
386
401
|
current.run.managed.executionAttempt.phase = 'checkpointed';
|
|
@@ -404,7 +419,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
|
|
|
404
419
|
});
|
|
405
420
|
} catch (error) { result = { ...result, code: -1, errorCode: 'checkpoint_rejected', diagnostic: safeCheckpointDiagnostic(error) }; }
|
|
406
421
|
}
|
|
407
|
-
await
|
|
422
|
+
await updateRecordAfterControl(root, record.provider, record.session, async current => {
|
|
408
423
|
const live = current?.run;
|
|
409
424
|
if (live?.managed?.resumeLease?.id !== leaseId) return current;
|
|
410
425
|
delete live.managed.resumeLease;
|
|
@@ -526,3 +541,17 @@ export function createManagedSupervisor(options) {
|
|
|
526
541
|
async close() { closed = true; controller.abort(); await Promise.allSettled([running, auditing]); },
|
|
527
542
|
};
|
|
528
543
|
}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
// Stop/pause can wake the owned turn before its control transaction releases
|
|
547
|
+
// the record lock. Retry only acquisition conflicts; never steal a live lock.
|
|
548
|
+
async function updateRecordAfterControl(root, provider, session, mutate) {
|
|
549
|
+
const until = Date.now() + 5000;
|
|
550
|
+
while (true) {
|
|
551
|
+
try { return await updateRecord(root, provider, session, mutate); }
|
|
552
|
+
catch (error) {
|
|
553
|
+
if (error.message !== 'Delivery state is busy; retry the command.' || Date.now() >= until) throw error;
|
|
554
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|