@llblab/pi-kit 0.19.1 → 0.20.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +4 -4
  4. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +5 -0
  5. package/node_modules/@llblab/pi-state-flow/README.md +3 -1
  6. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +45 -5
  7. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +9 -9
  8. package/node_modules/@llblab/pi-state-flow/dist/lib/skills.d.ts +19 -2
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/skills.js +52 -11
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +1 -1
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +0 -2
  12. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +2 -2
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +36 -15
  14. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  15. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +1 -1
  16. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +1 -1
  17. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +5 -5
  18. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +2 -1
  19. package/node_modules/@llblab/pi-state-flow/docs/usage.md +2 -2
  20. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +48 -5
  21. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +9 -9
  22. package/node_modules/@llblab/pi-state-flow/lib/skills.ts +65 -11
  23. package/node_modules/@llblab/pi-state-flow/lib/status.ts +1 -2
  24. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +2 -2
  25. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +35 -15
  26. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  27. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +1 -1
  28. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +1 -1
  29. package/node_modules/@llblab/pi-telegram/AGENTS.md +9 -7
  30. package/node_modules/@llblab/pi-telegram/BACKLOG.md +13 -32
  31. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +11 -0
  32. package/node_modules/@llblab/pi-telegram/README.md +4 -3
  33. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +2 -2
  34. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +3 -5
  35. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +1 -1
  36. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +93 -9
  37. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +9 -3
  38. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +231 -71
  39. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +4 -0
  40. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +34 -7
  41. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +63 -2
  42. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +3 -0
  43. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +13 -0
  44. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.d.ts +2 -0
  45. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.js +25 -4
  46. package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +1 -0
  47. package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +32 -12
  48. package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +10 -0
  49. package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +22 -1
  50. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +2 -2
  51. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +1 -1
  52. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +1 -2
  53. package/node_modules/@llblab/pi-telegram/dist/lib/sync.d.ts +9 -0
  54. package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +41 -1
  55. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +9 -1
  56. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +23 -3
  57. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +7 -2
  58. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +2 -0
  59. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +68 -37
  60. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +6 -4
  61. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +134 -61
  62. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +9 -0
  63. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +49 -3
  64. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +77 -6
  65. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +384 -8
  66. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.d.ts +3 -0
  67. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.js +6 -0
  68. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  69. package/node_modules/@llblab/pi-telegram/docs/architecture.md +21 -14
  70. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +31 -10
  71. package/node_modules/@llblab/pi-telegram/docs/updates.md +2 -0
  72. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +5 -7
  73. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +69 -12
  74. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +236 -94
  75. package/node_modules/@llblab/pi-telegram/lib/bus.ts +39 -9
  76. package/node_modules/@llblab/pi-telegram/lib/extension.ts +69 -2
  77. package/node_modules/@llblab/pi-telegram/lib/journal.ts +14 -0
  78. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +23 -4
  79. package/node_modules/@llblab/pi-telegram/lib/locks.ts +32 -13
  80. package/node_modules/@llblab/pi-telegram/lib/paths.ts +29 -1
  81. package/node_modules/@llblab/pi-telegram/lib/polling.ts +2 -2
  82. package/node_modules/@llblab/pi-telegram/lib/queue.ts +2 -3
  83. package/node_modules/@llblab/pi-telegram/lib/sync.ts +45 -1
  84. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +30 -2
  85. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +11 -3
  86. package/node_modules/@llblab/pi-telegram/lib/threads.ts +73 -36
  87. package/node_modules/@llblab/pi-telegram/lib/updates.ts +145 -78
  88. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +67 -4
  89. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +465 -7
  90. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +7 -0
  91. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  92. package/package.json +3 -3
@@ -11,7 +11,7 @@ import { isDeepStrictEqual } from "node:util";
11
11
  import { isTelegramApiCommitUnknownError, TelegramApiCommitUnknownError, } from "./telegram-api.js";
12
12
  import { withTelegramFileTransaction } from "./locks.js";
13
13
  import * as ThreadReconciler from "./thread-reconciler.js";
14
- import { planTelegramWorkspaceSlotAllocation, TELEGRAM_WORKSPACE_SLOTS, } from "./workspace-slots.js";
14
+ import { planTelegramWorkspaceSlotAllocation, TELEGRAM_WORKSPACE_SLOTS, TelegramWorkspaceSlotUnavailableError, } from "./workspace-slots.js";
15
15
  import { resolveAgentDir, resolveTelegramProfileTempFilePath, } from "./paths.js";
16
16
  const TELEGRAM_WORKSPACE_KEY_MAX_LENGTH = 180;
17
17
  const TELEGRAM_SESSION_ID_MAX_LENGTH = 256;
@@ -1301,19 +1301,31 @@ export function createTelegramTopicTargetStore(options) {
1301
1301
  dirty = true;
1302
1302
  mutationRevision += 1;
1303
1303
  };
1304
- const persistSnapshot = (invalidation) => {
1304
+ const markWorkspaceBindingInactiveByTarget = (target, inactiveSinceMs = getNowMs()) => {
1305
+ if (!Number.isFinite(inactiveSinceMs) || inactiveSinceMs < 0)
1306
+ return false;
1307
+ for (const [key, binding] of workspaceBindings) {
1308
+ if (!targetMatches(binding.target, target) || binding.inactiveSinceMs !== undefined)
1309
+ continue;
1310
+ workspaceBindings.set(key, { ...binding, inactiveSinceMs });
1311
+ markDirty();
1312
+ return true;
1313
+ }
1314
+ return false;
1315
+ };
1316
+ const persistSnapshot = (transition) => {
1305
1317
  const persist = persistQueue.then(async () => {
1306
1318
  const path = getPath();
1307
1319
  if (loadedPath !== path && !dirty)
1308
1320
  resetForPath(path);
1309
1321
  if (options.canPersist && !options.canPersist()) {
1310
- if (!invalidation)
1322
+ if (!transition)
1311
1323
  await loadFromDisk();
1312
1324
  return false;
1313
1325
  }
1314
1326
  if (!dirty || !loaded)
1315
1327
  await loadFromDisk();
1316
- if (invalidation && !invalidation.isCurrent())
1328
+ if (transition && !transition.isCurrent())
1317
1329
  return false;
1318
1330
  const nowMs = getNowMs();
1319
1331
  reservations = reservations.filter((reservation) => reservation.expiresAtMs === undefined ||
@@ -1322,6 +1334,11 @@ export function createTelegramTopicTargetStore(options) {
1322
1334
  const currentRecords = Array.from(records.values())
1323
1335
  .filter(isPersistedThreadRecord)
1324
1336
  .map(cloneRecord);
1337
+ if (transition?.kind === "detach") {
1338
+ const owners = currentRecords.filter((record) => targetMatches(record.target, transition.target));
1339
+ if (owners.length !== 1 || !isDeepStrictEqual(normalizeRecord(owners[0]), transition.owner))
1340
+ return false;
1341
+ }
1325
1342
  records = new Map(currentRecords.map((record) => [
1326
1343
  getRecordOwnerKey(record),
1327
1344
  cloneRecord(record),
@@ -1358,21 +1375,34 @@ export function createTelegramTopicTargetStore(options) {
1358
1375
  return serialized;
1359
1376
  }),
1360
1377
  };
1361
- if (invalidation) {
1362
- const record = file.threads.find((record) => targetMatches(record.target, invalidation.target));
1378
+ if (transition) {
1379
+ const record = file.threads.find((record) => targetMatches(record.target, transition.target));
1363
1380
  if (!record)
1364
1381
  return false;
1382
+ if (transition.kind === "detach") {
1383
+ const bindings = file.workspaceBindings.filter((binding) => targetMatches(binding.target, record.target));
1384
+ if (bindings.length !== 1 || !/^[A-Z]$/.test(record.slot ?? "") ||
1385
+ bindings[0].slot !== record.slot)
1386
+ return false;
1387
+ }
1365
1388
  file.threads = file.threads.filter((candidate) => candidate !== record);
1366
- file.syncObservations = file.syncObservations.filter((observation) => !targetMatches(observation.target, record.target));
1367
- file.syncObservations.push({
1368
- target: { ...record.target },
1369
- syncStatus: "deleted",
1370
- observedAtMs: nowMs,
1371
- ...(record.instanceId ? { instanceId: record.instanceId } : {}),
1372
- ...(record.slot ? { slot: record.slot } : {}),
1373
- lastSyncError: invalidation.lastSyncError,
1374
- lastReconcileAction: "mark-stale",
1375
- });
1389
+ for (const binding of file.workspaceBindings) {
1390
+ if (targetMatches(binding.target, transition.target) && binding.inactiveSinceMs === undefined) {
1391
+ binding.inactiveSinceMs = nowMs;
1392
+ }
1393
+ }
1394
+ if (transition.kind === "invalidate") {
1395
+ file.syncObservations = file.syncObservations.filter((observation) => !targetMatches(observation.target, record.target));
1396
+ file.syncObservations.push({
1397
+ target: { ...record.target },
1398
+ syncStatus: "deleted",
1399
+ observedAtMs: nowMs,
1400
+ ...(record.instanceId ? { instanceId: record.instanceId } : {}),
1401
+ ...(record.slot ? { slot: record.slot } : {}),
1402
+ lastSyncError: transition.lastSyncError,
1403
+ lastReconcileAction: "mark-stale",
1404
+ });
1405
+ }
1376
1406
  }
1377
1407
  let persistedSemanticSnapshot;
1378
1408
  try {
@@ -1382,7 +1412,7 @@ export function createTelegramTopicTargetStore(options) {
1382
1412
  /* missing or invalid snapshots must be replaced */
1383
1413
  }
1384
1414
  // Normalize optional fields to wire JSON; object key order is not a state change.
1385
- if (isDeepStrictEqual(persistedSemanticSnapshot, getTelegramStateSemanticSnapshot(JSON.parse(JSON.stringify(file)))) &&
1415
+ if (!transition && isDeepStrictEqual(persistedSemanticSnapshot, getTelegramStateSemanticSnapshot(JSON.parse(JSON.stringify(file)))) &&
1386
1416
  mutationRevision === persistedRevision &&
1387
1417
  statusRevision === persistedStatusRevision) {
1388
1418
  loadedPath = path;
@@ -1398,18 +1428,21 @@ export function createTelegramTopicTargetStore(options) {
1398
1428
  });
1399
1429
  await chmod(tempPath, 0o600);
1400
1430
  try {
1401
- if (invalidation) {
1431
+ if (transition) {
1402
1432
  let applied = false;
1403
1433
  const commit = () => {
1404
1434
  if (getPath() !== path || mutationRevision !== persistedRevision ||
1405
- statusRevision !== persistedStatusRevision || !invalidation.isCurrent())
1435
+ statusRevision !== persistedStatusRevision || !transition.isCurrent())
1406
1436
  return;
1407
1437
  // Fence and rename share one synchronous commit boundary. No stale
1408
- // invalidation enters the live projection before durable commit.
1438
+ // transition enters the live projection before durable commit.
1409
1439
  renameSync(tempPath, path);
1410
1440
  loadedPath = path;
1411
- records = new Map(Array.from(records).filter(([, record]) => !targetMatches(record.target, invalidation.target)));
1441
+ records = new Map(Array.from(records).filter(([, record]) => !targetMatches(record.target, transition.target)));
1412
1442
  syncObservations = file.syncObservations;
1443
+ workspaceBindings = new Map(file.workspaceBindings.map((binding) => [
1444
+ getWorkspaceBindingMapKey(binding), cloneWorkspaceBinding(binding),
1445
+ ]));
1413
1446
  mutationRevision += 1;
1414
1447
  dirty = false;
1415
1448
  applied = true;
@@ -1465,7 +1498,13 @@ export function createTelegramTopicTargetStore(options) {
1465
1498
  await persistSnapshot();
1466
1499
  },
1467
1500
  invalidateTarget(target, isCurrent, lastSyncError) {
1468
- return persistSnapshot({ target, isCurrent, lastSyncError });
1501
+ return persistSnapshot({ kind: "invalidate", target, isCurrent, lastSyncError });
1502
+ },
1503
+ detachTargetOwner(expected, isCurrent) {
1504
+ const owner = normalizeRecord(expected);
1505
+ if (!owner)
1506
+ return Promise.resolve(false);
1507
+ return persistSnapshot({ kind: "detach", owner, target: owner.target, isCurrent });
1469
1508
  },
1470
1509
  list() {
1471
1510
  return Array.from(records.values()).map(cloneRecord);
@@ -1994,18 +2033,7 @@ export function createTelegramTopicTargetStore(options) {
1994
2033
  markDirty();
1995
2034
  return true;
1996
2035
  },
1997
- markWorkspaceBindingInactiveByTarget(target, inactiveSinceMs = getNowMs()) {
1998
- if (!Number.isFinite(inactiveSinceMs) || inactiveSinceMs < 0)
1999
- return false;
2000
- for (const [key, binding] of workspaceBindings) {
2001
- if (!targetMatches(binding.target, target) || binding.inactiveSinceMs !== undefined)
2002
- continue;
2003
- workspaceBindings.set(key, { ...binding, inactiveSinceMs });
2004
- markDirty();
2005
- return true;
2006
- }
2007
- return false;
2008
- },
2036
+ markWorkspaceBindingInactiveByTarget,
2009
2037
  markWorkspaceBindingActiveByTarget(target) {
2010
2038
  if (hasWorkspaceRetirementConflict({ target }))
2011
2039
  return false;
@@ -2419,8 +2447,11 @@ export function createTelegramTopicTargetStore(options) {
2419
2447
  const record = Array.from(records.values()).find((entry) => targetMatches(entry.target, target));
2420
2448
  const pending = syncStatus === "deleted" ? pendingProvisions.find((entry) => entry.target && targetMatches(entry.target, target)) : undefined;
2421
2449
  const source = record ?? pending;
2450
+ // Confirmed absence ends this binding's active target lifetime, even after
2451
+ // an earlier stale observation already removed its live record.
2452
+ const inactive = syncStatus === "deleted" && markWorkspaceBindingInactiveByTarget(target);
2422
2453
  if (!source?.target)
2423
- return false;
2454
+ return inactive;
2424
2455
  syncObservations = syncObservations.filter((entry) => !targetMatches(entry.target, target));
2425
2456
  syncObservations.push({
2426
2457
  target: { ...source.target }, syncStatus, observedAtMs: getNowMs(),
@@ -3576,7 +3607,7 @@ export function createTelegramTopicTargetProvisioner(deps) {
3576
3607
  if (existing && isCurrentThreadRecord(existing)) {
3577
3608
  const slot = existing.slot ?? deps.store.allocateSlot(request.profileKey);
3578
3609
  if (!slot) {
3579
- throw new Error("Telegram Workspace slot reservation is unavailable.");
3610
+ throw new TelegramWorkspaceSlotUnavailableError();
3580
3611
  }
3581
3612
  const occupied = listOccupiedTelegramThreadIdentities({
3582
3613
  records: deps.store.list(),
@@ -3691,7 +3722,7 @@ export function createTelegramTopicTargetProvisioner(deps) {
3691
3722
  (candidateThreadName ? undefined : identity?.slot) ??
3692
3723
  preferredNameSlot), request.workspaceBindingKey);
3693
3724
  if (!slot) {
3694
- throw new Error("Telegram Workspace slot reservation is unavailable.");
3725
+ throw new TelegramWorkspaceSlotUnavailableError();
3695
3726
  }
3696
3727
  const uniqueCandidate = candidateThreadName &&
3697
3728
  isTelegramTopicThreadNameValidForSlot(candidateThreadName, slot) &&
@@ -441,6 +441,8 @@ export interface TelegramUpdateWorkerJournalSnapshot {
441
441
  }
442
442
  export interface TelegramUpdateWorkerJournalPort {
443
443
  read: () => TelegramUpdateWorkerJournalSnapshot;
444
+ /** Optional strict observation; it must not recover, repair or grant new receipt authority. */
445
+ isQueueReceiptCurrent?: (receipt: TelegramQueueAdmissionReceiptLike, owner: TelegramUpdateJournalQueueOwner) => boolean;
444
446
  markQueued: (receipt: {
445
447
  queueKind: "prompt" | "control";
446
448
  receiptId: string;
@@ -527,7 +529,7 @@ export interface TelegramUpdateWorkerRuntime<TContext> {
527
529
  receipts: readonly TelegramQueueAdmissionReceiptLike[];
528
530
  ctx: TContext;
529
531
  reason: TelegramQueueReceiptCompletionReason;
530
- }) => void;
532
+ }) => boolean;
531
533
  stop: () => Promise<void>;
532
534
  waitForDrain: () => Promise<void>;
533
535
  getState: () => TelegramUpdateWorkerStateSnapshot;
@@ -1000,9 +1002,9 @@ export interface TelegramQueueAdmissionItemLike {
1000
1002
  export interface TelegramQueueAdmissionSettlementRuntime<TContext> {
1001
1003
  isItemReady: (item: TelegramQueueAdmissionItemLike) => boolean;
1002
1004
  getQueueReceiptOwner: (receipt: TelegramQueueAdmissionReceiptLike) => TelegramUpdateJournalQueueOwner | undefined;
1003
- onPromptHandedOff: (item: TelegramQueueAdmissionItemLike, ctx: TContext) => void;
1004
- onControlSettled: (item: TelegramQueueAdmissionItemLike, ctx: TContext) => void;
1005
- onItemsDiscarded: (items: readonly TelegramQueueAdmissionItemLike[], ctx: TContext) => void;
1005
+ onPromptHandedOff: (item: TelegramQueueAdmissionItemLike, ctx: TContext) => boolean;
1006
+ onControlSettled: (item: TelegramQueueAdmissionItemLike, ctx: TContext) => boolean;
1007
+ onItemsDiscarded: (items: readonly TelegramQueueAdmissionItemLike[], ctx: TContext) => boolean;
1006
1008
  }
1007
1009
  export declare function createTelegramQueueAdmissionSettlementMuxRuntime<TContext>(runtimes: readonly TelegramQueueAdmissionSettlementRuntime<TContext>[]): TelegramQueueAdmissionSettlementRuntime<TContext>;
1008
1010
  export declare function createTelegramQueueAdmissionSettlementRuntime<TContext>(worker: TelegramUpdateWorkerRuntime<TContext>): TelegramQueueAdmissionSettlementRuntime<TContext>;
@@ -303,10 +303,10 @@ export function reportTelegramQueueAdmission(values, receipts) {
303
303
  return true;
304
304
  }
305
305
  export function buildTelegramUpdateFlowAction(update, allowedUserId) {
306
- const deletedMessageIds = extractDeletedTelegramMessageIds(update);
307
- if (deletedMessageIds.length > 0) {
308
- return { kind: "deleted", messageIds: deletedMessageIds };
309
- }
306
+ // Business chats are independent from bot chats even when chat/message IDs coincide.
307
+ // Raw handlers may own that namespace; the default DM runtime has no deletion authority.
308
+ if (update.deleted_business_messages !== undefined)
309
+ return { kind: "ignore" };
310
310
  if (update.message_reaction) {
311
311
  return { kind: "reaction", reactionUpdate: update.message_reaction };
312
312
  }
@@ -1017,6 +1017,22 @@ export function createTelegramUpdateWorkerRuntime(deps) {
1017
1017
  }
1018
1018
  return true;
1019
1019
  };
1020
+ const getCurrentQueueReceipt = (receipt) => {
1021
+ const committed = committedQueueReceipts.get(receipt.receiptId);
1022
+ if (!committed || !areTelegramQueueAdmissionReceiptsEqual(committed.receipt, normalizeQueueReceipt(receipt)))
1023
+ return undefined;
1024
+ try {
1025
+ if (deps.journal.isQueueReceiptCurrent &&
1026
+ deps.journal.isQueueReceiptCurrent(committed.receipt, committed.queueOwner) !== true) {
1027
+ return undefined;
1028
+ }
1029
+ }
1030
+ catch (error) {
1031
+ recordRuntimeEvent(error, { phase: "queue-receipt-observation" });
1032
+ return undefined;
1033
+ }
1034
+ return committed;
1035
+ };
1020
1036
  const clearRetryTimer = () => {
1021
1037
  if (retryTimer !== undefined)
1022
1038
  cancelRetry(retryTimer);
@@ -1581,11 +1597,9 @@ export function createTelegramUpdateWorkerRuntime(deps) {
1581
1597
  owner === expectedOwner &&
1582
1598
  !expectedOwner.controller.signal.aborted) {
1583
1599
  pendingSignal = false;
1584
- const result = await drain(expectedOwner);
1585
- if (result !== "idle") {
1586
- pendingSignal = false;
1587
- return;
1588
- }
1600
+ // Keep newer wakes in this run so waitForDrain also covers them. A failure
1601
+ // observed after signal() still keeps its latch; this loop never clears it.
1602
+ await drain(expectedOwner);
1589
1603
  }
1590
1604
  };
1591
1605
  const operation = run();
@@ -1785,27 +1799,20 @@ export function createTelegramUpdateWorkerRuntime(deps) {
1785
1799
  }
1786
1800
  transition("queued", input.updateId);
1787
1801
  },
1788
- isQueueReceiptCommitted(receipt) {
1789
- const committed = committedQueueReceipts.get(receipt.receiptId);
1790
- return (committed !== undefined &&
1791
- areTelegramQueueAdmissionReceiptsEqual(committed.receipt, normalizeQueueReceipt(receipt)));
1792
- },
1802
+ isQueueReceiptCommitted: receipt => getCurrentQueueReceipt(receipt) !== undefined,
1793
1803
  getQueueReceiptOwner(receipt) {
1794
- const committed = committedQueueReceipts.get(receipt.receiptId);
1795
- return committed &&
1796
- areTelegramQueueAdmissionReceiptsEqual(committed.receipt, normalizeQueueReceipt(receipt))
1797
- ? { ...committed.queueOwner }
1798
- : undefined;
1804
+ const committed = getCurrentQueueReceipt(receipt);
1805
+ return committed ? { ...committed.queueOwner } : undefined;
1799
1806
  },
1800
1807
  completeQueueReceipts(input) {
1801
1808
  const expectedOwner = owner;
1802
1809
  if (!expectedOwner ||
1803
1810
  expectedOwner.controller.signal.aborted ||
1804
1811
  !(deps.isContextCurrent?.(input.ctx) ?? expectedOwner.ctx === input.ctx)) {
1805
- return;
1812
+ return false;
1806
1813
  }
1807
1814
  if (input.receipts.length === 0)
1808
- return;
1815
+ return true;
1809
1816
  const normalizedReceipts = input.receipts.map(normalizeQueueReceipt);
1810
1817
  const receiptIds = new Set();
1811
1818
  const sourceUpdateIds = new Set();
@@ -1813,13 +1820,13 @@ export function createTelegramUpdateWorkerRuntime(deps) {
1813
1820
  for (const receipt of normalizedReceipts) {
1814
1821
  const committed = committedQueueReceipts.get(receipt.receiptId);
1815
1822
  if (!committed)
1816
- continue;
1823
+ return false;
1817
1824
  if (receiptIds.has(receipt.receiptId) ||
1818
1825
  !areTelegramQueueAdmissionReceiptsEqual(committed.receipt, receipt) ||
1819
1826
  !isTelegramUpdateJournalQueueOwnerProcess(committed.queueOwner, expectedOwner.queueOwnerIdentity) ||
1820
1827
  receipt.sourceUpdateIds.some((updateId) => sourceUpdateIds.has(updateId))) {
1821
1828
  blockWithFailure("invalid-outcome", "queue-receipt-completion-invalid", new TelegramUpdateAdmissionOutcomeError(`Telegram ${input.reason} requested invalid queue receipt ${receipt.receiptId}.`));
1822
- return;
1829
+ return false;
1823
1830
  }
1824
1831
  receiptIds.add(receipt.receiptId);
1825
1832
  queuedCompletions.push({
@@ -1830,22 +1837,23 @@ export function createTelegramUpdateWorkerRuntime(deps) {
1830
1837
  sourceUpdateIds.add(updateId);
1831
1838
  }
1832
1839
  }
1833
- if (receiptIds.size === 0)
1834
- return;
1835
1840
  let removedUpdateIds;
1836
1841
  try {
1837
1842
  removedUpdateIds = deps.journal.completeQueued(queuedCompletions).removedUpdateIds;
1838
1843
  }
1839
1844
  catch (error) {
1840
1845
  blockWithFailure("journal-write", "queue-receipt-completion", error);
1841
- return;
1846
+ return false;
1842
1847
  }
1843
1848
  const removed = new Set(removedUpdateIds);
1844
1849
  if (removed.size !== sourceUpdateIds.size ||
1845
1850
  [...sourceUpdateIds].some((updateId) => !removed.has(updateId))) {
1846
1851
  blockWithFailure("journal-write", "queue-receipt-completion", new Error(`Telegram ${input.reason} did not complete every receipt source.`));
1847
- return;
1852
+ return false;
1848
1853
  }
1854
+ input.receipts.forEach((receipt, index) => {
1855
+ acknowledgedQueueReceiptCompletions.set(receipt, normalizedReceipts[index]);
1856
+ });
1849
1857
  for (const updateId of sourceUpdateIds)
1850
1858
  claims.delete(updateId);
1851
1859
  for (const receiptId of receiptIds) {
@@ -1859,6 +1867,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
1859
1867
  notifyStateChange();
1860
1868
  pendingSignal = true;
1861
1869
  launchDrain();
1870
+ return true;
1862
1871
  },
1863
1872
  async stop() {
1864
1873
  const expectedOwner = owner;
@@ -2023,9 +2032,17 @@ export function createTelegramInputCustodyWorkerJournalPort(store) {
2023
2032
  };
2024
2033
  return {
2025
2034
  read: () => store.read(),
2035
+ isQueueReceiptCurrent(receipt, owner) {
2036
+ const entries = store.read().entries.filter(entry => entry.queueReceiptId === receipt.receiptId);
2037
+ return receipt.sourceUpdateIds.length > 0 && entries.length === receipt.sourceUpdateIds.length &&
2038
+ entries.every((entry, index) => entry.updateId === receipt.sourceUpdateIds[index] &&
2039
+ entry.state === "queued" && entry.queueKind === receipt.queueKind && !entry.queueHandoff &&
2040
+ entry.queueOwner !== undefined && areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, owner));
2041
+ },
2026
2042
  markQueued: legacyMutation,
2027
2043
  markExecutionFailure: legacyMutation,
2028
- removeCompleted: legacyMutation,
2044
+ // The common drain batches excluded IDs here; the store rejects all non-vetoed input atomically.
2045
+ removeCompleted: updateIds => store.removeExcluded(updateIds),
2029
2046
  completeQueued: receipts => store.completeQueued(receipts),
2030
2047
  inputCustody: {
2031
2048
  acquireInput: store.acquireInput,
@@ -2620,6 +2637,8 @@ export function createTelegramInputCustodyActivationReadinessResolver(deps) {
2620
2637
  };
2621
2638
  }
2622
2639
  export function createTelegramInputCustodyLifecycleBindingResolver(deps) {
2640
+ // Stable per store, not per descriptor: a renewed source has new captured worker ports.
2641
+ const ports = new WeakMap();
2623
2642
  return () => {
2624
2643
  if (!deps.isEnabled())
2625
2644
  return undefined;
@@ -2627,7 +2646,11 @@ export function createTelegramInputCustodyLifecycleBindingResolver(deps) {
2627
2646
  const recipientBindingKey = deps.getRecipientBindingKey()?.trim();
2628
2647
  if (!source || !source.runtimeKey || !source.recoveryKey || !recipientBindingKey)
2629
2648
  return undefined;
2630
- const port = createTelegramInputCustodyWorkerJournalPort(source.journal);
2649
+ let port = ports.get(source.journal);
2650
+ if (!port) {
2651
+ port = createTelegramInputCustodyWorkerJournalPort(source.journal);
2652
+ ports.set(source.journal, port);
2653
+ }
2631
2654
  return { runtimeKey: JSON.stringify({ source: source.runtimeKey, recipientBindingKey }),
2632
2655
  recoveryKey: source.recoveryKey,
2633
2656
  recipientBindingKey, journal: { ...port, appendBatch: source.journal.appendBatch,
@@ -2757,6 +2780,7 @@ export function createTelegramUpdateAdmissionHandle(deps) {
2757
2780
  try {
2758
2781
  execution.assertCurrent();
2759
2782
  await deps.defaultHandle(boundUpdate, ctx, execution);
2783
+ execution.assertCurrent();
2760
2784
  }
2761
2785
  finally {
2762
2786
  immediate = false;
@@ -2778,10 +2802,45 @@ export function createTelegramCustodiedUpdateAdmissionHandle(deps) {
2778
2802
  });
2779
2803
  return (update, ctx, signal) => session.execute(update, started => admission(started, ctx, signal));
2780
2804
  }
2805
+ // A dispatched prompt can remain in memory until agent_start. Remember only acknowledged
2806
+ // removal of these exact receipt objects for later local discard, never for dispatch/replay.
2807
+ const acknowledgedQueueReceiptCompletions = new WeakMap();
2808
+ const isQueueReceiptCompletionAcknowledged = (receipt) => {
2809
+ const acknowledged = acknowledgedQueueReceiptCompletions.get(receipt);
2810
+ return acknowledged !== undefined && areTelegramQueueAdmissionReceiptsEqual(acknowledged, receipt);
2811
+ };
2781
2812
  export function createTelegramQueueAdmissionSettlementMuxRuntime(runtimes) {
2782
- const settle = (operation) => {
2783
- for (const runtime of runtimes)
2784
- operation(runtime);
2813
+ // Plan the whole request before mutation; unready is not an acknowledgement of completion.
2814
+ const complete = (items, ctx, kind) => {
2815
+ const plan = new Map();
2816
+ for (const receipt of items.flatMap(item => item.admissionReceipts ?? [])) {
2817
+ if (kind === "discard" && isQueueReceiptCompletionAcknowledged(receipt))
2818
+ continue;
2819
+ const candidates = runtimes.filter(runtime => runtime.isItemReady({ admissionReceipts: [receipt] }));
2820
+ const selected = candidates[0];
2821
+ if (!selected)
2822
+ return false;
2823
+ if (candidates.length > 1) {
2824
+ const owner = selected.getQueueReceiptOwner(receipt);
2825
+ if (!owner || candidates.some(runtime => {
2826
+ const candidate = runtime.getQueueReceiptOwner(receipt);
2827
+ return !candidate || !areTelegramUpdateJournalQueueOwnersEqual(owner, candidate);
2828
+ }))
2829
+ return false;
2830
+ }
2831
+ const receipts = plan.get(selected) ?? [];
2832
+ receipts.push(receipt);
2833
+ plan.set(selected, receipts);
2834
+ }
2835
+ for (const [runtime, receipts] of plan) {
2836
+ const item = { admissionReceipts: receipts };
2837
+ const completed = kind === "discard" ? runtime.onItemsDiscarded([item], ctx)
2838
+ : kind === "prompt" ? runtime.onPromptHandedOff(item, ctx)
2839
+ : runtime.onControlSettled(item, ctx);
2840
+ if (completed !== true)
2841
+ return false;
2842
+ }
2843
+ return true;
2785
2844
  };
2786
2845
  return {
2787
2846
  isItemReady: (item) => (item.admissionReceipts ?? []).every((receipt) => runtimes.some((runtime) => runtime.isItemReady({ admissionReceipts: [receipt] }))),
@@ -2799,19 +2858,21 @@ export function createTelegramQueueAdmissionSettlementMuxRuntime(runtimes) {
2799
2858
  }
2800
2859
  return owner ? { ...owner } : undefined;
2801
2860
  },
2802
- onPromptHandedOff: (item, ctx) => settle((runtime) => runtime.onPromptHandedOff(item, ctx)),
2803
- onControlSettled: (item, ctx) => settle((runtime) => runtime.onControlSettled(item, ctx)),
2804
- onItemsDiscarded: (items, ctx) => settle((runtime) => runtime.onItemsDiscarded(items, ctx)),
2861
+ onPromptHandedOff: (item, ctx) => complete([item], ctx, "prompt"),
2862
+ onControlSettled: (item, ctx) => complete([item], ctx, "control"),
2863
+ onItemsDiscarded: (items, ctx) => complete(items, ctx, "discard"),
2805
2864
  };
2806
2865
  }
2807
2866
  export function createTelegramQueueAdmissionSettlementRuntime(worker) {
2808
2867
  const complete = (items, ctx, reason) => {
2809
2868
  const receipts = [];
2810
2869
  for (const item of items) {
2811
- if (item.admissionReceipts)
2812
- receipts.push(...item.admissionReceipts);
2870
+ for (const receipt of item.admissionReceipts ?? []) {
2871
+ if (reason !== "discard" || !isQueueReceiptCompletionAcknowledged(receipt))
2872
+ receipts.push(receipt);
2873
+ }
2813
2874
  }
2814
- worker.completeQueueReceipts({ receipts, ctx, reason });
2875
+ return receipts.length === 0 || worker.completeQueueReceipts({ receipts, ctx, reason });
2815
2876
  };
2816
2877
  return {
2817
2878
  isItemReady: (item) => (item.admissionReceipts ?? []).every(worker.isQueueReceiptCommitted),
@@ -3065,10 +3126,6 @@ export function createTelegramQueueHandoffReconciler(deps) {
3065
3126
  const recipientJournalBindingKey = deps.createRecipientJournalBindingKey(recipient);
3066
3127
  if (!recipientJournalBindingKey)
3067
3128
  continue;
3068
- const recipientItem = structuredClone(item);
3069
- recipientItem.admissionReceipts = [
3070
- { ...receipt, journalBindingKey: recipientJournalBindingKey },
3071
- ];
3072
3129
  const expectedOwner = deps.getReceiptOwner(receipt);
3073
3130
  const lifecycle = deps.getLifecycleForReceipt(receipt);
3074
3131
  if (!expectedOwner ||
@@ -3091,11 +3148,11 @@ export function createTelegramQueueHandoffReconciler(deps) {
3091
3148
  },
3092
3149
  handoffToken,
3093
3150
  lifecycle,
3094
- stageRemote: async () => {
3095
- const payload = createTelegramQueueHandoff({
3096
- handoffToken,
3097
- item: recipientItem,
3098
- }).payload;
3151
+ stageRemote: async ({ payload: sourcePayload }) => {
3152
+ const payload = {
3153
+ ...sourcePayload,
3154
+ admissionReceipts: [{ ...sourcePayload.admissionReceipts[0], journalBindingKey: recipientJournalBindingKey }],
3155
+ };
3099
3156
  if (followerRegistered) {
3100
3157
  return deps.stageThroughFollower({
3101
3158
  recipient,
@@ -3244,11 +3301,27 @@ export function createTelegramUpdateAdmissionLifecycleRuntime(deps) {
3244
3301
  let activeRuntimeKey;
3245
3302
  let journal;
3246
3303
  let worker;
3304
+ let workerInputCustody;
3247
3305
  let settlement;
3248
3306
  let journalBindingKey;
3249
3307
  let operation = Promise.resolve();
3250
3308
  let foreignQueueOwnerLiveness;
3251
3309
  let releaseSourceReference;
3310
+ let sourceReferenceBinding;
3311
+ const withRetainedSourceReference = (operation) => {
3312
+ if (!journal || !worker)
3313
+ throw new Error("Telegram update admission worker is not active.");
3314
+ // Synchronous retained-source access needs a reference, not renewed execution authority.
3315
+ const release = !releaseSourceReference && sourceReferenceBinding
3316
+ ? deps.acquireSourceReference?.(sourceReferenceBinding) : undefined;
3317
+ try {
3318
+ return operation(journal);
3319
+ }
3320
+ finally {
3321
+ release?.();
3322
+ }
3323
+ };
3324
+ const readObservedJournal = () => withRetainedSourceReference(source => source.read());
3252
3325
  const stopCurrent = async (forget) => {
3253
3326
  await worker?.stop();
3254
3327
  const release = releaseSourceReference;
@@ -3261,8 +3334,10 @@ export function createTelegramUpdateAdmissionLifecycleRuntime(deps) {
3261
3334
  }
3262
3335
  if (!forget)
3263
3336
  return;
3337
+ sourceReferenceBinding = undefined;
3264
3338
  journal = undefined;
3265
3339
  worker = undefined;
3340
+ workerInputCustody = undefined;
3266
3341
  settlement = undefined;
3267
3342
  journalBindingKey = undefined;
3268
3343
  activeRuntimeKey = undefined;
@@ -3274,7 +3349,8 @@ export function createTelegramUpdateAdmissionLifecycleRuntime(deps) {
3274
3349
  await stopCurrent(true);
3275
3350
  return;
3276
3351
  }
3277
- if (replace || !worker || activeRuntimeKey !== binding.runtimeKey) {
3352
+ if (replace || !worker || activeRuntimeKey !== binding.runtimeKey ||
3353
+ workerInputCustody !== binding.journal.inputCustody) {
3278
3354
  const { runtimeKey, recoveryKey } = binding;
3279
3355
  await stopCurrent(true);
3280
3356
  binding = deps.resolveBinding();
@@ -3283,10 +3359,13 @@ export function createTelegramUpdateAdmissionLifecycleRuntime(deps) {
3283
3359
  throw new Error("Telegram update source binding changed during startup.");
3284
3360
  }
3285
3361
  const release = deps.acquireSourceReference?.(binding);
3362
+ const inputCustody = binding.journal.inputCustody;
3286
3363
  try {
3287
3364
  journal = binding.journal;
3288
3365
  worker = deps.createWorker(binding.journal, binding);
3366
+ workerInputCustody = inputCustody;
3289
3367
  settlement = createTelegramQueueAdmissionSettlementRuntime(worker);
3368
+ sourceReferenceBinding = { ...binding, journal };
3290
3369
  releaseSourceReference = release;
3291
3370
  }
3292
3371
  catch (error) {
@@ -3430,7 +3509,7 @@ export function createTelegramUpdateAdmissionLifecycleRuntime(deps) {
3430
3509
  if (!journal || !worker || !journal.cancelQueuedHandoff) {
3431
3510
  throw new Error("Telegram update journal queue handoff cancellation is not available.");
3432
3511
  }
3433
- const result = journal.cancelQueuedHandoff(input);
3512
+ const result = withRetainedSourceReference(source => source.cancelQueuedHandoff(input));
3434
3513
  worker.signal();
3435
3514
  return result;
3436
3515
  },
@@ -3468,18 +3547,12 @@ export function createTelegramUpdateAdmissionLifecycleRuntime(deps) {
3468
3547
  : undefined,
3469
3548
  ownsJournalBinding: (candidate) => journalBindingKey !== undefined && journalBindingKey === candidate,
3470
3549
  getForeignQueueOwnerLiveness: () => foreignQueueOwnerLiveness,
3471
- getJournalEntryCount() {
3472
- if (!journal || !worker) {
3473
- throw new Error("Telegram update admission worker is not active.");
3474
- }
3475
- return journal.read().entries.length;
3476
- },
3550
+ getJournalEntryCount: () => readObservedJournal().entries.length,
3477
3551
  hasPendingQueueMutationForItem(item) {
3478
3552
  return Boolean(journal &&
3479
3553
  worker &&
3480
- journal
3481
- .read()
3482
- .entries.some((entry) => entry.state !== "queued" &&
3554
+ readObservedJournal().entries.some((entry) => entry.state !== "queued" &&
3555
+ entry.preApprovalExcluded !== true &&
3483
3556
  isTelegramReactionDependencyForQueueItem(entry.update, item)));
3484
3557
  },
3485
3558
  signal: () => worker?.signal(),
@@ -3496,9 +3569,9 @@ export function createTelegramUpdateAdmissionLifecycleRuntime(deps) {
3496
3569
  },
3497
3570
  isItemReady: (item) => settlement?.isItemReady(item) ??
3498
3571
  (item.admissionReceipts?.length ?? 0) === 0,
3499
- onPromptHandedOff: (item, ctx) => settlement?.onPromptHandedOff(item, ctx),
3500
- onControlSettled: (item, ctx) => settlement?.onControlSettled(item, ctx),
3501
- onItemsDiscarded: (items, ctx) => settlement?.onItemsDiscarded(items, ctx),
3572
+ onPromptHandedOff: (item, ctx) => settlement?.onPromptHandedOff(item, ctx) ?? false,
3573
+ onControlSettled: (item, ctx) => settlement?.onControlSettled(item, ctx) ?? false,
3574
+ onItemsDiscarded: (items, ctx) => settlement?.onItemsDiscarded(items, ctx) ?? false,
3502
3575
  };
3503
3576
  }
3504
3577
  /** Compose source-bound routing and late grouped settlement under one worker. */
@@ -75,6 +75,11 @@ export type TelegramWorkspaceRetirementFence = (TelegramWorkspaceRetirementFence
75
75
  }) | (TelegramWorkspaceRetirementFenceBase & {
76
76
  phase: "deletion-issued";
77
77
  deletionIssuedAtMs: number;
78
+ }) | (TelegramWorkspaceRetirementFenceBase & {
79
+ destructiveKind?: "pressure-retirement";
80
+ phase: "deletion-rejected";
81
+ deletionIssuedAtMs: number;
82
+ rejectionConfirmedAtMs: number;
78
83
  }) | (TelegramWorkspaceRetirementFenceBase & {
79
84
  phase: "commit-ready";
80
85
  absenceConfirmedAtMs: number;
@@ -215,6 +220,10 @@ export interface TelegramWorkspaceAdmissionLedger {
215
220
  issueThreadCleanupDeletionPermit: TelegramWorkspaceAdmissionLedger["issueDeletionPermit"];
216
221
  confirmRetirementAbsence: (expected: TelegramWorkspaceRetirementFence) => TelegramWorkspaceRetirementFence;
217
222
  confirmThreadCleanupAbsence: TelegramWorkspaceAdmissionLedger["confirmRetirementAbsence"];
223
+ /** Caller must have exact transport proof of rejection, never merely target presence. */
224
+ confirmRetirementRejection: (expected: TelegramWorkspaceRetirementFence) => TelegramWorkspaceRetirementFence;
225
+ /** Caller must first durably withdraw the exact rejected intent, retaining its binding. */
226
+ completeRejectedRetirementFence: (expected: TelegramWorkspaceRetirementFence) => boolean;
218
227
  releaseUnissuedRetirementFence: (expected: TelegramWorkspaceRetirementFence) => boolean;
219
228
  releaseUnissuedThreadCleanupFence: TelegramWorkspaceAdmissionLedger["releaseUnissuedRetirementFence"];
220
229
  completeRetirementFence: (expected: TelegramWorkspaceRetirementFence) => boolean;