@awak-app/simy-cli 0.7.10 → 0.7.12

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 (42) hide show
  1. package/README.md +17 -1
  2. package/package.json +1 -1
  3. package/src/agent.js +47 -7
  4. package/src/auto-update.js +32 -9
  5. package/src/cli-contract.js +1 -1
  6. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +108 -11
  7. package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +326 -0
  8. package/src/codex-plugins/simy/hooks/update-dispatch-identity.mjs +78 -0
  9. package/src/codex-plugins/simy/hooks/update-state-store.mjs +73 -0
  10. package/src/delivery-hooks/desktop.js +1 -1
  11. package/src/delivery-hooks/supervisor.js +39 -10
  12. package/src/desktop-host.js +90 -15
  13. package/src/desktop-status.js +1 -1
  14. package/src/direct-executor.js +104 -4
  15. package/src/local-task.js +14 -3
  16. package/src/provider-stream.js +3 -0
  17. package/src/runner.js +19 -4
  18. package/src/session-supervision/claude-desktop-lineage.js +19 -5
  19. package/src/session-supervision/claude-sessions.js +73 -15
  20. package/src/session-supervision/codex-app-server.js +28 -9
  21. package/src/session-supervision/index.js +25 -14
  22. package/src/session-supervision/pull-requests.js +52 -0
  23. package/src/session-supervision/work-state.js +84 -0
  24. package/src/sqm/claude-plugin.js +5 -0
  25. package/src/sqm/review-worker.js +15 -6
  26. package/src/update-detachment/claude-observation.js +33 -0
  27. package/src/update-detachment/codex-context.js +27 -0
  28. package/src/update-detachment/coordinator.js +117 -0
  29. package/src/update-detachment/dispatch-gate.js +1 -0
  30. package/src/update-detachment/ledger-adapter.js +62 -0
  31. package/src/update-detachment/ledger-reader.js +42 -0
  32. package/src/update-detachment/provenance.js +1 -0
  33. package/src/update-detachment/provider-adapters.js +33 -0
  34. package/src/update-detachment/reconcile.js +105 -0
  35. package/src/update-detachment/reconciliation-adapter.js +13 -0
  36. package/src/update-detachment/runtime.js +319 -0
  37. package/src/update-detachment/state-store.js +1 -0
  38. package/src/update-detachment/worker-entry.js +145 -0
  39. package/src/update-detachment/worker-ledger-runtime.js +34 -0
  40. package/src/update-detachment/worker-ledger.js +138 -0
  41. package/src/update-detachment/worker-storage.js +29 -0
  42. package/src/update-detachment/worker.js +167 -0
@@ -0,0 +1,105 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+
3
+ const IDENTITY = ['provider', 'session', 'account', 'origin', 'worktree', 'device', 'root'];
4
+ const POLICY = ['model', 'permissions', 'approvals', 'budget', 'deadline'];
5
+ const STOPPED = new Set(['stopped', 'paused', 'paused_by_user', 'cancelled']);
6
+ const ATTEMPT_STATES = new Set(['intent', 'sending', 'sent', 'running', 'result_saved', 'confirmed']);
7
+ const present = value => typeof value === 'string' && value.length > 0;
8
+ const own = (object, key) => Object.hasOwn(object || {}, key);
9
+
10
+ // Pure assessment: no persistence, writer acquisition, provider turn or replay.
11
+ export function assessReconciliation(snapshot, observation, { now = Date.now(), maxAgeMs = 5_000, connectionOnly = false } = {}) {
12
+ const taskState = snapshot?.task?.state;
13
+ const stopped = STOPPED.has(taskState);
14
+ const result = (status, reasons, evidence = {}) => ({
15
+ status, reasons, connectionState: status === 'ready' ? 'ready' : 'recovery_required',
16
+ taskState, preserveStop: stopped, canReleaseGate: status === 'ready' && !stopped,
17
+ discardNextStep: status !== 'ready', replayAttempts: false,
18
+ nextAction: status === 'already_running' ? 'wait_original_provider' :
19
+ status === 'changed' ? 'review_current_state' : status === 'recovery_required' ? 'inspect_original_conversation' : 'none',
20
+ evidence,
21
+ });
22
+ const recovery = reason => result('recovery_required', [reason]);
23
+ if (!snapshot || !observation || !IDENTITY.every(key => present(snapshot.identity?.[key]))) return recovery('snapshot_identity_missing');
24
+ if (!IDENTITY.every(key => observation.identity?.[key] === snapshot.identity[key])) return recovery('provider_identity_mismatch');
25
+ if (!['codex', 'claude'].includes(snapshot.identity.provider)) return recovery('unsupported_provider');
26
+ if (!present(taskState) || !present(snapshot.revision) || !Number.isSafeInteger(snapshot.generation) || snapshot.generation < 0) return recovery('snapshot_invalid');
27
+ if (!POLICY.every(key => own(snapshot.policy, key) && snapshot.policy[key] !== undefined && own(observation.policy, key) && observation.policy[key] !== undefined) || !present(snapshot.policy.model) || !present(observation.policy.model)) return recovery('policy_unobserved');
28
+ if (observation.generation !== snapshot.generation) return recovery('owner_generation_mismatch');
29
+ if (!Number.isFinite(now) || !Number.isFinite(maxAgeMs) || maxAgeMs < 0 || !Number.isFinite(observation.observedAt) ||
30
+ observation.observedAt > now || now - observation.observedAt > maxAgeMs) return recovery('observation_stale');
31
+ if (observation.consistent !== true || !present(observation.revision)) return recovery('observation_unstable');
32
+ const history = observation.history;
33
+ if (!present(snapshot.historyCursor) || history?.incomplete !== false || history.truncated !== false ||
34
+ history.fromCursor !== snapshot.historyCursor || !present(history.cursor) || !Array.isArray(history.events) ||
35
+ history.events.some(event => !present(event?.id)) || new Set(history.events.map(event => event.id)).size !== history.events.length) return recovery('history_cursor_unverified');
36
+ if (!Array.isArray(snapshot.artifacts) || !Array.isArray(observation.artifacts) ||
37
+ !Array.isArray(snapshot.attempts) || !Array.isArray(observation.executions)) return recovery('evidence_unobserved');
38
+ const writer = observation.writer;
39
+ if (snapshot.identity.provider === 'codex') {
40
+ const readOnlyBoundary = connectionOnly === true && writer?.source === 'read_only_boundary' && writer.acquisitionRequired === true && writer.verified === false && writer.state === 'idle' && writer.id === snapshot.identity.session && snapshot.writer?.id === snapshot.identity.session;
41
+ if (!readOnlyBoundary && (!present(snapshot.writer?.id) || writer?.verified !== true || writer.id !== snapshot.writer.id ||
42
+ !['idle', 'running'].includes(writer.state))) return recovery('codex_writer_unverified');
43
+ } else if (observation.liveHook?.verified !== true ||
44
+ observation.liveHook.pid !== snapshot.liveHook?.pid || observation.liveHook.identity !== snapshot.liveHook?.identity ||
45
+ observation.liveHook.startedAt !== snapshot.liveHook?.startedAt || !present(observation.liveHook.identity) ||
46
+ !Number.isSafeInteger(observation.liveHook.pid) || observation.liveHook.pid < 2 || !present(observation.liveHook.startedAt) ||
47
+ !['idle', 'running'].includes(writer?.state)) return recovery('claude_live_hook_unverified');
48
+
49
+ const attempts = snapshot.attempts;
50
+ if (attempts.some(attempt => !present(attempt?.id) || !ATTEMPT_STATES.has(attempt.state)) ||
51
+ new Set(attempts.map(attempt => attempt.id)).size !== attempts.length) return recovery('attempt_ledger_invalid');
52
+ if (observation.executions.some(execution => !present(execution?.id) || !['running', 'completed', 'failed', 'cancelled'].includes(execution.state)) ||
53
+ new Set(observation.executions.map(execution => execution.id)).size !== observation.executions.length) return recovery('execution_evidence_invalid');
54
+ const executionIds = attempts.filter(attempt => attempt.state !== 'intent').map(attempt => attempt.providerExecutionId);
55
+ if (new Set(executionIds).size !== executionIds.length) return recovery('attempt_execution_ambiguous');
56
+ if (observation.executions.some(execution => !attempts.some(attempt => attempt.state !== 'intent' &&
57
+ attempt.providerExecutionId === execution.id && attempt.id === execution.attemptId))) return recovery('untracked_execution');
58
+ const matches = [];
59
+ for (const attempt of attempts) {
60
+ if (attempt.state === 'intent') return recovery('attempt_intent_unresolved');
61
+ if (!present(attempt.providerExecutionId)) return recovery('attempt_send_outcome_unknown');
62
+ const execution = observation.executions.find(item => item.id === attempt.providerExecutionId);
63
+ if (!execution || execution.attemptId !== attempt.id) return recovery('attempt_execution_unmatched');
64
+ if (['completed', 'failed', 'cancelled'].includes(execution.state) && !present(execution.resultRef)) return recovery('execution_result_missing');
65
+ if (['result_saved', 'confirmed'].includes(attempt.state) &&
66
+ (execution.state !== 'completed' || !present(attempt.resultRef) || attempt.resultRef !== execution.resultRef)) return recovery('saved_result_mismatch');
67
+ matches.push({ attemptId: attempt.id, providerExecutionId: execution.id, state: execution.state,
68
+ ...(execution.resultRef ? { resultRef: execution.resultRef } : {}) });
69
+ }
70
+ const evidence = { writerAcquisitionRequired: connectionOnly === true && writer.acquisitionRequired === true, historyCursor: history.cursor, attempts: matches,
71
+ artifacts: structuredClone(observation.artifacts), currentPolicy: Object.fromEntries(POLICY.map(key => [key, structuredClone(observation.policy[key])])) };
72
+ // These observations update the plan; they never authorize the old plan.
73
+ const changed = POLICY.filter(key => !isDeepStrictEqual(snapshot.policy[key], observation.policy[key]));
74
+ if (changed.length || history.events.length || history.cursor !== snapshot.historyCursor ||
75
+ !isDeepStrictEqual(snapshot.artifacts, observation.artifacts)) return result('changed', ['manual_or_provider_progress', ...changed.map(key => `${key}_changed`)], evidence);
76
+ if (writer.state === 'running' || matches.some(item => item.state === 'running')) return result('already_running', ['original_provider_running'], evidence);
77
+ if (matches.some(item => ['failed', 'cancelled'].includes(item.state))) return result('recovery_required', ['execution_did_not_complete'], evidence);
78
+ if (attempts.length && attempts.every(attempt => attempt.state !== 'intent') && matches.every(item => item.state === 'completed')) return result('completed', ['execution_results_observed'], evidence);
79
+ if (stopped) return result('changed', ['user_stop_preserved'], evidence);
80
+ if (taskState !== 'active') return result('changed', ['task_not_runnable'], evidence);
81
+ return result('ready', ['identity_history_writer_and_policy_verified'], evidence);
82
+ }
83
+
84
+ // readSnapshot is PR1's read-only normalized view, not a state-store implementation.
85
+ // Two ledger/observation samples fence concurrent manual edits and owner changes.
86
+ export async function reconcileSession({ readSnapshot, adapter, now = Date.now, maxAgeMs, connectionOnly = false }) {
87
+ try {
88
+ if (typeof readSnapshot !== 'function' || typeof adapter?.observe !== 'function') throw new Error('Missing reader');
89
+ const snapshot = structuredClone(await readSnapshot());
90
+ const first = structuredClone(await adapter.observe(structuredClone(snapshot)));
91
+ const observation = structuredClone(await adapter.observe(structuredClone(snapshot)));
92
+ const current = await readSnapshot();
93
+ if (!isDeepStrictEqual(snapshot, current) || !isDeepStrictEqual({ ...first, observedAt: undefined }, { ...observation, observedAt: undefined })) return {
94
+ status: 'recovery_required', reasons: ['concurrent_change'], connectionState: 'recovery_required',
95
+ taskState: current?.task?.state, preserveStop: STOPPED.has(current?.task?.state), canReleaseGate: false,
96
+ discardNextStep: true, replayAttempts: false, nextAction: 'inspect_original_conversation', evidence: {},
97
+ };
98
+ return assessReconciliation(snapshot, observation, { now: now(), maxAgeMs, connectionOnly });
99
+ } catch {
100
+ // Provider errors can contain private transcript/authentication data.
101
+ return { status: 'recovery_required', reasons: ['observation_unavailable'], connectionState: 'recovery_required',
102
+ canReleaseGate: false, preserveStop: true, discardNextStep: true, replayAttempts: false,
103
+ nextAction: 'inspect_original_conversation', evidence: {} };
104
+ }
105
+ }
@@ -0,0 +1,13 @@
1
+ import { reconcileSession } from './reconcile.js';
2
+
3
+ // Use PR2's assessment without interpreting a changed/stopped/unknown task as
4
+ // permission to release the root barrier. The caller supplies the registered
5
+ // scope reader and original-provider observation adapter, never a new session.
6
+ export function createCoordinatorReconciler({ readSnapshot, adapter, now, maxAgeMs } = {}) {
7
+ if (typeof readSnapshot !== 'function' || typeof adapter?.observe !== 'function') return undefined;
8
+ return async () => {
9
+ const result = await reconcileSession({ readSnapshot, adapter, now, maxAgeMs });
10
+ return { verified: result.status === 'ready' && result.canReleaseGate === true,
11
+ unresolvedAttempts: result.status === 'ready' ? 0 : null, assessment: result };
12
+ };
13
+ }
@@ -0,0 +1,319 @@
1
+ import { readCodexPolicyContext } from './codex-context.js';
2
+ import { mkdirSync, realpathSync } from 'node:fs';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { join, resolve } from 'node:path';
5
+ import { execFile } from 'node:child_process';
6
+ import { promisify } from 'node:util';
7
+ import { readFile, lstat, readlink, realpath } from 'node:fs/promises';
8
+ import { atomicSave, readState, digest, withLock } from './state-store.js';
9
+ import { readDispatchGate, adoptHookOwner, DISPATCH_PROTOCOL, confirmDispatch, flushDispatchIntake, readTaskAdmission } from './dispatch-gate.js';
10
+ import { createLedgerCoordinatorAdapters } from './ledger-adapter.js';
11
+ import { listDispatchRegistrations, normalizeDispatchIdentity } from './provenance.js';
12
+ import { reconcileSession } from './reconcile.js';
13
+ import { createLedgerExecutionWorker } from './worker-ledger.js';
14
+ import { processIdentity, recordPath, updateRecord, hasPendingUserStop, hasPendingUpdateInvalidation, taskDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
15
+ import { openCodexAppServer, readCodexSession, readCodexObservation } from '../session-supervision/codex-app-server.js';
16
+ import { observeClaudeReadOnly } from './claude-observation.js';
17
+
18
+ const execFileAsync = promisify(execFile);
19
+ export async function worktreeEvidence(worktree) {
20
+ const options={cwd:worktree,timeout:5000,maxBuffer:8*1024*1024};
21
+ const [head,diff,files] = await Promise.all([
22
+ execFileAsync('git',['rev-parse','HEAD'],options),
23
+ execFileAsync('git',['diff','--binary','HEAD','--'],options),
24
+ execFileAsync('git',['ls-files','--others','--exclude-standard','-z'],options),
25
+ ]);
26
+ const names=files.stdout.split('\0').filter(Boolean);
27
+ if(names.length>256)throw failure('artifact_scope_too_large');
28
+ const untracked=[];
29
+ for(const name of names){ const file=join(worktree,name), details=await lstat(file);
30
+ if(details.size>4*1024*1024)throw failure('artifact_scope_too_large');
31
+ if(!details.isFile() && !details.isSymbolicLink())throw failure('artifact_type_unknown');
32
+ untracked.push([name,digest(details.isSymbolicLink() ? await readlink(file) : await readFile(file))]); }
33
+ return [{ref:'workspace',digest:digest([head.stdout.trim(),diff.stdout,untracked])}];
34
+ }
35
+ const stopped = new Set(['stopped','paused','paused_by_user','cancelled']);
36
+ const terminal = new Set(['completed','reported_done','failed','succeeded']);
37
+ const taskState = run => stopped.has(run.state) ? run.state : run.managed?.pendingApproval ? 'approval_wait' : ['blocked','waiting_human','recovery_required'].includes(run.state) ? 'needs_user' : 'active';
38
+ const originalTaskId = record => record.run.managed?.taskId || record.run.id;
39
+ const authority = record => digest([record.run?.id,record.run?.state,record.run?.managed,record.run?.deadline,record.run?.continuations,record.run?.failures,record.autoContinue,record.process,record.cwd,record.run?.executionCwd]);
40
+ const boundaryEvidence = record => digest([record?.runtime,record?.updateObservation,record?.hookSessionProcess,record?.hookEndedAt]);
41
+ const failure = code => Object.assign(new Error(`Update connection: ${code}`),{code});
42
+
43
+ // Only metadata and existing process observations. No turn/start, resume, new
44
+ // Claude process, model selection, account mutation or provider authentication.
45
+ export async function observeOriginalProvider(record, { openClient = openCodexAppServer, inspectProcess = processIdentity, baseline, readCurrentRecord, readInvalidation, claudeObservationOptions } = {}) {
46
+ if (record.provider === 'claude') {
47
+ const proof=await observeClaudeReadOnly(record,{...claudeObservationOptions,inspectProcess});
48
+ if(typeof readCurrentRecord!=='function')throw failure('claude_current_boundary_unavailable');
49
+ const current=await readCurrentRecord();
50
+ const evidence=value=>digest([value?.runtime,value?.updateObservation,value?.hookSessionProcess,value?.hookEndedAt,authority(value || {})]);
51
+ if(!current || evidence(current)!==evidence(record))throw failure('claude_boundary_changed');
52
+ if(typeof readInvalidation!=='function' || await readInvalidation(current))throw failure('claude_boundary_changed');
53
+ return proof;
54
+ }
55
+ const client = await openClient({cancelOnAbort:true, signal:AbortSignal.timeout(8000)});
56
+ try {
57
+ const metadata = await readCodexSession(client,record.session);
58
+ const observed = await readCodexObservation(client,record.session);
59
+ const page = await client.request('thread/turns/list',{threadId:record.session,cursor:null,limit:5,sortDirection:'desc',itemsView:'summary'},2000);
60
+ if(!Array.isArray(page?.data) || page.data.some(turn=>!turn?.id || !['completed','interrupted','failed'].includes(turn.status)))throw failure('provider_history_not_settled');
61
+ const coverage=baseline?.latestTurnId ? page.data.some(turn=>turn.id===baseline.latestTurnId) : page.data.length===0 ? page.nextCursor==null : observed.latestTurnId===page.data[0].id;
62
+ if(!coverage)throw failure('provider_history_coverage_unknown');
63
+ const raw = await client.request('thread/read',{threadId:record.session,includeTurns:false},2000);
64
+ const policy=await readCodexPolicyContext(raw.thread?.path,record.session,record.cwd);
65
+ if(typeof observed.isRunning !== 'boolean')throw failure('codex_turn_boundary_unobserved');
66
+ return {identity:{session:metadata.session,worktree:await realpath(metadata.cwd)},model:policy.model,permissions:policy.permissions,
67
+ cursor:digest([page.data,metadata.lastActivityAt,policy.revision]),latestTurnId:page.data[0]?.id || null,executionStates:page.data.map(turn=>({id:turn.id,state:turn.status})),historyCoverage:coverage,running:observed.isRunning,
68
+ // Connection-only evidence. The existing execution path must acquire its
69
+ // writer and verify fresh permissions before each new turn.
70
+ writer:{id:record.session,verified:false,state:observed.isRunning?'running':'idle',source:'read_only_boundary',acquisitionRequired:true}};
71
+ } finally { await client.close(); }
72
+ }
73
+
74
+ export function createUpdateRuntime({ root, origin, readContext, listRecords, observe = observeOriginalProvider,
75
+ worker = null, now=Date.now } = {}) {
76
+ mkdirSync(root,{recursive:true,mode:0o700});root=realpathSync(root); const file=join(root,'update-detachment','transaction.json');
77
+ let transaction=readState(file), ownerTransferred=false;
78
+ const workerFor=identity=>worker || createLedgerExecutionWorker({identity});
79
+ const context=()=>readContext?.() || {runs:[],directTasks:[],localTasks:[]};
80
+ const write=patch=>withLock(file,()=> {
81
+ const current=readState(file);
82
+ if (transaction && current?.revision!==transaction.revision) throw failure('transaction_revision_changed');
83
+ transaction={schemaVersion:1,operationId:randomUUID(),tasks:[],...current,...patch,revision:(current?.revision || 0)+1};
84
+ atomicSave(file,transaction);
85
+ });
86
+ let presented=null;
87
+ const uiState = task => task.stop_requested ? 'stopped' : ['stopped','cancelled'].includes(task.status || task.state) ? 'stopped' : ['paused','paused_by_user'].includes(task.status || task.state) ? 'paused' : task.managed?.pendingApproval ? 'approval_wait' : ['running','queued','active','dispatching','coding'].includes(task.status || task.state) ? 'running' : 'needs_user';
88
+ const rows=()=> (transaction?.connectionState==='detaching' ? transaction?.pendingTasks || [] : transaction?.tasks || []).map(task=>({title:task.title || 'Provider task',taskState:task.canResume===false && !['paused','paused_by_user','cancelled','stopped','approval_wait'].includes(task.uiState)?'needs_user':['paused_by_user','cancelled'].includes(task.uiState)?'paused':task.uiState || 'needs_user',
89
+ ...(task.canResume===false?{canResume:false,reason:'Open the original conversation to review and continue.'}:{}),
90
+ disposition:task.workerPreserved?'continuing':transaction.connectionState==='detaching'?'waiting_for_boundary':'saved',executionPreserved:task.workerPreserved===true}));
91
+ const pendingTasks=async()=> {
92
+ const c=context(),records=(await listRecords()).filter(record=>record.run && !terminal.has(record.run.state));
93
+ return [...records.map(record=>({title:record.title || 'Provider task',uiState:uiState(record.run)})),
94
+ ...(c.runs || []).map(run=>({title:'Coding task',uiState:uiState(run)})),
95
+ ...(c.localTasks || []).map(task=>({title:'Local task',uiState:uiState(task)})),
96
+ ...(c.directTasks || []).map(task=>({title:'Direct task',uiState:uiState(task)}))];
97
+ };
98
+ const adapters=createLedgerCoordinatorAdapters({root,readWriters:async()=> {
99
+ const writers=[];
100
+ for(const record of await listRecords()) {
101
+ if(!record.run || stopped.has(record.run.state) || terminal.has(record.run.state))continue;
102
+ const owner=record.hookSessionProcess || record.process;
103
+ if(!owner?.identity || !Number.isSafeInteger(owner.pid))throw failure('original_writer_inventory_unavailable');
104
+ const actual=await processIdentity(owner.pid);
105
+ if(actual!==owner.identity)throw failure('original_writer_identity_changed');
106
+ const capability=readDispatchGate(root).writerCapabilities?.[digest([record.provider,record.session])];
107
+ if(capability?.protocol!==DISPATCH_PROTOCOL || !capability.moduleDigest || capability.pid!==owner.pid || capability.processIdentity!==actual)throw failure('original_writer_protocol_unverified');
108
+ writers.push({provider:record.provider,session:record.session,pid:owner.pid,processIdentity:actual,moduleDigest:capability.moduleDigest});
109
+ }
110
+ return writers;
111
+ }});
112
+ const store={read:async()=>({...await adapters.store.read(),...transaction,connectionState:readDispatchGate(root).connection}),
113
+ write:async patch=>{write({...patch,...(patch.connectionState==='detaching'?{pendingTasks:await pendingTasks()}:{}),...(patch.connectionState==='connected' && transaction?.updateState==='applying'?{updateState:'succeeded'}:patch.connectionState==='recovery_required' && transaction?.updateState==='applying'?{updateState:'failed'}:{})});await adapters.store.write(patch);}};
114
+ const records=async()=> (await listRecords()).filter(record=>record.run);
115
+ const currentRecord=async record=>(await records()).find(item=>item.provider===record.provider && item.session===record.session);
116
+ const identity=async record=> {
117
+ const actor=context().actor;
118
+ if (!actor?.account || !actor?.device || actor.origin!==origin) throw failure('account_or_device_unavailable');
119
+ return {root,origin,account:actor.account,device:actor.device,provider:record.provider,session:record.session,worktree:await realpath(taskDirectory(record))};
120
+ };
121
+ const bind=async record=> {
122
+ const expected=normalizeDispatchIdentity(await identity(record)),taskId=originalTaskId(record);
123
+ const recordRef=recordPath(join(root,'delivery-hooks'),record.provider,record.session);
124
+ const matches=listDispatchRegistrations(root,{retained:true}).filter(item=>item.recordRef===recordRef && item.taskId===taskId);
125
+ if(!matches.length || matches.some(item=>digest(item.identity)!==digest(expected)))throw failure('original_provenance_unavailable');
126
+ return matches[0].identity;
127
+ };
128
+ const capture=async()=> {
129
+ const tasks=[];
130
+ for (const record of await records()) {
131
+ // Closed records remain in their original durable storage. They have no
132
+ // live writer to reconnect and may legitimately outlive their worktree.
133
+ // The global receipt check below still rejects every unresolved attempt.
134
+ if(terminal.has(record.run.state))continue;
135
+ const binding=await bind(record), provider=await observe(record,{readCurrentRecord:()=>currentRecord(record),readInvalidation:current=>hasPendingUpdateInvalidation(join(root,'delivery-hooks'),current)});
136
+ if(provider.identity.session!==binding.session || provider.identity.worktree!==await realpath(record.cwd)) throw failure('provider_identity_changed');
137
+ if(provider.running) return null;
138
+ tasks.push({taskId:originalTaskId(record),identity:binding,sessionCwd:await realpath(record.cwd),
139
+ title:record.title || record.provider,uiState:stopped.has(record.run.state)?record.run.state:record.run.managed?.pendingApproval?'approval_wait':taskState(record.run)==='active'?'running':'needs_user',
140
+ canResume:provider.readOnlyReconnect===true?false:undefined,boundaryEvidence:provider.readOnlyReconnect===true?boundaryEvidence(record):null,
141
+ authority:authority(record),artifacts:await worktreeEvidence(binding.worktree), recordRef:recordPath(join(root,'delivery-hooks'),record.provider,record.session), provider,
142
+ policy:{model:provider.model,permissions:provider.permissions,approvals:digest(record.run.managed?.approvedAction || null),budget:digest([record.run.continuations,record.run.failures,record.run.managed?.usage]),deadline:record.run.deadline ?? null}});
143
+ }
144
+ return tasks;
145
+ };
146
+ const boundary=async()=> {
147
+ const startingGate=readDispatchGate(root);
148
+ if(startingGate.connection!=='detaching')return {safe:false,liveExecutions:0,pendingWrites:0,unsettledPermissions:0};
149
+ const c=context(), execution=[...(c.runs||[]),...(c.localTasks||[])];
150
+ const unowned=execution.filter(run=>run.child || run.operation || run.pendingLedgerUpdate || run.ledgerUpdateRunning || ['queued','running','dispatching','coding'].includes(run.status));
151
+ const workers=[];
152
+ for(const task of c.directTasks || []) {
153
+ if(!['queued','running','stopping'].includes(task.status)) continue;
154
+ if(!task.worker_handle) {unowned.push(task);continue;}
155
+ const owner=workerFor(task.worker_handle.identity);
156
+ if(typeof owner.handoff !== 'function'){unowned.push(task);continue;}
157
+ const proof=await owner.handoff(task.worker_handle),state=proof.state;
158
+ if(proof.verified !== true || !['running','completed','stopping'].includes(state?.state)) {unowned.push(task);continue;}
159
+ const preservation=proof.preservation || owner.readSnapshot(task.worker_handle);
160
+ if(!preservation?.policy || preservation.task.id!==task.id){unowned.push(task);continue;}
161
+ workers.push({taskId:task.id,identity:task.worker_handle.identity,handle:task.worker_handle,
162
+ workerPreserved:state.state==='running',uiState:task.stop_requested || preservation.task.stopRequested?'stopped':'running',title:'Direct task',
163
+ policy:preservation.policy,deadlineAt:preservation.policy.deadline,authority:digest([preservation.policy,preservation.metadata]),
164
+ generation:task.worker_handle.generation,preservation});
165
+ }
166
+ const pendingWrites=execution.filter(run=>run.pendingLedgerUpdate||run.ledgerUpdateRunning).length;
167
+ if(unowned.length || pendingWrites) return {safe:false,liveExecutions:unowned.length,pendingWrites,unsettledPermissions:0};
168
+ const tasks=await capture(); if(!tasks) return {safe:false,liveExecutions:1,pendingWrites:0,unsettledPermissions:0};
169
+ flushDispatchIntake(root);
170
+ let ledger=readDispatchGate(root);
171
+ for(const attempt of Object.values(ledger.attempts)) {
172
+ if(attempt.phase!=='result_saved')continue;
173
+ const task=tasks.find(item=>attempt.taskHash===digest(item.taskId) && attempt.identityHash===digest(item.identity));
174
+ if(!task || task.identity.provider!=='codex')continue;
175
+ const receipts=Object.values(ledger.receipts).filter(receipt=>receipt.attemptId===attempt.id);
176
+ const ids=[...new Set(receipts.map(receipt=>receipt.providerExecutionId).filter(Boolean))];
177
+ const refs=[...new Set(receipts.map(receipt=>receipt.resultRef).filter(Boolean))];
178
+ const source=readState(task.recordRef),saved=source?.run?.managed?.executionAttempt;
179
+ if(ids.length!==1 || refs.length!==1 || saved?.id!==attempt.id || saved.phase!=='checkpointed' || refs[0]!==`delivery-record:${task.identity.provider}:${task.identity.session}:${attempt.id}` || !task.provider.executionStates?.some(turn=>turn.id===ids[0] && turn.state==='completed'))continue;
180
+ // A durable exact checkpoint plus the actual provider's exact completed
181
+ // turn proves intake; unrelated conversation progress is never a receipt.
182
+ confirmDispatch({root,attemptId:attempt.id});
183
+ }
184
+ ledger=readDispatchGate(root);
185
+ const unresolved=Object.values(ledger.attempts).filter(attempt=>attempt.phase!=='confirmed' && !workers.some(task=>task.handle.attemptId===attempt.id && attempt.taskHash===digest(task.taskId) && attempt.identityHash===digest(task.identity) && attempt.generation===task.generation));
186
+ if(unresolved.length)return {safe:false,liveExecutions:0,pendingWrites:0,unsettledPermissions:0};
187
+ const currentGate=readDispatchGate(root);
188
+ if(currentGate.connection!=='detaching' || currentGate.generation!==startingGate.generation)throw failure('boundary_owner_changed');
189
+ for(const task of tasks)if(task.canResume===false)await adapters.gate.fenceTask({identity:task.identity,taskId:task.taskId,
190
+ reason:{code:task.policy.model?'provider_policy_unknown':'provider_model_unknown',evidenceRef:task.recordRef}});
191
+ write({tasks:[...tasks,...workers]});
192
+ return {safe:true,liveExecutions:workers.filter(task=>task.workerPreserved).length,workerHandoffVerified:true,pendingWrites:0,unsettledPermissions:0};
193
+ };
194
+ const reconcile=async()=> {
195
+ const actor=context().actor, assessments=[];
196
+ const workers=(transaction?.tasks || []).filter(task=>task.handle);
197
+ if(Object.values(readDispatchGate(root).attempts).some(attempt=>attempt.phase!=='confirmed' && !workers.some(task=>task.handle.attemptId===attempt.id)))return {verified:false,unresolvedAttempts:1};
198
+ for (const task of transaction?.tasks || []) {
199
+ if(task.handle) {
200
+ if(actor?.account!==task.identity.account || actor?.origin!==task.identity.origin || actor?.device!==task.identity.device || task.identity.root!==root)return {verified:false,unresolvedAttempts:1};
201
+ const owner=workerFor(task.identity),proof=await owner.handoff(task.handle),snapshot=proof.preservation || owner.readSnapshot(task.handle);
202
+ if(proof.verified!==true || !['running','completed','stopping'].includes(proof.state?.state) || snapshot.policy?.deadline!==task.deadlineAt || digest([snapshot.policy,snapshot.metadata])!==task.authority)return {verified:false,unresolvedAttempts:1};
203
+ if(task.preservation.task.stopRequested && !snapshot.task.stopRequested)return {verified:false,unresolvedAttempts:1};
204
+ continue;
205
+ }
206
+ if(actor?.account!==task.identity.account || actor?.device!==task.identity.device || actor?.origin!==task.identity.origin) return {verified:false,unresolvedAttempts:1};
207
+ const find=async()=> (await records()).find(record=>recordPath(join(root,'delivery-hooks'),record.provider,record.session)===task.recordRef);
208
+ const readSnapshot=async()=> {
209
+ const record=await find(); if(!record || authority(record)!==task.authority)throw failure('task_authority_changed');
210
+ const ledger=readDispatchGate(root);
211
+ if(Object.values(ledger.attempts).some(attempt=>attempt.taskHash===digest(task.taskId) && attempt.phase!=='confirmed'))throw failure('unresolved_attempt');
212
+ return {identity:task.identity,revision:task.authority,generation:ledger.generation,task:{state:taskState(record.run)},policy:task.policy,
213
+ historyCursor:task.provider.cursor,artifacts:task.artifacts,attempts:[],writer:task.provider.writer,liveHook:task.provider.liveHook};
214
+ };
215
+ if(task.canResume===false) {
216
+ await readSnapshot();
217
+ for(let sample=0;sample<2;sample++) {
218
+ const record=await find();if(!record || authority(record)!==task.authority || digest(await bind(record))!==digest(task.identity))throw failure('task_authority_changed');
219
+ const current=await observe(record,{baseline:task.provider,readCurrentRecord:find,readInvalidation:current=>hasPendingUpdateInvalidation(join(root,'delivery-hooks'),current)});
220
+ if(current.readOnlyReconnect!==true || current.canResume!==false || current.permissions!==null || current.running!==false || current.historyCoverage!==true ||
221
+ current.identity?.session!==task.identity.session || current.identity?.worktree!==task.sessionCwd || current.cursor!==task.provider.cursor ||
222
+ digest(current.liveHook)!==digest(task.provider.liveHook) || digest(await worktreeEvidence(task.identity.worktree))!==digest(task.artifacts))throw failure('read_only_connection_changed');
223
+ const admission=readTaskAdmission({root,identity:task.identity,taskId:task.taskId});
224
+ if(admission.record?.status!=='blocked' || admission.canResume!==false)throw failure('task_fence_unavailable');
225
+ }
226
+ assessments.push({status:'connected_read_only',canResume:false,reasons:['provider_policy_unknown']});
227
+ // Adopting this hook generation permits lifecycle intake only: every
228
+ // new dispatch still encounters the durable task admission fence.
229
+ await updateRecord(join(root,'delivery-hooks'),task.identity.provider,task.identity.session,async current=>{
230
+ if(!current || authority(current)!==task.authority || boundaryEvidence(current)!==task.boundaryEvidence)throw failure('claude_boundary_changed');
231
+ const owner=task.provider.liveHook,latestActor=context().actor;
232
+ if(await processIdentity(owner.pid)!==owner.identity || latestActor?.account!==task.identity.account || latestActor?.device!==task.identity.device || latestActor?.origin!==task.identity.origin)throw failure('original_claude_owner_unavailable');
233
+ if(hasPendingUpdateInvalidation(join(root,'delivery-hooks'),current))throw failure('claude_boundary_changed');
234
+ if(hasPendingUserStop(join(root,'delivery-hooks'),current))throw failure('user_stop_preserved');
235
+ const admission=readTaskAdmission({root,identity:task.identity,taskId:task.taskId});
236
+ if(admission.record?.status!=='blocked' || admission.canResume!==false)throw failure('task_fence_unavailable');
237
+ // The record lock plus durable lifecycle/stop markers reject events
238
+ // that arrived while the asynchronous process check held this lock.
239
+ adoptHookOwner({root,provider:task.identity.provider,session:task.identity.session,expectedRevision:admission.revision});
240
+ return current;
241
+ });
242
+ continue;
243
+ }
244
+ const adapter={observe:async snapshot=> {
245
+ const record=await find(), current=await observe(record,{baseline:task.provider,readCurrentRecord:find,readInvalidation:current=>hasPendingUpdateInvalidation(join(root,'delivery-hooks'),current)});
246
+ const liveIdentity=await identity(record);
247
+ if(current.identity?.session!==liveIdentity.session || current.identity?.worktree!==task.sessionCwd || await realpath(record.cwd)!==task.sessionCwd)throw failure('provider_identity_changed');
248
+ return {identity:liveIdentity,revision:digest([current.cursor,current.writer,current.permissions]),observedAt:now(),generation:snapshot.generation,consistent:true,
249
+ policy:{...task.policy,model:current.model,permissions:current.permissions},writer:current.writer,liveHook:current.liveHook,artifacts:await worktreeEvidence(task.identity.worktree),executions:[],
250
+ history:{fromCursor:snapshot.historyCursor,cursor:current.cursor,incomplete:current.historyCoverage!==true,truncated:current.historyCoverage!==true,events:current.cursor===snapshot.historyCursor?[]:[{id:current.cursor}]}};
251
+ }};
252
+ const result=await reconcileSession({readSnapshot,adapter,now,connectionOnly:true}); assessments.push(result);
253
+ const preserved=result.status==='changed' && result.reasons.every(reason=>['user_stop_preserved','task_not_runnable'].includes(reason));
254
+ if(result.status!=='ready' && !preserved)return {verified:false,unresolvedAttempts:1,assessments};
255
+ // Verified original hook lifetime, no cold process takeover.
256
+ adoptHookOwner({root,provider:task.identity.provider,session:task.identity.session,expectedRevision:readDispatchGate(root).revision});
257
+ }
258
+ return {verified:true,unresolvedAttempts:0,assessments};
259
+ };
260
+ return {bind,boundary,reconcile,store,gate:adapters.gate,
261
+ async initialize() {if(!transaction)write({connectionState:readDispatchGate(root).connection,phase:'startup',updateState:'pending'});},
262
+ snapshot(base,update) {
263
+ if(ownerTransferred)transaction=readState(file);
264
+ const action=base.state==='connected' && update?.state==='waiting_for_idle'?'detach_update':base.state==='detaching'?'cancel_detach_update':!base.busy && ['detached','recovery_required'].includes(base.state)?'update_reconnect':null;
265
+ const frame={...base,schemaVersion:1,connectionState:base.state,
266
+ updateState:transaction?.updateState || 'pending',tasks:rows(),actions:action?[action]:[]};
267
+ const fingerprint=digest(frame);
268
+ if(!ownerTransferred && transaction && transaction.presentation!==fingerprint)write({presentation:fingerprint});
269
+ presented={...frame,operationId:transaction?.operationId,revision:transaction?.revision || 0};
270
+ return presented;
271
+ },
272
+ validate(command,base,update) {
273
+ const frame=this.snapshot(base,update);
274
+ if(command.operationId!==frame.operationId || command.revision!==frame.revision || !frame.actions.includes(command.action) || transaction?.claimedCommands?.includes(command.id))throw failure('stale_operation');
275
+ write({claimedCommands:[...(transaction?.claimedCommands || []).slice(-255),command.id]});
276
+ },
277
+ async prepareHandoff() {
278
+ const gate=readDispatchGate(root);
279
+ if(gate.connection!=='detached' || transaction?.connectionState!=='detached')throw failure('handoff_not_detached');
280
+ const token=randomUUID(),ownerIdentity=await processIdentity(process.pid);
281
+ if(!ownerIdentity)throw failure('handoff_owner_unavailable');
282
+ write({transferToken:token,ownerPid:process.pid,ownerIdentity,generation:gate.generation,updateState:'applying'});
283
+ for(const task of context().directTasks || []) {
284
+ if(transaction.tasks.some(saved=>saved.handle && saved.taskId===task.id)) {
285
+ task.observation_suspended=true;task.update_owner_transferred=true;
286
+ }
287
+ }
288
+ adapters.handoff();ownerTransferred=true;return token;
289
+ },
290
+ async acceptHandoff(token) {
291
+ transaction=readState(file);
292
+ const original=transaction;
293
+ if(!original || original.transferToken!==token || original.generation!==readDispatchGate(root).generation || readDispatchGate(root).connection!=='detached' || original.connectionState!=='detached' || await processIdentity(original.ownerPid)!==original.ownerIdentity)throw failure('handoff_identity_changed');
294
+ // Claim coordinator ownership before consuming the transfer token. Both
295
+ // the durable revision and token are checked again after asynchronous IO.
296
+ const ownerIdentity=await processIdentity(process.pid);
297
+ if(!ownerIdentity)throw failure('handoff_owner_unavailable');
298
+ await adapters.gate.close();
299
+ write({ownerPid:process.pid,ownerIdentity,transferToken:null});
300
+ },
301
+ async reclaimFailedHandoff() {
302
+ const current=readState(file);
303
+ if(!ownerTransferred || !current)return;
304
+ if(current.ownerPid!==process.pid && await processIdentity(current.ownerPid)===current.ownerIdentity)throw failure('candidate_owner_still_live');
305
+ transaction=current;
306
+ const ownerIdentity=await processIdentity(process.pid);
307
+ if(!ownerIdentity)throw failure('handoff_owner_unavailable');
308
+ write({transferToken:null,ownerPid:process.pid,ownerIdentity,updateState:'failed'});
309
+ for(const task of context().directTasks || []) {
310
+ if(transaction.tasks.some(saved=>saved.handle && saved.taskId===task.id)) {
311
+ task.observation_suspended=false;task.update_owner_transferred=false;
312
+ }
313
+ }
314
+ ownerTransferred=false;
315
+ // Never reopen admission or steal a crashed candidate's coordinator lock.
316
+ },
317
+ transferred:()=>ownerTransferred,
318
+ };
319
+ }
@@ -0,0 +1 @@
1
+ export * from '../codex-plugins/simy/hooks/update-state-store.mjs';
@@ -0,0 +1,145 @@
1
+ import * as ledgerStore from './update-state-store.mjs';
2
+ import * as ledgerGate from './update-dispatch-gate.mjs';
3
+ const ledgerApi = { ...ledgerStore, ...ledgerGate };
4
+ // Copied with its decoder into a content-addressed runtime before launch.
5
+ import { randomUUID } from 'node:crypto';
6
+ import { spawn } from 'node:child_process';
7
+ import { createServer } from 'node:net';
8
+ import { open, mkdtemp, chmod, rm } from 'node:fs/promises';
9
+ import { tmpdir } from 'node:os';
10
+ import path from 'node:path';
11
+ import { createProviderStreamDecoder } from './provider-stream.mjs';
12
+ import { recordWorkerEvidence } from './worker-ledger-runtime.mjs';
13
+ import { durableJson, ownedProcessIdentity } from './worker-storage.mjs';
14
+
15
+ process.once('message', async (spec) => {
16
+ let child;
17
+ let server;
18
+ let socketDirectory;
19
+ const output = [];
20
+ const usage = [];
21
+ let terminalError = null;
22
+ let providerSessionId = null;
23
+ let providerSessionSaved = false;
24
+ let stopped = false;
25
+ let deadlineExceeded = false;
26
+ let writes = Promise.resolve();
27
+ let timer;
28
+ let escalation;
29
+ let completion;
30
+ let storageError = null;
31
+ const failedOutputs = new Set();
32
+ let stdout;
33
+ let stderr;
34
+ const rememberStorageError = (error) => { storageError ||= error.message; };
35
+ const save = async (name, value) => {
36
+ try { await durableJson(spec.directory, name, value); } catch (error) { rememberStorageError(error); }
37
+ };
38
+ const owner = { attemptId: spec.attemptId, identity: spec.identity, runtime: spec.runtime, pid: process.pid, ownerToken: randomUUID(), ...(spec.ledger ? { taskId: spec.ledger.taskId, generation: spec.ledger.generation, identityHash: spec.ledger.identityHash } : {}) };
39
+ const signal = (value) => {
40
+ if (!child?.pid) return;
41
+ try { process.kill(-child.pid, value); } catch (error) { if (error.code !== 'ESRCH') throw error; }
42
+ };
43
+ const stop = () => {
44
+ stopped = true;
45
+ // Persist the stop before signalling; reconnect must not resume it.
46
+ writes = writes.then(() => save('stop.json', { ...owner, at: Date.now() })).then(() => {
47
+ signal('SIGTERM');
48
+ escalation ||= setTimeout(() => signal('SIGKILL'), 1500);
49
+ });
50
+ return writes;
51
+ };
52
+ try {
53
+ owner.processIdentity = await ownedProcessIdentity(process.pid);
54
+ socketDirectory = await mkdtemp(path.join(tmpdir(), 'simy-owner-'));
55
+ await chmod(socketDirectory, 0o700);
56
+ const socket = path.join(socketDirectory, 'ipc');
57
+ server = createServer((connection) => {
58
+ let input = '';
59
+ connection.setTimeout(2000, () => connection.destroy());
60
+ connection.on('data', async (chunk) => {
61
+ input += chunk;
62
+ if (input.length > 4096) return connection.destroy();
63
+ if (!input.includes('\n')) return;
64
+ connection.pause();
65
+ try {
66
+ const request = JSON.parse(input);
67
+ if (request.attemptId !== owner.attemptId || JSON.stringify(request.identity) !== JSON.stringify(owner.identity)) throw new Error('Owner mismatch');
68
+ if (request.operation === 'stop') await stop();
69
+ else if (request.operation !== 'inspect') throw new Error('Unsupported operation');
70
+ await writes;
71
+ connection.end(JSON.stringify({ ...owner, acknowledged: true, stopped, output: output.slice(-100), token_usage: { records: usage.slice(-100), truncated: usage.length > 100 }, storageError }) + '\n');
72
+ } catch { connection.destroy(); }
73
+ });
74
+ });
75
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(socket, resolve); });
76
+ await chmod(socket, 0o600);
77
+ await durableJson(spec.directory, 'owner.json', { ...owner, socket, deadlineAt: spec.deadlineAt });
78
+ stdout = await open(path.join(spec.directory, 'stdout'), 'wx', 0o600);
79
+ stderr = await open(path.join(spec.directory, 'stderr'), 'wx', 0o600);
80
+ const onLine = (line) => { output.push(line); if (output.length > 400) output.shift(); };
81
+ const decoder = createProviderStreamDecoder({ backend: spec.backend, onLine, onEvent: event => { providerSessionId ||= event.type === 'thread.started' ? event.thread_id : event.type === 'system' ? event.session_id : null; }, onUsage: (record) => usage.push({ ...record, phase: 'direct', backend: spec.backend }), onTerminalError: (error) => { terminalError ||= error; } });
82
+ const errorDecoder = createProviderStreamDecoder({ backend: spec.backend, stream: 'stderr', onLine });
83
+ // The claim/intent exists before this sole invocation. A lost launch ACK is never replayed.
84
+ await durableJson(spec.directory, 'dispatch.json', { ...owner, state: 'sending', at: Date.now() });
85
+ if (spec.deadlineAt !== null && Date.now() >= spec.deadlineAt) throw new Error('Worker deadline already expired');
86
+ child = spawn(spec.command.bin, spec.command.args, { cwd: spec.cwd, env: { ...process.env, ...spec.command.env }, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
87
+ completion = new Promise((resolve) => {
88
+ let spawnError = null;
89
+ child.once('error', (error) => { spawnError = error.message; });
90
+ child.once('close', (code, exitSignal) => resolve({ code, signal: exitSignal, spawnError }));
91
+ });
92
+ const capture = (stream, file, parser) => stream.on('data', (chunk) => {
93
+ stream.pause();
94
+ writes = writes.then(async () => {
95
+ if (!failedOutputs.has(file)) { try { await file.write(chunk); await file.sync(); } catch (error) { failedOutputs.add(file); rememberStorageError(error); } }
96
+ parser.push(chunk.toString('utf8'));
97
+ if (providerSessionId && !providerSessionSaved) {
98
+ await save('provider-execution.json', { ...owner, providerSessionId });
99
+ providerSessionSaved = true;
100
+ }
101
+ }).catch(rememberStorageError).finally(() => stream.resume());
102
+ });
103
+ capture(child.stdout, stdout, decoder);
104
+ capture(child.stderr, stderr, errorDecoder);
105
+ if (spec.deadlineAt !== null) timer = setTimeout(() => { deadlineExceeded = true; void stop(); }, Math.max(1, spec.deadlineAt - Date.now()));
106
+ await durableJson(spec.directory, 'started.json', { ...owner, providerPid: child.pid ?? null, at: Date.now() });
107
+ const handoff = { ...owner, providerPid: child.pid ?? null, providerProcessIdentity: await ownedProcessIdentity(child.pid), deadlineAt: spec.deadlineAt, acknowledged: true };
108
+ await durableJson(spec.directory, 'handoff.json', handoff);
109
+ recordWorkerEvidence(spec, 'handoff', handoff, ledgerApi);
110
+ if (process.connected) {
111
+ process.send({ ...owner, acknowledged: true }, () => { if (process.connected) process.disconnect(); });
112
+ }
113
+ const exit = await completion;
114
+ clearTimeout(timer); clearTimeout(escalation);
115
+ await writes;
116
+ decoder.flush(); errorDecoder.flush();
117
+ await stdout.close().catch(rememberStorageError); await stderr.close().catch(rememberStorageError);
118
+ const receipt = {
119
+ ...owner, ...exit, deadlineAt: spec.deadlineAt, finishedAt: Date.now(), providerSessionId, output,
120
+ result: output.at(-1) || null, token_usage: { records: usage },
121
+ status: storageError ? 'recovery_required' : deadlineExceeded ? 'failed' : stopped ? 'stopped' : exit.code === 0 && !terminalError ? 'succeeded' : 'failed',
122
+ error: storageError || (deadlineExceeded ? 'Worker deadline exceeded.' : stopped ? null : terminalError || exit.spawnError || (exit.code !== 0 ? `Provider exited with code ${exit.code}.` : null)),
123
+ };
124
+ await durableJson(spec.directory, 'result.json', receipt);
125
+ recordWorkerEvidence(spec, 'result', receipt, ledgerApi);
126
+ } catch (error) {
127
+ rememberStorageError(error);
128
+ await save('recovery.json', { ...owner, status: 'recovery_required', error: error.message });
129
+ if (process.connected) {
130
+ process.send({ error: 'Worker requires recovery; do not replay the attempt.' }, () => { if (process.connected) process.disconnect(); });
131
+ }
132
+ // Receipt failure after dispatch must not drop the executing owner's deadline.
133
+ if (completion) {
134
+ const exit = await completion;
135
+ await writes;
136
+ await save('recovery.json', { ...owner, ...exit, status: 'recovery_required', error: error.message, finishedAt: Date.now() });
137
+ }
138
+ } finally {
139
+ clearTimeout(timer); clearTimeout(escalation);
140
+ await stdout?.close().catch(() => {});
141
+ await stderr?.close().catch(() => {});
142
+ server?.close();
143
+ if (socketDirectory) await rm(socketDirectory, { recursive: true, force: true });
144
+ }
145
+ });