@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.
- package/CHANGELOG.md +85 -1
- package/dist/types/async/job-manager.d.ts +83 -0
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
- package/dist/types/cli/model-presets-cli.d.ts +9 -0
- package/dist/types/cli/root-flags.d.ts +14 -0
- package/dist/types/cli/update-cli.d.ts +15 -0
- package/dist/types/cli.d.ts +7 -0
- package/dist/types/commands/auth-gateway.d.ts +3 -0
- package/dist/types/commands/gc.d.ts +13 -0
- package/dist/types/commands/launch.d.ts +10 -0
- package/dist/types/commands/model-presets.d.ts +21 -0
- package/dist/types/config/autorouting-tier-map.d.ts +120 -0
- package/dist/types/config/file-lock.d.ts +54 -2
- package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
- package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
- package/dist/types/config/keybindings.d.ts +3 -2
- package/dist/types/config/model-preset-registry.d.ts +368 -0
- package/dist/types/config/model-profile-activation.d.ts +39 -0
- package/dist/types/config/model-profiles.d.ts +5 -3
- package/dist/types/config/model-registry.d.ts +17 -5
- package/dist/types/config/settings-schema.d.ts +19 -1
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
- package/dist/types/coordinator-mcp/projection-scan.d.ts +54 -0
- package/dist/types/crash/record-loader.d.ts +15 -0
- package/dist/types/extensibility/extensions/types.d.ts +2 -5
- package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
- package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +72 -1
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/main.d.ts +3 -1
- package/dist/types/master-mode/context.d.ts +21 -0
- package/dist/types/master-mode/first-request.d.ts +34 -0
- package/dist/types/master-mode/snapshot.d.ts +32 -0
- package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
- package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
- package/dist/types/modes/components/oauth-selector.d.ts +1 -0
- package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
- package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
- package/dist/types/modes/components/status-line/segments.d.ts +10 -0
- package/dist/types/modes/jobs-observer.d.ts +36 -0
- package/dist/types/modes/print-mode.d.ts +17 -0
- package/dist/types/modes/ui-language.d.ts +7 -1
- package/dist/types/sdk/broker/broker.d.ts +80 -1
- package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
- package/dist/types/sdk/broker/index.d.ts +3 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +26 -0
- package/dist/types/sdk/broker/master-capability.d.ts +10 -0
- package/dist/types/sdk/broker/session-index.d.ts +40 -11
- package/dist/types/sdk/broker/session-scope.d.ts +72 -0
- package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
- package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
- package/dist/types/sdk/broker/state-version.d.ts +4 -1
- package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
- package/dist/types/sdk/bus/index.d.ts +6 -0
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/cli/index.d.ts +1 -0
- package/dist/types/sdk/cli/master-cli.d.ts +63 -0
- package/dist/types/sdk/cli/rows.d.ts +4 -3
- package/dist/types/sdk/cli/session-cli.d.ts +18 -2
- package/dist/types/sdk/client/client.d.ts +3 -2
- package/dist/types/sdk/client/discovery.d.ts +1 -0
- package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
- package/dist/types/sdk/host/control/index.d.ts +1 -1
- package/dist/types/sdk/host/control/operations.d.ts +2 -0
- package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
- package/dist/types/sdk/host/host.d.ts +10 -0
- package/dist/types/sdk/host/model-pin.d.ts +7 -2
- package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
- package/dist/types/sdk/host/session-runtime.d.ts +67 -1
- package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
- package/dist/types/sdk/lifecycle/service.d.ts +15 -3
- package/dist/types/sdk/lifecycle-session.d.ts +2 -0
- package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
- package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
- package/dist/types/sdk/router/session-router.d.ts +11 -0
- package/dist/types/sdk/session.d.ts +16 -0
- package/dist/types/sdk/startup-capability.d.ts +2 -1
- package/dist/types/session/account-inventory.d.ts +1 -0
- package/dist/types/session/agent-session.d.ts +24 -7
- package/dist/types/session/credential-store-errors.d.ts +1 -0
- package/dist/types/session/fold-coordinator.d.ts +201 -0
- package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
- package/dist/types/session/sdk-run-capability.d.ts +2 -0
- package/dist/types/session/session-manager.d.ts +65 -0
- package/dist/types/session/yield-queue.d.ts +5 -4
- package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
- package/dist/types/telemetry/control.d.ts +11 -0
- package/dist/types/telemetry/events.d.ts +20 -0
- package/dist/types/telemetry/index.d.ts +3 -0
- package/dist/types/telemetry/transport.d.ts +22 -0
- package/dist/types/tools/bash-interactive.d.ts +23 -0
- package/dist/types/tools/bash.d.ts +4 -0
- package/dist/types/tools/gh.d.ts +8 -0
- package/dist/types/tools/index.d.ts +10 -5
- package/dist/types/web/search/providers/base.d.ts +9 -4
- package/dist/types/web/search/providers/xai.d.ts +2 -1
- package/dist/types/web/search/types.d.ts +14 -0
- package/package.json +10 -7
- package/scripts/generate-hotkeys-docs.ts +4 -0
- package/scripts/generate-sdk-operation-inventory.ts +9 -3
- package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
- package/src/async/job-manager.ts +671 -39
- package/src/cli/accounts-cli.ts +9 -5
- package/src/cli/args.ts +17 -0
- package/src/cli/auth-broker-cli.ts +40 -12
- package/src/cli/auth-gateway-cli.ts +273 -97
- package/src/cli/config-cli.ts +2 -2
- package/src/cli/fast-help.ts +4 -0
- package/src/cli/model-presets-cli.ts +100 -0
- package/src/cli/root-flags.ts +5 -0
- package/src/cli/update-cli.ts +117 -16
- package/src/cli.ts +6 -2
- package/src/commands/auth-gateway.ts +12 -8
- package/src/commands/gc.ts +19 -0
- package/src/commands/launch.ts +86 -18
- package/src/commands/model-presets.ts +24 -0
- package/src/commands/sdk.ts +113 -21
- package/src/config/autorouting-tier-map.ts +44 -0
- package/src/config/file-lock-gc.ts +9 -4
- package/src/config/file-lock.ts +890 -99
- package/src/config/internal/model-preset-registry-test-state.ts +28 -0
- package/src/config/internal/model-preset-registry-test-support.ts +11 -0
- package/src/config/keybindings.ts +6 -2
- package/src/config/model-preset-registry.ts +2795 -0
- package/src/config/model-profile-activation.ts +115 -28
- package/src/config/model-profile-contract.ts +4 -1
- package/src/config/model-profiles.ts +56 -14
- package/src/config/model-registry.ts +1044 -122
- package/src/config/model-resolver.ts +5 -0
- package/src/config/settings-schema.ts +19 -1
- package/src/config/settings.ts +11 -1
- package/src/coordinator-mcp/model-preset.ts +32 -17
- package/src/coordinator-mcp/projection-scan.ts +592 -0
- package/src/coordinator-mcp/question-state.ts +9 -11
- package/src/coordinator-mcp/server.ts +111 -42
- package/src/coordinator-mcp/session-reaper.ts +9 -3
- package/src/crash/index-store.ts +2 -0
- package/src/crash/record-loader.ts +10 -0
- package/src/extensibility/extensions/loader.ts +0 -1
- package/src/extensibility/extensions/types.ts +2 -3
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
- package/src/gjc-runtime/empty-delete-gc.ts +301 -0
- package/src/gjc-runtime/gc-render.ts +29 -0
- package/src/gjc-runtime/gc-runtime.ts +174 -11
- package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
- package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
- package/src/gjc-runtime/session-state-lock.ts +1321 -147
- package/src/gjc-runtime/session-state-sidecar.ts +1004 -25
- package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
- package/src/gjc-runtime/tmux-owner-isolation.ts +2 -2
- package/src/gjc-runtime/tmux-sessions.ts +252 -13
- package/src/hooks/skill-state.ts +6 -0
- package/src/index.ts +1 -0
- package/src/internal-urls/docs-index.generated.ts +14 -13
- package/src/internal-urls/issue-pr-protocol.ts +4 -2
- package/src/lsp/config.ts +4 -3
- package/src/lsp/path-trust.ts +12 -6
- package/src/main.ts +66 -4
- package/src/master-mode/context.ts +50 -0
- package/src/master-mode/first-request.ts +65 -0
- package/src/master-mode/snapshot.ts +205 -0
- package/src/modes/acp/acp-agent.ts +616 -161
- package/src/modes/components/footer.ts +4 -2
- package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
- package/src/modes/components/jobs-overlay-model.ts +99 -16
- package/src/modes/components/model-selector.ts +158 -18
- package/src/modes/components/oauth-selector.ts +6 -1
- package/src/modes/components/settings-selector.ts +1 -0
- package/src/modes/components/status-line/model-name.ts +53 -0
- package/src/modes/components/status-line/priority-row.ts +107 -0
- package/src/modes/components/status-line/segments.ts +29 -10
- package/src/modes/components/tool-status-header.ts +151 -15
- package/src/modes/controllers/extension-ui-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +51 -6
- package/src/modes/controllers/selector-controller.ts +191 -96
- package/src/modes/interactive-mode.ts +14 -1
- package/src/modes/jobs-observer.ts +149 -16
- package/src/modes/print-mode.ts +64 -0
- package/src/modes/runtime-init.ts +1 -1
- package/src/modes/ui-language.ts +49 -2
- package/src/modes/utils/injected-user-submission.ts +15 -1
- package/src/prompts/system/master-mode.md +28 -0
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/fold-receipt.md +3 -0
- package/src/prompts/tools/job.md +1 -1
- package/src/sdk/acp/adapter.ts +17 -2
- package/src/sdk/broker/broker.ts +1671 -34
- package/src/sdk/broker/endpoint-authority.ts +86 -0
- package/src/sdk/broker/ensure.ts +5 -0
- package/src/sdk/broker/index.ts +3 -0
- package/src/sdk/broker/lifecycle.ts +217 -46
- package/src/sdk/broker/master-capability.ts +232 -0
- package/src/sdk/broker/session-index.ts +121 -17
- package/src/sdk/broker/session-scope.ts +272 -0
- package/src/sdk/broker/spawn-authority.ts +1031 -0
- package/src/sdk/broker/spawn-substrate.ts +548 -0
- package/src/sdk/broker/state-version.ts +26 -14
- package/src/sdk/broker/transport.ts +1 -0
- package/src/sdk/broker/worktree-occupancy.ts +90 -0
- package/src/sdk/bus/chat-daemon-control.ts +5 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +46 -5
- package/src/sdk/bus/index.ts +51 -4
- package/src/sdk/bus/kind-aware-reconciliation.ts +36 -6
- package/src/sdk/bus/prompt-reconciliation.ts +28 -4
- package/src/sdk/bus/slack-conversation.ts +5 -1
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/cli/index.ts +1 -0
- package/src/sdk/cli/master-cli.ts +259 -0
- package/src/sdk/cli/rows.ts +11 -8
- package/src/sdk/cli/session-cli.ts +276 -6
- package/src/sdk/client/client.ts +14 -8
- package/src/sdk/client/discovery.ts +3 -1
- package/src/sdk/host/control/dispatch.ts +101 -37
- package/src/sdk/host/control/index.ts +2 -0
- package/src/sdk/host/control/operations.ts +2 -0
- package/src/sdk/host/control/runtime-gate.ts +203 -11
- package/src/sdk/host/host.ts +27 -2
- package/src/sdk/host/model-pin.ts +185 -25
- package/src/sdk/host/sdk-run-capability.ts +1 -0
- package/src/sdk/host/session-runtime.test.ts +581 -43
- package/src/sdk/host/session-runtime.ts +655 -101
- package/src/sdk/lifecycle/broker-client.ts +28 -0
- package/src/sdk/lifecycle/service.ts +279 -20
- package/src/sdk/lifecycle-session.ts +10 -2
- package/src/sdk/prompt-deadline-manager.ts +16 -1
- package/src/sdk/protocol/adapter-validation.ts +27 -0
- package/src/sdk/protocol/operation-inventory.generated.json +53 -2
- package/src/sdk/protocol/operation-registry.ts +17 -4
- package/src/sdk/router/session-router.ts +294 -154
- package/src/sdk/session.ts +440 -167
- package/src/sdk/startup-capability.ts +1 -0
- package/src/session/account-inventory.ts +27 -11
- package/src/session/agent-session.ts +1292 -180
- package/src/session/blob-store.ts +15 -5
- package/src/session/credential-store-errors.ts +2 -0
- package/src/session/fold-coordinator.ts +433 -0
- package/src/session/internal/managed-session-storage.ts +153 -78
- package/src/session/sdk-run-capability-internal.ts +15 -0
- package/src/session/sdk-run-capability.ts +2 -0
- package/src/session/session-manager.ts +342 -28
- package/src/session/yield-queue.ts +24 -10
- package/src/setup/frictionless-onboarding.ts +2 -0
- package/src/setup/provider-presets.json +1 -1
- package/src/slash-commands/helpers/credential-switch.ts +6 -1
- package/src/task/executor.ts +19 -3
- package/src/telemetry/control.ts +26 -0
- package/src/telemetry/events.ts +1268 -0
- package/src/telemetry/index.ts +3 -0
- package/src/telemetry/transport.ts +87 -0
- package/src/tools/bash-interactive.ts +182 -85
- package/src/tools/bash.ts +1028 -109
- package/src/tools/gh.ts +104 -48
- package/src/tools/github-cache.ts +2 -2
- package/src/tools/index.ts +11 -5
- package/src/tools/tool-catalog.generated.ts +1 -1
- package/src/web/search/provider.ts +120 -11
- package/src/web/search/providers/anthropic.ts +89 -17
- package/src/web/search/providers/base.ts +9 -4
- package/src/web/search/providers/gemini.ts +87 -15
- package/src/web/search/providers/openai-compatible.ts +23 -8
- package/src/web/search/providers/xai.ts +98 -12
- package/src/web/search/types.ts +16 -0
package/src/async/job-manager.ts
CHANGED
|
@@ -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.#
|
|
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
|
-
|
|
918
|
-
|
|
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
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
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
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
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:
|
|
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
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
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
|
|
2320
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2486
|
-
|
|
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.#
|
|
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 (
|
|
2620
|
-
|
|
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
|
|
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
|
})();
|