@capybara-hq/kernel 0.1.0-rc.25 → 0.1.0-rc.26

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 (85) hide show
  1. package/dist/admin/outbox-dead.service.d.ts +7 -3
  2. package/dist/admin/outbox-dead.service.js +17 -6
  3. package/dist/admin/overview.service.d.ts +6 -6
  4. package/dist/admin/overview.service.js +7 -7
  5. package/dist/background/wake-rules.d.ts +5 -0
  6. package/dist/background/wake-rules.js +16 -5
  7. package/dist/catalog/marketplaces.service.d.ts +1 -1
  8. package/dist/channels/channels.module.d.ts +9 -8
  9. package/dist/channels/channels.module.js +11 -10
  10. package/dist/channels/connections.service.d.ts +3 -3
  11. package/dist/channels/connections.service.js +6 -6
  12. package/dist/channels/deliver-job.d.ts +10 -3
  13. package/dist/channels/deliver-job.js +7 -3
  14. package/dist/channels/jobs.d.ts +11 -4
  15. package/dist/channels/jobs.js +18 -3
  16. package/dist/channels/outbound.d.ts +5 -2
  17. package/dist/channels/outbound.js +9 -2
  18. package/dist/conversations/conversations.controller.d.ts +4 -2
  19. package/dist/conversations/conversations.controller.js +4 -2
  20. package/dist/conversations/conversations.service.d.ts +37 -4
  21. package/dist/conversations/conversations.service.js +77 -20
  22. package/dist/conversations/events.d.ts +6 -0
  23. package/dist/conversations/events.js +6 -0
  24. package/dist/db/kernel-schema.d.ts +10 -1
  25. package/dist/devices/devices.service.d.ts +14 -6
  26. package/dist/devices/devices.service.js +20 -7
  27. package/dist/evals/evals.module.js +5 -1
  28. package/dist/evals/jobs.d.ts +14 -6
  29. package/dist/evals/jobs.js +21 -19
  30. package/dist/evals/runs.service.d.ts +5 -6
  31. package/dist/evals/runs.service.js +11 -33
  32. package/dist/infra/outbox/outbox.service.d.ts +6 -0
  33. package/dist/infra/outbox/outbox.service.js +51 -17
  34. package/dist/infra/outbox/registry.d.ts +52 -0
  35. package/dist/infra/outbox/registry.js +57 -0
  36. package/dist/local-devices/device-policy.service.d.ts +131 -26
  37. package/dist/local-devices/device-policy.service.js +341 -145
  38. package/dist/local-devices/machines.d.ts +17 -2
  39. package/dist/local-devices/machines.js +66 -5
  40. package/dist/local-devices/policy-op-jobs.d.ts +15 -0
  41. package/dist/local-devices/policy-op-jobs.js +12 -0
  42. package/dist/local-devices/policy-summary.d.ts +36 -3
  43. package/dist/local-devices/policy-summary.js +47 -1
  44. package/dist/orgs/orgs.service.js +4 -1
  45. package/dist/overrides.d.ts +4 -0
  46. package/dist/overrides.js +2 -0
  47. package/dist/platform/ops/conversations.ops.js +1 -1
  48. package/dist/runtime/maintenance.d.ts +6 -3
  49. package/dist/runtime/maintenance.js +21 -7
  50. package/dist/runtime/plugin-context.service.d.ts +9 -3
  51. package/dist/runtime/plugin-context.service.js +17 -5
  52. package/dist/runtime/run-context.service.d.ts +7 -3
  53. package/dist/runtime/run-context.service.js +12 -2
  54. package/dist/runtime/run-finish.d.ts +2 -2
  55. package/dist/runtime/run-finish.js +11 -6
  56. package/dist/runtime/run-jobs.d.ts +21 -4
  57. package/dist/runtime/run-jobs.js +29 -6
  58. package/dist/runtime/run-orphan-check.job.d.ts +14 -11
  59. package/dist/runtime/run-orphan-check.job.js +16 -11
  60. package/dist/runtime/run-queue.d.ts +23 -31
  61. package/dist/runtime/run-queue.js +62 -64
  62. package/dist/runtime/run-status.d.ts +12 -0
  63. package/dist/runtime/run-status.js +21 -0
  64. package/dist/runtime/run-worker.d.ts +10 -2
  65. package/dist/runtime/run-worker.js +44 -19
  66. package/dist/runtime/step-log.d.ts +6 -5
  67. package/dist/runtime/step-log.js +6 -5
  68. package/dist/runtime/usage-report.d.ts +3 -1
  69. package/dist/runtime/usage-report.js +4 -2
  70. package/dist/runtime/waits.d.ts +16 -6
  71. package/dist/runtime/waits.js +37 -15
  72. package/dist/schedules/scan.job.d.ts +6 -6
  73. package/dist/schedules/scan.job.js +6 -6
  74. package/dist/skills/catalog.d.ts +7 -3
  75. package/dist/skills/catalog.js +12 -2
  76. package/dist/tools/approvals.service.d.ts +96 -19
  77. package/dist/tools/approvals.service.js +300 -67
  78. package/dist/tools/builtins/sandbox/task-output.js +21 -6
  79. package/dist/tools/device-op-resolver.d.ts +40 -0
  80. package/dist/tools/device-op-resolver.js +121 -0
  81. package/dist/tools/network-asks.d.ts +1 -1
  82. package/dist/tools/network-asks.js +4 -3
  83. package/dist/tools/tools.module.js +4 -0
  84. package/package.json +2 -2
  85. package/src/migrations/0125_conversation_tree_stopped_at.sql +5 -0
@@ -50,8 +50,9 @@ export declare class OutboxDeadService {
50
50
  * the fast path after the commit; the relay delivers it otherwise. When the same (queue, job id)
51
51
  * is owed by a pending row already, the dead row is deleted instead (`already_owed`). A pending
52
52
  * row added between the check and the update fails the update on the unique index: the retry
53
- * then runs once more and takes the `already_owed` branch. `outbox.not_found` (404) when no dead
54
- * row has the id.
53
+ * then runs once more and takes the `already_owed` branch; a second conflict on that run (the
54
+ * job owed, delivered and owed again meanwhile) is `conflict` (409), the dead row left as it was.
55
+ * `outbox.not_found` (404) when no dead row has the id.
55
56
  */
56
57
  retry(id: string, actor: WriteActor, detail?: Record<string, unknown>): Promise<{
57
58
  outcome: OutboxRetryOutcome;
@@ -61,6 +62,9 @@ export declare class OutboxDeadService {
61
62
  discard(id: string, actor: WriteActor, detail?: Record<string, unknown>): Promise<void>;
62
63
  /** The dead row `id`, locked for the transaction; `outbox.not_found` when it is not dead. */
63
64
  private lockDead;
64
- /** A platform-level audit event (no org: the outbox is the deployment's), naming the job. */
65
+ /**
66
+ * A platform-level audit event (no org: the outbox is the deployment's), naming the job. The
67
+ * caller's detail (`via`, `operation`) comes first, so it cannot override the row's fields.
68
+ */
65
69
  private audit;
66
70
  }
@@ -89,8 +89,9 @@ let OutboxDeadService = class OutboxDeadService {
89
89
  * the fast path after the commit; the relay delivers it otherwise. When the same (queue, job id)
90
90
  * is owed by a pending row already, the dead row is deleted instead (`already_owed`). A pending
91
91
  * row added between the check and the update fails the update on the unique index: the retry
92
- * then runs once more and takes the `already_owed` branch. `outbox.not_found` (404) when no dead
93
- * row has the id.
92
+ * then runs once more and takes the `already_owed` branch; a second conflict on that run (the
93
+ * job owed, delivered and owed again meanwhile) is `conflict` (409), the dead row left as it was.
94
+ * `outbox.not_found` (404) when no dead row has the id.
94
95
  */
95
96
  async retry(id, actor, detail = {}) {
96
97
  try {
@@ -99,7 +100,14 @@ let OutboxDeadService = class OutboxDeadService {
99
100
  catch (e) {
100
101
  if (!isPendingConflict(e))
101
102
  throw e;
102
- return this.retryOnce(id, actor, detail);
103
+ }
104
+ try {
105
+ return await this.retryOnce(id, actor, detail);
106
+ }
107
+ catch (e) {
108
+ if (!isPendingConflict(e))
109
+ throw e;
110
+ throw new ApiError("conflict", `The job of dead outbox row ${id} was owed again while it was retried; list the dead rows with GET /api/v1/admin/outbox/dead and retry or discard it again`);
103
111
  }
104
112
  }
105
113
  async retryOnce(id, actor, detail) {
@@ -125,7 +133,7 @@ let OutboxDeadService = class OutboxDeadService {
125
133
  outcome = "retried";
126
134
  afterCommit(() => this.fastPath([id]));
127
135
  }
128
- await this.audit("outbox.retried", id, row, actor, { outcome, ...detail });
136
+ await this.audit("outbox.retried", id, row, actor, { ...detail, outcome });
129
137
  return { outcome };
130
138
  }
131
139
  /** Deletes a dead row: its job is never delivered. `outbox.not_found` (404) when none. */
@@ -147,7 +155,10 @@ let OutboxDeadService = class OutboxDeadService {
147
155
  throw notFound(id);
148
156
  return row;
149
157
  }
150
- /** A platform-level audit event (no org: the outbox is the deployment's), naming the job. */
158
+ /**
159
+ * A platform-level audit event (no org: the outbox is the deployment's), naming the job. The
160
+ * caller's detail (`via`, `operation`) comes first, so it cannot override the row's fields.
161
+ */
151
162
  audit(action, id, row, actor, detail) {
152
163
  return audit(this.db.db, {
153
164
  actorAccountId: actor.accountId,
@@ -155,11 +166,11 @@ let OutboxDeadService = class OutboxDeadService {
155
166
  action,
156
167
  target: id,
157
168
  detail: {
169
+ ...detail,
158
170
  queue: row.queue,
159
171
  jobName: row.job_name,
160
172
  jobId: row.job_id,
161
173
  ...(row.org_id !== null && { orgId: row.org_id }),
162
- ...detail,
163
174
  },
164
175
  });
165
176
  }
@@ -107,11 +107,11 @@ export declare function extensionReason(e: Pick<AdminExtensionView, "status" | "
107
107
  /** The problem of a provider in the platform view, or null (`org_configured` is not one). */
108
108
  export declare function providerProblem(p: ProviderStatus): AttentionProviderProblem | null;
109
109
  /**
110
- * The admin overview (spec §4.1, §5.2): its numbers and the 待处理 lists, read live from the
111
- * sources the admin pages read (`ExtensionAdmin`, the catalog, the provider registry's cached
112
- * lists, the dead outbox rows, the run queue's lost-jobs canary). Each source is read on its own: a failing one is reported in
113
- * `unavailable`. Reads are not audited (decision D5) and not cached further (every source is PG
114
- * or already Redis-cached).
110
+ * The admin overview (spec §4.1, §5.2): its numbers and the 待处理 lists, read live from the sources
111
+ * the admin pages read (`ExtensionAdmin`, the catalog, the provider registry's cached lists, the
112
+ * dead outbox rows, the run queue's lost-jobs canary). Each source is read on its own: a failing
113
+ * one is reported in `unavailable`. Reads are not audited (decision D5) and not cached further
114
+ * (every source is PG or already Redis-cached).
115
115
  */
116
116
  export declare class AdminOverviewService {
117
117
  private readonly extensions;
@@ -136,7 +136,7 @@ export declare class AdminOverviewService {
136
136
  /**
137
137
  * The extensions needing a look, but not one pending approval whose plugin waits in the platform
138
138
  * inbox (a permission confirmation or an org upload: the inbox already lists and counts it). A
139
- * config-declared extension has no catalog item and stays. Without the catalog nothing is left out.
139
+ * config-declared extension has no catalog item and stays. Without the catalog none is left out.
140
140
  */
141
141
  private extensionRows;
142
142
  /** Every live catalog item, page by page (the catalog lists by name). */
@@ -53,11 +53,11 @@ export function providerProblem(p) {
53
53
  return p.models.some((m) => m.kind === "chat" && m.disabled !== true) ? null : "no_chat_model";
54
54
  }
55
55
  /**
56
- * The admin overview (spec §4.1, §5.2): its numbers and the 待处理 lists, read live from the
57
- * sources the admin pages read (`ExtensionAdmin`, the catalog, the provider registry's cached
58
- * lists, the dead outbox rows, the run queue's lost-jobs canary). Each source is read on its own: a failing one is reported in
59
- * `unavailable`. Reads are not audited (decision D5) and not cached further (every source is PG
60
- * or already Redis-cached).
56
+ * The admin overview (spec §4.1, §5.2): its numbers and the 待处理 lists, read live from the sources
57
+ * the admin pages read (`ExtensionAdmin`, the catalog, the provider registry's cached lists, the
58
+ * dead outbox rows, the run queue's lost-jobs canary). Each source is read on its own: a failing
59
+ * one is reported in `unavailable`. Reads are not audited (decision D5) and not cached further
60
+ * (every source is PG or already Redis-cached).
61
61
  */
62
62
  let AdminOverviewService = class AdminOverviewService {
63
63
  extensions;
@@ -164,13 +164,13 @@ let AdminOverviewService = class AdminOverviewService {
164
164
  /**
165
165
  * The extensions needing a look, but not one pending approval whose plugin waits in the platform
166
166
  * inbox (a permission confirmation or an org upload: the inbox already lists and counts it). A
167
- * config-declared extension has no catalog item and stays. Without the catalog nothing is left out.
167
+ * config-declared extension has no catalog item and stays. Without the catalog none is left out.
168
168
  */
169
169
  async extensionRows(items) {
170
170
  const [views, catalog] = await Promise.all([this.extensions.list(), items.catch(() => [])]);
171
171
  const inInbox = new Set(catalog.filter((i) => i.pending).map((i) => i.name));
172
172
  return views.flatMap((v) => {
173
- // every status of extensionNeedsAction has a reason, and so has enabled with an invalid config
173
+ // every status of extensionNeedsAction has a reason, as has enabled with an invalid config
174
174
  const reason = extensionReason(v);
175
175
  if (reason === null)
176
176
  return [];
@@ -54,6 +54,11 @@ export type WakeDecision = {
54
54
  * - the wake setting is on (the org's override, else `deploymentOn`);
55
55
  * - the root's queue is not paused and the root is not deleted (a paused root holds the wakes of
56
56
  * the whole tree, agent map spec §5.3);
57
+ * - for a `subagent_delivery` to the root: the root's tree is not stopped (`tree_stopped_at`, set by
58
+ * a person's stop of the root and cleared by the root's next run; agent map spec §6, changed in
59
+ * acceptance 2026-11-13): no sub-agent's finish or report, whenever its child finished, wakes a
60
+ * root a person stopped, the delivery waits for the next run. A sub-agent of a stopped tree is
61
+ * never woken here (`RunQueue.childWakeIn` refuses it first);
57
62
  * - the event's trigger account can still send to the root (`canSend`, which the caller binds to
58
63
  * the root);
59
64
  * - no other wake of this conversation started in the last {@link WAKE_WINDOW_MS} (else `later`).
@@ -38,6 +38,11 @@ const WAKE_EVENT_TYPES = ["background_task_finished", "subagent_delivery"];
38
38
  * - the wake setting is on (the org's override, else `deploymentOn`);
39
39
  * - the root's queue is not paused and the root is not deleted (a paused root holds the wakes of
40
40
  * the whole tree, agent map spec §5.3);
41
+ * - for a `subagent_delivery` to the root: the root's tree is not stopped (`tree_stopped_at`, set by
42
+ * a person's stop of the root and cleared by the root's next run; agent map spec §6, changed in
43
+ * acceptance 2026-11-13): no sub-agent's finish or report, whenever its child finished, wakes a
44
+ * root a person stopped, the delivery waits for the next run. A sub-agent of a stopped tree is
45
+ * never woken here (`RunQueue.childWakeIn` refuses it first);
41
46
  * - the event's trigger account can still send to the root (`canSend`, which the caller binds to
42
47
  * the root);
43
48
  * - no other wake of this conversation started in the last {@link WAKE_WINDOW_MS} (else `later`).
@@ -68,21 +73,27 @@ export async function wakeDecisionIn(t, conv, consumed, deploymentOn, canSend) {
68
73
  .orderBy("e.seq", "desc")
69
74
  .limit(CANDIDATES)
70
75
  .execute();
71
- const candidates = [...finishes, ...deliveries]
72
- .sort((a, b) => Number(b.seq) - Number(a.seq))
73
- .slice(0, CANDIDATES);
74
- if (candidates.length === 0)
76
+ if (finishes.length === 0 && deliveries.length === 0)
75
77
  return null;
76
78
  const org = (await readOrgSettings(t, conv.orgId)).wakeOnBackgroundTask;
77
79
  if (!effectiveWakeOnBackgroundTask(deploymentOn, org))
78
80
  return null;
81
+ // read under the root's row lock when `conv` is the root (`RunQueue.ensureRunIn` holds it), so a
82
+ // person's stop of the tree (which writes `tree_stopped_at` on that row) is seen by every wake
83
+ // decided after its commit
79
84
  const root = await t
80
85
  .selectFrom("conversations")
81
- .select(["queue_paused", "deleted_at"])
86
+ .select(["queue_paused", "deleted_at", "tree_stopped_at"])
82
87
  .where("id", "=", conv.rootId)
83
88
  .executeTakeFirst();
84
89
  if (!root || root.queue_paused || root.deleted_at !== null)
85
90
  return null;
91
+ const treeStopped = root.tree_stopped_at !== null;
92
+ const candidates = [...finishes, ...(treeStopped ? [] : deliveries)]
93
+ .sort((a, b) => Number(b.seq) - Number(a.seq))
94
+ .slice(0, CANDIDATES);
95
+ if (candidates.length === 0)
96
+ return null;
86
97
  let boundary;
87
98
  for (const c of candidates) {
88
99
  if (await canSend(c.account)) {
@@ -188,7 +188,7 @@ export declare function headsToResolve(entries: readonly MarketplaceEntry[], imp
188
188
  /** The host versions (an entry's `minKernel` is judged against them). */
189
189
  export declare const HOST_VERSIONS: {
190
190
  readonly kernel: string;
191
- readonly sdk: "0.1.0-rc.19";
191
+ readonly sdk: "0.1.0-rc.20";
192
192
  };
193
193
  /** Whether this deployment satisfies the entry's `minKernel`. */
194
194
  export declare const entryCompatible: (e: Pick<MarketplaceEntry, "minKernel">) => boolean;
@@ -9,14 +9,15 @@ import { ChannelOutbound } from "./outbound.js";
9
9
  * Channels on every role: the org's channel connections, inbound (dedupe and enqueue; the inbound
10
10
  * jobs), the outbound delivery, the producer of the channel queue `cap-kernel-channels`, the
11
11
  * anonymous guests. The roles' parts are the role modules': the queue's consumer
12
- * ({@link ChannelWorkersModule}) and the gateway check job are the worker role's, the long-connection
13
- * supervisor and the webhook inbound the gateway role's (`GatewayModule`).
12
+ * ({@link ChannelWorkersModule}) and the gateway check job are the worker role's, the
13
+ * long-connection supervisor and the webhook inbound the gateway role's (`GatewayModule`).
14
14
  */
15
15
  export declare class ChannelsModule {
16
16
  }
17
17
  /**
18
18
  * The consumer of `cap-kernel-channels`: deliveries (`DELIVER_JOB`, owed through the outbox or
19
- * hinted) and inbound jobs (`INBOUND_JOB`), both handled by {@link ChannelOutbound}.
19
+ * hinted), a connection's catch-up (`RESUME_JOB`, owed) and inbound jobs (`INBOUND_JOB`), all
20
+ * handled by {@link ChannelOutbound}.
20
21
  */
21
22
  export declare class ChannelsProcessor extends WorkerHost<Worker<ChannelJobData>> {
22
23
  private readonly outbound;
@@ -32,11 +33,11 @@ export declare class ChannelsProcessor extends WorkerHost<Worker<ChannelJobData>
32
33
  */
33
34
  export declare function channelsProcessorOptions(config: Pick<KernelConfig, "runtime" | "channels">): ProcessorWorkerOptions;
34
35
  /**
35
- * The consumer of the channel queue (concurrency `channels.deliveryConcurrency`, the run queue's job
36
- * lock and stalled detection, the delivery backoff). Imported only by the worker role
37
- * (`WorkerModule`), so other roles get no processor and no BullMQ worker. It never starts by itself:
38
- * the worker role (`WorkerRole`) starts it after the extension load; `ChannelOutbound.close` closes it
39
- * on stop.
36
+ * The consumer of the channel queue (concurrency `channels.deliveryConcurrency`, the run queue's
37
+ * job lock and stalled detection, the delivery backoff). Imported only by the worker role
38
+ * (`WorkerModule`), so other roles get no processor and no BullMQ worker. It never starts by
39
+ * itself: the worker role (`WorkerRole`) starts it after the extension load;
40
+ * `ChannelOutbound.close` closes it on stop.
40
41
  */
41
42
  export declare class ChannelWorkersModule {
42
43
  static forRoot(config: KernelConfig): DynamicModule;
@@ -26,7 +26,7 @@ import { SchedulesModule } from "../schedules/schedules.module.js";
26
26
  import { ToolsModule } from "../tools/tools.module.js";
27
27
  import { ChannelConnections } from "./connections.service.js";
28
28
  import { ChannelInbound } from "./inbound.js";
29
- import { CHANNEL_QUEUE_NAME, ChannelJobs, DELIVER_JOB, INBOUND_JOB, } from "./jobs.js";
29
+ import { CHANNEL_QUEUE_NAME, ChannelJobs, DELIVER_JOB, INBOUND_JOB, RESUME_JOB, } from "./jobs.js";
30
30
  import { ChannelOutbound, deliverBackoff } from "./outbound.js";
31
31
  import { AnonymousGuestLock, AnonymousGuests } from "./threads.js";
32
32
  /** The channel queue's registration (its producer here; the admin health reads its counts). */
@@ -35,8 +35,8 @@ const CHANNEL_QUEUE_MODULE = QueueModule.registerQueue(CHANNEL_QUEUE_NAME);
35
35
  * Channels on every role: the org's channel connections, inbound (dedupe and enqueue; the inbound
36
36
  * jobs), the outbound delivery, the producer of the channel queue `cap-kernel-channels`, the
37
37
  * anonymous guests. The roles' parts are the role modules': the queue's consumer
38
- * ({@link ChannelWorkersModule}) and the gateway check job are the worker role's, the long-connection
39
- * supervisor and the webhook inbound the gateway role's (`GatewayModule`).
38
+ * ({@link ChannelWorkersModule}) and the gateway check job are the worker role's, the
39
+ * long-connection supervisor and the webhook inbound the gateway role's (`GatewayModule`).
40
40
  */
41
41
  let ChannelsModule = class ChannelsModule {
42
42
  };
@@ -77,7 +77,8 @@ ChannelsModule = __decorate([
77
77
  export { ChannelsModule };
78
78
  /**
79
79
  * The consumer of `cap-kernel-channels`: deliveries (`DELIVER_JOB`, owed through the outbox or
80
- * hinted) and inbound jobs (`INBOUND_JOB`), both handled by {@link ChannelOutbound}.
80
+ * hinted), a connection's catch-up (`RESUME_JOB`, owed) and inbound jobs (`INBOUND_JOB`), all
81
+ * handled by {@link ChannelOutbound}.
81
82
  */
82
83
  let ChannelsProcessor = class ChannelsProcessor extends WorkerHost {
83
84
  outbound;
@@ -86,7 +87,7 @@ let ChannelsProcessor = class ChannelsProcessor extends WorkerHost {
86
87
  this.outbound = outbound;
87
88
  }
88
89
  process(job, token) {
89
- if (job.name !== DELIVER_JOB && job.name !== INBOUND_JOB) {
90
+ if (job.name !== DELIVER_JOB && job.name !== INBOUND_JOB && job.name !== RESUME_JOB) {
90
91
  return Promise.reject(new UnrecoverableError(`unknown channel job "${job.name}"`));
91
92
  }
92
93
  return runJob(CHANNEL_QUEUE_NAME, job, () => this.outbound.process(job, token));
@@ -124,11 +125,11 @@ export function channelsProcessorOptions(config) {
124
125
  };
125
126
  }
126
127
  /**
127
- * The consumer of the channel queue (concurrency `channels.deliveryConcurrency`, the run queue's job
128
- * lock and stalled detection, the delivery backoff). Imported only by the worker role
129
- * (`WorkerModule`), so other roles get no processor and no BullMQ worker. It never starts by itself:
130
- * the worker role (`WorkerRole`) starts it after the extension load; `ChannelOutbound.close` closes it
131
- * on stop.
128
+ * The consumer of the channel queue (concurrency `channels.deliveryConcurrency`, the run queue's
129
+ * job lock and stalled detection, the delivery backoff). Imported only by the worker role
130
+ * (`WorkerModule`), so other roles get no processor and no BullMQ worker. It never starts by
131
+ * itself: the worker role (`WorkerRole`) starts it after the extension load;
132
+ * `ChannelOutbound.close` closes it on stop.
132
133
  */
133
134
  let ChannelWorkersModule = ChannelWorkersModule_1 = class ChannelWorkersModule {
134
135
  static forRoot(config) {
@@ -123,7 +123,7 @@ export declare class ChannelConnections implements OnApplicationShutdown {
123
123
  private readonly registry;
124
124
  private readonly bus;
125
125
  private readonly redis;
126
- /** A connection that can deliver again catches up its conversations (`resumeConnection`). */
126
+ /** A connection that can deliver again owes its conversations' catch-up (`oweResume`). */
127
127
  private readonly jobs;
128
128
  /** `channels.webhookBaseUrl`: without it webhook mode is unavailable. */
129
129
  private readonly config;
@@ -138,8 +138,8 @@ export declare class ChannelConnections implements OnApplicationShutdown {
138
138
  constructor(db: KernelDb,
139
139
  /** The org row lock (orgs/org-lock.ts): joins the transaction of the write. */
140
140
  orgLock: Pick<OrgLock, "lockAndAuthorize">, keyring: Keyring, registry: Pick<ExtensionRegistry, "list">, bus: ExtensionContext["bus"], redis: Pick<Redis, "del">,
141
- /** A connection that can deliver again catches up its conversations (`resumeConnection`). */
142
- jobs: Pick<ChannelJobs, "resumeConnection">,
141
+ /** A connection that can deliver again owes its conversations' catch-up (`oweResume`). */
142
+ jobs: Pick<ChannelJobs, "oweResume">,
143
143
  /** `channels.webhookBaseUrl`: without it webhook mode is unavailable. */
144
144
  config: Pick<KernelConfigService, "get">, events: Pick<KernelEvents, "on">,
145
145
  /** Secret values handled here (written or decrypted) are redacted from the logs (`addOrgSecrets`). */
@@ -154,7 +154,7 @@ let ChannelConnections = class ChannelConnections {
154
154
  constructor(db,
155
155
  /** The org row lock (orgs/org-lock.ts): joins the transaction of the write. */
156
156
  orgLock, keyring, registry, bus, redis,
157
- /** A connection that can deliver again catches up its conversations (`resumeConnection`). */
157
+ /** A connection that can deliver again owes its conversations' catch-up (`oweResume`). */
158
158
  jobs,
159
159
  /** `channels.webhookBaseUrl`: without it webhook mode is unavailable. */
160
160
  config, events,
@@ -546,11 +546,6 @@ let ChannelConnections = class ChannelConnections {
546
546
  }
547
547
  // A mode switch also reconnects: the holder drops the long connection when going to webhook (F9)
548
548
  await this.changed(id, rotated !== undefined || patch.status !== undefined || modeChanged);
549
- if ((rotated || patch.status === "active") && row.status === "active") {
550
- await this.jobs
551
- .resumeConnection(id)
552
- .catch((err) => this.log.warn("cannot resume channel deliveries", { err }));
553
- }
554
549
  return { channel: await this.viewOf(row), verifySkipped };
555
550
  }
556
551
  /** The rows of {@link update}, in their own transaction (the caller publishes after the commit). */
@@ -592,6 +587,11 @@ let ChannelConnections = class ChannelConnections {
592
587
  .executeTakeFirst();
593
588
  if (!updated)
594
589
  throw notFound();
590
+ // can deliver again (re-enabled, or new credentials while active): the conversations that fell
591
+ // behind meanwhile are caught up by a job owed here (transactional outbox spec §3.1)
592
+ if ((rotated || patch.status === "active") && updated.status === "active") {
593
+ await this.jobs.oweResume(id, orgId, updated.updated_at);
594
+ }
595
595
  await audit(this.db.db, {
596
596
  actorAccountId,
597
597
  ...(actorTokenId !== undefined && { actorTokenId }),
@@ -4,15 +4,22 @@ import type { OutboxService } from "../infra/outbox/outbox.service.js";
4
4
  * with no BullMQ and no Nest class, so the event log can owe it without importing the channels
5
5
  * domain. `jobs.ts` (the producer) re-exports these names.
6
6
  */
7
- /** 渠道队列(出站投递与入站任务,不含 `:`),只在 worker 角色上消费。 */
7
+ /** The channel queue (outbound deliveries and inbound jobs; no `:`), consumed only by workers. */
8
8
  export declare const CHANNEL_QUEUE_NAME = "cap-kernel-channels";
9
- /** 投递任务名。 */
9
+ /** The delivery job's name. */
10
10
  export declare const DELIVER_JOB = "deliver";
11
- /** 投递任务 id(不含 `:`):同一会话、同一日志位置只排一个任务。 */
11
+ /** The delivery job id (no `:`): one job per conversation and log position. */
12
12
  export declare const deliverJobId: (conversationId: string, seq: number) => string;
13
13
  export interface DeliverJobData {
14
14
  conversationId: string;
15
15
  }
16
+ /** The catch-up job of a connection that can deliver again (re-enabled, credentials rotated). */
17
+ export declare const RESUME_JOB = "channel-resume";
18
+ /** Its job id: one per connection and update (`updated_at` in ms; no `:`). */
19
+ export declare const resumeJobId: (connectionId: string, at: Date) => string;
20
+ export interface ResumeJobData {
21
+ connectionId: string;
22
+ }
16
23
  /**
17
24
  * Owes the conversation's delivery job for the log position `seq` in the transaction in context:
18
25
  * the caller knows the conversation has a channel target. Shares the job id of
@@ -3,12 +3,16 @@
3
3
  * with no BullMQ and no Nest class, so the event log can owe it without importing the channels
4
4
  * domain. `jobs.ts` (the producer) re-exports these names.
5
5
  */
6
- /** 渠道队列(出站投递与入站任务,不含 `:`),只在 worker 角色上消费。 */
6
+ /** The channel queue (outbound deliveries and inbound jobs; no `:`), consumed only by workers. */
7
7
  export const CHANNEL_QUEUE_NAME = "cap-kernel-channels";
8
- /** 投递任务名。 */
8
+ /** The delivery job's name. */
9
9
  export const DELIVER_JOB = "deliver";
10
- /** 投递任务 id(不含 `:`):同一会话、同一日志位置只排一个任务。 */
10
+ /** The delivery job id (no `:`): one job per conversation and log position. */
11
11
  export const deliverJobId = (conversationId, seq) => `deliver-${conversationId}-${seq}`;
12
+ /** The catch-up job of a connection that can deliver again (re-enabled, credentials rotated). */
13
+ export const RESUME_JOB = "channel-resume";
14
+ /** Its job id: one per connection and update (`updated_at` in ms; no `:`). */
15
+ export const resumeJobId = (connectionId, at) => `channel-resume-${connectionId}-${at.getTime()}`;
12
16
  /**
13
17
  * Owes the conversation's delivery job for the log position `seq` in the transaction in context:
14
18
  * the caller knows the conversation has a channel target. Shares the job id of
@@ -5,8 +5,8 @@ import { KernelDb } from "../infra/database/kernel-db.js";
5
5
  import { TransactionHost } from "../infra/database/transaction-host.js";
6
6
  import { KernelEvents } from "../infra/events/kernel-events.js";
7
7
  import { OutboxService } from "../infra/outbox/outbox.service.js";
8
- import { type DeliverJobData } from "./deliver-job.js";
9
- export { CHANNEL_QUEUE_NAME, DELIVER_JOB, type DeliverJobData, deliverJobId, oweDelivery, } from "./deliver-job.js";
8
+ import { type DeliverJobData, type ResumeJobData } from "./deliver-job.js";
9
+ export { CHANNEL_QUEUE_NAME, DELIVER_JOB, type DeliverJobData, deliverJobId, oweDelivery, RESUME_JOB, type ResumeJobData, resumeJobId, } from "./deliver-job.js";
10
10
  /**
11
11
  * 结束后复核排的任务 id:每次唯一(带随机后缀,不含 `:`)。不能复用 `deliver-<会话>-<seq>`——同 id 的任务
12
12
  * 可能正因锁被占而即将直接结束,BullMQ 会把新任务去重进它,于是没人投递。
@@ -24,7 +24,7 @@ export interface InboundJobData {
24
24
  connectionId: string;
25
25
  event: InboundEvent;
26
26
  }
27
- export type ChannelJobData = DeliverJobData | InboundJobData;
27
+ export type ChannelJobData = DeliverJobData | InboundJobData | ResumeJobData;
28
28
  /** 入站任务名。 */
29
29
  export declare const INBOUND_JOB = "inbound";
30
30
  /**
@@ -86,7 +86,14 @@ export declare class ChannelJobs implements OnApplicationShutdown {
86
86
  */
87
87
  enqueueInbound(connectionId: string, event: InboundEvent, jobId: string): Promise<void>;
88
88
  /**
89
- * 连接重新可投递(轮换了凭据 / 重新启用):给该连接投递进度落后于会话日志的会话各排一个出站任务,
89
+ * Owes the catch-up of a connection that can deliver again in the caller's transaction (the
90
+ * update that re-enabled it or rotated its credentials while active): the deliveries that ran
91
+ * while it could not deliver have ended, and nothing else finds the conversations lagging the log.
92
+ * `at` is the connection row's new `updated_at`.
93
+ */
94
+ oweResume(connectionId: string, orgId: string, at: Date): Promise<boolean>;
95
+ /**
96
+ * The {@link RESUME_JOB} job: 连接重新可投递(轮换了凭据 / 重新启用):给该连接投递进度落后于会话日志的会话各排一个出站任务,
90
97
  * 补投停用或凭据失效期间落下的内容(不必等下一条消息)。
91
98
  */
92
99
  resumeConnection(connectionId: string): Promise<void>;
@@ -17,8 +17,8 @@ import { KernelEvents } from "../infra/events/kernel-events.js";
17
17
  import { OutboxService } from "../infra/outbox/outbox.service.js";
18
18
  import { OUTBOX_JOBS } from "../infra/outbox/registry.js";
19
19
  import { KERNEL_LOGGER } from "../infra/tokens.js";
20
- import { CHANNEL_QUEUE_NAME, DELIVER_JOB, deliverJobId, oweDelivery, } from "./deliver-job.js";
21
- export { CHANNEL_QUEUE_NAME, DELIVER_JOB, deliverJobId, oweDelivery, } from "./deliver-job.js";
20
+ import { CHANNEL_QUEUE_NAME, DELIVER_JOB, deliverJobId, oweDelivery, RESUME_JOB, resumeJobId, } from "./deliver-job.js";
21
+ export { CHANNEL_QUEUE_NAME, DELIVER_JOB, deliverJobId, oweDelivery, RESUME_JOB, resumeJobId, } from "./deliver-job.js";
22
22
  /**
23
23
  * 结束后复核排的任务 id:每次唯一(带随机后缀,不含 `:`)。不能复用 `deliver-<会话>-<seq>`——同 id 的任务
24
24
  * 可能正因锁被占而即将直接结束,BullMQ 会把新任务去重进它,于是没人投递。
@@ -160,7 +160,22 @@ let ChannelJobs = class ChannelJobs {
160
160
  });
161
161
  }
162
162
  /**
163
- * 连接重新可投递(轮换了凭据 / 重新启用):给该连接投递进度落后于会话日志的会话各排一个出站任务,
163
+ * Owes the catch-up of a connection that can deliver again in the caller's transaction (the
164
+ * update that re-enabled it or rotated its credentials while active): the deliveries that ran
165
+ * while it could not deliver have ended, and nothing else finds the conversations lagging the log.
166
+ * `at` is the connection row's new `updated_at`.
167
+ */
168
+ oweResume(connectionId, orgId, at) {
169
+ return this.outbox.add({
170
+ queue: CHANNEL_QUEUE_NAME,
171
+ name: RESUME_JOB,
172
+ jobId: resumeJobId(connectionId, at),
173
+ data: { connectionId },
174
+ orgId,
175
+ });
176
+ }
177
+ /**
178
+ * The {@link RESUME_JOB} job: 连接重新可投递(轮换了凭据 / 重新启用):给该连接投递进度落后于会话日志的会话各排一个出站任务,
164
179
  * 补投停用或凭据失效期间落下的内容(不必等下一条消息)。
165
180
  */
166
181
  async resumeConnection(connectionId) {
@@ -97,7 +97,7 @@ export declare class ChannelOutbound {
97
97
  /** The conversation lock provider; delivery takes it under its own key prefix. */
98
98
  lock: Pick<ConversationLock, "scoped">, connections: Pick<ChannelConnections, "outbound">, approvals: Pick<ApprovalsService, "describe">, questions: Pick<QuestionsService, "forCall">, registry: Pick<ToolRegistry, "builtinSpecs" | "extensionToolSpecs">,
99
99
  /** 入站任务的处理(worker 上执行 `ChannelInbound.handle`);失败抛错由 BullMQ 重试。 */
100
- inbound: Pick<ChannelInbound, "handle">, jobs: Pick<ChannelJobs, "enqueue">, config: Pick<KernelConfigService, "get">, log: Pick<Logger, "error" | "warn" | "info">, txHost: TransactionHost,
100
+ inbound: Pick<ChannelInbound, "handle">, jobs: Pick<ChannelJobs, "enqueue" | "resumeConnection">, config: Pick<KernelConfigService, "get">, log: Pick<Logger, "error" | "warn" | "info">, txHost: TransactionHost,
101
101
  /** Pauses the task of a scheduled run whose chat is gone (scheduled tasks spec §8). */
102
102
  schedules: Pick<ScheduledTasksService, "pauseFor">);
103
103
  /**
@@ -112,7 +112,10 @@ export declare class ChannelOutbound {
112
112
  /** Stops: the jobs in flight are handed back at their next checkpoint, the worker closes. */
113
113
  close(): Promise<void>;
114
114
  private requeue;
115
- /** One job of the channel queue (the `@Processor` calls it): a delivery or an inbound event. */
115
+ /**
116
+ * One job of the channel queue (the `@Processor` calls it): a delivery, an inbound event or a
117
+ * connection's catch-up.
118
+ */
116
119
  process(job: Job<ChannelJobData>, token: string | undefined): Promise<void>;
117
120
  /** 本副本没有加载连接的插件:推迟重试(不消耗重试次数),排队超过上限才放弃。 */
118
121
  private pluginPending;
@@ -28,7 +28,7 @@ import { ToolRegistry } from "../tools/registry.js";
28
28
  import { approvalCard, cardMessage, connectUrl, conversationUrl, questionCard, webBase, } from "./cards.js";
29
29
  import { ChannelConnections } from "./connections.service.js";
30
30
  import { ChannelInbound } from "./inbound.js";
31
- import { ChannelJobs, INBOUND_JOB, idempotencyKey, } from "./jobs.js";
31
+ import { ChannelJobs, INBOUND_JOB, idempotencyKey, RESUME_JOB, } from "./jobs.js";
32
32
  import { phrase } from "./phrases.js";
33
33
  import { insertThread } from "./threads.js";
34
34
  // The queue, job names, ids and priorities live with the producer (jobs.ts)
@@ -233,12 +233,19 @@ let ChannelOutbound = class ChannelOutbound {
233
233
  await job.moveToDelayed(Date.now() + delayMs, token);
234
234
  throw new DelayedError();
235
235
  }
236
- /** One job of the channel queue (the `@Processor` calls it): a delivery or an inbound event. */
236
+ /**
237
+ * One job of the channel queue (the `@Processor` calls it): a delivery, an inbound event or a
238
+ * connection's catch-up.
239
+ */
237
240
  async process(job, token) {
238
241
  if (this.stopping.signal.aborted)
239
242
  return this.requeue(job, token, 0);
240
243
  if (job.name === INBOUND_JOB)
241
244
  return this.processInbound(job, token);
245
+ // a connection's catch-up: a failed add fails the job, which BullMQ retries
246
+ if (job.name === RESUME_JOB) {
247
+ return this.jobs.resumeConnection(job.data.connectionId);
248
+ }
242
249
  return this.deliver(job, token);
243
250
  }
244
251
  /** 本副本没有加载连接的插件:推迟重试(不消耗重试次数),排队超过上限才放弃。 */
@@ -312,8 +312,10 @@ export declare class ConversationsController {
312
312
  }>;
313
313
  /**
314
314
  * Stops the current run (the conversation's owner): 202 `{ runId }`; nothing active → 409
315
- * `run.not_active`. On a root conversation `runId` is the stopped run, and its background
316
- * sub-agents keep working. On a sub-agent's conversation (the root's owner, at any depth) the
315
+ * `run.not_active`. On a root conversation every active sub-agent of its tree stops with its
316
+ * run, and nothing they deliver starts the root again until its next run (agent map spec §6,
317
+ * changed in acceptance 2026-11-13); `runId` is the stopped run (the latest when only sub-agents
318
+ * ran). On a sub-agent's conversation (the root's owner, at any depth) the
317
319
  * sub-agent stops with every sub-agent below it, and `runId` is the sub-agent's latest run,
318
320
  * which may already be finished when only its descendants were working (agent map spec §6).
319
321
  */
@@ -456,8 +456,10 @@ let ConversationsController = class ConversationsController {
456
456
  }
457
457
  /**
458
458
  * Stops the current run (the conversation's owner): 202 `{ runId }`; nothing active → 409
459
- * `run.not_active`. On a root conversation `runId` is the stopped run, and its background
460
- * sub-agents keep working. On a sub-agent's conversation (the root's owner, at any depth) the
459
+ * `run.not_active`. On a root conversation every active sub-agent of its tree stops with its
460
+ * run, and nothing they deliver starts the root again until its next run (agent map spec §6,
461
+ * changed in acceptance 2026-11-13); `runId` is the stopped run (the latest when only sub-agents
462
+ * ran). On a sub-agent's conversation (the root's owner, at any depth) the
461
463
  * sub-agent stops with every sub-agent below it, and `runId` is the sub-agent's latest run,
462
464
  * which may already be finished when only its descendants were working (agent map spec §6).
463
465
  */
@@ -537,10 +537,43 @@ export declare class ConversationsService {
537
537
  * 等人处理的运行没有 worker:直接取消等待并写终态。其余经 Redis 广播,运行所在的任意副本都会收到;
538
538
  * 运行已是孤儿(没有任何 worker 在执行)时直接写终态。
539
539
  *
540
- * A root conversation's stop stops only its own run: its background sub-agents keep working
541
- * (agent map spec §6). A sub-agent's conversation (its owner, from the agent map) stops that
542
- * sub-agent with every descendant (`RunControl.stopTree`); `run.not_active` when nothing in that
543
- * subtree runs, else the sub-agent's latest run id.
540
+ * A root conversation's stop stops everything below it too (agent map spec §6, changed in
541
+ * acceptance 2026-11-13: after a person stops, the conversation stays stopped). In order:
542
+ * 1. `tree_stopped_at` is set on the root under its row lock: by the stopped run's terminal
543
+ * transaction (a waiting, queued or orphaned run), before the stop is asked for (a run a
544
+ * worker holds, which is active until then), or on its own (nothing of the root ran). From
545
+ * then on no sub-agent delivery wakes the root (`wakeDecisionIn`) and no
546
+ * sub-agent of the tree starts a run by a wake or a parent's message (`RunQueue.childWakeIn`),
547
+ * until the root next starts a run (a person's message, the queue, a background Bash task's
548
+ * finish), which clears it. A child that finishes just before or after the stop, `completed`
549
+ * or `aborted`, thus never wakes the root; its delivery stays in the log and reaches the
550
+ * model with the next run;
551
+ * 2. (with 1) the root's own active run stops as before ({@link abortRootRun});
552
+ * 3. every active sub-agent run of the tree stops (`RunControl.stopTree`, as the agent map's stop
553
+ * and the deletion do), with the background tasks of every sub-agent. A sub-agent the root's
554
+ * run dispatched after that read is stopped when the root run ends
555
+ * (`Waits.stopDescendantsOfStopped`).
556
+ * The root's own background Bash tasks keep running: they are the conversation's tasks, stopped
557
+ * with `TaskStop` or the task list. `run.not_active` when neither the root nor its tree had an
558
+ * active run (the stop still holds the tree's wakes); else the root's run id (its latest when only
559
+ * sub-agents ran).
560
+ *
561
+ * A sub-agent's conversation (its owner, from the agent map) stops that sub-agent with every
562
+ * descendant (`RunControl.stopTree`); `run.not_active` when nothing in that subtree runs, else
563
+ * the sub-agent's latest run id. Its `aborted` finish never wakes its parent.
544
564
  */
545
565
  abort(orgId: string, conversationId: string, accountId: string): Promise<string>;
566
+ /**
567
+ * Marks a person's stop of the root's tree (`tree_stopped_at`, see {@link abort}); committed
568
+ * before any stop, so every wake decided after it (under the root's row lock) sees it.
569
+ */
570
+ private holdTree;
571
+ /**
572
+ * Stops a root conversation's own active run (queued / running / waiting) and returns its id;
573
+ * null when none is active. Its sub-agents are the caller's. The tree's hold
574
+ * (`tree_stopped_at`) is written by the stopped run's terminal transaction (`RunFinish.finishIn`
575
+ * for a run a person stopped), in the lock order every stop and decision takes (conversation,
576
+ * decided row, run); a run a worker holds is marked before its stop is asked for as well.
577
+ */
578
+ private abortRootRun;
546
579
  }