@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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/agent.js +50 -8
  3. package/src/auto-update.js +32 -9
  4. package/src/cli-contract.js +1 -1
  5. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +108 -11
  6. package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +326 -0
  7. package/src/codex-plugins/simy/hooks/update-dispatch-identity.mjs +78 -0
  8. package/src/codex-plugins/simy/hooks/update-state-store.mjs +73 -0
  9. package/src/delivery-hooks/supervisor.js +39 -10
  10. package/src/desktop-host.js +54 -8
  11. package/src/desktop-status.js +1 -1
  12. package/src/direct-executor.js +104 -4
  13. package/src/local-task.js +14 -3
  14. package/src/runner.js +19 -4
  15. package/src/session-supervision/claude-sessions.js +57 -5
  16. package/src/session-supervision/codex-app-server.js +20 -8
  17. package/src/sqm/claude-plugin.js +5 -0
  18. package/src/sqm/review-worker.js +15 -6
  19. package/src/update-detachment/claude-observation.js +33 -0
  20. package/src/update-detachment/codex-context.js +27 -0
  21. package/src/update-detachment/coordinator.js +117 -0
  22. package/src/update-detachment/dispatch-gate.js +1 -0
  23. package/src/update-detachment/ledger-adapter.js +62 -0
  24. package/src/update-detachment/ledger-reader.js +42 -0
  25. package/src/update-detachment/provenance.js +1 -0
  26. package/src/update-detachment/provider-adapters.js +33 -0
  27. package/src/update-detachment/reconcile.js +105 -0
  28. package/src/update-detachment/reconciliation-adapter.js +13 -0
  29. package/src/update-detachment/runtime.js +319 -0
  30. package/src/update-detachment/state-store.js +1 -0
  31. package/src/update-detachment/worker-entry.js +145 -0
  32. package/src/update-detachment/worker-ledger-runtime.js +34 -0
  33. package/src/update-detachment/worker-ledger.js +138 -0
  34. package/src/update-detachment/worker-storage.js +29 -0
  35. 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 run = record.run;
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.cwd, stdio: ['ignore', 'pipe', 'pipe'], signal,
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 updateRecord(root, record.provider, record.session, async current => {
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
+ }