polygram 0.21.0 → 0.23.0
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/lib/util/claude-session-jsonl.js +3 -2
- 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
package/lib/error/classify.js
CHANGED
|
@@ -131,10 +131,136 @@ const AUTO_RECOVER = {
|
|
|
131
131
|
imageProcess: 'reset_session',
|
|
132
132
|
};
|
|
133
133
|
|
|
134
|
+
function stableCodeShape(kind, userMessage) {
|
|
135
|
+
return Object.freeze({
|
|
136
|
+
kind,
|
|
137
|
+
userMessage,
|
|
138
|
+
isTransient: false,
|
|
139
|
+
autoRecover: null,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function mapCodes(codes, shape) {
|
|
144
|
+
return Object.fromEntries(codes.map((code) => [code, shape]));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const CODEX_ERROR_SHAPES = Object.freeze({
|
|
148
|
+
auth: stableCodeShape(
|
|
149
|
+
'codexAuthUnavailable',
|
|
150
|
+
'🔑 Codex authentication is unavailable. The operator needs to re-authenticate the pinned deployment.',
|
|
151
|
+
),
|
|
152
|
+
model: stableCodeShape(
|
|
153
|
+
'codexModelUnavailable',
|
|
154
|
+
'⚠️ That Codex model or reasoning effort is unavailable. Choose one from the authenticated Codex catalog.',
|
|
155
|
+
),
|
|
156
|
+
rateLimit: stableCodeShape(
|
|
157
|
+
'codexRateLimited',
|
|
158
|
+
'⏳ Codex is rate-limited. Codex owns any provider retry; otherwise wait before sending a new request.',
|
|
159
|
+
),
|
|
160
|
+
protocol: stableCodeShape(
|
|
161
|
+
'codexProtocol',
|
|
162
|
+
'🧩 Codex protocol or runtime pin verification failed. The operator must restore the reviewed deployment.',
|
|
163
|
+
),
|
|
164
|
+
timeout: stableCodeShape(
|
|
165
|
+
'codexTimeout',
|
|
166
|
+
'⏳ Codex timed out. No automatic retry was started; check Codex status before taking action.',
|
|
167
|
+
),
|
|
168
|
+
cancelled: stableCodeShape(
|
|
169
|
+
'codexCancelled',
|
|
170
|
+
'🛑 The Codex request was cancelled. No automatic retry was started.',
|
|
171
|
+
),
|
|
172
|
+
durability: stableCodeShape(
|
|
173
|
+
'codexDurability',
|
|
174
|
+
'⚠️ Codex could not verify this request’s durable outcome. Do not resend automatically; check status and follow operator recovery guidance.',
|
|
175
|
+
),
|
|
176
|
+
childExit: stableCodeShape(
|
|
177
|
+
'codexChildExit',
|
|
178
|
+
'⚠️ The Codex app-server exited unexpectedly. The outcome may be unknown; wait for operator diagnostics before sending more work.',
|
|
179
|
+
),
|
|
180
|
+
notSent: stableCodeShape(
|
|
181
|
+
'codexNotSent',
|
|
182
|
+
'↩️ Codex did not receive this request. It is safe to try again.',
|
|
183
|
+
),
|
|
184
|
+
providerError: stableCodeShape(
|
|
185
|
+
'codexProviderError',
|
|
186
|
+
'⚠️ Codex rejected the request. No automatic retry was started; check Codex status.',
|
|
187
|
+
),
|
|
188
|
+
busy: stableCodeShape(
|
|
189
|
+
'codexBusy',
|
|
190
|
+
'⏳ Another chat owns this daemon’s native Codex runtime. No session or configuration was changed.',
|
|
191
|
+
),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const CODEX_ERROR_CODES = {
|
|
195
|
+
...mapCodes([
|
|
196
|
+
'CODEX_AUTH_UNAVAILABLE',
|
|
197
|
+
], CODEX_ERROR_SHAPES.auth),
|
|
198
|
+
...mapCodes([
|
|
199
|
+
'CODEX_MODEL_UNAVAILABLE',
|
|
200
|
+
'CODEX_EFFORT_UNAVAILABLE',
|
|
201
|
+
'CODEX_MODEL_SELECTION_MISMATCH',
|
|
202
|
+
], CODEX_ERROR_SHAPES.model),
|
|
203
|
+
...mapCodes([
|
|
204
|
+
'CODEX_RATE_LIMITED',
|
|
205
|
+
'CODEX_RATE_LIMIT_EXCEEDED',
|
|
206
|
+
], CODEX_ERROR_SHAPES.rateLimit),
|
|
207
|
+
...mapCodes([
|
|
208
|
+
'CODEX_PROTOCOL_ERROR',
|
|
209
|
+
'CODEX_SERVER_REQUEST_DENIED',
|
|
210
|
+
'CODEX_RUNTIME_PIN_MISMATCH',
|
|
211
|
+
'CODEX_STATIC_PROFILE_MISMATCH',
|
|
212
|
+
'CODEX_THREAD_POLICY_MISMATCH',
|
|
213
|
+
'CODEX_PREFLIGHT_UNEXPECTED_NOTIFICATION',
|
|
214
|
+
'CODEX_PREFLIGHT_CLIENT_FAULT',
|
|
215
|
+
'CODEX_RUNTIME_CLIENT_FAULT',
|
|
216
|
+
], CODEX_ERROR_SHAPES.protocol),
|
|
217
|
+
...mapCodes([
|
|
218
|
+
'CODEX_RPC_TIMEOUT',
|
|
219
|
+
'CODEX_TURN_START_TIMEOUT',
|
|
220
|
+
'CODEX_TURN_TIMEOUT',
|
|
221
|
+
'CODEX_INTERRUPT_TIMEOUT',
|
|
222
|
+
'CODEX_TERMINAL_CLEANUP_TIMEOUT',
|
|
223
|
+
'CODEX_SINK_TIMEOUT',
|
|
224
|
+
'CODEX_PROCESS_CLOSE_TIMEOUT',
|
|
225
|
+
'CODEX_CONTAINMENT_CLOSE_TIMEOUT',
|
|
226
|
+
], CODEX_ERROR_SHAPES.timeout),
|
|
227
|
+
...mapCodes([
|
|
228
|
+
'CODEX_SINK_ABORTED',
|
|
229
|
+
'CODEX_CLIENT_CLOSED',
|
|
230
|
+
'CODEX_PROCESS_CLOSED',
|
|
231
|
+
'CODEX_PROCESS_QUIESCING',
|
|
232
|
+
'CODEX_LIFECYCLE_SUPERSEDED',
|
|
233
|
+
], CODEX_ERROR_SHAPES.cancelled),
|
|
234
|
+
...mapCodes([
|
|
235
|
+
'CODEX_DURABILITY_FAILED',
|
|
236
|
+
'CODEX_RPC_CHECKPOINT_FAILED',
|
|
237
|
+
'CODEX_GENERATION_NOT_DURABLE',
|
|
238
|
+
'CODEX_DISPATCH_STEER_NOT_DURABLE',
|
|
239
|
+
'CODEX_QUEUE_SETTLEMENT_NOT_DURABLE',
|
|
240
|
+
'CODEX_RETIREMENT_UNVERIFIED',
|
|
241
|
+
'CODEX_RPC_OUTCOME_UNKNOWN',
|
|
242
|
+
'CODEX_CONTAINMENT_FAILED',
|
|
243
|
+
'CODEX_CONTAINMENT_QUARANTINED',
|
|
244
|
+
'CODEX_BOOT_RECONSTRUCTION_FAILED',
|
|
245
|
+
'CODEX_BOOT_RECONSTRUCTION_REQUIRED',
|
|
246
|
+
'CODEX_PERSISTED_IDENTITY_MISSING',
|
|
247
|
+
], CODEX_ERROR_SHAPES.durability),
|
|
248
|
+
...mapCodes([
|
|
249
|
+
'CODEX_PROCESS_EXITED',
|
|
250
|
+
'CODEX_PROCESS_ERROR',
|
|
251
|
+
'CODEX_TRANSPORT_ERROR',
|
|
252
|
+
'CODEX_PROCESS_CLEANUP_UNVERIFIED',
|
|
253
|
+
], CODEX_ERROR_SHAPES.childExit),
|
|
254
|
+
CODEX_RPC_NOT_SENT: CODEX_ERROR_SHAPES.notSent,
|
|
255
|
+
CODEX_RPC_ERROR: CODEX_ERROR_SHAPES.providerError,
|
|
256
|
+
CODEX_DAEMON_GENERATION_BUSY: CODEX_ERROR_SHAPES.busy,
|
|
257
|
+
};
|
|
258
|
+
|
|
134
259
|
// Typed-code short-circuits — set on errors polygram throws itself
|
|
135
260
|
// (see lib/process-manager-sdk.js), not pattern-matched. Keep these
|
|
136
261
|
// in sync with the codes pm emits.
|
|
137
262
|
const CODES = {
|
|
263
|
+
...CODEX_ERROR_CODES,
|
|
138
264
|
// 0.7.6 (item H): queue cap drop. Pre-empts pattern matching so
|
|
139
265
|
// the queue-overflow message is exact, not classified.
|
|
140
266
|
QUEUE_OVERFLOW: {
|
|
@@ -284,9 +410,22 @@ function classify(err) {
|
|
|
284
410
|
// Errors polygram constructs internally (QUEUE_OVERFLOW etc.) set
|
|
285
411
|
// `err.code` so we don't depend on string content.
|
|
286
412
|
const code = err?.code;
|
|
413
|
+
if (
|
|
414
|
+
code === 'CODEX_RPC_ERROR'
|
|
415
|
+
&& (
|
|
416
|
+
err?.rpcCode === 429
|
|
417
|
+
|| err?.providerCode === 'rate_limit'
|
|
418
|
+
|| err?.providerCode === 'rate_limit_exceeded'
|
|
419
|
+
)
|
|
420
|
+
) {
|
|
421
|
+
return { ...CODEX_ERROR_SHAPES.rateLimit };
|
|
422
|
+
}
|
|
287
423
|
if (code && CODES[code]) {
|
|
288
424
|
return { ...CODES[code] };
|
|
289
425
|
}
|
|
426
|
+
if (typeof code === 'string' && /^CODEX_[A-Z0-9_]+$/.test(code)) {
|
|
427
|
+
return { ...CODEX_ERROR_SHAPES.providerError };
|
|
428
|
+
}
|
|
290
429
|
|
|
291
430
|
// SDKAssistantMessage.error is a short string code from a fixed
|
|
292
431
|
// union — match those directly, not via regex. Result subtypes
|
|
@@ -475,6 +614,7 @@ function classifyTurnEndError(err) {
|
|
|
475
614
|
// one (regression caught in the 0.16 code review).
|
|
476
615
|
const isTimeout = code === 'TURN_TIMEOUT'
|
|
477
616
|
|| code === 'TURN_MAX_EXCEEDED'
|
|
617
|
+
|| CODES[code]?.kind === 'codexTimeout'
|
|
478
618
|
|| /wall-clock ceiling|idle with no Claude activity/i.test(err?.message || '');
|
|
479
619
|
return { errorSuffix: 'stream interrupted', reactorState: isTimeout ? 'TIMEOUT' : 'ERROR' };
|
|
480
620
|
}
|
package/lib/handlers/abort.js
CHANGED
|
@@ -8,16 +8,17 @@
|
|
|
8
8
|
* 1. Mark the session aborted BEFORE the SDK interrupt fires —
|
|
9
9
|
* pm-sdk's close handler races; if we marked after, the
|
|
10
10
|
* generic error-reply could slip through.
|
|
11
|
-
* 2.
|
|
12
|
-
* interrupt keeps the proc warm (no --resume); kill only for
|
|
13
|
-
* ghosts / detached background shells / unverifiable state.
|
|
14
|
-
* 3. pm.drainQueue() — rejects queued pendings with
|
|
11
|
+
* 2. pm.drainQueue() — rejects queued pendings with
|
|
15
12
|
* err.code='INTERRUPTED' so the abort-grace classifier
|
|
16
13
|
* suppresses error replies on the way out.
|
|
14
|
+
* 3. Stop through the backend contract. Claude CLI keeps its
|
|
15
|
+
* tiered cheap-interrupt/kill behavior; Codex delegates its
|
|
16
|
+
* exact terminal/clean/empty proof to ProcessManager.
|
|
17
17
|
* 4. Clear ✍ reactions on already-autosteered messages from
|
|
18
18
|
* this turn (now dead context).
|
|
19
19
|
* 5. Acknowledge with a 👍 reaction on the stop message when
|
|
20
|
-
*
|
|
20
|
+
* stopping was verified. A Codex containment failure gets the
|
|
21
|
+
* existing error reaction instead. Never text.
|
|
21
22
|
*
|
|
22
23
|
* Returns true when the message was handled as an abort, false
|
|
23
24
|
* otherwise. Caller short-circuits on true.
|
|
@@ -25,6 +26,41 @@
|
|
|
25
26
|
|
|
26
27
|
'use strict';
|
|
27
28
|
|
|
29
|
+
const CODEX_ACTIVE_STATES = new Set([
|
|
30
|
+
'StartingTurn',
|
|
31
|
+
'Active',
|
|
32
|
+
'Settling',
|
|
33
|
+
'BackgroundWorking',
|
|
34
|
+
'BackgroundSettling',
|
|
35
|
+
'Quiescing',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const SAFE_CODEX_STOP_ERROR_CODES = new Set([
|
|
39
|
+
'CODEX_BACKGROUND_OWNERSHIP_UNKNOWN',
|
|
40
|
+
'CODEX_CONTAINMENT_CLOSE_TIMEOUT',
|
|
41
|
+
'CODEX_CONTAINMENT_FAILED',
|
|
42
|
+
'CODEX_DURABILITY_FAILED',
|
|
43
|
+
'CODEX_INTERRUPT_TIMEOUT',
|
|
44
|
+
'CODEX_INTERRUPT_UNMATCHED',
|
|
45
|
+
'CODEX_LIFECYCLE_SUPERSEDED',
|
|
46
|
+
'CODEX_PROCESS_CLOSED',
|
|
47
|
+
'CODEX_PROCESS_EXITED',
|
|
48
|
+
'CODEX_PROTOCOL_ERROR',
|
|
49
|
+
'CODEX_RETIREMENT_UNVERIFIED',
|
|
50
|
+
'CODEX_RPC_CHECKPOINT_FAILED',
|
|
51
|
+
'CODEX_RPC_ERROR',
|
|
52
|
+
'CODEX_RPC_NOT_SENT',
|
|
53
|
+
'CODEX_RPC_OUTCOME_UNKNOWN',
|
|
54
|
+
'CODEX_TERMINAL_CLEANUP_TIMEOUT',
|
|
55
|
+
'CODEX_TURN_START_TIMEOUT',
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
function safeCodexStopErrorCode(error, fallback) {
|
|
59
|
+
return SAFE_CODEX_STOP_ERROR_CODES.has(error?.code)
|
|
60
|
+
? error.code
|
|
61
|
+
: fallback;
|
|
62
|
+
}
|
|
63
|
+
|
|
28
64
|
function createHandleAbort({
|
|
29
65
|
pm,
|
|
30
66
|
bot,
|
|
@@ -49,6 +85,108 @@ function createHandleAbort({
|
|
|
49
85
|
const proc = pm.has(sessionKey) ? pm.get(sessionKey) : null;
|
|
50
86
|
let hadActive = !!proc?.inFlight;
|
|
51
87
|
|
|
88
|
+
// Codex has an explicit verified stop protocol in Orchestra:
|
|
89
|
+
// queued sends are cancelled, the exact active turn is reconciled,
|
|
90
|
+
// background terminals are cleaned, and a fresh empty registry is
|
|
91
|
+
// durably observed before ProcessManager resolves true. Do not mix that
|
|
92
|
+
// contract with the Claude CLI pane/shell heuristics below.
|
|
93
|
+
if (proc?.backend === 'codex') {
|
|
94
|
+
hadActive = hadActive || CODEX_ACTIVE_STATES.has(proc.state);
|
|
95
|
+
let markedAborted = false;
|
|
96
|
+
if (hadActive) {
|
|
97
|
+
markSessionAborted(sessionKey);
|
|
98
|
+
markedAborted = true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let queueDrained = 0;
|
|
102
|
+
let queueDrainErrorCode = null;
|
|
103
|
+
try {
|
|
104
|
+
const count = pm.drainQueue(sessionKey, 'INTERRUPTED');
|
|
105
|
+
if (Number.isSafeInteger(count) && count > 0) queueDrained = count;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
queueDrainErrorCode = safeCodexStopErrorCode(
|
|
108
|
+
error,
|
|
109
|
+
'CODEX_QUEUE_DRAIN_FAILED',
|
|
110
|
+
);
|
|
111
|
+
logger.error?.(
|
|
112
|
+
`[${botName}] Codex queue drain failed (${queueDrainErrorCode})`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if (queueDrained > 0 && !markedAborted) {
|
|
116
|
+
markSessionAborted(sessionKey);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let feedbackClearErrorCode = null;
|
|
120
|
+
try {
|
|
121
|
+
await clearAutosteeredReactions(sessionKey);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
feedbackClearErrorCode = safeCodexStopErrorCode(
|
|
124
|
+
error,
|
|
125
|
+
'CODEX_FEEDBACK_CLEAR_FAILED',
|
|
126
|
+
);
|
|
127
|
+
logger.error?.(
|
|
128
|
+
`[${botName}] Codex stop feedback clear failed (${feedbackClearErrorCode})`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let stopVerified = false;
|
|
133
|
+
let stopErrorCode = null;
|
|
134
|
+
try {
|
|
135
|
+
stopVerified = await pm.interrupt(sessionKey) === true;
|
|
136
|
+
if (!stopVerified) stopErrorCode = 'CODEX_STOP_NOT_VERIFIED';
|
|
137
|
+
} catch (error) {
|
|
138
|
+
stopErrorCode = safeCodexStopErrorCode(
|
|
139
|
+
error,
|
|
140
|
+
'CODEX_STOP_FAILED',
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (!stopVerified) {
|
|
144
|
+
logger.error?.(
|
|
145
|
+
`[${botName}] Codex stop did not verify (${stopErrorCode})`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
logEvent('abort-requested', {
|
|
150
|
+
chat_id: chatId,
|
|
151
|
+
user_id: msg.from?.id || null,
|
|
152
|
+
backend: 'codex',
|
|
153
|
+
had_active: hadActive,
|
|
154
|
+
cancel_mode: stopVerified ? 'interrupt' : 'none',
|
|
155
|
+
kill_reason: null,
|
|
156
|
+
killed_background_shell: false,
|
|
157
|
+
busy_probe: null,
|
|
158
|
+
queue_drained: queueDrained,
|
|
159
|
+
queue_drain_error_code: queueDrainErrorCode,
|
|
160
|
+
feedback_clear_error_code: feedbackClearErrorCode,
|
|
161
|
+
stop_verified: stopVerified,
|
|
162
|
+
stop_error_code: stopErrorCode,
|
|
163
|
+
trigger: cleanText.slice(0, 40),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// 👍 means the complete native stop proof settled, not merely that an
|
|
167
|
+
// interrupt request was sent or acknowledged. A failed proof gets the
|
|
168
|
+
// existing error reaction instead, so containment failure is visible
|
|
169
|
+
// without introducing a text acknowledgement.
|
|
170
|
+
try {
|
|
171
|
+
await tg(bot, 'setMessageReaction', {
|
|
172
|
+
chat_id: chatId,
|
|
173
|
+
message_id: msg.message_id,
|
|
174
|
+
reaction: [{
|
|
175
|
+
type: 'emoji',
|
|
176
|
+
emoji: stopVerified ? '👍' : '🤯',
|
|
177
|
+
}],
|
|
178
|
+
}, {
|
|
179
|
+
source: stopVerified ? 'abort-ack' : 'abort-error',
|
|
180
|
+
botName,
|
|
181
|
+
});
|
|
182
|
+
} catch (error) {
|
|
183
|
+
logger.error?.(
|
|
184
|
+
`[${botName}] Codex stop reaction failed (TELEGRAM_REACTION_FAILED)`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
52
190
|
// Mark BEFORE killing: the 'close' event fires almost immediately
|
|
53
191
|
// after interrupt, and the surrounding handleMessage's catch
|
|
54
192
|
// needs to see the flag to skip the generic error-reply.
|
|
@@ -36,6 +36,73 @@ function priorityFor(chatConfig, config) {
|
|
|
36
36
|
return mode === 'queue' ? 'later' : 'next';
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
const QUEUEABLE_CODEX_STATES = new Set([
|
|
40
|
+
'Active',
|
|
41
|
+
'Idle',
|
|
42
|
+
'StartingTurn',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
const AMBIGUOUS_STEER_ERROR_CODES = new Set([
|
|
46
|
+
'CODEX_RPC_OUTCOME_UNKNOWN',
|
|
47
|
+
'CODEX_RPC_CHECKPOINT_FAILED',
|
|
48
|
+
'CODEX_DURABILITY_FAILED',
|
|
49
|
+
'CODEX_RPC_ERROR',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const OPAQUE_ID_CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/;
|
|
53
|
+
|
|
54
|
+
function opaqueId(value) {
|
|
55
|
+
return (
|
|
56
|
+
typeof value === 'string'
|
|
57
|
+
&& value.length > 0
|
|
58
|
+
&& Buffer.byteLength(value, 'utf8') <= 512
|
|
59
|
+
&& !OPAQUE_ID_CONTROL_CHAR_RE.test(value)
|
|
60
|
+
)
|
|
61
|
+
? value
|
|
62
|
+
: null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function codexGenerationId(entry) {
|
|
66
|
+
return opaqueId(entry?.generationId);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isCodexEntry(pm, sessionKey, entry) {
|
|
70
|
+
const backend = typeof pm.getBackend === 'function'
|
|
71
|
+
? pm.getBackend(sessionKey)
|
|
72
|
+
: entry?.backend;
|
|
73
|
+
return backend === 'codex' || entry?.runtime === 'codex';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sameCodexGeneration(pm, sessionKey, generationId) {
|
|
77
|
+
const current = pm.get(sessionKey);
|
|
78
|
+
return {
|
|
79
|
+
current,
|
|
80
|
+
same: (
|
|
81
|
+
current != null
|
|
82
|
+
&& codexGenerationId(current) === generationId
|
|
83
|
+
),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function canQueueCodexNotSent(entry) {
|
|
88
|
+
return (
|
|
89
|
+
entry != null
|
|
90
|
+
&& !entry.closed
|
|
91
|
+
&& QUEUEABLE_CODEX_STATES.has(entry.state)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function codexClassification(outcome, priority, reason, detail = {}) {
|
|
96
|
+
return {
|
|
97
|
+
autosteered: false,
|
|
98
|
+
outcome,
|
|
99
|
+
queueOnce: outcome === 'queue-once',
|
|
100
|
+
priority,
|
|
101
|
+
reason,
|
|
102
|
+
...detail,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
39
106
|
function createAutosteerHandlers({
|
|
40
107
|
config,
|
|
41
108
|
pm,
|
|
@@ -94,7 +161,195 @@ function createAutosteerHandlers({
|
|
|
94
161
|
return { autosteered: true, priority };
|
|
95
162
|
}
|
|
96
163
|
|
|
97
|
-
|
|
164
|
+
/**
|
|
165
|
+
* Attempt a native Codex turn/steer RPC.
|
|
166
|
+
*
|
|
167
|
+
* The caller owns the per-session intent lock and the durable Telegram
|
|
168
|
+
* dispatch reservation. This function keeps that lock held by awaiting the
|
|
169
|
+
* RPC and returns a classification; it never performs the fallback send.
|
|
170
|
+
*/
|
|
171
|
+
async function tryCodexAutosteer({
|
|
172
|
+
sessionKey,
|
|
173
|
+
chatConfig,
|
|
174
|
+
chatId,
|
|
175
|
+
msg,
|
|
176
|
+
prompt,
|
|
177
|
+
}) {
|
|
178
|
+
const priority = priorityFor(chatConfig, config);
|
|
179
|
+
if (!isAutosteerEnabledFor(chatConfig, config)) {
|
|
180
|
+
return codexClassification('not-applicable', priority, 'disabled');
|
|
181
|
+
}
|
|
182
|
+
if (!pm.has(sessionKey)) {
|
|
183
|
+
return codexClassification('not-applicable', priority, 'missing-session');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const entry = pm.get(sessionKey);
|
|
187
|
+
if (!isCodexEntry(pm, sessionKey, entry)) {
|
|
188
|
+
return codexClassification('not-applicable', priority, 'not-codex');
|
|
189
|
+
}
|
|
190
|
+
const generationId = codexGenerationId(entry);
|
|
191
|
+
if (!generationId) {
|
|
192
|
+
return codexClassification(
|
|
193
|
+
'unavailable',
|
|
194
|
+
priority,
|
|
195
|
+
'missing-generation-id',
|
|
196
|
+
{
|
|
197
|
+
generationId: null,
|
|
198
|
+
},
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (entry.closed || !entry.inFlight) {
|
|
202
|
+
return codexClassification(
|
|
203
|
+
'not-applicable',
|
|
204
|
+
priority,
|
|
205
|
+
entry.closed ? 'closed' : 'not-in-flight',
|
|
206
|
+
{ generationId },
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const expectedTurnId = opaqueId(entry.activeTurnId);
|
|
211
|
+
if (priority === 'later') {
|
|
212
|
+
return codexClassification('queue-once', priority, 'queue-mode', {
|
|
213
|
+
generationId,
|
|
214
|
+
turnId: expectedTurnId,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let result;
|
|
219
|
+
try {
|
|
220
|
+
result = await pm.steerTurn(sessionKey, prompt, {
|
|
221
|
+
context: { sourceMsgId: msg.message_id },
|
|
222
|
+
});
|
|
223
|
+
} catch (error) {
|
|
224
|
+
const observed = sameCodexGeneration(pm, sessionKey, generationId);
|
|
225
|
+
if (!observed.same) {
|
|
226
|
+
return codexClassification('ambiguous', priority, 'generation-changed', {
|
|
227
|
+
generationId,
|
|
228
|
+
observedGenerationId: codexGenerationId(observed.current),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (
|
|
232
|
+
error?.code === 'CODEX_RPC_NOT_SENT'
|
|
233
|
+
&& canQueueCodexNotSent(observed.current)
|
|
234
|
+
) {
|
|
235
|
+
return codexClassification('queue-once', priority, 'rpc-not-sent', {
|
|
236
|
+
generationId,
|
|
237
|
+
turnId: expectedTurnId,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (AMBIGUOUS_STEER_ERROR_CODES.has(error?.code)) {
|
|
241
|
+
return codexClassification(
|
|
242
|
+
'ambiguous',
|
|
243
|
+
priority,
|
|
244
|
+
error.code === 'CODEX_RPC_OUTCOME_UNKNOWN'
|
|
245
|
+
? 'rpc-outcome-unknown'
|
|
246
|
+
: 'steer-outcome-ambiguous',
|
|
247
|
+
{ generationId, errorCode: error.code },
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return codexClassification(
|
|
251
|
+
'unavailable',
|
|
252
|
+
priority,
|
|
253
|
+
!QUEUEABLE_CODEX_STATES.has(observed.current?.state)
|
|
254
|
+
? String(observed.current?.state || 'unavailable').toLowerCase()
|
|
255
|
+
: 'steer-failed',
|
|
256
|
+
{ generationId, errorCode: opaqueId(error?.code) },
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const observed = sameCodexGeneration(pm, sessionKey, generationId);
|
|
261
|
+
if (!observed.same) {
|
|
262
|
+
return codexClassification('ambiguous', priority, 'generation-changed', {
|
|
263
|
+
generationId,
|
|
264
|
+
observedGenerationId: codexGenerationId(observed.current),
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const turnId = opaqueId(result?.turnId);
|
|
269
|
+
if (result?.outcome === 'queueable-not-active') {
|
|
270
|
+
if (!canQueueCodexNotSent(observed.current)) {
|
|
271
|
+
return codexClassification(
|
|
272
|
+
'unavailable',
|
|
273
|
+
priority,
|
|
274
|
+
String(observed.current?.state || result.reason || 'unavailable')
|
|
275
|
+
.toLowerCase(),
|
|
276
|
+
{ generationId },
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
return codexClassification('queue-once', priority, 'not-active', {
|
|
280
|
+
generationId,
|
|
281
|
+
turnId,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
if (result?.outcome === 'unavailable') {
|
|
285
|
+
return codexClassification(
|
|
286
|
+
'unavailable',
|
|
287
|
+
priority,
|
|
288
|
+
opaqueId(result.reason) || 'unavailable',
|
|
289
|
+
{ generationId },
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
if (result?.outcome !== 'accepted') {
|
|
293
|
+
return codexClassification(
|
|
294
|
+
'ambiguous',
|
|
295
|
+
priority,
|
|
296
|
+
'unrecognized-steer-outcome',
|
|
297
|
+
{ generationId },
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const resultGenerationId = opaqueId(result.generationId);
|
|
302
|
+
const attemptId = opaqueId(result.attemptId);
|
|
303
|
+
const targetAttemptId = result.targetAttemptId == null
|
|
304
|
+
? null
|
|
305
|
+
: opaqueId(result.targetAttemptId);
|
|
306
|
+
if (
|
|
307
|
+
!turnId
|
|
308
|
+
|| !attemptId
|
|
309
|
+
|| !targetAttemptId
|
|
310
|
+
|| resultGenerationId !== generationId
|
|
311
|
+
|| (expectedTurnId && turnId !== expectedTurnId)
|
|
312
|
+
) {
|
|
313
|
+
return codexClassification(
|
|
314
|
+
'ambiguous',
|
|
315
|
+
priority,
|
|
316
|
+
'accepted-without-durable-identifiers',
|
|
317
|
+
{
|
|
318
|
+
generationId,
|
|
319
|
+
turnId,
|
|
320
|
+
attemptId,
|
|
321
|
+
targetAttemptId,
|
|
322
|
+
...(resultGenerationId !== generationId
|
|
323
|
+
? { observedGenerationId: resultGenerationId }
|
|
324
|
+
: {}),
|
|
325
|
+
},
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
autosteeredRefs.add(sessionKey, { chatId, msgId: msg.message_id });
|
|
330
|
+
logEvent('autosteer', {
|
|
331
|
+
chat_id: chatId,
|
|
332
|
+
msg_id: msg.message_id,
|
|
333
|
+
text_len: prompt?.length ?? 0,
|
|
334
|
+
priority,
|
|
335
|
+
backend: 'codex',
|
|
336
|
+
generation_id: generationId,
|
|
337
|
+
turn_id: turnId,
|
|
338
|
+
attempt_id: attemptId,
|
|
339
|
+
target_attempt_id: targetAttemptId,
|
|
340
|
+
});
|
|
341
|
+
return {
|
|
342
|
+
autosteered: true,
|
|
343
|
+
outcome: 'accepted',
|
|
344
|
+
priority,
|
|
345
|
+
generationId,
|
|
346
|
+
turnId,
|
|
347
|
+
attemptId,
|
|
348
|
+
targetAttemptId,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return { willAutosteer, tryAutosteer, tryCodexAutosteer };
|
|
98
353
|
}
|
|
99
354
|
|
|
100
355
|
module.exports = {
|