polygram 0.22.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/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/polygram.js
CHANGED
|
@@ -25,7 +25,8 @@ const path = require('path');
|
|
|
25
25
|
const processGuard = require('@shumkov/orchestra').processGuard;
|
|
26
26
|
const dbClient = require('./lib/db');
|
|
27
27
|
const {
|
|
28
|
-
migrateJsonToDb, getClaudeSessionId,
|
|
28
|
+
migrateJsonToDb, getClaudeSessionId, resolveProviderSessionForSpawn,
|
|
29
|
+
resolveSessionForSpawn,
|
|
29
30
|
} = require('./lib/db/sessions');
|
|
30
31
|
const { buildPrompt, resolvePromptBackend } = require('./lib/prompt');
|
|
31
32
|
const { filterAttachments, resolveFileCaps, resolveMaxFileOverride, MAX_TOTAL_BYTES } = require('./lib/attachments');
|
|
@@ -57,6 +58,18 @@ const { sweepTmuxOrphans } = require('@shumkov/orchestra').orphanSweep;
|
|
|
57
58
|
const { createAutosteeredRefs } = require('./lib/autosteered-refs');
|
|
58
59
|
const { createBuildSdkOptions } = require('./lib/sdk/build-options');
|
|
59
60
|
const { createSdkCallbacks } = require('./lib/sdk/callbacks');
|
|
61
|
+
const {
|
|
62
|
+
createCodexRuntimeController,
|
|
63
|
+
} = require('./lib/codex/runtime-controller');
|
|
64
|
+
const {
|
|
65
|
+
buildCodexSpawnContext,
|
|
66
|
+
} = require('./lib/codex/spawn-context');
|
|
67
|
+
const {
|
|
68
|
+
scheduleCodexRetention,
|
|
69
|
+
} = require('./lib/codex/retention-scheduler');
|
|
70
|
+
const {
|
|
71
|
+
createTelegramDeliveryFinalizer,
|
|
72
|
+
} = require('./lib/codex/delivery-finalizer');
|
|
60
73
|
const { createQuestionStore } = require('./lib/questions/store');
|
|
61
74
|
const { createQuestionHandlers } = require('./lib/handlers/questions');
|
|
62
75
|
const { isRewindCommand, createRewindHandler } = require('./lib/rewind/rewind');
|
|
@@ -75,6 +88,10 @@ const { classifyReplay, executeReplayPlan } = require('./lib/handlers/replay-dis
|
|
|
75
88
|
const { createDropRedeliverer } = require('./lib/handlers/drop-redeliver');
|
|
76
89
|
const { createSessionFeedback } = require('./lib/feedback/session-feedback');
|
|
77
90
|
const { createSlashCommands } = require('./lib/handlers/slash-commands');
|
|
91
|
+
const {
|
|
92
|
+
buildCodexReconciliationView,
|
|
93
|
+
createHandleCodexReconciliationCallback,
|
|
94
|
+
} = require('./lib/handlers/codex-reconciliation');
|
|
78
95
|
const { createApprovals } = require('./lib/handlers/approvals');
|
|
79
96
|
const { canonicalizeToolInput } = require('./lib/canonical-json');
|
|
80
97
|
const {
|
|
@@ -480,12 +497,88 @@ async function clearAutosteeredReactions(sessionKey) {
|
|
|
480
497
|
// once the runtime context (config, BOT_NAME, makeCanUseTool, logEvent)
|
|
481
498
|
// is available.
|
|
482
499
|
let buildSdkOptions = null;
|
|
500
|
+
let codexRuntimeController = null;
|
|
501
|
+
|
|
502
|
+
async function resolveSessionRuntimeView({
|
|
503
|
+
sessionKey,
|
|
504
|
+
chatId,
|
|
505
|
+
threadId = null,
|
|
506
|
+
} = {}) {
|
|
507
|
+
if (codexRuntimeController) {
|
|
508
|
+
const runtimeView = await codexRuntimeController.resolveRuntimeView({
|
|
509
|
+
sessionKey,
|
|
510
|
+
chatId,
|
|
511
|
+
threadId,
|
|
512
|
+
});
|
|
513
|
+
if (!isCodexRuntimeView(runtimeView)) return runtimeView;
|
|
514
|
+
if (typeof pm?.getModelSettingsStatus !== 'function') {
|
|
515
|
+
const error = new Error('Codex model-settings status is unavailable');
|
|
516
|
+
error.code = 'CODEX_RUNTIME_UNAVAILABLE';
|
|
517
|
+
throw error;
|
|
518
|
+
}
|
|
519
|
+
const settingsStatus = await pm.getModelSettingsStatus(sessionKey);
|
|
520
|
+
const processStatus = settingsStatus.outcome;
|
|
521
|
+
const unavailableReason = settingsStatus.reason ?? null;
|
|
522
|
+
const copySettings = (settings) => (
|
|
523
|
+
settings
|
|
524
|
+
&& typeof settings.model === 'string'
|
|
525
|
+
&& typeof settings.effort === 'string'
|
|
526
|
+
? Object.freeze({
|
|
527
|
+
model: settings.model,
|
|
528
|
+
effort: settings.effort,
|
|
529
|
+
})
|
|
530
|
+
: null
|
|
531
|
+
);
|
|
532
|
+
return Object.freeze({
|
|
533
|
+
...runtimeView,
|
|
534
|
+
desiredSettings: Object.freeze({
|
|
535
|
+
model: runtimeView.model,
|
|
536
|
+
effort: runtimeView.effort,
|
|
537
|
+
}),
|
|
538
|
+
nextTurnSettings: copySettings(settingsStatus.nextTurn),
|
|
539
|
+
observedThreadSettings: copySettings(settingsStatus.observedThread),
|
|
540
|
+
activeTurnSettings: copySettings(settingsStatus.currentTurn),
|
|
541
|
+
processStatus,
|
|
542
|
+
unavailableReason,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
if (resolvePromptBackend({ config, chatId, threadId }) === 'codex') {
|
|
546
|
+
const error = new Error('Codex runtime controller is not initialized');
|
|
547
|
+
error.code = 'CODEX_RUNTIME_UNAVAILABLE';
|
|
548
|
+
throw error;
|
|
549
|
+
}
|
|
550
|
+
return Object.freeze({ runtime: 'claude' });
|
|
551
|
+
}
|
|
483
552
|
|
|
484
|
-
function buildSpawnContext(sessionKey) {
|
|
553
|
+
async function buildSpawnContext(sessionKey) {
|
|
485
554
|
const chatId = getChatIdFromKey(sessionKey);
|
|
486
555
|
const chatConfig = config.chats[chatId];
|
|
487
556
|
if (!chatConfig) return null;
|
|
488
557
|
const threadId = sessionKey.includes(':') ? sessionKey.split(':')[1] : null;
|
|
558
|
+
const promptBackend = resolvePromptBackend({
|
|
559
|
+
config,
|
|
560
|
+
chatId,
|
|
561
|
+
threadId: threadId || null,
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
if (promptBackend === 'codex') {
|
|
565
|
+
if (!codexRuntimeController) {
|
|
566
|
+
const error = new Error('Codex runtime controller is not initialized');
|
|
567
|
+
error.code = 'CODEX_RUNTIME_UNAVAILABLE';
|
|
568
|
+
throw error;
|
|
569
|
+
}
|
|
570
|
+
return buildCodexSpawnContext({
|
|
571
|
+
sessionKey,
|
|
572
|
+
chatId,
|
|
573
|
+
threadId: threadId || null,
|
|
574
|
+
chatConfig,
|
|
575
|
+
db,
|
|
576
|
+
pm,
|
|
577
|
+
runtimeController: codexRuntimeController,
|
|
578
|
+
getSessionLabel,
|
|
579
|
+
logEvent,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
489
582
|
|
|
490
583
|
// S2: a stored session is valid ONLY for the config it was spawned
|
|
491
584
|
// under. agent / cwd are spawn-identity — baked into the process at
|
|
@@ -557,7 +650,7 @@ function buildSpawnContext(sessionKey) {
|
|
|
557
650
|
}
|
|
558
651
|
|
|
559
652
|
async function getOrSpawnForChat(sessionKey) {
|
|
560
|
-
const ctx = buildSpawnContext(sessionKey);
|
|
653
|
+
const ctx = await buildSpawnContext(sessionKey);
|
|
561
654
|
if (!ctx) return null;
|
|
562
655
|
return pm.getOrSpawn(sessionKey, ctx);
|
|
563
656
|
}
|
|
@@ -574,6 +667,33 @@ async function sendToProcess(sessionKey, prompt, context = {}, { onDispatched }
|
|
|
574
667
|
// 90 min. The checkpoint never extends a turn past this.
|
|
575
668
|
const maxTurnHardMs = (chatConfig.maxTurnHard || config.defaults?.maxTurnHard || 5400) * 1000;
|
|
576
669
|
|
|
670
|
+
// CodexProcess owns a bounded cancellable queue. Commit the send into that
|
|
671
|
+
// queue immediately so /stop can reject it; the full-turn stdin lock below
|
|
672
|
+
// exists only to prevent Claude stream-json batching.
|
|
673
|
+
if (
|
|
674
|
+
entry.runtime === 'codex'
|
|
675
|
+
|| entry.backend === 'codex'
|
|
676
|
+
) {
|
|
677
|
+
if (
|
|
678
|
+
context.codexDispatchGenerationId
|
|
679
|
+
&& entry.generationId !== context.codexDispatchGenerationId
|
|
680
|
+
) {
|
|
681
|
+
const error = new Error(
|
|
682
|
+
'Codex queue authorization belongs to a replaced generation',
|
|
683
|
+
);
|
|
684
|
+
error.code = 'CODEX_DISPATCH_GENERATION_CHANGED';
|
|
685
|
+
throw error;
|
|
686
|
+
}
|
|
687
|
+
const turnP = pm.send(sessionKey, prompt, {
|
|
688
|
+
timeoutMs,
|
|
689
|
+
maxTurnMs,
|
|
690
|
+
maxTurnHardMs,
|
|
691
|
+
context,
|
|
692
|
+
});
|
|
693
|
+
if (typeof onDispatched === 'function') onDispatched();
|
|
694
|
+
return await turnP;
|
|
695
|
+
}
|
|
696
|
+
|
|
577
697
|
// 0.12 Phase 2.1: HeartbeatReactor binding removed for CliProcess.
|
|
578
698
|
// 0.11.0-channels needed a random-cycling working-pool reactor because
|
|
579
699
|
// the channels protocol gave no per-tool visibility. CliProcess (0.12)
|
|
@@ -644,6 +764,7 @@ let attemptAutoResume = null;
|
|
|
644
764
|
let errorReplyText = null;
|
|
645
765
|
let queueWarnThreshold = null;
|
|
646
766
|
let inFlightHandlers = null;
|
|
767
|
+
let recoverCodexRequest = null;
|
|
647
768
|
|
|
648
769
|
// AUTH_DISABLED (docs/AUTH_DISABLED_HANDLING_SPEC.md): dedupe/re-arm gate for
|
|
649
770
|
// the operator notification (dispatcher.js) + Netdata-visibility heartbeat
|
|
@@ -725,12 +846,14 @@ const {
|
|
|
725
846
|
MODEL_OPTIONS,
|
|
726
847
|
EFFORT_OPTIONS,
|
|
727
848
|
MODEL_VERSIONS_DESC,
|
|
849
|
+
isCodexRuntimeView,
|
|
728
850
|
} = require('./lib/handlers/config-ui');
|
|
729
851
|
let formatConfigInfoText = null;
|
|
730
852
|
// CRITICAL: these placeholders MUST exist or 'use strict' boot fails
|
|
731
853
|
// with ReferenceError. v4 review (commit 39) found 4 missing — restored.
|
|
732
854
|
// Each handler is wired in main() once its deps exist.
|
|
733
855
|
let handleConfigCallback = null;
|
|
856
|
+
let handleCodexReconciliationCallback = null;
|
|
734
857
|
let handleAbortIfRequested = null;
|
|
735
858
|
let autosteer = null;
|
|
736
859
|
let dispatchSlashCommand = null;
|
|
@@ -782,6 +905,23 @@ function parsePairCodeArgs(text) {
|
|
|
782
905
|
async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
783
906
|
const chatConfig = config.chats[chatId];
|
|
784
907
|
if (!chatConfig) return;
|
|
908
|
+
const selectedInboundProvider = resolvePromptBackend({
|
|
909
|
+
config,
|
|
910
|
+
chatId,
|
|
911
|
+
threadId: msg.message_thread_id?.toString() || null,
|
|
912
|
+
}) === 'codex'
|
|
913
|
+
? 'codex'
|
|
914
|
+
: 'claude';
|
|
915
|
+
if (
|
|
916
|
+
msg._requiredProvider
|
|
917
|
+
&& msg._requiredProvider !== selectedInboundProvider
|
|
918
|
+
) {
|
|
919
|
+
const error = new Error(
|
|
920
|
+
`Recovery requires ${msg._requiredProvider}, but the session now selects ${selectedInboundProvider}`,
|
|
921
|
+
);
|
|
922
|
+
error.code = 'PROVIDER_RECOVERY_SELECTION_CHANGED';
|
|
923
|
+
throw error;
|
|
924
|
+
}
|
|
785
925
|
|
|
786
926
|
// Mark the inbound row as 'dispatched' so the boot replay loop knows
|
|
787
927
|
// this turn started. Cleared to 'replied' (or 'failed') when done.
|
|
@@ -792,6 +932,17 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
792
932
|
dbWrite(() => db.setInboundHandlerStatus({
|
|
793
933
|
chat_id: chatId, msg_id: msg.message_id, status: 'dispatched',
|
|
794
934
|
}), 'set handler_status=dispatched');
|
|
935
|
+
// This write is deliberately not best-effort: provider choice is the
|
|
936
|
+
// replay fence. A conflicting duplicate must stop before any provider
|
|
937
|
+
// process, reservation, or RPC observes the input.
|
|
938
|
+
db.recordInboundRuntimeSelection({
|
|
939
|
+
session_key: sessionKey,
|
|
940
|
+
bot_name: BOT_NAME,
|
|
941
|
+
telegram_chat_id: String(chatId),
|
|
942
|
+
telegram_message_id: String(msg.message_id),
|
|
943
|
+
provider: selectedInboundProvider,
|
|
944
|
+
ts: Date.now(),
|
|
945
|
+
});
|
|
795
946
|
}
|
|
796
947
|
|
|
797
948
|
const text = msg.text || msg.caption || '';
|
|
@@ -829,9 +980,15 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
829
980
|
// command — which post-compaction lands in a stale-session state and
|
|
830
981
|
// emits "🗜️ No active session — /compact only works once a turn has
|
|
831
982
|
// started." Visible duplicate-reply UX bug.
|
|
832
|
-
const
|
|
833
|
-
|
|
834
|
-
|
|
983
|
+
const markHandlerStatus = (status) => dbWrite(
|
|
984
|
+
() => db.setInboundHandlerStatus({
|
|
985
|
+
chat_id: chatId,
|
|
986
|
+
msg_id: msg.message_id,
|
|
987
|
+
status,
|
|
988
|
+
}),
|
|
989
|
+
`set handler_status=${status}`,
|
|
990
|
+
);
|
|
991
|
+
const markReplied = () => markHandlerStatus('replied');
|
|
835
992
|
|
|
836
993
|
// sendReply accepts (text, meta?) with optional extra Telegram params
|
|
837
994
|
// pulled out via meta.params (kept separate so meta stays for DB tags).
|
|
@@ -856,9 +1013,47 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
856
1013
|
// (2026-06-03). getTopicConfig returns {} when there's no active topic.
|
|
857
1014
|
const _cardTopicCfg = getTopicConfig(chatConfig, threadIdStr || null);
|
|
858
1015
|
const effectiveRichText = resolveRichTextEnabled(config, chatId, threadIdStr || null);
|
|
859
|
-
|
|
860
|
-
|
|
1016
|
+
let runtimeView;
|
|
1017
|
+
try {
|
|
1018
|
+
runtimeView = await resolveSessionRuntimeView({
|
|
1019
|
+
sessionKey,
|
|
1020
|
+
chatId,
|
|
1021
|
+
threadId: threadIdStr || null,
|
|
1022
|
+
});
|
|
1023
|
+
} catch (error) {
|
|
1024
|
+
console.error(
|
|
1025
|
+
`[${label}] config card runtime view failed: `
|
|
1026
|
+
+ `${error?.code || error?.name || 'unknown'}`,
|
|
1027
|
+
);
|
|
1028
|
+
await sendReply('Configuration is temporarily unavailable.');
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
const info = await formatConfigInfoText(
|
|
1032
|
+
chatConfig,
|
|
1033
|
+
show,
|
|
1034
|
+
sessionKey,
|
|
1035
|
+
_cardTopicCfg,
|
|
1036
|
+
effectiveRichText,
|
|
1037
|
+
runtimeView,
|
|
1038
|
+
);
|
|
1039
|
+
const reply_markup = buildConfigKeyboard(
|
|
1040
|
+
chatConfig,
|
|
1041
|
+
show,
|
|
1042
|
+
_cardTopicCfg,
|
|
1043
|
+
effectiveRichText,
|
|
1044
|
+
runtimeView,
|
|
1045
|
+
);
|
|
861
1046
|
await sendReply(info, { params: { reply_markup } });
|
|
1047
|
+
if (text === '/config' && isCodexRuntimeView(runtimeView)) {
|
|
1048
|
+
for (const attempt of db.listUnresolvedCodexAttempts({
|
|
1049
|
+
session_key: sessionKey,
|
|
1050
|
+
})) {
|
|
1051
|
+
const view = buildCodexReconciliationView(attempt);
|
|
1052
|
+
await sendReply(view.text, {
|
|
1053
|
+
params: { reply_markup: view.reply_markup },
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
862
1057
|
return;
|
|
863
1058
|
}
|
|
864
1059
|
// Slash command dispatch — extracted to lib/handlers/slash-commands.js.
|
|
@@ -883,22 +1078,24 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
883
1078
|
// read, and a token can expire between an original dispatch and its later
|
|
884
1079
|
// redelivery, so skipping it there would silently re-wedge exactly the
|
|
885
1080
|
// messages most likely to hit an expired-auth window (post-restart backlog).
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
1081
|
+
if (selectedInboundProvider === 'claude') {
|
|
1082
|
+
let auth;
|
|
1083
|
+
try {
|
|
1084
|
+
auth = checkClaudeAuthHealth();
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
console.error(`[auth] health check failed: ${err.message}`);
|
|
1087
|
+
auth = { state: 'unknown' };
|
|
1088
|
+
}
|
|
1089
|
+
if (auth.state === 'expired') {
|
|
1090
|
+
const expIso = new Date(auth.refreshTokenExpiresAt).toISOString();
|
|
1091
|
+
console.error(`[auth] Claude login EXPIRED (refresh token ${expIso}) — refusing turn for ${label}; re-login on the host.`);
|
|
1092
|
+
logEvent('auth-expired', {
|
|
1093
|
+
session_key: sessionKey, chat_id: chatId, source: 'dispatch-gate',
|
|
1094
|
+
refresh_token_expires_at: auth.refreshTokenExpiresAt,
|
|
1095
|
+
});
|
|
1096
|
+
await sendReply('🔑 Claude login has expired and needs to be re-authenticated. Messages can\'t be processed until the login is refreshed on the host.');
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
902
1099
|
}
|
|
903
1100
|
|
|
904
1101
|
const t0 = Date.now();
|
|
@@ -1314,7 +1511,21 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1314
1511
|
// Skip the 🤔 → ✍ flash for messages that are about to be
|
|
1315
1512
|
// autosteered. willAutosteer evaluates the same pre-condition
|
|
1316
1513
|
// tryAutosteer would.
|
|
1317
|
-
|
|
1514
|
+
const feedbackEntry = pm.get(sessionKey);
|
|
1515
|
+
const pendingCodexFollowup = Boolean(
|
|
1516
|
+
feedbackEntry
|
|
1517
|
+
&& !feedbackEntry.closed
|
|
1518
|
+
&& feedbackEntry.inFlight
|
|
1519
|
+
&& (
|
|
1520
|
+
feedbackEntry.runtime === 'codex'
|
|
1521
|
+
|| feedbackEntry.backend === 'codex'
|
|
1522
|
+
),
|
|
1523
|
+
);
|
|
1524
|
+
if (
|
|
1525
|
+
!voiceAck.ackEmitted
|
|
1526
|
+
&& !pendingCodexFollowup
|
|
1527
|
+
&& !autosteer.willAutosteer(sessionKey, chatConfig)
|
|
1528
|
+
) {
|
|
1318
1529
|
reactor.setState('THINKING');
|
|
1319
1530
|
}
|
|
1320
1531
|
|
|
@@ -1347,9 +1558,182 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1347
1558
|
const releaseIntent = await intentLock.acquire(sessionKey);
|
|
1348
1559
|
let steered = { autosteered: false };
|
|
1349
1560
|
let sendPromise = null;
|
|
1561
|
+
let codexDispatch = null;
|
|
1562
|
+
let codexDispatchDecision = null;
|
|
1350
1563
|
try {
|
|
1351
|
-
|
|
1352
|
-
|
|
1564
|
+
const current = pm.get(sessionKey);
|
|
1565
|
+
const liveCodexGeneration = Boolean(
|
|
1566
|
+
current
|
|
1567
|
+
&& !current.closed
|
|
1568
|
+
&& current.inFlight
|
|
1569
|
+
&& typeof current.generationId === 'string'
|
|
1570
|
+
&& (
|
|
1571
|
+
current.runtime === 'codex'
|
|
1572
|
+
|| current.backend === 'codex'
|
|
1573
|
+
),
|
|
1574
|
+
);
|
|
1575
|
+
if (liveCodexGeneration) {
|
|
1576
|
+
if (!codexRuntimeController) {
|
|
1577
|
+
codexDispatchDecision = 'unavailable';
|
|
1578
|
+
} else {
|
|
1579
|
+
try {
|
|
1580
|
+
const dispatchClaim = codexRuntimeController.claimDispatchReservation({
|
|
1581
|
+
sessionKey,
|
|
1582
|
+
generationId: current.generationId,
|
|
1583
|
+
botName: BOT_NAME,
|
|
1584
|
+
telegramChatId: String(chatId),
|
|
1585
|
+
telegramMessageId: String(msg.message_id),
|
|
1586
|
+
});
|
|
1587
|
+
codexDispatch = {
|
|
1588
|
+
reservationId: dispatchClaim.reservationId,
|
|
1589
|
+
generationId: dispatchClaim.generationId,
|
|
1590
|
+
state: dispatchClaim.reservation?.state,
|
|
1591
|
+
};
|
|
1592
|
+
if (!dispatchClaim.claimed) {
|
|
1593
|
+
codexDispatchDecision = 'duplicate';
|
|
1594
|
+
} else {
|
|
1595
|
+
steered = await autosteer.tryCodexAutosteer({
|
|
1596
|
+
sessionKey,
|
|
1597
|
+
chatConfig,
|
|
1598
|
+
chatId,
|
|
1599
|
+
msg,
|
|
1600
|
+
prompt,
|
|
1601
|
+
});
|
|
1602
|
+
if (steered.outcome === 'accepted') {
|
|
1603
|
+
try {
|
|
1604
|
+
codexRuntimeController.finalizeAcceptedSteer({
|
|
1605
|
+
sessionKey,
|
|
1606
|
+
generationId: codexDispatch.generationId,
|
|
1607
|
+
reservationId: codexDispatch.reservationId,
|
|
1608
|
+
steerAttemptId: steered.attemptId,
|
|
1609
|
+
targetAttemptId: steered.targetAttemptId,
|
|
1610
|
+
});
|
|
1611
|
+
codexDispatchDecision = 'accepted';
|
|
1612
|
+
} catch (error) {
|
|
1613
|
+
try {
|
|
1614
|
+
codexRuntimeController.markDispatchDisposition({
|
|
1615
|
+
sessionKey,
|
|
1616
|
+
generationId: codexDispatch.generationId,
|
|
1617
|
+
reservationId: codexDispatch.reservationId,
|
|
1618
|
+
disposition: 'ambiguous',
|
|
1619
|
+
});
|
|
1620
|
+
} catch {
|
|
1621
|
+
// The generation may have changed after app-server
|
|
1622
|
+
// acceptance. It is still unsafe to queue the input.
|
|
1623
|
+
}
|
|
1624
|
+
steered = { autosteered: false, outcome: 'ambiguous' };
|
|
1625
|
+
codexDispatchDecision = 'ambiguous';
|
|
1626
|
+
console.error(
|
|
1627
|
+
`[${label}] Codex steer linkage failed closed: `
|
|
1628
|
+
+ `${error?.code || error?.name || 'unknown'}`,
|
|
1629
|
+
);
|
|
1630
|
+
}
|
|
1631
|
+
} else if (steered.outcome === 'ambiguous') {
|
|
1632
|
+
try {
|
|
1633
|
+
codexRuntimeController.markDispatchDisposition({
|
|
1634
|
+
sessionKey,
|
|
1635
|
+
generationId: codexDispatch.generationId,
|
|
1636
|
+
reservationId: codexDispatch.reservationId,
|
|
1637
|
+
disposition: 'ambiguous',
|
|
1638
|
+
});
|
|
1639
|
+
} catch {
|
|
1640
|
+
// Generation drift is itself ambiguous. Never queue.
|
|
1641
|
+
}
|
|
1642
|
+
codexDispatchDecision = 'ambiguous';
|
|
1643
|
+
} else if (steered.outcome === 'unavailable') {
|
|
1644
|
+
codexRuntimeController.markDispatchDisposition({
|
|
1645
|
+
sessionKey,
|
|
1646
|
+
generationId: codexDispatch.generationId,
|
|
1647
|
+
reservationId: codexDispatch.reservationId,
|
|
1648
|
+
disposition: 'cancelled',
|
|
1649
|
+
});
|
|
1650
|
+
codexDispatchDecision = 'unavailable';
|
|
1651
|
+
} else {
|
|
1652
|
+
// Queue mode, a definitely-not-sent steer, disabled
|
|
1653
|
+
// autosteer, and a turn that became idle all converge on
|
|
1654
|
+
// the ordinary CodexProcess queue.
|
|
1655
|
+
const queueEntry = pm.get(sessionKey);
|
|
1656
|
+
const sameQueueGeneration = (
|
|
1657
|
+
queueEntry
|
|
1658
|
+
&& !queueEntry.closed
|
|
1659
|
+
&& queueEntry.generationId === codexDispatch.generationId
|
|
1660
|
+
);
|
|
1661
|
+
if (
|
|
1662
|
+
sameQueueGeneration
|
|
1663
|
+
&& ['Active', 'Idle', 'StartingTurn']
|
|
1664
|
+
.includes(queueEntry.state)
|
|
1665
|
+
) {
|
|
1666
|
+
codexRuntimeController.markDispatchDisposition({
|
|
1667
|
+
sessionKey,
|
|
1668
|
+
generationId: codexDispatch.generationId,
|
|
1669
|
+
reservationId: codexDispatch.reservationId,
|
|
1670
|
+
disposition: 'queue-authorized',
|
|
1671
|
+
});
|
|
1672
|
+
codexDispatchDecision = 'queue';
|
|
1673
|
+
} else if (sameQueueGeneration) {
|
|
1674
|
+
codexRuntimeController.markDispatchDisposition({
|
|
1675
|
+
sessionKey,
|
|
1676
|
+
generationId: codexDispatch.generationId,
|
|
1677
|
+
reservationId: codexDispatch.reservationId,
|
|
1678
|
+
disposition: 'cancelled',
|
|
1679
|
+
});
|
|
1680
|
+
codexDispatchDecision = 'unavailable';
|
|
1681
|
+
} else {
|
|
1682
|
+
try {
|
|
1683
|
+
codexRuntimeController.markDispatchDisposition({
|
|
1684
|
+
sessionKey,
|
|
1685
|
+
generationId: codexDispatch.generationId,
|
|
1686
|
+
reservationId: codexDispatch.reservationId,
|
|
1687
|
+
disposition: 'ambiguous',
|
|
1688
|
+
});
|
|
1689
|
+
} catch {
|
|
1690
|
+
// The stale generation can no longer be mutated safely.
|
|
1691
|
+
}
|
|
1692
|
+
codexDispatchDecision = 'ambiguous';
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
} catch (error) {
|
|
1697
|
+
if (codexDispatch) {
|
|
1698
|
+
try {
|
|
1699
|
+
codexRuntimeController.markDispatchDisposition({
|
|
1700
|
+
sessionKey,
|
|
1701
|
+
generationId: codexDispatch.generationId,
|
|
1702
|
+
reservationId: codexDispatch.reservationId,
|
|
1703
|
+
disposition: 'ambiguous',
|
|
1704
|
+
});
|
|
1705
|
+
} catch {
|
|
1706
|
+
// Preserve the conservative classification even when the
|
|
1707
|
+
// durable owner changed before the transition.
|
|
1708
|
+
}
|
|
1709
|
+
codexDispatchDecision = 'ambiguous';
|
|
1710
|
+
} else {
|
|
1711
|
+
codexDispatchDecision = 'unavailable';
|
|
1712
|
+
}
|
|
1713
|
+
console.error(
|
|
1714
|
+
`[${label}] Codex dispatch reservation failed closed: `
|
|
1715
|
+
+ `${error?.code || error?.name || 'unknown'}`,
|
|
1716
|
+
);
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
} else {
|
|
1720
|
+
// Preserve Claude's synchronous injection path and timing.
|
|
1721
|
+
steered = autosteer.tryAutosteer({
|
|
1722
|
+
sessionKey,
|
|
1723
|
+
chatConfig,
|
|
1724
|
+
chatId,
|
|
1725
|
+
msg,
|
|
1726
|
+
prompt,
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
if (
|
|
1730
|
+
!steered.autosteered
|
|
1731
|
+
&& !['duplicate', 'ambiguous', 'unavailable']
|
|
1732
|
+
.includes(codexDispatchDecision)
|
|
1733
|
+
) {
|
|
1734
|
+
if (codexDispatchDecision === 'queue') {
|
|
1735
|
+
reactor.setState('THINKING');
|
|
1736
|
+
}
|
|
1353
1737
|
// Primary turn. Kick off the dispatch and hold the latch until
|
|
1354
1738
|
// pm.send has made the process inFlight (onDispatched). The
|
|
1355
1739
|
// turn RESULT is awaited only AFTER the latch is released — the
|
|
@@ -1363,6 +1747,8 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1363
1747
|
// the question lifecycle (callbacks.js onQuestionAsked/-Resumed) can
|
|
1364
1748
|
// pause it while the bot waits on the USER and resume on the answer.
|
|
1365
1749
|
streamer, reactor, typing: stopTyping, sourceMsgId: msg.message_id,
|
|
1750
|
+
codexDispatchReservationId: codexDispatch?.reservationId,
|
|
1751
|
+
codexDispatchGenerationId: codexDispatch?.generationId,
|
|
1366
1752
|
// 0.7.4 (item B): fire THINKING when Claude actually starts
|
|
1367
1753
|
// emitting — not the moment we wrote stdin.
|
|
1368
1754
|
onFirstStream: () => reactor.setState('THINKING'),
|
|
@@ -1374,6 +1760,46 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1374
1760
|
} finally {
|
|
1375
1761
|
releaseIntent();
|
|
1376
1762
|
}
|
|
1763
|
+
if (codexDispatchDecision === 'duplicate') {
|
|
1764
|
+
stopTyping();
|
|
1765
|
+
reactor.stop();
|
|
1766
|
+
const terminalDuplicateStatus = (
|
|
1767
|
+
codexDispatch?.state === 'settled'
|
|
1768
|
+
? 'replied'
|
|
1769
|
+
: ['failed', 'interrupted', 'cancelled'].includes(codexDispatch?.state)
|
|
1770
|
+
? 'failed'
|
|
1771
|
+
: codexDispatch?.state === 'ambiguous'
|
|
1772
|
+
? 'codex-ambiguous'
|
|
1773
|
+
: null
|
|
1774
|
+
);
|
|
1775
|
+
if (terminalDuplicateStatus) {
|
|
1776
|
+
dbWrite(() => db.setInboundHandlerStatus({
|
|
1777
|
+
chat_id: chatId,
|
|
1778
|
+
msg_id: msg.message_id,
|
|
1779
|
+
status: terminalDuplicateStatus,
|
|
1780
|
+
}), 'restore duplicate Codex handler status');
|
|
1781
|
+
}
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
if (['ambiguous', 'unavailable'].includes(codexDispatchDecision)) {
|
|
1785
|
+
stopTyping();
|
|
1786
|
+
await reactor.clear().catch(() => {});
|
|
1787
|
+
reactor.stop();
|
|
1788
|
+
const sendCodexDispatchNotice = (notice) => tg(bot, 'sendMessage', {
|
|
1789
|
+
chat_id: chatId,
|
|
1790
|
+
text: notice,
|
|
1791
|
+
...replyOpts(threadId),
|
|
1792
|
+
}, {
|
|
1793
|
+
source: 'codex-autosteer-notice',
|
|
1794
|
+
botName: BOT_NAME,
|
|
1795
|
+
});
|
|
1796
|
+
await sendCodexDispatchNotice(
|
|
1797
|
+
codexDispatchDecision === 'ambiguous'
|
|
1798
|
+
? 'This follow-up may have been incorporated. Please wait for the current turn to finish before retrying it.'
|
|
1799
|
+
: 'I couldn’t add that follow-up while the current turn was stopping. Please send it again after the turn finishes.',
|
|
1800
|
+
);
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1377
1803
|
if (steered.autosteered) {
|
|
1378
1804
|
stopTyping();
|
|
1379
1805
|
// setState('AUTOSTEERED') is terminal — bypasses throttle,
|
|
@@ -1382,15 +1808,36 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1382
1808
|
// AUTOSTEERED is terminal; stop the reactor's STALL / TIMEOUT
|
|
1383
1809
|
// timers so they don't pin the closure for up to 30s.
|
|
1384
1810
|
reactor.stop();
|
|
1385
|
-
markReplied();
|
|
1386
1811
|
return;
|
|
1387
1812
|
}
|
|
1388
1813
|
|
|
1814
|
+
let deliveryFinalizer = null;
|
|
1389
1815
|
try {
|
|
1390
1816
|
const result = await sendPromise;
|
|
1391
1817
|
// sendToProcess failures are captured (not thrown) so the latch
|
|
1392
1818
|
// always releases; re-throw here into the existing handler.
|
|
1393
1819
|
if (result && result.__sendError) throw result.__sendError;
|
|
1820
|
+
let telegramDeliveryComplete = true;
|
|
1821
|
+
deliveryFinalizer = createTelegramDeliveryFinalizer({
|
|
1822
|
+
controller: codexRuntimeController,
|
|
1823
|
+
sessionKey,
|
|
1824
|
+
result,
|
|
1825
|
+
queuedDispatch: (
|
|
1826
|
+
codexDispatchDecision === 'queue'
|
|
1827
|
+
&& codexDispatch?.reservationId
|
|
1828
|
+
)
|
|
1829
|
+
? {
|
|
1830
|
+
reservationId: codexDispatch.reservationId,
|
|
1831
|
+
botName: BOT_NAME,
|
|
1832
|
+
telegramChatId: String(chatId),
|
|
1833
|
+
telegramMessageId: String(msg.message_id),
|
|
1834
|
+
}
|
|
1835
|
+
: null,
|
|
1836
|
+
markHandlerStatus,
|
|
1837
|
+
});
|
|
1838
|
+
const finalizeResultDelivery = (deliveryComplete = true) => (
|
|
1839
|
+
deliveryFinalizer.finalize(deliveryComplete)
|
|
1840
|
+
);
|
|
1394
1841
|
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
1395
1842
|
|
|
1396
1843
|
// 0.7.6 (item F): persist per-turn telemetry. Stream-json result
|
|
@@ -1560,7 +2007,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1560
2007
|
// a placeholder so the user doesn't see silence with no reaction.
|
|
1561
2008
|
// NO_REPLY is an explicit "stay silent" signal from the agent —
|
|
1562
2009
|
// those still markReplied silently.
|
|
1563
|
-
if (result.text === 'NO_REPLY') {
|
|
2010
|
+
if (result.text === 'NO_REPLY') {
|
|
2011
|
+
await finalizeResultDelivery();
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
1564
2014
|
if (!result.text) {
|
|
1565
2015
|
// 0.8.0-rc.7: tool-only completion is NOT an error. Under SDK
|
|
1566
2016
|
// pm, a turn that ends after running tools (no closing text
|
|
@@ -1581,7 +2031,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1581
2031
|
num_tool_uses: result.metrics?.numToolUses,
|
|
1582
2032
|
num_assistant_messages: result.metrics?.numAssistantMessages,
|
|
1583
2033
|
});
|
|
1584
|
-
|
|
2034
|
+
await finalizeResultDelivery();
|
|
1585
2035
|
return;
|
|
1586
2036
|
}
|
|
1587
2037
|
// 0.7.1: if the fallback send itself fails, throw rather than
|
|
@@ -1615,7 +2065,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1615
2065
|
// does NOT remove the visible emoji. Without this clear, the
|
|
1616
2066
|
// user sees 👀 next to their message indefinitely.
|
|
1617
2067
|
await reactor.clear().catch(() => {});
|
|
1618
|
-
|
|
2068
|
+
await finalizeResultDelivery();
|
|
1619
2069
|
return;
|
|
1620
2070
|
}
|
|
1621
2071
|
|
|
@@ -1640,7 +2090,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1640
2090
|
reactor.clear().catch(() => {});
|
|
1641
2091
|
clearAutosteeredReactions(sessionKey).catch(() => {});
|
|
1642
2092
|
console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | channels-delivered | ${chatConfig.model}/${chatConfig.effort}`);
|
|
1643
|
-
|
|
2093
|
+
await finalizeResultDelivery();
|
|
1644
2094
|
return;
|
|
1645
2095
|
}
|
|
1646
2096
|
|
|
@@ -1781,7 +2231,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1781
2231
|
}
|
|
1782
2232
|
clearAutosteeredReactions(sessionKey).catch(() => {});
|
|
1783
2233
|
console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
|
|
1784
|
-
|
|
2234
|
+
await finalizeResultDelivery(
|
|
2235
|
+
!mediaContext.deliveryIncomplete && telegramDeliveryComplete,
|
|
2236
|
+
);
|
|
1785
2237
|
return;
|
|
1786
2238
|
}
|
|
1787
2239
|
// Preview can't hold the final body (overflow OR last edit
|
|
@@ -1842,7 +2294,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1842
2294
|
}
|
|
1843
2295
|
clearAutosteeredReactions(sessionKey).catch(() => {});
|
|
1844
2296
|
console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | streamed-redeliver(${reason}, ${chunks.length} chunks${r.failed.length ? `, ${r.failed.length} failed` : ''}) | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
|
|
1845
|
-
|
|
2297
|
+
await finalizeResultDelivery(
|
|
2298
|
+
!mediaContext.deliveryIncomplete && telegramDeliveryComplete,
|
|
2299
|
+
);
|
|
1846
2300
|
return;
|
|
1847
2301
|
}
|
|
1848
2302
|
// Not streamed (response too short — never crossed minChars).
|
|
@@ -1855,6 +2309,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1855
2309
|
message_id: msg.message_id,
|
|
1856
2310
|
reaction: [{ type: 'emoji', emoji: parsed.reaction }],
|
|
1857
2311
|
}, outMeta).catch((err) => {
|
|
2312
|
+
telegramDeliveryComplete = false;
|
|
1858
2313
|
console.error(`[${label}] setMessageReaction failed: ${err.message}`);
|
|
1859
2314
|
});
|
|
1860
2315
|
} else if (parsed.sticker) {
|
|
@@ -1863,6 +2318,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1863
2318
|
sticker: parsed.sticker,
|
|
1864
2319
|
...(threadId && { message_thread_id: threadId }),
|
|
1865
2320
|
}, { ...outMeta, stickerName: parsed.stickerLabel }).catch((err) => {
|
|
2321
|
+
telegramDeliveryComplete = false;
|
|
1866
2322
|
console.error(`[${label}] sendSticker failed: ${err.message}`);
|
|
1867
2323
|
});
|
|
1868
2324
|
} else if (parsed.text) {
|
|
@@ -1876,7 +2332,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1876
2332
|
? stripMediaMarkdown(parsed.text)
|
|
1877
2333
|
: parsed.text;
|
|
1878
2334
|
const chunks = chunkMarkdownText(shortText, TG_CHUNK_BUDGET);
|
|
1879
|
-
await deliverReplies({
|
|
2335
|
+
const deliveryResult = await deliverReplies({
|
|
1880
2336
|
bot,
|
|
1881
2337
|
send: (b, method, params, m) => tg(b, method, params, m),
|
|
1882
2338
|
chatId,
|
|
@@ -1886,6 +2342,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1886
2342
|
meta: outMeta,
|
|
1887
2343
|
logger: { error: (m) => console.error(`[${label}] ${m}`) },
|
|
1888
2344
|
});
|
|
2345
|
+
mediaContext.recordTextFailures(deliveryResult.failed.length);
|
|
1889
2346
|
}
|
|
1890
2347
|
|
|
1891
2348
|
await mediaContext.flushPartialDeliveryWarning(outMetaBase);
|
|
@@ -1912,8 +2369,22 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1912
2369
|
// without breaking NEW-TURN.
|
|
1913
2370
|
clearAutosteeredReactions(sessionKey).catch(() => {});
|
|
1914
2371
|
console.log(`[${label}] ${elapsed}s | ${result.text.length} chars | ${chatConfig.model}/${chatConfig.effort} | $${result.cost?.toFixed(4) || '?'}`);
|
|
1915
|
-
|
|
2372
|
+
await finalizeResultDelivery(
|
|
2373
|
+
!mediaContext.deliveryIncomplete && telegramDeliveryComplete,
|
|
2374
|
+
);
|
|
1916
2375
|
} catch (err) {
|
|
2376
|
+
let deliverySettlementError = null;
|
|
2377
|
+
try {
|
|
2378
|
+
await deliveryFinalizer?.failIfPending();
|
|
2379
|
+
} catch (settlementError) {
|
|
2380
|
+
deliverySettlementError = settlementError;
|
|
2381
|
+
logEvent('codex-telegram-delivery-failure-settlement-failed', {
|
|
2382
|
+
chat_id: chatId,
|
|
2383
|
+
msg_id: msg.message_id,
|
|
2384
|
+
bot: BOT_NAME,
|
|
2385
|
+
code: settlementError?.code || settlementError?.name || 'unknown',
|
|
2386
|
+
});
|
|
2387
|
+
}
|
|
1917
2388
|
// If the user just aborted this session, silently finalise the stream
|
|
1918
2389
|
// without the scary "⚠ stream interrupted" banner. The user has already
|
|
1919
2390
|
// seen their "Остановлено." ack; adding a warning to the partial bubble
|
|
@@ -1955,6 +2426,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1955
2426
|
await streamer.finalize('', errorSuffix ? { errorSuffix } : {}).catch(() => {});
|
|
1956
2427
|
reactor.setState(reactorState);
|
|
1957
2428
|
}
|
|
2429
|
+
if (deliverySettlementError) throw deliverySettlementError;
|
|
1958
2430
|
throw err;
|
|
1959
2431
|
} finally {
|
|
1960
2432
|
stopTyping();
|
|
@@ -2125,6 +2597,11 @@ function createBot(token) {
|
|
|
2125
2597
|
handleAbortIfRequested: (...a) => handleAbortIfRequested(...a),
|
|
2126
2598
|
getRewindHandler: () => rewindHandler,
|
|
2127
2599
|
isRewindCommand,
|
|
2600
|
+
resolveRuntime: ({ chatId, threadId }) => resolvePromptBackend({
|
|
2601
|
+
config,
|
|
2602
|
+
chatId,
|
|
2603
|
+
threadId: threadId || null,
|
|
2604
|
+
}),
|
|
2128
2605
|
getQuestionHandlers: () => questionHandlers,
|
|
2129
2606
|
shouldHandle,
|
|
2130
2607
|
getSessionKey,
|
|
@@ -2249,6 +2726,10 @@ function createBot(token) {
|
|
|
2249
2726
|
const data = ctx.callbackQuery.data;
|
|
2250
2727
|
if (data.startsWith('cfg:')) {
|
|
2251
2728
|
await handleConfigCallback(ctx);
|
|
2729
|
+
} else if (data.startsWith('cxr:')) {
|
|
2730
|
+
if (handleCodexReconciliationCallback) {
|
|
2731
|
+
await handleCodexReconciliationCallback(ctx);
|
|
2732
|
+
}
|
|
2252
2733
|
} else if (data.startsWith('q:')) {
|
|
2253
2734
|
if (questionHandlers) await questionHandlers.handleQuestionCallback(ctx);
|
|
2254
2735
|
} else {
|
|
@@ -2298,9 +2779,9 @@ function createBot(token) {
|
|
|
2298
2779
|
});
|
|
2299
2780
|
return;
|
|
2300
2781
|
}
|
|
2301
|
-
//
|
|
2302
|
-
//
|
|
2303
|
-
//
|
|
2782
|
+
// Claude can fold a same-message re-edit into its running turn. Codex
|
|
2783
|
+
// defers the edited content until the same generation is idle, then
|
|
2784
|
+
// re-dispatches it as a provider-pinned visible turn.
|
|
2304
2785
|
const injected = maybeInjectEditCorrection?.(ctx.editedMessage);
|
|
2305
2786
|
if (!injected) maybePostTurnEdit?.(ctx.editedMessage, oldText, botUsername, mentionRe);
|
|
2306
2787
|
} catch (err) {
|
|
@@ -2376,6 +2857,11 @@ let startPollWatchdog = null;
|
|
|
2376
2857
|
|
|
2377
2858
|
async function main() {
|
|
2378
2859
|
const cgroupOomObserver = createCgroupOomObserver();
|
|
2860
|
+
let codexManagerOptions = Object.freeze({
|
|
2861
|
+
codexHostIdentity: null,
|
|
2862
|
+
codexBootSessionIdentity: null,
|
|
2863
|
+
codexRecoveryState: Object.freeze({ status: 'clear' }),
|
|
2864
|
+
});
|
|
2379
2865
|
loadConfig();
|
|
2380
2866
|
|
|
2381
2867
|
let dbOverride;
|
|
@@ -2406,6 +2892,18 @@ async function main() {
|
|
|
2406
2892
|
loadStickers();
|
|
2407
2893
|
DB_PATH = dbOverride || path.join(DB_DIR, `${BOT_NAME}.db`);
|
|
2408
2894
|
console.log(`[polygram] bot: ${BOT_NAME} (${Object.keys(config.chats).length} chats) db: ${DB_PATH}`);
|
|
2895
|
+
let ipcRuntimeDir;
|
|
2896
|
+
try {
|
|
2897
|
+
ipcRuntimeDir = ipcServer.ensureRuntimeDirectory();
|
|
2898
|
+
process.env.POLYGRAM_IPC_DIR = ipcRuntimeDir;
|
|
2899
|
+
ipcServer.socketPathFor(BOT_NAME);
|
|
2900
|
+
} catch (error) {
|
|
2901
|
+
console.error(
|
|
2902
|
+
`[fatal] IPC runtime directory is unsafe or unavailable (${error.code || 'IPC_DIR_INVALID'})`,
|
|
2903
|
+
);
|
|
2904
|
+
process.exit(2);
|
|
2905
|
+
return;
|
|
2906
|
+
}
|
|
2409
2907
|
const sessionLauncher = process.env.ORCHESTRA_SESSION_LAUNCHER;
|
|
2410
2908
|
const requireExistingServer = process.env.ORCHESTRA_TMUX_REQUIRE_SERVER === '1';
|
|
2411
2909
|
console.log(`[polygram] session containment configured: ${sessionLauncher ? 'yes' : 'no'}`);
|
|
@@ -2449,6 +2947,39 @@ async function main() {
|
|
|
2449
2947
|
try {
|
|
2450
2948
|
db = dbClient.open(DB_PATH);
|
|
2451
2949
|
console.log(`[db] opened ${DB_PATH}`);
|
|
2950
|
+
try {
|
|
2951
|
+
codexRuntimeController = createCodexRuntimeController({
|
|
2952
|
+
config,
|
|
2953
|
+
db,
|
|
2954
|
+
defaultDaemonSecretRoots: [
|
|
2955
|
+
DATA_DIR,
|
|
2956
|
+
ipcRuntimeDir,
|
|
2957
|
+
path.dirname(CONFIG_PATH),
|
|
2958
|
+
path.join(process.env.HOME || DATA_DIR, '.claude'),
|
|
2959
|
+
],
|
|
2960
|
+
logger: console,
|
|
2961
|
+
});
|
|
2962
|
+
} catch (error) {
|
|
2963
|
+
codexRuntimeController = null;
|
|
2964
|
+
console.warn(
|
|
2965
|
+
`[codex] runtime unavailable; Claude remains enabled: ${error.message}`,
|
|
2966
|
+
);
|
|
2967
|
+
}
|
|
2968
|
+
if (codexRuntimeController) {
|
|
2969
|
+
try {
|
|
2970
|
+
const codexBoot = codexRuntimeController.initialize();
|
|
2971
|
+
codexManagerOptions = codexBoot.managerOptions;
|
|
2972
|
+
if (codexBoot.recovery.status === 'quarantined') {
|
|
2973
|
+
console.warn(
|
|
2974
|
+
`[codex] runtime quarantined at boot (${codexBoot.recovery.reason || 'unknown'})`,
|
|
2975
|
+
);
|
|
2976
|
+
}
|
|
2977
|
+
} catch (error) {
|
|
2978
|
+
console.warn(
|
|
2979
|
+
`[codex] runtime unavailable; Claude remains enabled: ${error.message}`,
|
|
2980
|
+
);
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2452
2983
|
tg = createSender(db, console, config);
|
|
2453
2984
|
richMediaFileIdCache = createMediaFileIdCache();
|
|
2454
2985
|
richEditMessageText = createRichEditor({
|
|
@@ -2525,6 +3056,7 @@ async function main() {
|
|
|
2525
3056
|
setImmediate(() => runEventsPrune('boot'));
|
|
2526
3057
|
setInterval(() => runEventsPrune('interval'), 24 * 3_600_000).unref?.();
|
|
2527
3058
|
}
|
|
3059
|
+
scheduleCodexRetention({ db, logEvent, logger: console });
|
|
2528
3060
|
|
|
2529
3061
|
// #5 secret redaction — background sweep (deterministic floor). Conservative:
|
|
2530
3062
|
// DISABLED unless config.defaults.secret_sweep.enabled; dryRun defaults ON, so
|
|
@@ -2806,7 +3338,7 @@ async function main() {
|
|
|
2806
3338
|
});
|
|
2807
3339
|
const channelsClaudeBin = pinnedClaudeBin;
|
|
2808
3340
|
|
|
2809
|
-
const
|
|
3341
|
+
const orchestraProcessFactory = createProcessFactory({
|
|
2810
3342
|
config,
|
|
2811
3343
|
spawnFn: buildSdkOptions,
|
|
2812
3344
|
db,
|
|
@@ -2836,7 +3368,17 @@ async function main() {
|
|
|
2836
3368
|
// normally supersedes this; kept so any context-less spawn still gets the backend
|
|
2837
3369
|
// default rather than orchestra's neutral 100MB).
|
|
2838
3370
|
maxOutboundFileBytes: resolveFileCaps({ localApi: !!config.bot?.apiRoot }).outBytes,
|
|
3371
|
+
codexClientFactory: codexRuntimeController?.clientFactory,
|
|
3372
|
+
codexCheckpointSink: codexRuntimeController?.checkpointSink,
|
|
3373
|
+
codexExpectedStaticProfile: codexRuntimeController?.resolveReceipt,
|
|
3374
|
+
codexHostIdentity: codexManagerOptions.codexHostIdentity,
|
|
3375
|
+
codexBootSessionIdentity: codexManagerOptions.codexBootSessionIdentity,
|
|
2839
3376
|
});
|
|
3377
|
+
const processFactory = (sessionKey, spawnContext) => {
|
|
3378
|
+
const proc = orchestraProcessFactory(sessionKey, spawnContext);
|
|
3379
|
+
if (proc?.runtime === 'codex') codexRuntimeController.registerProcess(proc);
|
|
3380
|
+
return proc;
|
|
3381
|
+
};
|
|
2840
3382
|
// Route in-process approval prompts through the SAME canUseTool plumbing
|
|
2841
3383
|
// that SDK chats use:
|
|
2842
3384
|
// - SdkProcess: SDK callbacks fire 'approval-required' via its own
|
|
@@ -2883,6 +3425,7 @@ async function main() {
|
|
|
2883
3425
|
logger: console,
|
|
2884
3426
|
callbacks: sdkCallbacks,
|
|
2885
3427
|
budget: cap,
|
|
3428
|
+
...codexManagerOptions,
|
|
2886
3429
|
});
|
|
2887
3430
|
// formatConfigInfoText MUST be wired BEFORE createHandleConfigCallback
|
|
2888
3431
|
// — the latter destructures formatConfigInfoText from its deps at
|
|
@@ -2890,12 +3433,17 @@ async function main() {
|
|
|
2890
3433
|
// caught this as the same class as the v3 BLOCKER.
|
|
2891
3434
|
formatConfigInfoText = createFormatConfigInfoText({
|
|
2892
3435
|
pm, db, getClaudeSessionId,
|
|
3436
|
+
resolveRuntimeView: resolveSessionRuntimeView,
|
|
2893
3437
|
});
|
|
2894
3438
|
handleConfigCallback = createHandleConfigCallback({
|
|
2895
|
-
config, db, dbWrite, pm, getSessionKey,
|
|
3439
|
+
config, db, dbWrite, pm, intentLock, getSessionKey,
|
|
2896
3440
|
formatConfigInfoText, buildConfigKeyboard, saveConfig,
|
|
3441
|
+
resolveRuntimeView: resolveSessionRuntimeView,
|
|
2897
3442
|
botName: BOT_NAME, logger: console,
|
|
2898
3443
|
});
|
|
3444
|
+
handleCodexReconciliationCallback = createHandleCodexReconciliationCallback({
|
|
3445
|
+
config, db, intentLock, getSessionKey, logger: console,
|
|
3446
|
+
});
|
|
2899
3447
|
handleAbortIfRequested = createHandleAbort({
|
|
2900
3448
|
pm, bot, tg, logEvent, isAbortRequest,
|
|
2901
3449
|
markSessionAborted, clearAutosteeredReactions, getSessionKey,
|
|
@@ -2922,6 +3470,14 @@ async function main() {
|
|
|
2922
3470
|
} = createDispatcher({
|
|
2923
3471
|
config, db, dbWrite, tg, botName: BOT_NAME, logEvent,
|
|
2924
3472
|
handleMessage, sendToProcess,
|
|
3473
|
+
recoverCodex: (input) => {
|
|
3474
|
+
if (typeof recoverCodexRequest !== 'function') {
|
|
3475
|
+
const error = new Error('Exact Codex recovery path is unavailable');
|
|
3476
|
+
error.code = 'CODEX_RECOVERY_UNAVAILABLE';
|
|
3477
|
+
throw error;
|
|
3478
|
+
}
|
|
3479
|
+
return recoverCodexRequest(input);
|
|
3480
|
+
},
|
|
2925
3481
|
classifyError, isAutoResumable,
|
|
2926
3482
|
abortGrace, autoResumeTracker,
|
|
2927
3483
|
chunkMarkdownText, deliverReplies,
|
|
@@ -2932,9 +3488,9 @@ async function main() {
|
|
|
2932
3488
|
logger: console,
|
|
2933
3489
|
}));
|
|
2934
3490
|
// 0.12.0 post-turn edit re-delivery: constructed AFTER dispatchHandleMessage is assigned (above).
|
|
2935
|
-
//
|
|
2936
|
-
//
|
|
2937
|
-
//
|
|
3491
|
+
// Claude keeps its same-message fold behavior. Codex defers edits to the
|
|
3492
|
+
// exact live generation's idle boundary and then re-dispatches them as
|
|
3493
|
+
// provider-pinned turns. The on-edit 👀 acknowledges the deferred action.
|
|
2938
3494
|
maybePostTurnEdit = createEditRedelivery({
|
|
2939
3495
|
pm, config, getSessionKey, shouldHandle, dispatchHandleMessage, bot,
|
|
2940
3496
|
react: (chatId, msgId) => applyReactionToMessages({
|
|
@@ -2956,6 +3512,29 @@ async function main() {
|
|
|
2956
3512
|
}).catch(() => {}),
|
|
2957
3513
|
bot, logEvent, logger: console,
|
|
2958
3514
|
});
|
|
3515
|
+
recoverCodexRequest = async ({
|
|
3516
|
+
sessionKey,
|
|
3517
|
+
chatId,
|
|
3518
|
+
msg,
|
|
3519
|
+
bot: recoveryBot,
|
|
3520
|
+
providerRecovery,
|
|
3521
|
+
}) => {
|
|
3522
|
+
if (providerRecovery?.provider !== 'codex') {
|
|
3523
|
+
return { ok: false, reason: 'provider recovery is not exact Codex' };
|
|
3524
|
+
}
|
|
3525
|
+
await handleMessage(
|
|
3526
|
+
sessionKey,
|
|
3527
|
+
String(chatId),
|
|
3528
|
+
{
|
|
3529
|
+
...msg,
|
|
3530
|
+
_isReplay: true,
|
|
3531
|
+
_requiredProvider: 'codex',
|
|
3532
|
+
_codexAutoResume: true,
|
|
3533
|
+
},
|
|
3534
|
+
recoveryBot,
|
|
3535
|
+
);
|
|
3536
|
+
return { ok: true };
|
|
3537
|
+
};
|
|
2959
3538
|
dropRedeliverer = createDropRedeliverer({
|
|
2960
3539
|
db, redeliver: redeliverAsFreshTurn, logEvent, logger: console,
|
|
2961
3540
|
});
|
|
@@ -2965,10 +3544,11 @@ async function main() {
|
|
|
2965
3544
|
logger: console,
|
|
2966
3545
|
}));
|
|
2967
3546
|
dispatchSlashCommand = createSlashCommands({
|
|
2968
|
-
config, db, dbWrite, pm, pairings, parsePairingTtl,
|
|
3547
|
+
config, db, dbWrite, pm, intentLock, pairings, parsePairingTtl,
|
|
2969
3548
|
contextHintShown, formatContextReply, getClaudeSessionId,
|
|
2970
3549
|
getOrSpawnForChat, parsePairCodeArgs,
|
|
2971
3550
|
modelVersionsDesc: MODEL_VERSIONS_DESC, saveConfig,
|
|
3551
|
+
resolveRuntimeView: resolveSessionRuntimeView,
|
|
2972
3552
|
botName: BOT_NAME, logEvent, logger: console,
|
|
2973
3553
|
});
|
|
2974
3554
|
console.log('[polygram] using SDK ProcessManager');
|
|
@@ -3263,7 +3843,37 @@ async function main() {
|
|
|
3263
3843
|
return msg;
|
|
3264
3844
|
};
|
|
3265
3845
|
|
|
3266
|
-
const
|
|
3846
|
+
const replaySessionKey = (row) => {
|
|
3847
|
+
const chatConfig = config.chats[row.chat_id];
|
|
3848
|
+
if (!chatConfig) return null;
|
|
3849
|
+
const threadId = row.thread_id == null
|
|
3850
|
+
? null
|
|
3851
|
+
: String(row.thread_id);
|
|
3852
|
+
return getSessionKey(row.chat_id, threadId, chatConfig);
|
|
3853
|
+
};
|
|
3854
|
+
const getProviderRecovery = (row) => {
|
|
3855
|
+
const sessionKey = replaySessionKey(row);
|
|
3856
|
+
if (!sessionKey) {
|
|
3857
|
+
return {
|
|
3858
|
+
provider: 'unknown',
|
|
3859
|
+
reason: 'session-configuration-missing',
|
|
3860
|
+
};
|
|
3861
|
+
}
|
|
3862
|
+
return db.getReplayProviderRecovery({
|
|
3863
|
+
sessionKey,
|
|
3864
|
+
botName: BOT_NAME,
|
|
3865
|
+
telegramChatId: String(row.chat_id),
|
|
3866
|
+
telegramMessageId: String(row.msg_id),
|
|
3867
|
+
});
|
|
3868
|
+
};
|
|
3869
|
+
|
|
3870
|
+
const plan = classifyReplay({
|
|
3871
|
+
candidates,
|
|
3872
|
+
cleanShutdown,
|
|
3873
|
+
hasCompletedTurn,
|
|
3874
|
+
announceable,
|
|
3875
|
+
getProviderRecovery,
|
|
3876
|
+
});
|
|
3267
3877
|
|
|
3268
3878
|
const result = await executeReplayPlan({
|
|
3269
3879
|
plan,
|
|
@@ -3273,11 +3883,49 @@ async function main() {
|
|
|
3273
3883
|
recover: async (row) => {
|
|
3274
3884
|
const chatConfig = config.chats[row.chat_id];
|
|
3275
3885
|
if (!chatConfig) return { ok: false };
|
|
3886
|
+
const selected = resolvePromptBackend({
|
|
3887
|
+
config,
|
|
3888
|
+
chatId: row.chat_id,
|
|
3889
|
+
threadId: row.thread_id == null ? null : String(row.thread_id),
|
|
3890
|
+
}) === 'codex'
|
|
3891
|
+
? 'codex'
|
|
3892
|
+
: 'claude';
|
|
3893
|
+
if (selected !== 'claude') return { ok: false };
|
|
3894
|
+
const msg = reconstruct(row);
|
|
3895
|
+
msg._requiredProvider = 'claude';
|
|
3276
3896
|
return redeliverAsFreshTurn({
|
|
3277
|
-
chatId: row.chat_id, msg
|
|
3897
|
+
chatId: row.chat_id, msg,
|
|
3278
3898
|
source: 'boot-replay', preMark: 'replay-attempted',
|
|
3279
3899
|
});
|
|
3280
3900
|
},
|
|
3901
|
+
recoverCodex: async (row) => {
|
|
3902
|
+
const chatConfig = config.chats[row.chat_id];
|
|
3903
|
+
if (!chatConfig) return { ok: false };
|
|
3904
|
+
const selected = resolvePromptBackend({
|
|
3905
|
+
config,
|
|
3906
|
+
chatId: row.chat_id,
|
|
3907
|
+
threadId: row.thread_id == null ? null : String(row.thread_id),
|
|
3908
|
+
}) === 'codex'
|
|
3909
|
+
? 'codex'
|
|
3910
|
+
: 'claude';
|
|
3911
|
+
if (selected !== 'codex') {
|
|
3912
|
+
logEvent('codex-replay-deferred', {
|
|
3913
|
+
chat_id: row.chat_id,
|
|
3914
|
+
thread_id: row.thread_id ?? null,
|
|
3915
|
+
msg_id: row.msg_id,
|
|
3916
|
+
reason: 'runtime-selection-changed',
|
|
3917
|
+
});
|
|
3918
|
+
return { ok: false };
|
|
3919
|
+
}
|
|
3920
|
+
const msg = reconstruct(row);
|
|
3921
|
+
msg._requiredProvider = 'codex';
|
|
3922
|
+
return redeliverAsFreshTurn({
|
|
3923
|
+
chatId: row.chat_id,
|
|
3924
|
+
msg,
|
|
3925
|
+
source: 'boot-replay-codex',
|
|
3926
|
+
preMark: 'replay-attempted',
|
|
3927
|
+
});
|
|
3928
|
+
},
|
|
3281
3929
|
// CLEAN path — one visibility notice per (chat, thread). plainText so
|
|
3282
3930
|
// no markdown/HTML parse on a boot send.
|
|
3283
3931
|
sendNotice: async (g) => {
|
|
@@ -3295,11 +3943,12 @@ async function main() {
|
|
|
3295
3943
|
});
|
|
3296
3944
|
|
|
3297
3945
|
if (candidates.length > 0) {
|
|
3298
|
-
console.log(`[replay] ${cleanShutdown ? 'clean restart' : 'crash'} — recovered ${result.recovered}, skipped ${result.skipped}, noticed ${result.noticed}${result.noticeFailed ? `, notice-failed ${result.noticeFailed}` : ''}`);
|
|
3946
|
+
console.log(`[replay] ${cleanShutdown ? 'clean restart' : 'crash'} — recovered ${result.recovered}, skipped ${result.skipped}, noticed ${result.noticed}${result.noticeFailed ? `, notice-failed ${result.noticeFailed}` : ''}${result.deferred ? `, deferred ${result.deferred}` : ''}`);
|
|
3299
3947
|
logEvent('replay-on-boot', {
|
|
3300
3948
|
bot: BOT_NAME, clean: cleanShutdown,
|
|
3301
3949
|
recovered: result.recovered, skipped: result.skipped,
|
|
3302
3950
|
noticed: result.noticed, notice_failed: result.noticeFailed,
|
|
3951
|
+
deferred: result.deferred || 0,
|
|
3303
3952
|
total: candidates.length,
|
|
3304
3953
|
});
|
|
3305
3954
|
}
|