@awak-app/simy-cli 0.5.38 → 0.5.40
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 +2 -2
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/action-association.mjs +52 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +48 -10
- package/src/delivery-hooks/desktop.js +12 -0
- package/src/delivery-hooks/supervisor.js +10 -3
- package/src/desktop-host.js +36 -9
- package/src/session-supervision/action-association-api.js +99 -0
- package/src/session-supervision/action-association-host.js +213 -0
- package/src/session-supervision/action-association.js +114 -0
- package/src/session-supervision/analysis-manager.js +46 -12
- package/src/session-supervision/codex-app-server.js +80 -16
- package/src/session-supervision/continuation-analysis.js +33 -4
- package/src/session-supervision/handoff.js +1 -1
- package/src/session-supervision/index.js +1 -0
- package/src/sqm/claude-plugin.js +1 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { listRecords, updateRecord, TERMINAL, managedBudgetReason } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
2
|
+
import { createActionAssociationApi, ActionAssociationApiError } from './action-association-api.js';
|
|
3
|
+
import { actionCandidateRequest, prepareActionAssociation, importActionBinding,
|
|
4
|
+
settleActionAssociation, cancelActionAssociation } from './action-association.js';
|
|
5
|
+
|
|
6
|
+
const namespaceFor = (origin, scope) => JSON.stringify([new URL(origin).origin, scope.organizationId, scope.userId]);
|
|
7
|
+
const identity = record => ({ provider: record.provider, session: record.session, taskId: record.run.managed.taskId });
|
|
8
|
+
const eligible = record => record.run?.managed?.taskId && (!record.run.managed.handoff || record.run.managed.handoff.status === 'received');
|
|
9
|
+
const publicCandidate = item => ({ id: item.id, revision: item.revision, title: item.title.slice(0,120) });
|
|
10
|
+
const publicBinding = binding => binding ? { ...binding, reason: (binding.reason || '').slice(0,60), coveredWork: (binding.coveredWork || '').slice(0,60), evidence: [],
|
|
11
|
+
action: binding.action ? { ...binding.action, title: (binding.action.title || '').slice(0,120), description: null, projectName: (binding.action.projectName || '').slice(0,60) } : null } : null;
|
|
12
|
+
const hardFailure = code => ['unauthorized', 'unavailable', 'invalid_request', 'invalid_response'].includes(code);
|
|
13
|
+
|
|
14
|
+
// Association is optional metadata. Network failures never control the task's
|
|
15
|
+
// work state; provider turns and their budgets remain owned by the supervisor.
|
|
16
|
+
export function createActionAssociationHost({ root, sessionRoot, webOrigin, api = createActionAssociationApi({ webOrigin, sessionRoot }),
|
|
17
|
+
now = Date.now, refreshMs = 60_000, list = listRecords } = {}) {
|
|
18
|
+
const abort = new AbortController(), views = new Map();
|
|
19
|
+
let auth = null, closed = false, running = null, serial = Promise.resolve();
|
|
20
|
+
const enqueue = work => {
|
|
21
|
+
const result = serial.then(work); serial = result.catch(() => {}); return result;
|
|
22
|
+
};
|
|
23
|
+
const current = async () => {
|
|
24
|
+
const next = await api.authRevision();
|
|
25
|
+
if (next !== auth) {
|
|
26
|
+
auth = next; views.clear();
|
|
27
|
+
for (const record of (await list(root)).filter(eligible)) {
|
|
28
|
+
const association = record.run.managed.actionAssociation;
|
|
29
|
+
if (!association || association.authRevision === next) continue;
|
|
30
|
+
await updateRecord(root, record.provider, record.session, latest => {
|
|
31
|
+
const local = latest?.run?.managed?.actionAssociation;
|
|
32
|
+
if (!local || latest.run.managed.taskId !== record.run.managed.taskId) return latest;
|
|
33
|
+
local.invalidated = true; local.binding = null; delete local.outbox; local.status = 'unavailable';
|
|
34
|
+
return latest;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return next;
|
|
39
|
+
};
|
|
40
|
+
const fresh = async target => (await list(root)).find(record => eligible(record) && record.provider === target.provider && record.session === target.session && record.run.managed.taskId === target.taskId);
|
|
41
|
+
const valid = async expected => !closed && expected && await current() === expected;
|
|
42
|
+
const importEnvelope = async (record, envelope, expected, userCorrection = false) => {
|
|
43
|
+
if (!await valid(expected)) throw new ActionAssociationApiError('account_changed');
|
|
44
|
+
if (envelope.binding && envelope.binding.taskId !== record.run.managed.taskId) throw new ActionAssociationApiError('invalid_response');
|
|
45
|
+
const namespace = namespaceFor(webOrigin, envelope.scope);
|
|
46
|
+
const local = record.run.managed.actionAssociation;
|
|
47
|
+
// An empty remote binding must not erase a cached ambiguous/no-match result.
|
|
48
|
+
if (userCorrection || envelope.binding || local?.binding || local?.namespace && local.namespace !== namespace) {
|
|
49
|
+
await importActionBinding({ root, ...identity(record), namespace, binding: envelope.binding, userCorrection, now: now() });
|
|
50
|
+
}
|
|
51
|
+
await updateRecord(root, record.provider, record.session, latest => {
|
|
52
|
+
const local = latest?.run?.managed?.actionAssociation;
|
|
53
|
+
if (latest?.run?.managed?.taskId !== record.run.managed.taskId) return latest;
|
|
54
|
+
latest.run.managed.actionAssociation = { ...local, authRevision: expected };
|
|
55
|
+
if (!local?.job) delete latest.run.managed.actionAssociation.invalidated;
|
|
56
|
+
return latest;
|
|
57
|
+
});
|
|
58
|
+
return namespace;
|
|
59
|
+
};
|
|
60
|
+
async function sync(record, expected, force = false, queue = true) {
|
|
61
|
+
const target = identity(record), association = record.run.managed.actionAssociation;
|
|
62
|
+
let view = views.get(target.taskId);
|
|
63
|
+
const request = actionCandidateRequest(record);
|
|
64
|
+
const contextRevision = request?.context.revision || null;
|
|
65
|
+
if (!force && view?.auth === expected && view.contextRevision === contextRevision && now() < view.nextRefresh && (!association?.outbox || view.error)) {
|
|
66
|
+
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() });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
view = { ...view, auth: expected, contextRevision, nextRefresh: now() + refreshMs };
|
|
70
|
+
views.set(target.taskId, view);
|
|
71
|
+
try {
|
|
72
|
+
// Never publish an outbox from an earlier organization or login before a
|
|
73
|
+
// scoped server response confirms who owns the current request.
|
|
74
|
+
const snapshot = request ? await api.candidates(request, { signal: abort.signal }) : await api.readBinding(target.taskId, { signal: abort.signal });
|
|
75
|
+
const namespace = await importEnvelope(record, snapshot, expected);
|
|
76
|
+
view.namespace = namespace; view.error = null;
|
|
77
|
+
view.candidates = (snapshot.candidates || []).map(publicCandidate);
|
|
78
|
+
view.candidateTruncated = snapshot.retrieval?.truncated === true || view.candidates.length > 5;
|
|
79
|
+
view.snapshot = request ? snapshot : null;
|
|
80
|
+
let latest = await fresh(target);
|
|
81
|
+
if (!latest || !await valid(expected)) return;
|
|
82
|
+
const pending = latest.run.managed.actionAssociation?.outbox;
|
|
83
|
+
if (pending && pending.namespace === namespace && latest.run.managed.actionAssociation.authRevision === expected) {
|
|
84
|
+
if (snapshot.binding?.origin === 'automatic' && snapshot.binding.action?.id === pending.actionId && snapshot.binding.contextRevision === pending.contextRevision && snapshot.binding.candidateRevision === pending.candidateRevision) {
|
|
85
|
+
await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, binding: snapshot.binding, now: now() }); return;
|
|
86
|
+
}
|
|
87
|
+
if (snapshot.contextRevision !== pending.contextRevision || snapshot.candidateRevision !== pending.candidateRevision) {
|
|
88
|
+
await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, unavailable: true, now: now() });
|
|
89
|
+
view.nextRefresh = 0; return;
|
|
90
|
+
}
|
|
91
|
+
const payload = Object.fromEntries(['expectedRevision', 'contextRevision', 'candidateRevision', 'actionId', 'actionRevision', 'relation', 'coveredWork', 'reason', 'evidence'].map(key => [key, pending[key]]));
|
|
92
|
+
const saved = await api.saveBinding(target.taskId, { ...payload, origin: 'automatic' }, { signal: abort.signal });
|
|
93
|
+
if (!await valid(expected) || namespaceFor(webOrigin, saved.scope) !== namespace) return;
|
|
94
|
+
await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, binding: saved.binding, now: now() });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (queue && request && !latest.run.managed.actionAssociation?.manualOverride && !TERMINAL.has(latest.run.state)) {
|
|
98
|
+
// A read turn is queued only at the original session's safe boundary.
|
|
99
|
+
// Blocked tasks retain candidates for auxiliary correction in the UI.
|
|
100
|
+
await prepareActionAssociation({ root, record: latest, snapshot, webOrigin, now: now() });
|
|
101
|
+
await updateRecord(root, latest.provider, latest.session, currentRecord => {
|
|
102
|
+
if (currentRecord?.run?.managed?.taskId === target.taskId && currentRecord.run.managed.actionAssociation) currentRecord.run.managed.actionAssociation.authRevision = expected;
|
|
103
|
+
return currentRecord;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (!await valid(expected)) return;
|
|
108
|
+
view.error = error.code || 'offline';
|
|
109
|
+
const latest = await fresh(target);
|
|
110
|
+
if (!latest) return;
|
|
111
|
+
if (error.code === 'conflict') {
|
|
112
|
+
try {
|
|
113
|
+
const remote = await api.readBinding(target.taskId, { signal: abort.signal });
|
|
114
|
+
const namespace = await importEnvelope(latest, remote, expected);
|
|
115
|
+
const box = latest.run.managed.actionAssociation?.outbox;
|
|
116
|
+
if (box && remote.binding) await settleActionAssociation({ root, ...target, outboxId: box.id, namespace, binding: remote.binding, now: now() });
|
|
117
|
+
else if (box) await settleActionAssociation({ root, ...target, outboxId: box.id, namespace, unavailable: true, now: now() });
|
|
118
|
+
view.namespace = namespace; view.nextRefresh = 0;
|
|
119
|
+
} catch { view.error = 'offline'; }
|
|
120
|
+
} else if (hardFailure(error.code)) {
|
|
121
|
+
view.candidates = []; view.snapshot = null;
|
|
122
|
+
await importActionBinding({ root, ...target, namespace: view.namespace || null, unavailable: true, now: now() });
|
|
123
|
+
} else view.nextRefresh = now() + Math.min(refreshMs, 15_000);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function reconcile() {
|
|
127
|
+
const expected = await current(), records = (await list(root)).filter(eligible).slice(0, 30);
|
|
128
|
+
let cursor = 0;
|
|
129
|
+
await Promise.all(Array.from({ length: Math.min(3, records.length) }, async () => {
|
|
130
|
+
while (!closed && cursor < records.length) {
|
|
131
|
+
const record = records[cursor++];
|
|
132
|
+
const managed = record.run.managed, job = managed.actionAssociation?.job;
|
|
133
|
+
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' ||
|
|
134
|
+
managedBudgetReason(record.run, now()) || now() >= record.run.deadline || record.run.continuations >= record.run.maxContinuations ||
|
|
135
|
+
managed.understandingRevision !== job.understandingRevision || record.run.revision !== job.runRevision || managed.actionAssociation.invalidated)) {
|
|
136
|
+
await cancelActionAssociation({ root, ...identity(record), now: now() });
|
|
137
|
+
}
|
|
138
|
+
if (!expected) {
|
|
139
|
+
if (managed.actionAssociation?.namespace) await importActionBinding({ root, ...identity(record), namespace: null, unavailable: true, now: now() });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
try { await sync(await fresh(identity(record)) || record, expected); } catch { /* Optional metadata never stops other task drives. */ }
|
|
143
|
+
}
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
async prepareCached(record) {
|
|
148
|
+
const view = views.get(record?.run?.managed?.taskId), request = actionCandidateRequest(record);
|
|
149
|
+
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;
|
|
150
|
+
try {
|
|
151
|
+
const prepared = await prepareActionAssociation({ root, record, snapshot: view.snapshot, webOrigin, now: now() });
|
|
152
|
+
return prepared || record;
|
|
153
|
+
} catch { return record; }
|
|
154
|
+
},
|
|
155
|
+
tick() {
|
|
156
|
+
if (closed) return Promise.resolve();
|
|
157
|
+
if (!running) running = reconcile().finally(() => { running = null; });
|
|
158
|
+
return running;
|
|
159
|
+
},
|
|
160
|
+
async decorate(status) {
|
|
161
|
+
const expected = await current();
|
|
162
|
+
let remainingBytes = 24 * 1024;
|
|
163
|
+
const perRunBytes = Math.floor(remainingBytes / Math.max(1,(status.runs || []).filter(run => run.managed).length));
|
|
164
|
+
for (const run of status.runs || []) {
|
|
165
|
+
if (!run.managed) continue;
|
|
166
|
+
const local = run.actionAssociation, view = views.get(run.taskId);
|
|
167
|
+
// Durable caches alone are not authorization to disclose Action text.
|
|
168
|
+
run.actionAssociation = !expected ? { status: 'signed_out', binding: null, candidates: [], canCorrect: false } :
|
|
169
|
+
!view || view.auth !== expected ? { status: 'loading', binding: null, candidates: [], canCorrect: false } : {
|
|
170
|
+
status: view.error || local?.status || 'waiting_boundary',
|
|
171
|
+
binding: view.namespace && local?.namespace === view.namespace ? publicBinding(local.binding) : null,
|
|
172
|
+
candidates: (view.candidates || []).slice(0,5), canCorrect: !view.error,
|
|
173
|
+
retryOn: local?.retryOn || null, truncated: view.candidateTruncated || false,
|
|
174
|
+
};
|
|
175
|
+
let bytes = Buffer.byteLength(JSON.stringify(run.actionAssociation));
|
|
176
|
+
if (bytes > Math.min(remainingBytes,perRunBytes)) { run.actionAssociation.candidates = []; run.actionAssociation.truncated = true; bytes = Buffer.byteLength(JSON.stringify(run.actionAssociation)); }
|
|
177
|
+
if (bytes > Math.min(remainingBytes,perRunBytes) && run.actionAssociation.binding) {
|
|
178
|
+
const binding = run.actionAssociation.binding;
|
|
179
|
+
run.actionAssociation.binding = { taskId: binding.taskId, revision: binding.revision, origin: binding.origin, locked: binding.locked, relation: binding.relation, action: binding.action ? { id: binding.action.id, revision: binding.action.revision, title: binding.action.title.slice(0,40), status: binding.action.status, dueAt: binding.action.dueAt, timeZone: binding.action.timeZone } : null };
|
|
180
|
+
bytes = Buffer.byteLength(JSON.stringify(run.actionAssociation));
|
|
181
|
+
}
|
|
182
|
+
remainingBytes = Math.max(0,remainingBytes - bytes);
|
|
183
|
+
}
|
|
184
|
+
return status;
|
|
185
|
+
},
|
|
186
|
+
correct(command) {
|
|
187
|
+
return enqueue(async () => {
|
|
188
|
+
const expected = await current(), record = await fresh(command);
|
|
189
|
+
if (!expected) throw new ActionAssociationApiError('signed_out');
|
|
190
|
+
if (!record) throw new ActionAssociationApiError('invalid_request');
|
|
191
|
+
await sync(record, expected, true, false);
|
|
192
|
+
const view = views.get(command.taskId);
|
|
193
|
+
if (!await valid(expected) || !view?.namespace || view.error) throw new ActionAssociationApiError(view?.error || 'unavailable');
|
|
194
|
+
if (command.action === 'session_action_refresh') { view.nextRefresh = 0; return; }
|
|
195
|
+
const latest = await fresh(command), binding = latest?.run.managed.actionAssociation?.binding;
|
|
196
|
+
if ((binding?.revision ?? null) !== command.expectedBindingRevision) throw new ActionAssociationApiError('conflict');
|
|
197
|
+
const selected = command.action === 'session_action_select' ? view.snapshot?.candidates.find(item => item.id === command.actionId) || (!view.snapshot && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(command.actionId || '') ? { id: command.actionId, revision: null } : null) : null;
|
|
198
|
+
if (command.action !== 'session_action_unlink' && !selected) throw new ActionAssociationApiError('invalid_request');
|
|
199
|
+
const envelope = await api.saveBinding(command.taskId, {
|
|
200
|
+
expectedRevision: command.expectedBindingRevision, contextRevision: view.contextRevision,
|
|
201
|
+
candidateRevision: view.snapshot?.candidateRevision || null, origin: 'user', actionId: selected?.id || null,
|
|
202
|
+
actionRevision: selected?.revision || null, relation: selected ? 'part_of' : null,
|
|
203
|
+
coveredWork: latest.run.managed.sessionUnderstanding?.objective || latest.run.objective,
|
|
204
|
+
reason: selected ? 'Selected by the user.' : 'Unlinked by the user.', evidence: [],
|
|
205
|
+
}, { signal: abort.signal });
|
|
206
|
+
if (namespaceFor(webOrigin, envelope.scope) !== view.namespace) throw new ActionAssociationApiError('account_changed');
|
|
207
|
+
await importEnvelope(latest, envelope, expected, true);
|
|
208
|
+
view.error = null; view.nextRefresh = 0;
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
async close() { closed = true; abort.abort(); await Promise.allSettled([serial, running]); views.clear(); },
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { listRecords, updateRecord, TERMINAL } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
3
|
+
import { associationSignals, associationRevision } from '../codex-plugins/simy/hooks/action-association.mjs';
|
|
4
|
+
export { associationSignals, associationRevision, associationContract, validateAssociationResult } from '../codex-plugins/simy/hooks/action-association.mjs';
|
|
5
|
+
|
|
6
|
+
function stable(run) {
|
|
7
|
+
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
|
+
}
|
|
9
|
+
function restoreAssociationWork(run, saved) {
|
|
10
|
+
const managed = run.managed;
|
|
11
|
+
managed.phase = saved.phase; managed.plan = saved.plan; managed.decision = saved.decision;
|
|
12
|
+
run.nextStep = saved.nextStep; run.checkpoint = saved.checkpoint;
|
|
13
|
+
if (run.state === saved.state) run.reason = saved.reason;
|
|
14
|
+
if (run.state === 'recovery_required' && run.recoveryCause === 'provider_error' && !managed.providerWarning && !managed.attempts?.at(-1)?.restorationUnverified && run.failures < run.maxFailures) {
|
|
15
|
+
run.state = saved.state; run.reason = saved.reason; run.recoveryCause = saved.recoveryCause; run.blocker = saved.blocker; managed.autoRecovery = saved.autoRecovery;
|
|
16
|
+
}
|
|
17
|
+
managed.awaitingAssociationHook = false;
|
|
18
|
+
}
|
|
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 => {
|
|
21
|
+
const run = record?.run, managed = run?.managed;
|
|
22
|
+
if (!managed || managed.taskId !== taskId || run.revision !== expectedRevision) return record;
|
|
23
|
+
if (!stable(run) || now >= run.deadline || run.continuations >= run.maxContinuations) {
|
|
24
|
+
if (!managed.actionAssociation?.job && !managed.actionAssociation?.outbox && !managed.actionAssociation?.manualOverride) managed.actionAssociation = { ...managed.actionAssociation, status: 'waiting_boundary', retryOn: 'original_session_safe_boundary', updatedAt: now };
|
|
25
|
+
return record;
|
|
26
|
+
}
|
|
27
|
+
if (managed.actionAssociation?.manualOverride && !managed.actionAssociation.namespace) return record;
|
|
28
|
+
if (managed.actionAssociation?.namespace !== namespace) managed.actionAssociation = { status: 'unavailable', namespace };
|
|
29
|
+
if (managed.actionAssociation.manualOverride || managed.actionAssociation.binding?.locked) return record;
|
|
30
|
+
if (remoteBinding && managed.actionAssociation.binding?.revision > remoteBinding.revision) return record;
|
|
31
|
+
if (remoteBinding?.taskId === taskId) { managed.actionAssociation.binding = remoteBinding; managed.actionAssociation.manualOverride = remoteBinding.locked === true; managed.actionAssociation.status = remoteBinding.action ? 'bound' : 'unlinked'; if (remoteBinding.action && remoteBinding.contextRevision === associationSignals(managed.sessionUnderstanding)?.revision) return record; }
|
|
32
|
+
if (managed.actionAssociation?.manualOverride || managed.actionAssociation?.job || managed.actionAssociation?.outbox) return record;
|
|
33
|
+
const signals = associationSignals(managed.sessionUnderstanding);
|
|
34
|
+
if (!signals || !Array.isArray(candidates) || candidates.length > 10 || !candidateRevision || !scope) return record;
|
|
35
|
+
const key = associationRevision({ context: signals, candidateRevision, scope, namespace });
|
|
36
|
+
if (managed.actionAssociation?.cacheKey === key) return record;
|
|
37
|
+
const job = { id: randomUUID(), taskId, provider, session, runRevision: run.revision, understandingRevision: managed.understandingRevision, context: signals, candidates, candidateRevision, scope, namespace, cacheKey: key,
|
|
38
|
+
saved: { phase: managed.phase, plan: managed.plan, decision: managed.decision, nextStep: run.nextStep, state: run.state, reason: run.reason, checkpoint: run.checkpoint, recoveryCause: run.recoveryCause, blocker: run.blocker, autoRecovery: managed.autoRecovery }, createdAt: now };
|
|
39
|
+
managed.actionAssociation = { ...managed.actionAssociation, status: candidates.length ? 'assessing' : 'no_match', namespace, cacheKey: key, ...(candidates.length ? { job } : {}) };
|
|
40
|
+
if (!candidates.length) return record;
|
|
41
|
+
managed.phase = 'action_association';
|
|
42
|
+
managed.decision = { id: job.id, outcome: 'proceed', action: { kind: 'read', scope: 'task', summary: 'Compare scoped Action candidates with the original user request' } };
|
|
43
|
+
run.nextStep = managed.decision.action.summary;
|
|
44
|
+
if (managed.capability === 'live_hook') managed.awaitingAssociationHook = true;
|
|
45
|
+
return record;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
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 => {
|
|
50
|
+
const run = record?.run, managed = run?.managed;
|
|
51
|
+
if (managed?.taskId !== taskId || managed.handoff && managed.handoff.status !== 'received') return record;
|
|
52
|
+
const previous = managed.actionAssociation || {};
|
|
53
|
+
if (previous.namespace && previous.namespace !== namespace && previous.job && managed.resumeLease) { previous.namespace = namespace; previous.binding = null; previous.status = 'unavailable'; previous.invalidated = true; delete previous.outbox; return record; }
|
|
54
|
+
if (userCorrection && previous.job && !managed.resumeLease) {
|
|
55
|
+
const saved = previous.job.saved;
|
|
56
|
+
restoreAssociationWork(run, saved);
|
|
57
|
+
delete previous.job;
|
|
58
|
+
}
|
|
59
|
+
if (binding && binding.taskId !== taskId) throw new Error('Remote binding task differs.');
|
|
60
|
+
const association = previous.namespace && previous.namespace !== namespace ? {} : previous;
|
|
61
|
+
association.namespace = namespace;
|
|
62
|
+
if (binding && association.binding?.revision > binding.revision) return record;
|
|
63
|
+
if (binding) {
|
|
64
|
+
association.binding = binding; association.manualOverride = binding.locked === true;
|
|
65
|
+
association.status = binding.action ? 'bound' : 'unlinked';
|
|
66
|
+
if (userCorrection || binding.locked) delete association.outbox;
|
|
67
|
+
} else if (unavailable) { association.binding = null; association.status = 'unavailable'; delete association.outbox; }
|
|
68
|
+
else { association.binding = null; association.manualOverride = false; if (!association.job && !association.outbox) association.status = 'waiting_boundary'; }
|
|
69
|
+
association.updatedAt = now; managed.actionAssociation = association;
|
|
70
|
+
return record;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
export const correctActionAssociation = options => importActionBinding({ ...options, userCorrection: true });
|
|
74
|
+
export async function pendingAssociationTasks(root) {
|
|
75
|
+
return (await listRecords(root)).filter(record => stable(record.run) && !record.run.managed.actionAssociation?.manualOverride && !record.run.managed.actionAssociation?.job && !record.run.managed.actionAssociation?.outbox && associationSignals(record.run.managed.sessionUnderstanding));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function actionCandidateRequest(record) {
|
|
79
|
+
const context = associationSignals(record?.run?.managed?.sessionUnderstanding);
|
|
80
|
+
return context ? { taskId: record.run.managed.taskId, context, limit: 10 } : null;
|
|
81
|
+
}
|
|
82
|
+
export async function prepareActionAssociation({ root, record, snapshot, webOrigin, now = Date.now() }) {
|
|
83
|
+
const request = actionCandidateRequest(record);
|
|
84
|
+
if (!request || snapshot?.schemaVersion !== 1 || snapshot.taskId !== request.taskId || snapshot.contextRevision !== request.context.revision || !snapshot.scope?.organizationId || !snapshot.scope?.userId) return null;
|
|
85
|
+
if (!Array.isArray(snapshot.candidates) || snapshot.candidates.length > 10 || new Set(snapshot.candidates.map(item => item.id)).size !== snapshot.candidates.length) return null;
|
|
86
|
+
for (const item of snapshot.candidates) {
|
|
87
|
+
if (typeof item.id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(item.id) || typeof item.revision !== 'string' || item.revision.length > 200 || typeof item.title !== 'string' || item.title.length > 500 || typeof item.description === 'string' && item.description.length > 2000 || !Array.isArray(item.evidence) || item.evidence.length > 10 || new Set(item.evidence.map(fact => fact.ref)).size !== item.evidence.length || item.evidence.some(fact => typeof fact.ref !== 'string' || fact.ref.length > 200 || !['goal','deliverable','project','source'].includes(fact.kind) || typeof fact.text !== 'string' || fact.text.length > 2000)) return null;
|
|
88
|
+
}
|
|
89
|
+
const namespace = JSON.stringify([new URL(webOrigin).origin,snapshot.scope.organizationId,snapshot.scope.userId]);
|
|
90
|
+
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 });
|
|
92
|
+
}
|
|
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 => {
|
|
95
|
+
const association = record?.run?.managed?.actionAssociation;
|
|
96
|
+
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
|
+
if (binding && binding.taskId === taskId) {
|
|
98
|
+
association.binding = binding; association.manualOverride = binding.locked === true;
|
|
99
|
+
association.status = binding.action ? 'bound' : 'unlinked'; delete association.outbox;
|
|
100
|
+
} else if (unavailable) { association.status = 'unavailable'; association.binding = null; delete association.outbox; }
|
|
101
|
+
association.updatedAt = now;
|
|
102
|
+
return record;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
export async function cancelActionAssociation({ root, provider, session, taskId, now = Date.now() }) {
|
|
106
|
+
return updateRecord(root, provider, session, record => {
|
|
107
|
+
const run = record?.run, managed = run?.managed, association = managed?.actionAssociation;
|
|
108
|
+
if (managed?.taskId !== taskId || managed.resumeLease || !association?.job) return record;
|
|
109
|
+
const saved = association.job.saved;
|
|
110
|
+
restoreAssociationWork(run, saved);
|
|
111
|
+
managed.awaitingAssociationHook = false; association.status = 'unavailable'; delete association.job; association.updatedAt = now;
|
|
112
|
+
return record;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -9,7 +9,7 @@ const RESULT_STATES = new Set(['continue', 'no_action', 'needs_user', 'waiting',
|
|
|
9
9
|
const keyOf = candidate => `${candidate.provider}:${candidate.session}`;
|
|
10
10
|
const activityOf = candidate => Number.isFinite(candidate.lastActivityAt) ? candidate.lastActivityAt : 0;
|
|
11
11
|
export const continuationAnalysisRevision = candidate => createHash('sha256')
|
|
12
|
-
.update(JSON.stringify([candidate.provider, candidate.session, candidate.cwd, activityOf(candidate)]))
|
|
12
|
+
.update(JSON.stringify([candidate.provider, candidate.session, candidate.cwd, activityOf(candidate), ...(candidate.isRunning === true ? ['running'] : [])]))
|
|
13
13
|
.digest('hex');
|
|
14
14
|
const eligible = candidate => candidate.capabilities?.canAnalyze === true &&
|
|
15
15
|
['unmanaged', 'monitor'].includes(candidate.binding?.mode || 'unmanaged');
|
|
@@ -60,15 +60,19 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
60
60
|
} finally { await rm(temporary, { force: true }); }
|
|
61
61
|
};
|
|
62
62
|
const changed = () => { void Promise.resolve().then(onChange).catch(() => {}); };
|
|
63
|
-
const enqueue = (candidate, force = false) => {
|
|
63
|
+
const enqueue = (candidate, force = false, forceAnalysis = false) => {
|
|
64
64
|
const key = keyOf(candidate), revision = continuationAnalysisRevision(candidate);
|
|
65
65
|
const entry = entries.get(key);
|
|
66
66
|
if (entry?.revision === revision && (['queued', 'analyzing'].includes(entry.result?.state) ||
|
|
67
67
|
!force && !['failed', 'uncertain'].includes(entry.result?.state))) return false;
|
|
68
68
|
entries.delete(key);
|
|
69
|
-
entries.set(key, { revision,
|
|
69
|
+
entries.set(key, { revision, cwd: candidate.cwd, forceAnalysis, settledResult: entry?.cwd === candidate.cwd ? entry.settledResult : null,
|
|
70
|
+
result: { state: 'queued' } });
|
|
70
71
|
return true;
|
|
71
72
|
};
|
|
73
|
+
const finishedAnalysis = (result, previous) => result.contextDigest && result.settledTurnId
|
|
74
|
+
? { ...result, awaitingTurn: false, analyzedAt: result.contextDigest === previous?.contextDigest ? previous.analyzedAt : now() }
|
|
75
|
+
: previous;
|
|
72
76
|
const startDrain = () => {
|
|
73
77
|
if (closed || draining) return;
|
|
74
78
|
draining = (async () => {
|
|
@@ -80,7 +84,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
80
84
|
if (entry.result?.state !== 'queued' || !candidate || !eligible(candidate) || entry.revision !== continuationAnalysisRevision(candidate)) continue;
|
|
81
85
|
entry.result = { state: 'analyzing' };
|
|
82
86
|
await persist(); changed();
|
|
83
|
-
return { key, revision: entry.revision, candidate };
|
|
87
|
+
return { key, revision: entry.revision, candidate, previousAnalysis: entry.forceAnalysis ? null : entry.settledResult };
|
|
84
88
|
}
|
|
85
89
|
return null;
|
|
86
90
|
});
|
|
@@ -96,7 +100,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
96
100
|
});
|
|
97
101
|
let result, inference;
|
|
98
102
|
try {
|
|
99
|
-
inference = Promise.resolve(analyze(selected.candidate, { signal }));
|
|
103
|
+
inference = Promise.resolve(analyze(selected.candidate, { signal, previousAnalysis: selected.previousAnalysis }));
|
|
100
104
|
result = await Promise.race([inference, cancellation]);
|
|
101
105
|
if (!RESULT_STATES.has(result?.state) || typeof result.reason !== 'string' ||
|
|
102
106
|
(result.state === 'continue' && (!result.objective || !result.remaining?.length || !result.nextAction || !result.basis?.some(item => item.role === 'user')))) {
|
|
@@ -114,12 +118,22 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
114
118
|
const current = candidates.get(selected.key), entry = entries.get(selected.key);
|
|
115
119
|
if (closed) return;
|
|
116
120
|
if (!current || !eligible(current) || continuationAnalysisRevision(current) !== selected.revision || entry?.revision !== selected.revision) {
|
|
121
|
+
// Live timestamps may advance during inference while the completed
|
|
122
|
+
// boundary stays identical. Retain that snapshot for display/reuse;
|
|
123
|
+
// never publish it as a current actionable recommendation.
|
|
124
|
+
if (current && eligible(current) && current.cwd === selected.candidate.cwd && entry?.cwd === current.cwd &&
|
|
125
|
+
result.contextDigest && result.settledTurnId && ['queued', 'analyzing'].includes(entry.result?.state)) {
|
|
126
|
+
entry.settledResult = finishedAnalysis(result, selected.previousAnalysis);
|
|
127
|
+
await persist(); changed();
|
|
128
|
+
}
|
|
117
129
|
if (entry?.revision === selected.revision && entry.result?.state === 'analyzing') {
|
|
118
130
|
entry.result = { state: 'queued' }; await persist(); changed();
|
|
119
131
|
}
|
|
120
132
|
return;
|
|
121
133
|
}
|
|
122
|
-
|
|
134
|
+
const settledResult = finishedAnalysis(result, selected.previousAnalysis || entry.settledResult);
|
|
135
|
+
entries.set(selected.key, { revision: selected.revision, cwd: current.cwd, settledResult,
|
|
136
|
+
result: { ...result, analyzedAt: settledResult?.analyzedAt || now() } });
|
|
123
137
|
await persist(); changed();
|
|
124
138
|
});
|
|
125
139
|
}
|
|
@@ -141,10 +155,21 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
141
155
|
for (const candidate of sorted) {
|
|
142
156
|
const key = keyOf(candidate), revision = continuationAnalysisRevision(candidate), previous = known.get(key);
|
|
143
157
|
const active = activityOf(candidate);
|
|
144
|
-
const newActivity = document.initialized && (previous ? active > previous.activity : active > document.baselineAt);
|
|
145
|
-
|
|
158
|
+
const newActivity = document.initialized && (previous ? active > previous.activity || (candidate.isRunning === true) !== (previous.isRunning === true) : active > document.baselineAt);
|
|
159
|
+
const entry = entries.get(key);
|
|
160
|
+
// Migrate prior idle cache entries without treating historical sessions
|
|
161
|
+
// as fresh activity. Its old revision already proves this exact cwd.
|
|
162
|
+
if (entry && !entry.cwd && entry.revision === continuationAnalysisRevision({ ...candidate, isRunning: false })) {
|
|
163
|
+
entry.cwd = candidate.cwd;
|
|
164
|
+
entry.settledResult = entry.result?.contextDigest ? entry.result : null;
|
|
165
|
+
dirty = true;
|
|
166
|
+
}
|
|
167
|
+
// A reader that raced a terminal boundary (or a provider without live
|
|
168
|
+
// status) is retried at discovery cadence, never a busy inference loop.
|
|
169
|
+
const recheck = entry?.result?.reasonCode === 'turn_settling' || entry?.result?.awaitingTurn === true && candidate.isRunning !== true;
|
|
170
|
+
if (eligible(candidate) && (backfill.has(key) || newActivity || recheck)) dirty = enqueue(candidate, recheck) || dirty;
|
|
146
171
|
if (previous?.revision !== revision) {
|
|
147
|
-
known.delete(key); known.set(key, { revision, activity: active }); dirty = true;
|
|
172
|
+
known.delete(key); known.set(key, { revision, activity: active, isRunning: candidate.isRunning }); dirty = true;
|
|
148
173
|
}
|
|
149
174
|
}
|
|
150
175
|
// Empty discovery during provider startup must not consume backfill.
|
|
@@ -155,14 +180,23 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
155
180
|
},
|
|
156
181
|
project(candidate) {
|
|
157
182
|
const revision = continuationAnalysisRevision(candidate), entry = entries.get(keyOf(candidate));
|
|
158
|
-
|
|
183
|
+
let analysis = entry?.revision === revision ? { ...entry.result, revision } : { state: 'unanalysed', revision };
|
|
184
|
+
const previous = entry?.cwd === candidate.cwd ? entry.settledResult : null;
|
|
185
|
+
if (candidate.isRunning === true || analysis.awaitingTurn === true) {
|
|
186
|
+
analysis = { state: 'waiting', awaitingTurn: true, revision,
|
|
187
|
+
reason: 'This turn is still running. SIMY analyzes only finished turns and will update automatically when it ends.',
|
|
188
|
+
...(previous ? { previousAnalysis: previous } : {}) };
|
|
189
|
+
} else if (previous && ['queued', 'analyzing'].includes(analysis.state)) {
|
|
190
|
+
analysis.previousAnalysis = previous;
|
|
191
|
+
}
|
|
192
|
+
return { ...candidate, analysis };
|
|
159
193
|
},
|
|
160
194
|
async request(candidate) {
|
|
161
195
|
if (!candidate || !eligible(candidate)) throw new Error('This conversation cannot be analyzed.');
|
|
162
196
|
await exclusive(async () => {
|
|
163
197
|
if (closed) throw new Error('Session analysis is unavailable.');
|
|
164
198
|
candidates.set(keyOf(candidate), candidate);
|
|
165
|
-
if (enqueue(candidate, true)) { await persist(); changed(); }
|
|
199
|
+
if (enqueue(candidate, true, true)) { await persist(); changed(); }
|
|
166
200
|
});
|
|
167
201
|
startDrain();
|
|
168
202
|
},
|
|
@@ -178,7 +212,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
|
|
|
178
212
|
requireRecommendation(candidate) {
|
|
179
213
|
if (closed) throw new Error('Session analysis is unavailable.');
|
|
180
214
|
const analysis = this.project(candidate).analysis;
|
|
181
|
-
if (!eligible(candidate) || analysis.state !== 'continue') throw new Error('Analyze this conversation before continuing; it needs a current recommendation.');
|
|
215
|
+
if (!eligible(candidate) || candidate.isRunning === true || analysis.awaitingTurn === true || analysis.state !== 'continue') throw new Error('Analyze this conversation before continuing; it needs a current recommendation.');
|
|
182
216
|
return analysis;
|
|
183
217
|
},
|
|
184
218
|
async whenIdle() { do { await serial; await draining; } while (draining); await serial; },
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { associationContract } from '../codex-plugins/simy/hooks/action-association.mjs';
|
|
1
2
|
import { spawn } from 'node:child_process';
|
|
2
3
|
import { realpath } from 'node:fs/promises';
|
|
3
4
|
import { inspectBackendExecutable } from '../backend-executable.js';
|
|
@@ -6,6 +7,9 @@ import { boundConversation, textContent } from './conversation-context.js';
|
|
|
6
7
|
|
|
7
8
|
const REQUEST_TIMEOUT = 15_000;
|
|
8
9
|
const MAX_LINE = 256 * 1024;
|
|
10
|
+
const ANALYSIS_MAX_LINE = 1024 * 1024;
|
|
11
|
+
const ANALYSIS_MAX_TURNS = 16;
|
|
12
|
+
const ANALYSIS_PAGE_SIZE = 6;
|
|
9
13
|
const SOURCE_KINDS = ['cli', 'vscode', 'exec', 'appServer', 'unknown'];
|
|
10
14
|
|
|
11
15
|
export class CodexAppServerError extends Error {
|
|
@@ -34,7 +38,10 @@ export class CodexAppServerClient {
|
|
|
34
38
|
const end = newline < 0 ? chunk.length : newline;
|
|
35
39
|
const piece = chunk.subarray(offset, end);
|
|
36
40
|
if (!discard) {
|
|
37
|
-
if (pending.length + piece.length > this.maxLineBytes) {
|
|
41
|
+
if (pending.length + piece.length > this.maxLineBytes) {
|
|
42
|
+
pending = Buffer.alloc(0); discard = true;
|
|
43
|
+
this.rejectPending(new CodexAppServerError('response_too_large', 'Codex response exceeded the bounded context limit.'));
|
|
44
|
+
}
|
|
38
45
|
else if (piece.length) pending = pending.length ? Buffer.concat([pending, piece]) : Buffer.from(piece);
|
|
39
46
|
}
|
|
40
47
|
if (newline >= 0) {
|
|
@@ -50,7 +57,10 @@ export class CodexAppServerClient {
|
|
|
50
57
|
}
|
|
51
58
|
|
|
52
59
|
consume(line) {
|
|
53
|
-
if (Buffer.byteLength(line) > this.maxLineBytes)
|
|
60
|
+
if (Buffer.byteLength(line) > this.maxLineBytes) {
|
|
61
|
+
this.rejectPending(new CodexAppServerError('response_too_large', 'Codex response exceeded the bounded context limit.'));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
54
64
|
let message;
|
|
55
65
|
try { message = JSON.parse(line); } catch { return; }
|
|
56
66
|
if (typeof message.method === 'string') {
|
|
@@ -68,6 +78,10 @@ export class CodexAppServerClient {
|
|
|
68
78
|
fail(error) {
|
|
69
79
|
if (this.closed) return;
|
|
70
80
|
this.closed = true;
|
|
81
|
+
this.rejectPending(error);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
rejectPending(error) {
|
|
71
85
|
for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error); }
|
|
72
86
|
this.pending.clear();
|
|
73
87
|
}
|
|
@@ -138,6 +152,7 @@ export function codexThreadMetadata(thread) {
|
|
|
138
152
|
lastActivityAt: Number.isFinite(thread.updatedAt) ? thread.updatedAt * 1000 : null,
|
|
139
153
|
model: typeof thread.model === 'string' ? thread.model : null,
|
|
140
154
|
observation: { source: 'codex_app_server', observedAt: Date.now() },
|
|
155
|
+
...(thread.status?.type === 'active' || thread.status?.type === 'idle' ? { isRunning: thread.status.type === 'active' } : {}),
|
|
141
156
|
availability: 'unknown',
|
|
142
157
|
};
|
|
143
158
|
}
|
|
@@ -166,26 +181,75 @@ export async function readCodexSession(client, session) {
|
|
|
166
181
|
|
|
167
182
|
export async function readCodexConversation(candidate, { openClient = openCodexAppServer, signal } = {}) {
|
|
168
183
|
if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
|
|
169
|
-
const client = await openClient({ signal, maxLineBytes:
|
|
184
|
+
const client = await openClient({ signal, maxLineBytes: ANALYSIS_MAX_LINE });
|
|
170
185
|
const cancelled = () => { void client.close(); };
|
|
171
186
|
signal?.addEventListener('abort', cancelled, { once: true });
|
|
172
187
|
try {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
188
|
+
// Full thread history can contain tens of MiB of tool output. Analysis
|
|
189
|
+
// reads only metadata and a fixed number of recent display summaries.
|
|
190
|
+
const metadata = await readCodexSession(client, candidate.session);
|
|
191
|
+
if (metadata.cwd !== candidate.cwd) throw new CodexAppServerError('identity', 'Exact conversation identity is unavailable.');
|
|
192
|
+
if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
|
|
193
|
+
const terminal = turn => ['completed', 'interrupted', 'failed'].includes(turn?.status);
|
|
194
|
+
const turns = [];
|
|
195
|
+
const turnIds = new Set(), cursors = new Set();
|
|
196
|
+
let cursor = null;
|
|
197
|
+
do {
|
|
198
|
+
if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
|
|
199
|
+
const limit = Math.min(ANALYSIS_PAGE_SIZE, ANALYSIS_MAX_TURNS - turns.filter(terminal).length);
|
|
200
|
+
let page;
|
|
201
|
+
try {
|
|
202
|
+
page = await client.request('thread/turns/list', { threadId: candidate.session, cursor, limit, sortDirection: 'desc', itemsView: 'summary' });
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (String(error?.code) === '-32601' || /unknown method|method.*not found|unrecognized method/i.test(String(error?.message || ''))) {
|
|
205
|
+
throw new CodexAppServerError('unsupported', 'This Codex version does not support bounded conversation history.');
|
|
206
|
+
}
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
if (!Array.isArray(page?.data) || page.data.length > limit || page.nextCursor != null && (typeof page.nextCursor !== 'string' || !page.nextCursor || page.nextCursor.length > 2048)) {
|
|
210
|
+
throw new CodexAppServerError('context_unavailable', 'Codex returned an invalid bounded conversation page.');
|
|
211
|
+
}
|
|
212
|
+
for (const turn of page.data) {
|
|
213
|
+
if (typeof turn?.id !== 'string' || !turn.id || turnIds.has(turn.id)) throw new CodexAppServerError('context_unavailable', 'Codex returned ambiguous conversation pagination.');
|
|
214
|
+
turnIds.add(turn.id); turns.push(turn);
|
|
215
|
+
}
|
|
216
|
+
cursor = page.nextCursor || null;
|
|
217
|
+
if (cursor && (!page.data.length || cursors.has(cursor))) throw new CodexAppServerError('context_unavailable', 'Codex returned ambiguous conversation pagination.');
|
|
218
|
+
if (cursor) cursors.add(cursor);
|
|
219
|
+
} while (cursor && turns.filter(terminal).length < ANALYSIS_MAX_TURNS && turns.length < ANALYSIS_MAX_TURNS + ANALYSIS_PAGE_SIZE);
|
|
220
|
+
// A timestamp covers the whole live thread. Instead pin the last terminal
|
|
221
|
+
// turn and check it again: streaming output in the newer turn is irrelevant
|
|
222
|
+
// to this read-only snapshot, but a newly settled turn must invalidate it.
|
|
223
|
+
const settled = turns.filter(terminal).slice(0, ANALYSIS_MAX_TURNS);
|
|
224
|
+
const boundary = settled[0];
|
|
225
|
+
const verification = await client.request('thread/turns/list', {
|
|
226
|
+
threadId: candidate.session, cursor: null, limit: ANALYSIS_PAGE_SIZE, sortDirection: 'desc', itemsView: 'summary',
|
|
227
|
+
});
|
|
228
|
+
if (!Array.isArray(verification?.data) || verification.data.length > ANALYSIS_PAGE_SIZE) {
|
|
229
|
+
throw new CodexAppServerError('context_unavailable', 'Codex returned an invalid snapshot check.');
|
|
230
|
+
}
|
|
231
|
+
const latestBoundary = verification.data.find(terminal);
|
|
232
|
+
// Compare the immutable settled summary, never the running turn's prose.
|
|
233
|
+
if (JSON.stringify(boundary) !== JSON.stringify(latestBoundary)) {
|
|
234
|
+
throw new CodexAppServerError('turn_settling', 'The latest turn settled during the snapshot read.');
|
|
177
235
|
}
|
|
178
|
-
|
|
179
|
-
|
|
236
|
+
const latestMetadata = await readCodexSession(client, candidate.session);
|
|
237
|
+
if (latestMetadata.cwd !== candidate.cwd) throw new CodexAppServerError('identity', 'Exact conversation identity is unavailable.');
|
|
238
|
+
if (latestMetadata.model !== metadata.model) {
|
|
239
|
+
throw new CodexAppServerError('revision_changed', 'Conversation changed while reading recent history.');
|
|
180
240
|
}
|
|
181
241
|
if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
|
|
182
|
-
const turns = response.thread.turns;
|
|
183
|
-
if (!Array.isArray(turns)) return boundConversation(null);
|
|
184
242
|
const messages = [];
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
243
|
+
// A display summary omits intermediate reasoning/tool context even when
|
|
244
|
+
// every turn fits. Never represent it as the complete conversation; the
|
|
245
|
+
// classifier must establish a self-contained objective from retained text.
|
|
246
|
+
const awaitingTurn = latestMetadata.isRunning === true || [...turns, ...verification.data].some(turn => ['inProgress', 'in_progress'].includes(turn?.status));
|
|
247
|
+
let incomplete = !settled.length || turns.some(turn => !terminal(turn) && !['inProgress', 'in_progress'].includes(turn?.status)), truncated = true;
|
|
248
|
+
for (const [index, turn] of settled.toReversed().entries()) {
|
|
249
|
+
if (turn.itemsView === 'notLoaded') { truncated = true; if (turn === boundary) incomplete = true; continue; }
|
|
250
|
+
if (turn.itemsView !== 'summary') throw new CodexAppServerError('context_unavailable', 'Codex did not return the requested conversation summaries.');
|
|
188
251
|
if (!Array.isArray(turn?.items)) { incomplete = true; continue; }
|
|
252
|
+
if (turn === boundary && !turn.items.length) incomplete = true;
|
|
189
253
|
for (const [itemIndex, item] of turn.items.entries()) {
|
|
190
254
|
const role = item?.type === 'userMessage' ? 'user' : item?.type === 'agentMessage' ? 'assistant' : null;
|
|
191
255
|
if (!role) continue;
|
|
@@ -194,7 +258,7 @@ export async function readCodexConversation(candidate, { openClient = openCodexA
|
|
|
194
258
|
messages.push({ role, turnRef: `${turn.id || index}:${item.id || itemIndex}`, text });
|
|
195
259
|
}
|
|
196
260
|
}
|
|
197
|
-
return boundConversation(messages, { incomplete });
|
|
261
|
+
return { ...boundConversation(messages, { incomplete, truncated }), awaitingTurn, settledTurnId: boundary?.id || null };
|
|
198
262
|
} finally {
|
|
199
263
|
signal?.removeEventListener('abort', cancelled);
|
|
200
264
|
await client.close();
|
|
@@ -235,7 +299,7 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
|
|
|
235
299
|
...(authorizedWait ? { approvedAction: { approvalId: approved.id, action: approved.action, oneShot: true } } : {}),
|
|
236
300
|
budgets: { deadline: run.deadline, continuations: run.continuations, maxContinuations: run.maxContinuations, failures: run.failures, maxFailures: run.maxFailures } };
|
|
237
301
|
const approvalInstruction = authorizedWait ? `SIMY recorded a matching, one-shot scoped user approval ${approved.id} for this exact selected action. The decision outcome "wait" describes its pre-approval assessment; the approved action is authorized now. Execute it once, then report what actually happened. ` : '';
|
|
238
|
-
const prompt = run.managed.phase === 'understanding' ? `Understand the ORIGINAL user request in this exact Codex conversation. This first/clarifying turn is read-only and must not perform task work. Context: ${JSON.stringify({ ...context, priorUnderstanding: run.managed.sessionUnderstanding, userAnswer: run.managed.understandingAnswer })}\n${understandingCheckpointContract(decision)} Do not write SIMY state directly.` : `Continue this existing SIMY managed task in the exact Codex conversation. Context: ${JSON.stringify(context)}\n${approvalInstruction}Execute ONLY the selected decision action. Do not perform any action from the NEXT plan in this turn. For a next local action within the enrolled task use the exact scope "task"; do not relabel external, broad, or destructive work as local. Preserve provider permissions. End with one JSON checkpoint. ${assessedCheckpointContract(decision)} Do not write SIMY state directly.`;
|
|
302
|
+
const prompt = run.managed.phase === 'action_association' ? associationContract(run.managed.actionAssociation.job) : run.managed.phase === 'understanding' ? `Understand the ORIGINAL user request in this exact Codex conversation. This first/clarifying turn is read-only and must not perform task work. Context: ${JSON.stringify({ ...context, priorUnderstanding: run.managed.sessionUnderstanding, userAnswer: run.managed.understandingAnswer })}\n${understandingCheckpointContract(decision)} Do not write SIMY state directly.` : `Continue this existing SIMY managed task in the exact Codex conversation. Context: ${JSON.stringify(context)}\n${approvalInstruction}Execute ONLY the selected decision action. Do not perform any action from the NEXT plan in this turn. For a next local action within the enrolled task use the exact scope "task"; do not relabel external, broad, or destructive work as local. Preserve provider permissions. End with one JSON checkpoint. ${assessedCheckpointContract(decision)} Do not write SIMY state directly.`;
|
|
239
303
|
const initialSandbox = run.managed.providerSandbox || resumed.sandbox;
|
|
240
304
|
const currentSandbox = resumed.sandbox;
|
|
241
305
|
originalSandbox = currentSandbox;
|