@capybara-hq/kernel 0.1.0-rc.23 → 0.1.0-rc.24

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 (206) hide show
  1. package/bootstrap.json +25 -25
  2. package/dist/admin/admin.module.d.ts +2 -1
  3. package/dist/admin/admin.module.js +7 -3
  4. package/dist/admin/outbox-dead.controller.d.ts +23 -0
  5. package/dist/admin/outbox-dead.controller.js +62 -0
  6. package/dist/admin/outbox-dead.schemas.d.ts +8 -0
  7. package/dist/admin/outbox-dead.schemas.js +18 -0
  8. package/dist/admin/outbox-dead.service.d.ts +66 -0
  9. package/dist/admin/outbox-dead.service.js +179 -0
  10. package/dist/admin/overview.service.d.ts +40 -5
  11. package/dist/admin/overview.service.js +41 -7
  12. package/dist/agents/from-template.js +1 -1
  13. package/dist/background/finish.d.ts +5 -2
  14. package/dist/background/finish.js +14 -3
  15. package/dist/background/launch.d.ts +2 -1
  16. package/dist/background/launch.js +5 -2
  17. package/dist/background/store.d.ts +8 -0
  18. package/dist/background/store.js +24 -0
  19. package/dist/catalog/admin-catalog.controller.d.ts +21 -2
  20. package/dist/catalog/admin-catalog.controller.js +45 -6
  21. package/dist/catalog/admin-catalog.schemas.d.ts +5 -0
  22. package/dist/catalog/admin-catalog.schemas.js +8 -0
  23. package/dist/catalog/catalog.module.d.ts +2 -1
  24. package/dist/catalog/catalog.module.js +5 -1
  25. package/dist/catalog/marketplaces.service.d.ts +1 -1
  26. package/dist/catalog/platform-import-update.d.ts +50 -0
  27. package/dist/catalog/platform-import-update.js +116 -0
  28. package/dist/catalog/plugin-import.d.ts +36 -1
  29. package/dist/catalog/plugin-import.js +23 -1
  30. package/dist/catalog/plugin-upgrade.d.ts +18 -0
  31. package/dist/catalog/plugin-upgrade.js +19 -0
  32. package/dist/channels/channels.module.d.ts +5 -2
  33. package/dist/channels/channels.module.js +9 -2
  34. package/dist/channels/deliver-job.d.ts +25 -0
  35. package/dist/channels/deliver-job.js +25 -0
  36. package/dist/channels/inbound.d.ts +1 -1
  37. package/dist/channels/inbound.js +2 -15
  38. package/dist/channels/jobs.d.ts +28 -18
  39. package/dist/channels/jobs.js +63 -26
  40. package/dist/config/schema.d.ts +0 -3
  41. package/dist/config/schema.js +11 -20
  42. package/dist/conversations/agent-tree.d.ts +16 -0
  43. package/dist/conversations/agent-tree.js +36 -0
  44. package/dist/conversations/conversations.service.d.ts +44 -14
  45. package/dist/conversations/conversations.service.js +52 -38
  46. package/dist/conversations/event-log.d.ts +7 -10
  47. package/dist/conversations/event-log.js +24 -15
  48. package/dist/conversations/events.js +2 -1
  49. package/dist/conversations/queue.service.d.ts +8 -3
  50. package/dist/conversations/queue.service.js +37 -13
  51. package/dist/conversations/titles.d.ts +21 -8
  52. package/dist/conversations/titles.js +42 -15
  53. package/dist/db/kernel-schema.d.ts +26 -8
  54. package/dist/devices/devices.service.d.ts +12 -3
  55. package/dist/devices/devices.service.js +18 -3
  56. package/dist/errors.d.ts +11 -0
  57. package/dist/errors.js +11 -0
  58. package/dist/evals/run-replay.js +14 -8
  59. package/dist/extensions/extensions.module.js +3 -0
  60. package/dist/extensions/lifecycle.d.ts +6 -1
  61. package/dist/extensions/lifecycle.js +9 -2
  62. package/dist/extensions/usage-subscribers.d.ts +43 -0
  63. package/dist/extensions/usage-subscribers.js +108 -0
  64. package/dist/http/api.module.js +2 -0
  65. package/dist/index.d.ts +1 -0
  66. package/dist/index.js +1 -0
  67. package/dist/infra/cli/kernel-cli.module.js +5 -0
  68. package/dist/infra/events/kernel-events.d.ts +9 -35
  69. package/dist/infra/outbox/outbox-relay.module.d.ts +6 -0
  70. package/dist/infra/outbox/outbox-relay.module.js +18 -0
  71. package/dist/infra/outbox/outbox.dispatcher.d.ts +103 -0
  72. package/dist/infra/outbox/outbox.dispatcher.js +310 -0
  73. package/dist/infra/outbox/outbox.module.d.ts +24 -0
  74. package/dist/infra/outbox/outbox.module.js +50 -0
  75. package/dist/infra/outbox/outbox.relay.d.ts +31 -0
  76. package/dist/infra/outbox/outbox.relay.js +76 -0
  77. package/dist/infra/outbox/outbox.service.d.ts +51 -0
  78. package/dist/infra/outbox/outbox.service.js +131 -0
  79. package/dist/infra/outbox/registry.d.ts +120 -0
  80. package/dist/infra/outbox/registry.js +108 -0
  81. package/dist/infra/queue/queue-names.d.ts +8 -0
  82. package/dist/infra/queue/queue-names.js +16 -0
  83. package/dist/kernel.module.js +3 -0
  84. package/dist/local-devices/device-policy.service.d.ts +4 -0
  85. package/dist/local-devices/device-policy.service.js +61 -3
  86. package/dist/local-devices/machines.d.ts +13 -5
  87. package/dist/local-devices/machines.js +45 -7
  88. package/dist/local-devices/policy-summary.d.ts +44 -1
  89. package/dist/local-devices/policy-summary.js +69 -0
  90. package/dist/models/embeddings.d.ts +9 -9
  91. package/dist/models/embeddings.js +11 -18
  92. package/dist/models/models.module.js +3 -0
  93. package/dist/orgs/orgs.service.js +7 -0
  94. package/dist/overrides.d.ts +20 -3
  95. package/dist/overrides.js +10 -2
  96. package/dist/platform/exclusions.js +5 -0
  97. package/dist/platform/ops/admin.ops.js +18 -2
  98. package/dist/platform/ops/catalog-admin.ops.d.ts +3 -1
  99. package/dist/platform/ops/catalog-admin.ops.js +32 -4
  100. package/dist/platform/ops/outbox.ops.d.ts +21 -0
  101. package/dist/platform/ops/outbox.ops.js +125 -0
  102. package/dist/platform/ops/schedules.ops.js +40 -1
  103. package/dist/platform/platform-ops.module.d.ts +2 -1
  104. package/dist/platform/platform-ops.module.js +3 -0
  105. package/dist/platform/route-schemas.js +3 -0
  106. package/dist/roles/worker.module.js +9 -4
  107. package/dist/runtime/langgraph-loop.js +13 -5
  108. package/dist/runtime/limits.d.ts +15 -0
  109. package/dist/runtime/limits.js +25 -0
  110. package/dist/runtime/loop.d.ts +8 -3
  111. package/dist/runtime/loop.js +2 -1
  112. package/dist/runtime/maintenance.d.ts +4 -2
  113. package/dist/runtime/maintenance.js +17 -2
  114. package/dist/runtime/model-call.d.ts +14 -16
  115. package/dist/runtime/model-call.js +14 -24
  116. package/dist/runtime/queue-lost-jobs.d.ts +24 -0
  117. package/dist/runtime/queue-lost-jobs.js +51 -0
  118. package/dist/runtime/run-control.d.ts +42 -5
  119. package/dist/runtime/run-control.js +66 -12
  120. package/dist/runtime/run-finish.d.ts +39 -12
  121. package/dist/runtime/run-finish.js +97 -14
  122. package/dist/runtime/run-jobs.d.ts +95 -13
  123. package/dist/runtime/run-jobs.js +199 -28
  124. package/dist/runtime/run-orphan-check.job.d.ts +142 -0
  125. package/dist/runtime/run-orphan-check.job.js +339 -0
  126. package/dist/runtime/run-processors.d.ts +16 -9
  127. package/dist/runtime/run-processors.js +1 -1
  128. package/dist/runtime/run-queue.d.ts +64 -55
  129. package/dist/runtime/run-queue.js +147 -284
  130. package/dist/runtime/run-steps.d.ts +1 -2
  131. package/dist/runtime/run-steps.js +8 -11
  132. package/dist/runtime/run-worker.d.ts +6 -17
  133. package/dist/runtime/run-worker.js +32 -34
  134. package/dist/runtime/runtime.module.d.ts +2 -3
  135. package/dist/runtime/runtime.module.js +5 -6
  136. package/dist/runtime/step-log.d.ts +16 -4
  137. package/dist/runtime/step-log.js +26 -6
  138. package/dist/runtime/stream.controller.d.ts +2 -4
  139. package/dist/runtime/stream.controller.js +5 -11
  140. package/dist/runtime/subagents/child.js +1 -1
  141. package/dist/runtime/subagents/definition.d.ts +2 -1
  142. package/dist/runtime/subagents/definition.js +4 -2
  143. package/dist/runtime/subagents/deliveries.d.ts +6 -3
  144. package/dist/runtime/subagents/deliveries.js +16 -7
  145. package/dist/runtime/subagents/dispatch.d.ts +12 -12
  146. package/dist/runtime/subagents/dispatch.js +13 -38
  147. package/dist/runtime/subagents/messages.d.ts +5 -1
  148. package/dist/runtime/subagents/messages.js +11 -3
  149. package/dist/runtime/subagents/skill-fork.d.ts +3 -3
  150. package/dist/runtime/subagents/skill-fork.js +5 -5
  151. package/dist/runtime/subagents/subagents.service.js +1 -1
  152. package/dist/runtime/subagents/tree-jobs.d.ts +61 -0
  153. package/dist/runtime/subagents/tree-jobs.js +86 -0
  154. package/dist/runtime/usage-report-jobs.d.ts +23 -48
  155. package/dist/runtime/usage-report-jobs.js +67 -119
  156. package/dist/runtime/usage-report.d.ts +28 -77
  157. package/dist/runtime/usage-report.js +35 -213
  158. package/dist/runtime/waits.d.ts +48 -198
  159. package/dist/runtime/waits.js +103 -805
  160. package/dist/sandbox/sandbox.service.d.ts +17 -4
  161. package/dist/sandbox/sandbox.service.js +55 -14
  162. package/dist/schedules/calendar.d.ts +134 -0
  163. package/dist/schedules/calendar.js +410 -0
  164. package/dist/schedules/fire.d.ts +1 -3
  165. package/dist/schedules/fire.js +5 -10
  166. package/dist/schedules/run-tracker.d.ts +0 -5
  167. package/dist/schedules/run-tracker.js +0 -13
  168. package/dist/schedules/scan.job.js +15 -20
  169. package/dist/schedules/schedule-math.d.ts +42 -0
  170. package/dist/schedules/schedule-math.js +52 -0
  171. package/dist/schedules/scheduled-tasks.service.d.ts +16 -0
  172. package/dist/schedules/scheduled-tasks.service.js +53 -0
  173. package/dist/schedules/schedules.controller.d.ts +5 -1
  174. package/dist/schedules/schedules.controller.js +15 -1
  175. package/dist/schedules/schedules.schemas.d.ts +35 -0
  176. package/dist/schedules/schedules.schemas.js +27 -0
  177. package/dist/schedules/store.d.ts +5 -0
  178. package/dist/schedules/store.js +20 -0
  179. package/dist/tools/approvals.service.d.ts +26 -7
  180. package/dist/tools/approvals.service.js +88 -10
  181. package/dist/tools/builtin-names.d.ts +5 -3
  182. package/dist/tools/builtin-names.js +12 -4
  183. package/dist/tools/builtins/builtin-tools.d.ts +1 -2
  184. package/dist/tools/builtins/builtin-tools.js +21 -17
  185. package/dist/tools/builtins/sandbox/bash.js +9 -3
  186. package/dist/tools/builtins/sandbox/common.d.ts +18 -1
  187. package/dist/tools/builtins/sandbox/common.js +14 -1
  188. package/dist/tools/builtins/sandbox/index.d.ts +2 -1
  189. package/dist/tools/builtins/sandbox/index.js +4 -1
  190. package/dist/tools/builtins/sandbox/task-list.d.ts +12 -0
  191. package/dist/tools/builtins/sandbox/task-list.js +130 -0
  192. package/dist/tools/builtins/sandbox/task-output.d.ts +8 -1
  193. package/dist/tools/builtins/sandbox/task-output.js +21 -3
  194. package/dist/tools/builtins/web/extract.d.ts +2 -4
  195. package/dist/tools/builtins/web/extract.js +2 -5
  196. package/dist/tools/pipeline.d.ts +1 -1
  197. package/dist/tools/pipeline.js +1 -1
  198. package/dist/tools/questions.service.d.ts +15 -6
  199. package/dist/tools/questions.service.js +20 -8
  200. package/dist/tools/registry.d.ts +4 -2
  201. package/dist/tools/registry.js +4 -3
  202. package/dist/tools/tools.module.js +4 -3
  203. package/package.json +3 -3
  204. package/src/migrations/0118_device_policy_ops.sql +6 -0
  205. package/src/migrations/0119_outbox.sql +22 -0
  206. package/src/migrations/0120_drop_usage_report_sweep.sql +7 -0
@@ -0,0 +1,142 @@
1
+ import { EventLog } from "../conversations/event-log.js";
2
+ import type { ConversationKind, KernelSchemaDb } from "../db/kernel-schema.js";
3
+ import { KernelConfigService } from "../infra/config/kernel-config.service.js";
4
+ import { KernelDb } from "../infra/database/kernel-db.js";
5
+ import { TransactionHost } from "../infra/database/transaction-host.js";
6
+ import { KernelEvents } from "../infra/events/kernel-events.js";
7
+ import { LockService } from "../infra/lock/lock.service.js";
8
+ import type { KernelLogger } from "../log.js";
9
+ import { KyselyCheckpointSaver } from "./checkpointer.js";
10
+ import { ConversationLock } from "./conversation-lock.js";
11
+ import { QueueLostJobs } from "./queue-lost-jobs.js";
12
+ import { RunFinish } from "./run-finish.js";
13
+ import { RunJobs } from "./run-jobs.js";
14
+ import { RunQueue } from "./run-queue.js";
15
+ import { Waits } from "./waits.js";
16
+ /** The check's `@LockedInterval` job name (its run lock key is the default `cron:run-orphan-check`). */
17
+ export declare const RUN_ORPHAN_CHECK_JOB = "run-orphan-check";
18
+ /** The check's period: a fixed constant, not a config field. */
19
+ export declare const RUN_ORPHAN_CHECK_PERIOD_MS = 60000;
20
+ /**
21
+ * How long a run must have been in its state (queued, or running since its last start) before it
22
+ * can count as an orphan: a transition in flight is never repaired.
23
+ */
24
+ export declare const RUN_ORPHAN_MIN_AGE_MS = 60000;
25
+ /** The candidates one page of a pass reads (a keyset page over the state's start time and id). */
26
+ export declare const RUN_ORPHAN_CHECK_BATCH = 500;
27
+ /**
28
+ * The most pages one pass reads: a bound on its work. Healthy long runs and queued runs waiting
29
+ * for a quota slot are passed over page by page, so a real orphan behind them is still reached;
30
+ * beyond this many candidates the rest wait for the next minute.
31
+ */
32
+ export declare const RUN_ORPHAN_CHECK_MAX_PAGES = 10;
33
+ /** The error the canary logs (transactional outbox spec §8.3). */
34
+ export declare const QUEUE_LOST_JOBS_MESSAGE = "queued run without a job: the queue lost jobs";
35
+ /** What one pass of {@link RunOrphanCheck.check} repaired. */
36
+ export interface RunOrphanCheckResult {
37
+ /** `running` runs nobody runs, failed `run.orphaned`. */
38
+ runningRepaired: number;
39
+ /** `queued` runs whose job was lost, owed again (each one is the canary). */
40
+ queuedReadded: number;
41
+ }
42
+ /** An active run that may be an orphan, with where its job lives. */
43
+ export interface OrphanCandidate {
44
+ runId: string;
45
+ conversationId: string;
46
+ orgId: string;
47
+ kind: ConversationKind;
48
+ status: "queued" | "running";
49
+ /** The run's job id (a resume's `runs.job_id`, else its trigger's) and queue. */
50
+ jobId: string;
51
+ queue: string;
52
+ /** When the run entered its state, as PG prints it (compared again in the repair). */
53
+ since: string;
54
+ }
55
+ /**
56
+ * One page of the active runs of chat and sub-agent conversations in `statuses` that entered their
57
+ * state more than `minAgeMs` ago, by (state start, id), after `after` (an eval's hidden
58
+ * conversation drives its runs itself).
59
+ */
60
+ export declare function orphanCandidates(db: KernelSchemaDb, statuses: readonly ("queued" | "running")[], minAgeMs: number, limit: number, after?: Pick<OrphanCandidate, "since" | "runId">): Promise<OrphanCandidate[]>;
61
+ /**
62
+ * Whether the outbox holds the job `jobId` on `queue`: a pending row (it is still owed) or a dead
63
+ * one (the outbox gave up on it; the super admin sees it under `outbox_dead` and retries or
64
+ * discards it, so it is not a job the queue lost).
65
+ */
66
+ export declare function jobInOutbox(db: KernelSchemaDb, queue: string, jobId: string): Promise<boolean>;
67
+ /**
68
+ * The `queued` runs (in their state for over `minAgeMs`) whose job is neither in the outbox
69
+ * (owed, or dead) nor in BullMQ (`jobExists`), and that are still queued after both looks: the run
70
+ * queue lost their jobs. The outbox is read before BullMQ (a row is deleted only after its job was added) and the
71
+ * run again after both (a job that finished took its run out of `queued` first), so a run in
72
+ * flight is never reported. The `run-orphan-check` uses it; so does the dual-replica test harness,
73
+ * which asserts at its teardown that the kernel left none (`minAgeMs` 0).
74
+ */
75
+ export declare function queuedRunsWithoutJob(db: KernelSchemaDb, jobExists: (queue: string, jobId: string) => Promise<boolean>, opts: {
76
+ minAgeMs: number;
77
+ }): Promise<OrphanCandidate[]>;
78
+ /**
79
+ * The `run-orphan-check` (transactional outbox spec §6 "Replica death during a run", §8.3): once a
80
+ * minute across replicas (worker role), the one repair of a run nobody runs. A run counts when it
81
+ * has been in its state for over {@link RUN_ORPHAN_MIN_AGE_MS} with neither its BullMQ job nor a
82
+ * conversation lock holder:
83
+ * - `running`: its replica died while running it (BullMQ's stalled re-deliveries ran out, or the
84
+ * process died inside `onFailed`; run jobs are `attempts: 1, removeOnFail`). It is failed
85
+ * `run.orphaned` (its terminal transaction cancels its waits and owes what every run end owes),
86
+ * then, after the commit, its checkpoints are dropped, its fork child stopped (`run.orphaned`), a
87
+ * sub-agent's failure delivered to its parent at once and the conversation's next run started.
88
+ * - `queued`, with no outbox row either (pending or dead): a canary. Every queued run has its job owed in its
89
+ * own transaction, so this means Redis lost data. It is logged as an error, counted for the admin
90
+ * overview ({@link QueueLostJobs}) and its job owed again so the run is not stuck.
91
+ * Redis is read before each repair's transaction, which then re-reads the run under its
92
+ * conversation row lock and acts only if it is still in the same state since the same time. Read
93
+ * paths never repair runs.
94
+ */
95
+ export declare class RunOrphanCheck {
96
+ private readonly kdb;
97
+ private readonly jobs;
98
+ private readonly lock;
99
+ private readonly finisher;
100
+ private readonly eventLog;
101
+ private readonly checkpoints;
102
+ /** A failed sub-agent run reports to its parent at once ({@link Waits.childFinished}). */
103
+ private readonly waits;
104
+ /** The conversation's next run, after a repaired run's end. */
105
+ private readonly queue;
106
+ private readonly lostJobs;
107
+ private readonly events;
108
+ readonly txHost: TransactionHost;
109
+ readonly config: KernelConfigService;
110
+ readonly lockService: LockService;
111
+ readonly log: KernelLogger;
112
+ constructor(kdb: KernelDb, jobs: Pick<RunJobs, "getJob" | "owe">, lock: Pick<ConversationLock, "holder">, finisher: Pick<RunFinish, "finishIn">, eventLog: Pick<EventLog, "notify" | "publishChanged">, checkpoints: Pick<KyselyCheckpointSaver, "deleteThread">,
113
+ /** A failed sub-agent run reports to its parent at once ({@link Waits.childFinished}). */
114
+ waits: Pick<Waits, "childFinished">,
115
+ /** The conversation's next run, after a repaired run's end. */
116
+ queue: Pick<RunQueue, "ensureRun">, lostJobs: Pick<QueueLostJobs, "report">, events: KernelEvents, txHost: TransactionHost, config: KernelConfigService, lockService: LockService, log: KernelLogger);
117
+ private get db();
118
+ run(): Promise<void>;
119
+ /** One pass: repairs the orphans it finds (each on its own; a failure is logged). */
120
+ check(): Promise<RunOrphanCheckResult>;
121
+ /**
122
+ * Neither a conversation lock holder nor the run's job in BullMQ (nor, queued, in the outbox: an
123
+ * owed row is delivered by the relay, a dead one waits for the super admin).
124
+ */
125
+ private withoutJobOrHolder;
126
+ /**
127
+ * Fails a `running` orphan (`run.orphaned`), then does what its end causes after the commit:
128
+ * the open pages hear it, its checkpoints go, its fork child stops, a sub-agent reports to its
129
+ * parent and the conversation's next run starts (each also owed by the terminal transaction).
130
+ */
131
+ private failRunning;
132
+ /** {@link failRunning}'s transaction: the terminal state, if the run is still the same orphan. */
133
+ private failIn;
134
+ /** Owes a lost queued run's job again under its own id, if it is still the same orphan. */
135
+ private oweAgainIn;
136
+ /**
137
+ * Under the conversation row lock (the first lock of every path that changes a run's state): the
138
+ * run is still in the state and since the time the pass read. Not `FOR UPDATE` on the run: the
139
+ * terminal transaction locks it after the run's wait rows.
140
+ */
141
+ private stillIn;
142
+ }
@@ -0,0 +1,339 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
8
+ return function (target, key) { decorator(target, key, paramIndex); }
9
+ };
10
+ import { Inject, Injectable } from "@nestjs/common";
11
+ import { sql } from "kysely";
12
+ import { EventLog } from "../conversations/event-log.js";
13
+ import { KernelConfigService } from "../infra/config/kernel-config.service.js";
14
+ import { KernelDb } from "../infra/database/kernel-db.js";
15
+ import { TransactionHost } from "../infra/database/transaction-host.js";
16
+ import { Transactional } from "../infra/database/transactional.decorator.js";
17
+ import { KernelEvents } from "../infra/events/kernel-events.js";
18
+ import { LockService } from "../infra/lock/lock.service.js";
19
+ import { LockedInterval } from "../infra/schedule/locked-interval.js";
20
+ import { KERNEL_LOGGER } from "../infra/tokens.js";
21
+ import { KyselyCheckpointSaver } from "./checkpointer.js";
22
+ import { ConversationLock } from "./conversation-lock.js";
23
+ import { QueueLostJobs } from "./queue-lost-jobs.js";
24
+ import { notifyFinished, RunFinish } from "./run-finish.js";
25
+ import { RunJobs, runJobId, runQueueNameFor } from "./run-jobs.js";
26
+ import { RunQueue } from "./run-queue.js";
27
+ import { Waits } from "./waits.js";
28
+ /** The check's `@LockedInterval` job name (its run lock key is the default `cron:run-orphan-check`). */
29
+ export const RUN_ORPHAN_CHECK_JOB = "run-orphan-check";
30
+ /** The check's period: a fixed constant, not a config field. */
31
+ export const RUN_ORPHAN_CHECK_PERIOD_MS = 60_000;
32
+ /**
33
+ * How long a run must have been in its state (queued, or running since its last start) before it
34
+ * can count as an orphan: a transition in flight is never repaired.
35
+ */
36
+ export const RUN_ORPHAN_MIN_AGE_MS = 60_000;
37
+ /** The candidates one page of a pass reads (a keyset page over the state's start time and id). */
38
+ export const RUN_ORPHAN_CHECK_BATCH = 500;
39
+ /**
40
+ * The most pages one pass reads: a bound on its work. Healthy long runs and queued runs waiting
41
+ * for a quota slot are passed over page by page, so a real orphan behind them is still reached;
42
+ * beyond this many candidates the rest wait for the next minute.
43
+ */
44
+ export const RUN_ORPHAN_CHECK_MAX_PAGES = 10;
45
+ /** The error the canary logs (transactional outbox spec §8.3). */
46
+ export const QUEUE_LOST_JOBS_MESSAGE = "queued run without a job: the queue lost jobs";
47
+ /** The SQL of the time a run entered its current state (queued, or its last start). */
48
+ const SINCE = sql `GREATEST(COALESCE(r.queued_at, r.created_at), COALESCE(r.started_at, r.created_at))`;
49
+ /**
50
+ * One page of the active runs of chat and sub-agent conversations in `statuses` that entered their
51
+ * state more than `minAgeMs` ago, by (state start, id), after `after` (an eval's hidden
52
+ * conversation drives its runs itself).
53
+ */
54
+ export async function orphanCandidates(db, statuses, minAgeMs, limit, after) {
55
+ let q = db
56
+ .selectFrom("runs as r")
57
+ .innerJoin("conversations as c", "c.id", "r.conversation_id")
58
+ .select([
59
+ "r.id",
60
+ "r.conversation_id",
61
+ "r.status",
62
+ "r.job_id",
63
+ "r.trigger_seq",
64
+ "c.kind",
65
+ "c.org_id",
66
+ sql `${SINCE}::text`.as("since"),
67
+ ])
68
+ .where("r.status", "in", statuses)
69
+ .where("c.kind", "in", ["chat", "subagent"])
70
+ .where(SINCE, "<", sql `now() - ${minAgeMs}::integer * interval '1 millisecond'`);
71
+ if (after !== undefined) {
72
+ q = q.where(sql `(${SINCE}, r.id) > (${after.since}::timestamptz, ${after.runId})`);
73
+ }
74
+ const rows = await q.orderBy(SINCE).orderBy("r.id").limit(limit).execute();
75
+ return rows.map((r) => ({
76
+ runId: r.id,
77
+ conversationId: r.conversation_id,
78
+ orgId: r.org_id,
79
+ kind: r.kind,
80
+ status: r.status,
81
+ jobId: r.job_id ?? runJobId(r.conversation_id, Number(r.trigger_seq)),
82
+ queue: runQueueNameFor(r.kind),
83
+ since: r.since,
84
+ }));
85
+ }
86
+ /**
87
+ * Whether the outbox holds the job `jobId` on `queue`: a pending row (it is still owed) or a dead
88
+ * one (the outbox gave up on it; the super admin sees it under `outbox_dead` and retries or
89
+ * discards it, so it is not a job the queue lost).
90
+ */
91
+ export async function jobInOutbox(db, queue, jobId) {
92
+ const row = await db
93
+ .selectFrom("outbox")
94
+ .select("id")
95
+ .where("queue", "=", queue)
96
+ .where("job_id", "=", jobId)
97
+ .executeTakeFirst();
98
+ return row !== undefined;
99
+ }
100
+ /** Every page of {@link orphanCandidates}, at most {@link RUN_ORPHAN_CHECK_MAX_PAGES}. */
101
+ async function* candidatePages(db, statuses, minAgeMs) {
102
+ let after;
103
+ for (let page = 0; page < RUN_ORPHAN_CHECK_MAX_PAGES; page++) {
104
+ const rows = await orphanCandidates(db, statuses, minAgeMs, RUN_ORPHAN_CHECK_BATCH, after);
105
+ if (rows.length > 0)
106
+ yield rows;
107
+ if (rows.length < RUN_ORPHAN_CHECK_BATCH)
108
+ return;
109
+ after = rows[rows.length - 1];
110
+ }
111
+ }
112
+ /**
113
+ * The `queued` runs (in their state for over `minAgeMs`) whose job is neither in the outbox
114
+ * (owed, or dead) nor in BullMQ (`jobExists`), and that are still queued after both looks: the run
115
+ * queue lost their jobs. The outbox is read before BullMQ (a row is deleted only after its job was added) and the
116
+ * run again after both (a job that finished took its run out of `queued` first), so a run in
117
+ * flight is never reported. The `run-orphan-check` uses it; so does the dual-replica test harness,
118
+ * which asserts at its teardown that the kernel left none (`minAgeMs` 0).
119
+ */
120
+ export async function queuedRunsWithoutJob(db, jobExists, opts) {
121
+ const out = [];
122
+ for await (const page of candidatePages(db, ["queued"], opts.minAgeMs)) {
123
+ for (const c of page) {
124
+ if (await jobInOutbox(db, c.queue, c.jobId))
125
+ continue;
126
+ if (await jobExists(c.queue, c.jobId))
127
+ continue;
128
+ const now = await db
129
+ .selectFrom("runs")
130
+ .select("status")
131
+ .where("id", "=", c.runId)
132
+ .executeTakeFirst();
133
+ if (now?.status === "queued")
134
+ out.push(c);
135
+ }
136
+ }
137
+ return out;
138
+ }
139
+ /**
140
+ * The `run-orphan-check` (transactional outbox spec §6 "Replica death during a run", §8.3): once a
141
+ * minute across replicas (worker role), the one repair of a run nobody runs. A run counts when it
142
+ * has been in its state for over {@link RUN_ORPHAN_MIN_AGE_MS} with neither its BullMQ job nor a
143
+ * conversation lock holder:
144
+ * - `running`: its replica died while running it (BullMQ's stalled re-deliveries ran out, or the
145
+ * process died inside `onFailed`; run jobs are `attempts: 1, removeOnFail`). It is failed
146
+ * `run.orphaned` (its terminal transaction cancels its waits and owes what every run end owes),
147
+ * then, after the commit, its checkpoints are dropped, its fork child stopped (`run.orphaned`), a
148
+ * sub-agent's failure delivered to its parent at once and the conversation's next run started.
149
+ * - `queued`, with no outbox row either (pending or dead): a canary. Every queued run has its job owed in its
150
+ * own transaction, so this means Redis lost data. It is logged as an error, counted for the admin
151
+ * overview ({@link QueueLostJobs}) and its job owed again so the run is not stuck.
152
+ * Redis is read before each repair's transaction, which then re-reads the run under its
153
+ * conversation row lock and acts only if it is still in the same state since the same time. Read
154
+ * paths never repair runs.
155
+ */
156
+ let RunOrphanCheck = class RunOrphanCheck {
157
+ kdb;
158
+ jobs;
159
+ lock;
160
+ finisher;
161
+ eventLog;
162
+ checkpoints;
163
+ waits;
164
+ queue;
165
+ lostJobs;
166
+ events;
167
+ txHost;
168
+ config;
169
+ lockService;
170
+ log;
171
+ constructor(kdb, jobs, lock, finisher, eventLog, checkpoints,
172
+ /** A failed sub-agent run reports to its parent at once ({@link Waits.childFinished}). */
173
+ waits,
174
+ /** The conversation's next run, after a repaired run's end. */
175
+ queue, lostJobs, events, txHost, config, lockService, log) {
176
+ this.kdb = kdb;
177
+ this.jobs = jobs;
178
+ this.lock = lock;
179
+ this.finisher = finisher;
180
+ this.eventLog = eventLog;
181
+ this.checkpoints = checkpoints;
182
+ this.waits = waits;
183
+ this.queue = queue;
184
+ this.lostJobs = lostJobs;
185
+ this.events = events;
186
+ this.txHost = txHost;
187
+ this.config = config;
188
+ this.lockService = lockService;
189
+ this.log = log;
190
+ }
191
+ get db() {
192
+ return this.kdb.db;
193
+ }
194
+ async run() {
195
+ await this.check();
196
+ }
197
+ /** One pass: repairs the orphans it finds (each on its own; a failure is logged). */
198
+ async check() {
199
+ const out = { runningRepaired: 0, queuedReadded: 0 };
200
+ const lost = [];
201
+ const pages = candidatePages(this.db, ["queued", "running"], RUN_ORPHAN_MIN_AGE_MS);
202
+ for await (const page of pages) {
203
+ for (const c of page) {
204
+ try {
205
+ if (!(await this.withoutJobOrHolder(c)))
206
+ continue;
207
+ if (c.status === "running") {
208
+ if (await this.failRunning(c))
209
+ out.runningRepaired++;
210
+ }
211
+ else if (await this.oweAgainIn(c)) {
212
+ out.queuedReadded++;
213
+ lost.push(c);
214
+ }
215
+ }
216
+ catch (err) {
217
+ this.log.error("run-orphan-check: cannot repair a run", { runId: c.runId, err });
218
+ }
219
+ }
220
+ }
221
+ if (lost.length > 0) {
222
+ this.log.error(QUEUE_LOST_JOBS_MESSAGE, {
223
+ count: lost.length,
224
+ runs: lost.map((c) => ({ runId: c.runId, conversationId: c.conversationId })),
225
+ });
226
+ await this.lostJobs.report(lost.length);
227
+ }
228
+ return out;
229
+ }
230
+ /**
231
+ * Neither a conversation lock holder nor the run's job in BullMQ (nor, queued, in the outbox: an
232
+ * owed row is delivered by the relay, a dead one waits for the super admin).
233
+ */
234
+ async withoutJobOrHolder(c) {
235
+ if ((await this.lock.holder(c.conversationId)) !== null)
236
+ return false;
237
+ if (c.status === "queued" && (await jobInOutbox(this.db, c.queue, c.jobId)))
238
+ return false;
239
+ return (await this.jobs.getJob(c.jobId, c.queue)) === undefined;
240
+ }
241
+ /**
242
+ * Fails a `running` orphan (`run.orphaned`), then does what its end causes after the commit:
243
+ * the open pages hear it, its checkpoints go, its fork child stops, a sub-agent reports to its
244
+ * parent and the conversation's next run starts (each also owed by the terminal transaction).
245
+ */
246
+ async failRunning(c) {
247
+ const seq = await this.failIn(c);
248
+ if (seq === null)
249
+ return false;
250
+ this.log.warn("run-orphan-check: failed a running run nobody runs", {
251
+ runId: c.runId,
252
+ conversationId: c.conversationId,
253
+ });
254
+ await notifyFinished(this.eventLog, c.conversationId, seq, "failed");
255
+ await this.checkpoints
256
+ .deleteThread(c.runId)
257
+ .catch((err) => this.log.warn("checkpoint delete failed", { runId: c.runId, err }));
258
+ await this.events.emit("run.orphaned", { runId: c.runId });
259
+ await this.waits.childFinished(c.runId).catch((err) => this.log.error("cannot report an orphaned sub-agent run to its parent", {
260
+ runId: c.runId,
261
+ err,
262
+ }));
263
+ await this.queue.ensureRun(c.conversationId).catch((err) => this.log.error("cannot start the next run after an orphan", {
264
+ conversationId: c.conversationId,
265
+ err,
266
+ }));
267
+ return true;
268
+ }
269
+ /** {@link failRunning}'s transaction: the terminal state, if the run is still the same orphan. */
270
+ async failIn(c) {
271
+ if (!(await this.stillIn(c)))
272
+ return null;
273
+ return this.finisher.finishIn({
274
+ conversationId: c.conversationId,
275
+ runId: c.runId,
276
+ status: "failed",
277
+ consumed: null,
278
+ event: {
279
+ type: "run_failed",
280
+ payload: {
281
+ error: { code: "run.orphaned", message: "The run was lost and will not continue" },
282
+ },
283
+ },
284
+ });
285
+ }
286
+ /** Owes a lost queued run's job again under its own id, if it is still the same orphan. */
287
+ async oweAgainIn(c) {
288
+ if (!(await this.stillIn(c)))
289
+ return false;
290
+ const data = { runId: c.runId, conversationId: c.conversationId };
291
+ return this.jobs.owe(data, c.jobId, c.kind, c.orgId);
292
+ }
293
+ /**
294
+ * Under the conversation row lock (the first lock of every path that changes a run's state): the
295
+ * run is still in the state and since the time the pass read. Not `FOR UPDATE` on the run: the
296
+ * terminal transaction locks it after the run's wait rows.
297
+ */
298
+ async stillIn(c) {
299
+ await this.db
300
+ .selectFrom("conversations")
301
+ .select("id")
302
+ .where("id", "=", c.conversationId)
303
+ .forUpdate()
304
+ .executeTakeFirst();
305
+ const r = await this.db
306
+ .selectFrom("runs as r")
307
+ .select(["r.status", sql `${SINCE}::text`.as("since")])
308
+ .where("r.id", "=", c.runId)
309
+ .executeTakeFirst();
310
+ return r?.status === c.status && r.since === c.since;
311
+ }
312
+ };
313
+ __decorate([
314
+ LockedInterval(RUN_ORPHAN_CHECK_JOB, () => RUN_ORPHAN_CHECK_PERIOD_MS)
315
+ ], RunOrphanCheck.prototype, "run", null);
316
+ __decorate([
317
+ Transactional({ propagation: "requires_new" })
318
+ ], RunOrphanCheck.prototype, "failIn", null);
319
+ __decorate([
320
+ Transactional({ propagation: "requires_new" })
321
+ ], RunOrphanCheck.prototype, "oweAgainIn", null);
322
+ RunOrphanCheck = __decorate([
323
+ Injectable(),
324
+ __param(0, Inject(KernelDb)),
325
+ __param(1, Inject(RunJobs)),
326
+ __param(2, Inject(ConversationLock)),
327
+ __param(3, Inject(RunFinish)),
328
+ __param(4, Inject(EventLog)),
329
+ __param(5, Inject(KyselyCheckpointSaver)),
330
+ __param(6, Inject(Waits)),
331
+ __param(7, Inject(RunQueue)),
332
+ __param(8, Inject(QueueLostJobs)),
333
+ __param(9, Inject(KernelEvents)),
334
+ __param(10, Inject(TransactionHost)),
335
+ __param(11, Inject(KernelConfigService)),
336
+ __param(12, Inject(LockService)),
337
+ __param(13, Inject(KERNEL_LOGGER))
338
+ ], RunOrphanCheck);
339
+ export { RunOrphanCheck };
@@ -3,33 +3,40 @@ import { type DynamicModule } from "@nestjs/common";
3
3
  import type { Job, Worker } from "bullmq";
4
4
  import type { KernelConfig } from "../config/schema.js";
5
5
  import { TitleQueue } from "../conversations/titles.js";
6
- import { type RunJobData } from "./run-jobs.js";
6
+ import { type RUN_JOB, type RunJobData } from "./run-jobs.js";
7
7
  import { RunWorker } from "./run-worker.js";
8
- import { UsageReporter, type UsageReportJob } from "./usage-report.js";
8
+ import { type USAGE_REPORT_JOB, UsageReporter, type UsageReportJob } from "./usage-report.js";
9
9
  type RunResult = Awaited<ReturnType<RunWorker["process"]>>;
10
+ /** A run job: both run queues carry only {@link RUN_JOB} jobs (the outbox registry's `run`). */
11
+ type RunJob = Job<RunJobData, RunResult, typeof RUN_JOB>;
10
12
  /** The consumer of `cap-kernel-runs` (conversation runs): {@link RunWorker}. */
11
13
  export declare class RunsProcessor extends WorkerHost<Worker<RunJobData, RunResult>> {
12
14
  private readonly runs;
13
15
  constructor(runs: RunWorker);
14
- process(job: Job<RunJobData>, token?: string): Promise<RunResult>;
15
- completed(job: Job<RunJobData>, result: RunResult): void;
16
- failed(job: Job<RunJobData> | undefined, err: Error): void;
16
+ process(job: RunJob, token?: string): Promise<RunResult>;
17
+ completed(job: RunJob, result: RunResult): void;
18
+ failed(job: RunJob | undefined, err: Error): void;
17
19
  error(err: Error): void;
18
20
  }
19
21
  /** The consumer of `cap-kernel-subagent-runs` (sub-agent runs, spec §7.4): {@link RunWorker}. */
20
22
  export declare class SubagentRunsProcessor extends WorkerHost<Worker<RunJobData, RunResult>> {
21
23
  private readonly runs;
22
24
  constructor(runs: RunWorker);
23
- process(job: Job<RunJobData>, token?: string): Promise<RunResult>;
24
- completed(job: Job<RunJobData>, result: RunResult): void;
25
- failed(job: Job<RunJobData> | undefined, err: Error): void;
25
+ process(job: RunJob, token?: string): Promise<RunResult>;
26
+ completed(job: RunJob, result: RunResult): void;
27
+ failed(job: RunJob | undefined, err: Error): void;
26
28
  error(err: Error): void;
27
29
  }
30
+ /**
31
+ * A delivery job: the usage report queue carries only {@link USAGE_REPORT_JOB} jobs (the outbox
32
+ * registry's `usage-report`).
33
+ */
34
+ type UsageJob = Job<UsageReportJob, void, typeof USAGE_REPORT_JOB>;
28
35
  /** The consumer of `cap-kernel-usage-report`: {@link UsageReporter}. */
29
36
  export declare class UsageReportProcessor extends WorkerHost<Worker<UsageReportJob>> {
30
37
  private readonly reporter;
31
38
  constructor(reporter: UsageReporter);
32
- process(job: Job<UsageReportJob>, token?: string): Promise<void>;
39
+ process(job: UsageJob, token?: string): Promise<void>;
33
40
  failed(job: Job<UsageReportJob> | undefined, err: Error): void;
34
41
  error(err: Error): void;
35
42
  }
@@ -14,7 +14,7 @@ import { Inject, Injectable, Module } from "@nestjs/common";
14
14
  import { TITLE_QUEUE, TitleQueue, TitlesModule } from "../conversations/titles.js";
15
15
  import { configuredProcessor } from "../infra/queue/configured-processor.js";
16
16
  import { runJob } from "../infra/queue/run-job.js";
17
- import { RUN_QUEUE_NAME, SUBAGENT_RUN_QUEUE_NAME } from "./run-jobs.js";
17
+ import { RUN_QUEUE_NAME, SUBAGENT_RUN_QUEUE_NAME, } from "./run-jobs.js";
18
18
  import { MAX_STALLED_COUNT, RunWorker } from "./run-worker.js";
19
19
  import { RuntimeModule } from "./runtime.module.js";
20
20
  import { USAGE_REPORT_QUEUE, UsageReporter, UsageReportModule, } from "./usage-report.js";