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

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 (91) 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/catalog-content.d.ts +6 -1
  8. package/dist/catalog/catalog-content.js +2 -1
  9. package/dist/catalog/marketplaces.service.d.ts +1 -1
  10. package/dist/catalog/org-catalog.service.js +5 -2
  11. package/dist/catalog/plugin-install.d.ts +5 -0
  12. package/dist/catalog/plugin-install.js +37 -1
  13. package/dist/channels/channels.module.d.ts +9 -8
  14. package/dist/channels/channels.module.js +11 -10
  15. package/dist/channels/connections.service.d.ts +3 -3
  16. package/dist/channels/connections.service.js +6 -6
  17. package/dist/channels/deliver-job.d.ts +10 -3
  18. package/dist/channels/deliver-job.js +7 -3
  19. package/dist/channels/jobs.d.ts +11 -4
  20. package/dist/channels/jobs.js +18 -3
  21. package/dist/channels/outbound.d.ts +5 -2
  22. package/dist/channels/outbound.js +9 -2
  23. package/dist/conversations/conversations.controller.d.ts +4 -2
  24. package/dist/conversations/conversations.controller.js +4 -2
  25. package/dist/conversations/conversations.service.d.ts +37 -4
  26. package/dist/conversations/conversations.service.js +77 -20
  27. package/dist/conversations/events.d.ts +6 -0
  28. package/dist/conversations/events.js +6 -0
  29. package/dist/db/kernel-schema.d.ts +10 -1
  30. package/dist/devices/devices.service.d.ts +14 -6
  31. package/dist/devices/devices.service.js +20 -7
  32. package/dist/evals/evals.module.js +5 -1
  33. package/dist/evals/jobs.d.ts +14 -6
  34. package/dist/evals/jobs.js +21 -19
  35. package/dist/evals/runs.service.d.ts +5 -6
  36. package/dist/evals/runs.service.js +11 -33
  37. package/dist/infra/outbox/outbox.service.d.ts +6 -0
  38. package/dist/infra/outbox/outbox.service.js +51 -17
  39. package/dist/infra/outbox/registry.d.ts +52 -0
  40. package/dist/infra/outbox/registry.js +57 -0
  41. package/dist/local-devices/device-policy.service.d.ts +131 -26
  42. package/dist/local-devices/device-policy.service.js +341 -145
  43. package/dist/local-devices/machines.d.ts +17 -2
  44. package/dist/local-devices/machines.js +66 -5
  45. package/dist/local-devices/policy-op-jobs.d.ts +15 -0
  46. package/dist/local-devices/policy-op-jobs.js +12 -0
  47. package/dist/local-devices/policy-summary.d.ts +36 -3
  48. package/dist/local-devices/policy-summary.js +47 -1
  49. package/dist/orgs/orgs.service.js +4 -1
  50. package/dist/overrides.d.ts +4 -0
  51. package/dist/overrides.js +2 -0
  52. package/dist/platform/ops/conversations.ops.js +1 -1
  53. package/dist/runtime/maintenance.d.ts +6 -3
  54. package/dist/runtime/maintenance.js +21 -7
  55. package/dist/runtime/plugin-context.service.d.ts +9 -3
  56. package/dist/runtime/plugin-context.service.js +17 -5
  57. package/dist/runtime/run-context.service.d.ts +7 -3
  58. package/dist/runtime/run-context.service.js +12 -2
  59. package/dist/runtime/run-finish.d.ts +2 -2
  60. package/dist/runtime/run-finish.js +11 -6
  61. package/dist/runtime/run-jobs.d.ts +21 -4
  62. package/dist/runtime/run-jobs.js +29 -6
  63. package/dist/runtime/run-orphan-check.job.d.ts +14 -11
  64. package/dist/runtime/run-orphan-check.job.js +16 -11
  65. package/dist/runtime/run-queue.d.ts +23 -31
  66. package/dist/runtime/run-queue.js +62 -64
  67. package/dist/runtime/run-status.d.ts +12 -0
  68. package/dist/runtime/run-status.js +21 -0
  69. package/dist/runtime/run-worker.d.ts +10 -2
  70. package/dist/runtime/run-worker.js +44 -19
  71. package/dist/runtime/step-log.d.ts +6 -5
  72. package/dist/runtime/step-log.js +6 -5
  73. package/dist/runtime/usage-report.d.ts +3 -1
  74. package/dist/runtime/usage-report.js +4 -2
  75. package/dist/runtime/waits.d.ts +16 -6
  76. package/dist/runtime/waits.js +37 -15
  77. package/dist/schedules/scan.job.d.ts +6 -6
  78. package/dist/schedules/scan.job.js +6 -6
  79. package/dist/skills/catalog.d.ts +7 -3
  80. package/dist/skills/catalog.js +12 -2
  81. package/dist/tools/approvals.service.d.ts +96 -19
  82. package/dist/tools/approvals.service.js +300 -67
  83. package/dist/tools/builtins/sandbox/task-output.js +21 -6
  84. package/dist/tools/connectors.service.js +0 -4
  85. package/dist/tools/device-op-resolver.d.ts +40 -0
  86. package/dist/tools/device-op-resolver.js +121 -0
  87. package/dist/tools/network-asks.d.ts +1 -1
  88. package/dist/tools/network-asks.js +4 -3
  89. package/dist/tools/tools.module.js +4 -0
  90. package/package.json +2 -2
  91. package/src/migrations/0125_conversation_tree_stopped_at.sql +5 -0
@@ -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
  }
@@ -342,13 +342,11 @@ let ConversationsService = class ConversationsService {
342
342
  * log, runs and trajectory stay (super admins still see them).
343
343
  */
344
344
  async remove(orgId, conversationId, accountId) {
345
- await this.writable(orgId, conversationId, accountId);
346
- try {
347
- await this.abort(orgId, conversationId, accountId);
348
- }
349
- catch (e) {
350
- if (!(e instanceof ApiError && e.code === "run.not_active"))
351
- throw e;
345
+ const conv = await this.writable(orgId, conversationId, accountId);
346
+ // the root's own run only: the tree stops below, after the mark (a sub-agent's conversation
347
+ // is read-only, so `writable` refused it)
348
+ if (conv.parentConversationId === null) {
349
+ await this.abortRootRun(conversationId, accountId);
352
350
  }
353
351
  await this.markDeleted(orgId, conversationId, accountId);
354
352
  // after the mark, so no finish or delivery of the stops below can start a run on the deleted
@@ -981,25 +979,77 @@ let ConversationsService = class ConversationsService {
981
979
  * 等人处理的运行没有 worker:直接取消等待并写终态。其余经 Redis 广播,运行所在的任意副本都会收到;
982
980
  * 运行已是孤儿(没有任何 worker 在执行)时直接写终态。
983
981
  *
984
- * A root conversation's stop stops only its own run: its background sub-agents keep working
985
- * (agent map spec §6). A sub-agent's conversation (its owner, from the agent map) stops that
986
- * sub-agent with every descendant (`RunControl.stopTree`); `run.not_active` when nothing in that
987
- * subtree runs, else the sub-agent's latest run id.
982
+ * A root conversation's stop stops everything below it too (agent map spec §6, changed in
983
+ * acceptance 2026-11-13: after a person stops, the conversation stays stopped). In order:
984
+ * 1. `tree_stopped_at` is set on the root under its row lock: by the stopped run's terminal
985
+ * transaction (a waiting, queued or orphaned run), before the stop is asked for (a run a
986
+ * worker holds, which is active until then), or on its own (nothing of the root ran). From
987
+ * then on no sub-agent delivery wakes the root (`wakeDecisionIn`) and no
988
+ * sub-agent of the tree starts a run by a wake or a parent's message (`RunQueue.childWakeIn`),
989
+ * until the root next starts a run (a person's message, the queue, a background Bash task's
990
+ * finish), which clears it. A child that finishes just before or after the stop, `completed`
991
+ * or `aborted`, thus never wakes the root; its delivery stays in the log and reaches the
992
+ * model with the next run;
993
+ * 2. (with 1) the root's own active run stops as before ({@link abortRootRun});
994
+ * 3. every active sub-agent run of the tree stops (`RunControl.stopTree`, as the agent map's stop
995
+ * and the deletion do), with the background tasks of every sub-agent. A sub-agent the root's
996
+ * run dispatched after that read is stopped when the root run ends
997
+ * (`Waits.stopDescendantsOfStopped`).
998
+ * The root's own background Bash tasks keep running: they are the conversation's tasks, stopped
999
+ * with `TaskStop` or the task list. `run.not_active` when neither the root nor its tree had an
1000
+ * active run (the stop still holds the tree's wakes); else the root's run id (its latest when only
1001
+ * sub-agents ran).
1002
+ *
1003
+ * A sub-agent's conversation (its owner, from the agent map) stops that sub-agent with every
1004
+ * descendant (`RunControl.stopTree`); `run.not_active` when nothing in that subtree runs, else
1005
+ * the sub-agent's latest run id. Its `aborted` finish never wakes its parent.
988
1006
  */
989
1007
  async abort(orgId, conversationId, accountId) {
990
1008
  const conv = await this.get(orgId, conversationId, accountId);
1009
+ const latest = () => this.db
1010
+ .selectFrom("runs")
1011
+ .select("id")
1012
+ .where("conversation_id", "=", conversationId)
1013
+ .orderBy("created_at", "desc")
1014
+ .executeTakeFirst();
991
1015
  if (conv.parentConversationId !== null) {
992
- const latest = await this.db
993
- .selectFrom("runs")
994
- .select("id")
995
- .where("conversation_id", "=", conversationId)
996
- .orderBy("created_at", "desc")
997
- .executeTakeFirst();
1016
+ const last = await latest();
998
1017
  const n = await this.runs.stopTree(conversationId, accountId);
999
- if (n === 0 || !latest)
1018
+ if (n === 0 || !last)
1000
1019
  throw new ApiError("run.not_active", "No active run to abort");
1001
- return latest.id;
1020
+ return last.id;
1002
1021
  }
1022
+ const stopped = await this.abortRootRun(conversationId, accountId);
1023
+ // nothing of the root's ran: the hold is the stop's own write
1024
+ if (stopped === null)
1025
+ await this.holdTree(conversationId);
1026
+ const n = await this.runs.stopTree(conversationId, accountId, { includeSelf: false });
1027
+ if (stopped !== null)
1028
+ return stopped;
1029
+ const last = await latest();
1030
+ if (n === 0 || !last)
1031
+ throw new ApiError("run.not_active", "No active run to abort");
1032
+ return last.id;
1033
+ }
1034
+ /**
1035
+ * Marks a person's stop of the root's tree (`tree_stopped_at`, see {@link abort}); committed
1036
+ * before any stop, so every wake decided after it (under the root's row lock) sees it.
1037
+ */
1038
+ async holdTree(conversationId) {
1039
+ await this.db
1040
+ .updateTable("conversations")
1041
+ .set({ tree_stopped_at: sql `now()` })
1042
+ .where("id", "=", conversationId)
1043
+ .execute();
1044
+ }
1045
+ /**
1046
+ * Stops a root conversation's own active run (queued / running / waiting) and returns its id;
1047
+ * null when none is active. Its sub-agents are the caller's. The tree's hold
1048
+ * (`tree_stopped_at`) is written by the stopped run's terminal transaction (`RunFinish.finishIn`
1049
+ * for a run a person stopped), in the lock order every stop and decision takes (conversation,
1050
+ * decided row, run); a run a worker holds is marked before its stop is asked for as well.
1051
+ */
1052
+ async abortRootRun(conversationId, accountId) {
1003
1053
  const run = await this.db
1004
1054
  .selectFrom("runs")
1005
1055
  .select(["id", "status"])
@@ -1008,7 +1058,7 @@ let ConversationsService = class ConversationsService {
1008
1058
  .orderBy("created_at", "desc")
1009
1059
  .executeTakeFirst();
1010
1060
  if (!run)
1011
- throw new ApiError("run.not_active", "No active run to abort");
1061
+ return null;
1012
1062
  const runs = this.runs;
1013
1063
  // 等待中:直接结束;没成功(刚被续跑、已回到 queued)则按下面的常规路径中止。
1014
1064
  // the stop's terminal transaction owed the ensure-run backstop; the direct call starts what
@@ -1031,6 +1081,10 @@ let ConversationsService = class ConversationsService {
1031
1081
  await runs.ensureRunAfterCommit(conversationId);
1032
1082
  return run.id;
1033
1083
  }
1084
+ // a run a worker holds ends later: the hold is written now, before the stop is asked for,
1085
+ // while the run is still active (no wake can start the root before it), and again by its
1086
+ // terminal transaction
1087
+ await this.holdTree(conversationId);
1034
1088
  await runs.requestAbort(run.id, accountId);
1035
1089
  return run.id;
1036
1090
  }
@@ -1053,6 +1107,9 @@ __decorate([
1053
1107
  __decorate([
1054
1108
  Transactional({ propagation: "mandatory" })
1055
1109
  ], ConversationsService.prototype, "bindIn", null);
1110
+ __decorate([
1111
+ Transactional()
1112
+ ], ConversationsService.prototype, "holdTree", null);
1056
1113
  ConversationsService = __decorate([
1057
1114
  Injectable(),
1058
1115
  __param(0, Inject(KernelDb)),
@@ -737,6 +737,12 @@ export declare const ConversationEventSchema: z.ZodDiscriminatedUnion<[z.ZodObje
737
737
  byAccountId: z.ZodNullable<z.ZodString>;
738
738
  reason: z.ZodOptional<z.ZodString>;
739
739
  }, z.core.$strip>;
740
+ }, z.core.$strip>, z.ZodObject<{
741
+ type: z.ZodLiteral<"approval_updated">;
742
+ payload: z.ZodObject<{
743
+ approvalId: z.ZodString;
744
+ callId: z.ZodString;
745
+ }, z.core.$strip>;
740
746
  }, z.core.$strip>, z.ZodObject<{
741
747
  type: z.ZodLiteral<"question_asked">;
742
748
  payload: z.ZodObject<{
@@ -622,6 +622,12 @@ export const ConversationEventSchema = z.discriminatedUnion("type", [
622
622
  */
623
623
  reason: z.string().max(APPROVAL_DENY_REASON_MAX_CHARS).optional(),
624
624
  })),
625
+ /**
626
+ * A still-pending approval changed without a decision: a network ask's 「这台设备一直允许」 whose
627
+ * device change was not applied or not confirmed (`detail.attempt`, transactional outbox spec
628
+ * §7). Display only, never model-visible: the page reads the approval again.
629
+ */
630
+ ev("approval_updated", z.object({ approvalId: z.string(), callId: z.string() })),
625
631
  /**
626
632
  * 工具调用需要用户输入(`AskUserQuestion`、插件工具的 `input_required`、MCP elicitation):输入请求已建,
627
633
  * run 暂停等待回答。界面据此显示表单卡片(不进模型请求)。
@@ -268,6 +268,11 @@ export interface ConversationsTable {
268
268
  * (the column's `web` default is not relied on: a forgotten source must not read as web).
269
269
  */
270
270
  source: ColumnType<ConversationSource, ConversationSource, never>;
271
+ /**
272
+ * 0125: when a person stopped this root conversation and its sub-agent tree; cleared when the
273
+ * root next starts a run. While set the tree's wakes are held (agent map spec §6).
274
+ */
275
+ tree_stopped_at: ColumnType<Date | null, Date | string | null | undefined, Date | string | null>;
271
276
  }
272
277
  /** 0060: an org's order of file-preview@1 previewers (UI refresh B Task 11). */
273
278
  export interface FilePreviewersTable {
@@ -345,7 +350,11 @@ export interface RunsTable {
345
350
  waiting_since: TsNullable;
346
351
  /** 0012:累计等待时长(毫秒),不计入运行时长。 */
347
352
  waited_ms: Int8;
348
- /** 0012:续跑任务的 jobId;为空表示按触发消息生成的 jobId。 */
353
+ /**
354
+ * 0012: the run's job id when it is not the trigger's `runJobId`: a resume's
355
+ * (`resumeJobId`), or `<runJobId>-<n>` for a later run of the same trigger (R34); null: the
356
+ * trigger's first run, under `runJobId`.
357
+ */
349
358
  job_id: string | null;
350
359
  /** 0012:最近一次(续跑)回到 queued 的时刻。 */
351
360
  queued_at: TsNullable;
@@ -112,7 +112,8 @@ export declare const RECENT_POLICY_CHANGES = 10;
112
112
  /**
113
113
  * One sandbox change of a device, as its owner sees it on the device page (§10.4):
114
114
  * - `policy` (`device.policy_changed`): a local policy change sent to the machine (`web`, `im`),
115
- * or made on the machine by its owner (`device`, `capybara-device policy ...`);
115
+ * or made on the machine by its owner (`device`, `capybara-device policy ...`); one sent whose
116
+ * answer never came (`device.policy_unknown`) or that failed (`device.policy_failed`) too;
116
117
  * - `tier` (`device.tier_changed`): a cloud device's tier set by its owner;
117
118
  * - `isolation` (`device.isolation_reported`): the machine reported running without a sandbox,
118
119
  * or with one again.
@@ -141,8 +142,14 @@ export interface DeviceSandboxChangeView {
141
142
  by: string | null;
142
143
  /** `device`: made or reported on the machine itself (a `policy` change, an `isolation`). */
143
144
  via: "web" | "im" | "device";
144
- /** The machine answered with the policy as written (false: the answer never came). */
145
- confirmed: boolean;
145
+ /**
146
+ * What became of a `policy` change sent to the machine: `applied` (also every change made or
147
+ * reported on the machine, and every tier / isolation row); `unknown`, its answer never came
148
+ * (`device.policy_unknown`: an `applied` row follows when the machine reports it); `failed`,
149
+ * the machine refused it, never received it or stopped while applying it
150
+ * (`device.policy_failed`).
151
+ */
152
+ outcome: "applied" | "unknown" | "failed";
146
153
  }
147
154
  /** A change of a local device's policy by its owner (`POST /devices/:id/policy`). */
148
155
  export interface DevicePolicyChange {
@@ -309,7 +316,7 @@ export declare class DevicesService {
309
316
  * refused up front with `device.policy_refused` when the machine last reported
310
317
  * `remoteGrants: "off"` (otherwise the machine decides); narrowing is always sent, and
311
318
  * `isolation` is only ever `sandbox`. The machine offline → `device.offline`; a cloud device or a
312
- * program without the `policyOp` feature → `device.policy_unsupported`; the machine's refusal →
319
+ * program without the `policyOpId` feature → `device.policy_unsupported`; the machine's refusal →
313
320
  * `device.policy_refused` with its message. Callers are human sessions only (no personal access
314
321
  * token, no service token, no Platform op).
315
322
  */
@@ -394,8 +401,9 @@ export declare class DevicesService {
394
401
  private views;
395
402
  /**
396
403
  * The last {@link RECENT_POLICY_CHANGES} sandbox changes of each device in the org (audit rows
397
- * `device.policy_changed`, `device.tier_changed`, `device.isolation_reported`), newest first,
398
- * with the deciding account's display name.
404
+ * `device.policy_changed`, `device.policy_unknown`, `device.policy_failed`,
405
+ * `device.tier_changed`, `device.isolation_reported`), newest first, with the deciding account's
406
+ * display name.
399
407
  */
400
408
  private recentSandboxChanges;
401
409
  /**
@@ -436,7 +436,7 @@ let DevicesService = class DevicesService {
436
436
  * refused up front with `device.policy_refused` when the machine last reported
437
437
  * `remoteGrants: "off"` (otherwise the machine decides); narrowing is always sent, and
438
438
  * `isolation` is only ever `sandbox`. The machine offline → `device.offline`; a cloud device or a
439
- * program without the `policyOp` feature → `device.policy_unsupported`; the machine's refusal →
439
+ * program without the `policyOpId` feature → `device.policy_unsupported`; the machine's refusal →
440
440
  * `device.policy_refused` with its message. Callers are human sessions only (no personal access
441
441
  * token, no service token, no Platform op).
442
442
  */
@@ -452,7 +452,7 @@ let DevicesService = class DevicesService {
452
452
  const widens = (change.add?.length ?? 0) > 0 || (known !== null && tierWidens(known.tier, change.tier));
453
453
  // only a known "off" is refused here; an unknown summary, a machine that cannot take the op
454
454
  // or none at all are left to apply (the machine decides, or unsupported / offline)
455
- const canApply = summary?.features.includes("policyOp") === true;
455
+ const canApply = summary?.features.includes("policyOpId") === true;
456
456
  if (widens && canApply && known?.remoteGrants === "off") {
457
457
  throw new ApiError("device.policy_refused", remoteGrantsOffMessage(change));
458
458
  }
@@ -893,8 +893,9 @@ let DevicesService = class DevicesService {
893
893
  }
894
894
  /**
895
895
  * The last {@link RECENT_POLICY_CHANGES} sandbox changes of each device in the org (audit rows
896
- * `device.policy_changed`, `device.tier_changed`, `device.isolation_reported`), newest first,
897
- * with the deciding account's display name.
896
+ * `device.policy_changed`, `device.policy_unknown`, `device.policy_failed`,
897
+ * `device.tier_changed`, `device.isolation_reported`), newest first, with the deciding account's
898
+ * display name.
898
899
  */
899
900
  async recentSandboxChanges(orgId, deviceIds) {
900
901
  const out = new Map();
@@ -1040,6 +1041,8 @@ const limitsView = (l) => ({
1040
1041
  /** The audit actions a device's recent sandbox changes list. */
1041
1042
  const SANDBOX_CHANGE_ACTIONS = [
1042
1043
  "device.policy_changed",
1044
+ "device.policy_unknown",
1045
+ "device.policy_failed",
1043
1046
  "device.tier_changed",
1044
1047
  "device.isolation_reported",
1045
1048
  ];
@@ -1059,10 +1062,16 @@ function sandboxChangeOf(action, at, d, by) {
1059
1062
  by,
1060
1063
  };
1061
1064
  if (action === "device.tier_changed") {
1062
- return { ...base, kind: "tier", tier: { to: tierOrNull(d.to) }, via: "web", confirmed: true };
1065
+ return {
1066
+ ...base,
1067
+ kind: "tier",
1068
+ tier: { to: tierOrNull(d.to) },
1069
+ via: "web",
1070
+ outcome: "applied",
1071
+ };
1063
1072
  }
1064
1073
  if (action === "device.isolation_reported") {
1065
- return { ...base, kind: "isolation", isolation, via: "device", confirmed: true };
1074
+ return { ...base, kind: "isolation", isolation, via: "device", outcome: "applied" };
1066
1075
  }
1067
1076
  return {
1068
1077
  ...base,
@@ -1079,7 +1088,11 @@ function sandboxChangeOf(action, at, d, by) {
1079
1088
  : null,
1080
1089
  remoteGrants: d.remoteGrants === "owner" || d.remoteGrants === "off" ? d.remoteGrants : null,
1081
1090
  via: d.via === "im" ? "im" : d.via === "device" ? "device" : "web",
1082
- confirmed: d.confirmed === true,
1091
+ outcome: action === "device.policy_unknown"
1092
+ ? "unknown"
1093
+ : action === "device.policy_failed"
1094
+ ? "failed"
1095
+ : "applied",
1083
1096
  };
1084
1097
  }
1085
1098
  /**
@@ -21,7 +21,7 @@ import { OrgLockModule } from "../orgs/org-lock.js";
21
21
  import { ProvidersModule } from "../runtime/providers.js";
22
22
  import { RuntimeModule } from "../runtime/runtime.module.js";
23
23
  import { EvalBudgetModule } from "./budget.js";
24
- import { EVAL_QUEUE_NAME, EvalJobs } from "./jobs.js";
24
+ import { EVAL_CASE_JOB, EVAL_QUEUE_NAME, EvalJobs } from "./jobs.js";
25
25
  import { EvalReplaySeeder } from "./run-replay.js";
26
26
  import { EvalRunsService } from "./runs.service.js";
27
27
  import { EvalSetsService } from "./sets.service.js";
@@ -66,6 +66,10 @@ let EvalsProcessor = class EvalsProcessor extends WorkerHost {
66
66
  this.evals = evals;
67
67
  }
68
68
  process(job, token) {
69
+ // the queue's one job name, owed by an eval run's start
70
+ if (job.name !== EVAL_CASE_JOB) {
71
+ return Promise.reject(new Error(`unknown eval job "${job.name}"`));
72
+ }
69
73
  return runJob(EVAL_QUEUE_NAME, job, () => this.evals.process(job, token));
70
74
  }
71
75
  failed(job, err) {
@@ -1,6 +1,8 @@
1
- import type { Queue } from "bullmq";
1
+ import { OutboxService } from "../infra/outbox/outbox.service.js";
2
2
  /** 评测队列的 BullMQ 队列名(不含 `:`,C7)。 */
3
3
  export declare const EVAL_QUEUE_NAME = "cap-kernel-evals";
4
+ /** The one job name on {@link EVAL_QUEUE_NAME}: one case of an eval run. */
5
+ export declare const EVAL_CASE_JOB = "eval-case";
4
6
  /** 每个用例一个任务(C7;不含 `:`)。 */
5
7
  export declare const evalJobId: (runId: string, caseId: string) => string;
6
8
  /** 评测任务的数据。 */
@@ -10,11 +12,17 @@ export interface EvalJobData {
10
12
  orgId: string;
11
13
  }
12
14
  /**
13
- * The producer of `cap-kernel-evals` (one job per case, {@link evalJobId}); the eval worker
14
- * consumes it on the worker role. A leaf module: the runs service and the worker import it.
15
+ * The producer of `cap-kernel-evals`: owes one {@link EVAL_CASE_JOB} per case (under
16
+ * {@link evalJobId}) in the transaction that writes the run and its pending results (transactional
17
+ * outbox spec §3), so a run never waits for jobs that were never added. The eval worker consumes
18
+ * them on the worker role. A leaf module: the runs service and the worker import it.
15
19
  */
16
20
  export declare class EvalJobs {
17
- private readonly queue;
18
- constructor(queue: Pick<Queue, "addBulk">);
19
- enqueue(jobs: EvalJobData[]): Promise<void>;
21
+ private readonly outbox;
22
+ constructor(outbox: Pick<OutboxService, "addMany">);
23
+ /**
24
+ * Owes the case jobs in the caller's transaction (throws outside one), in one insert: a set has
25
+ * up to `evals.maxCasesPerSet` cases, written while the start holds the org lock.
26
+ */
27
+ owe(jobs: readonly EvalJobData[]): Promise<void>;
20
28
  }
@@ -7,39 +7,41 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
7
7
  var __param = (this && this.__param) || function (paramIndex, decorator) {
8
8
  return function (target, key) { decorator(target, key, paramIndex); }
9
9
  };
10
- import { getQueueToken } from "@nestjs/bullmq";
11
10
  import { Inject, Injectable } from "@nestjs/common";
11
+ import { OutboxService } from "../infra/outbox/outbox.service.js";
12
12
  /** 评测队列的 BullMQ 队列名(不含 `:`,C7)。 */
13
13
  export const EVAL_QUEUE_NAME = "cap-kernel-evals";
14
+ /** The one job name on {@link EVAL_QUEUE_NAME}: one case of an eval run. */
15
+ export const EVAL_CASE_JOB = "eval-case";
14
16
  /** 每个用例一个任务(C7;不含 `:`)。 */
15
17
  export const evalJobId = (runId, caseId) => `eval-${runId}-${caseId}`;
16
18
  /**
17
- * The producer of `cap-kernel-evals` (one job per case, {@link evalJobId}); the eval worker
18
- * consumes it on the worker role. A leaf module: the runs service and the worker import it.
19
+ * The producer of `cap-kernel-evals`: owes one {@link EVAL_CASE_JOB} per case (under
20
+ * {@link evalJobId}) in the transaction that writes the run and its pending results (transactional
21
+ * outbox spec §3), so a run never waits for jobs that were never added. The eval worker consumes
22
+ * them on the worker role. A leaf module: the runs service and the worker import it.
19
23
  */
20
24
  let EvalJobs = class EvalJobs {
21
- queue;
22
- constructor(queue) {
23
- this.queue = queue;
25
+ outbox;
26
+ constructor(outbox) {
27
+ this.outbox = outbox;
24
28
  }
25
- async enqueue(jobs) {
26
- await this.queue.addBulk(jobs.map((data) => ({
27
- name: "case",
29
+ /**
30
+ * Owes the case jobs in the caller's transaction (throws outside one), in one insert: a set has
31
+ * up to `evals.maxCasesPerSet` cases, written while the start holds the org lock.
32
+ */
33
+ async owe(jobs) {
34
+ await this.outbox.addMany(jobs.map((data) => ({
35
+ queue: EVAL_QUEUE_NAME,
36
+ name: EVAL_CASE_JOB,
37
+ jobId: evalJobId(data.runId, data.caseId),
28
38
  data,
29
- opts: {
30
- jobId: evalJobId(data.runId, data.caseId),
31
- // 处理函数外的暂时故障(PG / Redis):指数退避重试,不让整个用例一次失败。
32
- // 结果按认领号写入,重做不会重复写结果
33
- attempts: 3,
34
- backoff: { type: "exponential", delay: 1000 },
35
- removeOnComplete: true,
36
- removeOnFail: 1000,
37
- },
39
+ orgId: data.orgId,
38
40
  })));
39
41
  }
40
42
  };
41
43
  EvalJobs = __decorate([
42
44
  Injectable(),
43
- __param(0, Inject(getQueueToken(EVAL_QUEUE_NAME)))
45
+ __param(0, Inject(OutboxService))
44
46
  ], EvalJobs);
45
47
  export { EvalJobs };
@@ -145,7 +145,7 @@ export declare const RESULTS_ASOF_OVERLAP_MS = 15000;
145
145
  export declare class EvalRunsService {
146
146
  readonly txHost: TransactionHost;
147
147
  private readonly d;
148
- constructor(kdb: KernelDb, orgLock: OrgLock, locks: LockService, config: KernelConfigService, registry: ProviderRegistry, prices: PricesService, settings: SettingsService, queue: EvalJobs, budget: EvalBudget, purposes: ModelPurposesService, txHost: TransactionHost);
148
+ constructor(kdb: KernelDb, orgLock: OrgLock, locks: LockService, config: KernelConfigService, registry: ProviderRegistry, prices: PricesService, settings: SettingsService, jobs: EvalJobs, budget: EvalBudget, purposes: ModelPurposesService, txHost: TransactionHost);
149
149
  private liveSet;
150
150
  /**
151
151
  * 解析模型;不存在 / 不可用 → `eval.candidate_invalid`(供应商列表暂时失败照常抛出)。
@@ -187,8 +187,9 @@ export declare class EvalRunsService {
187
187
  private tokenCaps;
188
188
  /**
189
189
  * 启动:校验 token 上限([1, `evals.maxTokenCap`],缺省 `evals.defaultTokenCap`)、候选、评委用途;
190
- * 没有可回放的 → `eval.nothing_to_replay`。事务内建运行行、预建 pending 结果,提交后每个用例入队一个任务;
191
- * 入队失败 → 运行标 failed、结果 skipped,错误照常抛出。
190
+ * 没有可回放的 → `eval.nothing_to_replay`。One transaction writes the run row and its pending
191
+ * results and owes one case job per valid case (transactional outbox spec §3): the outbox adds
192
+ * them after the commit, so a run never waits for a job that was never added.
192
193
  */
193
194
  start(orgId: string, actor: string, setId: string, input: {
194
195
  mode: "step" | "run";
@@ -196,10 +197,8 @@ export declare class EvalRunsService {
196
197
  judge: boolean;
197
198
  tokenCap?: number;
198
199
  }, actorTokenId?: string, auditDetail?: Record<string, unknown>): Promise<EvalRunView>;
199
- /** The run row and its pending results, in a transaction of their own (enqueued after the commit). */
200
+ /** The run row, its pending results and its owed case jobs, in a transaction of their own. */
200
201
  private startIn;
201
- /** The enqueue failed: the run is `failed`, its pending results `skipped`. */
202
- private failStartIn;
203
202
  /** 各运行的汇总(只算已结束的)。 */
204
203
  private summaries;
205
204
  private static toView;