@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
@@ -8,6 +8,7 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
8
8
  return function (target, key) { decorator(target, key, paramIndex); }
9
9
  };
10
10
  import { randomUUID } from "node:crypto";
11
+ import { isDeepStrictEqual } from "node:util";
11
12
  import { connectorToolKey, parseToolKey, validateAllowHost, } from "@capybara-hq/plugin-sdk";
12
13
  import { Inject, Injectable } from "@nestjs/common";
13
14
  import { sql } from "kysely";
@@ -17,17 +18,19 @@ import { conversationSourceOf } from "../conversations/source.js";
17
18
  import { ApiError } from "../errors.js";
18
19
  import { audit } from "../identity/audit.js";
19
20
  import { KernelConfigService } from "../infra/config/kernel-config.service.js";
21
+ import { afterCommit } from "../infra/database/after-commit.js";
20
22
  import { KernelDb } from "../infra/database/kernel-db.js";
21
23
  import { TransactionHost } from "../infra/database/transaction-host.js";
22
24
  import { Transactional } from "../infra/database/transactional.decorator.js";
23
25
  import { KernelEvents } from "../infra/events/kernel-events.js";
24
26
  import { LockService } from "../infra/lock/lock.service.js";
25
27
  import { WithLock } from "../infra/lock/with-lock.decorator.js";
26
- import { DevicePolicyService } from "../local-devices/device-policy.service.js";
28
+ import { DevicePolicyService, } from "../local-devices/device-policy.service.js";
29
+ import { pendingPolicyOpsOf } from "../local-devices/policy-summary.js";
27
30
  import { ORG_ROLES, roleAtLeast } from "../orgs/roles.js";
28
31
  import { RunResume } from "../runtime/run-jobs.js";
29
32
  import { rootConversationOf } from "../runtime/subagents/child.js";
30
- import { SandboxService } from "../sandbox/sandbox.service.js";
33
+ import { deviceNotFound, SandboxService } from "../sandbox/sandbox.service.js";
31
34
  import { readScheduleSettings } from "../schedules/effective-settings.js";
32
35
  import { grantRefusalOf, NETWORK_GRANT_PREFIX, NETWORK_GRANT_SPEC_HASH, networkGrantRefusal, } from "../schedules/grant-rules.js";
33
36
  import { ScheduledTaskStore } from "../schedules/store.js";
@@ -55,6 +58,18 @@ export const NETWORK_ASK_TOOL_KEY = "Bash";
55
58
  /** The spec hash of a network ask (no tool definition is approved). */
56
59
  const NETWORK_ASK_SPEC_HASH = "network";
57
60
  const notFound = () => new ApiError("approval.not_found", "Approval not found");
61
+ /**
62
+ * A decision refused before it wrote anything (the ask closed, expired or is not the account's to
63
+ * decide): the device op's outcome is still recorded in the same transaction.
64
+ */
65
+ const refusedBeforeWrite = (e) => e instanceof ApiError &&
66
+ (e.code === "approval.already_decided" || e.code === "approval.not_found");
67
+ /** A pending network ask's reason when its device change got no answer (`detail.attempt`). */
68
+ const UNCONFIRMED_ATTEMPT = "The device did not confirm the change to its network rules; its next connection tells whether it took effect.";
69
+ /** A pending network ask's reason when the device applied it but its chooser cannot decide. */
70
+ const APPLIED_UNDECIDED_ATTEMPT = "The device allowed it, but the person who chose this can no longer decide this request.";
71
+ /** A pending network ask's reason when its device change failed without a message. */
72
+ const NOT_APPLIED_ATTEMPT = "The device did not apply the change to its network rules.";
58
73
  const iso = (v) => new Date(v).toISOString();
59
74
  const isRole = (v) => v !== null && ORG_ROLES.includes(v);
60
75
  /** The scopes of an approval without stored ones: a role escalation is approved only once. */
@@ -126,7 +141,10 @@ let ApprovalsService = class ApprovalsService {
126
141
  constructor(db, config, eventLog, events, txHost, schedules,
127
142
  /** A trial approval is kept only for a tool the task may hold as a grant (its agent's tools). */
128
143
  registry,
129
- /** 「这台设备一直允许」 sends the `policy` op to the device before the decision is recorded. */
144
+ /**
145
+ * 「这台设备一直允许」 records the device change's intent, sends the `policy` op, then records
146
+ * its outcome with the decision.
147
+ */
130
148
  policies,
131
149
  /** One decision of a network ask at a time, across replicas (the op is sent before the transaction). */
132
150
  lockService,
@@ -391,12 +409,13 @@ let ApprovalsService = class ApprovalsService {
391
409
  * A network ask's decision (network ask-to-allow spec §5.2), one at a time per ask across
392
410
  * replicas: only the device owner (`requested_for`) decides, in a signed-in session (a personal
393
411
  * access token is refused; the route already refuses service and embed tokens); the allowed
394
- * hosts are a subset of those asked. 「这台设备一直允许」 sends the `policy` op first, outside any
395
- * transaction; when the device cannot be reached or lacks the feature the decision is recorded
396
- * as a run grant with the reason, and when the device refuses (its owner takes no remote grants)
397
- * as `always` with outcome `refused` and nothing granted. Then {@link decideIn} records what took
398
- * effect and the run resumes after the commit; a change the device applied after the ask closed
399
- * is audited (`late`) and answered with a 409 that says so.
412
+ * hosts are a subset of those asked. 「这台设备一直允许」 goes through the device change's intent
413
+ * ({@link decideAlways}): when the device cannot be reached or lacks the feature the decision is
414
+ * recorded as a run grant with the reason, and when the device refuses (its owner takes no
415
+ * remote grants) as `always` with outcome `refused` and nothing granted; the decision and the
416
+ * op's outcome are one transaction. Every decision resumes the run in its transaction and is
417
+ * announced after the commit; a change the device applied after the ask closed is audited
418
+ * (`late`) and answered with a 409 that says so.
400
419
  */
401
420
  async decideNetwork(orgId, id, accountId, body, actorTokenId, via) {
402
421
  const row = await this.db
@@ -458,18 +477,11 @@ let ApprovalsService = class ApprovalsService {
458
477
  }
459
478
  network = { wanted: granted, granted, outcome: "once", via };
460
479
  if (scope === "always") {
461
- const applied = await this.applyAlways(orgId, detail.deviceId, granted, {
462
- approvalId: id,
463
- accountId,
464
- via,
465
- });
466
- network =
467
- applied === null
468
- ? { wanted: granted, granted, outcome: "always", via }
469
- : applied === "refused"
470
- ? // the device's owner takes no remote grants: nothing is allowed, not even this run
471
- { wanted: granted, granted: [], outcome: "refused", fallback: applied, via }
472
- : { wanted: granted, granted, outcome: "once_fallback", fallback: applied, via };
480
+ const decided = await this.decideAlways(orgId, ask, accountId, body, granted, via);
481
+ // null: the resolver decided it as this owner meanwhile (announced by its commit)
482
+ if (decided.toSeq !== null)
483
+ await this.announceDecided(decided.record, decided.toSeq);
484
+ return decided.record;
473
485
  }
474
486
  }
475
487
  else {
@@ -480,46 +492,267 @@ let ApprovalsService = class ApprovalsService {
480
492
  }
481
493
  network = { wanted: [], granted: [], outcome: null, via };
482
494
  }
483
- let decided;
495
+ const decided = await this.decideIn(orgId, id, accountId, body, actorTokenId, network);
496
+ await this.announceDecided(decided.record, decided.toSeq);
497
+ return decided.record;
498
+ }
499
+ /**
500
+ * 「这台设备一直允许」 (transactional outbox spec §7): the intent of the device change (naming the
501
+ * ask) is recorded, the `policy` op sent (no transaction open), then {@link settleNetworkOp}
502
+ * decides the ask and records the op's outcome in one transaction. When nothing could be sent
503
+ * (the device is offline, deleted, or its program lacks the feature) the decision is a run
504
+ * grant with the reason. The device's answer decides: applied → `always`; refused → `always`
505
+ * with outcome `refused`, nothing granted; no answer → a run grant (`once_fallback`, `offline`)
506
+ * while the op stays `unknown` for the machine's next hello or the resolver. A device that
507
+ * reconnects during the call and reports the op applied lets the resolver approve the ask as
508
+ * this owner first: that decision is returned (`toSeq` null: already announced), never a 409;
509
+ * when it granted other hosts than this call's (another of the owner's changes) and this call's
510
+ * change was not confirmed, `approval.already_decided` says so. A device deleted during the
511
+ * call is `device.not_found` (the op recorded unknown).
512
+ */
513
+ async decideAlways(orgId, ask, accountId, body, granted, via) {
514
+ const detail = ask.detail;
515
+ const accountName = (await this.displayNameOf(accountId)) ?? accountId;
516
+ let begun;
484
517
  try {
485
- decided = await this.decideIn(orgId, id, accountId, body, actorTokenId, network);
518
+ begun = await this.policies.begin({
519
+ orgId,
520
+ deviceId: detail.deviceId,
521
+ change: { add: granted },
522
+ source: { approvalId: ask.id, accountId, accountName, via },
523
+ });
486
524
  }
487
525
  catch (e) {
488
- // the device took the hosts, but a reply, the expiry or a stop closed the ask meanwhile:
489
- // the change stands (the owner chose it), so it is audited and said
490
- if (network.outcome === "always" &&
491
- e instanceof ApiError &&
492
- e.code === "approval.already_decided") {
493
- await this.auditLateAllow(orgId, ask, network);
494
- throw new ApiError("approval.already_decided", `The device now allows ${network.granted.join(", ")}; the request had already closed.`);
526
+ // the device was deleted meanwhile: nothing to write to, the run cannot go on there either
527
+ if (!(e instanceof ApiError && e.code === "device.not_found"))
528
+ throw e;
529
+ begun = { outcome: { applied: false, reason: "offline", message: e.message } };
530
+ }
531
+ if ("outcome" in begun) {
532
+ // nothing was sent: a run grant with the reason
533
+ const fallback = begun.outcome.applied ? "offline" : begun.outcome.reason;
534
+ return this.decideIn(orgId, ask.id, accountId, body, undefined, {
535
+ wanted: granted,
536
+ granted,
537
+ outcome: "once_fallback",
538
+ fallback,
539
+ via,
540
+ });
541
+ }
542
+ const { intent } = begun;
543
+ const sent = await this.policies.send(intent);
544
+ if (sent.notFound) {
545
+ // deleted during the call: nothing to grant on, the run cannot go on there either
546
+ await this.settleOpOnly({ machineId: intent.machineId, opId: intent.opId }, sent.result);
547
+ throw deviceNotFound();
548
+ }
549
+ const network = sent.result.outcome === "applied"
550
+ ? { wanted: granted, granted, outcome: "always", via }
551
+ : sent.result.outcome === "refused"
552
+ ? // the device's owner takes no remote grants: nothing is allowed, not even this run
553
+ { wanted: granted, granted: [], outcome: "refused", fallback: "refused", via }
554
+ : { wanted: granted, granted, outcome: "once_fallback", fallback: "offline", via };
555
+ const settled = await this.settleNetworkOp(orgId, ask.id, accountId, body, network, {
556
+ machineId: intent.machineId,
557
+ opId: intent.opId,
558
+ result: sent.result,
559
+ });
560
+ if ("decided" in settled)
561
+ return settled.decided;
562
+ if (settled.closed.code === "approval.already_decided" && !settled.late) {
563
+ // the machine reconnected mid-call and reported the op: the resolver approved it as this
564
+ // owner, with this change; it is their decision, not a conflict
565
+ const now = await this.db
566
+ .selectFrom("approvals")
567
+ .selectAll()
568
+ .where("id", "=", ask.id)
569
+ .executeTakeFirst();
570
+ const record = now === undefined ? null : toRecord(now);
571
+ if (record?.status === "approved" &&
572
+ record.detail?.outcome === "always" &&
573
+ record.decidedBy === accountId) {
574
+ const stored = [...(record.detail.grantedHosts ?? [])].sort();
575
+ if (isDeepStrictEqual(stored, [...granted].sort()))
576
+ return { record, toSeq: null };
577
+ // approved by another of this owner's changes (other hosts), and this call's change was
578
+ // not confirmed by the device: never report it as done
579
+ throw new ApiError("approval.already_decided", `The request was already approved to always allow ${stored.join(", ") || "no hosts"}; ` +
580
+ `the device did not confirm allowing ${granted.join(", ")}.`);
495
581
  }
496
- throw e;
497
582
  }
498
- await this.announceDecided(decided.record, decided.toSeq);
499
- return decided.record;
583
+ // the device took the hosts, but a reply, the expiry or a stop closed the ask meanwhile: the
584
+ // change stands (the owner chose it), audited late in the settle transaction, and said
585
+ if (settled.late && settled.closed.code === "approval.already_decided") {
586
+ throw new ApiError("approval.already_decided", `The device now allows ${granted.join(", ")}; the request had already closed.`);
587
+ }
588
+ throw settled.closed;
589
+ }
590
+ /** Records only the op's outcome, in its own transaction (the ask is left as it is). */
591
+ async settleOpOnly(op, result) {
592
+ await this.policies.settleIn(op, result);
593
+ }
594
+ /**
595
+ * The resolve transaction of a network ask's device change: the ask decided as `network` says
596
+ * ({@link decideTx}: lock order conversation → approval → run), then the op's outcome recorded
597
+ * (`DevicePolicyService.settleIn`: the machine's row). When the ask closed meanwhile (a reply,
598
+ * the expiry, a stop) only the op's outcome is recorded, and a change the device applied is
599
+ * audited `device.network_allowed` with `late: true` in the same transaction.
600
+ */
601
+ async settleNetworkOp(orgId, id, accountId, body, network, op) {
602
+ let decided;
603
+ try {
604
+ decided = await this.decideTx(orgId, id, accountId, body, undefined, network);
605
+ }
606
+ catch (e) {
607
+ // refused before any write (closed, expired, gone): the op's outcome is recorded anyway
608
+ if (!refusedBeforeWrite(e))
609
+ throw e;
610
+ const late = await this.settleClosedIn(id, op, network.via);
611
+ return { closed: e, late };
612
+ }
613
+ await this.policies.settleIn(op, op.result);
614
+ return { decided };
500
615
  }
501
- /** `device.network_allowed` of a policy change that reached the device after the ask closed. */
502
- async auditLateAllow(orgId, ask, network) {
616
+ /**
617
+ * The op's outcome for an ask that is no longer pending (in the caller's transaction): recorded,
618
+ * and a change the device applied audited `device.network_allowed` `late` (the change stands:
619
+ * the owner chose it; no inverse change is sent). True when it was audited late. `undecided`:
620
+ * the ask is still pending but the op's account can no longer decide it; the allow is audited
621
+ * without `late` (it is not after the ask) and the caller gives the ask the reason.
622
+ */
623
+ async settleClosedIn(approvalId, op, via, undecided = false) {
624
+ const pendingOp = await this.pendingOpOf(op.machineId, op.opId);
625
+ const out = await this.policies.settleIn(op, op.result);
626
+ if (out === null || pendingOp === null || op.result.outcome !== "applied")
627
+ return false;
628
+ const row = await this.db
629
+ .selectFrom("approvals")
630
+ .selectAll()
631
+ .where("id", "=", approvalId)
632
+ .executeTakeFirst();
633
+ if (!row)
634
+ return false;
635
+ const ask = toRecord(row);
503
636
  if (ask.detail === null)
504
- return;
637
+ return false;
505
638
  await audit(this.db.db, {
506
- actorAccountId: ask.requestedFor,
507
- orgId,
639
+ actorAccountId: pendingOp.source.accountId,
640
+ orgId: ask.orgId,
508
641
  action: "device.network_allowed",
509
642
  target: ask.detail.deviceId,
510
643
  detail: {
511
644
  approvalId: ask.id,
512
645
  deviceId: ask.detail.deviceId,
513
- hosts: network.granted,
646
+ hosts: pendingOp.add,
514
647
  scope: "always",
515
648
  outcome: "always",
516
- late: true,
517
- by: ask.requestedFor,
518
- via: network.via,
649
+ ...(undecided ? { undecided: true } : { late: true }),
650
+ by: pendingOp.source.accountId,
651
+ via,
519
652
  conversationId: ask.conversationId,
520
653
  runId: ask.runId,
521
654
  },
522
655
  });
656
+ return true;
657
+ }
658
+ /** The live intent `opId` on machine `machineId` (null: settled already, or expired). */
659
+ async pendingOpOf(machineId, opId) {
660
+ const r = await this.db
661
+ .selectFrom("device_machines")
662
+ .select("pending_policy_ops")
663
+ .where("id", "=", machineId)
664
+ .executeTakeFirst();
665
+ const op = pendingPolicyOpsOf(r?.pending_policy_ops).find((o) => o.id === opId);
666
+ return op === undefined || op.until <= Date.now() ? null : op;
667
+ }
668
+ /**
669
+ * The `device-op-resolve` job's outcome of an op that answers network ask `op.source.approvalId`
670
+ * (transactional outbox spec §7.4), in the resolver's transaction: under the ask's locks
671
+ * (conversation, then the approval) the op is read again (settled meanwhile: nothing to do).
672
+ * Applied, and the ask still pending → approved `always` by the op's account (`via` as it
673
+ * asked), the run resumed in this transaction as every decision does; the ask closed → audited
674
+ * `late`; still pending but the op's account can no longer decide it → the allow audited (not
675
+ * late) and the ask left pending with `applied_undecided`. Not applied (refused, never received,
676
+ * interrupted) or still unknown → the op's outcome recorded and the ask left pending with the
677
+ * reason (`detail.attempt`) and an `approval_updated` event so the card shows it: no person is
678
+ * there to fall back to a run grant (D2).
679
+ */
680
+ async settleFromResolver(ref, result) {
681
+ const t = this.db.db;
682
+ const op = { machineId: ref.machineId, opId: ref.opId, result };
683
+ if (!(await this.lockConversationOf(ref.approvalId))) {
684
+ await this.policies.settleIn(op, result);
685
+ return;
686
+ }
687
+ const row = await t
688
+ .selectFrom("approvals")
689
+ .selectAll()
690
+ .select(sql `expires_at <= now()`.as("expired"))
691
+ .where("id", "=", ref.approvalId)
692
+ .forUpdate()
693
+ .executeTakeFirst();
694
+ const pendingOp = await this.pendingOpOf(ref.machineId, ref.opId);
695
+ if (row === undefined || pendingOp === null) {
696
+ await this.policies.settleIn(op, result);
697
+ return;
698
+ }
699
+ const ask = toRecord(row);
700
+ const open = ask.status === "pending" && !row.expired && ask.kind === "network";
701
+ const via = pendingOp.source.via;
702
+ if (result.outcome === "applied") {
703
+ if (!open) {
704
+ await this.settleClosedIn(ask.id, op, via);
705
+ return;
706
+ }
707
+ let decided;
708
+ try {
709
+ decided = await this.decideTx(ask.orgId, ask.id, pendingOp.source.accountId, { decision: "approve", scope: "always" }, undefined, { wanted: pendingOp.add, granted: pendingOp.add, outcome: "always", via });
710
+ }
711
+ catch (e) {
712
+ // the op's account may no longer decide it: the change stands, the ask waits with why
713
+ if (!refusedBeforeWrite(e))
714
+ throw e;
715
+ if (await this.settleClosedIn(ask.id, op, via, true)) {
716
+ await this.attemptIn(ask, ref.opId, {
717
+ outcome: "applied_undecided",
718
+ message: APPLIED_UNDECIDED_ATTEMPT,
719
+ });
720
+ }
721
+ return;
722
+ }
723
+ await this.policies.settleIn(op, result);
724
+ afterCommit(() => this.announceDecided(decided.record, decided.toSeq));
725
+ return;
726
+ }
727
+ const out = await this.policies.settleIn(op, result);
728
+ if (out === null || !open || ask.detail === null)
729
+ return;
730
+ await this.attemptIn(ask, ref.opId, result.outcome === "unknown"
731
+ ? { outcome: "unknown", message: UNCONFIRMED_ATTEMPT }
732
+ : { outcome: "failed", message: out.applied ? NOT_APPLIED_ATTEMPT : out.message });
733
+ }
734
+ /**
735
+ * A pending network ask's reason (`detail.attempt`) and its `approval_updated` event (one per
736
+ * op and outcome), in the caller's transaction with the ask locked; notified after the commit.
737
+ */
738
+ async attemptIn(ask, opId, attempt) {
739
+ if (ask.detail === null)
740
+ return;
741
+ await this.db.db
742
+ .updateTable("approvals")
743
+ .set({ detail: JSON.stringify({ ...ask.detail, attempt }) })
744
+ .where("id", "=", ask.id)
745
+ .execute();
746
+ const written = await this.eventLog.appendIn(ask.conversationId, [
747
+ {
748
+ type: "approval_updated",
749
+ runId: ask.runId,
750
+ idemKey: `approval_updated:${ask.id}:${opId}:${attempt.outcome}`,
751
+ payload: { approvalId: ask.id, callId: ask.callId },
752
+ },
753
+ ]);
754
+ const toSeq = Math.max(...written.map((e) => e.seq));
755
+ afterCommit(() => this.eventLog.notify(ask.conversationId, toSeq));
523
756
  }
524
757
  /** The owner of a live device of the org (null: none, deleted, or of another org). */
525
758
  async deviceOwnerOf(orgId, deviceId) {
@@ -532,28 +765,6 @@ let ApprovalsService = class ApprovalsService {
532
765
  .executeTakeFirst();
533
766
  return d?.owner_account_id ?? null;
534
767
  }
535
- /**
536
- * Adds the hosts to the device's local policy (the `policy` op; never inside a transaction).
537
- * Null when it was applied, else why not: the decision falls back to a run grant.
538
- */
539
- async applyAlways(orgId, deviceId, hosts, source) {
540
- const accountName = (await this.displayNameOf(source.accountId)) ?? source.accountId;
541
- try {
542
- const out = await this.policies.apply({
543
- orgId,
544
- deviceId,
545
- change: { add: hosts },
546
- source: { ...source, accountName },
547
- });
548
- return out.applied ? null : out.reason;
549
- }
550
- catch (e) {
551
- // the device was deleted meanwhile: nothing to write to, the run cannot go on there either
552
- if (e instanceof ApiError && e.code === "device.not_found")
553
- return "offline";
554
- throw e;
555
- }
556
- }
557
768
  /** After the commit: the decision's events reach the page, the run's waits and IM delivery. */
558
769
  async announceDecided(record, toSeq) {
559
770
  await this.eventLog.notify(record.conversationId, toSeq);
@@ -577,7 +788,14 @@ let ApprovalsService = class ApprovalsService {
577
788
  }
578
789
  // its own transaction (as the legacy one was): `approval.decided` is emitted only after the
579
790
  // decision committed, even when a caller has a transaction open
580
- async decideIn(orgId, id, accountId, body, actorTokenId, network) {
791
+ decideIn(orgId, id, accountId, body, actorTokenId, network) {
792
+ return this.decideTx(orgId, id, accountId, body, actorTokenId, network);
793
+ }
794
+ /**
795
+ * A decision in the caller's transaction: lock order conversation → approval → run (the resume).
796
+ * Refuses (`approval.already_decided`, `approval.not_found`, …) before any write.
797
+ */
798
+ async decideTx(orgId, id, accountId, body, actorTokenId, network) {
581
799
  const t = this.db.db;
582
800
  // lock order: the conversation, the approval, then the run (as a stop of the waiting run)
583
801
  if (!(await this.lockConversationOf(id, orgId)))
@@ -612,7 +830,7 @@ let ApprovalsService = class ApprovalsService {
612
830
  if (locked.expired) {
613
831
  throw new ApiError("approval.already_decided", "Approval has expired");
614
832
  }
615
- // a network ask is decided only through decideNetwork (its policy op went first)
833
+ // a network ask is decided only with what took effect (decideNetwork, the op's resolver)
616
834
  if ((before.kind === "network") !== (network !== undefined)) {
617
835
  throw new Error(`approval ${id} is of kind ${before.kind}`);
618
836
  }
@@ -1309,10 +1527,25 @@ __decorate([
1309
1527
  ], ApprovalsService.prototype, "decideNetwork", null);
1310
1528
  __decorate([
1311
1529
  Transactional()
1312
- ], ApprovalsService.prototype, "auditLateAllow", null);
1530
+ ], ApprovalsService.prototype, "settleOpOnly", null);
1531
+ __decorate([
1532
+ Transactional()
1533
+ ], ApprovalsService.prototype, "settleNetworkOp", null);
1534
+ __decorate([
1535
+ Transactional({ propagation: "mandatory" })
1536
+ ], ApprovalsService.prototype, "settleClosedIn", null);
1537
+ __decorate([
1538
+ Transactional({ propagation: "mandatory" })
1539
+ ], ApprovalsService.prototype, "settleFromResolver", null);
1540
+ __decorate([
1541
+ Transactional({ propagation: "mandatory" })
1542
+ ], ApprovalsService.prototype, "attemptIn", null);
1313
1543
  __decorate([
1314
1544
  Transactional({ propagation: "requires_new" })
1315
1545
  ], ApprovalsService.prototype, "decideIn", null);
1546
+ __decorate([
1547
+ Transactional({ propagation: "mandatory" })
1548
+ ], ApprovalsService.prototype, "decideTx", null);
1316
1549
  __decorate([
1317
1550
  Transactional({ propagation: "mandatory" })
1318
1551
  ], ApprovalsService.prototype, "keepTrialGrant", null);
@@ -77,14 +77,18 @@ const notFound = (id) => new ToolError(`No background task ${clip(id, 40)} in th
77
77
  */
78
78
  export const subagentNotTaskText = (id) => `${clip(id, 40)} is a sub-agent, not a background task. Its report arrives in this conversation as a notification when it hands back or finishes. Don't poll or sleep for it: keep working or end your turn. Use SendMessage to give it more instructions.`;
79
79
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
80
- /** {@link notFound}, or {@link subagentNotTaskText} when `id` is a sub-agent of the caller's tree. */
81
- async function missingTask(env, ctx, id) {
80
+ /** Whether `id` is the agentId of a sub-agent in the caller's tree (not a task id). */
81
+ async function isSubagentId(env, ctx, id) {
82
82
  const trimmed = id.trim();
83
- if (env.subagents !== undefined &&
83
+ return (!TASK_ID_RE.test(id) &&
84
+ env.subagents !== undefined &&
84
85
  UUID_RE.test(trimmed) &&
85
- (await env.subagents.inTree(sandboxConversationOf(ctx), trimmed.toLowerCase()))) {
86
- return new ToolError(subagentNotTaskText(trimmed));
87
- }
86
+ (await env.subagents.inTree(sandboxConversationOf(ctx), trimmed.toLowerCase())));
87
+ }
88
+ /** {@link notFound}, or {@link subagentNotTaskText} when `id` is a sub-agent of the caller's tree. */
89
+ async function missingTask(env, ctx, id) {
90
+ if (await isSubagentId(env, ctx, id))
91
+ return new ToolError(subagentNotTaskText(id.trim()));
88
92
  return notFound(id);
89
93
  }
90
94
  /**
@@ -286,6 +290,17 @@ export function taskOutputTool(deps) {
286
290
  async run(input, ctx) {
287
291
  const { task_id, block = true, timeout } = input;
288
292
  const env = await taskEnv(deps, ctx);
293
+ // A sub-agent's agentId is answered, not refused: where its report arrives is the answer
294
+ // (acceptance 2026-11-13: guidance the model is expected to follow is not a failed call).
295
+ // TaskStop keeps the error: it cannot stop a sub-agent.
296
+ if (await isSubagentId(env, ctx, task_id)) {
297
+ const id = clip(task_id.trim(), 40);
298
+ return {
299
+ content: [{ type: "text", text: subagentNotTaskText(task_id.trim()) }],
300
+ isError: false,
301
+ display: display(`查看子智能体:${id}`, `Checked sub-agent: ${id}`),
302
+ };
303
+ }
289
304
  let task = await ownedTask(env, ctx, task_id);
290
305
  await recheckDevice(env, ctx);
291
306
  if (block && task.status === "running") {
@@ -287,10 +287,6 @@ let ConnectorsService = class ConnectorsService {
287
287
  checkCreateInput(actorAccountId, input) {
288
288
  const slug = this.resolveSlug(input);
289
289
  const endpoint = this.parseEndpoint(input.connector, input.endpoint);
290
- // a defaulted slug skips plugin names itself (`freeName`)
291
- if (!input.slugDefault && this.extensions.manifests().some((m) => m.name === slug)) {
292
- throw fail("slug", `Slug "${slug}" is the name of an installed plugin; choose another`);
293
- }
294
290
  const auth = input.auth;
295
291
  const credential = input.credential ?? undefined;
296
292
  if (!isOAuth(auth)) {
@@ -0,0 +1,40 @@
1
+ import { KernelDb } from "../infra/database/kernel-db.js";
2
+ import { TransactionHost } from "../infra/database/transaction-host.js";
3
+ import { DevicePolicyService } from "../local-devices/device-policy.service.js";
4
+ import type { DeviceOpResolveData } from "../local-devices/policy-op-jobs.js";
5
+ import { ApprovalsService } from "./approvals.service.js";
6
+ /**
7
+ * The `device-op-resolve` job (transactional outbox spec §7): records what became of a device
8
+ * policy op whose outcome its caller did not record (a crash between the call and the resolve
9
+ * transaction, an answer that never came). Owed by the op's intent for when the op can no longer
10
+ * reach the machine, and by a hello that reported the op (`device-op-reported-<opId>`).
11
+ *
12
+ * An op without an entry any more is resolved already: a no-op. Otherwise the outcome is the one
13
+ * the hello reported, else the machine's answer to a read-only `policyStatus`: `applied` →
14
+ * recorded applied; `refused`, never received (`unknown` to the machine, or `absent` from its
15
+ * hello, once the op can no longer reach it) → failed; `started` (the machine stopped between
16
+ * its log and its answer), once the op can no longer reach it → applied when the policy it
17
+ * reports holds the change, else failed (`interrupted`); no answer (`offline`) → `unknown`, left
18
+ * for the machine's next hello. It never sends the change
19
+ * again (a widening after its ask closed is not allowed). An op that answers a network ask
20
+ * decides it when applied and the ask is still pending, else leaves it pending with the reason
21
+ * (`ApprovalsService.settleFromResolver`).
22
+ */
23
+ export declare class DeviceOpResolver {
24
+ private readonly db;
25
+ readonly txHost: TransactionHost;
26
+ private readonly policies;
27
+ private readonly approvals;
28
+ constructor(db: KernelDb, txHost: TransactionHost, policies: Pick<DevicePolicyService, "outcomeOf" | "settleIn">, approvals: Pick<ApprovalsService, "settleFromResolver">);
29
+ resolve(data: DeviceOpResolveData): Promise<void>;
30
+ /**
31
+ * The outcome a hello reported; a refusal's message (a hello carries none) is the machine's
32
+ * answer to `policyStatus` when it is connected.
33
+ */
34
+ private reportedResult;
35
+ /**
36
+ * The resolve transaction: the op's outcome, and the network ask it answers decided or given
37
+ * the reason in the same transaction (`ApprovalsService.settleFromResolver`).
38
+ */
39
+ private settle;
40
+ }