@awak-app/simy-cli 0.5.59 → 0.5.61

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.
@@ -28,10 +28,11 @@ const deadlineBlocker = (record, candidate) => {
28
28
  // Association is optional metadata. Network failures never control the task's
29
29
  // work state; provider turns and their budgets remain owned by the supervisor.
30
30
  export function createActionAssociationHost({ root, sessionRoot, webOrigin, api = createActionAssociationApi({ webOrigin, sessionRoot }),
31
- now = Date.now, refreshMs = 60_000, list = listRecords, read = readRecord, onChange = () => {} } = {}) {
31
+ now = Date.now, refreshMs = 60_000, list = listRecords, readRecords, read = readRecord, onChange = () => {} } = {}) {
32
32
  const abort = new AbortController(), views = new Map();
33
+ if (readRecords) list = () => readRecords();
33
34
  const ordinary = createOrdinaryAssociationStore({ root, api, webOrigin, now, onChange });
34
- let auth = null, closed = false, running = null, serial = Promise.resolve();
35
+ let auth = null, closed = false, running = null, serial = Promise.resolve(), activeCursor = 0, historyCursor = 0;
35
36
  const enqueue = work => {
36
37
  const result = serial.then(work); serial = result.catch(() => {}); return result;
37
38
  };
@@ -44,7 +45,7 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
44
45
  if (!association || association.authRevision === next) continue;
45
46
  await updateRecord(root, record.provider, record.session, latest => {
46
47
  const local = latest?.run?.managed?.actionAssociation;
47
- if (!local || latest.run.managed.taskId !== record.run.managed.taskId) return latest;
48
+ if (!local || local.authRevision === next || latest.run.managed.taskId !== record.run.managed.taskId) return latest;
48
49
  local.invalidated = true; local.binding = null; delete local.outbox; local.status = 'unavailable';
49
50
  return latest;
50
51
  });
@@ -57,6 +58,9 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
57
58
  return eligible(record || {}) && record.run.managed.taskId === target.taskId ? record : null;
58
59
  };
59
60
  const valid = async expected => !closed && expected && await current() === expected;
61
+ // Called inside record locks: reading auth directly cannot recursively take
62
+ // the same lock through current()'s account-change invalidation pass.
63
+ const validateScope = expected => async () => !closed && await api.authRevision() === expected;
60
64
  const importEnvelope = async (record, envelope, expected, userCorrection = false) => {
61
65
  if (!await valid(expected)) throw new ActionAssociationApiError('account_changed');
62
66
  if (envelope.binding && envelope.binding.taskId !== record.run.managed.taskId) throw new ActionAssociationApiError('invalid_response');
@@ -64,9 +68,10 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
64
68
  const local = record.run.managed.actionAssociation;
65
69
  // An empty remote binding must not erase a cached ambiguous/no-match result.
66
70
  if (userCorrection || envelope.binding || local?.binding || local?.namespace && local.namespace !== namespace) {
67
- await importActionBinding({ root, ...identity(record), namespace, binding: envelope.binding, userCorrection, now: now() });
71
+ await importActionBinding({ root, ...identity(record), namespace, binding: envelope.binding, userCorrection, now: now(), validate: validateScope(expected) });
68
72
  }
69
- await updateRecord(root, record.provider, record.session, latest => {
73
+ await updateRecord(root, record.provider, record.session, async latest => {
74
+ if (!await validateScope(expected)()) throw new ActionAssociationApiError('account_changed');
70
75
  const local = latest?.run?.managed?.actionAssociation;
71
76
  if (latest?.run?.managed?.taskId !== record.run.managed.taskId) return latest;
72
77
  latest.run.managed.actionAssociation = { ...local, authRevision: expected };
@@ -81,11 +86,12 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
81
86
  const request = actionCandidateRequest(record);
82
87
  const contextRevision = request?.context.revision || null;
83
88
  if (!force && view?.auth === expected && view.contextRevision === contextRevision && now() < view.nextRefresh && (!association?.outbox || view.error)) {
84
- if (queue && !view.error && view.snapshot && !association?.manualOverride && !association?.job && !association?.outbox && !TERMINAL.has(record.run.state)) await prepareActionAssociation({ root, record, snapshot: view.snapshot, webOrigin, now: now() });
89
+ if (queue && !view.error && view.snapshot && !association?.manualOverride && !association?.job && !association?.outbox && !TERMINAL.has(record.run.state)) await prepareActionAssociation({ root, record, snapshot: view.snapshot, webOrigin, now: now(), validate: validateScope(expected) });
85
90
  return;
86
91
  }
87
92
  view = { ...view, auth: expected, contextRevision, nextRefresh: now() + refreshMs };
88
- views.set(target.taskId, view);
93
+ views.delete(target.taskId); views.set(target.taskId, view);
94
+ while (views.size > 500) views.delete(views.keys().next().value);
89
95
  try {
90
96
  // Never publish an outbox from an earlier organization or login before a
91
97
  // scoped server response confirms who owns the current request.
@@ -100,23 +106,24 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
100
106
  const pending = latest.run.managed.actionAssociation?.outbox;
101
107
  if (pending && pending.namespace === namespace && latest.run.managed.actionAssociation.authRevision === expected) {
102
108
  if (snapshot.binding?.origin === 'automatic' && snapshot.binding.action?.id === pending.actionId && snapshot.binding.contextRevision === pending.contextRevision && snapshot.binding.candidateRevision === pending.candidateRevision) {
103
- await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, binding: snapshot.binding, now: now() }); return;
109
+ await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, binding: snapshot.binding, now: now(), validate: validateScope(expected) }); return;
104
110
  }
105
111
  if (snapshot.contextRevision !== pending.contextRevision || snapshot.candidateRevision !== pending.candidateRevision) {
106
- await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, unavailable: true, now: now() });
112
+ await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, unavailable: true, now: now(), validate: validateScope(expected) });
107
113
  view.nextRefresh = 0; return;
108
114
  }
109
115
  const payload = Object.fromEntries(['expectedRevision', 'contextRevision', 'candidateRevision', 'actionId', 'actionRevision', 'relation', 'coveredWork', 'reason', 'evidence'].map(key => [key, pending[key]]));
110
116
  const saved = await api.saveBinding(target.taskId, { ...payload, origin: 'automatic' }, { signal: abort.signal });
111
117
  if (!await valid(expected) || namespaceFor(webOrigin, saved.scope) !== namespace) return;
112
- await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, binding: saved.binding, now: now() });
118
+ await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, binding: saved.binding, now: now(), validate: validateScope(expected) });
113
119
  return;
114
120
  }
115
121
  if (queue && request && !latest.run.managed.actionAssociation?.manualOverride && !TERMINAL.has(latest.run.state)) {
116
122
  // A read turn is queued only at the original session's safe boundary.
117
123
  // Blocked tasks retain candidates for auxiliary correction in the UI.
118
- await prepareActionAssociation({ root, record: latest, snapshot, webOrigin, now: now() });
119
- await updateRecord(root, latest.provider, latest.session, currentRecord => {
124
+ await prepareActionAssociation({ root, record: latest, snapshot, webOrigin, now: now(), validate: validateScope(expected) });
125
+ await updateRecord(root, latest.provider, latest.session, async currentRecord => {
126
+ if (!await validateScope(expected)()) throw new ActionAssociationApiError('account_changed');
120
127
  if (currentRecord?.run?.managed?.taskId === target.taskId && currentRecord.run.managed.actionAssociation) currentRecord.run.managed.actionAssociation.authRevision = expected;
121
128
  return currentRecord;
122
129
  });
@@ -131,43 +138,64 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
131
138
  const remote = await api.readBinding(target.taskId, { signal: abort.signal });
132
139
  const namespace = await importEnvelope(latest, remote, expected);
133
140
  const box = latest.run.managed.actionAssociation?.outbox;
134
- if (box && remote.binding) await settleActionAssociation({ root, ...target, outboxId: box.id, namespace, binding: remote.binding, now: now() });
135
- else if (box) await settleActionAssociation({ root, ...target, outboxId: box.id, namespace, unavailable: true, now: now() });
141
+ if (box && remote.binding) await settleActionAssociation({ root, ...target, outboxId: box.id, namespace, binding: remote.binding, now: now(), validate: validateScope(expected) });
142
+ else if (box) await settleActionAssociation({ root, ...target, outboxId: box.id, namespace, unavailable: true, now: now(), validate: validateScope(expected) });
136
143
  view.namespace = namespace; view.nextRefresh = 0;
137
144
  } catch { view.error = 'offline'; }
138
145
  } else if (hardFailure(error.code)) {
139
146
  view.candidates = []; view.snapshot = null;
140
- await importActionBinding({ root, ...target, namespace: view.namespace || null, unavailable: true, now: now() });
147
+ await importActionBinding({ root, ...target, namespace: view.namespace || null, unavailable: true, now: now(), validate: validateScope(expected) });
141
148
  } else view.nextRefresh = now() + Math.min(refreshMs, 15_000);
142
149
  }
143
150
  }
151
+ function window(records, cursor, limit) {
152
+ const count = Math.min(limit, records.length);
153
+ return { records: Array.from({ length: count }, (_, i) => records[(cursor + i) % records.length]), cursor: records.length ? (cursor + count) % records.length : 0 };
154
+ }
144
155
  async function reconcile() {
145
- const expected = await current(), records = (await list(root)).filter(eligible).slice(0, 30);
146
- let cursor = 0;
147
- await Promise.all(Array.from({ length: Math.min(3, records.length) }, async () => {
148
- while (!closed && cursor < records.length) {
149
- const record = records[cursor++];
156
+ const expected = await current(), all = (await list(root)).filter(eligible);
157
+ // Cancellation is a safety pass, independent of the optional network work
158
+ // budget. A large history must never hide an expired/paused matching job.
159
+ for (const record of all) {
160
+ if (closed) return;
150
161
  const managed = record.run.managed, job = managed.actionAssociation?.job;
151
162
  if (job && !managed.resumeLease && (!expected || TERMINAL.has(record.run.state) || ['paused', 'blocked'].includes(record.run.state) || record.run.state === 'recovery_required' && record.run.recoveryCause !== 'session_supervision' ||
152
163
  managedBudgetReason(record.run, now()) || now() >= record.run.deadline || record.run.continuations >= record.run.maxContinuations ||
153
164
  managed.understandingRevision !== job.understandingRevision || record.run.revision !== job.runRevision || managed.actionAssociation.invalidated)) {
154
- await cancelActionAssociation({ root, ...identity(record), now: now() });
165
+ await cancelActionAssociation({ root, ...identity(record), expectedJobId: job.id, now: now() });
155
166
  }
156
- if (!expected) {
157
- if (managed.actionAssociation?.namespace) await importActionBinding({ root, ...identity(record), namespace: null, unavailable: true, now: now() });
158
- continue;
167
+ if (!expected && managed.actionAssociation?.namespace && managed.actionAssociation.status !== 'unavailable') {
168
+ await importActionBinding({ root, ...identity(record), namespace: null, unavailable: true, now: now(), validate: validateScope(expected) });
159
169
  }
160
- try { await sync(await fresh(identity(record)) || record, expected); } catch { /* Optional metadata never stops other task drives. */ }
161
170
  }
171
+ if (!expected) return;
172
+ const active = all.filter(record => !TERMINAL.has(record.run.state)), history = all.filter(record => TERMINAL.has(record.run.state));
173
+ // Prefer live work while reserving a small history window. Both cursors
174
+ // rotate so the first hash-sorted records cannot monopolize every tick.
175
+ const nextActive = window(active, activeCursor, history.length ? 24 : 30);
176
+ const nextHistory = window(history, historyCursor, 30 - nextActive.records.length);
177
+ activeCursor = nextActive.cursor; historyCursor = nextHistory.cursor;
178
+ const records = [...nextActive.records, ...nextHistory.records];
179
+ let cursor = 0;
180
+ await Promise.all(Array.from({ length: Math.min(3, records.length) }, async () => {
181
+ while (!closed && cursor < records.length) {
182
+ const record = records[cursor++];
183
+ try { await sync(await fresh(identity(record)) || record, expected); } catch { /* Optional metadata never stops other task drives. */ }
184
+ }
162
185
  }));
163
186
  }
164
187
  return {
165
188
  // A projection is private to one desktop frame. Mutations and deadline
166
189
  // validation still reread the exact record and current authentication.
167
- async beginProjection() {
168
- await current();
169
- const records = await list(root);
170
- return { records, byKey: new Map(records.map(record => [`${record.provider}:${record.session}`, record])) };
190
+ async beginProjection({ records: suppliedRecords } = {}) {
191
+ const authRevision = await current();
192
+ const records = suppliedRecords || await list(root);
193
+ const taskIds = new Set(records.map(record => record.run?.managed?.taskId).filter(Boolean));
194
+ for (const taskId of views.keys()) if (!taskIds.has(taskId)) views.delete(taskId);
195
+ return { records, authRevision, byKey: new Map(records.map(record => [`${record.provider}:${record.session}`, record])) };
196
+ },
197
+ async validateProjection(projection) {
198
+ return !closed && await current() === projection.authRevision;
171
199
  },
172
200
  async decorateSessions(sessions, projection) {
173
201
  await ordinary.decorate(sessions, projection);
@@ -176,16 +204,16 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
176
204
  const record = records.get(`${item.provider}:${item.session}`);
177
205
  return { managed: true, taskId: record.run.managed.taskId, actionAssociation: record.run.managed.actionAssociation, candidate: item };
178
206
  }) };
179
- await this.decorate(status);
207
+ await this.decorate(status, projection);
180
208
  for (const run of status.runs) run.candidate.actionAssociation = run.actionAssociation;
181
209
  return sessions;
182
210
  },
183
211
  async deadlineContext(candidate, projection) {
184
- const expected = await current(), record = projection
212
+ const expected = projection ? projection.authRevision : await current(), record = projection
185
213
  ? projection.byKey.get(`${candidate.provider}:${candidate.session}`)
186
214
  : await read(root, candidate.provider, candidate.session);
187
215
  const local = record?.run?.managed?.actionAssociation || record?.actionAssociation;
188
- if (!expected || local?.authRevision !== expected || !candidate.actionAssociation?.canCorrect) return null;
216
+ if (!expected || projection && auth !== expected || local?.authRevision !== expected || !(candidate.actionAssociation?.canCorrect || candidate.actionAssociation?.canReadBinding)) return null;
189
217
  const binding = candidate.actionAssociation.binding;
190
218
  const fingerprint = value => JSON.stringify([value?.revision, value?.action?.id, value?.action?.revision, value?.action?.dueAt, value?.action?.timeZone, value?.action?.status]);
191
219
  return { namespace: local.namespace, provider: candidate.provider, session: candidate.session, taskId: candidate.taskId, binding,
@@ -195,15 +223,17 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
195
223
  if (!await valid(expected)) return false;
196
224
  const latest = await read(root, candidate.provider, candidate.session);
197
225
  const scoped = latest?.run?.managed?.actionAssociation || latest?.actionAssociation;
226
+ if (!await valid(expected)) return false;
198
227
  return (latest?.run?.managed?.taskId || latest?.associationTaskId) === candidate.taskId && scoped?.namespace === local.namespace && scoped?.authRevision === expected && fingerprint(scoped.binding) === fingerprint(binding);
199
228
  } };
200
229
 
201
230
  },
202
231
  async prepareCached(record) {
232
+ const expected = await current();
203
233
  const view = views.get(record?.run?.managed?.taskId), request = actionCandidateRequest(record);
204
234
  if (closed || !auth || !view || view.auth !== auth || view.error || !view.snapshot || !request || view.contextRevision !== request.context.revision || view.snapshot.contextRevision !== request.context.revision || view.namespace !== namespaceFor(webOrigin, view.snapshot.scope)) return record;
205
235
  try {
206
- const prepared = await prepareActionAssociation({ root, record, snapshot: view.snapshot, webOrigin, now: now() });
236
+ const prepared = await prepareActionAssociation({ root, record, snapshot: view.snapshot, webOrigin, now: now(), validate: validateScope(expected) });
207
237
  return prepared || record;
208
238
  } catch { return record; }
209
239
  },
@@ -212,8 +242,8 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
212
242
  if (!running) running = reconcile().finally(() => { running = null; });
213
243
  return running;
214
244
  },
215
- async decorate(status) {
216
- const expected = await current();
245
+ async decorate(status, projection) {
246
+ const expected = projection ? projection.authRevision : await current();
217
247
  let remainingBytes = 24 * 1024;
218
248
  const perRunBytes = Math.floor(remainingBytes / Math.max(1,(status.runs || []).filter(run => run.managed).length));
219
249
  for (const run of status.runs || []) {
@@ -3,6 +3,10 @@ import { listRecords, updateRecord, TERMINAL } from '../codex-plugins/simy/hooks
3
3
  import { associationSignals, associationRevision } from '../codex-plugins/simy/hooks/action-association.mjs';
4
4
  export { associationSignals, associationRevision, associationContract, validateAssociationResult } from '../codex-plugins/simy/hooks/action-association.mjs';
5
5
 
6
+ async function assertScope(validate) {
7
+ if (validate && !await validate()) throw Object.assign(new Error('Action association account changed.'), { code: 'account_changed' });
8
+ }
9
+
6
10
  function stable(run) {
7
11
  return run?.managed && (run.state === 'active' || run.state === 'recovery_required' && run.recoveryCause === 'session_supervision' && run.managed.autoRecovery === true) && !TERMINAL.has(run.state) && run.managed.phase === 'active' && !run.managed.resumeLease && (!run.managed.handoff || run.managed.handoff.status === 'received') && !run.managed.pendingApproval && !run.managed.approvedAction && !run.managed.providerWarning && !run.managed.executionUncertain && (!run.managed.executionAttempt || run.managed.executionAttempt.phase === 'checkpointed');
8
12
  }
@@ -16,8 +20,9 @@ function restoreAssociationWork(run, saved) {
16
20
  }
17
21
  managed.awaitingAssociationHook = false;
18
22
  }
19
- export async function queueActionAssociation({ root, provider, session, taskId, expectedRevision, candidates, candidateRevision, scope, namespace = null, remoteBinding = null, now = Date.now() }) {
20
- return updateRecord(root, provider, session, record => {
23
+ export async function queueActionAssociation({ root, provider, session, taskId, expectedRevision, candidates, candidateRevision, scope, namespace = null, remoteBinding = null, now = Date.now(), validate }) {
24
+ return updateRecord(root, provider, session, async record => {
25
+ await assertScope(validate);
21
26
  const run = record?.run, managed = run?.managed;
22
27
  if (!managed || managed.taskId !== taskId || run.revision !== expectedRevision) return record;
23
28
  if (!stable(run) || now >= run.deadline || run.continuations >= run.maxContinuations) {
@@ -45,8 +50,9 @@ export async function queueActionAssociation({ root, provider, session, taskId,
45
50
  return record;
46
51
  });
47
52
  }
48
- export async function importActionBinding({ root, provider, session, taskId, namespace, binding = null, unavailable = false, userCorrection = false, now = Date.now() }) {
49
- return updateRecord(root, provider, session, record => {
53
+ export async function importActionBinding({ root, provider, session, taskId, namespace, binding = null, unavailable = false, userCorrection = false, now = Date.now(), validate }) {
54
+ return updateRecord(root, provider, session, async record => {
55
+ await assertScope(validate);
50
56
  const run = record?.run, managed = run?.managed;
51
57
  if (managed?.taskId !== taskId || managed.handoff && managed.handoff.status !== 'received') return record;
52
58
  const previous = managed.actionAssociation || {};
@@ -79,7 +85,7 @@ export function actionCandidateRequest(record) {
79
85
  const context = associationSignals(record?.run?.managed?.sessionUnderstanding);
80
86
  return context ? { taskId: record.run.managed.taskId, context, limit: 10 } : null;
81
87
  }
82
- export async function prepareActionAssociation({ root, record, snapshot, webOrigin, now = Date.now() }) {
88
+ export async function prepareActionAssociation({ root, record, snapshot, webOrigin, now = Date.now(), validate }) {
83
89
  const request = actionCandidateRequest(record);
84
90
  if (!request || snapshot?.schemaVersion !== 1 || snapshot.taskId !== request.taskId || snapshot.contextRevision !== request.context.revision || !snapshot.scope?.organizationId || !snapshot.scope?.userId) return null;
85
91
  if (!Array.isArray(snapshot.candidates) || snapshot.candidates.length > 10 || new Set(snapshot.candidates.map(item => item.id)).size !== snapshot.candidates.length) return null;
@@ -88,10 +94,11 @@ export async function prepareActionAssociation({ root, record, snapshot, webOrig
88
94
  }
89
95
  const namespace = JSON.stringify([new URL(webOrigin).origin,snapshot.scope.organizationId,snapshot.scope.userId]);
90
96
  return queueActionAssociation({ root, provider: record.provider, session: record.session, taskId: request.taskId, expectedRevision: record.run.revision,
91
- candidates: snapshot.candidates, candidateRevision: snapshot.candidateRevision, scope: snapshot.scope, namespace, remoteBinding: snapshot.binding, now });
97
+ candidates: snapshot.candidates, candidateRevision: snapshot.candidateRevision, scope: snapshot.scope, namespace, remoteBinding: snapshot.binding, now, validate });
92
98
  }
93
- export async function settleActionAssociation({ root, provider, session, taskId, outboxId, namespace, binding = null, unavailable = false, now = Date.now() }) {
94
- return updateRecord(root, provider, session, record => {
99
+ export async function settleActionAssociation({ root, provider, session, taskId, outboxId, namespace, binding = null, unavailable = false, now = Date.now(), validate }) {
100
+ return updateRecord(root, provider, session, async record => {
101
+ await assertScope(validate);
95
102
  const association = record?.run?.managed?.actionAssociation;
96
103
  if (record?.run?.managed?.taskId !== taskId || association?.outbox?.id !== outboxId || association.outbox.namespace !== namespace || record.run.managed.handoff && record.run.managed.handoff.status !== 'received') return record;
97
104
  if (binding && binding.taskId === taskId) {
@@ -102,10 +109,10 @@ export async function settleActionAssociation({ root, provider, session, taskId,
102
109
  return record;
103
110
  });
104
111
  }
105
- export async function cancelActionAssociation({ root, provider, session, taskId, now = Date.now() }) {
112
+ export async function cancelActionAssociation({ root, provider, session, taskId, expectedJobId, now = Date.now() }) {
106
113
  return updateRecord(root, provider, session, record => {
107
114
  const run = record?.run, managed = run?.managed, association = managed?.actionAssociation;
108
- if (managed?.taskId !== taskId || managed.resumeLease || !association?.job) return record;
115
+ if (managed?.taskId !== taskId || managed.resumeLease || !association?.job || expectedJobId && association.job.id !== expectedJobId) return record;
109
116
  const saved = association.job.saved;
110
117
  restoreAssociationWork(run, saved);
111
118
  managed.awaitingAssociationHook = false; association.status = 'unavailable'; delete association.job; association.updatedAt = now;
@@ -150,7 +150,7 @@ export function createActionDeadlineService({
150
150
  plan: context.plan ?? null,
151
151
  recommendation: deadlineRecommendation(context),
152
152
  });
153
- async function reconcile(context) {
153
+ async function reconcile(context, { displayOnly = false } = {}) {
154
154
  const key = identity(context),
155
155
  action = context.binding?.action;
156
156
  const bindingRevision =
@@ -164,6 +164,14 @@ export function createActionDeadlineService({
164
164
  action?.status,
165
165
  terminal.has(context.taskStatus),
166
166
  ]);
167
+ if (displayOnly) {
168
+ // Host display already owns a frame-scoped record/auth snapshot and an
169
+ // authorization exit fence. An unchanged durable deadline needs no write
170
+ // transaction or per-session authorization read; project still evaluates
171
+ // the current time and fresh local decisions on every heartbeat.
172
+ const previous = await store.read(key);
173
+ if (previous?.fingerprint === fingerprint && previous.status !== 'unavailable') return project(previous, context);
174
+ }
167
175
  const state = await transact(context, (previous) => {
168
176
  if (context.online === false) return previous;
169
177
  // Tombstones retain the last authoritative revision, so old snapshots
@@ -54,7 +54,14 @@ export function createOrdinaryAssociationStore({
54
54
  const views = new Map(),
55
55
  abort = new AbortController();
56
56
  let serial = Promise.resolve(),
57
- closed = false;
57
+ closed = false, nextBatchAt = 0, nextPriorityAt = 0, priorityTimer = null;
58
+ const rememberView = (taskId, view) => {
59
+ views.delete(taskId); views.set(taskId, view);
60
+ while (views.size > 500) views.delete(views.keys().next().value);
61
+ };
62
+ const presentationKey = view => JSON.stringify([view?.auth, view?.status, view?.contextRevision, view?.namespace,
63
+ view?.snapshot?.candidateRevision, view?.snapshot?.binding,
64
+ view?.snapshot?.candidates?.slice(0, 5).map(item => [item.id, item.revision, item.title])]);
58
65
  const find = candidate => readRecord(root, candidate.provider, candidate.session);
59
66
  async function ensure(candidate, known) {
60
67
  if (
@@ -106,7 +113,7 @@ export function createOrdinaryAssociationStore({
106
113
  context = sessionAssociationContext(candidate);
107
114
  let view = views.get(taskId);
108
115
  if (!auth) {
109
- views.set(taskId, { auth, status: 'signed_out' });
116
+ rememberView(taskId, { auth, status: 'signed_out' });
110
117
  return;
111
118
  }
112
119
  if (
@@ -116,13 +123,16 @@ export function createOrdinaryAssociationStore({
116
123
  now() < view.nextRefresh
117
124
  )
118
125
  return;
126
+ const previous = view?.auth === auth ? view : null;
127
+ const sameContext = previous?.contextRevision === context.revision;
119
128
  view = {
120
- auth,
121
- contextRevision: context.revision,
122
- status: 'loading',
123
- nextRefresh: now() + 15000,
129
+ ...previous, auth, contextRevision: context.revision,
130
+ // Background renewal retains the last stable presentation. A user refresh
131
+ // can show progress without flashing every cached result back to loading.
132
+ status: force ? 'loading' : previous?.status || 'loading',
133
+ nextRefresh: now() + 60_000, lastAttemptAt: now(), failures: sameContext ? previous?.failures || 0 : 0,
124
134
  };
125
- views.set(taskId, view);
135
+ rememberView(taskId, view);
126
136
  try {
127
137
  const snapshot = await api.candidates(
128
138
  { taskId, context, limit: 10 },
@@ -141,7 +151,8 @@ export function createOrdinaryAssociationStore({
141
151
  root,
142
152
  candidate.provider,
143
153
  candidate.session,
144
- (latest) => {
154
+ async (latest) => {
155
+ if (closed || await api.authRevision() !== auth) throw new ActionAssociationApiError('account_changed');
145
156
  if (latest.associationTaskId !== taskId || latest.run?.managed)
146
157
  return latest;
147
158
  if (
@@ -173,6 +184,8 @@ export function createOrdinaryAssociationStore({
173
184
  Object.assign(view, {
174
185
  namespace,
175
186
  snapshot,
187
+ nextRefresh: now() + (snapshot.binding?.action ? 15_000 : 60_000),
188
+ failures: 0,
176
189
  status: snapshot.binding?.action
177
190
  ? 'bound'
178
191
  : snapshot.binding
@@ -182,6 +195,8 @@ export function createOrdinaryAssociationStore({
182
195
  } catch (error) {
183
196
  view.status = error.code || 'offline';
184
197
  view.snapshot = null;
198
+ view.failures = Math.min(4, view.failures + 1);
199
+ view.nextRefresh = now() + Math.min(120_000, 15_000 * 2 ** (view.failures - 1));
185
200
  }
186
201
  }
187
202
  return {
@@ -192,7 +207,10 @@ export function createOrdinaryAssociationStore({
192
207
  record,
193
208
  ]),
194
209
  );
195
- const auth = await api.authRevision();
210
+ const auth = snapshot && Object.hasOwn(snapshot, 'authRevision') ? snapshot.authRevision : await api.authRevision();
211
+ const taskIds = new Set([...records.values()].map(record => record.run?.managed?.taskId || record.associationTaskId).filter(Boolean));
212
+ for (const taskId of views.keys()) if (!taskIds.has(taskId) && !inFlight.has(taskId)) views.delete(taskId);
213
+ const pending = [];
196
214
  for (const candidate of sessions) {
197
215
  const record = await ensure(
198
216
  candidate,
@@ -207,26 +225,29 @@ export function createOrdinaryAssociationStore({
207
225
  if (
208
226
  auth &&
209
227
  !closed &&
210
- inFlight.size < 3 &&
211
228
  !inFlight.has(taskId) &&
212
229
  (!cached ||
213
230
  cached.auth !== auth ||
214
231
  cached.contextRevision !== context.revision ||
215
232
  now() >= cached.nextRefresh)
216
233
  ) {
217
- const work = sync(candidate, false, record)
218
- .catch(() => {})
219
- .finally(() => {
220
- inFlight.delete(taskId);
221
- if (!closed) onChange();
222
- });
223
- inFlight.set(taskId, work);
234
+ const scopedBinding = cached?.auth === auth && cached.snapshot && record.actionAssociation?.authRevision === auth && record.actionAssociation.namespace === cached.namespace;
235
+ pending.push({ candidate, record, taskId, lastAttemptAt: cached?.lastAttemptAt ?? -Infinity,
236
+ priority: Boolean(cached?.auth === auth && cached.snapshot && cached.contextRevision !== context.revision ||
237
+ scopedBinding && (record.actionAssociation.binding?.action || cached.snapshot.binding?.action) && now() >= cached.nextRefresh) });
224
238
  }
225
239
  const view = views.get(taskId);
226
- const snapshot = view?.auth === auth && auth ? view.snapshot : null;
227
- const binding = snapshot?.binding;
240
+ const bindingSnapshot = view?.auth === auth && auth ? view.snapshot : null;
241
+ const snapshot = bindingSnapshot?.contextRevision === context.revision ? bindingSnapshot : null;
242
+ const local = record.actionAssociation;
243
+ const scoped = Boolean(bindingSnapshot && local?.authRevision === auth && local.namespace === view.namespace);
244
+ const newer = scoped &&
245
+ (local.binding?.revision || 0) > (bindingSnapshot.binding?.revision || 0);
246
+ // A hook/second desktop can correct or unlink a binding while the
247
+ // network cache remains valid. The current persisted revision wins.
248
+ const binding = newer ? local.binding : bindingSnapshot?.binding;
228
249
  candidate.actionAssociation = {
229
- status: !auth ? 'signed_out' : view?.status || 'loading',
250
+ status: !auth ? 'signed_out' : newer ? local.status : view?.status || 'loading',
230
251
  binding: binding
231
252
  ? {
232
253
  ...binding,
@@ -248,11 +269,45 @@ export function createOrdinaryAssociationStore({
248
269
  title: item.title.slice(0, 120),
249
270
  })),
250
271
  canCorrect: Boolean(snapshot),
272
+ canReadBinding: Boolean(scoped && binding),
251
273
  contextRevision: view?.contextRevision || null,
252
274
  candidateRevision: snapshot?.candidateRevision || null,
253
275
  truncated: (snapshot?.candidates?.length || 0) > 5,
254
276
  };
255
277
  }
278
+ const priority = pending.filter(item => item.priority);
279
+ const priorityReady = priority.length && now() >= nextPriorityAt;
280
+ if (pending.length && (now() >= nextBatchAt || priorityReady) && inFlight.size < 3) {
281
+ const onlyPriority = now() < nextBatchAt;
282
+ if (onlyPriority) nextPriorityAt = now() + 250;
283
+ else { nextBatchAt = now() + 3_000; if (priority.length) nextPriorityAt = now() + 250; }
284
+ // Never-checked and least-recently-checked work wins over array order,
285
+ // so a short retry TTL cannot indefinitely starve later conversations.
286
+ // Existing conversations must reflect changed user context promptly. This
287
+ // separately bounded lane never treats old-account or unseen history as
288
+ // urgent, and still shares the three workers with normal backfill.
289
+ const batch = (onlyPriority ? priority : pending).sort((a, b) => Number(b.priority) - Number(a.priority) || a.lastAttemptAt - b.lastAttemptAt).slice(0, 3 - inFlight.size);
290
+ const before = batch.map(({ taskId }) => presentationKey(views.get(taskId)));
291
+ const work = batch.map(({ candidate, record, taskId }) => {
292
+ const result = sync(candidate, false, record).catch(() => {}).finally(() => inFlight.delete(taskId));
293
+ inFlight.set(taskId, result);
294
+ return result;
295
+ });
296
+ void Promise.allSettled(work).then(() => {
297
+ if (!closed && batch.some(({ taskId }, index) => presentationKey(views.get(taskId)) !== before[index])) onChange();
298
+ });
299
+ }
300
+ const waitingPriority = priority.some(({ taskId }) => !inFlight.has(taskId));
301
+ if (!waitingPriority && priorityTimer) { clearTimeout(priorityTimer); priorityTimer = null; }
302
+ if (waitingPriority && inFlight.size < 3 && now() < nextPriorityAt && !priorityTimer) {
303
+ // A command may arrive between the bounded priority slots. Wake once
304
+ // when the slot opens rather than waiting for the 3s history heartbeat.
305
+ priorityTimer = setTimeout(() => { priorityTimer = null; if (!closed) onChange(); }, nextPriorityAt - now());
306
+ priorityTimer.unref();
307
+ }
308
+ if (!snapshot && await api.authRevision() !== auth) {
309
+ for (const candidate of sessions) candidate.actionAssociation = { status: 'account_changed', binding: null, candidates: [], canCorrect: false };
310
+ }
256
311
  return sessions;
257
312
  },
258
313
  correct(command, candidate) {
@@ -336,7 +391,8 @@ export function createOrdinaryAssociationStore({
336
391
  root,
337
392
  candidate.provider,
338
393
  candidate.session,
339
- (latest) => {
394
+ async (latest) => {
395
+ if (closed || await api.authRevision() !== auth) throw new ActionAssociationApiError('account_changed');
340
396
  if (
341
397
  latest.associationTaskId !== command.taskId ||
342
398
  latest.run?.managed
@@ -364,6 +420,7 @@ export function createOrdinaryAssociationStore({
364
420
  },
365
421
  async close() {
366
422
  closed = true;
423
+ clearTimeout(priorityTimer); priorityTimer = null;
367
424
  abort.abort();
368
425
  await Promise.allSettled([serial, ...inFlight.values()]);
369
426
  views.clear();
@@ -27,6 +27,18 @@ export function createAutomaticContinuation({ root, getManager, selectCandidate,
27
27
  let closed = false;
28
28
  const tick = candidates => {
29
29
  if (closed) return;
30
+ // Drop display/dedup state once a current observation proves it obsolete.
31
+ // Absence alone is insufficient: provider outages must not retry a failed
32
+ // enrollment with the same opt-in epoch and context when it reappears.
33
+ for (const candidate of candidates) {
34
+ const key = `${candidate.provider}:${candidate.session}`;
35
+ const attempt = attempted.get(key);
36
+ const bound = ['managed', 'pending_takeover', 'standard_loop'].includes(candidate.binding?.mode);
37
+ if (candidate.autoContinue !== true || bound ||
38
+ attempt && !attempt.startsWith(`${candidate.autoContinueEpoch}:`)) {
39
+ attempted.delete(key); errors.delete(key);
40
+ }
41
+ }
30
42
  for (const candidate of candidates) {
31
43
  if (pending.size >= 3) break;
32
44
  if (candidate.autoContinue !== true || !candidate.autoContinueEpoch || candidate.isRunning ||
@@ -76,6 +88,11 @@ export function createAutomaticContinuation({ root, getManager, selectCandidate,
76
88
  },
77
89
  revoke(provider, session) { pending.get(`${provider}:${session}`)?.controller.abort(); },
78
90
  async whenIdle() { await Promise.allSettled([...pending.values()].map(entry => entry.job)); },
79
- async close() { closed = true; for (const entry of pending.values()) entry.controller.abort(); await Promise.allSettled([...pending.values()].map(entry => entry.job)); },
91
+ async close() {
92
+ closed = true;
93
+ for (const entry of pending.values()) entry.controller.abort();
94
+ await Promise.allSettled([...pending.values()].map(entry => entry.job));
95
+ attempted.clear(); errors.clear();
96
+ },
80
97
  };
81
98
  }