@awak-app/simy-cli 0.7.11 → 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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/agent.js +47 -7
  3. package/src/auto-update.js +32 -9
  4. package/src/cli-contract.js +1 -1
  5. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +108 -11
  6. package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +326 -0
  7. package/src/codex-plugins/simy/hooks/update-dispatch-identity.mjs +78 -0
  8. package/src/codex-plugins/simy/hooks/update-state-store.mjs +73 -0
  9. package/src/delivery-hooks/supervisor.js +39 -10
  10. package/src/desktop-host.js +54 -8
  11. package/src/desktop-status.js +1 -1
  12. package/src/direct-executor.js +104 -4
  13. package/src/local-task.js +14 -3
  14. package/src/runner.js +19 -4
  15. package/src/session-supervision/claude-sessions.js +57 -5
  16. package/src/session-supervision/codex-app-server.js +20 -8
  17. package/src/sqm/claude-plugin.js +5 -0
  18. package/src/sqm/review-worker.js +15 -6
  19. package/src/update-detachment/claude-observation.js +33 -0
  20. package/src/update-detachment/codex-context.js +27 -0
  21. package/src/update-detachment/coordinator.js +117 -0
  22. package/src/update-detachment/dispatch-gate.js +1 -0
  23. package/src/update-detachment/ledger-adapter.js +62 -0
  24. package/src/update-detachment/ledger-reader.js +42 -0
  25. package/src/update-detachment/provenance.js +1 -0
  26. package/src/update-detachment/provider-adapters.js +33 -0
  27. package/src/update-detachment/reconcile.js +105 -0
  28. package/src/update-detachment/reconciliation-adapter.js +13 -0
  29. package/src/update-detachment/runtime.js +319 -0
  30. package/src/update-detachment/state-store.js +1 -0
  31. package/src/update-detachment/worker-entry.js +145 -0
  32. package/src/update-detachment/worker-ledger-runtime.js +34 -0
  33. package/src/update-detachment/worker-ledger.js +138 -0
  34. package/src/update-detachment/worker-storage.js +29 -0
  35. package/src/update-detachment/worker.js +167 -0
@@ -1,3 +1,7 @@
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";
1
5
  import { sessionWorkState, compareSessionWork, reconcileFamilyCompletion, expireSessionWorkState } from './session-supervision/work-state.js';
2
6
  import { createPullRequestObserver } from './session-supervision/pull-requests.js';
3
7
  import { sessionDecisionPresentation } from './session-supervision/decision-presentation.js';
@@ -194,6 +198,15 @@ export async function startDesktopHost({ input = process.stdin, output = process
194
198
  };
195
199
  let agent, updater, analysisManager, automaticContinuation, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false, scheduledPublish = null;
196
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(()=>{}));} });
197
210
  let actionAssociations = makeActionAssociations();
198
211
  const statisticsCache = new Map();
199
212
  let activeStatisticsRead = null;
@@ -436,7 +449,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
436
449
  if (controller.signal.aborted || !desktopAccessReady(afterObservation) || accessScope(afterObservation) !== requestedScope) {
437
450
  await analysisManager.update([]); sessionRefreshAgain = desktopAccessReady(afterObservation); return;
438
451
  }
439
- await analysisManager.update(analysisSessions(sessions));
452
+ if (coordinator.acceptsNewWork()) await analysisManager.update(analysisSessions(sessions));
440
453
  if (controller.signal.aborted) return;
441
454
  sessionStatus = { ...value, sessions, initialLoading: false }; sessionRefreshedAt = Date.now(); })
442
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(); })
@@ -619,6 +632,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
619
632
  const sampledAccess = structuredClone(status.access || { state: 'unavailable', organizationId: null, organizationName: null });
620
633
  if (!await acceptStatusAccess(sampledAccess)) { publishAgain = true; return; }
621
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')};
622
637
  const requestedLinks = await decorateProviderSettings(status);
623
638
  const sameScope = desktopAccessReady(currentAccess) &&
624
639
  accessScope(lastStatus?.access) === accessScope(currentAccess);
@@ -667,6 +682,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
667
682
  const sampledAccess = structuredClone(status.access || { state: 'unavailable', organizationId: null, organizationName: null });
668
683
  if (!await acceptStatusAccess(sampledAccess)) { publishAgain = true; return; }
669
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')};
670
687
  const requestedScope = accessScope(currentAccess);
671
688
  const requestedLinks = await decorateProviderSettings(status);
672
689
  const allRecords = await recordReader.list();
@@ -709,7 +726,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
709
726
  status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: projection?.records || records }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
710
727
  await actionAssociations.decorate(status.deliveryHooks, projection);
711
728
  const discovery = refreshSessions();
712
- automaticContinuation?.tick(discovery.sessions);
729
+ if (coordinator.acceptsNewWork()) automaticContinuation?.tick(discovery.sessions);
713
730
  const sessions = discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate)));
714
731
  await actionAssociations.decorateSessions?.(sessions, projection);
715
732
  const stateRunsBySession = new Map((projection?.records || records).filter(record => record.run).map(record => [`${record.provider}:${record.session}`, record.run]));
@@ -791,6 +808,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
791
808
  })();
792
809
 
793
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();
794
814
  analysisManager = await makeAnalysisManager();
795
815
  automaticContinuation = makeAutomaticContinuation();
796
816
  if (!candidate) {
@@ -803,6 +823,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
803
823
  dependencies: { packageInfo: updatePackageRoot ? await readCliPackageInfo(updatePackageRoot) : packageInfo,
804
824
  logger: message => diagnostics.write(`update: ${message}`) },
805
825
  });
826
+ updater.setCoordinator?.(coordinator);
806
827
  agent = await (dependencies.startAgent || startAgent)({
807
828
  daemon: true, webOrigin, sessionRoot: root, quiet: true, updateManager: updater,
808
829
  deferStartupSynchronization: true, enforceDesktopAccess: true,
@@ -812,9 +833,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
812
833
  setImmediate(() => { void publish().catch(() => {}); });
813
834
  },
814
835
  });
836
+ if (env.SIMY_UPDATE_TRANSFER_TOKEN) await coordinator.reconnect();
815
837
  stopWorkflows = (dependencies.startWorkflowUpdates || startWorkflowUpdates)({ enabled: env.SIMY_AUTO_UPDATE !== "0" });
816
838
  updater.on?.("change", () => { void publish().catch(() => {}); });
817
- updater.start({ registry: agent.registry, isDesktopIdle: () => !unfinishedDesktopWork,
839
+ updater.start({ registry: agent.registry, coordinator, isDesktopIdle: () => !unfinishedDesktopWork,
818
840
  restartDaemon: async (targetVersion, installation) => {
819
841
  if (!installation?.entryPath || !installation?.executablePath) throw new Error("Signed update runtime is unavailable.");
820
842
  discoveryStopped = true;
@@ -826,12 +848,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
826
848
  // The candidate also shares association records and their sync outbox.
827
849
  // Drain the old writer before starting its replacement.
828
850
  await actionAssociations.close();
851
+ const transferToken = coordinator.snapshot().state === 'detached' ? await updateRuntime.prepareHandoff() : null;
829
852
  let child;
830
853
  try { child = (dependencies.spawn || spawn)(installation.executablePath,
831
854
  [join(dirname(installation.entryPath), "desktop-host.js")], {
832
855
  stdio: ["pipe", "pipe", "pipe"],
833
- env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken },
856
+ env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken, ...(transferToken ? {SIMY_UPDATE_TRANSFER_TOKEN:transferToken} : {}) },
834
857
  }); } catch (error) {
858
+ if(transferToken)await updateRuntime.reclaimFailedHandoff();
835
859
  actionAssociations = makeActionAssociations();
836
860
  analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); discoveryStopped = false; refreshSessions(true); throw error;
837
861
  }
@@ -869,7 +893,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
869
893
  let event; try { event = JSON.parse(line); } catch { return; }
870
894
  if (accepted) { emit(event); return; }
871
895
  if (event.type === "status") {
872
- 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)) {
873
897
  finish(reject, new Error("Updated desktop runtime identity mismatch."));
874
898
  } else { accepted = true; finish(accept, event); }
875
899
  }
@@ -879,8 +903,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
879
903
  await stopAgent();
880
904
  if (child.exitCode !== null || closed) throw new Error("Updated runtime stopped during handoff.");
881
905
  emit(first);
906
+ return {connectionTransferred:Boolean(transferToken)};
882
907
  } catch (error) {
883
908
  lines.close(); await stopUpdateCandidate(child);
909
+ if(transferToken)await updateRuntime.reclaimFailedHandoff();
884
910
  if (!closed && !relay) {
885
911
  actionAssociations = makeActionAssociations();
886
912
  analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); discoveryStopped = false; refreshSessions(true);
@@ -892,7 +918,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
892
918
  await publish();
893
919
  const maintenance = new Map();
894
920
  const tick = worker => {
895
- if (closed || relay || maintenance.has(worker)) return;
921
+ if (closed || relay || !coordinator.acceptsNewWork() || maintenance.has(worker)) return;
896
922
  const pending = Promise.resolve().then(() => worker.tick()).then(() => { if (!closed && !relay) return publish(); }).catch(() => {}).finally(() => maintenance.delete(worker));
897
923
  maintenance.set(worker, pending);
898
924
  };
@@ -999,6 +1025,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
999
1025
  if (!relay.stdin.destroyed && relay.exitCode === null) relay.stdin.write(`${JSON.stringify(command)}\n`);
1000
1026
  return;
1001
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
+ }
1002
1033
  // Read-only activity must never hold the ordered control queue. Share an
1003
1034
  // identical pending selection and cancel superseded selections immediately.
1004
1035
  let statisticsRequest, requestedStatisticsSelection;
@@ -1014,6 +1045,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
1014
1045
  if (closed) return;
1015
1046
  try {
1016
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
+ }
1017
1056
  if (action === "quit") {
1018
1057
  if (command.forUpdate === true) {
1019
1058
  if (command.force !== true) throw new Error('Confirm before stopping unfinished tasks.');
@@ -1048,6 +1087,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1048
1087
  if ((status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0 ||
1049
1088
  hasUnfinishedSessions(await recordReader.list())) throw new Error('Confirm before stopping tasks to restart.');
1050
1089
  }
1090
+ if (!coordinator.acceptsNewWork()) throw new Error('Reconcile the update connection before starting another update.');
1051
1091
  await executeUpdate(action === 'prepare_update_restart');
1052
1092
  emit({ type: 'result', id: command.id, ok: true });
1053
1093
  void publish().catch(() => {}); return;
@@ -1055,8 +1095,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
1055
1095
  const access = await accessSnapshot();
1056
1096
  const commandScope = accessScope(access);
1057
1097
  if (!desktopActionAllowed(access, command)) throw Object.assign(new Error('Desktop access is required.'), { code: 'desktop_access_required' });
1058
- 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();
1059
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
+ }
1060
1106
  let commandProviderLink;
1061
1107
  if (command.provider && !["provider_connect", "provider_disconnect", "delivery_stop", "delivery_pause", "delivery_reject", "session_untrack", "session_unmonitor"].includes(action) &&
1062
1108
  !(action === "session_set_auto_continue" && command.enabled === false)) {
@@ -1137,7 +1183,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1137
1183
  await agent.refreshDesktopStatus();
1138
1184
  await refreshPlugins();
1139
1185
  refreshSessions(true);
1140
- if (desktopAccessReady(currentAccess)) void actionAssociations.tick().then(publish).catch(() => {});
1186
+ if (desktopAccessReady(currentAccess) && coordinator.acceptsNewWork()) void actionAssociations.tick().then(publish).catch(() => {});
1141
1187
  }
1142
1188
  else if (action === "organizations_refresh") await agent.refreshOrganizations();
1143
1189
  else if (action === "sqm_set_enabled") await agent.setSqmEnabled(command.enabled);
@@ -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
+ }
package/src/local-task.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { captureDispatchProvenance } from './update-detachment/provenance.js';
2
+ import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, observeDispatchEvent, queueDispatchReceipt } from './update-detachment/dispatch-gate.js';
1
3
  import { normalizePurposeVerification } from "./purpose-local-proof.js";
2
4
  import { buildWorkflowReportingInstruction } from "./workflow-reporting.js";
3
5
  import { execFile } from "node:child_process";
@@ -449,12 +451,17 @@ export async function startLocalTask(
449
451
  beforeSpawn = null,
450
452
  session = null,
451
453
  apiOrigin = null,
454
+ sessionRoot,
452
455
  observationFetch = globalThis.fetch,
453
456
  deliverableUploadSleep = undefined,
454
457
  } = {},
455
458
  ) {
456
459
  if (!task || task.status !== "queued") throw new Error("Local Task must be queued.");
457
460
  if (task.invocation_count >= 1) throw new Error("Local Tasks allow only one Provider invocation.");
461
+ const dispatchOwner = captureDispatchOwner(sessionRoot);
462
+ const dispatchAttemptId = `${task.id}:provider:1`;
463
+ const provenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: task.backend, worktree: task.workspace_path, accountSession: session || undefined, origin: apiOrigin || undefined, taskId: task.id, recordRef: path.join(task.task_root, 'task.json') });
464
+ assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance });
458
465
  updateTask(task, {
459
466
  control_state: "starting",
460
467
  invocation_count: 1,
@@ -573,7 +580,7 @@ export async function startLocalTask(
573
580
  const tokenRecords = [];
574
581
  let child;
575
582
  try {
576
- child = command.spawn(command.bin, command.args, {
583
+ child = dispatchWithFence(dispatchOwner, { taskId: task.id, attemptId: dispatchAttemptId, provenance }, () => command.spawn(command.bin, command.args, {
577
584
  cwd: task.workspace_path,
578
585
  env: {
579
586
  ...process.env,
@@ -586,7 +593,7 @@ export async function startLocalTask(
586
593
  },
587
594
  stdio: ["ignore", "pipe", "pipe"],
588
595
  detached: process.platform !== "win32",
589
- });
596
+ }));
590
597
  } catch (error) {
591
598
  void rm(runtimeTempRoot, { recursive: true, force: true }).then(
592
599
  () => reject(error),
@@ -631,7 +638,7 @@ export async function startLocalTask(
631
638
  stream: "stdout",
632
639
  onLine,
633
640
  onUsage,
634
- onEvent: (event) => observationSender?.observe(event),
641
+ 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; observationSender?.observe(event); },
635
642
  onTerminalError: (message) => {
636
643
  task.error ||= message;
637
644
  task.reason_code ||= "provider_execution_failed";
@@ -786,6 +793,8 @@ export async function startLocalTask(
786
793
  : task.error || "Local Task failed.",
787
794
  });
788
795
  await persist(task);
796
+ try { queueDispatchReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, receiptId: `result:${dispatchAttemptId}`, resultRef: provenance.recordRef }); }
797
+ catch (error) { task.dispatch_recovery_required = error.code || 'provenance_persistence_failed'; try { await persist(task); } catch {} }
789
798
  resolve(task);
790
799
  };
791
800
  child.on("error", (spawnError) => void finish({ error: spawnError.message }));
@@ -831,6 +840,8 @@ export async function stopLocalTask(task, { persist = persistLocalTask } = {}) {
831
840
  export function localTaskSnapshot(task, { includeEvents = true, includeLocalPaths = false } = {}) {
832
841
  return {
833
842
  id: task.id,
843
+ ...(task.dispatch_recovery_required ? { dispatch_recovery_required: task.dispatch_recovery_required } : {}),
844
+ ...(includeLocalPaths && task.dispatch_identity ? { dispatch_identity: task.dispatch_identity } : {}),
834
845
  kind: "local_task",
835
846
  status: task.status,
836
847
  control_state: task.control_state,
package/src/runner.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { captureDispatchProvenance } from './update-detachment/provenance.js';
2
+ import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, observeDispatchEvent, queueDispatchReceipt } from './update-detachment/dispatch-gate.js';
1
3
  import { execFile, execFileSync } from "node:child_process";
2
4
  import { randomUUID } from "node:crypto";
3
5
  import { EventEmitter, once } from "node:events";
@@ -1060,7 +1062,11 @@ export async function executeProcessAttempt({
1060
1062
  onExecutionComplete = null,
1061
1063
  readOnly = false,
1062
1064
  }) {
1065
+ const dispatchOwner = captureDispatchOwner(run.sqmHistoryRoot || (run.localStateRoot ? path.dirname(run.localStateRoot) : undefined));
1063
1066
  const phase = marker === "SIMY_AUDIT_JSON:" ? "reviewer" : "executor";
1067
+ const dispatchAttemptId = `${run.snapshot.id}:${phase}:${attemptNumber ?? run.snapshot.attempts?.length ?? 0}`;
1068
+ const provenance = captureDispatchProvenance({ root: dispatchOwner.root, provider: backend, worktree: repositoryPath, accountSession: run.session, origin: run.apiOrigin || undefined, taskId: run.snapshot.id, recordRef: run.localStateRoot ? path.join(run.localStateRoot, `${run.snapshot.id}.json`) : `coding-run:${run.snapshot.id}` });
1069
+ assertTaskAdmission(dispatchOwner, { taskId: provenance.taskId, provenance });
1064
1070
  const command = await resolveCommandImpl({
1065
1071
  backend,
1066
1072
  instruction,
@@ -1094,7 +1100,7 @@ export async function executeProcessAttempt({
1094
1100
  let terminalError = null;
1095
1101
  let terminalErrorCode = null;
1096
1102
  const checkpointWrites = [];
1097
- const child = command.spawn(command.bin, command.args, {
1103
+ const child = dispatchWithFence(dispatchOwner, { taskId: run.snapshot.id, attemptId: dispatchAttemptId, provenance }, () => command.spawn(command.bin, command.args, {
1098
1104
  cwd: repositoryPath,
1099
1105
  env: {
1100
1106
  ...process.env,
@@ -1104,14 +1110,14 @@ export async function executeProcessAttempt({
1104
1110
  },
1105
1111
  stdio: ["ignore", "pipe", "pipe"],
1106
1112
  detached: process.platform !== "win32",
1107
- });
1113
+ }));
1108
1114
  run.child = child;
1109
1115
 
1110
1116
  const stdoutDecoder = createProviderStreamDecoder({
1111
1117
  backend,
1112
1118
  stream: "stdout",
1113
1119
  onLine: (line) => emitOutput(run, line, { backend }),
1114
- onEvent: (event) => observationSender?.observe(event),
1120
+ onEvent: (event) => { const identity = observeDispatchEvent({ root: dispatchOwner.root, attemptId: dispatchAttemptId, provider: backend, event, onError: error => { run.snapshot.metadata.dispatch_recovery_required = error.code || 'provenance_persistence_failed'; } }); if (identity) { run.snapshot.metadata.dispatch_identities ||= {}; run.snapshot.metadata.dispatch_identities[dispatchAttemptId] = identity; } observationSender?.observe(event); },
1115
1121
  onTerminalError: (message) => { terminalError ||= message; },
1116
1122
  onUsage: (usage) => {
1117
1123
  tokenUsageRecords.push({
@@ -1183,7 +1189,16 @@ export async function executeProcessAttempt({
1183
1189
  startedAt,
1184
1190
  finishedAt: new Date().toISOString(),
1185
1191
  };
1186
- try { await onExecutionComplete?.(execution); }
1192
+ try {
1193
+ if (run.localStateRoot) {
1194
+ run.snapshot.metadata.dispatch_results ||= {};
1195
+ run.snapshot.metadata.dispatch_results[dispatchAttemptId] = execution;
1196
+ await saveCodingRunSnapshot(run.localStateRoot, run.snapshot);
1197
+ try { queueDispatchReceipt({ root: dispatchOwner.root, attemptId: dispatchAttemptId, receiptId: `result:${dispatchAttemptId}`, resultRef: provenance.recordRef }); }
1198
+ catch (error) { run.snapshot.metadata.dispatch_recovery_required = error.code || 'provenance_persistence_failed'; await saveCodingRunSnapshot(run.localStateRoot, run.snapshot); }
1199
+ }
1200
+ await onExecutionComplete?.(execution);
1201
+ }
1187
1202
  catch (error) { reject(error); return; }
1188
1203
  // The run must always resolve: a WorkFlow observation failure (or a
1189
1204
  // sender bug) never changes or blocks the Provider result. flush() is
@@ -2,6 +2,7 @@ import { statisticsFromHandle, unavailableStatistics } from './statistics.js';
2
2
  import { constants } from 'node:fs';
3
3
  import { lstat, open, readdir, realpath } from 'node:fs/promises';
4
4
  import { homedir } from 'node:os';
5
+ import { createHash } from 'node:crypto';
5
6
  import { isAbsolute, join, relative, resolve, sep } from 'node:path';
6
7
  import { boundConversation, CONVERSATION_LIMITS, textContent } from './conversation-context.js';
7
8
  import { sessionTitle } from './session-titles.js';
@@ -101,7 +102,7 @@ async function latestCustomTitle(handle, size, id) {
101
102
  return title;
102
103
  }
103
104
 
104
- async function inspectTranscript(root, projectName, fileName, observedAt, includeContext = false, cacheMetadata = false, includeStatistics = false, signal) {
105
+ async function inspectTranscript(root, projectName, fileName, observedAt, includeContext = false, cacheMetadata = false, includeStatistics = false, signal, includeBoundary = false) {
105
106
  signal?.throwIfAborted();
106
107
  const id = fileName.slice(0, -'.jsonl'.length);
107
108
  if (!UUID.test(id)) return null;
@@ -147,12 +148,17 @@ async function inspectTranscript(root, projectName, fileName, observedAt, includ
147
148
  signal?.throwIfAborted();
148
149
  if (includeStatistics) candidate.statistics = await statisticsFromHandle(handle, opened, 'claude', id, { signal });
149
150
  if (includeContext) candidate.context = await conversationFromHandle(handle, opened, id, metadata.cwd);
151
+ if (includeBoundary) {
152
+ candidate.boundary = await boundaryFromHandle(handle, opened, id, metadata.cwd, signal);
153
+ const after = await lstat(path);
154
+ if (!after.isFile() || after.isSymbolicLink() || after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) return null;
155
+ }
150
156
  return candidate;
151
157
  } catch (error) { metadataCache.delete(path); if (signal?.aborted) throw error; return null; }
152
158
  finally { await handle?.close(); }
153
159
  }
154
160
 
155
- async function collect({ home = homedir(), limit = 100, session = null, prioritySessions = [], includeContext = false, cacheMetadata = false, includeStatistics = false, signal } = {}) {
161
+ async function collect({ home = homedir(), limit = 100, session = null, prioritySessions = [], includeContext = false, cacheMetadata = false, includeStatistics = false, includeBoundary = false, signal } = {}) {
156
162
  if (signal?.aborted) return [];
157
163
  const root = await projectsRoot(home);
158
164
  if (!root) return [];
@@ -203,13 +209,13 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
203
209
  let nextFile = 0;
204
210
  // Catalog prefixes are bounded independently of full transcript size. Use a
205
211
  // small I/O pool; exact-context/statistics reads stay serial and identity-safe.
206
- await Promise.all(Array.from({ length: Math.min(includeContext || includeStatistics ? 1 : 8, filesToInspect.length) }, async () => {
212
+ await Promise.all(Array.from({ length: Math.min(includeContext || includeStatistics || includeBoundary ? 1 : 8, filesToInspect.length) }, async () => {
207
213
  while (nextFile < filesToInspect.length && !signal?.aborted) {
208
214
  const file = filesToInspect[nextFile++];
209
- let candidate = await inspectTranscript(root, file.projectName, file.fileName, observedAt, includeContext, cacheMetadata, includeStatistics, signal);
215
+ let candidate = await inspectTranscript(root, file.projectName, file.fileName, observedAt, includeContext, cacheMetadata, includeStatistics, signal, includeBoundary);
210
216
  if (!candidate) continue;
211
217
  candidate = decorateLineage(candidate);
212
- const key = `${candidate.session}\0${candidate.cwd}${includeContext || includeStatistics ? `\0${file.projectName}\0${file.fileName}` : ''}`;
218
+ const key = `${candidate.session}\0${candidate.cwd}${includeContext || includeStatistics || includeBoundary ? `\0${file.projectName}\0${file.fileName}` : ''}`;
213
219
  if (!result.has(key) || candidate.lastActivityAt > result.get(key).lastActivityAt) result.set(key, candidate);
214
220
  }
215
221
  }));
@@ -305,6 +311,52 @@ export async function readClaudeConversation(candidate, { home, signal } = {}) {
305
311
  return { ...selected[0].context, awaitingTurn: candidate.isRunning === true };
306
312
  }
307
313
 
314
+ // Update recovery needs coverage of the complete saved transcript, not the
315
+ // deliberately shortened text used for presentation. Stream and validate every
316
+ // envelope while retaining only a digest and counters, never message prose.
317
+ async function boundaryFromHandle(handle, opened, session, cwd, signal) {
318
+ const maxBytes = 64 * 1024 * 1024, maxLineBytes = 4 * 1024 * 1024;
319
+ if (!opened.size || opened.size > maxBytes) return null;
320
+ const hash = createHash('sha256'), buffer = Buffer.alloc(64 * 1024);
321
+ let offset = 0, pending = Buffer.alloc(0), rows = 0, messageCount = 0, lastMessageRef = null;
322
+ const decoder = new TextDecoder('utf-8', { fatal: true });
323
+ while (offset < opened.size) {
324
+ signal?.throwIfAborted();
325
+ const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, opened.size - offset), offset);
326
+ if (!bytesRead) return null;
327
+ const chunk = buffer.subarray(0, bytesRead); hash.update(chunk); offset += bytesRead;
328
+ pending = Buffer.concat([pending, chunk]);
329
+ let newline;
330
+ while ((newline = pending.indexOf(10)) !== -1) {
331
+ if (newline > maxLineBytes || ++rows > 250000) return null;
332
+ const line = pending.subarray(0, newline); pending = pending.subarray(newline + 1);
333
+ if (!line.length) continue;
334
+ let item;
335
+ try { item = JSON.parse(decoder.decode(line)); } catch { return null; }
336
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
337
+ if (unscopedFileHistory(item)) continue;
338
+ if (item.sessionId !== session || item.isSidechain === true) return null;
339
+ if (!['user', 'assistant'].includes(item.type) || item.isMeta === true) continue;
340
+ if (typeof item.cwd !== 'string' || !isAbsolute(item.cwd) || !within(cwd, resolve(item.cwd)) || typeof item.uuid !== 'string' || !item.uuid) return null;
341
+ if (item.type === 'user' && !textContent(item.message?.content).trim() && !item.message?.content?.every?.(part => part?.type === 'tool_result')) return null;
342
+ messageCount++; lastMessageRef = item.uuid;
343
+ }
344
+ if (pending.length > maxLineBytes) return null;
345
+ }
346
+ const after = await handle.stat();
347
+ if (pending.length || !messageCount || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) return null;
348
+ return { schemaVersion: 1, complete: true, digest: hash.digest('hex'), byteLength: offset, messageCount, lastMessageRef };
349
+ }
350
+
351
+ export async function readClaudeBoundary(candidate, { home, signal = AbortSignal.timeout(8000) } = {}) {
352
+ signal.throwIfAborted();
353
+ if (!UUID.test(candidate?.session || '') || typeof candidate?.cwd !== 'string') return null;
354
+ const matches = (await collect({ home, session: candidate.session, includeBoundary: true, signal })).filter(item => item.cwd === candidate.cwd);
355
+ signal.throwIfAborted();
356
+ if (matches.length !== 1 || Number.isFinite(candidate.lastActivityAt) && matches[0].lastActivityAt !== candidate.lastActivityAt) return null;
357
+ return matches[0].boundary || null;
358
+ }
359
+
308
360
  export async function readClaudeStatistics(candidate, { home, signal } = {}) {
309
361
  signal?.throwIfAborted();
310
362
  if (!UUID.test(candidate?.session || '')) return unavailableStatistics();