@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.
- package/package.json +1 -1
- package/src/backend-executable.js +36 -21
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +105 -16
- package/src/delivery-hooks/desktop.js +4 -2
- package/src/delivery-hooks/supervisor.js +28 -19
- package/src/desktop-host.js +68 -24
- package/src/session-supervision/action-association-api.js +1 -1
- package/src/session-supervision/action-association-host.js +65 -35
- package/src/session-supervision/action-association.js +17 -10
- package/src/session-supervision/action-deadline.js +9 -1
- package/src/session-supervision/association-store.js +78 -21
- package/src/session-supervision/automatic-continuation.js +18 -1
- package/src/session-supervision/claude-sessions.js +48 -17
- package/src/session-supervision/codex-app-server.js +35 -14
- package/src/session-supervision/index.js +34 -10
- package/src/sqm/review-worker.js +5 -1
|
@@ -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(),
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
227
|
-
const
|
|
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() {
|
|
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
|
}
|