polygram 0.36.0 → 0.37.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/config.example.json +2 -0
- package/lib/codex/runtime-controller.js +23 -1
- package/lib/codex/runtime-profile.js +37 -0
- package/lib/codex/spawn-context.js +36 -0
- package/lib/db.js +141 -9
- package/lib/ipc/handlers.js +9 -1
- package/lib/ops/clean-resume.js +116 -25
- package/lib/ops/clean-shutdown.js +36 -0
- package/lib/ops/shutdown-disposition.js +2 -0
- package/lib/sdk/callbacks.js +1 -0
- package/lib/telegram/rich.js +7 -0
- package/migrations/019-codex-clean-restart-resume.sql +13 -0
- package/package.json +2 -2
- package/polygram.js +78 -21
- package/scripts/ipc-smoke.js +24 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.37.0",
|
|
5
5
|
"description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"telegram",
|
package/config.example.json
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
"replayWindowMs": 180000,
|
|
19
19
|
"_comment_resumeInterruptedCliTurns": "Temporary staged-rollout flag. Default false. When true, a deliberate clean restart may strictly resume an interrupted CLI session and send one tracked literal `continue`; crashes still replay the original message. Enable only after the exact pinned-CLI recovery gate passes for the installed Orchestra version.",
|
|
20
20
|
"resumeInterruptedCliTurns": false,
|
|
21
|
+
"_comment_resumeInterruptedCodexTurns": "Independent staged-rollout flag for exact native Codex clean-restart resume plus one literal `continue`. Default false; crashes and reboot never authorize continuation.",
|
|
22
|
+
"resumeInterruptedCodexTurns": false,
|
|
21
23
|
"_comment_chrome": "Opt-in to Claude Code's Chrome-extension browser-automation integration. Default false. Requires the 'Claude in Chrome' extension installed in the daemon-user's Chrome browser AND a live GUI session (Chrome must be running). See https://code.claude.com/docs/en/chrome. Per-chat override via `config.chats.<id>.chrome`.",
|
|
22
24
|
"chrome": false,
|
|
23
25
|
"_comment_pairedChatDefaults": "When a user sends /pair <CODE> from a private chat that isn't in config.chats yet, polygram auto-creates a chat entry using these defaults (merged over top-level `defaults`). Leave out `cwd` at your peril — without it, auto-onboarded DMs have no working directory and pairing will fail.",
|
|
@@ -186,6 +186,7 @@ function createCodexRuntimeController({
|
|
|
186
186
|
config,
|
|
187
187
|
db,
|
|
188
188
|
processEnv = process.env,
|
|
189
|
+
sessionLauncher = null,
|
|
189
190
|
defaultDaemonSecretRoots = [],
|
|
190
191
|
resolveHostIdentity = resolveCodexHostIdentity,
|
|
191
192
|
resolveBinary = resolvePinnedCodexBinary,
|
|
@@ -235,6 +236,14 @@ function createCodexRuntimeController({
|
|
|
235
236
|
|| !Number.isSafeInteger(catalogMaxAgeMs)
|
|
236
237
|
|| catalogMaxAgeMs <= 0
|
|
237
238
|
|| typeof now !== 'function'
|
|
239
|
+
|| (
|
|
240
|
+
sessionLauncher !== null
|
|
241
|
+
&& (
|
|
242
|
+
typeof sessionLauncher !== 'string'
|
|
243
|
+
|| sessionLauncher.length === 0
|
|
244
|
+
|| /[\u0000-\u001f\u007f]/u.test(sessionLauncher)
|
|
245
|
+
)
|
|
246
|
+
)
|
|
238
247
|
) {
|
|
239
248
|
throw new TypeError('Codex runtime controller dependencies are incomplete');
|
|
240
249
|
}
|
|
@@ -358,6 +367,7 @@ function createCodexRuntimeController({
|
|
|
358
367
|
return {
|
|
359
368
|
codexHome,
|
|
360
369
|
binaryPath: configured.binary ?? processEnv.POLYGRAM_CODEX_BIN,
|
|
370
|
+
sessionLauncher,
|
|
361
371
|
daemonSecretRoots: [
|
|
362
372
|
...new Set([...defaultDaemonSecretRoots, ...configuredSecretRoots]),
|
|
363
373
|
],
|
|
@@ -466,6 +476,7 @@ function createCodexRuntimeController({
|
|
|
466
476
|
request,
|
|
467
477
|
codexHome: configured.codexHome,
|
|
468
478
|
binaryPath: configured.binaryPath,
|
|
479
|
+
sessionLauncher: configured.sessionLauncher,
|
|
469
480
|
daemonSecretRoots: configured.daemonSecretRoots,
|
|
470
481
|
});
|
|
471
482
|
if (preparingByKey.has(key)) return preparingByKey.get(key);
|
|
@@ -483,6 +494,7 @@ function createCodexRuntimeController({
|
|
|
483
494
|
model: request.model,
|
|
484
495
|
effort: request.effort,
|
|
485
496
|
processEnv,
|
|
497
|
+
sessionLauncher: configured.sessionLauncher,
|
|
486
498
|
});
|
|
487
499
|
const homeAttestation = await attestCodexHome(
|
|
488
500
|
expectedStaticProfile.codexHome,
|
|
@@ -666,6 +678,11 @@ function createCodexRuntimeController({
|
|
|
666
678
|
cwd: expectedStaticProfile.cwd,
|
|
667
679
|
env: expectedStaticProfile.env,
|
|
668
680
|
expectedConfigSha256: expectedStaticProfile.ownedConfigSha256,
|
|
681
|
+
...(expectedStaticProfile.sessionLauncher == null ? {} : {
|
|
682
|
+
sessionLauncher: expectedStaticProfile.sessionLauncher,
|
|
683
|
+
expectedSessionLauncherSha256:
|
|
684
|
+
expectedStaticProfile.sessionLauncherSha256,
|
|
685
|
+
}),
|
|
669
686
|
onNotification,
|
|
670
687
|
onFault,
|
|
671
688
|
});
|
|
@@ -1206,7 +1223,10 @@ function createCodexRuntimeController({
|
|
|
1206
1223
|
for (const waiter of record.retirementWaiters.splice(0)) {
|
|
1207
1224
|
waiter.resolve(Object.freeze({
|
|
1208
1225
|
committed: true,
|
|
1209
|
-
disposition:
|
|
1226
|
+
disposition: record.stopCancellation
|
|
1227
|
+
? 'stop-cancelled'
|
|
1228
|
+
: 'retired',
|
|
1229
|
+
...(record.stopCancellation ?? {}),
|
|
1210
1230
|
}));
|
|
1211
1231
|
}
|
|
1212
1232
|
return true;
|
|
@@ -1309,6 +1329,7 @@ function createCodexRuntimeController({
|
|
|
1309
1329
|
attemptId: attempt.attempt_id,
|
|
1310
1330
|
providerSessionId: attempt.thread_id,
|
|
1311
1331
|
providerTurnId: attempt.turn_id,
|
|
1332
|
+
sourceMsgId: attempt.telegram_source_message_id,
|
|
1312
1333
|
});
|
|
1313
1334
|
}
|
|
1314
1335
|
if (maybeRetire(record)) {
|
|
@@ -1317,6 +1338,7 @@ function createCodexRuntimeController({
|
|
|
1317
1338
|
disposition: settlement.disposition === 'stop-cancelled'
|
|
1318
1339
|
? 'stop-cancelled'
|
|
1319
1340
|
: 'retired',
|
|
1341
|
+
...(record.stopCancellation ?? {}),
|
|
1320
1342
|
});
|
|
1321
1343
|
}
|
|
1322
1344
|
if (settlement.disposition !== 'pending-delivery') {
|
|
@@ -18,6 +18,7 @@ const {
|
|
|
18
18
|
buildCodexAppServerEnv,
|
|
19
19
|
CodexAppServerClient,
|
|
20
20
|
codexProtocolSchema,
|
|
21
|
+
characterizePinnedSessionLauncher,
|
|
21
22
|
resolveCodexTargetPin,
|
|
22
23
|
} = require('@shumkov/orchestra');
|
|
23
24
|
|
|
@@ -832,11 +833,14 @@ async function collectPermissionProfiles(client, workspace) {
|
|
|
832
833
|
|
|
833
834
|
async function characterizeRuntime(materialized, {
|
|
834
835
|
binaryReceipt,
|
|
836
|
+
sessionLauncherReceipt,
|
|
835
837
|
clientFactory,
|
|
836
838
|
}) {
|
|
837
839
|
let latchedFault = null;
|
|
838
840
|
const client = clientFactory({
|
|
839
841
|
binary: binaryReceipt.path,
|
|
842
|
+
sessionLauncher: sessionLauncherReceipt?.path ?? null,
|
|
843
|
+
expectedSessionLauncherSha256: sessionLauncherReceipt?.sha256 ?? null,
|
|
840
844
|
cwd: materialized.workspace,
|
|
841
845
|
codexHome: materialized.codexHome,
|
|
842
846
|
env: materialized.env,
|
|
@@ -937,11 +941,13 @@ function createCodexRuntimeProfileBuilder({
|
|
|
937
941
|
clientFactory = (options) => new CodexAppServerClient(options),
|
|
938
942
|
protocolSchema = codexProtocolSchema,
|
|
939
943
|
resolveTargetPin = resolveCodexTargetPin,
|
|
944
|
+
attestSessionLauncher = characterizePinnedSessionLauncher,
|
|
940
945
|
} = {}) {
|
|
941
946
|
if (
|
|
942
947
|
!Array.isArray(temporaryRoots)
|
|
943
948
|
|| typeof clientFactory !== 'function'
|
|
944
949
|
|| typeof resolveTargetPin !== 'function'
|
|
950
|
+
|| !['undefined', 'function'].includes(typeof attestSessionLauncher)
|
|
945
951
|
) {
|
|
946
952
|
throw new TypeError('Codex runtime profile builder options are invalid');
|
|
947
953
|
}
|
|
@@ -950,9 +956,38 @@ function createCodexRuntimeProfileBuilder({
|
|
|
950
956
|
async prepare(options = {}) {
|
|
951
957
|
const targetPin = resolveTargetPin();
|
|
952
958
|
validateBinaryReceipt(options.binaryReceipt, protocolSchema, targetPin);
|
|
959
|
+
if (
|
|
960
|
+
options.sessionLauncher != null
|
|
961
|
+
&& typeof attestSessionLauncher !== 'function'
|
|
962
|
+
) {
|
|
963
|
+
throw fail(
|
|
964
|
+
'Codex session launcher attestation is unavailable.',
|
|
965
|
+
'CODEX_SESSION_LAUNCHER_MISMATCH',
|
|
966
|
+
'Install the Orchestra release that attests the session launcher.',
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
const sessionLauncherReceipt = options.sessionLauncher == null
|
|
970
|
+
? null
|
|
971
|
+
: await attestSessionLauncher(
|
|
972
|
+
requiredString(options.sessionLauncher, 'session launcher'),
|
|
973
|
+
);
|
|
974
|
+
if (
|
|
975
|
+
sessionLauncherReceipt !== null
|
|
976
|
+
&& (
|
|
977
|
+
sessionLauncherReceipt.path !== options.sessionLauncher
|
|
978
|
+
|| !SHA256_RE.test(sessionLauncherReceipt.sha256)
|
|
979
|
+
)
|
|
980
|
+
) {
|
|
981
|
+
throw fail(
|
|
982
|
+
'Codex session launcher attestation was inconsistent.',
|
|
983
|
+
'CODEX_SESSION_LAUNCHER_MISMATCH',
|
|
984
|
+
'Restore the exact root-owned session launcher before enabling Codex.',
|
|
985
|
+
);
|
|
986
|
+
}
|
|
953
987
|
const materialized = provisionRuntimeFiles(options, temporaryRoots);
|
|
954
988
|
const characterized = await characterizeRuntime(materialized, {
|
|
955
989
|
binaryReceipt: options.binaryReceipt,
|
|
990
|
+
sessionLauncherReceipt,
|
|
956
991
|
clientFactory,
|
|
957
992
|
});
|
|
958
993
|
const model = requiredString(options.model, 'model');
|
|
@@ -979,6 +1014,8 @@ function createCodexRuntimeProfileBuilder({
|
|
|
979
1014
|
permissionProfileId: CODEX_PERMISSION_PROFILE_ID,
|
|
980
1015
|
model,
|
|
981
1016
|
effort,
|
|
1017
|
+
sessionLauncher: sessionLauncherReceipt?.path ?? null,
|
|
1018
|
+
sessionLauncherSha256: sessionLauncherReceipt?.sha256 ?? null,
|
|
982
1019
|
});
|
|
983
1020
|
},
|
|
984
1021
|
});
|
|
@@ -6,6 +6,9 @@ const {
|
|
|
6
6
|
const {
|
|
7
7
|
processMatchesRuntime,
|
|
8
8
|
} = require('../runtime-config');
|
|
9
|
+
const {
|
|
10
|
+
validateStrictResumeSpawn,
|
|
11
|
+
} = require('../ops/clean-resume');
|
|
9
12
|
|
|
10
13
|
async function buildCodexSpawnContext({
|
|
11
14
|
sessionKey,
|
|
@@ -18,6 +21,7 @@ async function buildCodexSpawnContext({
|
|
|
18
21
|
getSessionLabel,
|
|
19
22
|
logEvent,
|
|
20
23
|
mutateSessionOnDrift = true,
|
|
24
|
+
strictResume = null,
|
|
21
25
|
}) {
|
|
22
26
|
const prepared = await runtimeController.prepareSession({
|
|
23
27
|
sessionKey,
|
|
@@ -31,6 +35,38 @@ async function buildCodexSpawnContext({
|
|
|
31
35
|
'codex',
|
|
32
36
|
'codex',
|
|
33
37
|
);
|
|
38
|
+
if (strictResume) {
|
|
39
|
+
const stored = db.getProviderSession(sessionKey, 'codex:app-server');
|
|
40
|
+
const existingSessionId = validateStrictResumeSpawn({
|
|
41
|
+
strictResume,
|
|
42
|
+
promptBackend: 'codex',
|
|
43
|
+
liveProcess,
|
|
44
|
+
runtime: stored,
|
|
45
|
+
resolved: {
|
|
46
|
+
runtime: 'codex',
|
|
47
|
+
backend: 'codex',
|
|
48
|
+
cwd: runtime.cwd,
|
|
49
|
+
model: runtime.model,
|
|
50
|
+
effort: runtime.effort,
|
|
51
|
+
spawnProfileId: runtime.spawnProfileId,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
runtime: 'codex',
|
|
56
|
+
spawnProfileId: runtime.spawnProfileId,
|
|
57
|
+
modelSettings: {
|
|
58
|
+
model: runtime.model,
|
|
59
|
+
effort: runtime.effort,
|
|
60
|
+
},
|
|
61
|
+
chatId,
|
|
62
|
+
threadId,
|
|
63
|
+
label: getSessionLabel(chatConfig, threadId),
|
|
64
|
+
existingSessionId,
|
|
65
|
+
resumePolicy: 'require-interrupted-turn',
|
|
66
|
+
expectedInterruptedTurnId: strictResume.expectedInterruptedTurnId,
|
|
67
|
+
noWaitForCapacity: true,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
34
70
|
const replacementReason = (
|
|
35
71
|
liveCodexProcess
|
|
36
72
|
) ? runtimeController.processReplacementReason(
|
package/lib/db.js
CHANGED
|
@@ -42,7 +42,7 @@ const {
|
|
|
42
42
|
//
|
|
43
43
|
// 0.15: bumped 13 → 14. Adds migration 014-secret-redactions.sql
|
|
44
44
|
// (secret_redactions audit table + messages.secret_scanned_at).
|
|
45
|
-
const SCHEMA_VERSION =
|
|
45
|
+
const SCHEMA_VERSION = 19;
|
|
46
46
|
|
|
47
47
|
// Sentinel `error` value for outbound rows whose API call may or may not
|
|
48
48
|
// have reached Telegram. markStalePending writes it; hasOutboundReplyTo
|
|
@@ -248,11 +248,11 @@ function wrap(db) {
|
|
|
248
248
|
INSERT INTO agent_runtime_sessions (
|
|
249
249
|
session_key, namespace, provider, provider_session_id,
|
|
250
250
|
app_server_session_id, agent, cwd, model, effort, pm_backend,
|
|
251
|
-
created_ts, last_active_ts, generation_id
|
|
251
|
+
created_ts, last_active_ts, generation_id, spawn_profile_id
|
|
252
252
|
) VALUES (
|
|
253
253
|
@session_key, @namespace, @provider, @provider_session_id,
|
|
254
254
|
@app_server_session_id, @agent, @cwd, @model, @effort, @pm_backend,
|
|
255
|
-
@ts, @ts, @generation_id
|
|
255
|
+
@ts, @ts, @generation_id, @spawn_profile_id
|
|
256
256
|
)
|
|
257
257
|
ON CONFLICT(session_key, namespace) DO UPDATE SET
|
|
258
258
|
provider = excluded.provider,
|
|
@@ -263,9 +263,21 @@ function wrap(db) {
|
|
|
263
263
|
model = excluded.model,
|
|
264
264
|
effort = excluded.effort,
|
|
265
265
|
pm_backend = excluded.pm_backend,
|
|
266
|
+
spawn_profile_id = excluded.spawn_profile_id,
|
|
266
267
|
last_active_ts = excluded.last_active_ts,
|
|
267
268
|
generation_id = CASE
|
|
268
269
|
WHEN agent_runtime_sessions.provider_session_id = excluded.provider_session_id
|
|
270
|
+
AND agent_runtime_sessions.provider = excluded.provider
|
|
271
|
+
AND (
|
|
272
|
+
excluded.provider = 'claude'
|
|
273
|
+
OR (
|
|
274
|
+
agent_runtime_sessions.cwd IS excluded.cwd
|
|
275
|
+
AND agent_runtime_sessions.model IS excluded.model
|
|
276
|
+
AND agent_runtime_sessions.effort IS excluded.effort
|
|
277
|
+
AND agent_runtime_sessions.pm_backend IS excluded.pm_backend
|
|
278
|
+
AND agent_runtime_sessions.spawn_profile_id IS excluded.spawn_profile_id
|
|
279
|
+
)
|
|
280
|
+
)
|
|
269
281
|
AND agent_runtime_sessions.generation_id IS NOT NULL
|
|
270
282
|
AND agent_runtime_sessions.generation_id != ''
|
|
271
283
|
THEN agent_runtime_sessions.generation_id
|
|
@@ -321,6 +333,7 @@ function wrap(db) {
|
|
|
321
333
|
model: row.model || null,
|
|
322
334
|
effort: row.effort || null,
|
|
323
335
|
pm_backend: row.pm_backend || null,
|
|
336
|
+
spawn_profile_id: row.spawn_profile_id || null,
|
|
324
337
|
generation_id: crypto.randomUUID(),
|
|
325
338
|
ts: row.ts || Date.now(),
|
|
326
339
|
};
|
|
@@ -551,8 +564,10 @@ function wrap(db) {
|
|
|
551
564
|
const insertCleanRestartIntentStmt = db.prepare(`
|
|
552
565
|
INSERT INTO clean_restart_resume_intents (
|
|
553
566
|
bot_name, session_key, session_generation_id,
|
|
554
|
-
source_message_id, shutdown_at, policy_version
|
|
555
|
-
|
|
567
|
+
source_message_id, shutdown_at, policy_version,
|
|
568
|
+
interrupted_provider_turn_id, interrupted_spawn_profile_id,
|
|
569
|
+
continuation_authorized
|
|
570
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)
|
|
556
571
|
`);
|
|
557
572
|
|
|
558
573
|
function normalizeResumeIntent(intent) {
|
|
@@ -565,7 +580,54 @@ function wrap(db) {
|
|
|
565
580
|
if (!Number.isSafeInteger(intent.policyVersion) || intent.policyVersion <= 0) {
|
|
566
581
|
throw new TypeError('clean restart intent policy version is invalid');
|
|
567
582
|
}
|
|
568
|
-
|
|
583
|
+
const interruptedProviderTurnId =
|
|
584
|
+
intent.interruptedProviderTurnId ?? null;
|
|
585
|
+
const interruptedSpawnProfileId =
|
|
586
|
+
intent.interruptedSpawnProfileId ?? null;
|
|
587
|
+
if (
|
|
588
|
+
(interruptedProviderTurnId === null)
|
|
589
|
+
!== (interruptedSpawnProfileId === null)
|
|
590
|
+
|| (
|
|
591
|
+
interruptedProviderTurnId !== null
|
|
592
|
+
&& (
|
|
593
|
+
typeof interruptedProviderTurnId !== 'string'
|
|
594
|
+
|| interruptedProviderTurnId.length === 0
|
|
595
|
+
|| typeof interruptedSpawnProfileId !== 'string'
|
|
596
|
+
|| interruptedSpawnProfileId.length === 0
|
|
597
|
+
)
|
|
598
|
+
)
|
|
599
|
+
) {
|
|
600
|
+
throw new TypeError(
|
|
601
|
+
'clean restart Codex turn and spawn-profile IDs must be paired',
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
if (interruptedProviderTurnId !== null && intent.policyVersion !== 2) {
|
|
605
|
+
throw new TypeError('clean restart Codex intents require policy version 2');
|
|
606
|
+
}
|
|
607
|
+
const expectedCodexIdentity = interruptedProviderTurnId === null
|
|
608
|
+
? null
|
|
609
|
+
: {
|
|
610
|
+
providerSessionId: intent.expectedProviderSessionId,
|
|
611
|
+
cwd: intent.expectedCwd,
|
|
612
|
+
model: intent.expectedModel,
|
|
613
|
+
effort: intent.expectedEffort,
|
|
614
|
+
};
|
|
615
|
+
if (
|
|
616
|
+
expectedCodexIdentity !== null
|
|
617
|
+
&& Object.values(expectedCodexIdentity).some(
|
|
618
|
+
(value) => typeof value !== 'string' || value.length === 0,
|
|
619
|
+
)
|
|
620
|
+
) {
|
|
621
|
+
throw new TypeError(
|
|
622
|
+
'clean restart Codex intent requires exact retired runtime identity',
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
return {
|
|
626
|
+
...intent,
|
|
627
|
+
interruptedProviderTurnId,
|
|
628
|
+
interruptedSpawnProfileId,
|
|
629
|
+
expectedCodexIdentity,
|
|
630
|
+
};
|
|
569
631
|
}
|
|
570
632
|
|
|
571
633
|
function recordShutdown({
|
|
@@ -574,7 +636,11 @@ function wrap(db) {
|
|
|
574
636
|
since,
|
|
575
637
|
clean,
|
|
576
638
|
resumeIntents = [],
|
|
639
|
+
continuationAuthorized = false,
|
|
577
640
|
}) {
|
|
641
|
+
if (resumeIntents.length > 0 && continuationAuthorized !== true) {
|
|
642
|
+
throw new TypeError('clean restart intents require deploy authorization');
|
|
643
|
+
}
|
|
578
644
|
const cutoff = since ?? now - 30 * 60 * 1000;
|
|
579
645
|
const txn = db.transaction(() => {
|
|
580
646
|
const marked = markReplayPendingStmt.run(botName, cutoff);
|
|
@@ -597,7 +663,9 @@ function wrap(db) {
|
|
|
597
663
|
}
|
|
598
664
|
const session = getProviderSessionStmt.get(
|
|
599
665
|
intent.sessionKey,
|
|
600
|
-
|
|
666
|
+
intent.interruptedProviderTurnId === null
|
|
667
|
+
? CLAUDE_CHANNELS_NAMESPACE
|
|
668
|
+
: CODEX_APP_SERVER_NAMESPACE,
|
|
601
669
|
);
|
|
602
670
|
if (
|
|
603
671
|
!session
|
|
@@ -608,6 +676,28 @@ function wrap(db) {
|
|
|
608
676
|
`clean restart intent session generation is unavailable for ${intent.sessionKey}`,
|
|
609
677
|
);
|
|
610
678
|
}
|
|
679
|
+
if (
|
|
680
|
+
intent.interruptedSpawnProfileId !== null
|
|
681
|
+
&& session.spawn_profile_id !== intent.interruptedSpawnProfileId
|
|
682
|
+
) {
|
|
683
|
+
throw new Error(
|
|
684
|
+
`clean restart intent spawn profile changed for ${intent.sessionKey}`,
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
if (
|
|
688
|
+
intent.expectedCodexIdentity !== null
|
|
689
|
+
&& (
|
|
690
|
+
session.provider_session_id
|
|
691
|
+
!== intent.expectedCodexIdentity.providerSessionId
|
|
692
|
+
|| session.cwd !== intent.expectedCodexIdentity.cwd
|
|
693
|
+
|| session.model !== intent.expectedCodexIdentity.model
|
|
694
|
+
|| session.effort !== intent.expectedCodexIdentity.effort
|
|
695
|
+
)
|
|
696
|
+
) {
|
|
697
|
+
throw new Error(
|
|
698
|
+
`clean restart intent provider session changed for ${intent.sessionKey}`,
|
|
699
|
+
);
|
|
700
|
+
}
|
|
611
701
|
insertCleanRestartIntentStmt.run(
|
|
612
702
|
botName,
|
|
613
703
|
intent.sessionKey,
|
|
@@ -615,6 +705,8 @@ function wrap(db) {
|
|
|
615
705
|
intent.sourceMessageId,
|
|
616
706
|
now,
|
|
617
707
|
intent.policyVersion,
|
|
708
|
+
intent.interruptedProviderTurnId,
|
|
709
|
+
intent.interruptedSpawnProfileId,
|
|
618
710
|
);
|
|
619
711
|
}
|
|
620
712
|
db.prepare(`
|
|
@@ -4199,6 +4291,7 @@ function wrap(db) {
|
|
|
4199
4291
|
now = Date.now(),
|
|
4200
4292
|
since,
|
|
4201
4293
|
resumeIntents = [],
|
|
4294
|
+
continuationAuthorized = false,
|
|
4202
4295
|
} = {}) {
|
|
4203
4296
|
return recordShutdown({
|
|
4204
4297
|
botName,
|
|
@@ -4206,6 +4299,7 @@ function wrap(db) {
|
|
|
4206
4299
|
since,
|
|
4207
4300
|
clean: true,
|
|
4208
4301
|
resumeIntents,
|
|
4302
|
+
continuationAuthorized,
|
|
4209
4303
|
});
|
|
4210
4304
|
},
|
|
4211
4305
|
|
|
@@ -4264,13 +4358,18 @@ function wrap(db) {
|
|
|
4264
4358
|
runtime.generation_id AS current_generation_id,
|
|
4265
4359
|
runtime.provider_session_id,
|
|
4266
4360
|
runtime.namespace AS provider_namespace,
|
|
4267
|
-
runtime.pm_backend
|
|
4361
|
+
runtime.pm_backend,
|
|
4362
|
+
runtime.spawn_profile_id AS current_spawn_profile_id
|
|
4268
4363
|
FROM clean_restart_resume_intents AS intent
|
|
4269
4364
|
LEFT JOIN messages AS source
|
|
4270
4365
|
ON source.id = intent.source_message_id
|
|
4271
4366
|
LEFT JOIN agent_runtime_sessions AS runtime
|
|
4272
4367
|
ON runtime.session_key = intent.session_key
|
|
4273
|
-
AND runtime.namespace =
|
|
4368
|
+
AND runtime.namespace = CASE
|
|
4369
|
+
WHEN intent.interrupted_provider_turn_id IS NULL
|
|
4370
|
+
THEN '${CLAUDE_CHANNELS_NAMESPACE}'
|
|
4371
|
+
ELSE '${CODEX_APP_SERVER_NAMESPACE}'
|
|
4372
|
+
END
|
|
4274
4373
|
WHERE intent.bot_name = ?
|
|
4275
4374
|
AND intent.shutdown_at = ?
|
|
4276
4375
|
ORDER BY intent.session_key
|
|
@@ -4295,8 +4394,28 @@ function wrap(db) {
|
|
|
4295
4394
|
}
|
|
4296
4395
|
|
|
4297
4396
|
let reason = null;
|
|
4397
|
+
const hasInterruptedTurn =
|
|
4398
|
+
typeof row.interrupted_provider_turn_id === 'string'
|
|
4399
|
+
&& row.interrupted_provider_turn_id.length > 0;
|
|
4400
|
+
const hasInterruptedProfile =
|
|
4401
|
+
typeof row.interrupted_spawn_profile_id === 'string'
|
|
4402
|
+
&& row.interrupted_spawn_profile_id.length > 0;
|
|
4403
|
+
const codexShaped = row.interrupted_provider_turn_id != null
|
|
4404
|
+
|| row.interrupted_spawn_profile_id != null
|
|
4405
|
+
|| row.provider_namespace === CODEX_APP_SERVER_NAMESPACE;
|
|
4298
4406
|
if (!sourceMatches) {
|
|
4299
4407
|
reason = 'source-message-missing';
|
|
4408
|
+
} else if (row.continuation_authorized !== 1) {
|
|
4409
|
+
reason = 'unauthorized-restart';
|
|
4410
|
+
} else if (
|
|
4411
|
+
codexShaped
|
|
4412
|
+
&& (
|
|
4413
|
+
row.policy_version !== 2
|
|
4414
|
+
|| !hasInterruptedTurn
|
|
4415
|
+
|| !hasInterruptedProfile
|
|
4416
|
+
)
|
|
4417
|
+
) {
|
|
4418
|
+
reason = 'unsupported-codex-policy';
|
|
4300
4419
|
} else if (!supported.has(row.policy_version)) {
|
|
4301
4420
|
reason = 'unsupported-policy-version';
|
|
4302
4421
|
} else if (
|
|
@@ -4305,6 +4424,12 @@ function wrap(db) {
|
|
|
4305
4424
|
|| row.current_generation_id !== row.session_generation_id
|
|
4306
4425
|
) {
|
|
4307
4426
|
reason = 'session-generation-replaced';
|
|
4427
|
+
} else if (
|
|
4428
|
+
row.interrupted_spawn_profile_id != null
|
|
4429
|
+
&& row.current_spawn_profile_id
|
|
4430
|
+
!== row.interrupted_spawn_profile_id
|
|
4431
|
+
) {
|
|
4432
|
+
reason = 'spawn-profile-replaced';
|
|
4308
4433
|
}
|
|
4309
4434
|
|
|
4310
4435
|
return {
|
|
@@ -4317,6 +4442,13 @@ function wrap(db) {
|
|
|
4317
4442
|
provider_namespace: reason == null ? row.provider_namespace : null,
|
|
4318
4443
|
provider_session_id: reason == null ? row.provider_session_id : null,
|
|
4319
4444
|
pm_backend: reason == null ? row.pm_backend : null,
|
|
4445
|
+
interrupted_provider_turn_id:
|
|
4446
|
+
row.interrupted_provider_turn_id,
|
|
4447
|
+
interrupted_spawn_profile_id:
|
|
4448
|
+
row.interrupted_spawn_profile_id,
|
|
4449
|
+
current_spawn_profile_id: reason == null
|
|
4450
|
+
? row.current_spawn_profile_id
|
|
4451
|
+
: null,
|
|
4320
4452
|
executable: reason == null,
|
|
4321
4453
|
reason,
|
|
4322
4454
|
};
|
package/lib/ipc/handlers.js
CHANGED
|
@@ -16,8 +16,14 @@ const { buildBusySnapshot } = require('../queue-utils');
|
|
|
16
16
|
* captured: the dispatcher's map is assigned during boot and replaced on
|
|
17
17
|
* reload, so a value bound at wiring time can go stale.
|
|
18
18
|
* @param {(req) => any} opts.handleSendOverIpc
|
|
19
|
+
* @param {(req) => any} opts.requestDeployRestart
|
|
19
20
|
*/
|
|
20
|
-
function createIpcHandlers({
|
|
21
|
+
function createIpcHandlers({
|
|
22
|
+
botName,
|
|
23
|
+
getInFlightHandlers,
|
|
24
|
+
handleSendOverIpc,
|
|
25
|
+
requestDeployRestart,
|
|
26
|
+
} = {}) {
|
|
21
27
|
return {
|
|
22
28
|
ping: async () => ({ pong: true, bot: botName }),
|
|
23
29
|
|
|
@@ -28,6 +34,8 @@ function createIpcHandlers({ botName, getInFlightHandlers, handleSendOverIpc } =
|
|
|
28
34
|
botName,
|
|
29
35
|
}),
|
|
30
36
|
|
|
37
|
+
deploy_restart: (req) => requestDeployRestart(req),
|
|
38
|
+
|
|
31
39
|
send: (req) => handleSendOverIpc(req),
|
|
32
40
|
};
|
|
33
41
|
}
|
package/lib/ops/clean-resume.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
const
|
|
3
|
+
const CLAUDE_NAMESPACE = 'claude:channels';
|
|
4
|
+
const CODEX_NAMESPACE = 'codex:app-server';
|
|
5
|
+
const CLEAN_RESUME_POLICY_VERSION = 2;
|
|
6
|
+
const SUPPORTED_CLEAN_RESUME_POLICY_VERSIONS = Object.freeze([1, 2]);
|
|
5
7
|
|
|
6
8
|
function recoveryNoticeText({ ambiguous = false } = {}) {
|
|
7
9
|
if (ambiguous) {
|
|
@@ -10,11 +12,23 @@ function recoveryNoticeText({ ambiguous = false } = {}) {
|
|
|
10
12
|
return '↺ Restarted — I couldn\'t safely resume this message. If it still needs a reply, send it again.';
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
function
|
|
14
|
-
return
|
|
15
|
+
function isCodexClaim(claim) {
|
|
16
|
+
return claim?.interrupted_provider_turn_id != null
|
|
17
|
+
|| claim?.interrupted_spawn_profile_id != null
|
|
18
|
+
|| claim?.provider_namespace === CODEX_NAMESPACE;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function attestsExactResume(attestation, expectedSessionId, claim = null) {
|
|
22
|
+
const codex = isCodexClaim(claim);
|
|
23
|
+
return attestation?.namespace === (codex ? CODEX_NAMESPACE : CLAUDE_NAMESPACE)
|
|
15
24
|
&& attestation.sessionId === expectedSessionId
|
|
16
25
|
&& attestation.resumed === true
|
|
17
|
-
&& attestation.freshFallback === false
|
|
26
|
+
&& attestation.freshFallback === false
|
|
27
|
+
&& (!codex || (
|
|
28
|
+
attestation.interruptedTurnId
|
|
29
|
+
=== claim.interrupted_provider_turn_id
|
|
30
|
+
&& attestation.idle === true
|
|
31
|
+
));
|
|
18
32
|
}
|
|
19
33
|
|
|
20
34
|
function validateStrictResumeSpawn({
|
|
@@ -24,17 +38,45 @@ function validateStrictResumeSpawn({
|
|
|
24
38
|
runtime,
|
|
25
39
|
resolved,
|
|
26
40
|
} = {}) {
|
|
27
|
-
if (promptBackend === 'codex') {
|
|
28
|
-
const error = new Error('strict Claude resume cannot cross a backend change');
|
|
29
|
-
error.code = 'CLEAN_RESUME_CONFIG_DRIFT';
|
|
30
|
-
throw error;
|
|
31
|
-
}
|
|
32
41
|
if (liveProcess && !liveProcess.closed) {
|
|
33
42
|
const error = new Error('strict clean resume requires an unspawned session');
|
|
34
43
|
error.code = 'CLEAN_RESUME_SESSION_ALREADY_SPAWNED';
|
|
35
44
|
throw error;
|
|
36
45
|
}
|
|
37
46
|
|
|
47
|
+
if (promptBackend === 'codex') {
|
|
48
|
+
const providerDrift = runtime?.namespace !== CODEX_NAMESPACE
|
|
49
|
+
|| resolved?.runtime !== 'codex'
|
|
50
|
+
|| resolved?.backend !== 'codex';
|
|
51
|
+
const driftFields = [
|
|
52
|
+
['cwd', 'cwd'],
|
|
53
|
+
['model', 'model'],
|
|
54
|
+
['effort', 'effort'],
|
|
55
|
+
['spawn_profile_id', 'spawnProfileId'],
|
|
56
|
+
].filter(([stored, current]) => (
|
|
57
|
+
(runtime?.[stored] || null) !== (resolved?.[current] || null)
|
|
58
|
+
));
|
|
59
|
+
if (
|
|
60
|
+
!runtime
|
|
61
|
+
|| runtime.namespace !== CODEX_NAMESPACE
|
|
62
|
+
|| runtime.generation_id !== strictResume?.expectedGenerationId
|
|
63
|
+
|| runtime.provider_session_id !== strictResume?.expectedSessionId
|
|
64
|
+
|| runtime.spawn_profile_id !== strictResume?.expectedSpawnProfileId
|
|
65
|
+
|| resolved?.runtime !== 'codex'
|
|
66
|
+
|| resolved?.backend !== 'codex'
|
|
67
|
+
|| driftFields.length > 0
|
|
68
|
+
) {
|
|
69
|
+
const error = new Error(
|
|
70
|
+
'strict Codex resume session identity or config changed',
|
|
71
|
+
);
|
|
72
|
+
error.code = providerDrift || driftFields.length > 0
|
|
73
|
+
? 'CLEAN_RESUME_CONFIG_DRIFT'
|
|
74
|
+
: 'CLEAN_RESUME_SESSION_CHANGED';
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
return strictResume.expectedSessionId;
|
|
78
|
+
}
|
|
79
|
+
|
|
38
80
|
const driftFields = ['agent', 'cwd'].filter(
|
|
39
81
|
(field) => (runtime?.[field] || null) !== (resolved?.[field] || null),
|
|
40
82
|
);
|
|
@@ -86,6 +128,7 @@ async function executeCleanResumeClaim(claim, {
|
|
|
86
128
|
discardSpawn,
|
|
87
129
|
sendContinue,
|
|
88
130
|
deliverResult,
|
|
131
|
+
settleProviderDelivery,
|
|
89
132
|
sendNotice,
|
|
90
133
|
complete,
|
|
91
134
|
logEvent = () => {},
|
|
@@ -103,6 +146,7 @@ async function executeCleanResumeClaim(claim, {
|
|
|
103
146
|
source_message_id: claim?.source_message_id ?? null,
|
|
104
147
|
policy_version: claim?.policy_version ?? null,
|
|
105
148
|
};
|
|
149
|
+
const codex = isCodexClaim(claim);
|
|
106
150
|
const source = typeof loadSource === 'function'
|
|
107
151
|
? loadSource(claim.source_message_id)
|
|
108
152
|
: null;
|
|
@@ -150,6 +194,20 @@ async function executeCleanResumeClaim(claim, {
|
|
|
150
194
|
if (claim?.executable !== true) {
|
|
151
195
|
return fallback(claim?.reason || 'claim-not-executable');
|
|
152
196
|
}
|
|
197
|
+
const hasInterruptedTurn = typeof claim?.interrupted_provider_turn_id === 'string'
|
|
198
|
+
&& claim.interrupted_provider_turn_id.length > 0;
|
|
199
|
+
const hasInterruptedProfile = typeof claim?.interrupted_spawn_profile_id === 'string'
|
|
200
|
+
&& claim.interrupted_spawn_profile_id.length > 0;
|
|
201
|
+
if (
|
|
202
|
+
codex
|
|
203
|
+
&& (
|
|
204
|
+
claim?.policy_version !== 2
|
|
205
|
+
|| !hasInterruptedTurn
|
|
206
|
+
|| !hasInterruptedProfile
|
|
207
|
+
)
|
|
208
|
+
) {
|
|
209
|
+
return rejected('unsupported-codex-policy');
|
|
210
|
+
}
|
|
153
211
|
|
|
154
212
|
if (
|
|
155
213
|
!source
|
|
@@ -161,14 +219,22 @@ async function executeCleanResumeClaim(claim, {
|
|
|
161
219
|
return fallback('source-message-mismatch');
|
|
162
220
|
}
|
|
163
221
|
|
|
164
|
-
const runtime = loadRuntimeSession(claim.session_key);
|
|
222
|
+
const runtime = loadRuntimeSession(claim.session_key, claim);
|
|
223
|
+
const expectedNamespace = codex ? CODEX_NAMESPACE : CLAUDE_NAMESPACE;
|
|
165
224
|
if (
|
|
166
225
|
!runtime
|
|
167
|
-
|| runtime.namespace !==
|
|
226
|
+
|| runtime.namespace !== expectedNamespace
|
|
168
227
|
|| runtime.generation_id !== claim.session_generation_id
|
|
169
228
|
|| typeof runtime.provider_session_id !== 'string'
|
|
170
229
|
|| runtime.provider_session_id.length === 0
|
|
171
|
-
||
|
|
230
|
+
|| (codex
|
|
231
|
+
? runtime.pm_backend !== 'codex'
|
|
232
|
+
: !['cli', 'channels'].includes(runtime.pm_backend))
|
|
233
|
+
|| (codex && (
|
|
234
|
+
runtime.spawn_profile_id !== claim.interrupted_spawn_profile_id
|
|
235
|
+
|| typeof claim.interrupted_provider_turn_id !== 'string'
|
|
236
|
+
|| claim.interrupted_provider_turn_id.length === 0
|
|
237
|
+
))
|
|
172
238
|
) {
|
|
173
239
|
return rejected(
|
|
174
240
|
runtime?.generation_id !== claim.session_generation_id
|
|
@@ -202,7 +268,11 @@ async function executeCleanResumeClaim(claim, {
|
|
|
202
268
|
}
|
|
203
269
|
if (
|
|
204
270
|
!spawned?.process
|
|
205
|
-
|| !attestsExactResume(
|
|
271
|
+
|| !attestsExactResume(
|
|
272
|
+
spawned.attestation,
|
|
273
|
+
runtime.provider_session_id,
|
|
274
|
+
claim,
|
|
275
|
+
)
|
|
206
276
|
) {
|
|
207
277
|
if (spawned?.process) {
|
|
208
278
|
if (typeof discardSpawn !== 'function') {
|
|
@@ -223,7 +293,7 @@ async function executeCleanResumeClaim(claim, {
|
|
|
223
293
|
result = await sendContinue({
|
|
224
294
|
sessionKey: claim.session_key,
|
|
225
295
|
text: 'continue',
|
|
226
|
-
sourceMsgId: source.msg_id,
|
|
296
|
+
sourceMsgId: codex ? null : source.msg_id,
|
|
227
297
|
expectedProcess: spawned.process,
|
|
228
298
|
onDispatched: markReady,
|
|
229
299
|
});
|
|
@@ -237,40 +307,60 @@ async function executeCleanResumeClaim(claim, {
|
|
|
237
307
|
);
|
|
238
308
|
}
|
|
239
309
|
|
|
310
|
+
const settleCodexDelivery = async (disposition) => {
|
|
311
|
+
if (!codex) return;
|
|
312
|
+
if (typeof settleProviderDelivery !== 'function') {
|
|
313
|
+
throw new TypeError('Codex clean resume requires delivery settlement');
|
|
314
|
+
}
|
|
315
|
+
await settleProviderDelivery({ claim, result, disposition });
|
|
316
|
+
};
|
|
317
|
+
|
|
240
318
|
if (result?.deliveryAmbiguous === true) {
|
|
319
|
+
await settleCodexDelivery('failed');
|
|
241
320
|
return failed('continuation-delivery-ambiguous', { ambiguous: true });
|
|
242
321
|
}
|
|
243
322
|
if (result?.error) {
|
|
323
|
+
await settleCodexDelivery('failed');
|
|
244
324
|
return failed('continuation-failed');
|
|
245
325
|
}
|
|
326
|
+
if (codex && result?.alreadyDelivered) {
|
|
327
|
+
await settleCodexDelivery('failed');
|
|
328
|
+
return failed('continuation-delivery-incompatible');
|
|
329
|
+
}
|
|
246
330
|
if (!result?.alreadyDelivered) {
|
|
247
331
|
if (typeof result?.text !== 'string' || result.text.trim().length === 0) {
|
|
332
|
+
await settleCodexDelivery('failed');
|
|
248
333
|
return failed('continuation-empty');
|
|
249
334
|
}
|
|
335
|
+
let delivered;
|
|
250
336
|
try {
|
|
251
|
-
|
|
337
|
+
delivered = await deliverResult({
|
|
252
338
|
claim,
|
|
253
339
|
source,
|
|
254
340
|
text: result.text,
|
|
255
341
|
result,
|
|
256
342
|
});
|
|
257
|
-
if (delivered?.ok !== true) {
|
|
258
|
-
return failed(
|
|
259
|
-
delivered?.ambiguous
|
|
260
|
-
? 'continuation-delivery-ambiguous'
|
|
261
|
-
: 'continuation-delivery-failed',
|
|
262
|
-
{ ambiguous: delivered?.ambiguous === true },
|
|
263
|
-
);
|
|
264
|
-
}
|
|
265
343
|
} catch (error) {
|
|
344
|
+
await settleCodexDelivery('failed');
|
|
266
345
|
const ambiguous = error?.deliveryAmbiguous === true;
|
|
267
346
|
return failed(
|
|
268
347
|
ambiguous ? 'continuation-delivery-ambiguous' : 'continuation-delivery-failed',
|
|
269
348
|
{ ambiguous },
|
|
270
349
|
);
|
|
271
350
|
}
|
|
351
|
+
if (delivered?.ok !== true) {
|
|
352
|
+
await settleCodexDelivery('failed');
|
|
353
|
+
return failed(
|
|
354
|
+
delivered?.ambiguous
|
|
355
|
+
? 'continuation-delivery-ambiguous'
|
|
356
|
+
: 'continuation-delivery-failed',
|
|
357
|
+
{ ambiguous: delivered?.ambiguous === true },
|
|
358
|
+
);
|
|
359
|
+
}
|
|
272
360
|
}
|
|
273
361
|
|
|
362
|
+
await settleCodexDelivery('delivered');
|
|
363
|
+
|
|
274
364
|
await complete({
|
|
275
365
|
sourceMessageId: claim.source_message_id,
|
|
276
366
|
status: 'replied',
|
|
@@ -284,7 +374,7 @@ async function startCleanRestartRecovery({
|
|
|
284
374
|
botName,
|
|
285
375
|
maxAgeMs,
|
|
286
376
|
olderThanMs,
|
|
287
|
-
supportedPolicyVersions =
|
|
377
|
+
supportedPolicyVersions = SUPPORTED_CLEAN_RESUME_POLICY_VERSIONS,
|
|
288
378
|
sessionKeyForSource,
|
|
289
379
|
executeClaim,
|
|
290
380
|
trackTask = (task) => task,
|
|
@@ -349,6 +439,7 @@ async function startCleanRestartRecovery({
|
|
|
349
439
|
|
|
350
440
|
module.exports = {
|
|
351
441
|
CLEAN_RESUME_POLICY_VERSION,
|
|
442
|
+
SUPPORTED_CLEAN_RESUME_POLICY_VERSIONS,
|
|
352
443
|
executeCleanResumeClaim,
|
|
353
444
|
recoveryNoticeText,
|
|
354
445
|
attestsExactResume,
|
|
@@ -49,6 +49,34 @@ function buildResumeIntents({
|
|
|
49
49
|
reason: snapshot?.reason ?? null,
|
|
50
50
|
};
|
|
51
51
|
if (!result.eligible) return result;
|
|
52
|
+
const codex = result.runtime === 'codex'
|
|
53
|
+
|| result.namespace === 'codex:app-server';
|
|
54
|
+
if (
|
|
55
|
+
codex
|
|
56
|
+
&& (
|
|
57
|
+
result.runtime !== 'codex'
|
|
58
|
+
|| result.namespace !== 'codex:app-server'
|
|
59
|
+
|| typeof result.providerSessionId !== 'string'
|
|
60
|
+
|| result.providerSessionId.length === 0
|
|
61
|
+
|| typeof result.providerTurnId !== 'string'
|
|
62
|
+
|| result.providerTurnId.length === 0
|
|
63
|
+
|| typeof result.cwd !== 'string'
|
|
64
|
+
|| result.cwd.length === 0
|
|
65
|
+
|| typeof result.model !== 'string'
|
|
66
|
+
|| result.model.length === 0
|
|
67
|
+
|| typeof result.effort !== 'string'
|
|
68
|
+
|| result.effort.length === 0
|
|
69
|
+
|| typeof result.spawnProfileId !== 'string'
|
|
70
|
+
|| result.spawnProfileId.length === 0
|
|
71
|
+
|| policyVersion !== 2
|
|
72
|
+
)
|
|
73
|
+
) {
|
|
74
|
+
return {
|
|
75
|
+
...result,
|
|
76
|
+
eligible: false,
|
|
77
|
+
reason: 'codex-retirement-binding-missing',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
52
80
|
if (seenSessions.has(result.sessionKey)) {
|
|
53
81
|
throw new Error(`duplicate retirement snapshot for ${result.sessionKey}`);
|
|
54
82
|
}
|
|
@@ -69,6 +97,14 @@ function buildResumeIntents({
|
|
|
69
97
|
sessionKey: result.sessionKey,
|
|
70
98
|
sourceMessageId,
|
|
71
99
|
policyVersion,
|
|
100
|
+
...(codex ? {
|
|
101
|
+
interruptedProviderTurnId: result.providerTurnId,
|
|
102
|
+
interruptedSpawnProfileId: result.spawnProfileId,
|
|
103
|
+
expectedProviderSessionId: result.providerSessionId,
|
|
104
|
+
expectedCwd: result.cwd,
|
|
105
|
+
expectedModel: result.model,
|
|
106
|
+
expectedEffort: result.effort,
|
|
107
|
+
} : {}),
|
|
72
108
|
});
|
|
73
109
|
return {
|
|
74
110
|
...result,
|
|
@@ -21,6 +21,7 @@ function persistShutdownDisposition({
|
|
|
21
21
|
since,
|
|
22
22
|
observation,
|
|
23
23
|
resumeIntents = [],
|
|
24
|
+
continuationAuthorized = false,
|
|
24
25
|
forceCrashReason = null,
|
|
25
26
|
} = {}) {
|
|
26
27
|
const oomDetected = observation?.detected === true;
|
|
@@ -32,6 +33,7 @@ function persistShutdownDisposition({
|
|
|
32
33
|
now,
|
|
33
34
|
since,
|
|
34
35
|
resumeIntents,
|
|
36
|
+
continuationAuthorized,
|
|
35
37
|
});
|
|
36
38
|
return {
|
|
37
39
|
clean: !crashLike,
|
package/lib/sdk/callbacks.js
CHANGED
package/lib/telegram/rich.js
CHANGED
|
@@ -52,6 +52,12 @@ function resolveRichTextEnabled(config, chatId, threadId = null) {
|
|
|
52
52
|
// crafted string. See tests/rich.test.js for the ReDoS regression guard.
|
|
53
53
|
|
|
54
54
|
const TASK_ITEM_RE = /^[ \t]{0,3}[-*+][ \t]+\[[ xX]\][ \t]+/m;
|
|
55
|
+
// Any markdown list is structure: plain stays for a paragraph or two of
|
|
56
|
+
// prose, and a reply organized as bullets or numbered items renders as a
|
|
57
|
+
// rich document. Line-anchored, marker + space + content — a dash inside a
|
|
58
|
+
// sentence or a lone '-' line (divider territory) does not match. Matches
|
|
59
|
+
// marked's own list tokenization, so the gate and the renderer agree.
|
|
60
|
+
const LIST_ITEM_RE = /^[ \t]{0,3}(?:[-*+]|\d{1,9}[.)])[ \t]+\S/m;
|
|
55
61
|
const TABLE_ROW_RE = /^[ \t]{0,3}\|?[ \t]*:?-{2,}:?[ \t]*\|/m;
|
|
56
62
|
const DETAILS_RE = /<details[ \t>]/i;
|
|
57
63
|
const HEADING_RE = /^#{1,6}[ \t]+\S/m;
|
|
@@ -109,6 +115,7 @@ function stripFencedCodeBlocks(markdown) {
|
|
|
109
115
|
// than rendering prose-only rich blocks (the oversized-prose problem).
|
|
110
116
|
function hasNonMediaTrigger(scoped) {
|
|
111
117
|
return TASK_ITEM_RE.test(scoped)
|
|
118
|
+
|| LIST_ITEM_RE.test(scoped)
|
|
112
119
|
|| TABLE_ROW_RE.test(scoped)
|
|
113
120
|
|| DETAILS_RE.test(scoped)
|
|
114
121
|
|| HEADING_RE.test(scoped)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
ALTER TABLE agent_runtime_sessions ADD COLUMN spawn_profile_id TEXT;
|
|
2
|
+
|
|
3
|
+
ALTER TABLE clean_restart_resume_intents
|
|
4
|
+
ADD COLUMN interrupted_provider_turn_id TEXT;
|
|
5
|
+
|
|
6
|
+
ALTER TABLE clean_restart_resume_intents
|
|
7
|
+
ADD COLUMN interrupted_spawn_profile_id TEXT;
|
|
8
|
+
|
|
9
|
+
-- Rows written by a pre-v19 daemon during the one-time upgrade restart use
|
|
10
|
+
-- the default and therefore cannot authorize provider continuation.
|
|
11
|
+
ALTER TABLE clean_restart_resume_intents
|
|
12
|
+
ADD COLUMN continuation_authorized INTEGER NOT NULL DEFAULT 0
|
|
13
|
+
CHECK (continuation_authorized IN (0, 1));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.0",
|
|
4
4
|
"description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
|
|
5
5
|
"main": "lib/ipc/client.js",
|
|
6
6
|
"bin": {
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"dependencies": {
|
|
64
64
|
"@anthropic-ai/claude-agent-sdk": "0.2.123",
|
|
65
65
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
66
|
-
"@shumkov/orchestra": "0.10.
|
|
66
|
+
"@shumkov/orchestra": "0.10.13",
|
|
67
67
|
"better-sqlite3": "^12.9.0",
|
|
68
68
|
"grammy": "^1.42.0",
|
|
69
69
|
"markdown-it": "^14.1.1",
|
package/polygram.js
CHANGED
|
@@ -191,6 +191,7 @@ const {
|
|
|
191
191
|
} = require('./lib/ops/clean-shutdown');
|
|
192
192
|
const {
|
|
193
193
|
CLEAN_RESUME_POLICY_VERSION,
|
|
194
|
+
SUPPORTED_CLEAN_RESUME_POLICY_VERSIONS,
|
|
194
195
|
createCleanResumeTurnContext,
|
|
195
196
|
executeCleanResumeClaim,
|
|
196
197
|
startCleanRestartRecovery,
|
|
@@ -800,12 +801,6 @@ async function buildSpawnContext(
|
|
|
800
801
|
threadId: threadId || null,
|
|
801
802
|
});
|
|
802
803
|
|
|
803
|
-
if (strictResume && promptBackend === 'codex') {
|
|
804
|
-
validateStrictResumeSpawn({
|
|
805
|
-
strictResume,
|
|
806
|
-
promptBackend,
|
|
807
|
-
});
|
|
808
|
-
}
|
|
809
804
|
if (promptBackend === 'codex') {
|
|
810
805
|
if (!codexRuntimeController) {
|
|
811
806
|
const error = new Error('Codex runtime controller is not initialized');
|
|
@@ -823,6 +818,7 @@ async function buildSpawnContext(
|
|
|
823
818
|
getSessionLabel,
|
|
824
819
|
logEvent,
|
|
825
820
|
mutateSessionOnDrift,
|
|
821
|
+
strictResume,
|
|
826
822
|
});
|
|
827
823
|
}
|
|
828
824
|
|
|
@@ -3443,6 +3439,7 @@ let startPollWatchdog = null;
|
|
|
3443
3439
|
async function main() {
|
|
3444
3440
|
const cgroupOomObserver = createCgroupOomObserver();
|
|
3445
3441
|
const invocationId = parseSystemdInvocationId();
|
|
3442
|
+
let abnormalProviderTermination = null;
|
|
3446
3443
|
let codexManagerOptions = Object.freeze({
|
|
3447
3444
|
codexHostIdentity: null,
|
|
3448
3445
|
codexBootSessionIdentity: null,
|
|
@@ -3572,6 +3569,7 @@ async function main() {
|
|
|
3572
3569
|
codexRuntimeController = createCodexRuntimeController({
|
|
3573
3570
|
config,
|
|
3574
3571
|
db,
|
|
3572
|
+
sessionLauncher,
|
|
3575
3573
|
defaultDaemonSecretRoots: [
|
|
3576
3574
|
DATA_DIR,
|
|
3577
3575
|
ipcRuntimeDir,
|
|
@@ -3887,6 +3885,18 @@ async function main() {
|
|
|
3887
3885
|
renderQuestion: (payload) => questionHandlers?.renderAsk(payload),
|
|
3888
3886
|
logger: console,
|
|
3889
3887
|
});
|
|
3888
|
+
sdkCallbacks.onAbnormalTermination = (_sessionKey, evidence) => {
|
|
3889
|
+
if (abnormalProviderTermination !== null) return;
|
|
3890
|
+
abnormalProviderTermination = Object.freeze({
|
|
3891
|
+
backend: evidence?.backend ?? null,
|
|
3892
|
+
event: evidence?.event ?? null,
|
|
3893
|
+
exitCode: evidence?.exitCode ?? null,
|
|
3894
|
+
});
|
|
3895
|
+
logEvent('abnormal-provider-termination', {
|
|
3896
|
+
bot: BOT_NAME,
|
|
3897
|
+
...abnormalProviderTermination,
|
|
3898
|
+
});
|
|
3899
|
+
};
|
|
3890
3900
|
// 0.10.0: sdkCallbacks (the polygram-side lifecycle handlers — status
|
|
3891
3901
|
// reactor, stream chunk → bubble edit, etc.) move from the underlying
|
|
3892
3902
|
// SDK pm to the generic ProcessManager. The SDK pm gets legacyCallbacks
|
|
@@ -4409,7 +4419,7 @@ async function main() {
|
|
|
4409
4419
|
// replay picks it up. Prevents "Sorry, I couldn't process that message"
|
|
4410
4420
|
// from showing on every restart.
|
|
4411
4421
|
const SHUTDOWN_DRAIN_MS = 30_000;
|
|
4412
|
-
const shutdown = async () => {
|
|
4422
|
+
const shutdown = async ({ continuationAuthorized = false, trigger = 'signal' } = {}) => {
|
|
4413
4423
|
if (isShuttingDown) return;
|
|
4414
4424
|
const shutdownObservation = cgroupOomObserver.sample();
|
|
4415
4425
|
// Sample in-flight work at SIGNAL time, alongside the OOM observation and
|
|
@@ -4419,7 +4429,7 @@ async function main() {
|
|
|
4419
4429
|
// idle, and cannot answer "how much work did this restart cost?".
|
|
4420
4430
|
const inFlightAtSignal = countInFlight(inFlightHandlers);
|
|
4421
4431
|
isShuttingDown = true;
|
|
4422
|
-
console.log(
|
|
4432
|
+
console.log(`\nShutting down (${trigger})...`);
|
|
4423
4433
|
// 1. Stop accepting new inbound first so nothing new queues behind the drain.
|
|
4424
4434
|
if (bot && bot._stop) bot._stop();
|
|
4425
4435
|
|
|
@@ -4508,7 +4518,9 @@ async function main() {
|
|
|
4508
4518
|
// which CLI turn was still active; replay rows alone cannot establish
|
|
4509
4519
|
// that fact.
|
|
4510
4520
|
let pmRetired = false;
|
|
4511
|
-
let forceCrashReason =
|
|
4521
|
+
let forceCrashReason = abnormalProviderTermination
|
|
4522
|
+
? 'provider-process-terminated'
|
|
4523
|
+
: null;
|
|
4512
4524
|
let resumeIntents = [];
|
|
4513
4525
|
if (shutdownObservation?.detected !== true) {
|
|
4514
4526
|
try {
|
|
@@ -4519,7 +4531,7 @@ async function main() {
|
|
|
4519
4531
|
settlementTimeoutMs: SHUTDOWN_DRAIN_MS,
|
|
4520
4532
|
});
|
|
4521
4533
|
pmRetired = true;
|
|
4522
|
-
const projected = buildResumeIntents({
|
|
4534
|
+
const projected = continuationAuthorized ? buildResumeIntents({
|
|
4523
4535
|
snapshots: retirement.snapshots,
|
|
4524
4536
|
policyVersion: CLEAN_RESUME_POLICY_VERSION,
|
|
4525
4537
|
resolveSourceMessageId: ({ sessionKey, sourceMsgId }) => {
|
|
@@ -4542,10 +4554,12 @@ async function main() {
|
|
|
4542
4554
|
? messageId
|
|
4543
4555
|
: null;
|
|
4544
4556
|
},
|
|
4545
|
-
});
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
4557
|
+
}) : { resumeIntents: [], snapshots: retirement.snapshots };
|
|
4558
|
+
resumeIntents = projected.resumeIntents.filter((intent) => (
|
|
4559
|
+
intent.interruptedProviderTurnId == null
|
|
4560
|
+
? config.bot?.resumeInterruptedCliTurns === true
|
|
4561
|
+
: config.bot?.resumeInterruptedCodexTurns === true
|
|
4562
|
+
));
|
|
4549
4563
|
for (const snapshot of projected.snapshots) {
|
|
4550
4564
|
logEvent('clean-retirement-snapshot', {
|
|
4551
4565
|
bot: BOT_NAME,
|
|
@@ -4576,6 +4590,7 @@ async function main() {
|
|
|
4576
4590
|
botName: BOT_NAME,
|
|
4577
4591
|
observation: shutdownObservation,
|
|
4578
4592
|
resumeIntents,
|
|
4593
|
+
continuationAuthorized,
|
|
4579
4594
|
forceCrashReason,
|
|
4580
4595
|
});
|
|
4581
4596
|
if (res.clean) {
|
|
@@ -4642,8 +4657,8 @@ async function main() {
|
|
|
4642
4657
|
if (PID_PATH) processGuard.releasePidFile(PID_PATH);
|
|
4643
4658
|
setTimeout(() => process.exit(0), 100);
|
|
4644
4659
|
};
|
|
4645
|
-
process.on('SIGINT', shutdown);
|
|
4646
|
-
process.on('SIGTERM', shutdown);
|
|
4660
|
+
process.on('SIGINT', () => shutdown());
|
|
4661
|
+
process.on('SIGTERM', () => shutdown());
|
|
4647
4662
|
// rc.28: also catch SIGHUP. The shumabit deploy path runs polygram
|
|
4648
4663
|
// inside `tmux new-session`; on `launchctl kickstart -k` the
|
|
4649
4664
|
// shumabit-start launcher does `tmux kill-session -t polygram`
|
|
@@ -4655,7 +4670,7 @@ async function main() {
|
|
|
4655
4670
|
// been mid-tool-use longer than the replayWindowMs gets silently
|
|
4656
4671
|
// dropped. Surface symptom: bot stops responding after deploy on
|
|
4657
4672
|
// long agent runs (the §7.4 incident).
|
|
4658
|
-
process.on('SIGHUP', shutdown);
|
|
4673
|
+
process.on('SIGHUP', () => shutdown());
|
|
4659
4674
|
|
|
4660
4675
|
try {
|
|
4661
4676
|
// Fresh per-boot secret, persisted 0600 for same-UID readers (cron
|
|
@@ -4669,6 +4684,21 @@ async function main() {
|
|
|
4669
4684
|
botName: BOT_NAME,
|
|
4670
4685
|
getInFlightHandlers: () => inFlightHandlers,
|
|
4671
4686
|
handleSendOverIpc: (req) => handleSendOverIpc(req),
|
|
4687
|
+
requestDeployRestart: () => {
|
|
4688
|
+
if (isShuttingDown) {
|
|
4689
|
+
const error = new Error('shutdown already in progress');
|
|
4690
|
+
error.code = 'SHUTDOWN_IN_PROGRESS';
|
|
4691
|
+
throw error;
|
|
4692
|
+
}
|
|
4693
|
+
const oldPid = process.pid;
|
|
4694
|
+
shutdown({
|
|
4695
|
+
continuationAuthorized: true,
|
|
4696
|
+
trigger: 'deploy-ipc',
|
|
4697
|
+
}).catch((error) => {
|
|
4698
|
+
console.error(`[shutdown] deploy restart failed: ${error.message}`);
|
|
4699
|
+
});
|
|
4700
|
+
return { accepted: true, old_pid: oldPid };
|
|
4701
|
+
},
|
|
4672
4702
|
}),
|
|
4673
4703
|
logger: console,
|
|
4674
4704
|
});
|
|
@@ -4721,7 +4751,7 @@ async function main() {
|
|
|
4721
4751
|
botName: BOT_NAME,
|
|
4722
4752
|
maxAgeMs: 2 * (replayWindowMs || 3 * 60 * 1000),
|
|
4723
4753
|
olderThanMs: replayWindowMs || 30 * 60 * 1000,
|
|
4724
|
-
supportedPolicyVersions:
|
|
4754
|
+
supportedPolicyVersions: SUPPORTED_CLEAN_RESUME_POLICY_VERSIONS,
|
|
4725
4755
|
sessionKeyForSource: sourceSessionKey,
|
|
4726
4756
|
trackTask: trackHandlerTask,
|
|
4727
4757
|
onClaim: (kind, claim) => {
|
|
@@ -4739,11 +4769,18 @@ async function main() {
|
|
|
4739
4769
|
);
|
|
4740
4770
|
},
|
|
4741
4771
|
executeClaim: (claim, { onReady }) => executeCleanResumeClaim(claim, {
|
|
4742
|
-
enabled:
|
|
4772
|
+
enabled: claim.interrupted_provider_turn_id == null
|
|
4773
|
+
? config.bot?.resumeInterruptedCliTurns === true
|
|
4774
|
+
: config.bot?.resumeInterruptedCodexTurns === true,
|
|
4743
4775
|
loadSource: (messageId) => db.getInboundMessageById(messageId),
|
|
4744
4776
|
sessionKeyForSource: sourceSessionKey,
|
|
4745
|
-
loadRuntimeSession: (sessionKey) => (
|
|
4746
|
-
db.getProviderSession(
|
|
4777
|
+
loadRuntimeSession: (sessionKey, activeClaim) => (
|
|
4778
|
+
db.getProviderSession(
|
|
4779
|
+
sessionKey,
|
|
4780
|
+
activeClaim.interrupted_provider_turn_id == null
|
|
4781
|
+
? 'claude:channels'
|
|
4782
|
+
: 'codex:app-server',
|
|
4783
|
+
)
|
|
4747
4784
|
),
|
|
4748
4785
|
resolveStrictSpawnContext: async ({ claim: activeClaim, runtime }) => {
|
|
4749
4786
|
try {
|
|
@@ -4753,6 +4790,14 @@ async function main() {
|
|
|
4753
4790
|
strictResume: {
|
|
4754
4791
|
expectedGenerationId: activeClaim.session_generation_id,
|
|
4755
4792
|
expectedSessionId: runtime.provider_session_id,
|
|
4793
|
+
...(activeClaim.interrupted_provider_turn_id == null
|
|
4794
|
+
? {}
|
|
4795
|
+
: {
|
|
4796
|
+
expectedInterruptedTurnId:
|
|
4797
|
+
activeClaim.interrupted_provider_turn_id,
|
|
4798
|
+
expectedSpawnProfileId:
|
|
4799
|
+
activeClaim.interrupted_spawn_profile_id,
|
|
4800
|
+
}),
|
|
4756
4801
|
},
|
|
4757
4802
|
}),
|
|
4758
4803
|
};
|
|
@@ -4818,6 +4863,15 @@ async function main() {
|
|
|
4818
4863
|
ambiguous: failures > 0 || delivered.deliveryAmbiguous,
|
|
4819
4864
|
};
|
|
4820
4865
|
},
|
|
4866
|
+
settleProviderDelivery: ({
|
|
4867
|
+
claim: activeClaim,
|
|
4868
|
+
result,
|
|
4869
|
+
disposition,
|
|
4870
|
+
}) => codexRuntimeController.settleTelegramDelivery(
|
|
4871
|
+
activeClaim.session_key,
|
|
4872
|
+
result,
|
|
4873
|
+
{ disposition },
|
|
4874
|
+
),
|
|
4821
4875
|
sendNotice: async ({ source, text, ambiguous }) => {
|
|
4822
4876
|
if (!source) throw new Error('clean resume source message is missing');
|
|
4823
4877
|
await tg(bot, 'sendMessage', {
|
|
@@ -5159,6 +5213,9 @@ async function main() {
|
|
|
5159
5213
|
console.error(`[compact-orphan-handler] failed: ${err.message}`);
|
|
5160
5214
|
}
|
|
5161
5215
|
|
|
5216
|
+
logEvent('polygram-admission-open', lifecycleDetail({
|
|
5217
|
+
bot: BOT_NAME,
|
|
5218
|
+
}, invocationId));
|
|
5162
5219
|
console.log(`[${BOT_NAME}] Starting...`);
|
|
5163
5220
|
const pollPromise = pollBot(bot).catch(err => {
|
|
5164
5221
|
console.error(`[${BOT_NAME}] Fatal:`, err.message);
|
package/scripts/ipc-smoke.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* Quick IPC round-trip probe.
|
|
4
|
-
* Usage: node scripts/ipc-smoke.js <bot-name> [busy]
|
|
4
|
+
* Usage: node scripts/ipc-smoke.js <bot-name> [busy|restart]
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const { call, readSecret, socketPathFor } = require('../lib/ipc/client');
|
|
@@ -32,6 +32,29 @@ const { call, readSecret, socketPathFor } = require('../lib/ipc/client');
|
|
|
32
32
|
return;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
if (command === 'restart') {
|
|
36
|
+
const result = await call({
|
|
37
|
+
path,
|
|
38
|
+
op: 'deploy_restart',
|
|
39
|
+
secret: readSecret(bot),
|
|
40
|
+
callTimeoutMs: 60_000,
|
|
41
|
+
});
|
|
42
|
+
if (
|
|
43
|
+
result.ok !== true
|
|
44
|
+
|| result.accepted !== true
|
|
45
|
+
|| !Number.isSafeInteger(result.old_pid)
|
|
46
|
+
|| result.old_pid <= 1
|
|
47
|
+
) {
|
|
48
|
+
throw new Error(result.error || 'invalid deploy restart response');
|
|
49
|
+
}
|
|
50
|
+
console.log(JSON.stringify({
|
|
51
|
+
bot,
|
|
52
|
+
accepted: true,
|
|
53
|
+
old_pid: result.old_pid,
|
|
54
|
+
}));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
35
58
|
console.log('path:', path);
|
|
36
59
|
console.log('ping:', JSON.stringify(await call({ path, op: 'ping' })));
|
|
37
60
|
console.log('DONE');
|