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.
Files changed (47) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +5 -3
  3. package/config.example.json +23 -1
  4. package/lib/codex/binary.js +287 -0
  5. package/lib/codex/delivery-finalizer.js +97 -0
  6. package/lib/codex/diagnostics.js +483 -0
  7. package/lib/codex/event-normalizer.js +50 -0
  8. package/lib/codex/host-identity.js +103 -0
  9. package/lib/codex/model-catalog.js +359 -0
  10. package/lib/codex/retention-scheduler.js +59 -0
  11. package/lib/codex/runtime-controller.js +1292 -0
  12. package/lib/codex/runtime-profile.js +888 -0
  13. package/lib/codex/spawn-context.js +87 -0
  14. package/lib/db/auto-resume.js +77 -9
  15. package/lib/db/codex-reconciliation.js +490 -0
  16. package/lib/db/codex-retention.js +139 -0
  17. package/lib/db/sessions.js +102 -1
  18. package/lib/db.js +3418 -19
  19. package/lib/error/classify.js +140 -0
  20. package/lib/handlers/abort.js +143 -5
  21. package/lib/handlers/autosteer.js +256 -1
  22. package/lib/handlers/codex-reconciliation.js +230 -0
  23. package/lib/handlers/config-callback.js +324 -89
  24. package/lib/handlers/config-ui.js +401 -39
  25. package/lib/handlers/dispatcher.js +71 -8
  26. package/lib/handlers/edit-correction.js +11 -1
  27. package/lib/handlers/edit-redelivery.js +230 -4
  28. package/lib/handlers/gate-inbound.js +7 -0
  29. package/lib/handlers/replay-disposition.js +133 -11
  30. package/lib/handlers/slash-commands.js +443 -37
  31. package/lib/ipc/client.js +22 -12
  32. package/lib/ipc/runtime-directory.js +208 -0
  33. package/lib/ipc/server.js +18 -12
  34. package/lib/model-costs.js +4 -3
  35. package/lib/pm-interface.js +33 -0
  36. package/lib/prompt.js +15 -9
  37. package/lib/rewind/rewind.js +21 -1
  38. package/lib/runtime-config.js +605 -0
  39. package/lib/sdk/build-options.js +4 -1
  40. package/lib/sdk/callbacks.js +46 -3
  41. package/migrations/015-agent-runtime-sessions.sql +50 -0
  42. package/migrations/016-agent-turn-attempts.sql +211 -0
  43. package/migrations/017-config-runtime-changes.sql +46 -0
  44. package/ops/README.md +57 -0
  45. package/package.json +2 -2
  46. package/polygram.js +697 -48
  47. package/scripts/doctor.js +37 -8
@@ -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
  }
@@ -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. Tiered cancel (cheap by default, 2026-06-12): in-place
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
- * something was stopped; silence otherwise. Never text.
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
- return { willAutosteer, tryAutosteer };
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 = {