@gajae-code/coding-agent 0.15.5 → 0.16.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 (272) hide show
  1. package/CHANGELOG.md +85 -1
  2. package/dist/types/async/job-manager.d.ts +83 -0
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
  5. package/dist/types/cli/model-presets-cli.d.ts +9 -0
  6. package/dist/types/cli/root-flags.d.ts +14 -0
  7. package/dist/types/cli/update-cli.d.ts +15 -0
  8. package/dist/types/cli.d.ts +7 -0
  9. package/dist/types/commands/auth-gateway.d.ts +3 -0
  10. package/dist/types/commands/gc.d.ts +13 -0
  11. package/dist/types/commands/launch.d.ts +10 -0
  12. package/dist/types/commands/model-presets.d.ts +21 -0
  13. package/dist/types/config/autorouting-tier-map.d.ts +120 -0
  14. package/dist/types/config/file-lock.d.ts +54 -2
  15. package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
  16. package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
  17. package/dist/types/config/keybindings.d.ts +3 -2
  18. package/dist/types/config/model-preset-registry.d.ts +368 -0
  19. package/dist/types/config/model-profile-activation.d.ts +39 -0
  20. package/dist/types/config/model-profiles.d.ts +5 -3
  21. package/dist/types/config/model-registry.d.ts +17 -5
  22. package/dist/types/config/settings-schema.d.ts +19 -1
  23. package/dist/types/config/settings.d.ts +2 -0
  24. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
  25. package/dist/types/coordinator-mcp/projection-scan.d.ts +54 -0
  26. package/dist/types/crash/record-loader.d.ts +15 -0
  27. package/dist/types/extensibility/extensions/types.d.ts +2 -5
  28. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  29. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  30. package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
  31. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
  32. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  33. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +72 -1
  34. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
  36. package/dist/types/index.d.ts +1 -0
  37. package/dist/types/main.d.ts +3 -1
  38. package/dist/types/master-mode/context.d.ts +21 -0
  39. package/dist/types/master-mode/first-request.d.ts +34 -0
  40. package/dist/types/master-mode/snapshot.d.ts +32 -0
  41. package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
  42. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  43. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  44. package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
  45. package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
  46. package/dist/types/modes/components/status-line/segments.d.ts +10 -0
  47. package/dist/types/modes/jobs-observer.d.ts +36 -0
  48. package/dist/types/modes/print-mode.d.ts +17 -0
  49. package/dist/types/modes/ui-language.d.ts +7 -1
  50. package/dist/types/sdk/broker/broker.d.ts +80 -1
  51. package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
  52. package/dist/types/sdk/broker/index.d.ts +3 -0
  53. package/dist/types/sdk/broker/lifecycle.d.ts +26 -0
  54. package/dist/types/sdk/broker/master-capability.d.ts +10 -0
  55. package/dist/types/sdk/broker/session-index.d.ts +40 -11
  56. package/dist/types/sdk/broker/session-scope.d.ts +72 -0
  57. package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
  58. package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
  59. package/dist/types/sdk/broker/state-version.d.ts +4 -1
  60. package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
  61. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
  62. package/dist/types/sdk/bus/index.d.ts +6 -0
  63. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  64. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  65. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  66. package/dist/types/sdk/cli/index.d.ts +1 -0
  67. package/dist/types/sdk/cli/master-cli.d.ts +63 -0
  68. package/dist/types/sdk/cli/rows.d.ts +4 -3
  69. package/dist/types/sdk/cli/session-cli.d.ts +18 -2
  70. package/dist/types/sdk/client/client.d.ts +3 -2
  71. package/dist/types/sdk/client/discovery.d.ts +1 -0
  72. package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
  73. package/dist/types/sdk/host/control/index.d.ts +1 -1
  74. package/dist/types/sdk/host/control/operations.d.ts +2 -0
  75. package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
  76. package/dist/types/sdk/host/host.d.ts +10 -0
  77. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  78. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  79. package/dist/types/sdk/host/session-runtime.d.ts +67 -1
  80. package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
  81. package/dist/types/sdk/lifecycle/service.d.ts +15 -3
  82. package/dist/types/sdk/lifecycle-session.d.ts +2 -0
  83. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  84. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  85. package/dist/types/sdk/router/session-router.d.ts +11 -0
  86. package/dist/types/sdk/session.d.ts +16 -0
  87. package/dist/types/sdk/startup-capability.d.ts +2 -1
  88. package/dist/types/session/account-inventory.d.ts +1 -0
  89. package/dist/types/session/agent-session.d.ts +24 -7
  90. package/dist/types/session/credential-store-errors.d.ts +1 -0
  91. package/dist/types/session/fold-coordinator.d.ts +201 -0
  92. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  93. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  94. package/dist/types/session/session-manager.d.ts +65 -0
  95. package/dist/types/session/yield-queue.d.ts +5 -4
  96. package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
  97. package/dist/types/telemetry/control.d.ts +11 -0
  98. package/dist/types/telemetry/events.d.ts +20 -0
  99. package/dist/types/telemetry/index.d.ts +3 -0
  100. package/dist/types/telemetry/transport.d.ts +22 -0
  101. package/dist/types/tools/bash-interactive.d.ts +23 -0
  102. package/dist/types/tools/bash.d.ts +4 -0
  103. package/dist/types/tools/gh.d.ts +8 -0
  104. package/dist/types/tools/index.d.ts +10 -5
  105. package/dist/types/web/search/providers/base.d.ts +9 -4
  106. package/dist/types/web/search/providers/xai.d.ts +2 -1
  107. package/dist/types/web/search/types.d.ts +14 -0
  108. package/package.json +10 -7
  109. package/scripts/generate-hotkeys-docs.ts +4 -0
  110. package/scripts/generate-sdk-operation-inventory.ts +9 -3
  111. package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
  112. package/src/async/job-manager.ts +671 -39
  113. package/src/cli/accounts-cli.ts +9 -5
  114. package/src/cli/args.ts +17 -0
  115. package/src/cli/auth-broker-cli.ts +40 -12
  116. package/src/cli/auth-gateway-cli.ts +273 -97
  117. package/src/cli/config-cli.ts +2 -2
  118. package/src/cli/fast-help.ts +4 -0
  119. package/src/cli/model-presets-cli.ts +100 -0
  120. package/src/cli/root-flags.ts +5 -0
  121. package/src/cli/update-cli.ts +117 -16
  122. package/src/cli.ts +6 -2
  123. package/src/commands/auth-gateway.ts +12 -8
  124. package/src/commands/gc.ts +19 -0
  125. package/src/commands/launch.ts +86 -18
  126. package/src/commands/model-presets.ts +24 -0
  127. package/src/commands/sdk.ts +113 -21
  128. package/src/config/autorouting-tier-map.ts +44 -0
  129. package/src/config/file-lock-gc.ts +9 -4
  130. package/src/config/file-lock.ts +890 -99
  131. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  132. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  133. package/src/config/keybindings.ts +6 -2
  134. package/src/config/model-preset-registry.ts +2795 -0
  135. package/src/config/model-profile-activation.ts +115 -28
  136. package/src/config/model-profile-contract.ts +4 -1
  137. package/src/config/model-profiles.ts +56 -14
  138. package/src/config/model-registry.ts +1044 -122
  139. package/src/config/model-resolver.ts +5 -0
  140. package/src/config/settings-schema.ts +19 -1
  141. package/src/config/settings.ts +11 -1
  142. package/src/coordinator-mcp/model-preset.ts +32 -17
  143. package/src/coordinator-mcp/projection-scan.ts +592 -0
  144. package/src/coordinator-mcp/question-state.ts +9 -11
  145. package/src/coordinator-mcp/server.ts +111 -42
  146. package/src/coordinator-mcp/session-reaper.ts +9 -3
  147. package/src/crash/index-store.ts +2 -0
  148. package/src/crash/record-loader.ts +10 -0
  149. package/src/extensibility/extensions/loader.ts +0 -1
  150. package/src/extensibility/extensions/types.ts +2 -3
  151. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  152. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  153. package/src/gjc-runtime/gc-render.ts +29 -0
  154. package/src/gjc-runtime/gc-runtime.ts +174 -11
  155. package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
  156. package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
  157. package/src/gjc-runtime/session-state-lock.ts +1321 -147
  158. package/src/gjc-runtime/session-state-sidecar.ts +1004 -25
  159. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
  160. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -2
  161. package/src/gjc-runtime/tmux-sessions.ts +252 -13
  162. package/src/hooks/skill-state.ts +6 -0
  163. package/src/index.ts +1 -0
  164. package/src/internal-urls/docs-index.generated.ts +14 -13
  165. package/src/internal-urls/issue-pr-protocol.ts +4 -2
  166. package/src/lsp/config.ts +4 -3
  167. package/src/lsp/path-trust.ts +12 -6
  168. package/src/main.ts +66 -4
  169. package/src/master-mode/context.ts +50 -0
  170. package/src/master-mode/first-request.ts +65 -0
  171. package/src/master-mode/snapshot.ts +205 -0
  172. package/src/modes/acp/acp-agent.ts +616 -161
  173. package/src/modes/components/footer.ts +4 -2
  174. package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
  175. package/src/modes/components/jobs-overlay-model.ts +99 -16
  176. package/src/modes/components/model-selector.ts +158 -18
  177. package/src/modes/components/oauth-selector.ts +6 -1
  178. package/src/modes/components/settings-selector.ts +1 -0
  179. package/src/modes/components/status-line/model-name.ts +53 -0
  180. package/src/modes/components/status-line/priority-row.ts +107 -0
  181. package/src/modes/components/status-line/segments.ts +29 -10
  182. package/src/modes/components/tool-status-header.ts +151 -15
  183. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  184. package/src/modes/controllers/input-controller.ts +51 -6
  185. package/src/modes/controllers/selector-controller.ts +191 -96
  186. package/src/modes/interactive-mode.ts +14 -1
  187. package/src/modes/jobs-observer.ts +149 -16
  188. package/src/modes/print-mode.ts +64 -0
  189. package/src/modes/runtime-init.ts +1 -1
  190. package/src/modes/ui-language.ts +49 -2
  191. package/src/modes/utils/injected-user-submission.ts +15 -1
  192. package/src/prompts/system/master-mode.md +28 -0
  193. package/src/prompts/tools/bash.md +2 -2
  194. package/src/prompts/tools/fold-receipt.md +3 -0
  195. package/src/prompts/tools/job.md +1 -1
  196. package/src/sdk/acp/adapter.ts +17 -2
  197. package/src/sdk/broker/broker.ts +1671 -34
  198. package/src/sdk/broker/endpoint-authority.ts +86 -0
  199. package/src/sdk/broker/ensure.ts +5 -0
  200. package/src/sdk/broker/index.ts +3 -0
  201. package/src/sdk/broker/lifecycle.ts +217 -46
  202. package/src/sdk/broker/master-capability.ts +232 -0
  203. package/src/sdk/broker/session-index.ts +121 -17
  204. package/src/sdk/broker/session-scope.ts +272 -0
  205. package/src/sdk/broker/spawn-authority.ts +1031 -0
  206. package/src/sdk/broker/spawn-substrate.ts +548 -0
  207. package/src/sdk/broker/state-version.ts +26 -14
  208. package/src/sdk/broker/transport.ts +1 -0
  209. package/src/sdk/broker/worktree-occupancy.ts +90 -0
  210. package/src/sdk/bus/chat-daemon-control.ts +5 -2
  211. package/src/sdk/bus/chat-daemon-runtime.ts +46 -5
  212. package/src/sdk/bus/index.ts +51 -4
  213. package/src/sdk/bus/kind-aware-reconciliation.ts +36 -6
  214. package/src/sdk/bus/prompt-reconciliation.ts +28 -4
  215. package/src/sdk/bus/slack-conversation.ts +5 -1
  216. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  217. package/src/sdk/cli/index.ts +1 -0
  218. package/src/sdk/cli/master-cli.ts +259 -0
  219. package/src/sdk/cli/rows.ts +11 -8
  220. package/src/sdk/cli/session-cli.ts +276 -6
  221. package/src/sdk/client/client.ts +14 -8
  222. package/src/sdk/client/discovery.ts +3 -1
  223. package/src/sdk/host/control/dispatch.ts +101 -37
  224. package/src/sdk/host/control/index.ts +2 -0
  225. package/src/sdk/host/control/operations.ts +2 -0
  226. package/src/sdk/host/control/runtime-gate.ts +203 -11
  227. package/src/sdk/host/host.ts +27 -2
  228. package/src/sdk/host/model-pin.ts +185 -25
  229. package/src/sdk/host/sdk-run-capability.ts +1 -0
  230. package/src/sdk/host/session-runtime.test.ts +581 -43
  231. package/src/sdk/host/session-runtime.ts +655 -101
  232. package/src/sdk/lifecycle/broker-client.ts +28 -0
  233. package/src/sdk/lifecycle/service.ts +279 -20
  234. package/src/sdk/lifecycle-session.ts +10 -2
  235. package/src/sdk/prompt-deadline-manager.ts +16 -1
  236. package/src/sdk/protocol/adapter-validation.ts +27 -0
  237. package/src/sdk/protocol/operation-inventory.generated.json +53 -2
  238. package/src/sdk/protocol/operation-registry.ts +17 -4
  239. package/src/sdk/router/session-router.ts +294 -154
  240. package/src/sdk/session.ts +440 -167
  241. package/src/sdk/startup-capability.ts +1 -0
  242. package/src/session/account-inventory.ts +27 -11
  243. package/src/session/agent-session.ts +1292 -180
  244. package/src/session/blob-store.ts +15 -5
  245. package/src/session/credential-store-errors.ts +2 -0
  246. package/src/session/fold-coordinator.ts +433 -0
  247. package/src/session/internal/managed-session-storage.ts +153 -78
  248. package/src/session/sdk-run-capability-internal.ts +15 -0
  249. package/src/session/sdk-run-capability.ts +2 -0
  250. package/src/session/session-manager.ts +342 -28
  251. package/src/session/yield-queue.ts +24 -10
  252. package/src/setup/frictionless-onboarding.ts +2 -0
  253. package/src/setup/provider-presets.json +1 -1
  254. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  255. package/src/task/executor.ts +19 -3
  256. package/src/telemetry/control.ts +26 -0
  257. package/src/telemetry/events.ts +1268 -0
  258. package/src/telemetry/index.ts +3 -0
  259. package/src/telemetry/transport.ts +87 -0
  260. package/src/tools/bash-interactive.ts +182 -85
  261. package/src/tools/bash.ts +1028 -109
  262. package/src/tools/gh.ts +104 -48
  263. package/src/tools/github-cache.ts +2 -2
  264. package/src/tools/index.ts +11 -5
  265. package/src/tools/tool-catalog.generated.ts +1 -1
  266. package/src/web/search/provider.ts +120 -11
  267. package/src/web/search/providers/anthropic.ts +89 -17
  268. package/src/web/search/providers/base.ts +9 -4
  269. package/src/web/search/providers/gemini.ts +87 -15
  270. package/src/web/search/providers/openai-compatible.ts +23 -8
  271. package/src/web/search/providers/xai.ts +98 -12
  272. package/src/web/search/types.ts +16 -0
@@ -20,6 +20,8 @@ const DELIVERY_PREVIEW_HEAD_BYTES = 32 * 1024;
20
20
  const DELIVERY_PREVIEW_TAIL_BYTES = 32 * 1024;
21
21
  const DELIVERY_MAX_ATTEMPTS = 3;
22
22
  const MAX_DEAD_LETTERED_DELIVERIES = 50;
23
+ const MAX_DEAD_LETTER_OVERFLOW_OWNERS = 64;
24
+ const MAX_EVICTED_DEAD_LETTERS = 64;
23
25
 
24
26
  export interface AsyncJob {
25
27
  id: string;
@@ -44,6 +46,8 @@ export interface AsyncJob {
44
46
  setupFailureSummary?: string;
45
47
  /** Safe, bounded summary of a terminal local (non-provider) failure kind. */
46
48
  localErrorSummary?: LocalErrorSummary;
49
+ /** Compact delivery-failure marker retained while the job row remains live. */
50
+ deliveryFailure?: { attempt: number; lastError?: string; recordedAt: number };
47
51
  metadata?: AsyncJobMetadata;
48
52
  /**
49
53
  * Registry id of the agent that registered the job (e.g. "0-Main",
@@ -65,6 +69,8 @@ export function jobElapsedMs(job: Pick<AsyncJob, "startTime" | "endTime">, now:
65
69
  }
66
70
 
67
71
  export interface AsyncJobMetadata {
72
+ /** Set once a foreground wait has been folded, so surfacing can show folded work. */
73
+ backgrounded?: boolean;
68
74
  subagent?: {
69
75
  id: string;
70
76
  agent: string;
@@ -244,6 +250,11 @@ export interface AsyncJobManagerOptions {
244
250
  onJobComplete: (jobId: string, text: string, job?: AsyncJob) => void | Promise<void>;
245
251
  maxRunningJobs?: number;
246
252
  retentionMs?: number;
253
+ maxDeadLetterOverflowOwners?: number;
254
+ }
255
+
256
+ export interface AsyncJobAdmission {
257
+ readonly manager: AsyncJobManager;
247
258
  }
248
259
 
249
260
  export interface AsyncJobDisposeDiagnostics {
@@ -255,6 +266,11 @@ interface AsyncJobDelivery {
255
266
  jobId: string;
256
267
  generation: string;
257
268
  job: AsyncJob;
269
+ /** Delivery-owned public projection fields retained after the live job is evicted. */
270
+ kind: AsyncJob["type"];
271
+ label: string;
272
+ status: AsyncJob["status"];
273
+ backgrounded: boolean;
258
274
  text: string;
259
275
  originalBytes?: number;
260
276
  truncated?: boolean;
@@ -262,14 +278,85 @@ interface AsyncJobDelivery {
262
278
  nextAttemptAt: number;
263
279
  lastError?: string;
264
280
  ownerId?: string;
281
+ /** Cleanup-triggered terminal deliveries may retry while disposal drains them. */
282
+ retryDuringDispose: boolean;
265
283
  promise?: Promise<void>;
266
284
  }
267
285
 
286
+ interface AsyncJobReceiptClaim {
287
+ jobId: string;
288
+ generation: string;
289
+ kind: AsyncJob["type"];
290
+ label: string;
291
+ status: AsyncJob["status"];
292
+ backgrounded: boolean;
293
+ ownerId?: string;
294
+ }
295
+
268
296
  interface DeadLetteredDelivery {
269
297
  jobId: string;
270
298
  generation: string;
271
299
  attempt: number;
272
300
  lastError?: string;
301
+ recordedAt: number;
302
+ }
303
+
304
+ /**
305
+ * A retry-cap delivery failure recorded AFTER its job record was evicted.
306
+ *
307
+ * The ordinary dead-letter map is pruned by record existence
308
+ * ({@link AsyncJobManager.prototype constructor}'s `#pruneEvictedDeadLetters`),
309
+ * so a failure in that window would otherwise leave no visible terminal state
310
+ * and breach the no-silent-starvation contract. Scalar-only by design: nothing
311
+ * here retains an `AsyncJob`.
312
+ */
313
+ interface EvictedDeadLetteredDelivery {
314
+ jobId: string;
315
+ generation: string;
316
+ ownerId?: string;
317
+ backgrounded: boolean;
318
+ attempt: number;
319
+ lastError?: string;
320
+ recordedAt: number;
321
+ }
322
+
323
+ /** Per-job public delivery classification. Exactly one value per terminal job. */
324
+ export type JobDeliveryState = "pending" | "delivered" | "failed-visible";
325
+
326
+ /** One job as projected by {@link AsyncJobManager.getJobsSnapshot}. */
327
+ export interface AsyncJobSnapshotEntry {
328
+ id: string;
329
+ kind: string;
330
+ label: string;
331
+ status: AsyncJob["status"];
332
+ generation: string;
333
+ backgrounded: boolean;
334
+ deliveryState: JobDeliveryState;
335
+ }
336
+
337
+ /** Scalar delivery-failure evidence that may outlive its AsyncJob record. */
338
+ export interface DeadLetteredJobSnapshotEntry {
339
+ jobId: string;
340
+ generation: string;
341
+ ownerId?: string;
342
+ /** Whether the failed delivery originated from a backgrounded job. */
343
+ backgrounded?: boolean;
344
+ attempt: number;
345
+ lastError?: string;
346
+ recordedAt: number;
347
+ }
348
+
349
+ /**
350
+ * One atomic projection of job + delivery state.
351
+ *
352
+ * Computed in a single synchronous pass with no interleaved awaits so it cannot
353
+ * tear, and consumed verbatim: a UI that re-derives delivery classification from
354
+ * separate getters can disagree with this and produce a job that is in no public
355
+ * state at all.
356
+ */
357
+ export interface AsyncJobsSnapshot {
358
+ jobs: AsyncJobSnapshotEntry[];
359
+ deadLettered: DeadLetteredJobSnapshotEntry[];
273
360
  }
274
361
 
275
362
  export interface AsyncJobDeliveryState {
@@ -309,6 +396,8 @@ export interface AsyncJobRegisterOptions {
309
396
  metadata?: AsyncJobMetadata;
310
397
  onProgress?: (text: string, details?: Record<string, unknown>) => void | Promise<void>;
311
398
  lifecycle?: AsyncJobLifecycleCleanup;
399
+ /** Reserved capacity consumed atomically by this registration. */
400
+ admissionToken?: AsyncJobAdmission;
312
401
  }
313
402
 
314
403
  /**
@@ -544,6 +633,8 @@ export class AsyncJobManager {
544
633
  readonly #deliveries: AsyncJobDelivery[] = [];
545
634
  readonly #inFlightDeliveries: AsyncJobDelivery[] = [];
546
635
  readonly #suppressedDeliveries = new Set<string>();
636
+ /** Job ids retained by suppressed generations after terminal-event eviction. */
637
+ readonly #suppressedDeliveryJobIds = new Map<string, string>();
547
638
  readonly #deliveryAckOwners = new Map<string, string>();
548
639
  readonly #watchedJobs = new Map<string, number>();
549
640
  readonly #watchedGenerations = new Map<string, number>();
@@ -556,9 +647,14 @@ export class AsyncJobManager {
556
647
  readonly #outputRetentionBytes = DEFAULT_JOB_OUTPUT_RETENTION_BYTES;
557
648
  readonly #onJobComplete: AsyncJobManagerOptions["onJobComplete"];
558
649
  readonly #maxRunningJobs: number;
650
+ #pendingAdmissions = 0;
651
+ readonly #admissions = new WeakMap<AsyncJobAdmission, boolean>();
559
652
  readonly #retentionMs: number;
653
+ readonly #maxDeadLetterOverflowOwners: number;
560
654
  #deliveryLoop: Promise<void> | undefined;
561
655
  #disposed = false;
656
+ #disposing = false;
657
+ #runningOwnerCleanups = false;
562
658
  readonly #subagentRecords = new Map<string, SubagentRecord>();
563
659
  readonly #terminalEvents = new Map<string, TerminalEvent>();
564
660
  readonly #waitGenerationAliases = new Map<string, string>();
@@ -583,6 +679,16 @@ export class AsyncJobManager {
583
679
  readonly #descriptorResumeRunners = new Map<string, ResumeRunner>();
584
680
  readonly #deadLetteredDeliveries = new Map<string, DeadLetteredDelivery>();
585
681
  readonly #deadLetteredDeliveryOwners = new Map<string, string | undefined>();
682
+ readonly #deadLetterOverflowByOwner = new Map<string | undefined, number>();
683
+ readonly #deadLetterOverflowRecordedAt = new Map<string | undefined, number>();
684
+ /** Retry-cap failures whose job record was already evicted, keyed by the unique generation. */
685
+ readonly #evictedDeadLetters = new Map<string, EvictedDeadLetteredDelivery>();
686
+ readonly #parkedDeliveries = new Map<string, AsyncJobDelivery>();
687
+ readonly #receiptClaims = new Map<string, AsyncJobReceiptClaim>();
688
+ /** Generations settled by failNow, so the runner's later terminal path is a no-op. */
689
+ readonly #externallySettled = new Set<string>();
690
+ /** Closed before #disposed so owner cleanups can settle work but cannot register more. */
691
+ #registrationClosed = false;
586
692
  readonly #ownerSubagentShutdownLeases = new Map<string, OwnerSubagentShutdownLeaseState>();
587
693
  #ownerSubagentShutdownSeq = 0;
588
694
  #lastDisposeDiagnostics: AsyncJobDisposeDiagnostics = { stuckJobIds: [], deliveriesDrained: true };
@@ -836,7 +942,7 @@ export class AsyncJobManager {
836
942
  const owner = this.#deliveryAckOwners.get(event.generation);
837
943
  if (owner && owner !== state.token) continue;
838
944
  this.#deliveryAckOwners.set(event.generation, state.token);
839
- this.#suppressedDeliveries.add(event.generation);
945
+ this.#suppressDelivery(event.jobId, event.generation);
840
946
  }
841
947
  this.#deliveries.splice(
842
948
  0,
@@ -873,6 +979,10 @@ export class AsyncJobManager {
873
979
  this.#onJobComplete = options.onJobComplete;
874
980
  this.#maxRunningJobs = Math.max(1, Math.floor(options.maxRunningJobs ?? DEFAULT_MAX_RUNNING_JOBS));
875
981
  this.#retentionMs = Math.max(0, Math.floor(options.retentionMs ?? DEFAULT_RETENTION_MS));
982
+ this.#maxDeadLetterOverflowOwners = Math.max(
983
+ 1,
984
+ Math.floor(options.maxDeadLetterOverflowOwners ?? MAX_DEAD_LETTER_OVERFLOW_OWNERS),
985
+ );
876
986
  }
877
987
 
878
988
  /**
@@ -914,8 +1024,11 @@ export class AsyncJobManager {
914
1024
  if (options?.ownerId && this.#isOwnerSubagentShutdownFenced(options.ownerId)) {
915
1025
  throw new OwnerSubagentShutdownError();
916
1026
  }
917
- const runningCount = this.getRunningJobs().length;
918
- if (runningCount >= this.#maxRunningJobs) {
1027
+ if (this.#registrationClosed) throw new Error("Async job manager is shutting down");
1028
+ const admissionToken = options?.admissionToken;
1029
+ if (admissionToken) {
1030
+ if (!this.#consumeAdmission(admissionToken)) throw new Error("Invalid or expired job admission token");
1031
+ } else if (!this.hasCapacity()) {
919
1032
  throw new Error(
920
1033
  `Background job limit reached (${this.#maxRunningJobs}). Wait for running jobs to finish or cancel one.`,
921
1034
  );
@@ -958,6 +1071,13 @@ export class AsyncJobManager {
958
1071
  const outcome: SubagentRunOutcome =
959
1072
  typeof result === "string" ? { kind: "completed", text: result } : result;
960
1073
 
1074
+ // failNow already published a terminal state AND enqueued its delivery
1075
+ // for this generation; re-running the terminal path here would publish
1076
+ // twice and deliver twice.
1077
+ if (this.#externallySettled.has(job.generation)) {
1078
+ this.#drainResumeQueue();
1079
+ return;
1080
+ }
961
1081
  if (job.status === "cancelled") {
962
1082
  job.resultText = outcome.kind === "paused" ? outcome.note : outcome.text;
963
1083
  this.#markRecordTerminal(id, "cancelled", job.generation);
@@ -1009,6 +1129,10 @@ export class AsyncJobManager {
1009
1129
  this.#scheduleEviction(id);
1010
1130
  this.#drainResumeQueue();
1011
1131
  } catch (error) {
1132
+ if (this.#externallySettled.has(job.generation)) {
1133
+ this.#drainResumeQueue();
1134
+ return;
1135
+ }
1012
1136
  if (job.status === "cancelled") {
1013
1137
  job.errorText = error instanceof Error ? error.message : String(error);
1014
1138
  this.#markRecordTerminal(id, "cancelled", job.generation);
@@ -1031,6 +1155,7 @@ export class AsyncJobManager {
1031
1155
  this.#drainResumeQueue();
1032
1156
  }
1033
1157
  })().finally(() => {
1158
+ this.#externallySettled.delete(job.generation);
1034
1159
  this.#settledJobIds.add(id);
1035
1160
  });
1036
1161
 
@@ -1924,6 +2049,28 @@ export class AsyncJobManager {
1924
2049
  return this.#filterJobs(this.#jobs.values(), filter).filter(job => job.status === "running");
1925
2050
  }
1926
2051
 
2052
+ /** Whether a new running job can be admitted without starting side effects. */
2053
+ hasCapacity(filter?: AsyncJobFilter): boolean {
2054
+ return this.getRunningJobs(filter).length + this.#pendingAdmissions < this.#maxRunningJobs;
2055
+ }
2056
+
2057
+ reserveCapacity(): AsyncJobAdmission {
2058
+ if (this.#disposed || this.#registrationClosed || !this.hasCapacity()) {
2059
+ throw new Error(
2060
+ `Background job limit reached (${this.#maxRunningJobs}). Wait for running jobs to finish or cancel one.`,
2061
+ );
2062
+ }
2063
+ const token: AsyncJobAdmission = { manager: this };
2064
+ this.#admissions.set(token, true);
2065
+ this.#pendingAdmissions += 1;
2066
+ return token;
2067
+ }
2068
+
2069
+ releaseCapacity(token: AsyncJobAdmission): void {
2070
+ if (!this.#consumeAdmission(token)) return;
2071
+ this.#notifyChange();
2072
+ }
2073
+
1927
2074
  getRecentJobs(limit = 10, filter?: AsyncJobFilter): AsyncJob[] {
1928
2075
  return this.#filterJobs(this.#jobs.values(), filter)
1929
2076
  .filter(job => job.status !== "running")
@@ -1961,18 +2108,25 @@ export class AsyncJobManager {
1961
2108
  const byteLength = Buffer.byteLength(chunk, "utf8");
1962
2109
  if (byteLength === 0) return;
1963
2110
 
1964
- const startByte = state.nextOffset;
1965
- const endByte = startByte + byteLength;
1966
- state.chunks.push({ startByte, endByte, text: chunk });
1967
- state.retainedBytes += byteLength;
2111
+ const retainedChunk =
2112
+ byteLength > this.#outputRetentionBytes
2113
+ ? sliceTextAfterUtf8ByteOffset(chunk, byteLength - this.#outputRetentionBytes)
2114
+ : chunk;
2115
+ const retainedByteLength = Buffer.byteLength(retainedChunk, "utf8");
2116
+ const skippedBytes = byteLength - retainedByteLength;
2117
+ const startByte = state.nextOffset + skippedBytes;
2118
+ const endByte = startByte + retainedByteLength;
2119
+ state.chunks.push({ startByte, endByte, text: retainedChunk });
2120
+ state.retainedBytes += retainedByteLength;
1968
2121
  state.nextOffset = endByte;
2122
+ if (skippedBytes > 0) state.startOffset = Math.max(state.startOffset, startByte);
1969
2123
 
1970
2124
  while (state.retainedBytes > this.#outputRetentionBytes && state.chunks.length > 0) {
1971
2125
  const dropped = state.chunks.shift();
1972
2126
  if (!dropped) break;
1973
2127
  const droppedBytes = dropped.endByte - dropped.startByte;
1974
2128
  state.retainedBytes -= droppedBytes;
1975
- state.startOffset = dropped.endByte;
2129
+ state.startOffset = Math.max(state.startOffset, dropped.endByte);
1976
2130
  }
1977
2131
 
1978
2132
  this.#outputState.set(jobId, state);
@@ -2079,25 +2233,212 @@ export class AsyncJobManager {
2079
2233
  const deliveries = this.#filterDeliveries(filter);
2080
2234
  const inFlightDeliveries = this.#filterInFlightDeliveries(filter);
2081
2235
  const ownerId = filter?.ownerId;
2082
- const deadLettered = ownerId
2083
- ? Array.from(this.#deadLetteredDeliveries.keys()).filter(
2084
- jobId => this.#deadLetteredDeliveryOwners.get(jobId) === ownerId,
2085
- ).length
2086
- : this.#deadLetteredDeliveries.size;
2236
+ const parked = Array.from(this.#parkedDeliveries.values()).filter(
2237
+ delivery => !ownerId || delivery.ownerId === ownerId,
2238
+ );
2239
+ const claims = Array.from(this.#receiptClaims.values()).filter(claim => !ownerId || claim.ownerId === ownerId);
2240
+ const evictedDeadLettered = ownerId
2241
+ ? Array.from(this.#evictedDeadLetters.values()).filter(entry => entry.ownerId === ownerId).length
2242
+ : this.#evictedDeadLetters.size;
2243
+ const deadLettered =
2244
+ (ownerId
2245
+ ? Array.from(this.#deadLetteredDeliveries.keys()).filter(
2246
+ jobId => this.#deadLetteredDeliveryOwners.get(jobId) === ownerId,
2247
+ ).length
2248
+ : this.#deadLetteredDeliveries.size) + evictedDeadLettered;
2087
2249
  const nextRetryAt = deliveries.reduce<number | undefined>((next, delivery) => {
2088
2250
  if (next === undefined) return delivery.nextAttemptAt;
2089
2251
  return Math.min(next, delivery.nextAttemptAt);
2090
2252
  }, undefined);
2091
2253
 
2092
2254
  return {
2093
- queued: deliveries.length + inFlightDeliveries.length,
2094
- delivering: inFlightDeliveries.length > 0 || (this.#deliveryLoop !== undefined && deliveries.length > 0),
2255
+ queued: deliveries.length + inFlightDeliveries.length + parked.length + claims.length,
2256
+ delivering:
2257
+ inFlightDeliveries.length > 0 ||
2258
+ parked.length > 0 ||
2259
+ claims.length > 0 ||
2260
+ (this.#deliveryLoop !== undefined && deliveries.length > 0),
2095
2261
  nextRetryAt,
2096
- pendingJobIds: deliveries.concat(inFlightDeliveries).map(delivery => delivery.jobId),
2262
+ pendingJobIds: [...deliveries, ...inFlightDeliveries, ...parked, ...claims].map(delivery => delivery.jobId),
2097
2263
  deadLettered,
2098
2264
  };
2099
2265
  }
2100
2266
 
2267
+ /**
2268
+ * One synchronous pass over jobs plus delivery state.
2269
+ *
2270
+ * Every returned job carries exactly one {@link JobDeliveryState}, and
2271
+ * dead-lettered evidence is included even when its record is already gone, so
2272
+ * no terminal job can end up invisible to the UI.
2273
+ */
2274
+ getJobsSnapshot(filter?: AsyncJobFilter): AsyncJobsSnapshot {
2275
+ this.#expireMonitorTombstones();
2276
+ this.#pruneEvictedDeadLetters();
2277
+ this.#pruneSuppressedDeliveries();
2278
+
2279
+ const pending = new Set<string>();
2280
+ for (const delivery of this.#deliveries) pending.add(`${delivery.jobId}:${delivery.generation}`);
2281
+ for (const delivery of this.#inFlightDeliveries) pending.add(`${delivery.jobId}:${delivery.generation}`);
2282
+ for (const delivery of this.#parkedDeliveries.values()) pending.add(`${delivery.jobId}:${delivery.generation}`);
2283
+ for (const claim of this.#receiptClaims.values()) pending.add(`${claim.jobId}:${claim.generation}`);
2284
+
2285
+ const failedVisible = new Set<string>();
2286
+ for (const entry of this.#deadLetteredDeliveries.values()) {
2287
+ failedVisible.add(`${entry.jobId}:${entry.generation}`);
2288
+ }
2289
+ for (const entry of this.#evictedDeadLetters.values()) {
2290
+ failedVisible.add(`${entry.jobId}:${entry.generation}`);
2291
+ }
2292
+ for (const job of this.#jobs.values()) {
2293
+ if (job.deliveryFailure) failedVisible.add(`${job.id}:${job.generation}`);
2294
+ }
2295
+
2296
+ const jobs = this.#filterJobs(this.#jobs.values(), filter).map<AsyncJobSnapshotEntry>(job => {
2297
+ const key = `${job.id}:${job.generation}`;
2298
+ const deliveryState: JobDeliveryState = failedVisible.has(key)
2299
+ ? "failed-visible"
2300
+ : pending.has(key) || job.status === "running" || job.status === "paused"
2301
+ ? "pending"
2302
+ : "delivered";
2303
+ return {
2304
+ id: job.id,
2305
+ kind: job.type,
2306
+ label: job.label,
2307
+ status: job.status,
2308
+ generation: job.generation,
2309
+ backgrounded: job.metadata?.backgrounded === true,
2310
+ deliveryState,
2311
+ };
2312
+ });
2313
+
2314
+ const ownerId = filter?.ownerId;
2315
+ const projectedKeys = new Set(jobs.map(job => `${job.id}:${job.generation}`));
2316
+ // A zero-retention job can disappear from #jobs while its completion
2317
+ // callback is still queued or in flight. Project that delivery from its
2318
+ // immutable scalar fields so folded work remains visible until the callback
2319
+ // reaches a terminal delivery boundary; do not retain or expose its payload.
2320
+ for (const delivery of [...this.#deliveries, ...this.#inFlightDeliveries]) {
2321
+ if (ownerId && delivery.ownerId !== ownerId) continue;
2322
+ const key = `${delivery.jobId}:${delivery.generation}`;
2323
+ if (projectedKeys.has(key)) continue;
2324
+ projectedKeys.add(key);
2325
+ jobs.push({
2326
+ id: delivery.jobId,
2327
+ kind: delivery.kind,
2328
+ label: delivery.label,
2329
+ status: delivery.status,
2330
+ generation: delivery.generation,
2331
+ backgrounded: delivery.backgrounded,
2332
+ deliveryState: "pending",
2333
+ });
2334
+ }
2335
+ for (const delivery of this.#parkedDeliveries.values()) {
2336
+ if (ownerId && delivery.ownerId !== ownerId) continue;
2337
+ const key = `${delivery.jobId}:${delivery.generation}`;
2338
+ if (projectedKeys.has(key)) continue;
2339
+ projectedKeys.add(key);
2340
+ jobs.push({
2341
+ id: delivery.jobId,
2342
+ kind: delivery.kind,
2343
+ label: delivery.label,
2344
+ status: delivery.status,
2345
+ generation: delivery.generation,
2346
+ backgrounded: delivery.backgrounded,
2347
+ deliveryState: "pending",
2348
+ });
2349
+ }
2350
+ for (const claim of this.#receiptClaims.values()) {
2351
+ if (ownerId && claim.ownerId !== ownerId) continue;
2352
+ const key = `${claim.jobId}:${claim.generation}`;
2353
+ if (projectedKeys.has(key)) continue;
2354
+ projectedKeys.add(key);
2355
+ jobs.push({
2356
+ id: claim.jobId,
2357
+ kind: claim.kind,
2358
+ label: claim.label,
2359
+ status: claim.status,
2360
+ generation: claim.generation,
2361
+ backgrounded: claim.backgrounded,
2362
+ deliveryState: "pending",
2363
+ });
2364
+ }
2365
+
2366
+ const deadLettered: DeadLetteredJobSnapshotEntry[] = [];
2367
+ for (const entry of this.#deadLetteredDeliveries.values()) {
2368
+ const entryOwner = this.#deadLetteredDeliveryOwners.get(entry.jobId);
2369
+ if (ownerId && entryOwner !== ownerId) continue;
2370
+ const currentJob = this.#jobs.get(entry.jobId);
2371
+ deadLettered.push({
2372
+ jobId: entry.jobId,
2373
+ generation: entry.generation,
2374
+ ownerId: entryOwner,
2375
+ backgrounded: currentJob?.generation === entry.generation && currentJob.metadata?.backgrounded === true,
2376
+ attempt: entry.attempt,
2377
+ lastError: entry.lastError,
2378
+ recordedAt: entry.recordedAt,
2379
+ });
2380
+ }
2381
+ for (const entry of this.#evictedDeadLetters.values()) {
2382
+ if (ownerId && entry.ownerId !== ownerId) continue;
2383
+ deadLettered.push({ ...entry });
2384
+ }
2385
+ for (const [entryOwner, count] of this.#deadLetterOverflowByOwner) {
2386
+ if (ownerId && entryOwner !== ownerId) continue;
2387
+ deadLettered.push({
2388
+ jobId: `dead-letter-overflow:${entryOwner ?? "unknown"}`,
2389
+ generation: `dead-letter-overflow:${entryOwner ?? "unknown"}`,
2390
+ ownerId: entryOwner,
2391
+ backgrounded: true,
2392
+ attempt: 0,
2393
+ lastError: `${count} additional undelivered completion(s) exceeded retained dead-letter capacity`,
2394
+ recordedAt: this.#deadLetterOverflowRecordedAt.get(entryOwner) ?? Date.now(),
2395
+ });
2396
+ }
2397
+
2398
+ return { jobs, deadLettered };
2399
+ }
2400
+
2401
+ retainParkedDelivery(job: AsyncJob, text: string): void {
2402
+ if (this.#disposed) return;
2403
+ this.#parkedDeliveries.set(job.generation, {
2404
+ jobId: job.id,
2405
+ generation: job.generation,
2406
+ job,
2407
+ kind: job.type,
2408
+ label: job.label,
2409
+ status: job.status,
2410
+ backgrounded: job.metadata?.backgrounded === true,
2411
+ text,
2412
+ attempt: 0,
2413
+ nextAttemptAt: Date.now(),
2414
+ ownerId: job.ownerId,
2415
+ retryDuringDispose: false,
2416
+ });
2417
+ this.#notifyChange();
2418
+ }
2419
+
2420
+ retainDeliveryClaim(job: AsyncJob): void {
2421
+ if (this.#disposed) return;
2422
+ this.#receiptClaims.set(job.generation, {
2423
+ jobId: job.id,
2424
+ generation: job.generation,
2425
+ kind: job.type,
2426
+ label: job.label,
2427
+ status: job.status,
2428
+ backgrounded: job.metadata?.backgrounded === true,
2429
+ ownerId: job.ownerId,
2430
+ });
2431
+ this.#notifyChange();
2432
+ }
2433
+
2434
+ releaseDeliveryClaim(generation: string): void {
2435
+ if (this.#receiptClaims.delete(generation)) this.#notifyChange();
2436
+ }
2437
+
2438
+ clearParkedDelivery(generation: string): void {
2439
+ if (this.#parkedDeliveries.delete(generation)) this.#notifyChange();
2440
+ }
2441
+
2101
2442
  hasPendingDeliveries(filter?: AsyncJobFilter): boolean {
2102
2443
  return this.getDeliveryState(filter).queued > 0;
2103
2444
  }
@@ -2157,9 +2498,21 @@ export class AsyncJobManager {
2157
2498
  if (uniqueJobIds.length === 0) return 0;
2158
2499
  for (const jobId of uniqueJobIds) {
2159
2500
  const currentJob = this.#jobs.get(jobId);
2160
- if (currentJob) this.#suppressedDeliveries.add(currentJob.generation);
2501
+ if (currentJob) this.#suppressDelivery(currentJob.id, currentJob.generation);
2161
2502
  for (const delivery of [...this.#deliveries, ...this.#inFlightDeliveries]) {
2162
- if (delivery.jobId === jobId) this.#suppressedDeliveries.add(delivery.generation);
2503
+ if (delivery.jobId === jobId) this.#suppressDelivery(delivery.jobId, delivery.generation);
2504
+ }
2505
+ for (const parked of this.#parkedDeliveries.values()) {
2506
+ if (parked.jobId === jobId) this.#suppressDelivery(parked.jobId, parked.generation);
2507
+ }
2508
+ for (const claim of this.#receiptClaims.values()) {
2509
+ if (claim.jobId === jobId) this.#suppressDelivery(claim.jobId, claim.generation);
2510
+ }
2511
+ for (const [generation, parked] of this.#parkedDeliveries) {
2512
+ if (parked.jobId === jobId) this.#parkedDeliveries.delete(generation);
2513
+ }
2514
+ for (const [generation, claim] of this.#receiptClaims) {
2515
+ if (claim.jobId === jobId) this.#receiptClaims.delete(generation);
2163
2516
  }
2164
2517
  }
2165
2518
  const before = this.#deliveries.length;
@@ -2168,6 +2521,7 @@ export class AsyncJobManager {
2168
2521
  this.#deliveries.length,
2169
2522
  ...this.#deliveries.filter(delivery => !this.#isDeliveryAcknowledged(delivery.jobId, delivery.generation)),
2170
2523
  );
2524
+ this.#notifyChange();
2171
2525
  return before - this.#deliveries.length;
2172
2526
  }
2173
2527
 
@@ -2176,6 +2530,36 @@ export class AsyncJobManager {
2176
2530
  * matching agent registered; with no filter, cancels every running job
2177
2531
  * (used by `dispose()` to nuke the manager's state).
2178
2532
  */
2533
+ /**
2534
+ * Synchronously fail a running job AND enqueue its delivery, exactly once.
2535
+ *
2536
+ * An externally owned wait -- an ACP client terminal, say -- can fail while its
2537
+ * owner is being torn down. `cancel()` is the wrong tool there: it marks the job
2538
+ * cancelled, and the cancelled path deliberately enqueues NO delivery, so the
2539
+ * failure would never become visible to anyone. This is the single transition
2540
+ * that both fails the job and delivers that failure in one synchronous block, so
2541
+ * it cannot lose the race against disposal.
2542
+ */
2543
+ failNow(jobId: string, generation: string, errorText: string, options?: { abort?: boolean }): boolean {
2544
+ const job = this.#jobs.get(jobId);
2545
+ if (!job || job.generation !== generation) return false;
2546
+ if (job.status !== "running") return false;
2547
+ if (this.#externallySettled.has(generation)) return false;
2548
+ this.#externallySettled.add(generation);
2549
+ if (options?.abort !== false) job.abortController.abort();
2550
+ job.status = "failed";
2551
+ this.#freezeEndTime(job);
2552
+ job.errorText = errorText;
2553
+ this.#markRecordTerminal(jobId, "failed", generation);
2554
+ this.#publishTerminal(job);
2555
+ this.#enqueueDelivery(jobId, errorText);
2556
+ this.#runLifecycle(jobId, "terminal", job);
2557
+ this.#scheduleEviction(jobId);
2558
+ this.#drainResumeQueue();
2559
+ this.#notifyChange();
2560
+ return true;
2561
+ }
2562
+
2179
2563
  cancelAll(filter?: AsyncJobFilter): void {
2180
2564
  for (const job of this.getRunningJobs(filter)) {
2181
2565
  if (this.cancel(job.id, filter)) this.#scheduleEviction(job.id);
@@ -2247,6 +2631,21 @@ export class AsyncJobManager {
2247
2631
  };
2248
2632
  }
2249
2633
 
2634
+ /**
2635
+ * Mark a running job as backgrounded (folded out of its foreground call).
2636
+ * Read by getJobsSnapshot so folded work stays visible; safe to call twice.
2637
+ */
2638
+ markBackgrounded(jobId: string, generation: string): boolean {
2639
+ const job = this.#jobs.get(jobId);
2640
+ if (!job || job.generation !== generation) return false;
2641
+ job.metadata = { ...job.metadata, backgrounded: true };
2642
+ for (const delivery of [...this.#deliveries, ...this.#inFlightDeliveries]) {
2643
+ if (delivery.jobId === jobId && delivery.generation === generation) delivery.backgrounded = true;
2644
+ }
2645
+ this.#notifyChange();
2646
+ return true;
2647
+ }
2648
+
2250
2649
  async waitForAll(): Promise<void> {
2251
2650
  await Promise.all(Array.from(this.#jobs.values()).map(job => job.promise));
2252
2651
  }
@@ -2273,6 +2672,11 @@ export class AsyncJobManager {
2273
2672
  this.#ensureDeliveryLoop();
2274
2673
  const loop = this.#deliveryLoop;
2275
2674
  if (!loop) {
2675
+ const pending = this.#filterDeliveries()[0];
2676
+ if (!pending) return true;
2677
+ const index = this.#deliveries.indexOf(pending);
2678
+ if (index >= 0) this.#deliveries.splice(index, 1);
2679
+ await this.#deliverDelivery(pending);
2276
2680
  continue;
2277
2681
  }
2278
2682
 
@@ -2296,12 +2700,22 @@ export class AsyncJobManager {
2296
2700
  }
2297
2701
 
2298
2702
  async dispose(options?: { timeoutMs?: number }): Promise<boolean> {
2299
- this.#disposed = true;
2703
+ // Close registration FIRST, then run owner cleanups, and only then mark the
2704
+ // manager disposed. Setting #disposed before the cleanups made
2705
+ // #ensureDeliveryLoop a no-op, so a failure an owner cleanup settled through
2706
+ // failNow could never be drained -- it sat in the queue and vanished. Closing
2707
+ // registration keeps the original protection (cleanups cannot register fresh
2708
+ // work) without disabling delivery. Errors in cleanups are logged, never
2709
+ // escalated.
2710
+ this.#registrationClosed = true;
2300
2711
  this.#clearEvictionTimers();
2301
- // Run-and-clear any remaining owner cleanups before tearing down jobs so
2302
- // late-arriving timers cannot register fresh work against a disposed
2303
- // manager. Errors in cleanup callbacks are logged but never escalated.
2304
- this.runOwnerCleanups();
2712
+ this.#runningOwnerCleanups = true;
2713
+ try {
2714
+ this.runOwnerCleanups();
2715
+ } finally {
2716
+ this.#runningOwnerCleanups = false;
2717
+ }
2718
+ this.#disposing = true;
2305
2719
  this.cancelAll();
2306
2720
  for (const tombstone of this.#monitorTombstones.values()) {
2307
2721
  try {
@@ -2315,19 +2729,34 @@ export class AsyncJobManager {
2315
2729
  }
2316
2730
  this.#monitorTombstones.clear();
2317
2731
  const timeoutMs = options?.timeoutMs ?? 3_000;
2732
+ const disposalDeadline = Date.now() + Math.max(timeoutMs, 0);
2318
2733
  const waitResult = await this.#waitForAllWithDeadline(timeoutMs);
2319
- const drained = waitResult.completed ? await this.drainDeliveries({ timeoutMs }) : false;
2320
- this.#lastDisposeDiagnostics = { stuckJobIds: waitResult.stuckJobIds, deliveriesDrained: drained };
2734
+ const remainingDeliveryMs = Math.max(0, disposalDeadline - Date.now());
2735
+ const drained = await this.drainDeliveries({ timeoutMs: remainingDeliveryMs });
2736
+ if (!drained) this.#projectUndeliveredDisposalFailures();
2737
+ const disposalCompleted = waitResult.completed && drained;
2738
+ this.#lastDisposeDiagnostics = {
2739
+ stuckJobIds: waitResult.stuckJobIds,
2740
+ deliveriesDrained: disposalCompleted,
2741
+ };
2321
2742
  if (waitResult.stuckJobIds.length > 0) {
2322
2743
  logger.warn("Async job manager dispose timed out waiting for jobs", { stuckJobIds: waitResult.stuckJobIds });
2323
2744
  }
2324
2745
  this.#clearEvictionTimers();
2746
+ this.#disposed = true;
2325
2747
  this.#jobs.clear();
2326
2748
  this.#deliveries.length = 0;
2327
2749
  this.#inFlightDeliveries.length = 0;
2328
2750
  this.#deadLetteredDeliveries.clear();
2751
+ this.#evictedDeadLetters.clear();
2752
+ this.#parkedDeliveries.clear();
2753
+ this.#receiptClaims.clear();
2754
+ this.#externallySettled.clear();
2329
2755
  this.#deadLetteredDeliveryOwners.clear();
2756
+ this.#deadLetterOverflowByOwner.clear();
2757
+ this.#deadLetterOverflowRecordedAt.clear();
2330
2758
  this.#suppressedDeliveries.clear();
2759
+ this.#suppressedDeliveryJobIds.clear();
2331
2760
  this.#deliveryAckOwners.clear();
2332
2761
  this.#waitGenerationAliases.clear();
2333
2762
  this.#watchedJobs.clear();
@@ -2343,7 +2772,16 @@ export class AsyncJobManager {
2343
2772
  this.#ownerSubagentShutdownLeases.clear();
2344
2773
  this.#notifyChange();
2345
2774
  this.#changeListeners.clear();
2346
- return drained && waitResult.completed;
2775
+ return disposalCompleted;
2776
+ }
2777
+
2778
+ #projectUndeliveredDisposalFailures(): void {
2779
+ const retained = [...this.#deliveries, ...this.#inFlightDeliveries];
2780
+ for (const delivery of retained) {
2781
+ delivery.attempt = Math.max(1, delivery.attempt);
2782
+ delivery.lastError ??= "delivery did not settle before manager disposal deadline";
2783
+ this.#recordDeadLetterOrEvicted(delivery);
2784
+ }
2347
2785
  }
2348
2786
 
2349
2787
  #resolveJobId(preferredId?: string): string {
@@ -2352,7 +2790,11 @@ export class AsyncJobManager {
2352
2790
  let candidate = 1;
2353
2791
  while (true) {
2354
2792
  const id = `bg_${candidate}`;
2355
- if (!this.#jobs.has(id)) {
2793
+ // Never recycle an id that still has a queued or in-flight delivery: the
2794
+ // recycled record would give the pending old delivery a mismatched
2795
+ // generation and #deliverDelivery would silently discard it before
2796
+ // onJobComplete ever ran.
2797
+ if (!this.#jobs.has(id) && !this.#hasDeliveryCollisionForJobId(id)) {
2356
2798
  return id;
2357
2799
  }
2358
2800
  candidate += 1;
@@ -2360,22 +2802,33 @@ export class AsyncJobManager {
2360
2802
  }
2361
2803
 
2362
2804
  const base = preferredId.trim();
2363
- if (!this.#jobs.has(base)) return base;
2805
+ if (!this.#jobs.has(base) && !this.#hasDeliveryCollisionForJobId(base)) return base;
2364
2806
 
2365
2807
  let suffix = 2;
2366
2808
  let candidate = `${base}-${suffix}`;
2367
- while (this.#jobs.has(candidate)) {
2809
+ while (this.#jobs.has(candidate) || this.#hasDeliveryCollisionForJobId(candidate)) {
2368
2810
  suffix += 1;
2369
2811
  candidate = `${base}-${suffix}`;
2370
2812
  }
2371
2813
  return candidate;
2372
2814
  }
2373
2815
 
2816
+ #consumeAdmission(token: AsyncJobAdmission): boolean {
2817
+ if (token.manager !== this || this.#admissions.get(token) !== true) return false;
2818
+ this.#admissions.set(token, false);
2819
+ this.#pendingAdmissions -= 1;
2820
+ return true;
2821
+ }
2822
+
2374
2823
  #scheduleEviction(jobId: string): void {
2375
2824
  if (this.#disposed) return;
2376
2825
  this.#notifyChange();
2377
2826
  if (this.#retentionMs <= 0) {
2378
2827
  this.#evictJob(jobId);
2828
+ // The terminal notification above precedes eviction so consumers can see
2829
+ // the terminal transition; publish once more after the record disappears
2830
+ // so observers can project any delivery that still owns the generation.
2831
+ this.#notifyChange();
2379
2832
  return;
2380
2833
  }
2381
2834
  const existing = this.#evictionTimers.get(jobId);
@@ -2397,13 +2850,37 @@ export class AsyncJobManager {
2397
2850
  this.#runLifecycle(jobId, "evict");
2398
2851
  this.#purgeTerminalSubagentStateForJob(jobId);
2399
2852
  const job = this.#jobs.get(jobId);
2853
+ const deadLetter = this.#deadLetteredDeliveries.get(jobId);
2854
+ const failure =
2855
+ job?.deliveryFailure && (!deadLetter || deadLetter.generation === job.generation)
2856
+ ? job.deliveryFailure
2857
+ : deadLetter && job?.generation === deadLetter.generation
2858
+ ? deadLetter
2859
+ : undefined;
2860
+ if (job && failure) {
2861
+ this.#evictedDeadLetters.set(job.generation, {
2862
+ jobId: job.id,
2863
+ generation: job.generation,
2864
+ ownerId: this.#deadLetteredDeliveryOwners.get(jobId) ?? job.ownerId,
2865
+ backgrounded: job.metadata?.backgrounded === true,
2866
+ attempt: failure.attempt,
2867
+ lastError: failure.lastError,
2868
+ recordedAt: Date.now(),
2869
+ });
2870
+ while (this.#evictedDeadLetters.size > MAX_EVICTED_DEAD_LETTERS) {
2871
+ const oldestGeneration = this.#evictedDeadLetters.keys().next().value;
2872
+ if (oldestGeneration === undefined) break;
2873
+ const oldest = this.#evictedDeadLetters.get(oldestGeneration);
2874
+ if (oldest) this.#recordDeadLetterOverflow(oldest.ownerId);
2875
+ this.#evictedDeadLetters.delete(oldestGeneration);
2876
+ }
2877
+ }
2400
2878
  this.#jobs.delete(jobId);
2401
2879
  this.#settledJobIds.delete(jobId);
2402
2880
  this.#lifecycles.delete(jobId);
2403
2881
  this.#lifecyclePhases.delete(jobId);
2404
2882
  this.#deadLetteredDeliveries.delete(jobId);
2405
2883
  this.#deadLetteredDeliveryOwners.delete(jobId);
2406
- this.#suppressedDeliveries.delete(jobId);
2407
2884
  if (job) this.#publishedTerminalGenerations.delete(job.generation);
2408
2885
  this.#outputState.delete(jobId);
2409
2886
  }
@@ -2481,10 +2958,16 @@ export class AsyncJobManager {
2481
2958
  }
2482
2959
 
2483
2960
  #isDeliveryAcknowledged(jobId: string, generation?: string): boolean {
2484
- return (
2485
- (generation !== undefined && this.#suppressedDeliveries.has(generation)) ||
2486
- this.#suppressedDeliveries.has(jobId)
2487
- );
2961
+ if (generation !== undefined && this.#suppressedDeliveries.has(generation)) return true;
2962
+ for (const suppressedJobId of this.#suppressedDeliveryJobIds.values()) {
2963
+ if (suppressedJobId === jobId) return true;
2964
+ }
2965
+ return false;
2966
+ }
2967
+
2968
+ #suppressDelivery(jobId: string | null, generation: string): void {
2969
+ this.#suppressedDeliveries.add(generation);
2970
+ if (jobId !== null) this.#suppressedDeliveryJobIds.set(generation, jobId);
2488
2971
  }
2489
2972
 
2490
2973
  isDeliverySuppressed(jobId: string, generation?: string): boolean {
@@ -2504,10 +2987,88 @@ export class AsyncJobManager {
2504
2987
  }
2505
2988
  }
2506
2989
 
2990
+ /** Whether any queued or in-flight delivery still targets `jobId`. */
2991
+ #hasPendingDeliveryForJobId(jobId: string): boolean {
2992
+ return (
2993
+ this.#deliveries.some(delivery => delivery.jobId === jobId) ||
2994
+ this.#inFlightDeliveries.some(delivery => delivery.jobId === jobId)
2995
+ );
2996
+ }
2997
+
2998
+ /** Whether a retained delivery generation already claims `jobId`. */
2999
+ #hasDeliveryCollisionForJobId(jobId: string): boolean {
3000
+ if (this.#hasPendingDeliveryForJobId(jobId)) return true;
3001
+ for (const parked of this.#parkedDeliveries.values()) {
3002
+ if (parked.jobId === jobId) return true;
3003
+ }
3004
+ for (const claim of this.#receiptClaims.values()) {
3005
+ if (claim.jobId === jobId) return true;
3006
+ }
3007
+ for (const suppressedJobId of this.#suppressedDeliveryJobIds.values()) {
3008
+ if (suppressedJobId === jobId) return true;
3009
+ }
3010
+ if (this.#deadLetteredDeliveries.has(jobId)) return true;
3011
+ for (const entry of this.#evictedDeadLetters.values()) {
3012
+ if (entry.jobId === jobId) return true;
3013
+ }
3014
+ return false;
3015
+ }
3016
+
3017
+ /** Drop suppression projections once no retained state can deliver the generation. */
3018
+ #pruneSuppressedDeliveries(): void {
3019
+ for (const generation of this.#suppressedDeliveries) {
3020
+ let jobRetainsGeneration = false;
3021
+ for (const job of this.#jobs.values()) {
3022
+ if (job.generation === generation) {
3023
+ jobRetainsGeneration = true;
3024
+ break;
3025
+ }
3026
+ }
3027
+ if (jobRetainsGeneration) continue;
3028
+ if (this.#deliveries.some(delivery => delivery.generation === generation)) continue;
3029
+ if (this.#inFlightDeliveries.some(delivery => delivery.generation === generation)) continue;
3030
+ if (this.#parkedDeliveries.has(generation) || this.#receiptClaims.has(generation)) continue;
3031
+ if ([...this.#deadLetteredDeliveries.values()].some(entry => entry.generation === generation)) continue;
3032
+ if (this.#evictedDeadLetters.has(generation)) continue;
3033
+ this.#suppressedDeliveries.delete(generation);
3034
+ this.#suppressedDeliveryJobIds.delete(generation);
3035
+ }
3036
+ }
3037
+
3038
+ /**
3039
+ * Record a retry-cap failure whose job record is already gone.
3040
+ *
3041
+ * Ordering mirrors {@link AsyncJobManager.prototype} `#recordDeadLetter`:
3042
+ * insert, retire the exact owned tuple, then trim. Retiring before the trim
3043
+ * means a later trimmed entry can never strand registry authority.
3044
+ */
3045
+ #recordEvictedDeadLetter(delivery: AsyncJobDelivery): void {
3046
+ this.#evictedDeadLetters.delete(delivery.generation);
3047
+ this.#evictedDeadLetters.set(delivery.generation, {
3048
+ jobId: delivery.jobId,
3049
+ generation: delivery.generation,
3050
+ ownerId: delivery.ownerId,
3051
+ backgrounded: delivery.backgrounded,
3052
+ attempt: delivery.attempt,
3053
+ lastError: delivery.lastError,
3054
+ recordedAt: Date.now(),
3055
+ });
3056
+ retireOwnedRegistrationForDeadLetter(AsyncJobManager.endpointIdOf(this), delivery.jobId, delivery.generation);
3057
+ while (this.#evictedDeadLetters.size > MAX_EVICTED_DEAD_LETTERS) {
3058
+ const oldestGeneration = this.#evictedDeadLetters.keys().next().value;
3059
+ if (oldestGeneration === undefined) return;
3060
+ const oldest = this.#evictedDeadLetters.get(oldestGeneration);
3061
+ if (oldest) this.#recordDeadLetterOverflow(oldest.ownerId);
3062
+ this.#evictedDeadLetters.delete(oldestGeneration);
3063
+ }
3064
+ }
3065
+
2507
3066
  #recordDeadLetter(delivery: AsyncJobDelivery): void {
2508
3067
  this.#pruneEvictedDeadLetters();
2509
3068
  const currentJob = this.#jobs.get(delivery.jobId);
2510
3069
  if (!currentJob || currentJob.generation !== delivery.generation) return;
3070
+ const recordedAt = Date.now();
3071
+ currentJob.deliveryFailure = { attempt: delivery.attempt, lastError: delivery.lastError, recordedAt };
2511
3072
  this.#deadLetteredDeliveries.delete(delivery.jobId);
2512
3073
  this.#deadLetteredDeliveryOwners.delete(delivery.jobId);
2513
3074
  this.#deadLetteredDeliveries.set(delivery.jobId, {
@@ -2515,6 +3076,7 @@ export class AsyncJobManager {
2515
3076
  generation: delivery.generation,
2516
3077
  attempt: delivery.attempt,
2517
3078
  lastError: delivery.lastError,
3079
+ recordedAt,
2518
3080
  });
2519
3081
  this.#deadLetteredDeliveryOwners.set(delivery.jobId, delivery.ownerId);
2520
3082
  // The dead-lettered delivery never injects a message and has no later
@@ -2529,6 +3091,46 @@ export class AsyncJobManager {
2529
3091
  this.#deadLetteredDeliveries.delete(oldestJobId);
2530
3092
  this.#deadLetteredDeliveryOwners.delete(oldestJobId);
2531
3093
  }
3094
+ this.#notifyChange();
3095
+ }
3096
+
3097
+ #recordDeadLetterOverflow(ownerId: string | undefined): void {
3098
+ if (ownerId === undefined || this.#deadLetterOverflowByOwner.has(ownerId)) {
3099
+ this.#deadLetterOverflowByOwner.set(ownerId, (this.#deadLetterOverflowByOwner.get(ownerId) ?? 0) + 1);
3100
+ if (!this.#deadLetterOverflowRecordedAt.has(ownerId))
3101
+ this.#deadLetterOverflowRecordedAt.set(ownerId, Date.now());
3102
+ return;
3103
+ }
3104
+
3105
+ const namedOwners = [...this.#deadLetterOverflowByOwner.keys()].filter((key): key is string => key !== undefined);
3106
+ if (namedOwners.length >= this.#maxDeadLetterOverflowOwners) {
3107
+ const oldestOwner = namedOwners[0];
3108
+ if (oldestOwner !== undefined) {
3109
+ const oldestCount = this.#deadLetterOverflowByOwner.get(oldestOwner) ?? 0;
3110
+ const oldestRecordedAt = this.#deadLetterOverflowRecordedAt.get(oldestOwner);
3111
+ this.#deadLetterOverflowByOwner.delete(oldestOwner);
3112
+ this.#deadLetterOverflowRecordedAt.delete(oldestOwner);
3113
+ this.#deadLetterOverflowByOwner.set(
3114
+ undefined,
3115
+ (this.#deadLetterOverflowByOwner.get(undefined) ?? 0) + oldestCount,
3116
+ );
3117
+ const unknownRecordedAt = this.#deadLetterOverflowRecordedAt.get(undefined);
3118
+ if (oldestRecordedAt !== undefined) {
3119
+ this.#deadLetterOverflowRecordedAt.set(
3120
+ undefined,
3121
+ unknownRecordedAt === undefined ? oldestRecordedAt : Math.min(unknownRecordedAt, oldestRecordedAt),
3122
+ );
3123
+ }
3124
+ }
3125
+ }
3126
+ this.#deadLetterOverflowByOwner.set(ownerId, 1);
3127
+ this.#deadLetterOverflowRecordedAt.set(ownerId, Date.now());
3128
+ }
3129
+
3130
+ #recordDeadLetterOrEvicted(delivery: AsyncJobDelivery): void {
3131
+ const currentJob = this.#jobs.get(delivery.jobId);
3132
+ if (currentJob?.generation === delivery.generation) this.#recordDeadLetter(delivery);
3133
+ else this.#recordEvictedDeadLetter(delivery);
2532
3134
  }
2533
3135
 
2534
3136
  #enqueueDelivery(jobId: string, text: string): void {
@@ -2539,17 +3141,23 @@ export class AsyncJobManager {
2539
3141
  jobId,
2540
3142
  generation: job.generation,
2541
3143
  job,
3144
+ kind: job.type,
3145
+ label: job.label,
3146
+ status: job.status,
3147
+ backgrounded: job.metadata?.backgrounded === true,
2542
3148
  text: deliveryText.text,
2543
3149
  originalBytes: deliveryText.originalBytes,
2544
3150
  truncated: deliveryText.truncated,
2545
3151
  attempt: 0,
2546
3152
  nextAttemptAt: Date.now(),
2547
3153
  ownerId: job.ownerId,
3154
+ retryDuringDispose: this.#runningOwnerCleanups,
2548
3155
  });
2549
3156
  while (this.#deliveries.length > DEFAULT_MAX_DELIVERY_QUEUE) {
2550
3157
  const dropped = this.#deliveries.shift();
2551
- if (dropped) this.#recordDeadLetter(dropped);
3158
+ if (dropped) this.#recordDeadLetterOrEvicted(dropped);
2552
3159
  }
3160
+ this.#notifyChange();
2553
3161
  this.#ensureDeliveryLoop();
2554
3162
  }
2555
3163
 
@@ -2566,6 +3174,14 @@ export class AsyncJobManager {
2566
3174
  };
2567
3175
  }
2568
3176
 
3177
+ #boundedDeliveryErrorText(text: string): string {
3178
+ const bytes = Buffer.byteLength(text, "utf8");
3179
+ if (bytes <= DELIVERY_MAX_TEXT_BYTES) return text;
3180
+ const marker = ` [async delivery error truncated from ${bytes} bytes]`;
3181
+ const prefixBytes = Math.max(0, DELIVERY_MAX_TEXT_BYTES - Buffer.byteLength(marker, "utf8"));
3182
+ return `${sliceTextToUtf8ByteLength(text, prefixBytes)}${marker}`;
3183
+ }
3184
+
2569
3185
  #ensureDeliveryLoop(): void {
2570
3186
  if (this.#disposed) return;
2571
3187
  if (this.#deliveryLoop) {
@@ -2601,6 +3217,7 @@ export class AsyncJobManager {
2601
3217
  continue;
2602
3218
 
2603
3219
  this.#deliveries.splice(index, 1);
3220
+ this.#notifyChange();
2604
3221
  await this.#deliverDelivery(delivery);
2605
3222
  }
2606
3223
  }
@@ -2608,6 +3225,7 @@ export class AsyncJobManager {
2608
3225
  #deliverDelivery(delivery: AsyncJobDelivery): Promise<void> {
2609
3226
  const promise = (async () => {
2610
3227
  this.#inFlightDeliveries.push(delivery);
3228
+ this.#notifyChange();
2611
3229
  try {
2612
3230
  const currentJob = this.#jobs.get(delivery.jobId);
2613
3231
  if (currentJob && currentJob.generation !== delivery.generation) return;
@@ -2615,9 +3233,18 @@ export class AsyncJobManager {
2615
3233
  await this.#onJobComplete(delivery.jobId, delivery.text, delivery.job);
2616
3234
  } catch (error) {
2617
3235
  delivery.attempt += 1;
2618
- delivery.lastError = error instanceof Error ? error.message : String(error);
2619
- if (delivery.attempt >= DELIVERY_MAX_ATTEMPTS) {
2620
- this.#recordDeadLetter(delivery);
3236
+ delivery.lastError = this.#boundedDeliveryErrorText(error instanceof Error ? error.message : String(error));
3237
+ if (this.#disposed) {
3238
+ logger.warn("Async job completion delivery dropped after manager disposal", {
3239
+ jobId: delivery.jobId,
3240
+ attempt: delivery.attempt,
3241
+ error: delivery.lastError,
3242
+ });
3243
+ } else if (delivery.attempt >= DELIVERY_MAX_ATTEMPTS) {
3244
+ // Record the failure in the bounded scalar map when its job record has
3245
+ // already been evicted; otherwise #recordDeadLetter is pruned by record
3246
+ // existence and the terminal failure would become invisible.
3247
+ this.#recordDeadLetterOrEvicted(delivery);
2621
3248
  logger.warn("Async job completion delivery reached retry cap", {
2622
3249
  jobId: delivery.jobId,
2623
3250
  attempt: delivery.attempt,
@@ -2626,11 +3253,15 @@ export class AsyncJobManager {
2626
3253
  } else {
2627
3254
  delivery.nextAttemptAt = Date.now() + this.#getRetryDelay(delivery.attempt);
2628
3255
  const currentJob = this.#jobs.get(delivery.jobId);
3256
+ // An evicted record must not end retry eligibility: dispatch already
3257
+ // uses the retained delivery.job when #jobs no longer holds the record,
3258
+ // so only a PRESENT superseding generation discards.
2629
3259
  if (
2630
- currentJob?.generation === delivery.generation &&
3260
+ (currentJob === undefined || currentJob.generation === delivery.generation) &&
2631
3261
  !this.#isDeliveryAcknowledged(delivery.jobId, delivery.generation)
2632
3262
  ) {
2633
3263
  this.#deliveries.push(delivery);
3264
+ this.#notifyChange();
2634
3265
  }
2635
3266
  logger.warn("Async job completion delivery failed", {
2636
3267
  jobId: delivery.jobId,
@@ -2642,6 +3273,7 @@ export class AsyncJobManager {
2642
3273
  } finally {
2643
3274
  const index = this.#inFlightDeliveries.indexOf(delivery);
2644
3275
  if (index !== -1) this.#inFlightDeliveries.splice(index, 1);
3276
+ this.#notifyChange();
2645
3277
  if (!this.#disposed && this.#hasDeliverable()) this.#ensureDeliveryLoop();
2646
3278
  }
2647
3279
  })();