@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
@@ -1,3 +1,5 @@
1
+ import { captureDispatchProvenance } from '../update-detachment/provenance.js';
2
+ import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, queueDispatchReceipt, rootFromDelivery, observeDispatchEvent } from '../update-detachment/dispatch-gate.js';
1
3
  import { safeCheckpointDiagnostic } from './checkpoint-diagnostic.js';
2
4
  import { recordHandoffStage } from '../codex-plugins/simy/hooks/consistency-handoff.mjs';
3
5
  import { retainControllerEvidence } from '../codex-plugins/simy/hooks/purpose-evidence.mjs';
@@ -13,7 +15,7 @@ import { requireProviderLink } from '../provider-links.js';
13
15
  import { spawn } from 'node:child_process';
14
16
  import { randomUUID } from 'node:crypto';
15
17
  import { dirname } from 'node:path';
16
- import { canExecuteAutonomousExternal, automaticContinuationAllowed, settlePurposeVerification, assessedCheckpointContract, checkpointDelivery, decideStop, deliveryProgressContext, deliveryStop, listRecords, readRecord, managedBudgetReason, notifyState, processIdentity, sharesTaskWorkspace, taskDirectory, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
18
+ import { hasPendingUserStop, recordPath, canExecuteAutonomousExternal, automaticContinuationAllowed, settlePurposeVerification, assessedCheckpointContract, checkpointDelivery, decideStop, deliveryProgressContext, deliveryStop, listRecords, readRecord, managedBudgetReason, notifyState, processIdentity, sharesTaskWorkspace, taskDirectory, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
17
19
  import { resolveBackendExecutable } from '../backend-executable.js';
18
20
  import { executeCodexAppServer } from '../session-supervision/codex-app-server.js';
19
21
  import { acquireWorkspaceLease, leaseOwnerDead } from '../session-supervision/workspace-lease.js';
@@ -50,6 +52,7 @@ async function assertActiveLease(root, provider, session, leaseId, signal, curre
50
52
  if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
51
53
  await requireProviderLink({ root, provider });
52
54
  const current = await readRecord(root, provider, session);
55
+ if (hasPendingUserStop(root, current)) throw new Error('User stopped this provider task.');
53
56
  if (signal?.aborted || current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) {
54
57
  throw new Error('Managed recovery lease changed before provider dispatch.');
55
58
  }
@@ -83,12 +86,15 @@ export function parseAgentCheckpoint(output, expectedSession) {
83
86
  // The provider CLI is a new process. It is used only for an exact Codex UUID
84
87
  // enrolled for this recovery channel after a corroborated process exit.
85
88
  export async function executeCodexResume(record, { signal, root, leaseId, executable = resolveBackendExecutable, currentTime = Date.now, onActivity = () => {}, beforeDispatch = async () => {}, leaseMonitored = false } = {}) {
89
+ const dispatchOwner = captureDispatchOwner(rootFromDelivery(root));
86
90
  if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
91
+ const run = record.run;
92
+ const provenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: record.provider, providerSession: record.session, worktree: taskDirectory(record), taskId: run.managed.taskId, recordRef: recordPath(root, record.provider, record.session) });
93
+ assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance });
87
94
  const binary = await executable('codex');
88
95
  if (!binary) throw new Error('Codex executable unavailable.');
89
96
  await assertActiveLease(root, record.provider, record.session, leaseId, signal, currentTime);
90
- const run = record.run;
91
- const context = { taskId: run.managed.taskId, session: record.session, sessionCwd: record.cwd, executionCwd: taskDirectory(record), objective: run.objective, nextStep: run.nextStep,
97
+ const context = { taskId: run.managed.taskId, session: record.session, sessionCwd: record.cwd, executionCwd: taskDirectory(record), objective: run.objective, nextStep: run.nextStep,
92
98
  lastDependencyResolution: run.managed.lastDependencyResolution,
93
99
  ...(run.managed.upgradedFrom ? { adoptedLoop: run.managed.upgradedFrom, priorCheckpoint: run.checkpoint } : {}),
94
100
  remainingWork: run.managed.remainingWork, acceptanceCriteria: run.managed.acceptanceCriteria, proposedAction: run.managed.proposedAction,
@@ -102,14 +108,15 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
102
108
  if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
103
109
 
104
110
  return new Promise((resolve, reject) => {
105
- const child = spawn(binary, args, { cwd: record.cwd, stdio: ['ignore', 'pipe', 'pipe'], signal,
106
- env: { ...process.env, SIMY_HOME: dirname(root) } });
111
+ const child = dispatchWithFence(dispatchOwner, { taskId: run.managed?.taskId || record.session, attemptId: leaseId, provenance }, () => { if (hasPendingUserStop(root, record)) throw new Error('User stopped this provider task.'); return spawn(binary, args, { cwd: taskDirectory(record), stdio: ['ignore', 'pipe', 'pipe'], signal,
112
+ env: { ...process.env, SIMY_HOME: dirname(root) } }); });
107
113
  let error = '', spawnError = null, pending = '', lastMessage = null;
108
114
  const startedSessions = new Set();
109
115
  const consumeLine = line => {
110
116
  if (Buffer.byteLength(line) > MAX_OUTPUT) return;
111
117
  try {
112
118
  const event = JSON.parse(line);
119
+ observeDispatchEvent({ root: dispatchOwner.root, attemptId: leaseId, provider: record.provider, event });
113
120
  if (event.type === 'thread.started' && typeof event.thread_id === 'string') startedSessions.add(event.thread_id);
114
121
  if (event.type === 'item.completed' && event.item?.type === 'agent_message' && typeof event.item.text === 'string') lastMessage = event.item.text;
115
122
  } catch { /* Other provider events are ignored. */ }
@@ -123,7 +130,7 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
123
130
  };
124
131
  signal?.addEventListener('abort', terminate, { once: true });
125
132
  const stopWatch = leaseMonitored ? () => {} : watchLease(root, record.provider, record.session, current => {
126
- if (current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) terminate();
133
+ if (hasPendingUserStop(root, current) || current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) terminate();
127
134
  }, terminate);
128
135
  child.stdout.on('data', chunk => {
129
136
  onActivity();
@@ -138,7 +145,7 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
138
145
  cleanup(); if (pending) consumeLine(pending);
139
146
  const output = [...startedSessions].map(thread_id => JSON.stringify({ type: 'thread.started', thread_id })).join('\n') + '\n' +
140
147
  (lastMessage === null ? '' : JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: lastMessage } }));
141
- if (spawnError) reject(spawnError); else resolve({ code, output, error });
148
+ if (spawnError) reject(spawnError); else resolve({ code, dispatchAttemptId: leaseId, output, error });
142
149
  });
143
150
  });
144
151
  }
@@ -159,6 +166,13 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
159
166
  record = await recoverOutcomeAuditLease(record, { root, inspectProcess });
160
167
  let run = record.run;
161
168
  if (!run?.managed) continue;
169
+ if (hasPendingUserStop(root, record)) {
170
+ await updateRecord(root, record.provider, record.session, current => {
171
+ if (hasPendingUserStop(root, current)) { current.run.state = 'stopped'; current.run.reason = 'User interrupted the provider.'; current.run.updatedAt = now; }
172
+ return current;
173
+ });
174
+ continue;
175
+ }
162
176
  // The audit must retain a stable target until its result is settled, even
163
177
  // when no token ceiling is configured. Its own timeout/owner recovery
164
178
  // releases this lease; a worker never takes it over.
@@ -169,7 +183,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
169
183
  // A live/unknown owner is never taken over merely because time passed.
170
184
  if (!ownerDead && (owner || claimNow - run.managed.resumeLease?.claimedAt <= 5 * 60_000)) continue;
171
185
  await updateRecord(root, record.provider, record.session, current => {
172
- if (current?.run?.state !== 'auto_resuming' || current.run.managed.resumeLease?.id !== run.managed.resumeLease?.id) return current;
186
+ if (hasPendingUserStop(root, current) || current?.run?.state !== 'auto_resuming' || current.run.managed.resumeLease?.id !== run.managed.resumeLease?.id) return current;
173
187
  current.run.state = 'recovery_required'; current.run.recoveryCause = 'interrupted_owner'; current.run.managed.autoRecovery = false;
174
188
  recordRecoveryIncident(current.run, { code: 'interrupted_owner', subject: current.run.managed.executionAttempt?.id || '', now: claimNow });
175
189
  recordRecoveryStage(current.run, 'interrupted', { now: claimNow, attemptId: current.run.managed.executionAttempt?.id });
@@ -268,7 +282,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
268
282
  if (signal?.aborted) controller.abort();
269
283
  else signal?.addEventListener('abort', abort, { once: true });
270
284
  const stopWatch = watchLease(root, record.provider, record.session, current => {
271
- if (current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId ||
285
+ if (hasPendingUserStop(root, current) || current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId ||
272
286
  managedBudgetReason(current.run, currentTime(), { checkProgress: false }) || !waitingForTool && currentTime() - lastActivityAt >= 5 * 60_000) controller.abort();
273
287
  }, () => controller.abort());
274
288
  try {
@@ -381,6 +395,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
381
395
  if (result.verification) await settlePurposeVerification({ root, provider: record.provider, session: record.session, expectedLease: leaseId, review: result.verification, verifySnapshot: current => reviewWorkspaceIdentity(current, root, result.verification, { signal: controller.signal }), now: Date.now() });
382
396
  else await checkpointDelivery({ root, provider: record.provider, session: record.session, cwd: record.cwd,
383
397
  checkpoint: result.checkpoint || parseAgentCheckpoint(result.output, record.session), expectedLease: leaseId, toolEvidence: result.toolEvidence || [], sourceContext: result.sourceContext, controllerEvidence: result.controllerEvidence, now: Date.now() });
398
+ if (result.dispatchAttemptId) queueDispatchReceipt({ root: rootFromDelivery(root), attemptId: result.dispatchAttemptId, receiptId: `checkpoint:${leaseId}`, resultRef: `delivery-record:${record.provider}:${record.session}:${leaseId}` });
384
399
  await updateRecord(root, record.provider, record.session, current => {
385
400
  if (current?.run?.managed?.executionAttempt?.id !== leaseId) return current;
386
401
  current.run.managed.executionAttempt.phase = 'checkpointed';
@@ -404,7 +419,7 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
404
419
  });
405
420
  } catch (error) { result = { ...result, code: -1, errorCode: 'checkpoint_rejected', diagnostic: safeCheckpointDiagnostic(error) }; }
406
421
  }
407
- await updateRecord(root, record.provider, record.session, async current => {
422
+ await updateRecordAfterControl(root, record.provider, record.session, async current => {
408
423
  const live = current?.run;
409
424
  if (live?.managed?.resumeLease?.id !== leaseId) return current;
410
425
  delete live.managed.resumeLease;
@@ -526,3 +541,17 @@ export function createManagedSupervisor(options) {
526
541
  async close() { closed = true; controller.abort(); await Promise.allSettled([running, auditing]); },
527
542
  };
528
543
  }
544
+
545
+
546
+ // Stop/pause can wake the owned turn before its control transaction releases
547
+ // the record lock. Retry only acquisition conflicts; never steal a live lock.
548
+ async function updateRecordAfterControl(root, provider, session, mutate) {
549
+ const until = Date.now() + 5000;
550
+ while (true) {
551
+ try { return await updateRecord(root, provider, session, mutate); }
552
+ catch (error) {
553
+ if (error.message !== 'Delivery state is busy; retry the command.' || Date.now() >= until) throw error;
554
+ await new Promise(resolve => setTimeout(resolve, 25));
555
+ }
556
+ }
557
+ }
@@ -1,3 +1,9 @@
1
+ import { createUpdateRuntime } from './update-detachment/runtime.js';
2
+ import { createCoordinatorReconciler } from "./update-detachment/reconciliation-adapter.js";
3
+ import { createLedgerCoordinatorAdapters } from "./update-detachment/ledger-adapter.js";
4
+ import { createUpdateCoordinator } from "./update-detachment/coordinator.js";
5
+ import { sessionWorkState, compareSessionWork, reconcileFamilyCompletion, expireSessionWorkState } from './session-supervision/work-state.js';
6
+ import { createPullRequestObserver } from './session-supervision/pull-requests.js';
1
7
  import { sessionDecisionPresentation } from './session-supervision/decision-presentation.js';
2
8
  import { PROVIDER_LINKS, readProviderLinks, writeProviderLink, requireProviderLink } from "./provider-links.js";
3
9
  import { randomUUID } from "node:crypto";
@@ -11,7 +17,7 @@ import { stopDesktopWorkForUpdate } from './desktop-update-stop.js';
11
17
  import { desktopAccessReady, desktopActionAllowed, requireDesktopAccess, validDesktopAccessPath } from './desktop-access.js';
12
18
  import { createActionDeadlineService } from './session-supervision/action-deadline.js';
13
19
  import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
14
- import { controlDelivery, verifyDelivery, createRecordReader, updateRecord, TERMINAL } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
20
+ import { controlDelivery, verifyDelivery, createRecordReader, updateRecord, recordProviderArchive, TERMINAL } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
15
21
  import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
16
22
  import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
17
23
  import { createStopNotifier } from "./session-supervision/stop-notifications.js";
@@ -101,7 +107,7 @@ function desktopActionError(action, error) {
101
107
  if (action === "session_deadline_decide") return error?.code === "conflict"
102
108
  ? "Deadline changed; refresh before deciding." : "The deadline is unavailable. Refresh this conversation before deciding.";
103
109
  if (typeof action === "string" && action.startsWith("session_")) {
104
- return action.startsWith("session_action_") ? associationActionError(error) : sessionActionError(error);
110
+ return action.startsWith("session_action_") ? associationActionError(error) : sessionActionError(error, action);
105
111
  }
106
112
  if (["organization_select", "organization_cancel", "organizations_refresh"].includes(action)) {
107
113
  return error?.code === "desktop_organization_busy"
@@ -192,6 +198,15 @@ export async function startDesktopHost({ input = process.stdin, output = process
192
198
  };
193
199
  let agent, updater, analysisManager, automaticContinuation, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false, scheduledPublish = null;
194
200
  const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, list: async () => linkedSessions(await recordReader.list()), onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
201
+ const updateRuntime = createUpdateRuntime({ root, origin:webOrigin,
202
+ readContext:()=>agent?.getUpdateDetachmentContext?.(), listRecords:()=>recordReader.list(),
203
+ ...(dependencies.observeUpdateProvider ? {observe:dependencies.observeUpdateProvider} : {}) });
204
+ const coordinator = createUpdateCoordinator({ ...(dependencies.updateDetachment || {
205
+ store:updateRuntime.store,gate:updateRuntime.gate,
206
+ boundary:dependencies.updateSafeBoundary || (async()=>{const holds=updater?.updateDrainState?.();if(holds && (holds.activeWork || holds.applyHolds))return {safe:false,liveExecutions:holds.activeWork,pendingWrites:holds.applyHolds,unsettledPermissions:0};return updateRuntime.boundary();}),
207
+ reconcile:dependencies.reconcileUpdateConnection || createCoordinatorReconciler(dependencies.updateReconciliation) || updateRuntime.reconcile,
208
+ }), preserveConnectedOnStartup:true, initialState:env.SIMY_UPDATE_TRANSFER_TOKEN?'detached':'connected',
209
+ onChange:()=>{if(agent && !closed && !relay)setImmediate(()=>void publish().catch(()=>{}));} });
195
210
  let actionAssociations = makeActionAssociations();
196
211
  const statisticsCache = new Map();
197
212
  let activeStatisticsRead = null;
@@ -411,6 +426,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
411
426
  });
412
427
  unfinishedDesktopWork = false;
413
428
  })();
429
+ const observePullRequests = dependencies.observePullRequests || createPullRequestObserver();
414
430
  const refreshSessions = (force = false) => {
415
431
  if (discoveryStopped && !closed && !relay) return sessionStatus;
416
432
  if (closed || relay || !desktopAccessReady(currentAccess)) return { sessions: [], providers: {}, refreshedAt: null };
@@ -428,8 +444,12 @@ export async function startDesktopHost({ input = process.stdin, output = process
428
444
  sessionRefreshAgain = desktopAccessReady(latestAccess);
429
445
  return;
430
446
  }
431
- const sessions = linkedSessions(value.sessions);
432
- await analysisManager.update(analysisSessions(sessions));
447
+ const sessions = await observePullRequests(linkedSessions(value.sessions), { signal: controller.signal });
448
+ const afterObservation = await accessSnapshot();
449
+ if (controller.signal.aborted || !desktopAccessReady(afterObservation) || accessScope(afterObservation) !== requestedScope) {
450
+ await analysisManager.update([]); sessionRefreshAgain = desktopAccessReady(afterObservation); return;
451
+ }
452
+ if (coordinator.acceptsNewWork()) await analysisManager.update(analysisSessions(sessions));
433
453
  if (controller.signal.aborted) return;
434
454
  sessionStatus = { ...value, sessions, initialLoading: false }; sessionRefreshedAt = Date.now(); })
435
455
  .catch(() => { if (closed || relay || controller.signal.aborted) return; sessionStatus = { ...sessionStatus, initialLoading: false, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
@@ -612,6 +632,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
612
632
  const sampledAccess = structuredClone(status.access || { state: 'unavailable', organizationId: null, organizationName: null });
613
633
  if (!await acceptStatusAccess(sampledAccess)) { publishAgain = true; return; }
614
634
  status.access = sampledAccess;
635
+ status.updateDetachment = updateRuntime.snapshot(coordinator.snapshot(),updater?.snapshot?.());
636
+ if(!desktopAccessReady(sampledAccess))status.updateDetachment={...status.updateDetachment,tasks:[],actions:status.updateDetachment.actions.filter(action=>action==='cancel_detach_update')};
615
637
  const requestedLinks = await decorateProviderSettings(status);
616
638
  const sameScope = desktopAccessReady(currentAccess) &&
617
639
  accessScope(lastStatus?.access) === accessScope(currentAccess);
@@ -626,7 +648,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
626
648
  // Cached enrichment must follow the current connection preference even
627
649
  // while a full projection is waiting on records or remote metadata.
628
650
  status.deliveryHooks.runs = linkedSessions(status.deliveryHooks.runs || []);
629
- status.sessionSupervision.sessions = linkedSessions(status.sessionSupervision.sessions || []);
651
+ status.sessionSupervision.sessions = linkedSessions(status.sessionSupervision.sessions || []).map(candidate => expireSessionWorkState(candidate)).sort(compareSessionWork);
630
652
  status.sessionSupervision.providers ||= {};
631
653
  for (const provider of PROVIDER_LINKS) {
632
654
  if (!providerLinks[provider].connected) status.sessionSupervision.providers[provider] = { discovery: 'disconnected', reason: null };
@@ -660,6 +682,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
660
682
  const sampledAccess = structuredClone(status.access || { state: 'unavailable', organizationId: null, organizationName: null });
661
683
  if (!await acceptStatusAccess(sampledAccess)) { publishAgain = true; return; }
662
684
  status.access = sampledAccess;
685
+ status.updateDetachment = updateRuntime.snapshot(coordinator.snapshot(),updater?.snapshot?.());
686
+ if(!desktopAccessReady(sampledAccess))status.updateDetachment={...status.updateDetachment,tasks:[],actions:status.updateDetachment.actions.filter(action=>action==='cancel_detach_update')};
663
687
  const requestedScope = accessScope(currentAccess);
664
688
  const requestedLinks = await decorateProviderSettings(status);
665
689
  const allRecords = await recordReader.list();
@@ -702,9 +726,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
702
726
  status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: projection?.records || records }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
703
727
  await actionAssociations.decorate(status.deliveryHooks, projection);
704
728
  const discovery = refreshSessions();
705
- automaticContinuation?.tick(discovery.sessions);
729
+ if (coordinator.acceptsNewWork()) automaticContinuation?.tick(discovery.sessions);
706
730
  const sessions = discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate)));
707
731
  await actionAssociations.decorateSessions?.(sessions, projection);
732
+ const stateRunsBySession = new Map((projection?.records || records).filter(record => record.run).map(record => [`${record.provider}:${record.session}`, record.run]));
708
733
  const runsBySession = new Map((status.deliveryHooks.runs || []).map(run => [`${run.provider}:${run.session}`, run]));
709
734
  for (const session of sessions) {
710
735
  const key = `${session.provider}:${session.session}`;
@@ -727,8 +752,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
727
752
  const run = runsBySession.get(`${session.provider}:${session.session}`);
728
753
  if (run) run.actionDeadline = session.actionDeadline;
729
754
  }
730
- status.sessionSupervision = { ...discovery, refreshing: Boolean(sessionRefresh), sessions: sessions.map(candidate => ({ ...candidate,
731
- decisionRequest: sessionDecisionPresentation(candidate, runsBySession.get(`${candidate.provider}:${candidate.session}`)) })) };
755
+ status.sessionSupervision = { ...discovery, refreshing: Boolean(sessionRefresh), sessions: reconcileFamilyCompletion(sessions.map(candidate => ({ ...candidate,
756
+ decisionRequest: sessionDecisionPresentation(candidate, runsBySession.get(`${candidate.provider}:${candidate.session}`)),
757
+ workState: sessionWorkState(candidate, runsBySession.get(`${candidate.provider}:${candidate.session}`) || stateRunsBySession.get(`${candidate.provider}:${candidate.session}`), { provider: discovery.providers?.[candidate.provider] }) }))).sort(compareSessionWork) };
732
758
  const latestAccess = await accessSnapshot();
733
759
  if (JSON.stringify(await readProviderLinks(root)) !== requestedLinks ||
734
760
  !desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope ||
@@ -782,6 +808,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
782
808
  })();
783
809
 
784
810
  try {
811
+ await updateRuntime.initialize();
812
+ if (env.SIMY_UPDATE_TRANSFER_TOKEN) await updateRuntime.acceptHandoff(env.SIMY_UPDATE_TRANSFER_TOKEN);
813
+ else await coordinator.initialize();
785
814
  analysisManager = await makeAnalysisManager();
786
815
  automaticContinuation = makeAutomaticContinuation();
787
816
  if (!candidate) {
@@ -794,6 +823,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
794
823
  dependencies: { packageInfo: updatePackageRoot ? await readCliPackageInfo(updatePackageRoot) : packageInfo,
795
824
  logger: message => diagnostics.write(`update: ${message}`) },
796
825
  });
826
+ updater.setCoordinator?.(coordinator);
797
827
  agent = await (dependencies.startAgent || startAgent)({
798
828
  daemon: true, webOrigin, sessionRoot: root, quiet: true, updateManager: updater,
799
829
  deferStartupSynchronization: true, enforceDesktopAccess: true,
@@ -803,9 +833,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
803
833
  setImmediate(() => { void publish().catch(() => {}); });
804
834
  },
805
835
  });
836
+ if (env.SIMY_UPDATE_TRANSFER_TOKEN) await coordinator.reconnect();
806
837
  stopWorkflows = (dependencies.startWorkflowUpdates || startWorkflowUpdates)({ enabled: env.SIMY_AUTO_UPDATE !== "0" });
807
838
  updater.on?.("change", () => { void publish().catch(() => {}); });
808
- updater.start({ registry: agent.registry, isDesktopIdle: () => !unfinishedDesktopWork,
839
+ updater.start({ registry: agent.registry, coordinator, isDesktopIdle: () => !unfinishedDesktopWork,
809
840
  restartDaemon: async (targetVersion, installation) => {
810
841
  if (!installation?.entryPath || !installation?.executablePath) throw new Error("Signed update runtime is unavailable.");
811
842
  discoveryStopped = true;
@@ -817,12 +848,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
817
848
  // The candidate also shares association records and their sync outbox.
818
849
  // Drain the old writer before starting its replacement.
819
850
  await actionAssociations.close();
851
+ const transferToken = coordinator.snapshot().state === 'detached' ? await updateRuntime.prepareHandoff() : null;
820
852
  let child;
821
853
  try { child = (dependencies.spawn || spawn)(installation.executablePath,
822
854
  [join(dirname(installation.entryPath), "desktop-host.js")], {
823
855
  stdio: ["pipe", "pipe", "pipe"],
824
- env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken },
856
+ env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken, ...(transferToken ? {SIMY_UPDATE_TRANSFER_TOKEN:transferToken} : {}) },
825
857
  }); } catch (error) {
858
+ if(transferToken)await updateRuntime.reclaimFailedHandoff();
826
859
  actionAssociations = makeActionAssociations();
827
860
  analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); discoveryStopped = false; refreshSessions(true); throw error;
828
861
  }
@@ -860,7 +893,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
860
893
  let event; try { event = JSON.parse(line); } catch { return; }
861
894
  if (accepted) { emit(event); return; }
862
895
  if (event.type === "status") {
863
- if (!validCandidateStatus(event.status, targetVersion, webOrigin)) {
896
+ if (!validCandidateStatus(event.status, targetVersion, webOrigin) || transferToken && (event.status.updateDetachment?.connectionState !== 'connected' || event.status.updateDetachment.operationId !== updateRuntime.snapshot(coordinator.snapshot()).operationId)) {
864
897
  finish(reject, new Error("Updated desktop runtime identity mismatch."));
865
898
  } else { accepted = true; finish(accept, event); }
866
899
  }
@@ -870,8 +903,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
870
903
  await stopAgent();
871
904
  if (child.exitCode !== null || closed) throw new Error("Updated runtime stopped during handoff.");
872
905
  emit(first);
906
+ return {connectionTransferred:Boolean(transferToken)};
873
907
  } catch (error) {
874
908
  lines.close(); await stopUpdateCandidate(child);
909
+ if(transferToken)await updateRuntime.reclaimFailedHandoff();
875
910
  if (!closed && !relay) {
876
911
  actionAssociations = makeActionAssociations();
877
912
  analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); discoveryStopped = false; refreshSessions(true);
@@ -883,7 +918,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
883
918
  await publish();
884
919
  const maintenance = new Map();
885
920
  const tick = worker => {
886
- if (closed || relay || maintenance.has(worker)) return;
921
+ if (closed || relay || !coordinator.acceptsNewWork() || maintenance.has(worker)) return;
887
922
  const pending = Promise.resolve().then(() => worker.tick()).then(() => { if (!closed && !relay) return publish(); }).catch(() => {}).finally(() => maintenance.delete(worker));
888
923
  maintenance.set(worker, pending);
889
924
  };
@@ -990,6 +1025,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
990
1025
  if (!relay.stdin.destroyed && relay.exitCode === null) relay.stdin.write(`${JSON.stringify(command)}\n`);
991
1026
  return;
992
1027
  }
1028
+ if(command.action==='cancel_detach_update') {
1029
+ try {updateRuntime.validate(command,coordinator.snapshot(),updater?.snapshot?.());coordinator.cancel();emit({type:'result',id:command.id,ok:true});}
1030
+ catch {emit({type:'result',id:command.id,ok:false,message:'This update operation changed. Refresh its status.'});}
1031
+ return;
1032
+ }
993
1033
  // Read-only activity must never hold the ordered control queue. Share an
994
1034
  // identical pending selection and cancel superseded selections immediately.
995
1035
  let statisticsRequest, requestedStatisticsSelection;
@@ -1005,6 +1045,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
1005
1045
  if (closed) return;
1006
1046
  try {
1007
1047
  const action = command.action;
1048
+ if(['detach_update','update_reconnect'].includes(action)) {
1049
+ const access=await accessSnapshot(); requireDesktopAccess(access);
1050
+ updateRuntime.validate(command,coordinator.snapshot(),updater?.snapshot?.());
1051
+ if(action==='detach_update')await updater.maybeApply({detach:true});
1052
+ else await coordinator.reconnect();
1053
+ emit({type:'result',id:command.id,ok:coordinator.snapshot().state==='connected' && (action!=='detach_update' || updater.snapshot().state==='updated')});
1054
+ await publish();return;
1055
+ }
1008
1056
  if (action === "quit") {
1009
1057
  if (command.forUpdate === true) {
1010
1058
  if (command.force !== true) throw new Error('Confirm before stopping unfinished tasks.');
@@ -1039,6 +1087,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1039
1087
  if ((status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0 ||
1040
1088
  hasUnfinishedSessions(await recordReader.list())) throw new Error('Confirm before stopping tasks to restart.');
1041
1089
  }
1090
+ if (!coordinator.acceptsNewWork()) throw new Error('Reconcile the update connection before starting another update.');
1042
1091
  await executeUpdate(action === 'prepare_update_restart');
1043
1092
  emit({ type: 'result', id: command.id, ok: true });
1044
1093
  void publish().catch(() => {}); return;
@@ -1046,8 +1095,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
1046
1095
  const access = await accessSnapshot();
1047
1096
  const commandScope = accessScope(access);
1048
1097
  if (!desktopActionAllowed(access, command)) throw Object.assign(new Error('Desktop access is required.'), { code: 'desktop_access_required' });
1049
- if (desktopAccessReady(access) && !desktopActionAllowed({ state: 'unavailable' }, command)) await authorizeWork();
1098
+ const recoveryControl = ['provider_disconnect', 'session_unmonitor'].includes(action) ||
1099
+ ['session_set_auto_continue', 'session_set_email_reports'].includes(action) && command.enabled === false;
1100
+ if (['detach_update', 'update_reconnect'].includes(action)) requireDesktopAccess(access);
1101
+ else if (!recoveryControl && desktopAccessReady(access) && !desktopActionAllowed({ state: 'unavailable' }, command)) await authorizeWork();
1050
1102
 
1103
+ if (!coordinator.acceptsNewWork() && !recoveryControl && !["refresh", "reconnect", "task_detail", "check_update", "quit", "delivery_stop", "delivery_pause", "delivery_reject"].includes(action)) {
1104
+ throw new Error("The update connection requires recovery.");
1105
+ }
1051
1106
  let commandProviderLink;
1052
1107
  if (command.provider && !["provider_connect", "provider_disconnect", "delivery_stop", "delivery_pause", "delivery_reject", "session_untrack", "session_unmonitor"].includes(action) &&
1053
1108
  !(action === "session_set_auto_continue" && command.enabled === false)) {
@@ -1128,7 +1183,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1128
1183
  await agent.refreshDesktopStatus();
1129
1184
  await refreshPlugins();
1130
1185
  refreshSessions(true);
1131
- if (desktopAccessReady(currentAccess)) void actionAssociations.tick().then(publish).catch(() => {});
1186
+ if (desktopAccessReady(currentAccess) && coordinator.acceptsNewWork()) void actionAssociations.tick().then(publish).catch(() => {});
1132
1187
  }
1133
1188
  else if (action === "organizations_refresh") await agent.refreshOrganizations();
1134
1189
  else if (action === "sqm_set_enabled") await agent.setSqmEnabled(command.enabled);
@@ -1339,6 +1394,25 @@ export async function startDesktopHost({ input = process.stdin, output = process
1339
1394
  await authorizeWork(commandScope);
1340
1395
  const result = await deadlines.decide(context, { ...command, requestId: command.requestId || command.decisionId });
1341
1396
  if (result.status === 'conflict') throw Object.assign(new Error('Deadline changed; refresh before deciding.'), { code: 'conflict' });
1397
+ } else if (action === "session_archive" || action === "session_restore") {
1398
+ const candidate = await selectedSession(command, { preferObserved: true });
1399
+ await authorizeWork(commandScope);
1400
+ if (!['local', 'provider'].includes(command.scope)) throw new Error('Choose local or provider archive explicitly.');
1401
+ const archived = action === 'session_archive';
1402
+ if (command.scope === 'provider') {
1403
+ if (command.provider !== 'codex') throw new Error('Archive or restore this conversation in Claude, then refresh SIMY.');
1404
+ const client = await (dependencies.openCodexAppServer || openCodexAppServer)();
1405
+ try { await client.request(archived ? 'thread/archive' : 'thread/unarchive', { threadId: command.session });
1406
+ if (archived) await recordProviderArchive(deliveryRoot, command.provider, command.session, true, Date.now());
1407
+ }
1408
+ finally { await client.close(); }
1409
+ } else {
1410
+ await updateRecord(deliveryRoot, command.provider, command.session, previous => ({
1411
+ ...(previous || { schemaVersion: 1, provider: command.provider, session: command.session, cwd: candidate.cwd, observedAt: Date.now() }),
1412
+ listHidden: archived,
1413
+ }));
1414
+ }
1415
+ refreshSessions(true);
1342
1416
  } else if (action === "session_track" || action === "session_untrack") {
1343
1417
  const candidate = await selectedSession(command);
1344
1418
  if (candidate.capabilities?.canMonitor !== true) throw new Error('Tracking is unavailable for this conversation.');
@@ -1439,7 +1513,7 @@ function associationActionError(error) {
1439
1513
  return 'The Action service is unavailable. Your conversation continues with its existing settings.';
1440
1514
  }
1441
1515
 
1442
- function sessionActionError(error) {
1516
+ function sessionActionError(error, action) {
1443
1517
  if (error instanceof AutoContinueUnavailableError) return error.message.replace(/[\r\n\x00-\x1f]/g, ' ').slice(0, 600);
1444
1518
  const message = String(error?.message || "");
1445
1519
  if (/already has a Delivery Loop|budgets cannot be reset/i.test(message)) return "This conversation already has a task. Open its existing supervision instead.";
@@ -1447,6 +1521,7 @@ function sessionActionError(error) {
1447
1521
  if (/workspace changed|metadata.*unavailable|session.*changed/i.test(message)) return "The conversation identity or workspace changed. Refresh and select it again.";
1448
1522
  if (/objective|acceptance|autonomy policy|budget/i.test(message)) return message.replace(/[\r\n\x00-\x1f]/g, " ").slice(0, 240);
1449
1523
  if (/question changed|answer is empty|session understanding|context/i.test(message)) return message.replace(/[\r\n\x00-\x1f]/g, " ").slice(0, 240);
1524
+ if (action === "session_answer") return "Answer acceptance is unconfirmed. Check the original conversation before sending again.";
1450
1525
  return "This conversation could not be enrolled. Refresh its provider status and try again.";
1451
1526
  }
1452
1527
 
@@ -231,7 +231,7 @@ export function isUnauthorizedError(error) {
231
231
 
232
232
  export function canStopDesktopTask(task, registryIndex) {
233
233
  const state = task?.status || task?.snapshot?.state || task?.state || task?.control_state || task?.controlState || "unknown";
234
- return registryIndex === 0 ? !INACTIVE_STATES.has(state) : ["queued", "running", "stopping"].includes(state);
234
+ return registryIndex === 0 ? !INACTIVE_STATES.has(state) : ["queued", "running", "stopping"].includes(state) || registryIndex === 1 && state === "recovery_required" && Boolean(task?.worker_handle);
235
235
  }
236
236
 
237
237
  function taskStatusItem(task, registryIndex) {
@@ -1,4 +1,10 @@
1
+ import { atomicSave, digest } from './update-detachment/state-store.js';
2
+ import { join } from 'node:path';
3
+ import { captureDispatchProvenance } from './update-detachment/provenance.js';
1
4
  import { signalExecutorProcess } from "./runner.js";
5
+ import { createExecutionWorker } from "./update-detachment/worker.js";
6
+ import path from "node:path";
7
+ import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, observeDispatchEvent, queueDispatchReceipt } from './update-detachment/dispatch-gate.js';
2
8
  import { randomUUID } from "node:crypto";
3
9
  import { withSceneReasoning } from "./codex-plugins/simy/hooks/scene-reasoning.mjs";
4
10
 
@@ -58,10 +64,15 @@ export function createDirectTask({
58
64
 
59
65
  export async function startDirectTask(
60
66
  task,
61
- { resolveCommand = resolveDesktopExecutorCommand, environment = process.env } = {},
67
+ { resolveCommand = resolveDesktopExecutorCommand, environment = process.env, session, apiOrigin, sessionRoot, worker = null, workerBinding = null, deadlineAt = null, workerPolicy = null } = {},
62
68
  ) {
63
69
  if (!task || task.status !== "queued") throw new Error("Direct task must be queued.");
64
70
  if (task.invocation_count >= 1) throw new Error("Direct tasks allow only one Provider invocation.");
71
+ const dispatchOwner = captureDispatchOwner(workerBinding?.root || sessionRoot);
72
+ const dispatchAttemptId = `${task.id}:provider:1`;
73
+ const recordRef = join(dispatchOwner.root, 'direct-tasks', `${digest(task.id)}.json`);
74
+ const provenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: task.backend, worktree: task.repository_path, accountSession: session, origin: apiOrigin, taskId: task.id, recordRef });
75
+ assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance });
65
76
  const command = await resolveCommand({
66
77
  backend: task.backend,
67
78
  instruction: withSceneReasoning("Complete only the requested bounded direct task in this invocation. Respect the assigned permission mode; do not create an automatic retry loop or expand the task scope.", task.instruction),
@@ -75,17 +86,44 @@ export async function startDirectTask(
75
86
  task.invocation_count = 1;
76
87
  task.updated_at = new Date().toISOString();
77
88
 
89
+ if (worker || (resolveCommand === resolveDesktopExecutorCommand && process.platform !== "win32")) {
90
+ const owner = worker || createExecutionWorker();
91
+ const identity = owner.identity || workerBinding || {
92
+ provider: task.backend, session: task.id,
93
+ // Standalone direct invocations inherit the existing account environment.
94
+ // This is not a verified existing app-server/live-hook account binding.
95
+ account: "inherited-provider-environment", origin: "simy-direct",
96
+ worktree: path.resolve(task.repository_path),
97
+ };
98
+ task.worker = owner;
99
+ task.worker_handle = { attemptId: task.id, identity };
100
+ const originalDeadline = deadlineAt ?? null;
101
+ task.worker_start = owner.start({ ...task.worker_handle, command, cwd: task.repository_path, backend: task.backend,
102
+ deadlineAt: originalDeadline, taskId: task.id,
103
+ preservation: { taskState: "active", metadata: directTaskSnapshot(task), policy: workerPolicy || { model: "provider-default", permissions: task.permission_mode, approvals: { operation: task.operation, maxProviderInvocations: 1 }, budget: null, deadline: originalDeadline }, artifacts: [] } });
104
+ try { task.worker_handle = { ...task.worker_handle, ...await task.worker_start }; }
105
+ catch (error) {
106
+ if (error.workerHandle) task.worker_handle = error.workerHandle;
107
+ task.status = "recovery_required";
108
+ task.error = "Worker launch outcome could not be confirmed. Reconcile saved evidence before retrying.";
109
+ return task;
110
+ }
111
+ if (task.stop_requested) await owner.stop(task.worker_handle);
112
+ return await reconcileDirectWorker(task);
113
+ }
114
+
115
+ atomicSave(recordRef, { schemaVersion: 1, revision: 0, ...directTaskSnapshot(task) });
78
116
  return new Promise((resolve) => {
79
117
  let settled = false;
80
118
  let terminalError = null;
81
119
  const rawStdout = [];
82
120
  const tokenRecords = [];
83
- const child = command.spawn(command.bin, command.args, {
121
+ const child = dispatchWithFence(dispatchOwner, { taskId: task.id, attemptId: dispatchAttemptId, provenance }, () => command.spawn(command.bin, command.args, {
84
122
  cwd: task.repository_path,
85
123
  env: { ...process.env, ...command.env },
86
124
  stdio: ["ignore", "pipe", "pipe"],
87
125
  detached: process.platform !== "win32",
88
- });
126
+ }));
89
127
  task.child = child;
90
128
  const onLine = (line) => {
91
129
  const normalized = String(line || "").trim();
@@ -99,6 +137,7 @@ export async function startDirectTask(
99
137
  const stdoutDecoder = createProviderStreamDecoder({
100
138
  backend: task.backend,
101
139
  stream: "stdout",
140
+ onEvent: event => { const identity = observeDispatchEvent({ root: dispatchOwner.root, attemptId: dispatchAttemptId, provider: task.backend, event, onError: error => { task.dispatch_recovery_required = error.code || 'provenance_persistence_failed'; } }); if (identity) task.dispatch_identity = identity; },
102
141
  onLine,
103
142
  onTerminalError: (message) => { terminalError ||= message; },
104
143
  onUsage: (usage) => tokenRecords.push({ ...usage, phase: "direct", backend: task.backend }),
@@ -127,6 +166,13 @@ export async function startDirectTask(
127
166
  task.error = task.stop_requested ? null : error || (exitCode !== 0 ? `Provider exited with code ${exitCode}.` : null);
128
167
  task.result = task.output.at(-1) || rawStdout.join("").trim() || null;
129
168
  task.updated_at = new Date().toISOString();
169
+ try {
170
+ atomicSave(recordRef, { schemaVersion: 1, revision: 1, ...directTaskSnapshot(task), ...(task.dispatch_recovery_required ? { dispatch_recovery_required: task.dispatch_recovery_required } : {}), ...(task.dispatch_identity ? { dispatch_identity: task.dispatch_identity } : {}) });
171
+ queueDispatchReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, receiptId: `result:${dispatchAttemptId}`, resultRef: recordRef });
172
+ } catch (error) {
173
+ task.dispatch_recovery_required = error.code || 'provenance_persistence_failed';
174
+ try { atomicSave(recordRef, { schemaVersion: 1, revision: 1, ...directTaskSnapshot(task), ...(task.dispatch_identity ? { dispatch_identity: task.dispatch_identity } : {}) }); } catch {}
175
+ }
130
176
  resolve(task);
131
177
  };
132
178
  child.on("error", (error) => finish({ error: error.message }));
@@ -137,6 +183,7 @@ export async function startDirectTask(
137
183
  export function directTaskSnapshot(task) {
138
184
  return {
139
185
  id: task.id,
186
+ ...(task.dispatch_recovery_required ? { dispatch_recovery_required: task.dispatch_recovery_required } : {}),
140
187
  status: task.status,
141
188
  instruction: task.instruction,
142
189
  backend: task.backend,
@@ -153,15 +200,50 @@ export function directTaskSnapshot(task) {
153
200
  };
154
201
  }
155
202
 
203
+ // Read-only reconnect: never starts a second Provider invocation.
204
+ export async function reconcileDirectWorker(task, { worker = task.worker || createExecutionWorker() } = {}) {
205
+ while (true) {
206
+ if (task.observation_suspended) return task;
207
+ const state = await worker.result(task.worker_handle);
208
+ if (task.observation_suspended) return task;
209
+ if (state.state === "completed") {
210
+ const receipt = state.receipt;
211
+ Object.assign(task, {
212
+ status: receipt.status, output: receipt.output, result: receipt.result,
213
+ error: receipt.error, token_usage: receipt.token_usage,
214
+ updated_at: new Date(receipt.finishedAt).toISOString(),
215
+ });
216
+ return task;
217
+ }
218
+ if (state.state === "recovery_required") {
219
+ task.status = "recovery_required";
220
+ task.error = state.storageError
221
+ ? "Worker receipt storage failed. The confirmed owner remains stoppable; review saved evidence before retrying."
222
+ : "Worker owner could not be confirmed. Review saved evidence before retrying.";
223
+ return task;
224
+ }
225
+ task.output = state.output || task.output;
226
+ task.token_usage = state.token_usage || task.token_usage;
227
+ task.updated_at = new Date().toISOString();
228
+ await new Promise((resolve) => setTimeout(resolve, 100));
229
+ }
230
+ }
231
+
156
232
  // Only the child captured by this task may be signalled, including its owned tools.
157
233
  export function stopDirectTask(task) {
158
234
  if (!task) return Promise.resolve(task);
159
235
  return task.stopPromise ||= stopOwnedDirectTask(task);
160
236
  }
161
237
  async function stopOwnedDirectTask(task) {
162
- if (!task || !["queued", "running", "stopping"].includes(task.status)) return task;
238
+ if (!task || (!["queued", "running", "stopping"].includes(task.status) && !(task.status === "recovery_required" && task.worker_handle))) return task;
163
239
  task.stop_requested = true;
164
240
  task.status = "stopping";
241
+ if (task.worker_handle) {
242
+ // Launch uncertainty does not prevent stopping a separately confirmed owner.
243
+ await task.worker_start?.catch(() => {});
244
+ await task.worker.stop(task.worker_handle);
245
+ return reconcileDirectWorker(task);
246
+ }
165
247
  const child = task.child;
166
248
  if (!child) { task.status = "stopped"; task.updated_at = new Date().toISOString(); return task; }
167
249
  for (const signal of ["SIGINT", "SIGTERM", "SIGKILL"]) {
@@ -176,3 +258,21 @@ async function stopOwnedDirectTask(task) {
176
258
  if (task.child === child) throw new Error("The local task did not stop. Try quitting SIMY.");
177
259
  return task;
178
260
  }
261
+
262
+ // The host reconstructs its registry from the registered preservation record,
263
+ // never from a new Provider dispatch or a user-supplied unbound task ID.
264
+ export async function restoreDirectWorkerTask({ worker, handle, registry }) {
265
+ const state = await worker.adopt(handle);
266
+ const snapshot = worker.readSnapshot(handle);
267
+ if (snapshot?.task?.id !== handle.taskId || snapshot.metadata?.id !== handle.taskId) throw new Error("Direct worker task snapshot unavailable.");
268
+ let task = registry.get(handle.taskId);
269
+ if (!task) task = registry.create({ ...structuredClone(snapshot.metadata), repository_path: handle.identity.worktree,
270
+ worker, worker_handle: handle, child: null, invocation_count: 1,
271
+ stop_requested: snapshot.task.stopRequested || snapshot.task.state === "stopped" || state.state === "stopping" });
272
+ if (state.state === "completed") return reconcileDirectWorker(task, { worker });
273
+ task.stop_requested ||= snapshot.task.stopRequested || state.state === "stopping";
274
+ task.status = task.stop_requested ? "stopping" : state.state === "running" ? "running" : "recovery_required";
275
+ task.output = state.output || task.output;
276
+ task.token_usage = state.token_usage || task.token_usage;
277
+ return task;
278
+ }