@awak-app/simy-cli 0.7.21 → 0.7.23

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.
@@ -0,0 +1,105 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { mkdir, readFile, realpath, rm } from 'node:fs/promises';
4
+ import { resolveBackendExecutable } from '../backend-executable.js';
5
+ import { inspectProviderPlugins } from './provider-plugin-status.js';
6
+ import { runProviderCommand } from './provider-command.js';
7
+ import { inspectSqmAgentSkill, installSqmAgentSkill } from './agent-skill.js';
8
+ import { CLAUDE_MARKETPLACE, CLAUDE_PLUGIN_SELECTOR } from './claude-plugin.js';
9
+
10
+ const fail = reason => Object.assign(new Error('SIMY plugin repair could not be verified.'), { code: 'plugin_repair_failed', reason });
11
+
12
+ // Explicit user action only. Repair one provider, under the same lock as the
13
+ // background workflow updater. Never change SIMY consent, login or hook trust.
14
+ export async function repairProviderPlugin({ provider, home = homedir(),
15
+ codexHome = process.env.CODEX_HOME || join(home, '.codex'),
16
+ claudeHome = process.env.CLAUDE_CONFIG_DIR || join(home, '.claude'),
17
+ signal, authorize = async () => {}, onDiagnostic = () => {},
18
+ inspect = inspectProviderPlugins, inspectSources = inspectSqmAgentSkill,
19
+ prepare = installSqmAgentSkill, resolveExecutable = resolveBackendExecutable,
20
+ run = runProviderCommand,
21
+ } = {}) {
22
+ if (!['codex', 'claude'].includes(provider)) throw fail('unsupported_provider');
23
+ const startedAt = Date.now();
24
+ const check = async () => {
25
+ if (signal?.aborted) throw fail('cancelled');
26
+ await authorize();
27
+ if (signal?.aborted) throw fail('cancelled');
28
+ };
29
+ const options = { home, codexHome, claudeHome, providers: [provider], details: true,
30
+ timeoutMs: 20_000, signal, onDiagnostic, resolveExecutable, run };
31
+ const inventory = async () => (await inspect(options))[provider];
32
+ const command = async args => {
33
+ await check();
34
+ const executable = await resolveExecutable(provider, { signal, timeoutMs: 5_000 });
35
+ if (!executable) throw fail('cli_missing');
36
+ return run(executable, args, { cwd: home, signal, timeout: 20_000, maxBuffer: 1024 * 1024,
37
+ env: { ...process.env, HOME: home, ...(provider === 'codex' ? { CODEX_HOME: codexHome } : { CLAUDE_CONFIG_DIR: claudeHome }) } });
38
+ };
39
+ let locked = false;
40
+ const lock = join(home, '.simy', 'integrations', 'workflow-update.lock');
41
+ try {
42
+ await check();
43
+ let current = await inventory();
44
+ if (current?.state === 'enabled') return { state: 'repaired', plugin: current };
45
+ if (!['missing', 'disabled'].includes(current?.state)) throw fail(current?.reason || 'verification_failed');
46
+ if (provider === 'claude' && current.scope && current.scope !== 'user') throw fail('scope_requires_attention');
47
+ await mkdir(join(home, '.simy', 'integrations'), { recursive: true });
48
+ try { await mkdir(lock); locked = true; }
49
+ catch (error) { if (error.code === 'EEXIST') throw fail('busy'); throw error; }
50
+ await check();
51
+ // Reinspect after obtaining the lock; another updater may have finished.
52
+ current = await inventory();
53
+ if (current?.state === 'enabled') return { state: 'repaired', plugin: current };
54
+ if (!['missing', 'disabled'].includes(current?.state)) throw fail(current?.reason || 'verification_failed');
55
+ if (provider === 'claude' && current.scope && current.scope !== 'user') throw fail('scope_requires_attention');
56
+ let sources = await inspectSources({ home });
57
+ if (provider === 'codex' && current.state === 'disabled') {
58
+ const payload = JSON.parse((await command(['plugin', 'list', '--json'])).stdout);
59
+ const entry = payload.installed?.find(item => item.pluginId === current.selector);
60
+ if (entry?.source?.source !== 'local' || !entry.source.path ||
61
+ await realpath(entry.source.path) !== await realpath(sources.codex_plugin.path)) throw fail('source_conflict');
62
+ const marketplace = JSON.parse(await readFile(sources.codex_plugin.marketplace_path, 'utf8'));
63
+ if (current.selector !== `simy@${marketplace.name}`) throw fail('source_conflict');
64
+ }
65
+ if (provider === 'claude') {
66
+ const marketplaces = JSON.parse((await command(['plugin', 'marketplace', 'list', '--json'])).stdout);
67
+ if (!Array.isArray(marketplaces)) throw fail('invalid_inventory');
68
+ const existing = marketplaces.find(item => item.name === CLAUDE_MARKETPLACE);
69
+ if (existing && (existing.source !== 'directory' || !existing.path ||
70
+ await realpath(existing.path) !== await realpath(sources.claude_plugin.marketplace_root))) throw fail('source_conflict');
71
+ }
72
+ await check();
73
+ sources = await prepare({ home, platforms: [provider], migrateLegacyGate: false });
74
+ if (provider === 'codex') {
75
+ const marketplace = JSON.parse(await readFile(sources.codex_plugin.marketplace_path, 'utf8'));
76
+ if (!/^[A-Za-z0-9_-]+$/.test(marketplace.name || '')) throw fail('source_conflict');
77
+ const selector = `simy@${marketplace.name}`;
78
+ if (current.selector && current.selector !== selector) throw fail('source_conflict');
79
+ await command(['plugin', 'add', selector, '--json']);
80
+ } else {
81
+ // The managed marketplace installer preserves unrelated provider settings.
82
+ const marketplaces = JSON.parse((await command(['plugin', 'marketplace', 'list', '--json'])).stdout);
83
+ if (!Array.isArray(marketplaces)) throw fail('invalid_inventory');
84
+ const existing = marketplaces.find(item => item.name === CLAUDE_MARKETPLACE);
85
+ if (existing && (existing.source !== 'directory' || !existing.path ||
86
+ await realpath(existing.path) !== await realpath(sources.claude_plugin.marketplace_root))) throw fail('source_conflict');
87
+ if (!existing) {
88
+ await command(['plugin', 'marketplace', 'add', sources.claude_plugin.marketplace_root]);
89
+ }
90
+ await command(['plugin', current.state === 'missing' ? 'install' : 'enable', CLAUDE_PLUGIN_SELECTOR, '--scope', 'user']);
91
+ }
92
+ await check();
93
+ const verified = await inventory();
94
+ if (verified?.state !== 'enabled') throw fail(verified?.reason || 'verification_failed');
95
+ return { state: 'repaired', plugin: verified };
96
+ } catch (error) {
97
+ const reason = error?.reason || (signal?.aborted ? 'cancelled' : error?.code === 'provider_timeout' ? 'timeout' :
98
+ error instanceof SyntaxError ? 'invalid_inventory' :
99
+ ['EACCES', 'EPERM', 'EROFS'].includes(error?.code) ? 'permission_denied' : 'command_failed');
100
+ onDiagnostic({ provider, reason, elapsedMs: Date.now() - startedAt });
101
+ throw fail(reason);
102
+ } finally {
103
+ if (locked) await rm(lock, { recursive: true, force: true });
104
+ }
105
+ }
@@ -14,13 +14,22 @@ export async function inspectProviderPlugins({ home = homedir(),
14
14
  codexHome = process.env.CODEX_HOME || join(home, '.codex'),
15
15
  claudeHome = process.env.CLAUDE_CONFIG_DIR || join(home, '.claude'),
16
16
  resolveExecutable = resolveBackendExecutable, run = runProviderCommand,
17
- timeoutMs = 5_000, signal,
17
+ timeoutMs = 5_000, signal, providers = ['codex', 'claude'], details = false,
18
+ onDiagnostic = () => {},
18
19
  } = {}) {
20
+ if (providers.some(provider => !['codex', 'claude'].includes(provider))) throw new Error('Unsupported provider.');
19
21
  const inspect = async provider => {
22
+ const startedAt = Date.now();
23
+ const unavailable = reason => {
24
+ const elapsedMs = Date.now() - startedAt;
25
+ onDiagnostic({ provider, reason, elapsedMs });
26
+ return { ...unavailablePluginStatus(), ...(details ? { reason, elapsedMs } : {}) };
27
+ };
20
28
  try {
21
- if (signal?.aborted) return unavailablePluginStatus();
29
+ if (signal?.aborted) return unavailable('cancelled');
22
30
  const executable = await resolveExecutable(provider, { signal, timeoutMs });
23
- if (!executable || signal?.aborted) return unavailablePluginStatus();
31
+ if (signal?.aborted) return unavailable('cancelled');
32
+ if (!executable) return unavailable('cli_missing');
24
33
  const { stdout } = await run(executable, ['plugin', 'list', '--json'], {
25
34
  timeout: timeoutMs, maxBuffer: 1024 * 1024, signal, cwd: home,
26
35
  env: { ...process.env, HOME: home,
@@ -28,7 +37,7 @@ export async function inspectProviderPlugins({ home = homedir(),
28
37
  });
29
38
  const payload = JSON.parse(stdout);
30
39
  const entries = provider === 'codex' ? payload?.installed : payload;
31
- if (!Array.isArray(entries)) return unavailablePluginStatus();
40
+ if (!Array.isArray(entries)) return unavailable('invalid_inventory');
32
41
  const matches = entries.filter(entry => provider === 'codex'
33
42
  ? typeof entry?.pluginId === 'string' && /^simy@[A-Za-z0-9_-]+$/.test(entry.pluginId)
34
43
  : entry?.id === CLAUDE_PLUGIN_SELECTOR);
@@ -42,11 +51,14 @@ export async function inspectProviderPlugins({ home = homedir(),
42
51
  const entry = matches[0];
43
52
  if (!entry || provider === 'codex' && entry.installed === false) return { state: 'missing', installed: false, enabled: false };
44
53
  if (typeof entry.enabled !== 'boolean' || provider === 'codex' && entry.installed !== true ||
45
- provider === 'claude' && !scopes.includes(entry.scope)) return unavailablePluginStatus();
54
+ provider === 'claude' && !scopes.includes(entry.scope)) return unavailable('invalid_inventory');
46
55
  return { state: entry.enabled ? 'enabled' : 'disabled', installed: true, enabled: entry.enabled,
56
+ ...(details ? { selector: provider === 'codex' ? entry.pluginId : entry.id } : {}),
47
57
  ...(provider === 'claude' ? { scope: entry.scope, enablementContext: 'home' } : {}) };
48
- } catch { return unavailablePluginStatus(); }
58
+ } catch (error) {
59
+ return unavailable(signal?.aborted ? 'cancelled' : error?.code === 'provider_timeout' ? 'timeout' :
60
+ error instanceof SyntaxError ? 'invalid_inventory' : 'command_failed');
61
+ }
49
62
  };
50
- const [codex, claude] = await Promise.all([inspect('codex'), inspect('claude')]);
51
- return { codex, claude };
63
+ return Object.fromEntries(await Promise.all(providers.map(async provider => [provider, await inspect(provider)])));
52
64
  }
@@ -115,11 +115,12 @@ export function createSqmLocalReviewWorker({
115
115
  getHistoryScope = () => null,
116
116
  getWorkspaceUrl = () => null,
117
117
  recordHistory = async () => {},
118
+ readHistory = async () => [],
118
119
  onHistoryError = () => {},
119
120
  now = () => Date.now(),
120
121
  providerFailureCooldownMs = 5 * 60_000,
121
122
  getRecoveryKey = () => null,
122
- failureCircuit = createSqmFailureCircuit(),
123
+ failureCircuit = createSqmFailureCircuit({ now }),
123
124
  } = {}) {
124
125
  if (!api || typeof api.request !== "function") throw new TypeError("api.request is required");
125
126
  let activeJobId = null;
@@ -158,6 +159,7 @@ export function createSqmLocalReviewWorker({
158
159
  item.state = state; item.finishedAt = new Date().toISOString();
159
160
  item.result = state === "completed" ? "SQM review submitted." : "";
160
161
  item.error = state === "failed" ? formatFailure(failure, failureReported) : "";
162
+ if (state === 'completed') for (const field of ['errorCode', 'errorStage', 'httpStatus', 'providerDiagnostic']) delete item[field];
161
163
  if (failure) { item.phase = failure.stage; item.errorStage = failure.stage; item.errorCode = failure.code; item.httpStatus = failure.status; item.providerDiagnostic = failure.providerDiagnostic; }
162
164
  await persist(scope, item);
163
165
  }
@@ -273,20 +275,22 @@ export function createSqmLocalReviewWorker({
273
275
  snapshot() {
274
276
  const pause = currentPause();
275
277
  const circuit = failureCircuit.snapshot(getHistoryScope());
278
+ const requiresInteraction = pause && !pause.recoverable && pause.recoveryKey === getRecoveryKey(getProvider());
279
+ const waitingForRecovery = circuit.recovery_pending && !requiresInteraction && !activeJobId;
276
280
  return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, suspended,
277
- paused: pausedEnvironment !== null || circuit.paused_until_update || circuit.storage_unavailable,
278
- pause_reason: circuit.provider_retry_exhausted ? "provider_retry_failed_until_update" : circuit.paused_until_update ? "consecutive_failures_until_update" : circuit.storage_unavailable ? "failure_state_unavailable" : pauseReason,
281
+ paused: !activeJobId && (pausedEnvironment !== null || circuit.recovery_pending || circuit.storage_unavailable),
282
+ pause_reason: activeJobId ? null : requiresInteraction ? pause.code : waitingForRecovery ? "automatic_recovery_pending" : circuit.storage_unavailable ? "failure_state_unavailable" : pauseReason,
279
283
  ...circuit, issue: issueContext === contextKey(getProvider(), getHistoryScope()) ? issue : null,
280
- ...(pause ? { provider_pause: { code: pause.code, status: pause.status,
281
- retry_at: circuit.provider_retry_exhausted ? null : new Date(pause.retryAt).toISOString(),
282
- message: circuit.provider_retry_exhausted ? "The provider retry failed. New SQM reviews are paused until SIMY is updated." : pause.message } } : {}) };
284
+ ...(pause && !activeJobId ? { provider_pause: { code: pause.code, status: pause.status,
285
+ retry_at: new Date(Math.max(pause.retryAt, circuit.next_retry_at || 0)).toISOString(),
286
+ message: waitingForRecovery ? "SIMY will automatically retry after the recovery cooldown. No update or manual confirmation is required." : pause.message } } : {}) };
283
287
  },
284
288
  desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item, canStop: item.id === activeJobId })) }; },
285
289
  desktopTaskDetail(key) {
286
290
  const item = visibleHistory().find(item => item.key === key);
287
291
  return item ? { key, instruction: `SQM local review · ${item.provider}`, result: item.state === "completed" ? "SQM review submitted." : "", error: item.error || "", links: item.webUrl ? [{ label: "Analysis details", url: item.webUrl }] : [] } : null;
288
292
  },
289
- retry() { if (closed || suspended || pollPromise || failureCircuit.snapshot(getHistoryScope()).paused_until_update) return false; pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; onChange(); return true; },
293
+ retry() { if (closed || suspended || pollPromise || failureCircuit.snapshot(getHistoryScope()).recovery_pending) return false; pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; onChange(); return true; },
290
294
  resetHistory() { if (!pollPromise) { history.splice(0); issue = null; pausedEnvironment = null; pauseReason = null; } },
291
295
  poll() {
292
296
  if (closed || suspended || pollPromise) return pollPromise || Promise.resolve(null);
@@ -295,7 +299,8 @@ export function createSqmLocalReviewWorker({
295
299
  const pause = currentPause();
296
300
  // Known provider failures already have a bounded cooldown. Do not let
297
301
  // the environment gate suppress their recovery probe indefinitely.
298
- if (!pause?.recoverable || (pause.recoveryKey === getRecoveryKey(getProvider()) && now() < pause.retryAt)) return Promise.resolve(null);
302
+ if (pause?.recoveryKey === getRecoveryKey(getProvider()) &&
303
+ (!pause.recoverable || now() < pause.retryAt)) return Promise.resolve(null);
299
304
  pausedEnvironment = null; pauseReason = null;
300
305
  } else if (pausedEnvironment !== null) {
301
306
  pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null;
@@ -323,6 +328,23 @@ export function createSqmLocalReviewWorker({
323
328
  lease_seconds: leaseSeconds,
324
329
  }, { signal: reviewController.signal });
325
330
  if (issue) { issue = null; onChange(); }
331
+ if (claim?.recovered === true && claim.completed === true && typeof claim.job_id === 'string') {
332
+ // The server completed this exact saved result without re-running a
333
+ // provider. Reflect the original job's completion locally as well.
334
+ if (!history.some(item => item.id === claim.job_id)) {
335
+ let saved;
336
+ try { saved = (await readHistory(historyScope)).find(item => item.key === `sqm:${claim.job_id}`); }
337
+ catch { onHistoryError(); }
338
+ const item = { ...saved, id: claim.job_id, key: `sqm:${claim.job_id}`, kind: 'analysis',
339
+ label: saved?.label || `SQM · ${claim.job_id.slice(0, 8)}`, provider: saved?.provider || provider,
340
+ startedAt: saved?.startedAt || new Date().toISOString(), webUrl: saved?.webUrl || analysisTaskUrl(workspaceUrl, claim.job_id) };
341
+ history.unshift(item); historyScopes.set(item, JSON.stringify(historyScope)); history.splice(50);
342
+ }
343
+ await updateTask(claim.job_id, 'completed', historyScope);
344
+ await failureCircuit.success(historyScope);
345
+ pauses.delete(key);
346
+ return claim;
347
+ }
326
348
  if (!claim?.claimed) {
327
349
  // A successful empty claim proves SIMY access recovered, but it says
328
350
  // nothing about a local provider that has not executed again yet.
@@ -0,0 +1,51 @@
1
+ import {readFile,writeFile,mkdir,realpath} from 'node:fs/promises';
2
+ import {join,resolve} from 'node:path';
3
+ import {createHash,randomUUID} from 'node:crypto';
4
+ import {digest} from './state-store.js';
5
+ import {readDispatchGate,reconcileDispatchHistory} from './dispatch-gate.js';
6
+ import {createUpdateRuntime} from './runtime.js';
7
+ import {createUpdateCoordinator} from './coordinator.js';
8
+ import {readSession,isSessionValid} from '../session-store.js';
9
+ import {webApiUrl,webApiHeaders} from '../web-api.js';
10
+ import {acquireDesktopProcessLock} from '../desktop-process-lock.js';
11
+ import {listRecords} from '../codex-plugins/simy/hooks/delivery-loop.mjs';
12
+ const sha=bytes=>createHash('sha256').update(bytes).digest('hex');
13
+ export async function planDispatchHistoryRecovery({root}) {
14
+ root=await realpath(root);const state=readDispatchGate(root),items=[];
15
+ for(const attempt of Object.values(state.attempts)) {
16
+ const item={attemptId:attempt.id,attemptDigest:digest(attempt),disposition:attempt.phase==='confirmed'?'already_confirmed':'retain_blocked_unknown'};
17
+ const ref=attempt.provenance?.recordRef;
18
+ if(attempt.phase==='result_saved' && ref?.startsWith(resolve(root,'direct-tasks')+'/')) {
19
+ try {
20
+ const bytes=await readFile(ref),result=JSON.parse(bytes),match=/^(.*):provider:([1-9][0-9]*)$/.exec(attempt.id);
21
+ if(await realpath(ref)===ref && match && result.id===match[1] && result.id===attempt.provenance.taskId && digest(result.id)===attempt.taskHash &&
22
+ result.invocation_count===Number(match[2]) && ['succeeded','failed','stopped'].includes(result.status) && Object.values(state.receipts).some(r=>r.attemptId===attempt.id && r.generation===attempt.generation && r.resultRef===ref)) {
23
+ item.disposition='saved_terminal_result';item.evidence={ref,sha256:sha(bytes),status:result.status};
24
+ }
25
+ } catch { /* Unknown effects remain blocked, including removed test output. */ }
26
+ }
27
+ items.push(item);
28
+ }
29
+ const audit={version:1,gateDigest:digest(state),expectedRevision:state.revision,expectedGeneration:state.generation,items};
30
+ const directory=join(root,'update-detachment','recovery');await mkdir(directory,{recursive:true,mode:0o700});
31
+ const auditRef=join(directory,randomUUID()+'.json'),bytes=JSON.stringify(audit,null,2)+'\n';await writeFile(auditRef,bytes,{flag:'wx',mode:0o600});
32
+ return {auditRef,auditDigest:sha(bytes),revision:state.revision,generation:state.generation,results:items.filter(i=>i.disposition==='saved_terminal_result').length,blocked:items.filter(i=>i.disposition==='retain_blocked_unknown').length,replayAttempts:false};
33
+ }
34
+ export async function applyDispatchHistoryRecovery({root,auditRef,auditDigest}) {
35
+ root=await realpath(root);const lock=await acquireDesktopProcessLock(root);
36
+ try {const audit=JSON.parse(await readFile(auditRef,'utf8'));return reconcileDispatchHistory({root,auditRef,auditDigest,expectedRevision:audit.expectedRevision,expectedGeneration:audit.expectedGeneration});}
37
+ finally {await lock.release();}
38
+ }
39
+ export async function reconnectRecoveredHistory({root,origin='https://app.simy.one'}) {
40
+ root=await realpath(root);const lock=await acquireDesktopProcessLock(root);
41
+ try {
42
+ const session=await readSession(origin,root);if(!isSessionValid(session,Date.now(),origin))throw Error('Existing device sign-in required.');
43
+ const response=await fetch(webApiUrl('desktop-access',{webOrigin:origin,apiBaseUrl:session.api_base_url}),{headers:webApiHeaders(session),signal:AbortSignal.timeout(10000)});
44
+ const access=await response.json();if(!response.ok || access.state!=='ready' || access.organizationId!==session.organization_id)throw Error('Current server entitlement required.');
45
+ const runtime=createUpdateRuntime({root,origin,readContext:()=>({actor:{account:session.account_id,device:session.device_id,origin},runs:[],directTasks:[],localTasks:[]}),listRecords:()=>listRecords(join(root,'delivery-hooks'))});
46
+ // Reconciliation reads exact results or retained fences. It never resumes a task.
47
+ const coordinator=createUpdateCoordinator({store:runtime.store,gate:runtime.gate,boundary:runtime.boundary,reconcile:runtime.reconcile,initialState:'recovery_required'});
48
+ if(digest(await readSession(origin,root))!==digest(session))throw Error('Device connection changed.');
49
+ return await coordinator.reconnect();
50
+ } finally {await lock.release();}
51
+ }
@@ -6,7 +6,7 @@ import { execFile } from 'node:child_process';
6
6
  import { promisify } from 'node:util';
7
7
  import { readFile, lstat, readlink, realpath } from 'node:fs/promises';
8
8
  import { atomicSave, readState, digest, withLock } from './state-store.js';
9
- import { readDispatchGate, adoptHookOwner, DISPATCH_PROTOCOL, confirmDispatch, flushDispatchIntake, readTaskAdmission } from './dispatch-gate.js';
9
+ import { readDispatchGate, unresolvedDispatchAttempts, adoptHookOwner, DISPATCH_PROTOCOL, confirmDispatch, flushDispatchIntake, readTaskAdmission } from './dispatch-gate.js';
10
10
  import { createLedgerCoordinatorAdapters } from './ledger-adapter.js';
11
11
  import { listDispatchRegistrations, normalizeDispatchIdentity } from './provenance.js';
12
12
  import { reconcileSession } from './reconcile.js';
@@ -182,7 +182,7 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
182
182
  confirmDispatch({root,attemptId:attempt.id});
183
183
  }
184
184
  ledger=readDispatchGate(root);
185
- const unresolved=Object.values(ledger.attempts).filter(attempt=>attempt.phase!=='confirmed' && !workers.some(task=>task.handle.attemptId===attempt.id && attempt.taskHash===digest(task.taskId) && attempt.identityHash===digest(task.identity) && attempt.generation===task.generation));
185
+ const unresolved=unresolvedDispatchAttempts(ledger).filter(attempt=> !workers.some(task=>task.handle.attemptId===attempt.id && attempt.taskHash===digest(task.taskId) && attempt.identityHash===digest(task.identity) && attempt.generation===task.generation));
186
186
  if(unresolved.length)return {safe:false,liveExecutions:0,pendingWrites:0,unsettledPermissions:0};
187
187
  const currentGate=readDispatchGate(root);
188
188
  if(currentGate.connection!=='detaching' || currentGate.generation!==startingGate.generation)throw failure('boundary_owner_changed');
@@ -194,7 +194,7 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
194
194
  const reconcile=async()=> {
195
195
  const actor=context().actor, assessments=[];
196
196
  const workers=(transaction?.tasks || []).filter(task=>task.handle);
197
- if(Object.values(readDispatchGate(root).attempts).some(attempt=>attempt.phase!=='confirmed' && !workers.some(task=>task.handle.attemptId===attempt.id)))return {verified:false,unresolvedAttempts:1};
197
+ if(unresolvedDispatchAttempts(readDispatchGate(root)).some(attempt=> !workers.some(task=>task.handle.attemptId===attempt.id)))return {verified:false,unresolvedAttempts:1};
198
198
  for (const task of transaction?.tasks || []) {
199
199
  if(task.handle) {
200
200
  if(actor?.account!==task.identity.account || actor?.origin!==task.identity.origin || actor?.device!==task.identity.device || task.identity.root!==root)return {verified:false,unresolvedAttempts:1};