@llblab/pi-kit 0.19.2 → 0.21.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 (102) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +6 -6
  4. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +9 -0
  5. package/node_modules/@llblab/pi-state-flow/README.md +4 -4
  6. package/node_modules/@llblab/pi-state-flow/dist/index.d.ts +2 -2
  7. package/node_modules/@llblab/pi-state-flow/dist/index.js +2 -2
  8. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +1 -0
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +7 -3
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +41 -11
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +6 -0
  12. package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +94 -1
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +2 -5
  14. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +2 -0
  15. package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +11 -0
  16. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +4 -2
  17. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +8 -2
  18. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +6 -1
  19. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +19 -6
  20. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.d.ts +5 -0
  21. package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.js +27 -3
  22. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +2 -2
  23. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  24. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +10 -8
  25. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +2 -2
  26. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +4 -3
  27. package/node_modules/@llblab/pi-state-flow/docs/usage.md +5 -5
  28. package/node_modules/@llblab/pi-state-flow/index.ts +2 -2
  29. package/node_modules/@llblab/pi-state-flow/lib/durable.ts +8 -3
  30. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +41 -9
  31. package/node_modules/@llblab/pi-state-flow/lib/git.ts +83 -1
  32. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +2 -4
  33. package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +11 -0
  34. package/node_modules/@llblab/pi-state-flow/lib/status.ts +10 -4
  35. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +23 -6
  36. package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +29 -3
  37. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +2 -2
  38. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  39. package/node_modules/@llblab/pi-telegram/AGENTS.md +9 -7
  40. package/node_modules/@llblab/pi-telegram/BACKLOG.md +13 -32
  41. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +11 -0
  42. package/node_modules/@llblab/pi-telegram/README.md +4 -3
  43. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +2 -2
  44. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +3 -5
  45. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +1 -1
  46. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +93 -9
  47. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +9 -3
  48. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +231 -71
  49. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +4 -0
  50. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +34 -7
  51. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +63 -2
  52. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +3 -0
  53. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +13 -0
  54. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.d.ts +2 -0
  55. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.js +25 -4
  56. package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +1 -0
  57. package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +32 -12
  58. package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +10 -0
  59. package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +22 -1
  60. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +2 -2
  61. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +1 -1
  62. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +1 -2
  63. package/node_modules/@llblab/pi-telegram/dist/lib/sync.d.ts +9 -0
  64. package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +41 -1
  65. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +9 -1
  66. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +23 -3
  67. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +7 -2
  68. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +2 -0
  69. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +68 -37
  70. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +6 -4
  71. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +134 -61
  72. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +9 -0
  73. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +49 -3
  74. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +77 -6
  75. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +384 -8
  76. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.d.ts +3 -0
  77. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.js +6 -0
  78. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  79. package/node_modules/@llblab/pi-telegram/docs/architecture.md +21 -14
  80. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +31 -10
  81. package/node_modules/@llblab/pi-telegram/docs/updates.md +2 -0
  82. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +5 -7
  83. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +69 -12
  84. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +236 -94
  85. package/node_modules/@llblab/pi-telegram/lib/bus.ts +39 -9
  86. package/node_modules/@llblab/pi-telegram/lib/extension.ts +69 -2
  87. package/node_modules/@llblab/pi-telegram/lib/journal.ts +14 -0
  88. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +23 -4
  89. package/node_modules/@llblab/pi-telegram/lib/locks.ts +32 -13
  90. package/node_modules/@llblab/pi-telegram/lib/paths.ts +29 -1
  91. package/node_modules/@llblab/pi-telegram/lib/polling.ts +2 -2
  92. package/node_modules/@llblab/pi-telegram/lib/queue.ts +2 -3
  93. package/node_modules/@llblab/pi-telegram/lib/sync.ts +45 -1
  94. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +30 -2
  95. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +11 -3
  96. package/node_modules/@llblab/pi-telegram/lib/threads.ts +73 -36
  97. package/node_modules/@llblab/pi-telegram/lib/updates.ts +145 -78
  98. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +67 -4
  99. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +465 -7
  100. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +7 -0
  101. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  102. package/package.json +3 -3
@@ -34,6 +34,7 @@ import * as ThreadReconciler from "./thread-reconciler.ts";
34
34
  import {
35
35
  planTelegramWorkspaceSlotAllocation,
36
36
  TELEGRAM_WORKSPACE_SLOTS,
37
+ TelegramWorkspaceSlotUnavailableError,
37
38
  type TelegramWorkspaceSlotOccupancy,
38
39
  } from "./workspace-slots.ts";
39
40
  import {
@@ -458,6 +459,11 @@ export interface TelegramTopicTargetStore {
458
459
  isCurrent: () => boolean,
459
460
  lastSyncError: string,
460
461
  ) => Promise<boolean>;
462
+ /** Caller proves owner detachment; this does not assert Telegram Thread absence. */
463
+ detachTargetOwner: (
464
+ expected: TelegramTopicTargetRecord,
465
+ isCurrent: () => boolean,
466
+ ) => Promise<boolean>;
461
467
  list: () => TelegramTopicTargetRecord[];
462
468
  getFollowerRecoveryHintByTarget?: (
463
469
  target: TelegramTarget,
@@ -2108,20 +2114,33 @@ export function createTelegramTopicTargetStore(
2108
2114
  mutationRevision += 1;
2109
2115
  };
2110
2116
 
2111
- const persistSnapshot = (invalidation?: {
2117
+ const markWorkspaceBindingInactiveByTarget = (target: TelegramTarget, inactiveSinceMs = getNowMs()): boolean => {
2118
+ if (!Number.isFinite(inactiveSinceMs) || inactiveSinceMs < 0) return false;
2119
+ for (const [key, binding] of workspaceBindings) {
2120
+ if (!targetMatches(binding.target, target) || binding.inactiveSinceMs !== undefined) continue;
2121
+ workspaceBindings.set(key, { ...binding, inactiveSinceMs });
2122
+ markDirty();
2123
+ return true;
2124
+ }
2125
+ return false;
2126
+ };
2127
+
2128
+ const persistSnapshot = (transition?: {
2112
2129
  target: TelegramTarget;
2113
2130
  isCurrent: () => boolean;
2114
- lastSyncError: string;
2115
- }): Promise<boolean> => {
2131
+ } & (
2132
+ | { kind: "invalidate"; lastSyncError: string }
2133
+ | { kind: "detach"; owner: TelegramTopicTargetRecord }
2134
+ )): Promise<boolean> => {
2116
2135
  const persist = persistQueue.then(async () => {
2117
2136
  const path = getPath();
2118
2137
  if (loadedPath !== path && !dirty) resetForPath(path);
2119
2138
  if (options.canPersist && !options.canPersist()) {
2120
- if (!invalidation) await loadFromDisk();
2139
+ if (!transition) await loadFromDisk();
2121
2140
  return false;
2122
2141
  }
2123
2142
  if (!dirty || !loaded) await loadFromDisk();
2124
- if (invalidation && !invalidation.isCurrent()) return false;
2143
+ if (transition && !transition.isCurrent()) return false;
2125
2144
  const nowMs = getNowMs();
2126
2145
  reservations = reservations.filter(
2127
2146
  (reservation) =>
@@ -2134,6 +2153,10 @@ export function createTelegramTopicTargetStore(
2134
2153
  const currentRecords = Array.from(records.values())
2135
2154
  .filter(isPersistedThreadRecord)
2136
2155
  .map(cloneRecord);
2156
+ if (transition?.kind === "detach") {
2157
+ const owners = currentRecords.filter((record) => targetMatches(record.target, transition.target));
2158
+ if (owners.length !== 1 || !isDeepStrictEqual(normalizeRecord(owners[0]), transition.owner)) return false;
2159
+ }
2137
2160
  records = new Map(
2138
2161
  currentRecords.map((record) => [
2139
2162
  getRecordOwnerKey(record),
@@ -2176,20 +2199,32 @@ export function createTelegramTopicTargetStore(
2176
2199
  return serialized;
2177
2200
  }),
2178
2201
  };
2179
- if (invalidation) {
2180
- const record = file.threads.find((record) => targetMatches(record.target, invalidation.target));
2202
+ if (transition) {
2203
+ const record = file.threads.find((record) => targetMatches(record.target, transition.target));
2181
2204
  if (!record) return false;
2205
+ if (transition.kind === "detach") {
2206
+ const bindings = file.workspaceBindings.filter((binding) => targetMatches(binding.target, record.target));
2207
+ if (bindings.length !== 1 || !/^[A-Z]$/.test(record.slot ?? "") ||
2208
+ bindings[0]!.slot !== record.slot) return false;
2209
+ }
2182
2210
  file.threads = file.threads.filter((candidate) => candidate !== record);
2183
- file.syncObservations = file.syncObservations.filter((observation) => !targetMatches(observation.target, record.target));
2184
- file.syncObservations.push({
2185
- target: { ...record.target },
2186
- syncStatus: "deleted",
2187
- observedAtMs: nowMs,
2188
- ...(record.instanceId ? { instanceId: record.instanceId } : {}),
2189
- ...(record.slot ? { slot: record.slot } : {}),
2190
- lastSyncError: invalidation.lastSyncError,
2191
- lastReconcileAction: "mark-stale",
2192
- });
2211
+ for (const binding of file.workspaceBindings) {
2212
+ if (targetMatches(binding.target, transition.target) && binding.inactiveSinceMs === undefined) {
2213
+ binding.inactiveSinceMs = nowMs;
2214
+ }
2215
+ }
2216
+ if (transition.kind === "invalidate") {
2217
+ file.syncObservations = file.syncObservations.filter((observation) => !targetMatches(observation.target, record.target));
2218
+ file.syncObservations.push({
2219
+ target: { ...record.target },
2220
+ syncStatus: "deleted",
2221
+ observedAtMs: nowMs,
2222
+ ...(record.instanceId ? { instanceId: record.instanceId } : {}),
2223
+ ...(record.slot ? { slot: record.slot } : {}),
2224
+ lastSyncError: transition.lastSyncError,
2225
+ lastReconcileAction: "mark-stale",
2226
+ });
2227
+ }
2193
2228
  }
2194
2229
  let persistedSemanticSnapshot: Record<string, unknown> | undefined;
2195
2230
  try {
@@ -2201,7 +2236,7 @@ export function createTelegramTopicTargetStore(
2201
2236
  }
2202
2237
  // Normalize optional fields to wire JSON; object key order is not a state change.
2203
2238
  if (
2204
- isDeepStrictEqual(
2239
+ !transition && isDeepStrictEqual(
2205
2240
  persistedSemanticSnapshot,
2206
2241
  getTelegramStateSemanticSnapshot(JSON.parse(JSON.stringify(file))),
2207
2242
  ) &&
@@ -2221,17 +2256,20 @@ export function createTelegramTopicTargetStore(
2221
2256
  });
2222
2257
  await chmod(tempPath, 0o600);
2223
2258
  try {
2224
- if (invalidation) {
2259
+ if (transition) {
2225
2260
  let applied = false;
2226
2261
  const commit = () => {
2227
2262
  if (getPath() !== path || mutationRevision !== persistedRevision ||
2228
- statusRevision !== persistedStatusRevision || !invalidation.isCurrent()) return;
2263
+ statusRevision !== persistedStatusRevision || !transition.isCurrent()) return;
2229
2264
  // Fence and rename share one synchronous commit boundary. No stale
2230
- // invalidation enters the live projection before durable commit.
2265
+ // transition enters the live projection before durable commit.
2231
2266
  renameSync(tempPath, path);
2232
2267
  loadedPath = path;
2233
- records = new Map(Array.from(records).filter(([, record]) => !targetMatches(record.target, invalidation.target)));
2268
+ records = new Map(Array.from(records).filter(([, record]) => !targetMatches(record.target, transition.target)));
2234
2269
  syncObservations = file.syncObservations;
2270
+ workspaceBindings = new Map(file.workspaceBindings.map((binding) => [
2271
+ getWorkspaceBindingMapKey(binding), cloneWorkspaceBinding(binding),
2272
+ ]));
2235
2273
  mutationRevision += 1;
2236
2274
  dirty = false;
2237
2275
  applied = true;
@@ -2283,7 +2321,12 @@ export function createTelegramTopicTargetStore(
2283
2321
  await persistSnapshot();
2284
2322
  },
2285
2323
  invalidateTarget(target, isCurrent, lastSyncError) {
2286
- return persistSnapshot({ target, isCurrent, lastSyncError });
2324
+ return persistSnapshot({ kind: "invalidate", target, isCurrent, lastSyncError });
2325
+ },
2326
+ detachTargetOwner(expected, isCurrent) {
2327
+ const owner = normalizeRecord(expected);
2328
+ if (!owner) return Promise.resolve(false);
2329
+ return persistSnapshot({ kind: "detach", owner, target: owner.target, isCurrent });
2287
2330
  },
2288
2331
  list() {
2289
2332
  return Array.from(records.values()).map(cloneRecord);
@@ -2838,16 +2881,7 @@ export function createTelegramTopicTargetStore(
2838
2881
  markDirty();
2839
2882
  return true;
2840
2883
  },
2841
- markWorkspaceBindingInactiveByTarget(target, inactiveSinceMs = getNowMs()) {
2842
- if (!Number.isFinite(inactiveSinceMs) || inactiveSinceMs < 0) return false;
2843
- for (const [key, binding] of workspaceBindings) {
2844
- if (!targetMatches(binding.target, target) || binding.inactiveSinceMs !== undefined) continue;
2845
- workspaceBindings.set(key, { ...binding, inactiveSinceMs });
2846
- markDirty();
2847
- return true;
2848
- }
2849
- return false;
2850
- },
2884
+ markWorkspaceBindingInactiveByTarget,
2851
2885
  markWorkspaceBindingActiveByTarget(target) {
2852
2886
  if (hasWorkspaceRetirementConflict({ target })) return false;
2853
2887
  for (const [key, binding] of workspaceBindings) {
@@ -3274,7 +3308,10 @@ export function createTelegramTopicTargetStore(
3274
3308
  entry.target && targetMatches(entry.target, target),
3275
3309
  ) : undefined;
3276
3310
  const source = record ?? pending;
3277
- if (!source?.target) return false;
3311
+ // Confirmed absence ends this binding's active target lifetime, even after
3312
+ // an earlier stale observation already removed its live record.
3313
+ const inactive = syncStatus === "deleted" && markWorkspaceBindingInactiveByTarget(target);
3314
+ if (!source?.target) return inactive;
3278
3315
  syncObservations = syncObservations.filter((entry) => !targetMatches(entry.target, target));
3279
3316
  syncObservations.push({
3280
3317
  target: { ...source.target }, syncStatus, observedAtMs: getNowMs(),
@@ -4886,7 +4923,7 @@ export function createTelegramTopicTargetProvisioner(
4886
4923
  if (existing && isCurrentThreadRecord(existing)) {
4887
4924
  const slot = existing.slot ?? deps.store.allocateSlot(request.profileKey);
4888
4925
  if (!slot) {
4889
- throw new Error("Telegram Workspace slot reservation is unavailable.");
4926
+ throw new TelegramWorkspaceSlotUnavailableError();
4890
4927
  }
4891
4928
  const occupied = listOccupiedTelegramThreadIdentities({
4892
4929
  records: deps.store.list(),
@@ -5015,7 +5052,7 @@ export function createTelegramTopicTargetProvisioner(
5015
5052
  request.workspaceBindingKey,
5016
5053
  );
5017
5054
  if (!slot) {
5018
- throw new Error("Telegram Workspace slot reservation is unavailable.");
5055
+ throw new TelegramWorkspaceSlotUnavailableError();
5019
5056
  }
5020
5057
  const uniqueCandidate =
5021
5058
  candidateThreadName &&
@@ -698,10 +698,9 @@ export function buildTelegramUpdateFlowAction<
698
698
  NonNullable<TUpdate["message"] | TUpdate["edited_message"]>,
699
699
  NonNullable<TUpdate["guest_message"]>
700
700
  > {
701
- const deletedMessageIds = extractDeletedTelegramMessageIds(update);
702
- if (deletedMessageIds.length > 0) {
703
- return { kind: "deleted", messageIds: deletedMessageIds };
704
- }
701
+ // Business chats are independent from bot chats even when chat/message IDs coincide.
702
+ // Raw handlers may own that namespace; the default DM runtime has no deletion authority.
703
+ if (update.deleted_business_messages !== undefined) return { kind: "ignore" };
705
704
  if (update.message_reaction) {
706
705
  return { kind: "reaction", reactionUpdate: update.message_reaction };
707
706
  }
@@ -1739,6 +1738,11 @@ export interface TelegramUpdateWorkerJournalSnapshot {
1739
1738
 
1740
1739
  export interface TelegramUpdateWorkerJournalPort {
1741
1740
  read: () => TelegramUpdateWorkerJournalSnapshot;
1741
+ /** Optional strict observation; it must not recover, repair or grant new receipt authority. */
1742
+ isQueueReceiptCurrent?: (
1743
+ receipt: TelegramQueueAdmissionReceiptLike,
1744
+ owner: TelegramUpdateJournalQueueOwner,
1745
+ ) => boolean;
1742
1746
  markQueued: (receipt: {
1743
1747
  queueKind: "prompt" | "control";
1744
1748
  receiptId: string;
@@ -1858,7 +1862,7 @@ export interface TelegramUpdateWorkerRuntime<TContext> {
1858
1862
  receipts: readonly TelegramQueueAdmissionReceiptLike[];
1859
1863
  ctx: TContext;
1860
1864
  reason: TelegramQueueReceiptCompletionReason;
1861
- }) => void;
1865
+ }) => boolean;
1862
1866
  stop: () => Promise<void>;
1863
1867
  waitForDrain: () => Promise<void>;
1864
1868
  getState: () => TelegramUpdateWorkerStateSnapshot;
@@ -2268,6 +2272,23 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
2268
2272
  return true;
2269
2273
  };
2270
2274
 
2275
+ const getCurrentQueueReceipt = (receipt: TelegramQueueAdmissionReceiptLike) => {
2276
+ const committed = committedQueueReceipts.get(receipt.receiptId);
2277
+ if (!committed || !areTelegramQueueAdmissionReceiptsEqual(
2278
+ committed.receipt, normalizeQueueReceipt(receipt),
2279
+ )) return undefined;
2280
+ try {
2281
+ if (deps.journal.isQueueReceiptCurrent &&
2282
+ deps.journal.isQueueReceiptCurrent(committed.receipt, committed.queueOwner) !== true) {
2283
+ return undefined;
2284
+ }
2285
+ } catch (error) {
2286
+ recordRuntimeEvent(error, { phase: "queue-receipt-observation" });
2287
+ return undefined;
2288
+ }
2289
+ return committed;
2290
+ };
2291
+
2271
2292
  const clearRetryTimer = (): void => {
2272
2293
  if (retryTimer !== undefined) cancelRetry(retryTimer);
2273
2294
  retryTimer = undefined;
@@ -3023,11 +3044,9 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
3023
3044
  !expectedOwner.controller.signal.aborted
3024
3045
  ) {
3025
3046
  pendingSignal = false;
3026
- const result = await drain(expectedOwner);
3027
- if (result !== "idle") {
3028
- pendingSignal = false;
3029
- return;
3030
- }
3047
+ // Keep newer wakes in this run so waitForDrain also covers them. A failure
3048
+ // observed after signal() still keeps its latch; this loop never clears it.
3049
+ await drain(expectedOwner);
3031
3050
  }
3032
3051
  };
3033
3052
  const operation = run();
@@ -3265,25 +3284,10 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
3265
3284
  }
3266
3285
  transition("queued", input.updateId);
3267
3286
  },
3268
- isQueueReceiptCommitted(receipt) {
3269
- const committed = committedQueueReceipts.get(receipt.receiptId);
3270
- return (
3271
- committed !== undefined &&
3272
- areTelegramQueueAdmissionReceiptsEqual(
3273
- committed.receipt,
3274
- normalizeQueueReceipt(receipt),
3275
- )
3276
- );
3277
- },
3287
+ isQueueReceiptCommitted: receipt => getCurrentQueueReceipt(receipt) !== undefined,
3278
3288
  getQueueReceiptOwner(receipt) {
3279
- const committed = committedQueueReceipts.get(receipt.receiptId);
3280
- return committed &&
3281
- areTelegramQueueAdmissionReceiptsEqual(
3282
- committed.receipt,
3283
- normalizeQueueReceipt(receipt),
3284
- )
3285
- ? { ...committed.queueOwner }
3286
- : undefined;
3289
+ const committed = getCurrentQueueReceipt(receipt);
3290
+ return committed ? { ...committed.queueOwner } : undefined;
3287
3291
  },
3288
3292
  completeQueueReceipts(input) {
3289
3293
  const expectedOwner = owner;
@@ -3292,9 +3296,9 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
3292
3296
  expectedOwner.controller.signal.aborted ||
3293
3297
  !(deps.isContextCurrent?.(input.ctx) ?? expectedOwner.ctx === input.ctx)
3294
3298
  ) {
3295
- return;
3299
+ return false;
3296
3300
  }
3297
- if (input.receipts.length === 0) return;
3301
+ if (input.receipts.length === 0) return true;
3298
3302
  const normalizedReceipts = input.receipts.map(normalizeQueueReceipt);
3299
3303
  const receiptIds = new Set<string>();
3300
3304
  const sourceUpdateIds = new Set<number>();
@@ -3306,7 +3310,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
3306
3310
  }> = [];
3307
3311
  for (const receipt of normalizedReceipts) {
3308
3312
  const committed = committedQueueReceipts.get(receipt.receiptId);
3309
- if (!committed) continue;
3313
+ if (!committed) return false;
3310
3314
  if (
3311
3315
  receiptIds.has(receipt.receiptId) ||
3312
3316
  !areTelegramQueueAdmissionReceiptsEqual(
@@ -3328,7 +3332,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
3328
3332
  `Telegram ${input.reason} requested invalid queue receipt ${receipt.receiptId}.`,
3329
3333
  ),
3330
3334
  );
3331
- return;
3335
+ return false;
3332
3336
  }
3333
3337
  receiptIds.add(receipt.receiptId);
3334
3338
  queuedCompletions.push({
@@ -3339,7 +3343,6 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
3339
3343
  sourceUpdateIds.add(updateId);
3340
3344
  }
3341
3345
  }
3342
- if (receiptIds.size === 0) return;
3343
3346
  let removedUpdateIds: readonly number[];
3344
3347
  try {
3345
3348
  removedUpdateIds = deps.journal.completeQueued(
@@ -3351,7 +3354,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
3351
3354
  "queue-receipt-completion",
3352
3355
  error,
3353
3356
  );
3354
- return;
3357
+ return false;
3355
3358
  }
3356
3359
  const removed = new Set(removedUpdateIds);
3357
3360
  if (
@@ -3365,8 +3368,11 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
3365
3368
  `Telegram ${input.reason} did not complete every receipt source.`,
3366
3369
  ),
3367
3370
  );
3368
- return;
3371
+ return false;
3369
3372
  }
3373
+ input.receipts.forEach((receipt, index) => {
3374
+ acknowledgedQueueReceiptCompletions.set(receipt, normalizedReceipts[index]!);
3375
+ });
3370
3376
  for (const updateId of sourceUpdateIds) claims.delete(updateId);
3371
3377
  for (const receiptId of receiptIds) {
3372
3378
  committedQueueReceipts.delete(receiptId);
@@ -3385,6 +3391,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
3385
3391
  notifyStateChange();
3386
3392
  pendingSignal = true;
3387
3393
  launchDrain();
3394
+ return true;
3388
3395
  },
3389
3396
  async stop() {
3390
3397
  const expectedOwner = owner;
@@ -3676,9 +3683,17 @@ export function createTelegramInputCustodyWorkerJournalPort(
3676
3683
  };
3677
3684
  return {
3678
3685
  read: () => store.read() as unknown as TelegramUpdateWorkerJournalSnapshot,
3686
+ isQueueReceiptCurrent(receipt, owner) {
3687
+ const entries = store.read().entries.filter(entry => entry.queueReceiptId === receipt.receiptId);
3688
+ return receipt.sourceUpdateIds.length > 0 && entries.length === receipt.sourceUpdateIds.length &&
3689
+ entries.every((entry, index) => entry.updateId === receipt.sourceUpdateIds[index] &&
3690
+ entry.state === "queued" && entry.queueKind === receipt.queueKind && !entry.queueHandoff &&
3691
+ entry.queueOwner !== undefined && areTelegramUpdateJournalQueueOwnersEqual(entry.queueOwner, owner));
3692
+ },
3679
3693
  markQueued: legacyMutation,
3680
3694
  markExecutionFailure: legacyMutation,
3681
- removeCompleted: legacyMutation,
3695
+ // The common drain batches excluded IDs here; the store rejects all non-vetoed input atomically.
3696
+ removeCompleted: updateIds => store.removeExcluded(updateIds),
3682
3697
  completeQueued: receipts => store.completeQueued(receipts),
3683
3698
  inputCustody: {
3684
3699
  acquireInput: store.acquireInput,
@@ -4511,13 +4526,20 @@ export function createTelegramInputCustodyLifecycleBindingResolver(deps: {
4511
4526
  journal: TelegramInputJournalStore } | undefined;
4512
4527
  getRecipientBindingKey(): string | undefined;
4513
4528
  }): () => TelegramUpdateAdmissionLifecycleJournalBinding | undefined {
4529
+ // Stable per store, not per descriptor: a renewed source has new captured worker ports.
4530
+ const ports = new WeakMap<TelegramInputJournalStore,
4531
+ ReturnType<typeof createTelegramInputCustodyWorkerJournalPort>>();
4514
4532
  return () => {
4515
4533
  if (!deps.isEnabled()) return undefined;
4516
4534
  const source = deps.resolveInputJournal();
4517
4535
  const recipientBindingKey = deps.getRecipientBindingKey()?.trim();
4518
4536
  if (!source || !source.runtimeKey || !source.recoveryKey || !recipientBindingKey)
4519
4537
  return undefined;
4520
- const port = createTelegramInputCustodyWorkerJournalPort(source.journal);
4538
+ let port = ports.get(source.journal);
4539
+ if (!port) {
4540
+ port = createTelegramInputCustodyWorkerJournalPort(source.journal);
4541
+ ports.set(source.journal, port);
4542
+ }
4521
4543
  return { runtimeKey: JSON.stringify({ source: source.runtimeKey, recipientBindingKey }),
4522
4544
  recoveryKey: source.recoveryKey,
4523
4545
  recipientBindingKey, journal: { ...port, appendBatch: source.journal.appendBatch,
@@ -4681,6 +4703,7 @@ export function createTelegramUpdateAdmissionHandle<
4681
4703
  try {
4682
4704
  execution.assertCurrent();
4683
4705
  await deps.defaultHandle(boundUpdate, ctx, execution);
4706
+ execution.assertCurrent();
4684
4707
  } finally {
4685
4708
  immediate = false;
4686
4709
  }
@@ -4720,6 +4743,16 @@ export interface TelegramQueueAdmissionItemLike {
4720
4743
  admissionReceipts?: readonly TelegramQueueAdmissionReceiptLike[];
4721
4744
  }
4722
4745
 
4746
+ // A dispatched prompt can remain in memory until agent_start. Remember only acknowledged
4747
+ // removal of these exact receipt objects for later local discard, never for dispatch/replay.
4748
+ const acknowledgedQueueReceiptCompletions = new WeakMap<
4749
+ TelegramQueueAdmissionReceiptLike, TelegramQueueAdmissionReceiptLike
4750
+ >();
4751
+ const isQueueReceiptCompletionAcknowledged = (receipt: TelegramQueueAdmissionReceiptLike): boolean => {
4752
+ const acknowledged = acknowledgedQueueReceiptCompletions.get(receipt);
4753
+ return acknowledged !== undefined && areTelegramQueueAdmissionReceiptsEqual(acknowledged, receipt);
4754
+ };
4755
+
4723
4756
  export interface TelegramQueueAdmissionSettlementRuntime<TContext> {
4724
4757
  isItemReady: (item: TelegramQueueAdmissionItemLike) => boolean;
4725
4758
  getQueueReceiptOwner: (
@@ -4728,26 +4761,51 @@ export interface TelegramQueueAdmissionSettlementRuntime<TContext> {
4728
4761
  onPromptHandedOff: (
4729
4762
  item: TelegramQueueAdmissionItemLike,
4730
4763
  ctx: TContext,
4731
- ) => void;
4764
+ ) => boolean;
4732
4765
  onControlSettled: (
4733
4766
  item: TelegramQueueAdmissionItemLike,
4734
4767
  ctx: TContext,
4735
- ) => void;
4768
+ ) => boolean;
4736
4769
  onItemsDiscarded: (
4737
4770
  items: readonly TelegramQueueAdmissionItemLike[],
4738
4771
  ctx: TContext,
4739
- ) => void;
4772
+ ) => boolean;
4740
4773
  }
4741
4774
 
4742
4775
  export function createTelegramQueueAdmissionSettlementMuxRuntime<TContext>(
4743
4776
  runtimes: readonly TelegramQueueAdmissionSettlementRuntime<TContext>[],
4744
4777
  ): TelegramQueueAdmissionSettlementRuntime<TContext> {
4745
- const settle = (
4746
- operation: (
4747
- runtime: TelegramQueueAdmissionSettlementRuntime<TContext>,
4748
- ) => void,
4749
- ): void => {
4750
- for (const runtime of runtimes) operation(runtime);
4778
+ // Plan the whole request before mutation; unready is not an acknowledgement of completion.
4779
+ const complete = (
4780
+ items: readonly TelegramQueueAdmissionItemLike[],
4781
+ ctx: TContext,
4782
+ kind: "prompt" | "control" | "discard",
4783
+ ): boolean => {
4784
+ const plan = new Map<TelegramQueueAdmissionSettlementRuntime<TContext>, TelegramQueueAdmissionReceiptLike[]>();
4785
+ for (const receipt of items.flatMap(item => item.admissionReceipts ?? [])) {
4786
+ if (kind === "discard" && isQueueReceiptCompletionAcknowledged(receipt)) continue;
4787
+ const candidates = runtimes.filter(runtime => runtime.isItemReady({ admissionReceipts: [receipt] }));
4788
+ const selected = candidates[0];
4789
+ if (!selected) return false;
4790
+ if (candidates.length > 1) {
4791
+ const owner = selected.getQueueReceiptOwner(receipt);
4792
+ if (!owner || candidates.some(runtime => {
4793
+ const candidate = runtime.getQueueReceiptOwner(receipt);
4794
+ return !candidate || !areTelegramUpdateJournalQueueOwnersEqual(owner, candidate);
4795
+ })) return false;
4796
+ }
4797
+ const receipts = plan.get(selected) ?? [];
4798
+ receipts.push(receipt);
4799
+ plan.set(selected, receipts);
4800
+ }
4801
+ for (const [runtime, receipts] of plan) {
4802
+ const item = { admissionReceipts: receipts };
4803
+ const completed = kind === "discard" ? runtime.onItemsDiscarded([item], ctx)
4804
+ : kind === "prompt" ? runtime.onPromptHandedOff(item, ctx)
4805
+ : runtime.onControlSettled(item, ctx);
4806
+ if (completed !== true) return false;
4807
+ }
4808
+ return true;
4751
4809
  };
4752
4810
  return {
4753
4811
  isItemReady: (item) =>
@@ -4773,12 +4831,9 @@ export function createTelegramQueueAdmissionSettlementMuxRuntime<TContext>(
4773
4831
  }
4774
4832
  return owner ? { ...owner } : undefined;
4775
4833
  },
4776
- onPromptHandedOff: (item, ctx) =>
4777
- settle((runtime) => runtime.onPromptHandedOff(item, ctx)),
4778
- onControlSettled: (item, ctx) =>
4779
- settle((runtime) => runtime.onControlSettled(item, ctx)),
4780
- onItemsDiscarded: (items, ctx) =>
4781
- settle((runtime) => runtime.onItemsDiscarded(items, ctx)),
4834
+ onPromptHandedOff: (item, ctx) => complete([item], ctx, "prompt"),
4835
+ onControlSettled: (item, ctx) => complete([item], ctx, "control"),
4836
+ onItemsDiscarded: (items, ctx) => complete(items, ctx, "discard"),
4782
4837
  };
4783
4838
  }
4784
4839
 
@@ -4789,12 +4844,14 @@ export function createTelegramQueueAdmissionSettlementRuntime<TContext>(
4789
4844
  items: readonly TelegramQueueAdmissionItemLike[],
4790
4845
  ctx: TContext,
4791
4846
  reason: TelegramQueueReceiptCompletionReason,
4792
- ): void => {
4847
+ ): boolean => {
4793
4848
  const receipts: TelegramQueueAdmissionReceiptLike[] = [];
4794
4849
  for (const item of items) {
4795
- if (item.admissionReceipts) receipts.push(...item.admissionReceipts);
4850
+ for (const receipt of item.admissionReceipts ?? []) {
4851
+ if (reason !== "discard" || !isQueueReceiptCompletionAcknowledged(receipt)) receipts.push(receipt);
4852
+ }
4796
4853
  }
4797
- worker.completeQueueReceipts({ receipts, ctx, reason });
4854
+ return receipts.length === 0 || worker.completeQueueReceipts({ receipts, ctx, reason });
4798
4855
  };
4799
4856
  return {
4800
4857
  isItemReady: (item) =>
@@ -5322,10 +5379,6 @@ export function createTelegramQueueHandoffReconciler<TContext>(
5322
5379
  const recipientJournalBindingKey =
5323
5380
  deps.createRecipientJournalBindingKey(recipient);
5324
5381
  if (!recipientJournalBindingKey) continue;
5325
- const recipientItem = structuredClone(item);
5326
- recipientItem.admissionReceipts = [
5327
- { ...receipt, journalBindingKey: recipientJournalBindingKey },
5328
- ];
5329
5382
  const expectedOwner = deps.getReceiptOwner(receipt);
5330
5383
  const lifecycle = deps.getLifecycleForReceipt(receipt);
5331
5384
  if (
@@ -5350,11 +5403,11 @@ export function createTelegramQueueHandoffReconciler<TContext>(
5350
5403
  },
5351
5404
  handoffToken,
5352
5405
  lifecycle,
5353
- stageRemote: async () => {
5354
- const payload = createTelegramQueueHandoff({
5355
- handoffToken,
5356
- item: recipientItem,
5357
- }).payload;
5406
+ stageRemote: async ({ payload: sourcePayload }) => {
5407
+ const payload = {
5408
+ ...sourcePayload,
5409
+ admissionReceipts: [{ ...sourcePayload.admissionReceipts[0]!, journalBindingKey: recipientJournalBindingKey }],
5410
+ };
5358
5411
  if (followerRegistered) {
5359
5412
  return deps.stageThroughFollower({
5360
5413
  recipient,
@@ -5637,11 +5690,25 @@ export function createTelegramUpdateAdmissionLifecycleRuntime<TContext>(
5637
5690
  let activeRuntimeKey: string | undefined;
5638
5691
  let journal: TelegramUpdateAdmissionLifecycleJournalBinding["journal"] | undefined;
5639
5692
  let worker: TelegramUpdateWorkerRuntime<TContext> | undefined;
5693
+ let workerInputCustody: TelegramCustodyExecutionJournal | undefined;
5640
5694
  let settlement: TelegramQueueAdmissionSettlementRuntime<TContext> | undefined;
5641
5695
  let journalBindingKey: string | undefined;
5642
5696
  let operation: Promise<void> = Promise.resolve();
5643
5697
  let foreignQueueOwnerLiveness: TelegramProcessLiveness | undefined;
5644
5698
  let releaseSourceReference: (() => void) | undefined;
5699
+ let sourceReferenceBinding: TelegramUpdateAdmissionLifecycleJournalBinding | undefined;
5700
+
5701
+ const withRetainedSourceReference = <T>(
5702
+ operation: (source: TelegramUpdateAdmissionLifecycleJournalBinding["journal"]) => T,
5703
+ ): T => {
5704
+ if (!journal || !worker) throw new Error("Telegram update admission worker is not active.");
5705
+ // Synchronous retained-source access needs a reference, not renewed execution authority.
5706
+ const release = !releaseSourceReference && sourceReferenceBinding
5707
+ ? deps.acquireSourceReference?.(sourceReferenceBinding) : undefined;
5708
+ try { return operation(journal); } finally { release?.(); }
5709
+ };
5710
+ const readObservedJournal = (): TelegramUpdateWorkerJournalSnapshot =>
5711
+ withRetainedSourceReference(source => source.read());
5645
5712
 
5646
5713
  const stopCurrent = async (forget: boolean): Promise<void> => {
5647
5714
  await worker?.stop();
@@ -5652,8 +5719,10 @@ export function createTelegramUpdateAdmissionLifecycleRuntime<TContext>(
5652
5719
  { phase: "source-reference-release" });
5653
5720
  }
5654
5721
  if (!forget) return;
5722
+ sourceReferenceBinding = undefined;
5655
5723
  journal = undefined;
5656
5724
  worker = undefined;
5725
+ workerInputCustody = undefined;
5657
5726
  settlement = undefined;
5658
5727
  journalBindingKey = undefined;
5659
5728
  activeRuntimeKey = undefined;
@@ -5665,7 +5734,8 @@ export function createTelegramUpdateAdmissionLifecycleRuntime<TContext>(
5665
5734
  await stopCurrent(true);
5666
5735
  return;
5667
5736
  }
5668
- if (replace || !worker || activeRuntimeKey !== binding.runtimeKey) {
5737
+ if (replace || !worker || activeRuntimeKey !== binding.runtimeKey ||
5738
+ workerInputCustody !== binding.journal.inputCustody) {
5669
5739
  const { runtimeKey, recoveryKey } = binding;
5670
5740
  await stopCurrent(true);
5671
5741
  binding = deps.resolveBinding();
@@ -5674,10 +5744,13 @@ export function createTelegramUpdateAdmissionLifecycleRuntime<TContext>(
5674
5744
  throw new Error("Telegram update source binding changed during startup.");
5675
5745
  }
5676
5746
  const release = deps.acquireSourceReference?.(binding);
5747
+ const inputCustody = binding.journal.inputCustody;
5677
5748
  try {
5678
5749
  journal = binding.journal;
5679
5750
  worker = deps.createWorker(binding.journal, binding);
5751
+ workerInputCustody = inputCustody;
5680
5752
  settlement = createTelegramQueueAdmissionSettlementRuntime(worker);
5753
+ sourceReferenceBinding = { ...binding, journal };
5681
5754
  releaseSourceReference = release;
5682
5755
  } catch (error) {
5683
5756
  release?.();
@@ -5843,7 +5916,7 @@ export function createTelegramUpdateAdmissionLifecycleRuntime<TContext>(
5843
5916
  "Telegram update journal queue handoff cancellation is not available.",
5844
5917
  );
5845
5918
  }
5846
- const result = journal.cancelQueuedHandoff(input);
5919
+ const result = withRetainedSourceReference(source => source.cancelQueuedHandoff!(input));
5847
5920
  worker.signal();
5848
5921
  return result;
5849
5922
  },
@@ -5897,21 +5970,15 @@ export function createTelegramUpdateAdmissionLifecycleRuntime<TContext>(
5897
5970
  ownsJournalBinding: (candidate) =>
5898
5971
  journalBindingKey !== undefined && journalBindingKey === candidate,
5899
5972
  getForeignQueueOwnerLiveness: () => foreignQueueOwnerLiveness,
5900
- getJournalEntryCount() {
5901
- if (!journal || !worker) {
5902
- throw new Error("Telegram update admission worker is not active.");
5903
- }
5904
- return journal.read().entries.length;
5905
- },
5973
+ getJournalEntryCount: () => readObservedJournal().entries.length,
5906
5974
  hasPendingQueueMutationForItem(item) {
5907
5975
  return Boolean(
5908
5976
  journal &&
5909
5977
  worker &&
5910
- journal
5911
- .read()
5912
- .entries.some(
5978
+ readObservedJournal().entries.some(
5913
5979
  (entry) =>
5914
5980
  entry.state !== "queued" &&
5981
+ entry.preApprovalExcluded !== true &&
5915
5982
  isTelegramReactionDependencyForQueueItem(
5916
5983
  entry.update,
5917
5984
  item,
@@ -5934,11 +6001,11 @@ export function createTelegramUpdateAdmissionLifecycleRuntime<TContext>(
5934
6001
  settlement?.isItemReady(item) ??
5935
6002
  (item.admissionReceipts?.length ?? 0) === 0,
5936
6003
  onPromptHandedOff: (item, ctx) =>
5937
- settlement?.onPromptHandedOff(item, ctx),
6004
+ settlement?.onPromptHandedOff(item, ctx) ?? false,
5938
6005
  onControlSettled: (item, ctx) =>
5939
- settlement?.onControlSettled(item, ctx),
6006
+ settlement?.onControlSettled(item, ctx) ?? false,
5940
6007
  onItemsDiscarded: (items, ctx) =>
5941
- settlement?.onItemsDiscarded(items, ctx),
6008
+ settlement?.onItemsDiscarded(items, ctx) ?? false,
5942
6009
  };
5943
6010
  }
5944
6011