polygram 0.22.0 → 0.23.1
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/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -3
- package/config.example.json +23 -1
- package/lib/codex/binary.js +287 -0
- package/lib/codex/delivery-finalizer.js +97 -0
- package/lib/codex/diagnostics.js +483 -0
- package/lib/codex/event-normalizer.js +50 -0
- package/lib/codex/host-identity.js +103 -0
- package/lib/codex/model-catalog.js +359 -0
- package/lib/codex/retention-scheduler.js +59 -0
- package/lib/codex/runtime-controller.js +1292 -0
- package/lib/codex/runtime-profile.js +888 -0
- package/lib/codex/spawn-context.js +87 -0
- package/lib/db/auto-resume.js +77 -9
- package/lib/db/codex-reconciliation.js +490 -0
- package/lib/db/codex-retention.js +139 -0
- package/lib/db/sessions.js +102 -1
- package/lib/db.js +3418 -19
- package/lib/error/classify.js +140 -0
- package/lib/handlers/abort.js +143 -5
- package/lib/handlers/autosteer.js +256 -1
- package/lib/handlers/codex-reconciliation.js +230 -0
- package/lib/handlers/config-callback.js +324 -89
- package/lib/handlers/config-ui.js +401 -39
- package/lib/handlers/dispatcher.js +71 -8
- package/lib/handlers/edit-correction.js +11 -1
- package/lib/handlers/edit-redelivery.js +230 -4
- package/lib/handlers/gate-inbound.js +7 -0
- package/lib/handlers/replay-disposition.js +133 -11
- package/lib/handlers/slash-commands.js +443 -37
- package/lib/ipc/client.js +22 -12
- package/lib/ipc/runtime-directory.js +208 -0
- package/lib/ipc/server.js +18 -12
- package/lib/model-costs.js +4 -3
- package/lib/pm-interface.js +33 -0
- package/lib/prompt.js +15 -9
- package/lib/rewind/rewind.js +21 -1
- package/lib/runtime-config.js +605 -0
- package/lib/sdk/build-options.js +4 -1
- package/lib/sdk/callbacks.js +46 -3
- package/migrations/015-agent-runtime-sessions.sql +50 -0
- package/migrations/016-agent-turn-attempts.sql +211 -0
- package/migrations/017-config-runtime-changes.sql +46 -0
- package/ops/README.md +57 -0
- package/package.json +2 -2
- package/polygram.js +697 -48
- package/scripts/doctor.js +37 -8
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Post-turn edit re-delivery
|
|
5
|
-
* has finished, re-dispatch the edited message as a
|
|
6
|
-
* "an edit is just a message" model.
|
|
7
|
-
*
|
|
4
|
+
* Post-turn edit re-delivery. When the user edits a Telegram message after a
|
|
5
|
+
* turn has finished, re-dispatch the edited message as a new turn so the model
|
|
6
|
+
* acts on the change — the "an edit is just a message" model.
|
|
7
|
+
*
|
|
8
|
+
* Claude retains its per-message mid-turn injection behavior. Codex edits are
|
|
9
|
+
* deferred and dispatched as normal visible turns only when the exact process
|
|
10
|
+
* generation that observed them becomes idle. Teardown or replacement cancels
|
|
11
|
+
* the deferred content.
|
|
8
12
|
*
|
|
9
13
|
* Spec: docs/0.12.0-edit-redelivery-spec.md (twice-reviewed). Key correctness points the review
|
|
10
14
|
* surfaced:
|
|
@@ -47,8 +51,195 @@ function createEditRedelivery({
|
|
|
47
51
|
// redelivery (dispatchHandleMessage autosteers it naturally if a turn is in
|
|
48
52
|
// flight — through the formatted-prompt path, not a hand-built string).
|
|
49
53
|
const redeliveredAt = new Map(); // `${chatId}:${msgId}` → ts
|
|
54
|
+
const deferredCodexBySession = new Map();
|
|
50
55
|
const INTERLOCK_TTL_MS = 10 * 60 * 1000;
|
|
51
56
|
|
|
57
|
+
function codexGenerationId(proc) {
|
|
58
|
+
return typeof proc?.generationId === 'string' && proc.generationId.length > 0
|
|
59
|
+
? proc.generationId
|
|
60
|
+
: null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isCodexProcess(sessionKey, proc) {
|
|
64
|
+
const backend = typeof pm?.getBackend === 'function'
|
|
65
|
+
? pm.getBackend(sessionKey)
|
|
66
|
+
: proc?.backend ?? proc?.runtime;
|
|
67
|
+
return backend === 'codex' || proc?.backend === 'codex' || proc?.runtime === 'codex';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function removeDeferredListeners(state) {
|
|
71
|
+
state.proc.removeListener?.('idle', state.onIdle);
|
|
72
|
+
state.proc.removeListener?.('close', state.onClose);
|
|
73
|
+
state.proc.removeListener?.('session-reset', state.onSessionReset);
|
|
74
|
+
state.proc.removeListener?.('codex-settled', state.onCodexSettled);
|
|
75
|
+
state.proc.removeListener?.('codex-lifecycle', state.onCodexLifecycle);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function safeLogDeferredEvent(kind, detail) {
|
|
79
|
+
try {
|
|
80
|
+
logEvent(kind, detail);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
try {
|
|
83
|
+
logger.error?.(`[edit-redelivery] deferred event log failed: ${error.message}`);
|
|
84
|
+
} catch {
|
|
85
|
+
// Process lifecycle listeners must never throw back into supervision.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function cancelDeferred(state, reason) {
|
|
91
|
+
if (deferredCodexBySession.get(state.sessionKey) !== state) return;
|
|
92
|
+
deferredCodexBySession.delete(state.sessionKey);
|
|
93
|
+
removeDeferredListeners(state);
|
|
94
|
+
safeLogDeferredEvent('edit-redelivery-deferred-cancelled', {
|
|
95
|
+
session_key: state.sessionKey,
|
|
96
|
+
generation_id: state.generationId,
|
|
97
|
+
reason,
|
|
98
|
+
message_count: state.entries.size,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function dispatchDeferredSynthetic({
|
|
103
|
+
sessionKey,
|
|
104
|
+
chatId,
|
|
105
|
+
msgId,
|
|
106
|
+
synthetic,
|
|
107
|
+
oldLength,
|
|
108
|
+
newLength,
|
|
109
|
+
}) {
|
|
110
|
+
dispatchHandleMessage(sessionKey, chatId, synthetic, bot);
|
|
111
|
+
safeLogDeferredEvent('edit-redelivered', {
|
|
112
|
+
chat_id: chatId,
|
|
113
|
+
session_key: sessionKey,
|
|
114
|
+
msg_id: msgId,
|
|
115
|
+
old_len: oldLength,
|
|
116
|
+
new_len: newLength,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function flushDeferred(state) {
|
|
121
|
+
if (deferredCodexBySession.get(state.sessionKey) !== state) return;
|
|
122
|
+
const current = pm?.get?.(state.sessionKey);
|
|
123
|
+
if (
|
|
124
|
+
current !== state.proc
|
|
125
|
+
|| codexGenerationId(current) !== state.generationId
|
|
126
|
+
) {
|
|
127
|
+
cancelDeferred(state, 'generation-replaced');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (current.closed) {
|
|
131
|
+
cancelDeferred(state, 'close');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
// An idle-shaped event is not enough: process state is the final fence
|
|
135
|
+
// against dispatching while the generation still owns a live turn.
|
|
136
|
+
if (current.inFlight) return;
|
|
137
|
+
|
|
138
|
+
deferredCodexBySession.delete(state.sessionKey);
|
|
139
|
+
removeDeferredListeners(state);
|
|
140
|
+
for (const entry of state.entries.values()) {
|
|
141
|
+
try {
|
|
142
|
+
dispatchDeferredSynthetic(entry);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
try {
|
|
145
|
+
logger.error?.(`[edit-redelivery] deferred dispatch failed: ${error.message}`);
|
|
146
|
+
} catch {
|
|
147
|
+
// Process lifecycle listeners must never throw back into supervision.
|
|
148
|
+
}
|
|
149
|
+
safeLogDeferredEvent('edit-redelivery-deferred-cancelled', {
|
|
150
|
+
session_key: state.sessionKey,
|
|
151
|
+
generation_id: state.generationId,
|
|
152
|
+
reason: 'dispatch-failed',
|
|
153
|
+
message_count: 1,
|
|
154
|
+
msg_id: entry.msgId,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function deferCodexEdit(entry, proc) {
|
|
161
|
+
const generationId = codexGenerationId(proc);
|
|
162
|
+
if (!generationId) {
|
|
163
|
+
safeLogDeferredEvent('edit-redelivery-deferred-cancelled', {
|
|
164
|
+
session_key: entry.sessionKey,
|
|
165
|
+
generation_id: null,
|
|
166
|
+
reason: 'missing-generation-id',
|
|
167
|
+
message_count: 1,
|
|
168
|
+
msg_id: entry.msgId,
|
|
169
|
+
});
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
if (
|
|
173
|
+
typeof proc.on !== 'function'
|
|
174
|
+
|| typeof proc.removeListener !== 'function'
|
|
175
|
+
) {
|
|
176
|
+
safeLogDeferredEvent('edit-redelivery-deferred-cancelled', {
|
|
177
|
+
session_key: entry.sessionKey,
|
|
178
|
+
generation_id: generationId,
|
|
179
|
+
reason: 'event-interface-unavailable',
|
|
180
|
+
message_count: 1,
|
|
181
|
+
msg_id: entry.msgId,
|
|
182
|
+
});
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let state = deferredCodexBySession.get(entry.sessionKey);
|
|
187
|
+
if (
|
|
188
|
+
state
|
|
189
|
+
&& (
|
|
190
|
+
state.proc !== proc
|
|
191
|
+
|| state.generationId !== generationId
|
|
192
|
+
)
|
|
193
|
+
) {
|
|
194
|
+
cancelDeferred(state, 'generation-replaced');
|
|
195
|
+
state = null;
|
|
196
|
+
}
|
|
197
|
+
if (!state) {
|
|
198
|
+
state = {
|
|
199
|
+
sessionKey: entry.sessionKey,
|
|
200
|
+
generationId,
|
|
201
|
+
proc,
|
|
202
|
+
entries: new Map(),
|
|
203
|
+
};
|
|
204
|
+
state.onIdle = () => flushDeferred(state);
|
|
205
|
+
state.onClose = () => cancelDeferred(state, 'close');
|
|
206
|
+
state.onSessionReset = () => cancelDeferred(state, 'session-reset');
|
|
207
|
+
state.onCodexSettled = (event) => {
|
|
208
|
+
if (event?.kind === 'stopped') cancelDeferred(state, 'stop');
|
|
209
|
+
};
|
|
210
|
+
state.onCodexLifecycle = () => cancelDeferred(state, 'lifecycle-failed');
|
|
211
|
+
deferredCodexBySession.set(entry.sessionKey, state);
|
|
212
|
+
proc.on('idle', state.onIdle);
|
|
213
|
+
proc.on('close', state.onClose);
|
|
214
|
+
proc.on('session-reset', state.onSessionReset);
|
|
215
|
+
proc.on('codex-settled', state.onCodexSettled);
|
|
216
|
+
proc.on('codex-lifecycle', state.onCodexLifecycle);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const interlockKey = `${entry.chatId}:${entry.msgId}`;
|
|
220
|
+
const coalesced = state.entries.has(interlockKey);
|
|
221
|
+
// Map#set replaces the value without changing insertion order.
|
|
222
|
+
state.entries.set(interlockKey, entry);
|
|
223
|
+
safeLogDeferredEvent('edit-redelivery-deferred', {
|
|
224
|
+
chat_id: entry.chatId,
|
|
225
|
+
session_key: entry.sessionKey,
|
|
226
|
+
generation_id: generationId,
|
|
227
|
+
msg_id: entry.msgId,
|
|
228
|
+
coalesced,
|
|
229
|
+
});
|
|
230
|
+
// Close the check→listener registration race. If the turn settled (or the
|
|
231
|
+
// process was replaced) while the edit was being gated, release or cancel
|
|
232
|
+
// through the same generation-fenced path immediately.
|
|
233
|
+
if (
|
|
234
|
+
pm?.get?.(entry.sessionKey) !== proc
|
|
235
|
+
|| proc.closed
|
|
236
|
+
|| !proc.inFlight
|
|
237
|
+
) {
|
|
238
|
+
flushDeferred(state);
|
|
239
|
+
}
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
52
243
|
return function maybePostTurnEdit(editedMsg, oldText, botUsername, mentionRe = null) {
|
|
53
244
|
try {
|
|
54
245
|
if (!editedMsg?.chat) return false;
|
|
@@ -77,6 +268,41 @@ function createEditRedelivery({
|
|
|
77
268
|
// BEFORE the gate; the gate now runs upstream in the edited_message
|
|
78
269
|
// handler, so every path through here is already gated).
|
|
79
270
|
const proc = pm?.get?.(sessionKey);
|
|
271
|
+
if (proc?.inFlight && isCodexProcess(sessionKey, proc)) {
|
|
272
|
+
// Re-run the real-message gate before retaining content in memory. The
|
|
273
|
+
// outer edited-message path already gates today, but this handler keeps
|
|
274
|
+
// its own contract when reused independently.
|
|
275
|
+
if (!shouldHandle(editedMsg, chatConfig, botUsername)) return false;
|
|
276
|
+
const cleanNew = mentionRe ? newText.replace(mentionRe, '').trim() : newText.trim();
|
|
277
|
+
const synthetic = {
|
|
278
|
+
chat: editedMsg.chat,
|
|
279
|
+
message_id: editedMsg.message_id,
|
|
280
|
+
from: editedMsg.from,
|
|
281
|
+
text: cleanNew,
|
|
282
|
+
date: editedMsg.date,
|
|
283
|
+
...(threadId && { message_thread_id: Number(threadId) }),
|
|
284
|
+
reply_to_message: {
|
|
285
|
+
message_id: editedMsg.message_id,
|
|
286
|
+
from: editedMsg.from,
|
|
287
|
+
text: oldText || '',
|
|
288
|
+
date: editedMsg.date,
|
|
289
|
+
},
|
|
290
|
+
_isReplay: true,
|
|
291
|
+
_requiredProvider: 'codex',
|
|
292
|
+
};
|
|
293
|
+
const deferred = deferCodexEdit({
|
|
294
|
+
sessionKey,
|
|
295
|
+
chatId,
|
|
296
|
+
msgId: editedMsg.message_id,
|
|
297
|
+
synthetic,
|
|
298
|
+
oldLength: (oldText || '').length,
|
|
299
|
+
newLength: newText.length,
|
|
300
|
+
}, proc);
|
|
301
|
+
if (!deferred) return false;
|
|
302
|
+
try { react?.(chatId, editedMsg.message_id); } catch { /* best-effort */ }
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
|
|
80
306
|
const interlockKey = `${chatId}:${editedMsg.message_id}`;
|
|
81
307
|
const lastRedeliveredAt = redeliveredAt.get(interlockKey) || 0;
|
|
82
308
|
if (proc?.inFlight && (Date.now() - lastRedeliveredAt) < INTERLOCK_TTL_MS && lastRedeliveredAt > 0) {
|
|
@@ -50,6 +50,7 @@ function createGateInbound({
|
|
|
50
50
|
handleAbortIfRequested,
|
|
51
51
|
getRewindHandler = () => null,
|
|
52
52
|
isRewindCommand = () => false,
|
|
53
|
+
resolveRuntime = () => 'claude',
|
|
53
54
|
getQuestionHandlers = () => null,
|
|
54
55
|
shouldHandle,
|
|
55
56
|
getSessionKey,
|
|
@@ -158,6 +159,12 @@ function createGateInbound({
|
|
|
158
159
|
const rewindSafe = msg.chat?.type === 'private' || chatConfig?.isolateTopics === true;
|
|
159
160
|
const r = await rewindHandler.tryConsume({
|
|
160
161
|
sessionKey, chatId, threadId, msg, cleanText,
|
|
162
|
+
runtime: resolveRuntime({
|
|
163
|
+
sessionKey,
|
|
164
|
+
chatId,
|
|
165
|
+
threadId,
|
|
166
|
+
chatConfig,
|
|
167
|
+
}),
|
|
161
168
|
botUsername: getBotUsername?.() || '',
|
|
162
169
|
rewindSafe, isOperatorIdentity, paired, accessMode,
|
|
163
170
|
});
|
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const {
|
|
4
|
+
classifyCodexRecoveryEvidence,
|
|
5
|
+
} = require('../db/auto-resume');
|
|
6
|
+
|
|
7
|
+
function providerReplayDisposition(candidate, getProviderRecovery) {
|
|
8
|
+
if (typeof getProviderRecovery === 'function') {
|
|
9
|
+
let evidence;
|
|
10
|
+
try {
|
|
11
|
+
evidence = getProviderRecovery(candidate);
|
|
12
|
+
} catch {
|
|
13
|
+
return {
|
|
14
|
+
provider: 'unknown',
|
|
15
|
+
action: 'defer',
|
|
16
|
+
reason: 'provider-recovery-unavailable',
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
if (evidence?.provider === 'claude') {
|
|
20
|
+
return { provider: 'claude', action: 'legacy' };
|
|
21
|
+
}
|
|
22
|
+
if (evidence?.provider === 'codex') {
|
|
23
|
+
return {
|
|
24
|
+
provider: 'codex',
|
|
25
|
+
...classifyCodexRecoveryEvidence(evidence),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
provider: 'unknown',
|
|
30
|
+
action: 'defer',
|
|
31
|
+
reason: 'provider-recovery-unavailable',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const provider = candidate?.provider ?? candidate?.runtime;
|
|
36
|
+
if (provider === 'codex') {
|
|
37
|
+
return {
|
|
38
|
+
provider: 'codex',
|
|
39
|
+
...classifyCodexRecoveryEvidence(candidate),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return { provider: provider || 'claude', action: 'legacy' };
|
|
43
|
+
}
|
|
44
|
+
|
|
3
45
|
/**
|
|
4
46
|
* Boot-replay disposition (0.14) — decide what to do with the replay candidate
|
|
5
47
|
* set, given whether the last shutdown was a DELIBERATE restart or a CRASH.
|
|
@@ -30,15 +72,53 @@
|
|
|
30
72
|
* @param {(candidate)=>boolean} [opts.hasCompletedTurn] already-answered predicate (default: none)
|
|
31
73
|
* @param {(candidate)=>boolean} [opts.announceable] notice-eligible predicate (default: all). Excludes
|
|
32
74
|
* admin/slash + abort-shaped rows so we don't announce "I didn't resume your /new".
|
|
33
|
-
* @
|
|
75
|
+
* @param {(candidate)=>object} [opts.getProviderRecovery] authoritative provider
|
|
76
|
+
* and durable-recovery evidence. When supplied, unknown/error results defer
|
|
77
|
+
* rather than enter the legacy recovery path.
|
|
78
|
+
* @returns {{recover: Array, recoverCodex?: Array, skip: Array,
|
|
79
|
+
* defer?: Array, notices: Array<{chat_id, thread_id, items: Array}>}}
|
|
34
80
|
*/
|
|
35
|
-
function classifyReplay({
|
|
81
|
+
function classifyReplay({
|
|
82
|
+
candidates = [],
|
|
83
|
+
cleanShutdown = false,
|
|
84
|
+
hasCompletedTurn,
|
|
85
|
+
announceable,
|
|
86
|
+
getProviderRecovery,
|
|
87
|
+
} = {}) {
|
|
36
88
|
const answered = typeof hasCompletedTurn === 'function' ? hasCompletedTurn : () => false;
|
|
37
89
|
const pending = (candidates || []).filter((c) => !answered(c));
|
|
90
|
+
const legacy = [];
|
|
91
|
+
const recoverCodex = [];
|
|
92
|
+
const cancelled = [];
|
|
93
|
+
const defer = [];
|
|
94
|
+
|
|
95
|
+
for (const candidate of pending) {
|
|
96
|
+
const disposition = providerReplayDisposition(
|
|
97
|
+
candidate,
|
|
98
|
+
getProviderRecovery,
|
|
99
|
+
);
|
|
100
|
+
if (disposition.action === 'legacy') {
|
|
101
|
+
legacy.push(candidate);
|
|
102
|
+
} else if (disposition.action === 'recover') {
|
|
103
|
+
recoverCodex.push(candidate);
|
|
104
|
+
} else if (disposition.action === 'skip') {
|
|
105
|
+
cancelled.push(candidate);
|
|
106
|
+
} else {
|
|
107
|
+
defer.push(candidate);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
38
110
|
|
|
39
111
|
if (!cleanShutdown) {
|
|
40
|
-
//
|
|
41
|
-
|
|
112
|
+
// Legacy providers retain crash recovery. Codex requests use a separate
|
|
113
|
+
// recoverer so they cannot accidentally fail over to Claude.
|
|
114
|
+
const plan = {
|
|
115
|
+
recover: legacy,
|
|
116
|
+
skip: cancelled,
|
|
117
|
+
notices: [],
|
|
118
|
+
};
|
|
119
|
+
if (recoverCodex.length > 0) plan.recoverCodex = recoverCodex;
|
|
120
|
+
if (defer.length > 0) plan.defer = defer;
|
|
121
|
+
return plan;
|
|
42
122
|
}
|
|
43
123
|
|
|
44
124
|
// Deliberate restart: skip ALL pending (none re-answered), and group the
|
|
@@ -49,7 +129,8 @@ function classifyReplay({ candidates = [], cleanShutdown = false, hasCompletedTu
|
|
|
49
129
|
const isAnnounceable = typeof announceable === 'function' ? announceable : () => true;
|
|
50
130
|
const SEP = String.fromCharCode(0); // NUL separator (H8, collision-proof)
|
|
51
131
|
const groups = new Map();
|
|
52
|
-
|
|
132
|
+
const restartSkipped = [...legacy, ...recoverCodex];
|
|
133
|
+
for (const c of restartSkipped) {
|
|
53
134
|
if (!isAnnounceable(c)) continue;
|
|
54
135
|
const thread = c.thread_id == null ? null : c.thread_id;
|
|
55
136
|
const key = `${c.chat_id}${SEP}${thread == null ? '' : thread}`;
|
|
@@ -57,7 +138,13 @@ function classifyReplay({ candidates = [], cleanShutdown = false, hasCompletedTu
|
|
|
57
138
|
if (!g) { g = { chat_id: c.chat_id, thread_id: thread, items: [] }; groups.set(key, g); }
|
|
58
139
|
g.items.push(c);
|
|
59
140
|
}
|
|
60
|
-
|
|
141
|
+
const plan = {
|
|
142
|
+
recover: [],
|
|
143
|
+
skip: [...restartSkipped, ...cancelled],
|
|
144
|
+
notices: Array.from(groups.values()),
|
|
145
|
+
};
|
|
146
|
+
if (defer.length > 0) plan.defer = defer;
|
|
147
|
+
return plan;
|
|
61
148
|
}
|
|
62
149
|
|
|
63
150
|
/**
|
|
@@ -73,18 +160,29 @@ function classifyReplay({ candidates = [], cleanShutdown = false, hasCompletedTu
|
|
|
73
160
|
* skip items (not in any notice group) are marked terminal silently.
|
|
74
161
|
*
|
|
75
162
|
* @param {object} opts
|
|
76
|
-
* @param {{recover:Array, skip:Array,
|
|
163
|
+
* @param {{recover:Array, recoverCodex?:Array, skip:Array, defer?:Array,
|
|
164
|
+
* notices:Array}} opts.plan
|
|
77
165
|
* @param {object} opts.deps
|
|
78
166
|
* @param {(candidate)=>Promise<{ok:boolean}>} opts.deps.recover crash-path re-dispatch
|
|
167
|
+
* @param {(candidate)=>Promise<{ok:boolean}>} [opts.deps.recoverCodex]
|
|
168
|
+
* exact-runtime Codex crash-path re-dispatch
|
|
79
169
|
* @param {(group)=>Promise<{ok:boolean, messageId?:any, error?:string}>} opts.deps.sendNotice
|
|
80
170
|
* @param {(candidate)=>void} opts.deps.markSkipped mark row terminal 'replay-skipped'
|
|
81
171
|
* @param {(kind, detail)=>void} [opts.deps.logEvent]
|
|
82
|
-
* @returns {Promise<{recovered:number, skipped:number, noticed:number,
|
|
172
|
+
* @returns {Promise<{recovered:number, skipped:number, noticed:number,
|
|
173
|
+
* noticeFailed:number, deferred?:number}>}
|
|
83
174
|
*/
|
|
84
175
|
async function executeReplayPlan({ plan, deps }) {
|
|
85
|
-
const {
|
|
176
|
+
const {
|
|
177
|
+
recover = [],
|
|
178
|
+
recoverCodex = [],
|
|
179
|
+
skip = [],
|
|
180
|
+
notices = [],
|
|
181
|
+
defer = [],
|
|
182
|
+
} = plan || {};
|
|
86
183
|
const log = (deps && deps.logEvent) || (() => {});
|
|
87
184
|
let recovered = 0; let skipped = 0; let noticed = 0; let noticeFailed = 0;
|
|
185
|
+
let deferred = defer.length;
|
|
88
186
|
|
|
89
187
|
// CRASH branch — recover each (unchanged rc.57 behavior).
|
|
90
188
|
for (const c of recover) {
|
|
@@ -93,6 +191,24 @@ async function executeReplayPlan({ plan, deps }) {
|
|
|
93
191
|
if (r && r.ok) recovered += 1; else skipped += 1;
|
|
94
192
|
}
|
|
95
193
|
|
|
194
|
+
// Codex recovery is deliberately separate from the legacy recoverer. If the
|
|
195
|
+
// caller has not wired an exact-runtime path, leave the row pending.
|
|
196
|
+
for (const c of recoverCodex) {
|
|
197
|
+
if (typeof deps.recoverCodex !== 'function') {
|
|
198
|
+
deferred += 1;
|
|
199
|
+
log('codex-replay-deferred', {
|
|
200
|
+
chat_id: c.chat_id,
|
|
201
|
+
thread_id: c.thread_id == null ? null : c.thread_id,
|
|
202
|
+
msg_id: c.msg_id,
|
|
203
|
+
reason: 'codex-recoverer-unavailable',
|
|
204
|
+
});
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
// eslint-disable-next-line no-await-in-loop
|
|
208
|
+
const r = await deps.recoverCodex(c);
|
|
209
|
+
if (r && r.ok) recovered += 1; else deferred += 1;
|
|
210
|
+
}
|
|
211
|
+
|
|
96
212
|
// CLEAN branch — notice-then-mark, per group.
|
|
97
213
|
const inNotices = new Set();
|
|
98
214
|
for (const g of notices) for (const c of g.items) inNotices.add(c);
|
|
@@ -114,7 +230,13 @@ async function executeReplayPlan({ plan, deps }) {
|
|
|
114
230
|
for (const c of skip) {
|
|
115
231
|
if (!inNotices.has(c)) { deps.markSkipped(c); skipped += 1; }
|
|
116
232
|
}
|
|
117
|
-
|
|
233
|
+
const result = { recovered, skipped, noticed, noticeFailed };
|
|
234
|
+
if (deferred > 0) result.deferred = deferred;
|
|
235
|
+
return result;
|
|
118
236
|
}
|
|
119
237
|
|
|
120
|
-
module.exports = {
|
|
238
|
+
module.exports = {
|
|
239
|
+
classifyReplay,
|
|
240
|
+
executeReplayPlan,
|
|
241
|
+
classifyCodexRecoveryEvidence,
|
|
242
|
+
};
|