@rse/ase 1.0.6 → 2.0.0

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 (279) hide show
  1. package/dst/ase-agent-definition-api.d.ts +184 -0
  2. package/dst/ase-agent-definition-api.js +6 -0
  3. package/dst/ase-agent-definition-builtin/condenser.js +42 -0
  4. package/dst/ase-agent-definition-builtin/editor.js +45 -0
  5. package/dst/ase-agent-definition-builtin/estimator.js +98 -0
  6. package/dst/ase-agent-definition-builtin/implementer.js +121 -0
  7. package/dst/ase-agent-definition-builtin/integrator.js +56 -0
  8. package/dst/ase-agent-definition-builtin/qualifier.js +61 -0
  9. package/dst/ase-agent-definition-builtin/rebooter.js +41 -0
  10. package/dst/ase-agent-definition-builtin/researcher.js +77 -0
  11. package/dst/ase-agent-definition-core.js +827 -0
  12. package/dst/ase-agent-fleet.js +772 -0
  13. package/dst/ase-agent-process.js +232 -0
  14. package/dst/ase-agent-relay-client.js +509 -0
  15. package/dst/ase-agent-relay-core.js +213 -0
  16. package/dst/ase-agent-relay-server-bind.js +242 -0
  17. package/dst/ase-agent-relay-server-cli.js +50 -0
  18. package/dst/ase-agent-runtime-claim.js +189 -0
  19. package/dst/ase-agent-runtime-engine.js +1100 -0
  20. package/dst/ase-agent-runtime-exec.js +275 -0
  21. package/dst/ase-agent-runtime-phase.js +581 -0
  22. package/dst/ase-agent-runtime-request.js +272 -0
  23. package/dst/ase-agent-runtime-tool.js +207 -0
  24. package/dst/ase-agent-runtime.js +1316 -0
  25. package/dst/ase-agent.js +65 -0
  26. package/dst/ase-artifact.js +6 -5
  27. package/dst/ase-config-core.js +465 -0
  28. package/dst/ase-config-schema.js +351 -0
  29. package/dst/ase-config-scope.js +172 -0
  30. package/dst/ase-config.js +25 -585
  31. package/dst/ase-hook.js +230 -116
  32. package/dst/ase-lib-daemon-cli.js +130 -0
  33. package/dst/ase-lib-hash.js +20 -0
  34. package/dst/{ase-log.js → ase-lib-log.js} +40 -7
  35. package/dst/ase-lib-server-bind.js +115 -0
  36. package/dst/ase-lib-server-cli.js +474 -0
  37. package/dst/{ase-table.js → ase-lib-table.js} +1 -1
  38. package/dst/{ase-version.js → ase-lib-version.js} +3 -0
  39. package/dst/ase-man.js +96 -0
  40. package/dst/ase-mcp.js +166 -13
  41. package/dst/{ase-getopt.js → ase-service-getopt.js} +82 -80
  42. package/dst/{ase-markdown.js → ase-service-markdown.js} +18 -11
  43. package/dst/ase-service-otel.js +1246 -0
  44. package/dst/{ase-skills.js → ase-service-skills.js} +29 -64
  45. package/dst/ase-service-state.js +101 -0
  46. package/dst/ase-service.js +251 -76
  47. package/dst/ase-setup-common.js +141 -0
  48. package/dst/ase-setup-mcp.js +359 -0
  49. package/dst/ase-setup-settings.js +837 -0
  50. package/dst/ase-setup.js +113 -869
  51. package/dst/ase-spec.js +31 -19
  52. package/dst/ase-statusline.js +108 -43
  53. package/dst/ase-task-board-core.js +739 -0
  54. package/dst/ase-task-board-filter.js +127 -0
  55. package/dst/ase-task-board-flow.js +237 -0
  56. package/dst/ase-task-board-graph.js +497 -0
  57. package/dst/ase-task-board-stats.js +263 -0
  58. package/dst/ase-task-board-tui-control.js +775 -0
  59. package/dst/ase-task-board-tui-model.js +1008 -0
  60. package/dst/ase-task-board-tui-popup.js +626 -0
  61. package/dst/ase-task-board-tui-style.js +106 -0
  62. package/dst/ase-task-board-tui-view.js +847 -0
  63. package/dst/ase-task-board-tui.js +189 -0
  64. package/dst/ase-task-board-web-client.css +1 -0
  65. package/dst/ase-task-board-web-client.html +20 -0
  66. package/dst/ase-task-board-web-client.js +81 -0
  67. package/dst/ase-task-board-web-server.js +823 -0
  68. package/dst/ase-task-board.js +134 -0
  69. package/dst/ase-task-format.js +757 -0
  70. package/dst/ase-task-request.js +208 -0
  71. package/dst/ase-task-store-client.js +495 -0
  72. package/dst/ase-task-store-core.js +804 -0
  73. package/dst/ase-task-store-issues.js +237 -0
  74. package/dst/ase-task-store-plugin-api.js +6 -0
  75. package/dst/ase-task-store-plugin-ase.js +484 -0
  76. package/dst/ase-task-store-plugin-delegate.js +137 -0
  77. package/dst/ase-task-store-plugin-gitea.js +666 -0
  78. package/dst/ase-task-store-plugin-github.js +696 -0
  79. package/dst/ase-task-store-plugin-gitlab.js +668 -0
  80. package/dst/ase-task-store-server-bind.js +538 -0
  81. package/dst/ase-task-store-server-cli.js +154 -0
  82. package/dst/ase-task.js +1335 -357
  83. package/dst/{ase-compat.js → ase-util-compat.js} +2 -2
  84. package/dst/{ase-diagram.js → ase-util-diagram.js} +107 -32
  85. package/dst/{ase-meta.js → ase-util-meta.js} +2 -2
  86. package/dst/{ase-metric.js → ase-util-metric.js} +2 -2
  87. package/dst/{ase-mint.js → ase-util-mint.js} +19 -12
  88. package/dst/{ase-worktree.js → ase-util-worktree.js} +30 -19
  89. package/dst/ase-util.js +20 -0
  90. package/dst/ase.js +108 -32
  91. package/dst/man/ase-agent-adhoc.1 +515 -0
  92. package/dst/man/ase-agent-condenser.1 +193 -0
  93. package/dst/man/ase-agent-editor.1 +212 -0
  94. package/dst/man/ase-agent-estimator.1 +214 -0
  95. package/dst/man/ase-agent-fleet.1 +185 -0
  96. package/dst/man/ase-agent-implementer.1 +233 -0
  97. package/dst/man/ase-agent-integrator.1 +213 -0
  98. package/dst/man/ase-agent-qualifier.1 +214 -0
  99. package/dst/man/ase-agent-rebooter.1 +193 -0
  100. package/dst/man/ase-agent-relay.1 +128 -0
  101. package/dst/man/ase-agent-researcher.1 +217 -0
  102. package/dst/man/ase-agent.1 +264 -0
  103. package/dst/man/ase-artifact-list.1 +38 -0
  104. package/dst/man/ase-artifact-name.1 +39 -0
  105. package/dst/man/ase-artifact.1 +56 -0
  106. package/dst/man/ase-config-delete.1 +45 -0
  107. package/dst/man/ase-config-edit.1 +48 -0
  108. package/dst/man/ase-config-get.1 +46 -0
  109. package/dst/man/ase-config-init.1 +60 -0
  110. package/dst/man/ase-config-list.1 +42 -0
  111. package/dst/man/ase-config-set.1 +52 -0
  112. package/dst/man/ase-config.1 +225 -0
  113. package/dst/man/ase-hook-permission-request.1 +36 -0
  114. package/dst/man/ase-hook-pre-tool-use.1 +59 -0
  115. package/dst/man/ase-hook-session-end.1 +47 -0
  116. package/dst/man/ase-hook-session-persona.1 +37 -0
  117. package/dst/man/ase-hook-session-start.1 +81 -0
  118. package/dst/man/ase-hook-stop.1 +34 -0
  119. package/dst/man/ase-hook-user-prompt-submit.1 +34 -0
  120. package/dst/man/ase-hook.1 +51 -0
  121. package/dst/man/ase-man.1 +67 -0
  122. package/dst/man/ase-mcp.1 +61 -0
  123. package/dst/man/ase-service-log.1 +43 -0
  124. package/dst/man/ase-service-send.1 +47 -0
  125. package/dst/man/ase-service-start.1 +54 -0
  126. package/dst/man/ase-service-status.1 +46 -0
  127. package/dst/man/ase-service-stop.1 +37 -0
  128. package/dst/man/ase-service.1 +85 -0
  129. package/dst/man/ase-setup-disable.1 +44 -0
  130. package/dst/man/ase-setup-enable.1 +45 -0
  131. package/dst/man/ase-setup-install.1 +73 -0
  132. package/dst/man/ase-setup-mcp.1 +124 -0
  133. package/dst/man/ase-setup-status.1 +60 -0
  134. package/dst/man/ase-setup-statusline.1 +92 -0
  135. package/dst/man/ase-setup-telemetry.1 +92 -0
  136. package/dst/man/ase-setup-uninstall.1 +59 -0
  137. package/dst/man/ase-setup-update.1 +64 -0
  138. package/dst/man/ase-setup.1 +93 -0
  139. package/dst/man/ase-spec-export.1 +62 -0
  140. package/dst/man/ase-spec-lint.1 +40 -0
  141. package/dst/man/ase-spec-preview.1 +40 -0
  142. package/dst/man/ase-spec.1 +46 -0
  143. package/dst/man/ase-statusline.1 +189 -0
  144. package/dst/man/ase-task-assign.1 +71 -0
  145. package/dst/man/ase-task-board.1 +384 -0
  146. package/dst/man/ase-task-delete.1 +35 -0
  147. package/dst/man/ase-task-edit.1 +46 -0
  148. package/dst/man/ase-task-idscheme.1 +65 -0
  149. package/dst/man/ase-task-lifecycle.1 +57 -0
  150. package/dst/man/ase-task-list.1 +47 -0
  151. package/dst/man/ase-task-load.1 +36 -0
  152. package/dst/man/ase-task-newid.1 +61 -0
  153. package/dst/man/ase-task-purge.1 +37 -0
  154. package/dst/man/ase-task-rename.1 +44 -0
  155. package/dst/man/ase-task-request.1 +61 -0
  156. package/dst/man/ase-task-save.1 +48 -0
  157. package/dst/man/ase-task-status.1 +55 -0
  158. package/dst/man/ase-task-store.1 +194 -0
  159. package/dst/man/ase-task-view.1 +40 -0
  160. package/dst/man/ase-task.1 +160 -0
  161. package/dst/man/ase-util-compat.1 +25 -0
  162. package/dst/man/ase-util-diagram.1 +76 -0
  163. package/dst/man/ase-util-meta.1 +45 -0
  164. package/dst/man/ase-util-metric.1 +54 -0
  165. package/dst/man/ase-util-mint.1 +59 -0
  166. package/dst/man/ase-util-worktree.1 +72 -0
  167. package/dst/man/ase-util.1 +50 -0
  168. package/dst/man/ase.1 +124 -0
  169. package/package.json +79 -21
  170. package/plugin/.claude-plugin/plugin.json +1 -1
  171. package/plugin/.codex-plugin/plugin.json +1 -1
  172. package/plugin/.github/plugin/plugin.json +1 -1
  173. package/plugin/agents/ase-meta-diagram.md +12 -8
  174. package/plugin/agents/ase-repo-review.md +322 -0
  175. package/plugin/hooks/hooks-codex.json +5 -0
  176. package/plugin/hooks/hooks-copilot.json +5 -0
  177. package/plugin/hooks/hooks.json +8 -0
  178. package/plugin/meta/ase-common-approach.md +117 -0
  179. package/plugin/meta/ase-common-changeset.md +256 -0
  180. package/plugin/meta/ase-common-code.md +10 -173
  181. package/plugin/meta/ase-common-grill.md +317 -0
  182. package/plugin/meta/ase-common-resolve.md +305 -0
  183. package/plugin/meta/ase-common-task.md +83 -4
  184. package/plugin/meta/ase-constitution.md +0 -2
  185. package/plugin/meta/ase-format-specbook.md +208 -25
  186. package/plugin/meta/ase-format-specbook.yaml +242 -851
  187. package/plugin/meta/ase-format-task.md +669 -125
  188. package/plugin/meta/ase-persona.md +3 -4
  189. package/plugin/meta/ase-skill.md +9 -0
  190. package/plugin/mods/ase-mods.ts +409 -0
  191. package/plugin/mods/claude-code.d.ts +13417 -0
  192. package/plugin/skills/ase-arch-analyze/SKILL.md +1 -1
  193. package/plugin/skills/ase-arch-analyze/help.md +2 -2
  194. package/plugin/skills/ase-code-analyze/SKILL.md +59 -8
  195. package/plugin/skills/ase-code-analyze/help.md +29 -4
  196. package/plugin/skills/ase-code-edit/SKILL.md +107 -181
  197. package/plugin/skills/ase-code-edit/help.md +118 -31
  198. package/plugin/skills/ase-code-lint/SKILL.md +64 -13
  199. package/plugin/skills/ase-code-lint/help.md +32 -2
  200. package/plugin/skills/ase-docs-proofread/SKILL.md +4 -4
  201. package/plugin/skills/ase-docs-proofread/help.md +1 -1
  202. package/plugin/skills/ase-docs-refine/SKILL.md +4 -4
  203. package/plugin/skills/ase-docs-shorten/SKILL.md +4 -4
  204. package/plugin/skills/ase-help-intent/data.md +2164 -1087
  205. package/plugin/skills/ase-help-intent/help.md +1 -1
  206. package/plugin/skills/ase-help-skill/catalog.md +12 -9
  207. package/plugin/skills/ase-meta-brainstorm/SKILL.md +1 -1
  208. package/plugin/skills/ase-meta-config/SKILL.md +3 -4
  209. package/plugin/skills/ase-meta-config/help.md +31 -10
  210. package/plugin/skills/ase-meta-mint/SKILL.md +5 -5
  211. package/plugin/skills/ase-meta-mint/help.md +10 -5
  212. package/plugin/skills/ase-meta-why/help.md +1 -1
  213. package/plugin/skills/ase-meta-workflow/SKILL.md +7 -7
  214. package/plugin/skills/ase-meta-workflow/help.md +4 -4
  215. package/plugin/skills/ase-meta-workflow/sample.md +3 -3
  216. package/plugin/skills/ase-meta-workflow/workflow.txt +22 -18
  217. package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/SKILL.md +4 -3
  218. package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/help.md +5 -5
  219. package/plugin/skills/{ase-meta-commit → ase-repo-commit}/SKILL.md +6 -6
  220. package/plugin/skills/{ase-meta-commit → ase-repo-commit}/help.md +5 -5
  221. package/plugin/skills/{ase-meta-diff → ase-repo-diff}/SKILL.md +6 -6
  222. package/plugin/skills/{ase-meta-diff → ase-repo-diff}/help.md +8 -8
  223. package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/SKILL.md +19 -19
  224. package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/help.md +10 -12
  225. package/plugin/skills/ase-repo-merge/SKILL.md +346 -0
  226. package/plugin/skills/ase-repo-merge/help.md +107 -0
  227. package/plugin/skills/ase-repo-resolve/SKILL.md +158 -0
  228. package/plugin/skills/ase-repo-resolve/help.md +102 -0
  229. package/plugin/skills/ase-repo-review/SKILL.md +406 -0
  230. package/plugin/skills/ase-repo-review/help.md +181 -0
  231. package/plugin/skills/ase-spec-edit/SKILL.md +117 -183
  232. package/plugin/skills/ase-spec-edit/help.md +107 -26
  233. package/plugin/skills/ase-sync-reconcile/help.md +1 -1
  234. package/plugin/skills/ase-task-assign/SKILL.md +111 -0
  235. package/plugin/skills/ase-task-assign/help.md +119 -0
  236. package/plugin/skills/ase-task-condense/SKILL.md +21 -21
  237. package/plugin/skills/ase-task-condense/help.md +12 -10
  238. package/plugin/skills/ase-task-delete/SKILL.md +1 -1
  239. package/plugin/skills/ase-task-dissect/SKILL.md +71 -48
  240. package/plugin/skills/ase-task-dissect/help.md +10 -9
  241. package/plugin/skills/ase-task-edit/SKILL.md +379 -94
  242. package/plugin/skills/ase-task-edit/help.md +95 -22
  243. package/plugin/skills/ase-task-estimate/SKILL.md +492 -0
  244. package/plugin/skills/ase-task-estimate/help.md +158 -0
  245. package/plugin/skills/ase-task-grill/SKILL.md +246 -138
  246. package/plugin/skills/ase-task-grill/help.md +129 -26
  247. package/plugin/skills/ase-task-id/SKILL.md +8 -1
  248. package/plugin/skills/ase-task-id/help.md +6 -3
  249. package/plugin/skills/ase-task-implement/SKILL.md +535 -145
  250. package/plugin/skills/ase-task-implement/help.md +138 -47
  251. package/plugin/skills/ase-task-integrate/SKILL.md +549 -0
  252. package/plugin/skills/ase-task-integrate/help.md +187 -0
  253. package/plugin/skills/ase-task-list/SKILL.md +55 -23
  254. package/plugin/skills/ase-task-list/help.md +45 -21
  255. package/plugin/skills/ase-task-reboot/SKILL.md +84 -43
  256. package/plugin/skills/ase-task-reboot/help.md +28 -8
  257. package/plugin/skills/ase-task-rename/SKILL.md +10 -2
  258. package/plugin/skills/ase-task-research/SKILL.md +386 -0
  259. package/plugin/skills/ase-task-research/help.md +102 -0
  260. package/plugin/skills/ase-task-status/SKILL.md +137 -0
  261. package/plugin/skills/ase-task-status/help.md +80 -0
  262. package/plugin/skills/ase-task-view/SKILL.md +49 -24
  263. package/plugin/skills/ase-task-view/help.md +19 -9
  264. package/plugin/agents/ase-meta-review.md +0 -187
  265. package/plugin/skills/ase-code-craft/SKILL.md +0 -264
  266. package/plugin/skills/ase-code-craft/help.md +0 -122
  267. package/plugin/skills/ase-code-refactor/SKILL.md +0 -232
  268. package/plugin/skills/ase-code-refactor/help.md +0 -109
  269. package/plugin/skills/ase-code-resolve/SKILL.md +0 -289
  270. package/plugin/skills/ase-code-resolve/help.md +0 -116
  271. package/plugin/skills/ase-meta-review/SKILL.md +0 -210
  272. package/plugin/skills/ase-meta-review/help.md +0 -96
  273. package/plugin/skills/ase-task-preflight/SKILL.md +0 -166
  274. package/plugin/skills/ase-task-preflight/help.md +0 -82
  275. /package/dst/{ase-ignore.js → ase-lib-ignore.js} +0 -0
  276. /package/dst/{ase-stdio.js → ase-lib-stdio.js} +0 -0
  277. /package/dst/{ase-kv.js → ase-service-kv.js} +0 -0
  278. /package/dst/{ase-sleep.js → ase-service-sleep.js} +0 -0
  279. /package/dst/{ase-timestamp.js → ase-service-timestamp.js} +0 -0
@@ -0,0 +1,1316 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import readline from "node:readline";
10
+ import { Command } from "commander";
11
+ import { execa } from "execa";
12
+ import { DateTime } from "luxon";
13
+ import which from "which";
14
+ import { Task } from "./ase-task.js";
15
+ import * as TaskFormat from "./ase-task-format.js";
16
+ import { Config } from "./ase-config-core.js";
17
+ import { configSchema, parsePrices, modelPrice, tokenCost } from "./ase-config-schema.js";
18
+ import { Worktree } from "./ase-util-worktree.js";
19
+ import { writeStdout } from "./ase-lib-stdio.js";
20
+ import { parseTool, toolSpecs } from "./ase-setup-common.js";
21
+ import { AgentPresenceReporter } from "./ase-agent-relay-client.js";
22
+ import { telemetryEnabled } from "./ase-service-otel.js";
23
+ import { toolArgs, toolEntries, skillPrompt } from "./ase-agent-runtime-tool.js";
24
+ import { tempPreserved, tempDir, execaJob, jobScope, withinJobTimeout, gitInside, gitLocked, gitWorktree, worktreeRemove, gitWorktreeRemove, gitWorktreeAdd, gitWorktreeDetached, worktreeId, safeName, contextDir, worktreeName, gitClean, gitChangesetDiff } from "./ase-agent-runtime-exec.js";
25
+ import { AgentRun } from "./ase-agent-runtime-claim.js";
26
+ /* parse a duration "<number><unit>" with unit s, m, or h into milliseconds */
27
+ const parseDuration = (spec) => {
28
+ const m = /^(\d+)([smh])$/.exec(spec);
29
+ const unit = { s: 1000, m: 60 * 1000, h: 60 * 60 * 1000 };
30
+ const ms = m !== null ? Number.parseInt(m[1], 10) * unit[m[2]] : 0;
31
+ if (ms < 1 || ms > 2 ** 31 - 1)
32
+ throw new Error(`agent: invalid duration "${spec}" (expected <number><unit> with unit s, m, or h, up to about 24 days)`);
33
+ return ms;
34
+ };
35
+ /* the plain tags of the "Tags:" frontmatter key of a task */
36
+ export const tagsOf = (keys) => (keys.get("Tags") ?? "").split(",").map((tag) => tag.trim()).filter((tag) => tag !== "");
37
+ /* whether a task carries the tag <key>, plain or with any value */
38
+ export const taggedWith = (keys, key) => tagsOf(keys).some((tag) => tag === key || tag.startsWith(`${key}:`));
39
+ /* the flattened header keys of a task plan (like those of the listed tasks) */
40
+ export const flatKeys = (header) => new Map(Object.entries(header).map(([key, val]) => [key, Array.isArray(val) ? val.join(", ") : String(val)]));
41
+ /* set the tag <key> in the "Tags:" frontmatter key of a task plan,
42
+ replacing all its existing occurrences: as a plain tag "<key>" without <value>,
43
+ as a "<key>:<value>" tag with <value>, or not at all (removing it) with a null <value> */
44
+ export const setPlanTag = (plan, key, value) => {
45
+ const field = plan.header.Tags ?? [];
46
+ const tags = (Array.isArray(field) ? field : field.split(","))
47
+ .map((t) => t.trim()).filter((t) => t !== "");
48
+ const tag = value === undefined ? key : `${key}:${value}`;
49
+ const match = (t) => t === key || t.startsWith(`${key}:`);
50
+ const at = tags.findIndex(match);
51
+ const next = tags.flatMap((t, i) => !match(t) ? [t] : i === at && value !== null ? [tag] : []);
52
+ if (at < 0 && value !== null)
53
+ next.push(tag);
54
+ if (next.length > 0)
55
+ plan.header.Tags = next;
56
+ else
57
+ delete plan.header.Tags;
58
+ };
59
+ /* update a task plan by the operation <op> in a single conditional save (see "Task.update"),
60
+ retrying (with a growing delay, on a freshly loaded plan) on a failure, especially on a
61
+ concurrent change of the task (HTTP 412), so <op> has to (re)compute all its results,
62
+ where <rejected> is called whenever a save was surely not done (on HTTP 412), unlike
63
+ on other failures (e.g. a lost response), where the save might have been done */
64
+ export const updateTask = (log, id, op, rejected) => Task.updateRetry(log, id, op, rejected);
65
+ /* the permitted types and the built-in defaults of the change set flow keys */
66
+ const flowTypes = {
67
+ Source: ["worktree", "branch"],
68
+ Changeset: ["worktree", "branch", "attachment"],
69
+ Target: ["worktree", "branch", "source"]
70
+ };
71
+ const flowDefaults = {
72
+ Source: "worktree",
73
+ Changeset: "worktree",
74
+ Target: "source"
75
+ };
76
+ /* the raw value of the change set flow key "Source:", "Changeset:", or "Target:"
77
+ of a task (or else its configured default), with every placeholder "{id}" replaced
78
+ by the safe name of the task id (see "safeName"), like by the skills */
79
+ export const taskFlowRaw = (log, task, key) => {
80
+ let value = task.keys.get(key) ?? "";
81
+ if (value === "") {
82
+ const cfg = new Config("config", configSchema, log);
83
+ cfg.read();
84
+ const val = cfg.getExplicit(`project.task.default.${key.toLowerCase()}`);
85
+ value = typeof val === "string" ? val : (process.env[`ASE_PROJECT_TASK_DEFAULT_${key.toUpperCase()}`] ?? flowDefaults[key]);
86
+ }
87
+ return value.replaceAll("{id}", safeName(task.id));
88
+ };
89
+ /* the value of the change set flow key "Source:", "Changeset:", or "Target:" of a
90
+ task (or else its configured default), parsed and validated like by the skills,
91
+ as the names are passed to Git, and a "Target:" of "source" resolved to "Source:" */
92
+ export const taskFlow = (log, task, key) => {
93
+ const value = taskFlowRaw(log, task, key);
94
+ const i = value.indexOf(":");
95
+ const type = i < 0 ? value : value.slice(0, i);
96
+ const name = i < 0 ? "" : value.slice(i + 1);
97
+ const named = {
98
+ branch: /^(?![-./])[A-Za-z0-9_./-]+(?<![/.])$/.test(name) && !/\.\.|\/\/|\.lock$/.test(name),
99
+ attachment: /^[A-Za-z0-9_-]+$/.test(name)
100
+ };
101
+ if (!flowTypes[key].includes(type) || !(named[type] ?? name === ""))
102
+ throw new Error(`agent: invalid ${key} value "${value}" of task "${task.id}"`);
103
+ return type === "source" ? taskFlow(log, task, "Source") : { type, name };
104
+ };
105
+ /* the holders ("<agent>:<task>") of the Git working copies and branches used exclusively during
106
+ the processing of a task by any agent of this process (see "Agent.holdRoot" and "Agent.holdBranch") */
107
+ const rootHolders = new Map();
108
+ /* the agents of this process currently processing tasks, and the wake-ups of the
109
+ dispatchers of all running agents of this process, so in one-shot mode an agent
110
+ terminates only once all agents of this process are idle (as a task processed by
111
+ one agent can become dispatchable to another one, e.g. an implemented task to the qualifier) */
112
+ const busyAgents = new Set();
113
+ const wakeAgents = new Set();
114
+ /* the generic agent: it observes the tasks, selects the dispatchable ones (see
115
+ "select"), claims each one (see "claim") into a free worker slot, processes it
116
+ via an ASE skill of the headless agent tool (see "prepare"), and finally settles
117
+ it (see "settle"), where the concrete agent decides on all of these steps */
118
+ export class Agent {
119
+ log;
120
+ constructor(log) {
121
+ this.log = log;
122
+ }
123
+ /* the name of the agent ("--name", default: its kind), distinguishing
124
+ multiple agents of the same kind in the log lines (also of the shared
125
+ processing log attachment "agent"), and the temporary directories */
126
+ _name = "";
127
+ get name() {
128
+ return this._name !== "" ? this._name : this.kind;
129
+ }
130
+ /* the role of the agent in the recovery descriptions (default: its kind), and the namespace
131
+ of its runs (default: its kind, see "AgentRun"), which a configured agent can override */
132
+ get role() {
133
+ return this.kind;
134
+ }
135
+ get runKind() {
136
+ return this.kind;
137
+ }
138
+ /* the name of the requests processed by the agent, as announced in its presence (default: none) */
139
+ presenceRequest = "";
140
+ /* whether the prompts of the requests processed by the agent are required, optional, or
141
+ ignored (else they may be requested with an empty prompt), as announced in its presence */
142
+ presencePrompt = "required";
143
+ /* the lifecycle states the agent operates on, as announced in its presence (default: none) */
144
+ presenceStates = undefined;
145
+ /* the title and the description of the operation of the agent, as announced in its presence (default: none) */
146
+ presenceTitle = "";
147
+ presenceDesc = "";
148
+ /* whether tasks are processed serially only (clamping "--parallel" to 1, and
149
+ refusing multiple agents of the kind in a fleet, see "ase agent fleet") */
150
+ serial = false;
151
+ get serialOnly() {
152
+ return this.serial;
153
+ }
154
+ /* whether tasks are processed in Git worktrees (requiring "git" in $PATH) */
155
+ worktree = false;
156
+ /* the task lifecycle models the agent supports */
157
+ lifecycles = ["enterprise"];
158
+ /* the mode of operation of the agent (e.g. by its option "--mode", else empty) */
159
+ mode = "";
160
+ /* the per-agent CLI command and the effective configuration of the agent */
161
+ cmd = null;
162
+ tool = "claude";
163
+ parallel = 1;
164
+ timeoutSpec = "";
165
+ timeout = 0;
166
+ oneShot = false;
167
+ lifecycle = null;
168
+ /* the URL of the Git repository of "--repo", the directory of its
169
+ temporary clone, and the default branch of this clone */
170
+ repo = "";
171
+ clone = "";
172
+ head = "";
173
+ /* the "--tempdir" specification, the temporary directory of the agent,
174
+ and whether it holds work preserved for a manual recovery */
175
+ tempdir = "";
176
+ temp = null;
177
+ preserved = false;
178
+ /* the working directories in the temporary clone of "--repo" kept
179
+ for a manual recovery of the work preserved in them (see "preserve") */
180
+ keptdirs = new Set();
181
+ /* the client of the agent relay (if any) and the presence of the running agent announced to it */
182
+ relay = null;
183
+ presence = null;
184
+ /* per processed task id: the remainders of its AI request time and its real time (in
185
+ milliseconds) not yet booked onto it, as the bookings carry whole minutes only */
186
+ unbooked = new Map();
187
+ /* per processed task id: its working directory in the temporary clone
188
+ of "--repo" (see "workdir"), the Git working copy and branches held exclusively
189
+ for it (see "holdRoot" and "holdBranch"), and the reason it was last deferred for */
190
+ workdirs = new Map();
191
+ holds = new Map();
192
+ deferred = new Map();
193
+ /* the run of the agent process (see "AgentRun") */
194
+ _run = null;
195
+ get runLock() {
196
+ if (this._run === null)
197
+ this._run = new AgentRun(this.log, this.runKind);
198
+ return this._run;
199
+ }
200
+ /* register additional CLI options and initialize the agent from them,
201
+ and release the resources of the initialization after the run of the agent */
202
+ options(_cmd) { }
203
+ async init(_opts) { }
204
+ async teardown() { }
205
+ /* the interruption of the run of the agent (never aborted before its run) */
206
+ interruption = new AbortController().signal;
207
+ /* the prioritization of the dispatchable tasks <tasks> (in natural id order) on each
208
+ dispatching: the tasks in the order to claim them, where an omitted task is not
209
+ claimed this time (by default, the natural id order) */
210
+ async order(tasks) {
211
+ return tasks;
212
+ }
213
+ /* recover the tasks left behind by terminated runs of agents of its kind (see
214
+ "AgentRun"), at the start of the run, and decide whether the run lock has to be
215
+ retained after the run (until the exit of the process, e.g. while still
216
+ processing results are not finalized, so no other agent recovers them meanwhile) */
217
+ async recover() { }
218
+ retainRun() {
219
+ return this.unsettled.size > 0;
220
+ }
221
+ /* the finalizations of processed tasks which failed even after their retries (e.g. on
222
+ repeated concurrent changes of the task, or on a transient failure of the task store),
223
+ per task id, retried in the background with a growing delay (and a last time at the
224
+ end of the run), so no task stays claimed by a live agent until another agent recovers
225
+ it, where a finalization resolves to whether it is done (and throws on a failure) */
226
+ unsettled = new Map();
227
+ unsettledRetry = null;
228
+ unsettledTimer;
229
+ unsettledRound = 0;
230
+ /* remember a failed finalization of a task for its retrying */
231
+ unsettle(id, finish) {
232
+ this.unsettled.set(id, finish);
233
+ this.scheduleUnsettled();
234
+ }
235
+ /* schedule the next retry round of the failed finalizations (if any) */
236
+ scheduleUnsettled() {
237
+ if (this.unsettledTimer !== undefined || this.unsettled.size === 0)
238
+ return;
239
+ this.unsettledRound++;
240
+ this.unsettledTimer = setTimeout(() => {
241
+ this.retryUnsettled().finally(() => {
242
+ this.unsettledTimer = undefined;
243
+ this.scheduleUnsettled();
244
+ });
245
+ }, Math.min(this.unsettledRound, 30) * 1000);
246
+ this.unsettledTimer.unref();
247
+ }
248
+ /* retry all failed finalizations once (never concurrently to itself) */
249
+ async retryUnsettled() {
250
+ if (this.unsettledRetry !== null)
251
+ return this.unsettledRetry;
252
+ this.unsettledRetry = (async () => {
253
+ for (const [id, finish] of [...this.unsettled])
254
+ if (await finish().catch(() => false))
255
+ this.unsettled.delete(id);
256
+ if (this.unsettled.size === 0)
257
+ this.unsettledRound = 0;
258
+ })().finally(() => {
259
+ this.unsettledRetry = null;
260
+ });
261
+ return this.unsettledRetry;
262
+ }
263
+ /* whether a dispatchable task (e.g. one without a result of a previous
264
+ processing) is to be claimed, checked right before its claim */
265
+ async ready(_task) {
266
+ return true;
267
+ }
268
+ /* the wake-up of the dispatcher of the running agent (if running), and its delayed
269
+ triggering, e.g. once a task held off by the agent becomes dispatchable again */
270
+ wakeup = null;
271
+ redispatch(delay) {
272
+ const timer = setTimeout(() => {
273
+ this.wakeup?.();
274
+ }, delay);
275
+ timer.unref();
276
+ }
277
+ /* whether tasks are processed in parallel to others by multiple worker slots of the agent */
278
+ get parallelized() {
279
+ return this.parallel > 1;
280
+ }
281
+ /* defer a dispatchable task (instead of claiming it, see "ready"), reporting
282
+ the reason once per change of it, until the next dispatching */
283
+ async defer(task, reason) {
284
+ if (this.deferred.get(task.id) !== reason) {
285
+ this.deferred.set(task.id, reason);
286
+ await this.write("info", this.name, `task "${task.id}" waits for ${reason}`);
287
+ }
288
+ return false;
289
+ }
290
+ /* hold the Git working copy <root> the agent is started in exclusively for the processing
291
+ of a task (from right before its claim, see "ready", until it is settled), if it is used
292
+ directly (instead of an ASE worktree of it), i.e., if a skill changes it (e.g. implements
293
+ into it, stages in it, or integrates from or into it), so no agent of this process using
294
+ it directly processes another task meanwhile, which would mix up the uncommitted change
295
+ sets of the tasks (the agents of other processes, and humans, are not coordinated, so
296
+ a working copy should be used directly by a single agent process only);
297
+ returns false if it is already held for another task */
298
+ holdRoot(task, root) {
299
+ return this.hold(task, root);
300
+ }
301
+ /* hold the Git branch <branch> of the Git working copy <root> the agent is started in
302
+ (or, if empty, of the temporary clone of "--repo") exclusively for the processing of a
303
+ task (like "holdRoot"), if the task is processed in the worktree the branch is checked
304
+ out in (e.g. into a "branch:" changeset), so no agent of this process processes another
305
+ task in the same worktree meanwhile, which would mix up the change sets of the tasks
306
+ (e.g. into the commit onto the branch) and remove the worktree under the other task;
307
+ returns false if it is already held for another task */
308
+ holdBranch(task, root, branch) {
309
+ return this.hold(task, `${root !== "" ? root : this.repo}#${branch}`);
310
+ }
311
+ /* hold a resource (by its key) exclusively for the processing of a task */
312
+ hold(task, key) {
313
+ const holder = `${this.name}:${task.id}`;
314
+ const held = rootHolders.get(key);
315
+ if (held !== undefined && held !== holder)
316
+ return false;
317
+ rootHolders.set(key, holder);
318
+ const keys = this.holds.get(task.id) ?? new Set();
319
+ keys.add(key);
320
+ this.holds.set(task.id, keys);
321
+ return true;
322
+ }
323
+ /* release the Git working copy and branches held for a task (if any) */
324
+ unhold(id) {
325
+ const keys = this.holds.get(id);
326
+ if (keys === undefined)
327
+ return;
328
+ this.holds.delete(id);
329
+ for (const key of keys)
330
+ if (rootHolders.get(key) === `${this.name}:${id}`)
331
+ rootHolders.delete(key);
332
+ }
333
+ /* release all resources of the processing of a task: its held Git working
334
+ copy and branches and its working directory in the temporary clone */
335
+ async unbind(id) {
336
+ this.unhold(id);
337
+ const dir = this.workdirs.get(id);
338
+ if (dir !== undefined) {
339
+ this.workdirs.delete(id);
340
+ await gitWorktreeRemove(this.clone, dir).catch(() => { });
341
+ }
342
+ }
343
+ /* the Git working copy the agent is started in, if a task with the change set
344
+ flow values <values> is processed in it (see "workdir"), else empty */
345
+ localRoot(values) {
346
+ return this.clone === "" || values.some((value) => value.type === "worktree") ? Task.projectRoot() : "";
347
+ }
348
+ /* whether the working copy <root> belongs to the temporary clone of "--repo" */
349
+ cloned(root) {
350
+ return this.clone !== "" && (root === this.clone || [...this.workdirs.values()].includes(root));
351
+ }
352
+ /* the Git working copy in which a task with the change set flow values <values>
353
+ is processed: for any "worktree" value (or without "--repo") the one the agent
354
+ is started in, else a temporary worktree of the clone of "--repo" of its own,
355
+ detached at the default branch, after refreshing the default branch and the
356
+ branches of the values from the origin, where the Git operations on the clone
357
+ are mutually exclusive and its own working directory is never changed or used
358
+ by a skill (so concurrently processed tasks do not interfere) */
359
+ async workdir(task, values) {
360
+ if (this.localRoot(values) !== "") {
361
+ if (!(await gitInside()))
362
+ throw new Error("agent: task requires the Git working copy the agent is started in, but there is none");
363
+ return Task.projectRoot();
364
+ }
365
+ const cwd = this.clone;
366
+ const dir = path.join(this.temp.dir, "work", task.id);
367
+ if (this.keptdirs.has(dir))
368
+ throw new Error(`agent: working directory "${dir}" holds work of an earlier processing of the task, ` +
369
+ "preserved for a manual recovery");
370
+ this.workdirs.set(task.id, dir);
371
+ await gitLocked(cwd, async () => {
372
+ await execaJob("git", ["fetch", "--quiet", "--prune", "origin"], { cwd });
373
+ for (const branch of [this.head, ...values.filter((value) => value.type === "branch").map((value) => value.name)]) {
374
+ /* a branch checked out in a worktree (e.g. by the processing of another
375
+ task) cannot be moved by Git and is hence left as it is */
376
+ if ((await execaJob("git", ["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${branch}`], { cwd, reject: false })).exitCode === 0 && (await gitWorktree(cwd, branch)) === "")
377
+ await execaJob("git", ["branch", "--quiet", "--force", branch, `origin/${branch}`], { cwd });
378
+ }
379
+ await worktreeRemove(cwd, dir);
380
+ await execaJob("git", ["worktree", "add", "--quiet", "--detach", dir, this.head], { cwd });
381
+ });
382
+ return dir;
383
+ }
384
+ /* publish a branch of the working copy <root> to the origin of "--repo",
385
+ if <root> belongs to its temporary clone (a rejected push fails the task) */
386
+ async publish(root, branch, log) {
387
+ if (!this.cloned(root))
388
+ return;
389
+ await gitLocked(root, () => execaJob("git", ["push", "--quiet", "origin", `refs/heads/${branch}:refs/heads/${branch}`], { cwd: root }));
390
+ await log("info", `branch "${branch}" pushed to "${this.repo}"`);
391
+ }
392
+ /* delete a branch from the origin of "--repo", if <root> belongs to its temporary
393
+ clone and the branch was already deleted in it (e.g. after a merge) */
394
+ async unpublish(root, branch, log) {
395
+ if (!this.cloned(root) || (await execaJob("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], { cwd: root, reject: false })).exitCode === 0)
396
+ return;
397
+ await gitLocked(root, () => execaJob("git", ["push", "--quiet", "origin", "--delete", branch], { cwd: root }));
398
+ await log("info", `branch "${branch}" deleted from "${this.repo}"`);
399
+ }
400
+ /* rescue a branch of the working copy <root> which could not be published to the origin
401
+ of "--repo" (e.g. carrying a merge already recorded as integrated): keep its state as
402
+ the branch "ase-agent-rescue/<task-id>" in the temporary clone and preserve the clone
403
+ (instead of removing it after the run) for a manual recovery, where the rescuing runs
404
+ outside of the operation of the job (see "jobScope"), as the operation (e.g. a hanging
405
+ "git push" of the finishing) might have timed out, which cancels all its commands */
406
+ async rescue(root, branch, task, log) {
407
+ if (!this.cloned(root))
408
+ return;
409
+ await jobScope.exit(async () => {
410
+ const rescue = `ase-agent-rescue/${task.id}`;
411
+ await gitLocked(root, () => execaJob("git", ["branch", "--force", rescue, `refs/heads/${branch}`], { cwd: root }));
412
+ await this.markPreserved(`task "${task.id}": branch "${branch}" not published to "${this.repo}" -- kept as branch "${rescue}"`);
413
+ await log("error", `branch "${branch}" NOT published to "${this.repo}" -- kept as branch "${rescue}" ` +
414
+ `in the temporary clone "${this.clone}", which is preserved for a manual recovery`);
415
+ });
416
+ }
417
+ /* preserve the uncommitted work <what> of a task, left in its working directory <root>
418
+ (e.g. in a worktree nested inside it), if <root> belongs to the temporary clone of
419
+ "--repo": keep its working directory (instead of removing it after the processing,
420
+ and refuse its reuse by a later processing of the task in this run) and preserve the
421
+ clone for a manual recovery; returns false if <root> does not belong to the clone */
422
+ async preserve(root, task, what, log) {
423
+ if (!this.cloned(root))
424
+ return false;
425
+ if (this.workdirs.get(task.id) === root) {
426
+ this.workdirs.delete(task.id);
427
+ this.keptdirs.add(root);
428
+ }
429
+ await this.markPreserved(`task "${task.id}": ${what} kept in "${root}"`);
430
+ await log("error", `${what} kept in "${root}" of the temporary clone "${this.clone}", ` +
431
+ "which is preserved for a manual recovery");
432
+ return true;
433
+ }
434
+ /* mark the temporary directory as holding work preserved for a manual recovery */
435
+ async markPreserved(note) {
436
+ this.preserved = true;
437
+ await fs.promises.writeFile(path.join(this.temp.dir, tempPreserved), `${note}\n`, { flag: "a" });
438
+ }
439
+ /* whether a task is ready for the access of the working directory kind <workdir>
440
+ ("project", "source", "changeset", "changeset-read", or "target"), see "workdirJob" */
441
+ async workdirReady(task, workdir) {
442
+ if (workdir === "changeset")
443
+ return this.changesetReady(task);
444
+ else if (workdir === "changeset-read")
445
+ return this.changesetReadReady(task);
446
+ else if (workdir === "target")
447
+ return this.targetReady(task);
448
+ return true;
449
+ }
450
+ /* the work of a job in the working directory kind <workdir>: the project root
451
+ ("project"), or the working copy of "sourceJob" (with the "branch:" source checked out
452
+ on request), "changesetJob" (with the work committed on request), "changesetReadJob",
453
+ or "targetJob" (the latter two with the change set snapshot captured on request, for
454
+ its attachment of the kind <snapshot>, else empty), where the agent-owned worktrees
455
+ are named with the suffix <suffix> */
456
+ async workdirJob(task, workdir, suffix, parallel, options) {
457
+ if (workdir === "source")
458
+ return this.sourceJob(task, suffix, options.checkout);
459
+ else if (workdir === "changeset")
460
+ return this.changesetJob(task, parallel, options.commit, options.snapshot);
461
+ else if (workdir === "changeset-read")
462
+ return this.changesetReadJob(task, suffix);
463
+ else if (workdir === "target")
464
+ return this.targetJob(task, suffix, options.snapshot);
465
+ return { cwd: Task.projectRoot(), dirs: [], sourceDir: "", changesetWorktree: "" };
466
+ }
467
+ /* the change set snapshot of the working tree of the Git working copy <dir>, from the
468
+ fork point of its "HEAD" from the source commit <base> on, marked as partial on request */
469
+ async worktreeSnapshot(dir, base, partial) {
470
+ const { base: fork, diff } = await gitChangesetDiff(dir, base);
471
+ return diff !== "" ? { desc: `base ${fork}${partial ? ", partial" : ""}`, diff } : null;
472
+ }
473
+ /* the work of a job in the working copy holding the "Source:" of a task (see "workdir"),
474
+ where a "branch:" source is additionally checked out on request (<checkout>) in a
475
+ temporary, detached Git worktree "<id>-<suffix>" (granted to the agent tool), which is
476
+ removed afterwards */
477
+ async sourceJob(task, suffix, checkout) {
478
+ const source = taskFlow(this.log, task, "Source");
479
+ const root = await this.workdir(task, [source]);
480
+ if (source.type !== "branch" || !checkout)
481
+ return { cwd: root, dirs: [], sourceDir: "", changesetWorktree: "" };
482
+ const dir = Worktree.dir(`${worktreeId(task.id)}-${suffix}`, true, true, root);
483
+ await gitWorktreeDetached(root, dir, source.name);
484
+ return {
485
+ cwd: root,
486
+ dirs: [dir],
487
+ sourceDir: dir,
488
+ changesetWorktree: "",
489
+ cleanup: async (log) => {
490
+ await gitWorktreeRemove(root, dir);
491
+ await log("info", `temporary worktree of source branch "${source.name}" removed`);
492
+ }
493
+ };
494
+ }
495
+ /* whether a task is ready for the write access to its "Changeset:" (see "changesetJob"):
496
+ a task with a "branch:" changeset is processed only while its changeset branch is held for
497
+ it alone (see "holdBranch"), as two tasks with the same changeset branch would otherwise
498
+ be processed in the same worktree at once (mixing their change sets into the commit,
499
+ and removing the worktree under each other), and a task is processed directly in the
500
+ working copy the agent is started in (a serially processed "worktree" changeset, or a
501
+ "branch:" changeset checked out in it) only while the working copy is held for it alone
502
+ (see "holdRoot") and while it carries no uncommitted changes (except the ASE state), as
503
+ otherwise the uncommitted change set of an earlier task (e.g. one still to be reviewed and
504
+ integrated) or the uncommitted work of a human would mix into the one of this task (and,
505
+ for a "branch:" changeset, even be committed onto its branch), so the task is deferred
506
+ (and not claimed) until then (re-checked on the next change of the tasks, e.g. once the
507
+ earlier task was integrated) */
508
+ async changesetReady(task) {
509
+ let source;
510
+ let changeset;
511
+ try {
512
+ source = taskFlow(this.log, task, "Source");
513
+ changeset = taskFlow(this.log, task, "Changeset");
514
+ }
515
+ catch (_err) {
516
+ /* an invalid value fails the processing of the task */
517
+ return true;
518
+ }
519
+ const root = this.localRoot([source, changeset]);
520
+ if (changeset.type === "branch" && !this.holdBranch(task, root, changeset.name))
521
+ return this.defer(task, `the changeset branch "${changeset.name}" used by another task`);
522
+ else if (root === "")
523
+ return true;
524
+ else if ((changeset.type === "worktree" && !this.parallelized)
525
+ || (changeset.type === "branch" && (await gitWorktree(root, changeset.name).catch(() => "")) === root)) {
526
+ if (!this.holdRoot(task, root))
527
+ return this.defer(task, "the working copy used by another task");
528
+ else if (!(await gitClean(root).catch(() => true))) {
529
+ this.unhold(task.id);
530
+ return this.defer(task, "the working copy without uncommitted changes (e.g. of an earlier task)");
531
+ }
532
+ }
533
+ return true;
534
+ }
535
+ /* the work of a job with write access to the "Changeset:" of a task, processed from its
536
+ "Source:" (see "changesetReady"): a "worktree" changeset in the working copy itself
537
+ (switched to a "branch:" source, if processed serially, else in the ASE worktree of the
538
+ task, created by the skill itself), a "branch:" changeset in the worktree in which it is
539
+ checked out, else in an agent worktree named after the branch, like by the skill (with
540
+ the branch created from the source if missing), and an "attachment:" changeset left to
541
+ the skill, where the work on a "branch:" changeset is committed onto it on request
542
+ (<commit>) and published, and where
543
+ the change set snapshot is captured on request (for its attachment of the kind
544
+ <snapshot>, else empty) from the working tree carrying the change set (also for a
545
+ "branch:" changeset, before its commit), based on the source commit (so it shows the
546
+ entire change set, also of an earlier processing), while a snapshot is obsolete for an
547
+ "attachment:" changeset (except for the attachment of its own kind, which is the
548
+ change set itself) */
549
+ async changesetJob(task, parallel, commit, snapshot) {
550
+ const source = taskFlow(this.log, task, "Source");
551
+ const changeset = taskFlow(this.log, task, "Changeset");
552
+ const cwd = await this.workdir(task, [source, changeset]);
553
+ const base = snapshot !== "" && changeset.type !== "attachment" ? (await execaJob("git", ["rev-parse", "--verify", `${source.type === "branch" ? source.name : "HEAD"}^{commit}`], { cwd })).stdout.trim() : "";
554
+ if (changeset.type === "branch") {
555
+ /* check out the changeset branch (created from the source if missing) in a
556
+ worktree of the agent, unless it is already checked out in the working copy
557
+ or an ASE worktree (which then has to be without uncommitted changes, as
558
+ they would be committed onto the branch, too), process the task inside it
559
+ in place, and afterwards commit the result onto the branch (also a partial
560
+ one, as the plan tracks it) and publish it to the origin of a temporarily
561
+ cloned repository, where the worktree of the agent is removed only if clean
562
+ and a branch which could not be published is rescued (as the commit would
563
+ otherwise be lost with the clone, see "settle"), so the change set is not lost */
564
+ let dir = await gitWorktree(cwd, changeset.name);
565
+ const own = dir === "";
566
+ if (own) {
567
+ /* an existing directory (e.g. the worktree of another branch of the same
568
+ name, like "a-b" for "a/b") is never replaced, as it is not the agent's own */
569
+ dir = Worktree.dir(safeName(changeset.name), true, false, cwd);
570
+ if (fs.existsSync(dir))
571
+ throw new Error(`agent: directory "${dir}" for branch "${changeset.name}" already exists ` +
572
+ "-- cannot implement task into it");
573
+ await gitWorktreeAdd(cwd, dir, changeset.name, source.type === "branch" ? source.name : "HEAD");
574
+ }
575
+ else if (!(await gitClean(dir)))
576
+ throw new Error(`agent: working copy "${dir}" with branch "${changeset.name}" checked out has ` +
577
+ "uncommitted changes (e.g. the change set of an earlier task) -- cannot implement task into it");
578
+ return {
579
+ cwd,
580
+ dirs: [],
581
+ sourceDir: "",
582
+ changesetWorktree: worktreeName(cwd, dir),
583
+ ...(snapshot !== "" ? {
584
+ snapshot: async (partial) => this.worktreeSnapshot(dir, base, partial)
585
+ } : {}),
586
+ ...(commit ? {
587
+ finish: async (log) => {
588
+ /* the ASE state (e.g. the local task store) is never committed */
589
+ await execaJob("git", ["add", "--all", "--", ".", ":(exclude).ase"], { cwd: dir });
590
+ if ((await execaJob("git", ["diff", "--cached", "--quiet"], { cwd: dir, reject: false })).exitCode !== 0) {
591
+ const message = `${task.keys.get("Kind") || "CRAFTING"}: ${task.title} (${task.id})`;
592
+ await execaJob("git", ["commit", "--quiet", "-m", message], { cwd: dir });
593
+ await log("info", `change set committed onto branch "${changeset.name}"`);
594
+ }
595
+ else
596
+ await log("warning", `no change set to commit onto branch "${changeset.name}"`);
597
+ await this.publish(cwd, changeset.name, log).catch(async (err) => {
598
+ await this.rescue(cwd, changeset.name, task, log).catch((err2) => log("error", `rescuing branch "${changeset.name}" failed: ` +
599
+ (err2 instanceof Error ? err2.message : String(err2))));
600
+ throw err;
601
+ });
602
+ }
603
+ } : {}),
604
+ cleanup: async (log) => {
605
+ /* a worktree nested inside the working directory of the task in the
606
+ temporary clone of "--repo" is kept only by preserving the clone */
607
+ if (own && await gitClean(dir).catch(() => false))
608
+ await gitWorktreeRemove(cwd, dir);
609
+ else if (own && !(await this.preserve(cwd, task, `worktree "${dir}" with uncommitted change set`, log)))
610
+ await log("warning", `worktree "${dir}" with uncommitted change set kept for a manual recovery`);
611
+ }
612
+ };
613
+ }
614
+ else if (changeset.type === "worktree" && !parallel) {
615
+ /* process into the working copy (held for this task, see "changesetReady") only
616
+ if it still carries no uncommitted changes (except the ASE state), and switch
617
+ the working copy to the source branch */
618
+ if (!(await gitClean(cwd)))
619
+ throw new Error("agent: working copy has uncommitted changes (e.g. the change set of an earlier task) " +
620
+ "-- cannot implement task into it");
621
+ if (source.type === "branch") {
622
+ const { stdout: branch } = await execaJob("git", ["branch", "--show-current"], { cwd });
623
+ if (branch.trim() !== source.name)
624
+ await execaJob("git", ["switch", "--quiet", source.name], { cwd });
625
+ }
626
+ }
627
+ /* the snapshot of a "worktree" changeset is captured from the working copy itself (as
628
+ it was clean before, it carries the change set only), resp. from the ASE worktree of
629
+ the task (created by the skill, so there is no change set if it is missing) */
630
+ const capture = async (partial) => {
631
+ if (changeset.type === "attachment")
632
+ return changeset.name !== snapshot ? "obsolete" : null;
633
+ const dir = parallel ? await contextDir(cwd, task) : cwd;
634
+ return parallel && dir === cwd ? null : this.worktreeSnapshot(dir, base, partial);
635
+ };
636
+ return {
637
+ cwd, dirs: [], sourceDir: "",
638
+ changesetWorktree: changeset.type === "worktree" && parallel ? worktreeId(task.id) : "",
639
+ ...(snapshot !== "" ? { snapshot: capture } : {})
640
+ };
641
+ }
642
+ /* whether a task is ready for the review access to its "Changeset:" (see
643
+ "changesetReadJob"): a task whose uncommitted "worktree" change set is located
644
+ directly in the working copy the agent is started in (and not in an ASE worktree
645
+ of it) is processed (and hence staged) only while the working copy is held for it
646
+ alone (see "holdRoot"), else it is deferred */
647
+ async changesetReadReady(task) {
648
+ let changeset;
649
+ try {
650
+ changeset = taskFlow(this.log, task, "Changeset");
651
+ }
652
+ catch (_err) {
653
+ /* an invalid value fails the processing of the task */
654
+ return true;
655
+ }
656
+ const root = this.localRoot([changeset]);
657
+ if (changeset.type === "worktree" && root !== ""
658
+ && (await contextDir(root, task).catch(() => "")) === root && !this.holdRoot(task, root))
659
+ return this.defer(task, "the working copy used by another task");
660
+ return true;
661
+ }
662
+ /* the work of a job with review access to the "Changeset:" of a task (see
663
+ "changesetReadReady"): a "worktree" changeset staged in its context worktree, a
664
+ "branch:" changeset left in place (to be addressed by its name), and an "attachment:"
665
+ changeset applied staged in a temporary worktree "<id>-<name>-<suffix>", detached at
666
+ the commit it is based on, which is removed afterwards */
667
+ async changesetReadJob(task, suffix) {
668
+ const changeset = taskFlow(this.log, task, "Changeset");
669
+ const root = await this.workdir(task, [changeset]);
670
+ if (changeset.type === "branch")
671
+ return { cwd: root, dirs: [], sourceDir: "", changesetWorktree: "" };
672
+ else if (changeset.type === "attachment") {
673
+ /* materialize the change set attachment in a temporary worktree,
674
+ detached at the commit it is based on, staged for the review */
675
+ const kind = new RegExp(`^text/x-diff\\s*;.*\\bkind\\s*=\\s*"?${changeset.name}"?\\s*(?:;|$)`, "i");
676
+ const attachment = (await Task.attachments(this.log, task.id)).filter((att) => kind.test(att.type)).pop();
677
+ if (attachment?.data === undefined)
678
+ throw new Error(`agent: no change set attachment "${changeset.name}" exists`);
679
+ const base = /\bbase\s+([0-9a-f]+)\b/.exec(attachment.desc)?.[1] ?? "HEAD";
680
+ const dir = Worktree.dir(`${worktreeId(task.id)}-${changeset.name}-${suffix}`, true, true, root);
681
+ await gitWorktreeDetached(root, dir, base);
682
+ try {
683
+ await execaJob("git", ["apply", "--index"], { cwd: dir, input: attachment.data });
684
+ }
685
+ catch (err) {
686
+ await gitWorktreeRemove(root, dir);
687
+ throw err;
688
+ }
689
+ return { cwd: dir, dirs: [], sourceDir: "", changesetWorktree: "", cleanup: () => gitWorktreeRemove(root, dir) };
690
+ }
691
+ else {
692
+ /* stage the uncommitted change set in its context worktree */
693
+ const cwd = await contextDir(root, task);
694
+ await execaJob("git", ["add", "--all", "--", ".", ":(exclude).ase"], { cwd });
695
+ return { cwd, dirs: [], sourceDir: "", changesetWorktree: "" };
696
+ }
697
+ }
698
+ /* whether a task is ready for the integration access to its "Changeset:" and
699
+ "Target:" (see "targetJob"): a task integrated from or into the working copy the
700
+ agent is started in directly (an uncommitted "worktree" change set located in it, a
701
+ "branch:" changeset checked out in it, whose uncommitted changes the skill commits and
702
+ which it rebases in place, a "worktree" target, or a "branch:" target checked out in
703
+ it) is processed only while the working copy is held for it alone (see "holdRoot"),
704
+ and a task with a "branch:" changeset or target is processed only while these branches
705
+ are held for it alone (see "holdBranch"), as another task (e.g. one implemented into
706
+ the same changeset branch, or into this target branch) would otherwise be processed in
707
+ the same worktree at once (mixing their change sets, and merging, rebasing, or removing
708
+ the worktree under each other), else it is deferred */
709
+ async targetReady(task) {
710
+ let changeset;
711
+ let target;
712
+ try {
713
+ changeset = taskFlow(this.log, task, "Changeset");
714
+ target = taskFlow(this.log, task, "Target");
715
+ }
716
+ catch (_err) {
717
+ /* an invalid value fails the processing of the task */
718
+ return true;
719
+ }
720
+ const root = this.localRoot([changeset, target]);
721
+ if (changeset.type === "branch" && !this.holdBranch(task, root, changeset.name))
722
+ return this.defer(task, `the changeset branch "${changeset.name}" used by another task`);
723
+ else if (target.type === "branch" && !this.holdBranch(task, root, target.name))
724
+ return this.defer(task, `the target branch "${target.name}" used by another task`);
725
+ else if (root === "")
726
+ return true;
727
+ const direct = target.type === "worktree"
728
+ || (changeset.type === "worktree" && (await contextDir(root, task).catch(() => "")) === root)
729
+ || (changeset.type === "branch" && (await gitWorktree(root, changeset.name).catch(() => "")) === root)
730
+ || (target.type === "branch" && (await gitWorktree(root, target.name).catch(() => "")) === root);
731
+ if (direct && !this.holdRoot(task, root))
732
+ return this.defer(task, "the working copy used by another task");
733
+ return true;
734
+ }
735
+ /* the work of a job with integration access to the "Changeset:" and "Target:" of a task
736
+ (see "targetReady"), in the working copy itself, granting access to the context worktree
737
+ of a "worktree" changeset (whose ASE worktree id the skill selects) and to the worktrees in
738
+ which the changeset and target branches are checked out, where a target branch not checked
739
+ out anywhere yet is checked out in a temporary worktree "<id>-<suffix>", and where the
740
+ change set snapshot of the change set which finally landed on the target branch is
741
+ captured on request (for its attachment of the kind <snapshot>, else empty) */
742
+ async targetJob(task, suffix, snapshot) {
743
+ const changeset = taskFlow(this.log, task, "Changeset");
744
+ const target = taskFlow(this.log, task, "Target");
745
+ const root = await this.workdir(task, [changeset, target]);
746
+ const dirs = [];
747
+ let context = "";
748
+ if (changeset.type === "worktree") {
749
+ const dir = await contextDir(root, task);
750
+ context = worktreeName(root, dir);
751
+ dirs.push(dir);
752
+ }
753
+ else if (changeset.type === "branch")
754
+ dirs.push(await gitWorktree(root, changeset.name));
755
+ /* check out a target branch not checked out anywhere yet in a temporary
756
+ worktree, into which the merge commits, and afterwards publish the
757
+ target branch to the origin of a temporarily cloned repository (deleting
758
+ there a changeset branch which the merge deleted) and remove the
759
+ temporary worktree again in any case, where a target branch not moved by
760
+ the skill is not published (as it carries nothing to publish, but might be
761
+ outdated by the origin meanwhile, whose rejected push would then needlessly
762
+ fail the task and preserve the clone), a target branch which could
763
+ not be published is rescued (as the skill already recorded the task as
764
+ integrated, see "settle"), so its merge is not lost, while a changeset
765
+ branch which could not be deleted is only reported (as the merge itself
766
+ was already published) */
767
+ const tip = async (branch) => (await execaJob("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], { cwd: root, reject: false })).stdout.trim();
768
+ let temp = "";
769
+ let base = "";
770
+ if (target.type === "branch") {
771
+ let dir = await gitWorktree(root, target.name);
772
+ if (dir === "") {
773
+ temp = Worktree.dir(`${worktreeId(task.id)}-${suffix}`, true, true, root);
774
+ await gitWorktreeAdd(root, temp, target.name);
775
+ dir = temp;
776
+ }
777
+ dirs.push(dir);
778
+ base = await tip(target.name);
779
+ }
780
+ /* the snapshot of a "worktree" or "branch:" changeset is the change of the target
781
+ branch (a "worktree" target being the branch checked out in the working copy) by
782
+ its integration, unless the task was not integrated or its content did not change
783
+ (as the change set already landed earlier) */
784
+ const branch = snapshot === "" || changeset.type === "attachment" ? "" : target.type === "branch" ? target.name :
785
+ (await execaJob("git", ["branch", "--show-current"], { cwd: root })).stdout.trim();
786
+ const before = branch !== "" ? await tip(branch) : "";
787
+ const capture = async (partial) => {
788
+ const after = await tip(branch);
789
+ if (partial || after === "" || after === before)
790
+ return null;
791
+ const tree = async (commit) => (await execaJob("git", ["rev-parse", `${commit}^{tree}`], { cwd: root })).stdout.trim();
792
+ if (await tree(after) === await tree(before))
793
+ return null;
794
+ const { base: fork, diff } = await gitChangesetDiff(root, before, after);
795
+ return diff !== "" ? { desc: `base ${fork}, merged ${after}`, diff } : null;
796
+ };
797
+ return {
798
+ cwd: root,
799
+ dirs: dirs.filter((dir) => dir !== ""),
800
+ sourceDir: "",
801
+ changesetWorktree: context,
802
+ ...(before !== "" ? { snapshot: capture } : {}),
803
+ finish: async (log) => {
804
+ const reason = (err) => err instanceof Error ? err.message : String(err);
805
+ if (target.type === "branch" && await tip(target.name) === base)
806
+ await log("info", `target branch "${target.name}" unchanged -- nothing to publish`);
807
+ else if (target.type === "branch")
808
+ await this.publish(root, target.name, log).catch(async (err) => {
809
+ await this.rescue(root, target.name, task, log).catch((err2) => log("error", `rescuing branch "${target.name}" failed: ${reason(err2)}`));
810
+ throw err;
811
+ });
812
+ if (changeset.type === "branch")
813
+ await this.unpublish(root, changeset.name, log).catch((err) => log("warning", `changeset branch "${changeset.name}" not deleted from origin: ${reason(err)}`));
814
+ },
815
+ cleanup: async () => {
816
+ if (temp !== "")
817
+ await gitWorktreeRemove(root, temp);
818
+ }
819
+ };
820
+ }
821
+ /* write a processing log line in the style of the regular logger to stdout (each
822
+ line of a multi-line message as a full log line, with the line prefix of the logger,
823
+ e.g. of an agent fleet member, and with the level tag styled on a terminal only),
824
+ optionally collecting it for the processing log attachment of a task,
825
+ and record it (of the task, or of the agent itself) in the presence of the agent */
826
+ async write(level, prefix, msg, lines, task = "") {
827
+ const timestamp = DateTime.now().toFormat("yyyy-LL-dd HH:mm:ss.SSS");
828
+ lines?.push(`ase: [${timestamp}]: [${level.toUpperCase()}]: ${prefix}: ${msg}`);
829
+ this.presence?.line(level, task, msg);
830
+ const head = `${this.log.prefix()}ase: [${timestamp}]: ${this.log.tag(level, process.stdout.isTTY)}: ${prefix}: `;
831
+ await writeStdout(msg.split("\n").map((l) => `${head}${l}\n`).join(""));
832
+ }
833
+ /* run the skill of a prepared job via the headless agent tool, logging its decoded JSONL
834
+ events in real-time and collecting its agent message text lines as the <output> (without
835
+ buffering them, as the output of the agent tool can be huge), where the quiet entries are
836
+ only echoed (i.e., not kept in the processing log attachment of the task), and return its success */
837
+ async invoke(taskId, job, abort, log, echo, output) {
838
+ const { tool, timeout, timeoutSpec } = this;
839
+ const { cwd } = job;
840
+ const cli = toolSpecs[tool].cli;
841
+ const cmd = job.cmd ?? "";
842
+ const i = cmd.indexOf(" ");
843
+ const args = toolArgs[tool](job.prompt ?? skillPrompt(tool, i < 0 ? cmd : cmd.slice(0, i), i < 0 ? "" : cmd.slice(i + 1)), (job.dirs ?? [])
844
+ .filter((dir, i, dirs) => dir !== cwd && dirs.indexOf(dir) === i)
845
+ .flatMap((dir) => ["--add-dir", dir]));
846
+ await log("info", `running: ${cli} ${args.map((arg) => arg.includes(" ") ? `"${arg}"` : arg).join(" ")}`);
847
+ const started = Date.now();
848
+ const costs = { costToken: null, costUSD: null, cread: 0, cwrite: 0, input: 0, apiMs: null };
849
+ const subprocess = execa(cli, args, {
850
+ cwd,
851
+ env: { ASE_HEADLESS: "true" },
852
+ stdin: "ignore",
853
+ all: true,
854
+ buffer: false,
855
+ reject: false,
856
+ timeout,
857
+ cancelSignal: abort,
858
+ forceKillAfterDelay: 10 * 1000
859
+ });
860
+ for await (const line of readline.createInterface({ input: subprocess.all })) {
861
+ for (const entry of toolEntries(tool, line, costs)) {
862
+ if (entry.text === true)
863
+ output.push(entry.msg);
864
+ await (entry.quiet === true ? echo : log)(entry.level, `${cli}: ${entry.msg}`);
865
+ }
866
+ }
867
+ const result = await subprocess;
868
+ /* report the termination of the agent tool */
869
+ if (abort.aborted)
870
+ await log("warning", `${cli} aborted by interrupt`);
871
+ else if (result.timedOut)
872
+ await log("warning", `${cli} timed out after ${timeoutSpec}`);
873
+ else if (result.exitCode === undefined)
874
+ await log("warning", `${cli} failed: ${result.shortMessage}`);
875
+ else
876
+ await log(result.exitCode === 0 ? "info" : "warning", `${cli} finished with exit code ${result.exitCode}`);
877
+ /* report the usage of the agent tool, with its wall time measured locally */
878
+ const walltimeMs = Date.now() - started;
879
+ const usage = { walltimeSec: Math.round(walltimeMs / 1000), costToken: costs.costToken, costUSD: costs.costUSD };
880
+ await log("info", `${cli} usage: walltime: ${usage.walltimeSec}s, ` +
881
+ `tokens: ${usage.costToken ?? "n/a"}, ` +
882
+ `cost: ${usage.costUSD !== null ? `$${usage.costUSD.toFixed(2)}` : "n/a"}`);
883
+ this.presence?.used(usage);
884
+ /* book the usage onto the task, if the AI usage accounting is enabled ("telemetry.enabled"),
885
+ (the AI request time falling back to the wall time and an unreported cost to the price
886
+ table entry of the tool, as its model is unknown), without any human time and costs,
887
+ as the headless agent tool runs unattended; the times are booked in rounded minutes,
888
+ with the (signed) rounding remainder carried over to the next booking onto the task,
889
+ where a failed booking is only reported */
890
+ const ok = !abort.aborted && !result.timedOut && result.exitCode === 0;
891
+ try {
892
+ if (!telemetryEnabled(this.log))
893
+ return ok;
894
+ const tokens = { cread: costs.cread, cwrite: costs.cwrite, input: costs.input, output: costs.costToken ?? 0 };
895
+ let cost = costs.costUSD;
896
+ if (cost === null) {
897
+ const cfg = new Config("config", configSchema, this.log);
898
+ cfg.read();
899
+ cost = tokenCost(modelPrice(parsePrices(String(cfg.get("telemetry.prices.ai") ?? "")), tool), tokens);
900
+ }
901
+ const carry = this.unbooked.get(taskId) ?? { aiMs: 0, realMs: 0 };
902
+ const aiMs = carry.aiMs + (costs.apiMs ?? walltimeMs);
903
+ const realMs = carry.realMs + walltimeMs;
904
+ const timeAI = Math.max(0, Math.round(aiMs / (60 * 1000)));
905
+ const timeReal = Math.max(0, Math.round(realMs / (60 * 1000)));
906
+ this.unbooked.set(taskId, { aiMs: aiMs - timeAI * 60 * 1000, realMs: realMs - timeReal * 60 * 1000 });
907
+ await Task.addUsage(this.log, taskId, {
908
+ "time.ai": timeAI,
909
+ "time.real": timeReal,
910
+ "token.cread": tokens.cread,
911
+ "token.cwrite": tokens.cwrite,
912
+ "token.input": tokens.input,
913
+ "token.output": tokens.output,
914
+ "cost.ai": Number(cost.toFixed(4))
915
+ });
916
+ }
917
+ catch (err) {
918
+ await log("warning", `usage not booked onto task: ${err instanceof Error ? err.message : String(err)}`);
919
+ }
920
+ return ok;
921
+ }
922
+ /* process a claimed task via the skill of the headless agent tool in a worker slot
923
+ and afterwards settle it; finally the processing log is appended to the
924
+ attachment of the task */
925
+ async handle(task, claimed, slot, abort) {
926
+ const lines = [];
927
+ const prefix = `${this.name}[${slot}]: ${task.id}`;
928
+ const log = (level, msg) => this.write(level, prefix, msg, lines, task.id);
929
+ const echo = (level, msg) => this.write(level, prefix, msg, undefined, task.id);
930
+ /* a timed out preparing, which keeps running, releases the resources it still
931
+ creates (e.g. worktrees) and the resources of the task only once it ended */
932
+ let late = null;
933
+ try {
934
+ await log("info", claimed);
935
+ /* prepare the job and run its skill via the headless agent tool */
936
+ const output = [];
937
+ let ok = false;
938
+ let job = null;
939
+ try {
940
+ job = await withinJobTimeout("preparing the job", () => this.prepare(task, this.parallelized, log), (pending) => {
941
+ late = pending
942
+ .then((orphan) => orphan.cleanup?.(log))
943
+ .catch(() => { });
944
+ });
945
+ ok = await this.invoke(task.id, job, abort, log, echo, output);
946
+ }
947
+ catch (err) {
948
+ await log("error", err instanceof Error ? err.message : String(err));
949
+ }
950
+ /* finish the job independent of the success of the skill,
951
+ where a failure overrides even a success of the skill */
952
+ let finished = true;
953
+ const finish = job?.finish;
954
+ if (finish !== undefined) {
955
+ try {
956
+ await withinJobTimeout("finishing the job", () => finish(log, { ok, output }));
957
+ }
958
+ catch (err) {
959
+ await log("error", err instanceof Error ? err.message : String(err));
960
+ ok = false;
961
+ finished = false;
962
+ }
963
+ }
964
+ /* clean up the job in any case (a failure is reported only) */
965
+ const cleanup = job?.cleanup;
966
+ if (cleanup !== undefined) {
967
+ try {
968
+ await withinJobTimeout("cleaning up the job", () => cleanup(log));
969
+ }
970
+ catch (err) {
971
+ await log("warning", err instanceof Error ? err.message : String(err));
972
+ }
973
+ }
974
+ /* settle the task by the results of the skill invocation and the job */
975
+ await this.settle(task, ok, finished, abort.aborted, output, log);
976
+ }
977
+ catch (err) {
978
+ await log("error", err instanceof Error ? err.message : String(err));
979
+ }
980
+ finally {
981
+ /* release the resources of the task (after a timed out preparing ended) */
982
+ const pending = late;
983
+ if (pending !== null)
984
+ pending.finally(() => this.unbind(task.id)).catch(() => { });
985
+ else
986
+ await this.unbind(task.id);
987
+ }
988
+ /* append the processing log to the attachment of the task */
989
+ const now = DateTime.now().toFormat("yyyy-LL-dd HH:mm");
990
+ try {
991
+ const attached = await Task.attach(this.log, task.id, {
992
+ Type: "text/plain; charset=utf-8; kind=\"agent\"",
993
+ Created: now, Modified: now, Data: `${lines.join("\n")}\n`
994
+ }, true);
995
+ if (attached)
996
+ await this.write("info", prefix, "processing log attached to task", undefined, task.id);
997
+ else
998
+ await this.write("warning", prefix, "task vanished -- processing log not attached", undefined, task.id);
999
+ }
1000
+ catch (err) {
1001
+ await this.write("error", prefix, `processing log not attached: ${err instanceof Error ? err.message : String(err)}`, undefined, task.id);
1002
+ }
1003
+ }
1004
+ /* the per-agent CLI command "start", parsing the options of the agent, where the
1005
+ options shared by all agents carry no defaults, as they fall back to those
1006
+ given to "setup" (see "ase agent <kind> start" and "ase agent fleet start") */
1007
+ command() {
1008
+ if (this.cmd === null) {
1009
+ this.cmd = new Command("start")
1010
+ .description(this.description)
1011
+ .exitOverride()
1012
+ .option("-n, --name <name>", "name of the agent in its log lines, " +
1013
+ `the "Assignee:" of its tasks, and its process files (default: "${this.kind}")`)
1014
+ .option("-t, --tool <tool>", "agent tool (\"claude\", \"copilot\", or \"codex\"; " +
1015
+ "default: $ASE_TOOL, else \"claude\")")
1016
+ .option("-p, --parallel <num>", "maximum number of tasks processed in parallel (default: 1)")
1017
+ .option("-T, --timeout <duration>", "maximum duration of processing a task " +
1018
+ "as <number><unit> with unit s, m, or h (default: 60m)")
1019
+ .option("-o, --one-shot", "terminate once all dispatchable incoming tasks are processed")
1020
+ .option("--agent-relay <url>", "agent relay to pass the live presence of the agent to, " +
1021
+ "as \"ase[s]://<addr>:<port>[/<token>]\" (default: the configured \"project.relay.url\", else none)");
1022
+ if (this.worktree)
1023
+ this.cmd
1024
+ .option("-r, --repo <url>", "Git repository to clone temporarily for the tasks " +
1025
+ "without \"worktree\" values (default: the Git working copy the agent is started in)")
1026
+ .option("-d, --tempdir <dir>", "directory for the temporary files like the clone of \"--repo\", " +
1027
+ "with \"%p\" expanded to the process id and \"%h\" to a hash value (default: a fresh, private one)");
1028
+ this.options(this.cmd);
1029
+ }
1030
+ return this.cmd;
1031
+ }
1032
+ /* validate the own options of the agent ahead of its setup (e.g. before detaching it),
1033
+ throwing on an invalid one */
1034
+ async validate(_own) { }
1035
+ /* configure the agent from the default options, overridden by its own options,
1036
+ validate its configuration, and initialize it, where <relay> is the client
1037
+ of the agent relay to announce the presence of the agent to (if any) */
1038
+ async setup(global, own, relay = null) {
1039
+ const opts = {
1040
+ ...global,
1041
+ ...Object.fromEntries(Object.entries(own).filter((entry) => entry[1] !== undefined))
1042
+ };
1043
+ this._name = opts.name ?? "";
1044
+ if (this._name !== "" && !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(this._name))
1045
+ throw new Error(`agent: invalid name "${this._name}" (expected letters, digits, "_", ".", or "-")`);
1046
+ this.relay = relay;
1047
+ this.tool = parseTool(opts.tool);
1048
+ for (const cli of [toolSpecs[this.tool].cli, ...(this.worktree ? ["git"] : [])])
1049
+ await which(cli).catch(() => {
1050
+ throw new Error(`agent: ${this.name} requires "${cli}", which was not found in $PATH`);
1051
+ });
1052
+ if (this.worktree) {
1053
+ /* the "worktree" values of the change set flow keys refer to the Git working
1054
+ copy the agent is started in, which hence is mandatory without "--repo",
1055
+ while with "--repo" the skills run inside its temporary clone and hence
1056
+ can only reach a task store not located inside the working copy */
1057
+ this.repo = opts.repo ?? "";
1058
+ this.tempdir = opts.tempdir ?? "";
1059
+ if (this.repo === "" && !(await gitInside()))
1060
+ throw new Error(`agent: ${this.name} has to be started inside a Git working copy (or be given "--repo")`);
1061
+ else if (this.repo !== "" && Task.isLocal(this.log))
1062
+ throw new Error(`agent: ${this.name} with "--repo" requires a non-local task store ` +
1063
+ "(project uses a local \"ase:<path>\" one)");
1064
+ }
1065
+ if (!/^\d+$/.test(opts.parallel) || Number.parseInt(opts.parallel, 10) < 1)
1066
+ throw new Error(`agent: invalid number of parallel workers "${opts.parallel}" (expected a positive integer)`);
1067
+ this.parallel = Number.parseInt(opts.parallel, 10);
1068
+ if (this.serial && this.parallel > 1) {
1069
+ if (own.parallel !== undefined)
1070
+ await this.write("warning", this.name, `processing tasks serially only -- ignoring "--parallel ${opts.parallel}"`);
1071
+ this.parallel = 1;
1072
+ }
1073
+ this.timeoutSpec = opts.timeout;
1074
+ this.timeout = parseDuration(opts.timeout);
1075
+ this.oneShot = opts.oneShot;
1076
+ this.lifecycle = await Task.lifecycle(this.log);
1077
+ if (!this.lifecycles.includes(this.lifecycle.name))
1078
+ throw new Error(`agent: ${this.name} requires the ` +
1079
+ `${this.lifecycles.map((name) => `"${name}"`).join(" or ")} task lifecycle model ` +
1080
+ `(project uses "${this.lifecycle.name}")`);
1081
+ await this.init(opts);
1082
+ }
1083
+ /* clone the repository of "--repo" temporarily, with its default
1084
+ branch detached, so all branches can be refreshed from its origin */
1085
+ async cloneRepo() {
1086
+ this.temp = await tempDir(this.name, this.tempdir);
1087
+ this.clone = path.join(this.temp.dir, "clone");
1088
+ await execaJob("git", ["clone", "--quiet", this.repo, this.clone]);
1089
+ this.head = (await execaJob("git", ["branch", "--show-current"], { cwd: this.clone })).stdout.trim();
1090
+ await execaJob("git", ["checkout", "--quiet", "--detach"], { cwd: this.clone });
1091
+ await this.write("info", this.name, `cloned "${this.repo}" temporarily (default branch "${this.head}")`);
1092
+ }
1093
+ /* remove the temporary directory (if any), unless it holds work preserved for a manual recovery */
1094
+ async removeTemp() {
1095
+ if (this.temp === null)
1096
+ return;
1097
+ if (this.preserved)
1098
+ await this.write("error", this.name, `temporary directory "${this.temp.dir}" preserved ` +
1099
+ `for a manual recovery (see "${path.join(this.temp.dir, tempPreserved)}")`);
1100
+ else {
1101
+ await fs.promises.rm(this.temp.dir, { recursive: true, force: true });
1102
+ if (this.temp.base !== "")
1103
+ await fs.promises.rmdir(this.temp.base).catch(() => { });
1104
+ }
1105
+ this.temp = null;
1106
+ this.clone = "";
1107
+ }
1108
+ /* claim all dispatchable tasks in natural id order (as reordered by "order") into free worker slots,
1109
+ reporting the waiting and the noticed tasks once per change of their reason resp. notice */
1110
+ async dispatch(state) {
1111
+ const { slots, running, abort, notify } = state;
1112
+ const { items } = await Task.listHeaders(this.log);
1113
+ const selected = this.select(items, new Set(slots.values()));
1114
+ const tasks = selected.tasks;
1115
+ const notices = selected.notices ?? new Map();
1116
+ for (const [id, notice] of notices)
1117
+ if (state.notices.get(id) !== notice)
1118
+ await this.write("warning", this.name, `task "${id}" ${notice}`);
1119
+ state.notices = notices;
1120
+ for (const [id, reason] of selected.waiting)
1121
+ if (state.waiting.get(id) !== reason)
1122
+ await this.write("info", this.name, `task "${id}" waits for ${reason}`);
1123
+ state.waiting = selected.waiting;
1124
+ tasks.sort((a, b) => TaskFormat.compareIds(a.id, b.id));
1125
+ for (const task of await this.order(tasks)) {
1126
+ if (slots.size >= this.parallel || abort.signal.aborted)
1127
+ break;
1128
+ let claimed = null;
1129
+ try {
1130
+ /* a task not ready for the agent (e.g. one already carrying the result of
1131
+ a previous processing, or one deferred) is skipped, where the resources
1132
+ held for a task not claimed (e.g. its working copy) are released again */
1133
+ if (await this.ready(task))
1134
+ claimed = await this.claim(task);
1135
+ }
1136
+ catch (err) {
1137
+ await this.write("warning", this.name, `task "${task.id}" not claimed: ${err instanceof Error ? err.message : String(err)}`);
1138
+ }
1139
+ if (claimed === null) {
1140
+ this.unhold(task.id);
1141
+ continue;
1142
+ }
1143
+ this.deferred.delete(task.id);
1144
+ let slot = 1;
1145
+ while (slots.has(slot))
1146
+ slot++;
1147
+ slots.set(slot, task.id);
1148
+ busyAgents.add(this);
1149
+ this.presence?.busy([...slots.values()]);
1150
+ /* a task is processed in parallel to others also by the peer agents of its kind,
1151
+ where an agent becoming idle wakes up all agents of this process, as its
1152
+ processed task can have become dispatchable to another one */
1153
+ const job = this.handle(task, claimed, slot, abort.signal)
1154
+ .finally(() => {
1155
+ slots.delete(slot);
1156
+ this.presence?.busy([...slots.values()]);
1157
+ running.delete(job);
1158
+ notify();
1159
+ if (slots.size === 0) {
1160
+ if (!state.dispatching)
1161
+ busyAgents.delete(this);
1162
+ for (const wakeAgent of wakeAgents)
1163
+ wakeAgent();
1164
+ }
1165
+ });
1166
+ running.add(job);
1167
+ }
1168
+ }
1169
+ /* observe the tasks and dispatch each selected one (see "select"), after
1170
+ claiming it (see "claim"), to a free worker slot, until interrupted (or,
1171
+ in one-shot mode, until no task is dispatchable and no task is processed anymore) */
1172
+ async run() {
1173
+ const { tool, parallel } = this;
1174
+ /* the worker slots (slot number to task id), their jobs, and the wake-up
1175
+ of the dispatcher on task store changes, freed slots, and interrupts */
1176
+ const slots = new Map();
1177
+ const running = new Set();
1178
+ const abort = new AbortController();
1179
+ let dirty = true;
1180
+ let wake = () => { };
1181
+ const notify = () => {
1182
+ dirty = true;
1183
+ wake();
1184
+ };
1185
+ this.interruption = abort.signal;
1186
+ const interrupt = (signal) => {
1187
+ if (abort.signal.aborted)
1188
+ return;
1189
+ abort.abort();
1190
+ const write = running.size > 0 ?
1191
+ this.write("warning", this.name, `interrupted (received ${signal}) -- aborting ${running.size} running processing(s)`) :
1192
+ this.write("info", this.name, `interrupted (received ${signal})`);
1193
+ write.catch(() => { });
1194
+ notify();
1195
+ };
1196
+ /* a repeated terminating signal terminates the process at once (instead of
1197
+ awaiting the aborted processings and their cleanups, which can take minutes),
1198
+ leaving the claimed tasks to the recovery by a later run of an agent of its kind */
1199
+ const signalled = (signal) => {
1200
+ if (abort.signal.aborted) {
1201
+ this.log.write("warning", `agent: ${this.name}: interrupted again (received ${signal}) -- terminating immediately`);
1202
+ process.exit(128 + (os.constants.signals[signal] ?? 0));
1203
+ }
1204
+ interrupt(signal);
1205
+ };
1206
+ /* the state of the run shared with its dispatching */
1207
+ const state = { slots, running, abort, waiting: new Map(), notices: new Map(), dispatching: false, notify };
1208
+ /* dispatch on every change until interrupted (or, in one-shot mode, until a
1209
+ dispatching leaves all worker slots free while all agents of this process
1210
+ are idle), then await the aborted jobs, where the interrupt is triggered by
1211
+ all terminating signals which can be caught, so no claimed task keeps its "active" tag */
1212
+ process.on("SIGINT", signalled);
1213
+ process.on("SIGTERM", signalled);
1214
+ process.on("SIGHUP", signalled);
1215
+ process.on("SIGQUIT", signalled);
1216
+ wakeAgents.add(notify);
1217
+ this.wakeup = notify;
1218
+ const unsubscribe = Task.subscribe(this.log, notify);
1219
+ /* announce the presence of the agent to the agent relay (if any) while it runs */
1220
+ if (this.relay !== null) {
1221
+ this.presence = new AgentPresenceReporter(this.log, this.relay, this.kind, this.name, this.mode, this.presenceRequest, this.presencePrompt, this.presenceStates, this.presenceTitle, this.presenceDesc);
1222
+ this.presence.start();
1223
+ }
1224
+ /* a failed dispatching (e.g. on a transient failure of the task store) is retried
1225
+ after a growing delay, but in one-shot mode only a limited number of times in a row,
1226
+ after which the agent fails (instead of terminating as if no task were dispatchable) */
1227
+ const retries = 3;
1228
+ let failures = 0;
1229
+ let retry;
1230
+ /* an agent counts as busy for the termination of the peer agents of this process in
1231
+ one-shot mode not only while processing tasks, but also while starting up and while
1232
+ dispatching (where it might still claim a task), and an agent becoming idle after a
1233
+ dispatching wakes up all other agents of this process waiting for it to re-check
1234
+ their termination (but not itself, as it re-checks it right away) */
1235
+ busyAgents.add(this);
1236
+ const dispatched = () => {
1237
+ state.dispatching = false;
1238
+ if (slots.size > 0)
1239
+ return false;
1240
+ if (busyAgents.delete(this) && this.oneShot)
1241
+ for (const wakeAgent of wakeAgents)
1242
+ if (wakeAgent !== notify)
1243
+ wakeAgent();
1244
+ return this.oneShot && busyAgents.size === 0;
1245
+ };
1246
+ try {
1247
+ /* acquire the run lock of the agent and recover the tasks
1248
+ left behind by terminated runs of agents of its kind */
1249
+ await this.runLock.acquire();
1250
+ await this.recover().catch((err) => this.write("warning", this.name, `recovery failed: ${err instanceof Error ? err.message : String(err)}`));
1251
+ /* clone the repository of "--repo" temporarily */
1252
+ if (this.repo !== "")
1253
+ await this.cloneRepo();
1254
+ await this.write("info", this.name, `observing ${this.observing()} ` +
1255
+ `(tool: ${tool}, parallel: ${parallel}, timeout: ${this.timeoutSpec}${this.oneShot ? ", one-shot" : ""})`);
1256
+ let finished = false;
1257
+ let failure = null;
1258
+ while (!abort.signal.aborted && !finished) {
1259
+ if (dirty && slots.size < parallel) {
1260
+ dirty = false;
1261
+ state.dispatching = true;
1262
+ busyAgents.add(this);
1263
+ try {
1264
+ await this.dispatch(state);
1265
+ failures = 0;
1266
+ finished = dispatched();
1267
+ }
1268
+ catch (err) {
1269
+ dispatched();
1270
+ failures++;
1271
+ await this.write("error", this.name, `dispatching failed (${failures}. time): ` +
1272
+ `${err instanceof Error ? err.message : String(err)}`);
1273
+ if (this.oneShot && failures >= retries) {
1274
+ failure = err;
1275
+ break;
1276
+ }
1277
+ clearTimeout(retry);
1278
+ retry = setTimeout(notify, Math.min(failures, 30) * 1000);
1279
+ }
1280
+ }
1281
+ else
1282
+ await new Promise((resolve) => { wake = resolve; });
1283
+ }
1284
+ await Promise.all(running);
1285
+ if (failure !== null)
1286
+ throw new Error(`agent: ${this.name}: dispatching failed ${failures} times in a row -- giving up ` +
1287
+ `(${failure instanceof Error ? failure.message : String(failure)})`);
1288
+ await this.write("info", this.name, finished ? "no more dispatchable tasks -- terminated" : "terminated");
1289
+ }
1290
+ finally {
1291
+ clearTimeout(retry);
1292
+ unsubscribe();
1293
+ await this.presence?.stop();
1294
+ this.presence = null;
1295
+ process.off("SIGINT", signalled);
1296
+ process.off("SIGTERM", signalled);
1297
+ process.off("SIGHUP", signalled);
1298
+ process.off("SIGQUIT", signalled);
1299
+ /* let the agents of this process waiting for this one re-check their termination */
1300
+ this.wakeup = null;
1301
+ wakeAgents.delete(notify);
1302
+ busyAgents.delete(this);
1303
+ for (const wakeAgent of wakeAgents)
1304
+ wakeAgent();
1305
+ /* retry the failed finalizations a last time (those still failing are
1306
+ recovered by a later run, as the run lock is retained for them) */
1307
+ if (this.unsettled.size > 0)
1308
+ await this.retryUnsettled();
1309
+ /* remove the temporary directory (if any) */
1310
+ await this.removeTemp().catch((err) => this.write("warning", this.name, `temporary directory not removed: ${err instanceof Error ? err.message : String(err)}`));
1311
+ await this.teardown();
1312
+ if (!this.retainRun())
1313
+ await this.runLock.release();
1314
+ }
1315
+ }
1316
+ }