@nocobase/app-plugin-agents 1.0.0-beta.16 → 1.0.0-beta.18

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 (207) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +32 -21
  3. package/dist/client/chat/api.d.ts +1 -1
  4. package/dist/client/chat/api.d.ts.map +1 -1
  5. package/dist/client/chat/api.js +2 -2
  6. package/dist/client/chat/api.js.map +1 -1
  7. package/dist/client/chat/keys.d.ts +1 -0
  8. package/dist/client/chat/keys.d.ts.map +1 -1
  9. package/dist/client/chat/keys.js +1 -0
  10. package/dist/client/chat/keys.js.map +1 -1
  11. package/dist/client/chat/provider.d.ts +10 -4
  12. package/dist/client/chat/provider.d.ts.map +1 -1
  13. package/dist/client/chat/provider.js +86 -15
  14. package/dist/client/chat/provider.js.map +1 -1
  15. package/dist/client/chat/use-chat.d.ts +3 -1
  16. package/dist/client/chat/use-chat.d.ts.map +1 -1
  17. package/dist/client/chat/use-chat.js +2 -2
  18. package/dist/client/chat/use-chat.js.map +1 -1
  19. package/dist/client/chat/use-conversation-model.d.ts +12 -0
  20. package/dist/client/chat/use-conversation-model.d.ts.map +1 -0
  21. package/dist/client/chat/use-conversation-model.js +64 -0
  22. package/dist/client/chat/use-conversation-model.js.map +1 -0
  23. package/dist/client/chat.d.ts +1 -0
  24. package/dist/client/chat.d.ts.map +1 -1
  25. package/dist/client/chat.js +1 -0
  26. package/dist/client/chat.js.map +1 -1
  27. package/dist/client/model-suggestion.d.ts.map +1 -1
  28. package/dist/client/model-suggestion.js +4 -2
  29. package/dist/client/model-suggestion.js.map +1 -1
  30. package/dist/client/pages/usage/index.d.ts.map +1 -1
  31. package/dist/client/pages/usage/index.js +17 -5
  32. package/dist/client/pages/usage/index.js.map +1 -1
  33. package/dist/client/pages/usage/prices/model.d.ts +4 -4
  34. package/dist/client/pages/usage/prices/model.d.ts.map +1 -1
  35. package/dist/client/pages/usage/prices/model.js +2 -2
  36. package/dist/client/pages/usage/prices/model.js.map +1 -1
  37. package/dist/client/pages/usage/prices/price-cells.js +2 -2
  38. package/dist/client/pages/usage/prices/price-cells.js.map +1 -1
  39. package/dist/client/pages/usage/prices/prices-section.d.ts.map +1 -1
  40. package/dist/client/pages/usage/prices/prices-section.js +13 -13
  41. package/dist/client/pages/usage/prices/prices-section.js.map +1 -1
  42. package/dist/package.json +5 -2
  43. package/dist/server/cli/online-credential.d.ts +16 -0
  44. package/dist/server/cli/online-credential.d.ts.map +1 -0
  45. package/dist/server/cli/online-credential.js +51 -0
  46. package/dist/server/cli/online-credential.js.map +1 -0
  47. package/dist/server/cli/run-credential.d.ts +2 -0
  48. package/dist/server/cli/run-credential.d.ts.map +1 -1
  49. package/dist/server/cli/run-credential.js +12 -1
  50. package/dist/server/cli/run-credential.js.map +1 -1
  51. package/dist/server/cli/surface.js +2 -2
  52. package/dist/server/cli/surface.js.map +1 -1
  53. package/dist/server/composition.d.ts +2 -0
  54. package/dist/server/composition.d.ts.map +1 -1
  55. package/dist/server/composition.js +25 -4
  56. package/dist/server/composition.js.map +1 -1
  57. package/dist/server/core/agents/agent.service.d.ts +5 -0
  58. package/dist/server/core/agents/agent.service.d.ts.map +1 -1
  59. package/dist/server/core/agents/agent.service.js +6 -1
  60. package/dist/server/core/agents/agent.service.js.map +1 -1
  61. package/dist/server/core/agents/agent.views.d.ts +2 -0
  62. package/dist/server/core/agents/agent.views.d.ts.map +1 -1
  63. package/dist/server/core/agents/agent.views.js +4 -3
  64. package/dist/server/core/agents/agent.views.js.map +1 -1
  65. package/dist/server/core/brief/brief.js +1 -1
  66. package/dist/server/core/brief/brief.js.map +1 -1
  67. package/dist/server/core/conversations/chat-settings.d.ts +1 -0
  68. package/dist/server/core/conversations/chat-settings.d.ts.map +1 -1
  69. package/dist/server/core/conversations/chat-settings.js +71 -57
  70. package/dist/server/core/conversations/chat-settings.js.map +1 -1
  71. package/dist/server/core/conversations/conversation.service.d.ts.map +1 -1
  72. package/dist/server/core/conversations/conversation.service.js +3 -4
  73. package/dist/server/core/conversations/conversation.service.js.map +1 -1
  74. package/dist/server/core/reports/report.service.d.ts +1 -0
  75. package/dist/server/core/reports/report.service.d.ts.map +1 -1
  76. package/dist/server/core/reports/report.service.js +5 -2
  77. package/dist/server/core/reports/report.service.js.map +1 -1
  78. package/dist/server/core/runs/claim.d.ts +3 -1
  79. package/dist/server/core/runs/claim.d.ts.map +1 -1
  80. package/dist/server/core/runs/claim.js +370 -60
  81. package/dist/server/core/runs/claim.js.map +1 -1
  82. package/dist/server/core/runs/extensions.d.ts +22 -2
  83. package/dist/server/core/runs/extensions.d.ts.map +1 -1
  84. package/dist/server/core/runs/extensions.js +21 -2
  85. package/dist/server/core/runs/extensions.js.map +1 -1
  86. package/dist/server/core/runs/git-credentials.d.ts +5 -1
  87. package/dist/server/core/runs/git-credentials.d.ts.map +1 -1
  88. package/dist/server/core/runs/git-credentials.js +15 -2
  89. package/dist/server/core/runs/git-credentials.js.map +1 -1
  90. package/dist/server/core/runs/lifecycle.d.ts.map +1 -1
  91. package/dist/server/core/runs/lifecycle.js +9 -5
  92. package/dist/server/core/runs/lifecycle.js.map +1 -1
  93. package/dist/server/core/runs/runner-affinity.d.ts +6 -0
  94. package/dist/server/core/runs/runner-affinity.d.ts.map +1 -0
  95. package/dist/server/core/runs/runner-affinity.js +16 -0
  96. package/dist/server/core/runs/runner-affinity.js.map +1 -0
  97. package/dist/server/core/skills/skill.service.d.ts +2 -0
  98. package/dist/server/core/skills/skill.service.d.ts.map +1 -1
  99. package/dist/server/core/skills/skill.service.js +1 -0
  100. package/dist/server/core/skills/skill.service.js.map +1 -1
  101. package/dist/server/jobs/job.service.d.ts.map +1 -1
  102. package/dist/server/jobs/job.service.js +7 -4
  103. package/dist/server/jobs/job.service.js.map +1 -1
  104. package/dist/server/kernel/values.d.ts +5 -0
  105. package/dist/server/kernel/values.d.ts.map +1 -1
  106. package/dist/server/kernel/values.js +19 -0
  107. package/dist/server/kernel/values.js.map +1 -1
  108. package/dist/server/online/agent/configuration.d.ts +2 -2
  109. package/dist/server/online/agent/configuration.d.ts.map +1 -1
  110. package/dist/server/online/agent/configuration.js +21 -7
  111. package/dist/server/online/agent/configuration.js.map +1 -1
  112. package/dist/server/online/agent/index.d.ts +2 -1
  113. package/dist/server/online/agent/index.d.ts.map +1 -1
  114. package/dist/server/online/agent/index.js.map +1 -1
  115. package/dist/server/online/agent/native/backend.d.ts +2 -0
  116. package/dist/server/online/agent/native/backend.d.ts.map +1 -1
  117. package/dist/server/online/agent/native/backend.js +14 -12
  118. package/dist/server/online/agent/native/backend.js.map +1 -1
  119. package/dist/server/online/agent/native/execution.d.ts +2 -0
  120. package/dist/server/online/agent/native/execution.d.ts.map +1 -1
  121. package/dist/server/online/agent/native/execution.js +51 -22
  122. package/dist/server/online/agent/native/execution.js.map +1 -1
  123. package/dist/server/online/agent/native/types.d.ts +6 -0
  124. package/dist/server/online/agent/native/types.d.ts.map +1 -1
  125. package/dist/server/online/agent/runner/backend.d.ts.map +1 -1
  126. package/dist/server/online/agent/runner/backend.js +4 -3
  127. package/dist/server/online/agent/runner/backend.js.map +1 -1
  128. package/dist/server/online/agent/service.d.ts +2 -0
  129. package/dist/server/online/agent/service.d.ts.map +1 -1
  130. package/dist/server/online/agent/service.js +3 -1
  131. package/dist/server/online/agent/service.js.map +1 -1
  132. package/dist/server/online/agent/types.d.ts +15 -1
  133. package/dist/server/online/agent/types.d.ts.map +1 -1
  134. package/dist/server/online/sandbox/cli-command.d.ts +6 -0
  135. package/dist/server/online/sandbox/cli-command.d.ts.map +1 -0
  136. package/dist/server/online/sandbox/cli-command.js +370 -0
  137. package/dist/server/online/sandbox/cli-command.js.map +1 -0
  138. package/dist/server/online/sandbox/execution.d.ts +25 -0
  139. package/dist/server/online/sandbox/execution.d.ts.map +1 -0
  140. package/dist/server/online/sandbox/execution.js +63 -0
  141. package/dist/server/online/sandbox/execution.js.map +1 -0
  142. package/dist/server/online/sandbox/filesystem.d.ts +8 -0
  143. package/dist/server/online/sandbox/filesystem.d.ts.map +1 -0
  144. package/dist/server/online/sandbox/filesystem.js +72 -0
  145. package/dist/server/online/sandbox/filesystem.js.map +1 -0
  146. package/dist/server/online/sandbox/limits.d.ts +4 -0
  147. package/dist/server/online/sandbox/limits.d.ts.map +1 -0
  148. package/dist/server/online/sandbox/limits.js +46 -0
  149. package/dist/server/online/sandbox/limits.js.map +1 -0
  150. package/dist/server/online/sandbox/provider.d.ts +9 -0
  151. package/dist/server/online/sandbox/provider.d.ts.map +1 -0
  152. package/dist/server/online/sandbox/provider.js +237 -0
  153. package/dist/server/online/sandbox/provider.js.map +1 -0
  154. package/dist/server/online/sandbox/skills.d.ts +14 -0
  155. package/dist/server/online/sandbox/skills.d.ts.map +1 -0
  156. package/dist/server/online/sandbox/skills.js +56 -0
  157. package/dist/server/online/sandbox/skills.js.map +1 -0
  158. package/dist/server/online/sandbox/tools.d.ts +3 -0
  159. package/dist/server/online/sandbox/tools.d.ts.map +1 -0
  160. package/dist/server/online/sandbox/tools.js +35 -0
  161. package/dist/server/online/sandbox/tools.js.map +1 -0
  162. package/dist/server/online/sandbox/types.d.ts +72 -0
  163. package/dist/server/online/sandbox/types.d.ts.map +1 -0
  164. package/dist/server/online/sandbox/types.js +2 -0
  165. package/dist/server/online/sandbox/types.js.map +1 -0
  166. package/dist/server/providers/agents.d.ts +5 -2
  167. package/dist/server/providers/agents.d.ts.map +1 -1
  168. package/dist/server/providers/agents.js +63 -2
  169. package/dist/server/providers/agents.js.map +1 -1
  170. package/dist/server/routes/admin.d.ts.map +1 -1
  171. package/dist/server/routes/admin.js +1 -0
  172. package/dist/server/routes/admin.js.map +1 -1
  173. package/dist/server/routes/chat.d.ts +1 -1
  174. package/dist/server/routes/chat.d.ts.map +1 -1
  175. package/dist/server/routes/chat.js +15 -6
  176. package/dist/server/routes/chat.js.map +1 -1
  177. package/dist/server/routes/index.d.ts.map +1 -1
  178. package/dist/server/routes/index.js +17 -2
  179. package/dist/server/routes/index.js.map +1 -1
  180. package/dist/server/routes/openapi.js +1 -1
  181. package/dist/server/routes/openapi.js.map +1 -1
  182. package/dist/server/routes/schemas.js +1 -1
  183. package/dist/server/routes/schemas.js.map +1 -1
  184. package/dist/server/runners/runner.service.d.ts +1 -1
  185. package/dist/server/runners/runner.service.d.ts.map +1 -1
  186. package/dist/server/runners/runner.service.js +5 -1
  187. package/dist/server/runners/runner.service.js.map +1 -1
  188. package/dist/server/runners/runner.store.d.ts.map +1 -1
  189. package/dist/server/runners/runner.store.js +3 -1
  190. package/dist/server/runners/runner.store.js.map +1 -1
  191. package/dist/server/vendor/THIRD_PARTY_LICENSES.txt +782 -0
  192. package/dist/server/vendor/just-bash.d.ts +137 -0
  193. package/dist/server/vendor/just-bash.js +2102 -0
  194. package/dist/shared/conversations.d.ts +2 -1
  195. package/dist/shared/conversations.d.ts.map +1 -1
  196. package/dist/shared/conversations.js.map +1 -1
  197. package/dist/shared/runners.d.ts +1 -1
  198. package/dist/shared/runners.d.ts.map +1 -1
  199. package/dist/shared/runners.js +2 -0
  200. package/dist/shared/runners.js.map +1 -1
  201. package/docs/native-sandbox.md +55 -0
  202. package/docs/runner-agents.md +2 -0
  203. package/docs/ui-message-clients.md +10 -0
  204. package/package.json +11 -8
  205. package/skills/nocobase-app-plugin-agents/SKILL.md +1 -0
  206. package/skills/nocobase-app-plugin-agents/references/extension-points.md +21 -19
  207. package/skills/nocobase-app-plugin-agents/references/online-agent.md +2 -2
@@ -1,3 +1,5 @@
1
+ import { upgradeReservation } from './runner-affinity.js';
2
+ import { ACTIVE_JOB, jobsRepo } from '../../jobs/job.store.js';
1
3
  /**
2
4
  * Claiming: handing queued runs to a runner that fits them, within each agent's concurrency limit, one run per
3
5
  * (agent, subject, thread) at a time, through the runners' one long-poll (`routes/runners/runner.ts`), which hands
@@ -6,7 +8,7 @@
6
8
  * team or belongs to the person who woke the agent, its features cover what the run requires (worked out from the
7
9
  * payload: checkouts, directories, skills, secrets), its owner's local policy (`Runner.policy`) lets it take the
8
10
  * agent's runs on the run's subject and repositories, and it has a free slot. A working directory that is a directory
9
- * on one runner pins the run to that runner, one run at a time per directory. A run's variables go to the runner that
11
+ * on one runner pins the run to that runner; multiple runs may share that directory. A run's variables go to the runner that
10
12
  * takes it, personal ones included, unless one of them is for team runners only (`teamRunnersOnly`): then only a team
11
13
  * runner takes the run, and the variables that asked for one are noted on it (`teamOnlyVariables`), so its wait says
12
14
  * which.
@@ -55,7 +57,7 @@ import { joinBrief, renderBrief } from '../brief/index.js';
55
57
  import { cliPackageFor } from './cli-package.js';
56
58
  import { executionHistory } from './execution.js';
57
59
  import { describeVariables, variableRefs, } from '../variables/index.js';
58
- import { mountsFor, prepareExtensions, repoAccessFor, sessionKeyOf, withSections, } from './extensions.js';
60
+ import { mountsFor, prepareExtensions, repoAccessFor, responsibleOf, sessionKeyOf, withSections, } from './extensions.js';
59
61
  import { MAX_CLAIM_FAILURES, toolPolicyFor } from './policy.js';
60
62
  import { mintRunToken } from './run-tokens.js';
61
63
  import { ACTIVE, countActive, eventsRepo, findRunRecord, markDelivered, pendingInputs, runsOfKey, runsRepo, inputsRepo, sessionsRepo, toInput, toExecutions, toRun, } from './run.store.js';
@@ -75,7 +77,7 @@ class AssemblyError extends Error {
75
77
  this.name = 'AssemblyError';
76
78
  }
77
79
  }
78
- /** The run does not fit this runner after all (its payload needs another runner, or a busy directory). */
80
+ /** The run does not fit this runner after all (its payload needs another runner or unavailable capabilities). */
79
81
  class NotForThisRunner extends Error {
80
82
  constructor() {
81
83
  super('The run does not fit this runner.');
@@ -216,6 +218,16 @@ export function briefOf(agent, assembly, skills, cli, appName) {
216
218
  export function asksCredentialsOnDemand(runner) {
217
219
  return runner.features.includes('gitCredentials');
218
220
  }
221
+ /** Where a claim keeps the subject's responsible it read before its transaction (`Prepared`). */
222
+ const RESPONSIBLE_KEY = 'claim:responsibleUserId';
223
+ function responsibleIn(prepared) {
224
+ const value = prepared.get(RESPONSIBLE_KEY);
225
+ return typeof value === 'string' ? value : null;
226
+ }
227
+ /** Whether `runner` wraps code host CLIs with the run's credential (`RunGit.tools`). */
228
+ export function wrapsHostTools(runner) {
229
+ return runner.features.includes('gitTools');
230
+ }
219
231
  const DIRECTORY_KEY_SEPARATOR = '\n';
220
232
  /** The directories a run holds on its runner, as stored in `agRuns.directoryKey`. */
221
233
  function directoryKeyOf(runnerId, dirs) {
@@ -224,20 +236,6 @@ function directoryKeyOf(runnerId, dirs) {
224
236
  .map((dir) => `${runnerId}:${dir.path}`);
225
237
  return paths.length > 0 ? paths.join(DIRECTORY_KEY_SEPARATOR) : null;
226
238
  }
227
- /** Whether another run held by the runner works in any of `key`'s directories. */
228
- async function directoryBusy(conn, runnerId, runId, key) {
229
- const wanted = new Set(key.split(DIRECTORY_KEY_SEPARATOR));
230
- const held = await runsRepo(conn).findMany({
231
- filter: (f) => f.and([
232
- f.string('runnerId').eq(runnerId),
233
- f.string('id').ne(runId),
234
- f.or(ACTIVE.map((status) => f.string('status').eq(status))),
235
- ]),
236
- });
237
- return held.some((run) => (run.directoryKey ?? '')
238
- .split(DIRECTORY_KEY_SEPARATOR)
239
- .some((part) => part !== '' && wanted.has(part)));
240
- }
241
239
  /** Resolve omitted subject input in the claim transaction, keeping it in the audit history. */
242
240
  async function settleClaimInputs(conn, pending, effective, now) {
243
241
  const ids = new Set(effective.map((input) => input.id));
@@ -270,10 +268,16 @@ export function createClaimService(deps) {
270
268
  (assembly.workspaceKey && !supportsWorkspaceKey(runner)))
271
269
  return 'incompatible';
272
270
  const { skills, variables } = await resources(agent, assembly);
271
+ return compatibleAssembly(run, agent, runner, assembly, skills.length, variables);
272
+ }
273
+ function compatibleAssembly(run, agent, runner, assembly, skillCount, variables) {
274
+ if (!isProtocolSupported(runner.protocolVersion) ||
275
+ (assembly.workspaceKey && !supportsWorkspaceKey(runner)))
276
+ return 'incompatible';
273
277
  const names = variables.names;
274
278
  if (!covers(runner.features, [
275
279
  ...run.requires,
276
- ...payloadRequires(payloadDirs(assembly.dirs), skills.length, names.length, Boolean(assembly.workspaceKey)),
280
+ ...payloadRequires(payloadDirs(assembly.dirs), skillCount, names.length, Boolean(assembly.workspaceKey)),
277
281
  ]))
278
282
  return 'incompatible';
279
283
  if (!fitsActor(runner, agent, run.actorUserId, run.requires, clock.now().getTime()) ||
@@ -307,7 +311,8 @@ export function createClaimService(deps) {
307
311
  const entries = [];
308
312
  for (const entry of agent.modelEntries) {
309
313
  const single = { ...agent, modelEntries: [entry] };
310
- const candidates = registered.filter((runner) => runner.status !== 'revoked');
314
+ const reserved = upgradeReservation(run.executions ?? [], registered, now.getTime());
315
+ const candidates = registered.filter((runner) => runner.status !== 'revoked' && (!reserved || runner.id === reserved));
311
316
  const connected = candidates.filter((runner) => (runner.status === 'online' ||
312
317
  runner.status === 'upgrade_required') &&
313
318
  runner.lastSeenAt !== null &&
@@ -321,7 +326,6 @@ export function createClaimService(deps) {
321
326
  let unknown = false;
322
327
  let incompatible = false;
323
328
  let ready = false;
324
- let directoryBlocked = false;
325
329
  for (const runner of online) {
326
330
  if (!isProtocolSupported(runner.protocolVersion) ||
327
331
  runner.status === 'upgrade_required') {
@@ -377,16 +381,11 @@ export function createClaimService(deps) {
377
381
  const held = await deps.slotsHeld(conn, runner.id);
378
382
  const byTool = held.byTool ?? {};
379
383
  const used = held.jobs + held.runs;
380
- const directory = directoryKeyOf(runner.id, payloadDirs(assembly.dirs));
381
- const blockedDirectory = directory !== null &&
382
- (await directoryBusy(conn, runner.id, run.id, directory));
383
- directoryBlocked ||= blockedDirectory;
384
384
  const hasRoom = !runner.draining &&
385
385
  (runner.globalFree ?? 1) > 0 &&
386
386
  used < runner.slots &&
387
387
  (byTool[entry.tool] ?? 0) < toolLimit(runner, entry.tool) &&
388
- (runner.toolLoad?.[entry.tool]?.free ?? 1) > 0 &&
389
- !blockedDirectory;
388
+ (runner.toolLoad?.[entry.tool]?.free ?? 1) > 0;
390
389
  ready ||= hasRoom && runner.globalFree !== undefined;
391
390
  if (hasRoom && runner.globalFree === undefined)
392
391
  unknown = true;
@@ -398,10 +397,10 @@ export function createClaimService(deps) {
398
397
  ? 'unknown'
399
398
  : 'ready'
400
399
  : runner.draining
401
- ? runner.draining
402
- : blockedDirectory
403
- ? 'restricted'
404
- : 'busy',
400
+ ? runner.draining === 'diskLow'
401
+ ? 'busy'
402
+ : runner.draining
403
+ : 'busy',
405
404
  lastSeenAt: runner.lastSeenAt,
406
405
  used,
407
406
  limit: runner.slots,
@@ -426,8 +425,6 @@ export function createClaimService(deps) {
426
425
  : connected.length
427
426
  ? 'toolNotInstalled'
428
427
  : 'offline';
429
- if (directoryBlocked && !ready)
430
- reason = 'restricted';
431
428
  if (!ready &&
432
429
  compatible.length > 0 &&
433
430
  loads.every((runner) => runner.reason === 'updatePending' || runner.reason === 'pruning'))
@@ -465,6 +462,126 @@ export function createClaimService(deps) {
465
462
  entries,
466
463
  };
467
464
  }
465
+ /** Metadata only: batch new scopes, reuse common scopes across machines and visited candidates. */
466
+ function admissionResources(conn, agents = []) {
467
+ const variableScopes = new Set();
468
+ const skillScopes = new Set();
469
+ const variables = [];
470
+ const skills = [];
471
+ function missing(targets, seen) {
472
+ const fresh = new Map();
473
+ for (const target of targets) {
474
+ const key = JSON.stringify([target.scope, target.scopeId]);
475
+ if (!seen.has(key))
476
+ fresh.set(key, target);
477
+ }
478
+ for (const key of fresh.keys())
479
+ seen.add(key);
480
+ return [...fresh.values()];
481
+ }
482
+ return {
483
+ async load(checks) {
484
+ const vars = missing([
485
+ ...checks.flatMap(({ agent, assembly }) => variableTargets(agent, assembly)),
486
+ ...agents.map((agent) => ({ scope: 'agent', scopeId: agent.id })),
487
+ ], variableScopes);
488
+ const attached = missing(checks.flatMap(({ agent, assembly }) => skillTargets(agent, assembly)), skillScopes);
489
+ if (vars.length)
490
+ variables.push(...(await deps.variables.admissionMetadata(conn, vars)));
491
+ if (attached.length)
492
+ skills.push(...(await deps.skills.admissionMetadata(conn, attached)));
493
+ },
494
+ get(agent, assembly) {
495
+ const merged = new Map();
496
+ for (const target of variableTargets(agent, assembly))
497
+ for (const row of variables)
498
+ if (row.scope === target.scope && row.scopeId === target.scopeId)
499
+ merged.set(row.name, row);
500
+ const targets = skillTargets(agent, assembly);
501
+ return {
502
+ skills: skills.filter((row) => targets.some((target) => target.scope === row.scope && target.scopeId === row.scopeId)),
503
+ variables: {
504
+ names: [...merged.values()]
505
+ .filter((row) => row.kind === 'value')
506
+ .map((row) => row.name),
507
+ teamOnly: [...merged.values()].filter((row) => row.kind === 'value' && row.teamRunnersOnly),
508
+ passthrough: [...merged.values()]
509
+ .filter((row) => row.kind === 'runner')
510
+ .map((row) => row.name),
511
+ },
512
+ };
513
+ },
514
+ };
515
+ }
516
+ /** Eligibility needs existence/compatibility, not occupancy, other models or a full wait projection. */
517
+ async function preferredModelAvailable(conn, run, agent, inputs, metadata, inventory) {
518
+ const now = clock.now().getTime();
519
+ const registered = await inventory();
520
+ const reserved = upgradeReservation(run.executions ?? [], registered, now);
521
+ const entry = agent.modelEntries[0];
522
+ const single = { ...agent, modelEntries: [entry] };
523
+ const binding = deps.subjects.get(run.subject.kind);
524
+ if (!binding)
525
+ throw new Error(`No context provider for ${run.subject.kind}.`);
526
+ const checks = [];
527
+ for (const runner of registered) {
528
+ if ((reserved && runner.id !== reserved) ||
529
+ runner.status !== 'online' ||
530
+ !runner.lastSeenAt ||
531
+ Date.parse(runner.lastSeenAt) + TIMINGS.offlineAfterMs <= now ||
532
+ !isProtocolSupported(runner.protocolVersion) ||
533
+ !runsEntry(runner, entry, now) ||
534
+ (runner.trust !== 'team' && runner.ownerUserId !== run.actorUserId))
535
+ continue;
536
+ const assembly = await binding.context
537
+ .assemble(conn, {
538
+ run,
539
+ agent,
540
+ runner,
541
+ inputs,
542
+ cli: deps.cli.name,
543
+ appName: deps.app.name,
544
+ })
545
+ .catch(() => null);
546
+ // The diagnostic path treats an unreadable context as unknown and waits for the preferred model.
547
+ if (!assembly)
548
+ return true;
549
+ checks.push({ agent: single, runner, assembly });
550
+ }
551
+ await metadata.load(checks);
552
+ for (const { runner, assembly } of checks) {
553
+ const { skills, variables } = metadata.get(single, assembly);
554
+ if (skills.some((skill) => skill.hash === null))
555
+ throw notFound('Skill version');
556
+ if (compatibleAssembly(run, single, runner, assembly, skills.length, variables) !== 'ready')
557
+ continue;
558
+ if (variables.passthrough.length && runner.variables == null)
559
+ return true;
560
+ if (variables.passthrough.some((name) => !runner.variables?.includes(name)))
561
+ continue;
562
+ return true;
563
+ }
564
+ return false;
565
+ }
566
+ /** The same ordered-model decision is used for dispatch and for immediate runner affinity. */
567
+ async function modelAdmission(conn, run, agent, entry, inputs, metadata = admissionResources(conn), inventory = () => deps.runners.all(conn)) {
568
+ if (agent.modelEntries.indexOf(entry) <= 0 || !deps.runners)
569
+ return undefined;
570
+ const policy = await deps.selectionPolicies?.forRun(conn, run);
571
+ if (!policy)
572
+ return undefined;
573
+ const elapsed = Math.max(0, (clock.now().getTime() - queueStart(run)) / 1000);
574
+ const preferredAvailable = await preferredModelAvailable(conn, run, agent, inputs, metadata, inventory);
575
+ if (preferredAvailable && elapsed < policy.fallbackAfterSeconds)
576
+ return false;
577
+ return {
578
+ reason: preferredAvailable ? 'preferredQueued' : 'preferredUnavailable',
579
+ preferredTool: agent.modelEntries[0].tool,
580
+ preferredModel: agent.modelEntries[0].model,
581
+ queueEnteredAt: new Date(queueStart(run)).toISOString(),
582
+ waitedSeconds: elapsed,
583
+ };
584
+ }
468
585
  async function assemble({ conn }, run, agent, runner, inputs, leaseExpiresAt, prepared, entry, resources = claimResources(conn, run.id)) {
469
586
  const binding = deps.subjects.get(run.subjectKind);
470
587
  if (!binding)
@@ -494,9 +611,6 @@ export function createClaimService(deps) {
494
611
  throw new NotForThisRunner();
495
612
  const dirs = payloadDirs(assembly.dirs);
496
613
  const directoryKey = directoryKeyOf(runner.id, dirs);
497
- if (directoryKey !== null &&
498
- (await directoryBusy(conn, runner.id, run.id, directoryKey)))
499
- throw new NotForThisRunner();
500
614
  const { skills, variables } = await resources(agent, assembly);
501
615
  const names = variables.names;
502
616
  const requires = cleanList([
@@ -536,6 +650,8 @@ export function createClaimService(deps) {
536
650
  claim,
537
651
  repos: dirs.filter((dir) => dir.kind === 'repo'),
538
652
  onDemand: asksCredentialsOnDemand(runner),
653
+ tools: wrapsHostTools(runner),
654
+ responsibleUserId: responsibleIn(prepared),
539
655
  }, prepared);
540
656
  deps.secrets?.remember(runSecretsKey(run.id), [
541
657
  ...env.map((variable) => variable.value),
@@ -893,8 +1009,27 @@ export function createClaimService(deps) {
893
1009
  const consent = await commentConsentFor(conn, runner, candidate);
894
1010
  if (!consent.allowed)
895
1011
  return { kind: 'skipped' };
1012
+ // Read once, before the transaction, and handed to every repository access hook of this claim. A failed read
1013
+ // leaves the run queued for the next poll rather than letting a provider pick an identity without it.
1014
+ let responsibleUserId = null;
1015
+ if ((deps.repoAccess?.list().length ?? 0) > 0)
1016
+ try {
1017
+ responsibleUserId = await responsibleOf(conn, deps.subjects, {
1018
+ kind: candidate.subjectKind,
1019
+ id: candidate.subjectId,
1020
+ });
1021
+ }
1022
+ catch (error) {
1023
+ deps.onClaimFailure?.(candidate.id, error);
1024
+ return { kind: 'skipped' };
1025
+ }
896
1026
  // Before the transaction: on SQLite it holds the only connection, so the providers read what else they need now.
897
- const prepared = new Map(await prepareExtensions(toRun(candidate), deps.sections, deps.mounts, (error) => deps.onClaimFailure?.(candidate.id, error), deps.repoAccess, { onDemand: asksCredentialsOnDemand(runner) }));
1027
+ const prepared = new Map(await prepareExtensions(toRun(candidate), deps.sections, deps.mounts, (error) => deps.onClaimFailure?.(candidate.id, error), deps.repoAccess, {
1028
+ onDemand: asksCredentialsOnDemand(runner),
1029
+ tools: wrapsHostTools(runner),
1030
+ responsibleUserId,
1031
+ }));
1032
+ prepared.set(RESPONSIBLE_KEY, responsibleUserId);
898
1033
  const subjectContext = deps.subjects.get(candidate.subjectKind)?.context;
899
1034
  if (subjectContext?.prepare) {
900
1035
  try {
@@ -982,27 +1117,10 @@ export function createClaimService(deps) {
982
1117
  // Admission must precede preflight assembly: a missing or obsolete
983
1118
  // subject is cancelled normally, rather than counted as setup failure.
984
1119
  const resources = claimResources(conn, run.id);
985
- const selection = await selectionFacts(conn, toRun(current), agent, true, undefined, resources);
986
- const elapsed = Math.max(0, (now.getTime() - queueStart(toRun(current))) / 1000);
987
- const preferredAvailable = (selection?.entries[0]?.compatibleCount ?? 0) > 0 ||
988
- selection?.entries[0]?.reason === 'unknown' ||
989
- selection?.entries[0]?.blockers?.includes('unknown') === true;
990
- const chosenIndex = agent.modelEntries.indexOf(entry);
991
- if (selection && chosenIndex > 0) {
992
- // Throw to roll back the provisional dispatch; the original run keeps
993
- // waiting for its first choice without consuming an attempt or token.
994
- if (preferredAvailable && elapsed < selection.fallbackAfterSeconds)
995
- throw new NotForThisRunner();
996
- selectionSnapshot = {
997
- reason: preferredAvailable
998
- ? 'preferredQueued'
999
- : 'preferredUnavailable',
1000
- preferredTool: agent.modelEntries[0].tool,
1001
- preferredModel: agent.modelEntries[0].model,
1002
- queueEnteredAt: selection.queueEnteredAt,
1003
- waitedSeconds: elapsed,
1004
- };
1005
- }
1120
+ const admission = await modelAdmission(conn, toRun(current), agent, entry, pending.map(toInput));
1121
+ if (admission === false)
1122
+ throw new NotForThisRunner();
1123
+ selectionSnapshot = admission;
1006
1124
  assembled = await assemble(unit, run, agent, runner, pending.map(toInput), leaseExpiresAt, prepared, entry, resources);
1007
1125
  }
1008
1126
  catch (error) {
@@ -1118,9 +1236,9 @@ export function createClaimService(deps) {
1118
1236
  });
1119
1237
  }
1120
1238
  /** Queued runs that may be claimed now, in claim order. */
1121
- async function candidates() {
1239
+ async function candidates(runner) {
1122
1240
  const now = clock.now();
1123
- return runsRepo(tx.read()).findMany({
1241
+ const ordinary = await runsRepo(tx.read()).findMany({
1124
1242
  filter: (f) => f.and([
1125
1243
  f.string('status').eq('queued'),
1126
1244
  f.or([
@@ -1135,15 +1253,198 @@ export function createClaimService(deps) {
1135
1253
  ],
1136
1254
  limit: CANDIDATES,
1137
1255
  });
1256
+ if (ordinary.length === 0)
1257
+ return [];
1258
+ // A separate scan prevents ordinary candidate pages from hiding upgrade resumptions.
1259
+ const recovery = [];
1260
+ for (let offset = 0;; offset += CANDIDATES) {
1261
+ const page = await runsRepo(tx.read()).findMany({
1262
+ filter: (f) => f.and([
1263
+ f.string('status').eq('queued'),
1264
+ f.string('failureReason').eq('runnerUpgrade'),
1265
+ f
1266
+ .date('queueEnteredAt')
1267
+ .after(new Date(now.getTime() - 5 * 60_000)),
1268
+ f.or([
1269
+ f.date('availableAt').empty(),
1270
+ f.date('availableAt').notAfter(now),
1271
+ ]),
1272
+ ]),
1273
+ sort: (sort) => [
1274
+ sort.field('queueEnteredAt').asc(),
1275
+ sort.field('id').asc(),
1276
+ ],
1277
+ limit: CANDIDATES,
1278
+ offset,
1279
+ });
1280
+ recovery.push(...page.filter((run) => toExecutions(run.executionHistory).at(-1)?.runnerId === runner.id));
1281
+ if (page.length < CANDIDATES || recovery.length >= CANDIDATES)
1282
+ break;
1283
+ }
1284
+ return [
1285
+ ...new Map([...recovery, ...ordinary].map((run) => [run.id, run])).values(),
1286
+ ];
1287
+ }
1288
+ /** One batch for locations, one for machines, and occupancy batches only when a preference exists. */
1289
+ async function affinityBatch(queued, runner) {
1290
+ const conn = tx.read();
1291
+ if (queued.length === 0)
1292
+ return {
1293
+ machines: [runner],
1294
+ preferred: new Map(),
1295
+ held: [],
1296
+ jobs: [],
1297
+ };
1298
+ const locations = await sessionsRepo(conn).groupBy({
1299
+ by: ['runnerId', 'subjectKind', 'subjectId'],
1300
+ aggregate: (a) => ({ updatedAt: a.max('updatedAt') }),
1301
+ filter: (f) => f.or(queued.map((run) => f.and([
1302
+ f.string('subjectKind').eq(run.subjectKind),
1303
+ f.string('subjectId').eq(run.subjectId),
1304
+ ]))),
1305
+ sort: (sort) => sort.field('updatedAt').desc(),
1306
+ });
1307
+ const activeLocations = await runsRepo(conn).groupBy({
1308
+ by: ['runnerId', 'subjectKind', 'subjectId'],
1309
+ aggregate: (a) => ({ updatedAt: a.max('updatedAt') }),
1310
+ filter: (f) => f.and([
1311
+ f.string('runnerId').notEmpty(),
1312
+ f.or(ACTIVE.map((status) => f.string('status').eq(status))),
1313
+ f.or(queued.map((run) => f.and([
1314
+ f.string('subjectKind').eq(run.subjectKind),
1315
+ f.string('subjectId').eq(run.subjectId),
1316
+ ]))),
1317
+ ]),
1318
+ });
1319
+ const machineIds = [
1320
+ ...new Set([
1321
+ runner.id,
1322
+ ...queued.map((run) => toExecutions(run.executionHistory).at(-1)?.runnerId),
1323
+ ...locations.map((item) => item.runnerId),
1324
+ ...activeLocations.map((item) => item.runnerId),
1325
+ ].filter((id) => Boolean(id))),
1326
+ ];
1327
+ const machines = deps.runners
1328
+ ? await deps.runners.all(conn, machineIds)
1329
+ : [runner];
1330
+ const preferred = new Map();
1331
+ for (const run of queued) {
1332
+ const last = toExecutions(run.executionHistory).at(-1)?.runnerId;
1333
+ const current = activeLocations
1334
+ .filter((item) => item.subjectKind === run.subjectKind &&
1335
+ item.subjectId === run.subjectId)
1336
+ .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))[0]?.runnerId;
1337
+ const location = locations.find((item) => item.subjectKind === run.subjectKind &&
1338
+ item.subjectId === run.subjectId)?.runnerId;
1339
+ const machine = machines.find((item) => item.id === (last ?? current ?? location));
1340
+ if (machine)
1341
+ preferred.set(run.id, machine);
1342
+ }
1343
+ const ids = [...new Set([...preferred.values()].map((item) => item.id))];
1344
+ const held = ids.length === 0
1345
+ ? []
1346
+ : await runsRepo(conn).groupBy({
1347
+ by: ['runnerId', 'tool'],
1348
+ aggregate: (a) => ({ count: a.count() }),
1349
+ filter: (f) => f.and([
1350
+ f.or(ids.map((id) => f.string('runnerId').eq(id))),
1351
+ f.or(ACTIVE.map((status) => f.string('status').eq(status))),
1352
+ ]),
1353
+ });
1354
+ const jobs = ids.length === 0
1355
+ ? []
1356
+ : await jobsRepo(conn).groupBy({
1357
+ by: ['runnerId'],
1358
+ aggregate: (a) => ({ count: a.count() }),
1359
+ filter: (f) => f.and([
1360
+ f.or(ids.map((id) => f.string('runnerId').eq(id))),
1361
+ f.or(ACTIVE_JOB.map((status) => f.string('status').eq(status))),
1362
+ ]),
1363
+ });
1364
+ return { machines, preferred, held, jobs };
1365
+ }
1366
+ function hasImmediateRoom(run, agent, preferred, batch) {
1367
+ const now = clock.now().getTime();
1368
+ if (preferred.status !== 'online' ||
1369
+ !preferred.lastSeenAt ||
1370
+ Date.parse(preferred.lastSeenAt) + TIMINGS.offlineAfterMs <= now ||
1371
+ preferred.draining ||
1372
+ (preferred.globalFree ?? 1) <= 0 ||
1373
+ !fits(preferred, agent, run, now))
1374
+ return false;
1375
+ const held = batch.held.filter((item) => item.runnerId === preferred.id);
1376
+ if (held.reduce((count, item) => count + Number(item.count), 0) +
1377
+ batch.jobs
1378
+ .filter((item) => item.runnerId === preferred.id)
1379
+ .reduce((count, item) => count + Number(item.count), 0) >=
1380
+ preferred.slots)
1381
+ return false;
1382
+ if (!pickOpenEntry(preferred, agent, (tool) => held
1383
+ .filter((item) => item.tool === tool)
1384
+ .reduce((count, item) => count + Number(item.count), 0) <
1385
+ toolLimit(preferred, tool) &&
1386
+ (preferred.toolLoad?.[tool]?.free ?? 1) > 0, now))
1387
+ return false;
1388
+ return true;
1389
+ }
1390
+ /** Evaluate only the candidate reached in queue order, reusing this poll's batched capacity and metadata. */
1391
+ async function readyPreference(run, agent, batch, claimant, metadata, inventory) {
1392
+ const conn = tx.read();
1393
+ const runner = batch.preferred.get(run.id);
1394
+ if (!runner ||
1395
+ runner.id === claimant.id ||
1396
+ !fits(claimant, agent, run, clock.now().getTime()) ||
1397
+ upgradeReservation(toExecutions(run.executionHistory), batch.machines, clock.now().getTime()) ||
1398
+ !hasImmediateRoom(run, agent, runner, batch))
1399
+ return false;
1400
+ const binding = deps.subjects.get(run.subjectKind);
1401
+ if (!binding || !(await commentConsentFor(conn, runner, run)).allowed)
1402
+ return false;
1403
+ const assembly = await binding.context
1404
+ .assemble(conn, {
1405
+ run: toRun(run),
1406
+ agent,
1407
+ runner,
1408
+ inputs: [],
1409
+ cli: deps.cli.name,
1410
+ appName: deps.app.name,
1411
+ })
1412
+ .catch(() => null);
1413
+ if (!assembly)
1414
+ return false;
1415
+ await metadata.load([{ agent, assembly }]);
1416
+ const { skills, variables } = metadata.get(agent, assembly);
1417
+ if (!variables.passthrough.every((name) => runner.variables?.includes(name)) ||
1418
+ skills.some((row) => row.hash === null) ||
1419
+ compatibleAssembly(toRun(run), agent, runner, assembly, skills.length, variables) !== 'ready')
1420
+ return false;
1421
+ const entry = pickOpenEntry(runner, agent, (tool) => batch.held
1422
+ .filter((item) => item.runnerId === runner.id && item.tool === tool)
1423
+ .reduce((count, item) => count + Number(item.count), 0) <
1424
+ toolLimit(runner, tool) && (runner.toolLoad?.[tool]?.free ?? 1) > 0, clock.now().getTime());
1425
+ if (!entry)
1426
+ return false;
1427
+ if (agent.modelEntries.indexOf(entry) > 0) {
1428
+ const inputs = (await pendingInputs(conn, run.id)).map(toInput);
1429
+ if ((await modelAdmission(conn, toRun(run), agent, entry, inputs, metadata, inventory)) === false)
1430
+ return false;
1431
+ }
1432
+ return true;
1138
1433
  }
1139
1434
  async function claimOne(runner, seen, reported) {
1140
1435
  if (runner.draining)
1141
1436
  return null;
1142
1437
  const conn = tx.read();
1143
- const queued = await candidates();
1438
+ const queued = await candidates(runner);
1439
+ const affinity = await affinityBatch(queued, runner);
1144
1440
  const agents = await findAgents(conn, queued
1145
1441
  .filter((candidate) => !seen.has(candidate.id))
1146
1442
  .map((candidate) => candidate.agentId));
1443
+ const metadata = admissionResources(conn, [...agents.values()]);
1444
+ let inventoryRead;
1445
+ const inventory = () => (inventoryRead ??= deps.runners
1446
+ ? deps.runners.all(conn)
1447
+ : Promise.resolve([runner]));
1147
1448
  const full = new Set();
1148
1449
  for (const candidate of queued) {
1149
1450
  if (seen.has(candidate.id) || full.has(candidate.agentId))
@@ -1154,6 +1455,15 @@ export function createClaimService(deps) {
1154
1455
  (!deps.subjects.get(candidate.subjectKind)?.actsAs &&
1155
1456
  !fits(runner, agent, candidate, clock.now().getTime())))
1156
1457
  continue;
1458
+ const reserved = upgradeReservation(toExecutions(candidate.executionHistory), affinity.machines, clock.now().getTime());
1459
+ if (reserved && reserved !== runner.id)
1460
+ continue;
1461
+ const preferred = affinity.preferred.get(candidate.id);
1462
+ if (!reserved &&
1463
+ preferred &&
1464
+ preferred.id !== runner.id &&
1465
+ (await readyPreference(candidate, agent, affinity, runner, metadata, inventory)))
1466
+ continue;
1157
1467
  // None of its tools has room on the runner's side: the runs behind it may still fit.
1158
1468
  if (!pickOpenEntry(runner, agent, (tool) => reported.has(tool), clock.now().getTime()))
1159
1469
  continue;