@awak-app/simy-cli 0.7.11 → 0.7.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent.js +50 -8
- package/src/auto-update.js +32 -9
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +108 -11
- package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +326 -0
- package/src/codex-plugins/simy/hooks/update-dispatch-identity.mjs +78 -0
- package/src/codex-plugins/simy/hooks/update-state-store.mjs +73 -0
- package/src/delivery-hooks/supervisor.js +39 -10
- package/src/desktop-host.js +54 -8
- package/src/desktop-status.js +1 -1
- package/src/direct-executor.js +104 -4
- package/src/local-task.js +14 -3
- package/src/runner.js +19 -4
- package/src/session-supervision/claude-sessions.js +57 -5
- package/src/session-supervision/codex-app-server.js +20 -8
- package/src/sqm/claude-plugin.js +5 -0
- package/src/sqm/review-worker.js +15 -6
- package/src/update-detachment/claude-observation.js +33 -0
- package/src/update-detachment/codex-context.js +27 -0
- package/src/update-detachment/coordinator.js +117 -0
- package/src/update-detachment/dispatch-gate.js +1 -0
- package/src/update-detachment/ledger-adapter.js +62 -0
- package/src/update-detachment/ledger-reader.js +42 -0
- package/src/update-detachment/provenance.js +1 -0
- package/src/update-detachment/provider-adapters.js +33 -0
- package/src/update-detachment/reconcile.js +105 -0
- package/src/update-detachment/reconciliation-adapter.js +13 -0
- package/src/update-detachment/runtime.js +319 -0
- package/src/update-detachment/state-store.js +1 -0
- package/src/update-detachment/worker-entry.js +145 -0
- package/src/update-detachment/worker-ledger-runtime.js +34 -0
- package/src/update-detachment/worker-ledger.js +138 -0
- package/src/update-detachment/worker-storage.js +29 -0
- package/src/update-detachment/worker.js +167 -0
|
@@ -0,0 +1,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
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Retained with the worker: receipt intake survives the dispatching host.
|
|
2
|
+
|
|
3
|
+
export function recordWorkerEvidence(spec, kind, evidence, { createStateStore, digest, atomicSave, readState, readDispatchGate, recordDispatchReceipt, withLock }) {
|
|
4
|
+
if (!spec.ledger) return;
|
|
5
|
+
const { identity, taskId, attemptId, generation, recordRef } = spec.ledger;
|
|
6
|
+
const scope = createStateStore(identity);
|
|
7
|
+
const gate = readDispatchGate(identity.root);
|
|
8
|
+
const attempt = gate.attempts[digest(attemptId)];
|
|
9
|
+
if (!attempt || attempt.taskHash !== digest(taskId) || attempt.identityHash !== digest(identity) || attempt.generation !== generation) throw new Error('Worker ledger attempt mismatch');
|
|
10
|
+
if (scope.read().tasks[digest(taskId)]?.recordRef !== recordRef || recordRef !== `${spec.directory}/snapshot.json`) throw new Error('Worker registered snapshot mismatch');
|
|
11
|
+
const providerExecutionId = `worker:${attemptId}:${spec.runtime}`;
|
|
12
|
+
const resultRef = kind === 'result' ? `${spec.directory}/result.json` : null;
|
|
13
|
+
const receiptId = `${attemptId}:worker:${kind}`;
|
|
14
|
+
recordDispatchReceipt({ root: identity.root, attemptId, receiptId, providerExecutionId, resultRef });
|
|
15
|
+
const state = scope.read();
|
|
16
|
+
if (!state.receipts[digest(receiptId)]) scope.transact(state.revision, next => {
|
|
17
|
+
next.attempts[digest(attemptId)] = { id: attemptId, taskHash: digest(taskId), identityHash: digest(identity), generation, providerExecutionId, workerRef: spec.directory };
|
|
18
|
+
next.receipts[digest(receiptId)] = { attemptId, generation, kind, evidenceRef: `${spec.directory}/${kind === 'handoff' ? 'handoff' : 'result'}.json`, ...(kind === 'result' ? { outputRef: `${spec.directory}/stdout`, stderrRef: `${spec.directory}/stderr`, resultRef, usageRef: resultRef } : {}) };
|
|
19
|
+
return next;
|
|
20
|
+
});
|
|
21
|
+
if (kind === 'result') withLock(recordRef, () => {
|
|
22
|
+
const snapshot = readState(recordRef);
|
|
23
|
+
if (!snapshot || digest(snapshot.identity) !== digest(identity) || snapshot.task.id !== taskId || snapshot.attemptId !== attemptId || snapshot.generation !== generation) throw new Error('Worker preservation snapshot mismatch');
|
|
24
|
+
if (snapshot.resultRef === resultRef && snapshot.resultDigest === digest(evidence)) return;
|
|
25
|
+
snapshot.resultDigest = digest(evidence);
|
|
26
|
+
snapshot.revision++;
|
|
27
|
+
snapshot.task.state = snapshot.task.stopRequested || evidence.status === 'stopped' ? 'stopped' : evidence.status;
|
|
28
|
+
snapshot.task.stopRequested ||= evidence.status === 'stopped';
|
|
29
|
+
snapshot.outputRef = `${spec.directory}/stdout`;
|
|
30
|
+
snapshot.resultRef = resultRef;
|
|
31
|
+
snapshot.usageRef = `${spec.directory}/result.json`;
|
|
32
|
+
atomicSave(recordRef, snapshot);
|
|
33
|
+
});
|
|
34
|
+
}
|