@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.
- package/dst/ase-agent-definition-api.d.ts +184 -0
- package/dst/ase-agent-definition-api.js +6 -0
- package/dst/ase-agent-definition-builtin/condenser.js +42 -0
- package/dst/ase-agent-definition-builtin/editor.js +45 -0
- package/dst/ase-agent-definition-builtin/estimator.js +98 -0
- package/dst/ase-agent-definition-builtin/implementer.js +121 -0
- package/dst/ase-agent-definition-builtin/integrator.js +56 -0
- package/dst/ase-agent-definition-builtin/qualifier.js +61 -0
- package/dst/ase-agent-definition-builtin/rebooter.js +41 -0
- package/dst/ase-agent-definition-builtin/researcher.js +77 -0
- package/dst/ase-agent-definition-core.js +827 -0
- package/dst/ase-agent-fleet.js +772 -0
- package/dst/ase-agent-process.js +232 -0
- package/dst/ase-agent-relay-client.js +509 -0
- package/dst/ase-agent-relay-core.js +213 -0
- package/dst/ase-agent-relay-server-bind.js +242 -0
- package/dst/ase-agent-relay-server-cli.js +50 -0
- package/dst/ase-agent-runtime-claim.js +189 -0
- package/dst/ase-agent-runtime-engine.js +1100 -0
- package/dst/ase-agent-runtime-exec.js +275 -0
- package/dst/ase-agent-runtime-phase.js +581 -0
- package/dst/ase-agent-runtime-request.js +272 -0
- package/dst/ase-agent-runtime-tool.js +207 -0
- package/dst/ase-agent-runtime.js +1316 -0
- package/dst/ase-agent.js +65 -0
- package/dst/ase-artifact.js +6 -5
- package/dst/ase-config-core.js +465 -0
- package/dst/ase-config-schema.js +351 -0
- package/dst/ase-config-scope.js +172 -0
- package/dst/ase-config.js +25 -585
- package/dst/ase-hook.js +230 -116
- package/dst/ase-lib-daemon-cli.js +130 -0
- package/dst/ase-lib-hash.js +20 -0
- package/dst/{ase-log.js → ase-lib-log.js} +40 -7
- package/dst/ase-lib-server-bind.js +115 -0
- package/dst/ase-lib-server-cli.js +474 -0
- package/dst/{ase-table.js → ase-lib-table.js} +1 -1
- package/dst/{ase-version.js → ase-lib-version.js} +3 -0
- package/dst/ase-man.js +96 -0
- package/dst/ase-mcp.js +166 -13
- package/dst/{ase-getopt.js → ase-service-getopt.js} +82 -80
- package/dst/{ase-markdown.js → ase-service-markdown.js} +18 -11
- package/dst/ase-service-otel.js +1246 -0
- package/dst/{ase-skills.js → ase-service-skills.js} +29 -64
- package/dst/ase-service-state.js +101 -0
- package/dst/ase-service.js +251 -76
- package/dst/ase-setup-common.js +141 -0
- package/dst/ase-setup-mcp.js +359 -0
- package/dst/ase-setup-settings.js +837 -0
- package/dst/ase-setup.js +113 -869
- package/dst/ase-spec.js +31 -19
- package/dst/ase-statusline.js +108 -43
- package/dst/ase-task-board-core.js +739 -0
- package/dst/ase-task-board-filter.js +127 -0
- package/dst/ase-task-board-flow.js +237 -0
- package/dst/ase-task-board-graph.js +497 -0
- package/dst/ase-task-board-stats.js +263 -0
- package/dst/ase-task-board-tui-control.js +775 -0
- package/dst/ase-task-board-tui-model.js +1008 -0
- package/dst/ase-task-board-tui-popup.js +626 -0
- package/dst/ase-task-board-tui-style.js +106 -0
- package/dst/ase-task-board-tui-view.js +847 -0
- package/dst/ase-task-board-tui.js +189 -0
- package/dst/ase-task-board-web-client.css +1 -0
- package/dst/ase-task-board-web-client.html +20 -0
- package/dst/ase-task-board-web-client.js +81 -0
- package/dst/ase-task-board-web-server.js +823 -0
- package/dst/ase-task-board.js +134 -0
- package/dst/ase-task-format.js +757 -0
- package/dst/ase-task-request.js +208 -0
- package/dst/ase-task-store-client.js +495 -0
- package/dst/ase-task-store-core.js +804 -0
- package/dst/ase-task-store-issues.js +237 -0
- package/dst/ase-task-store-plugin-api.js +6 -0
- package/dst/ase-task-store-plugin-ase.js +484 -0
- package/dst/ase-task-store-plugin-delegate.js +137 -0
- package/dst/ase-task-store-plugin-gitea.js +666 -0
- package/dst/ase-task-store-plugin-github.js +696 -0
- package/dst/ase-task-store-plugin-gitlab.js +668 -0
- package/dst/ase-task-store-server-bind.js +538 -0
- package/dst/ase-task-store-server-cli.js +154 -0
- package/dst/ase-task.js +1335 -357
- package/dst/{ase-compat.js → ase-util-compat.js} +2 -2
- package/dst/{ase-diagram.js → ase-util-diagram.js} +107 -32
- package/dst/{ase-meta.js → ase-util-meta.js} +2 -2
- package/dst/{ase-metric.js → ase-util-metric.js} +2 -2
- package/dst/{ase-mint.js → ase-util-mint.js} +19 -12
- package/dst/{ase-worktree.js → ase-util-worktree.js} +30 -19
- package/dst/ase-util.js +20 -0
- package/dst/ase.js +108 -32
- package/dst/man/ase-agent-adhoc.1 +515 -0
- package/dst/man/ase-agent-condenser.1 +193 -0
- package/dst/man/ase-agent-editor.1 +212 -0
- package/dst/man/ase-agent-estimator.1 +214 -0
- package/dst/man/ase-agent-fleet.1 +185 -0
- package/dst/man/ase-agent-implementer.1 +233 -0
- package/dst/man/ase-agent-integrator.1 +213 -0
- package/dst/man/ase-agent-qualifier.1 +214 -0
- package/dst/man/ase-agent-rebooter.1 +193 -0
- package/dst/man/ase-agent-relay.1 +128 -0
- package/dst/man/ase-agent-researcher.1 +217 -0
- package/dst/man/ase-agent.1 +264 -0
- package/dst/man/ase-artifact-list.1 +38 -0
- package/dst/man/ase-artifact-name.1 +39 -0
- package/dst/man/ase-artifact.1 +56 -0
- package/dst/man/ase-config-delete.1 +45 -0
- package/dst/man/ase-config-edit.1 +48 -0
- package/dst/man/ase-config-get.1 +46 -0
- package/dst/man/ase-config-init.1 +60 -0
- package/dst/man/ase-config-list.1 +42 -0
- package/dst/man/ase-config-set.1 +52 -0
- package/dst/man/ase-config.1 +225 -0
- package/dst/man/ase-hook-permission-request.1 +36 -0
- package/dst/man/ase-hook-pre-tool-use.1 +59 -0
- package/dst/man/ase-hook-session-end.1 +47 -0
- package/dst/man/ase-hook-session-persona.1 +37 -0
- package/dst/man/ase-hook-session-start.1 +81 -0
- package/dst/man/ase-hook-stop.1 +34 -0
- package/dst/man/ase-hook-user-prompt-submit.1 +34 -0
- package/dst/man/ase-hook.1 +51 -0
- package/dst/man/ase-man.1 +67 -0
- package/dst/man/ase-mcp.1 +61 -0
- package/dst/man/ase-service-log.1 +43 -0
- package/dst/man/ase-service-send.1 +47 -0
- package/dst/man/ase-service-start.1 +54 -0
- package/dst/man/ase-service-status.1 +46 -0
- package/dst/man/ase-service-stop.1 +37 -0
- package/dst/man/ase-service.1 +85 -0
- package/dst/man/ase-setup-disable.1 +44 -0
- package/dst/man/ase-setup-enable.1 +45 -0
- package/dst/man/ase-setup-install.1 +73 -0
- package/dst/man/ase-setup-mcp.1 +124 -0
- package/dst/man/ase-setup-status.1 +60 -0
- package/dst/man/ase-setup-statusline.1 +92 -0
- package/dst/man/ase-setup-telemetry.1 +92 -0
- package/dst/man/ase-setup-uninstall.1 +59 -0
- package/dst/man/ase-setup-update.1 +64 -0
- package/dst/man/ase-setup.1 +93 -0
- package/dst/man/ase-spec-export.1 +62 -0
- package/dst/man/ase-spec-lint.1 +40 -0
- package/dst/man/ase-spec-preview.1 +40 -0
- package/dst/man/ase-spec.1 +46 -0
- package/dst/man/ase-statusline.1 +189 -0
- package/dst/man/ase-task-assign.1 +71 -0
- package/dst/man/ase-task-board.1 +384 -0
- package/dst/man/ase-task-delete.1 +35 -0
- package/dst/man/ase-task-edit.1 +46 -0
- package/dst/man/ase-task-idscheme.1 +65 -0
- package/dst/man/ase-task-lifecycle.1 +57 -0
- package/dst/man/ase-task-list.1 +47 -0
- package/dst/man/ase-task-load.1 +36 -0
- package/dst/man/ase-task-newid.1 +61 -0
- package/dst/man/ase-task-purge.1 +37 -0
- package/dst/man/ase-task-rename.1 +44 -0
- package/dst/man/ase-task-request.1 +61 -0
- package/dst/man/ase-task-save.1 +48 -0
- package/dst/man/ase-task-status.1 +55 -0
- package/dst/man/ase-task-store.1 +194 -0
- package/dst/man/ase-task-view.1 +40 -0
- package/dst/man/ase-task.1 +160 -0
- package/dst/man/ase-util-compat.1 +25 -0
- package/dst/man/ase-util-diagram.1 +76 -0
- package/dst/man/ase-util-meta.1 +45 -0
- package/dst/man/ase-util-metric.1 +54 -0
- package/dst/man/ase-util-mint.1 +59 -0
- package/dst/man/ase-util-worktree.1 +72 -0
- package/dst/man/ase-util.1 +50 -0
- package/dst/man/ase.1 +124 -0
- package/package.json +79 -21
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.github/plugin/plugin.json +1 -1
- package/plugin/agents/ase-meta-diagram.md +12 -8
- package/plugin/agents/ase-repo-review.md +322 -0
- package/plugin/hooks/hooks-codex.json +5 -0
- package/plugin/hooks/hooks-copilot.json +5 -0
- package/plugin/hooks/hooks.json +8 -0
- package/plugin/meta/ase-common-approach.md +117 -0
- package/plugin/meta/ase-common-changeset.md +256 -0
- package/plugin/meta/ase-common-code.md +10 -173
- package/plugin/meta/ase-common-grill.md +317 -0
- package/plugin/meta/ase-common-resolve.md +305 -0
- package/plugin/meta/ase-common-task.md +83 -4
- package/plugin/meta/ase-constitution.md +0 -2
- package/plugin/meta/ase-format-specbook.md +208 -25
- package/plugin/meta/ase-format-specbook.yaml +242 -851
- package/plugin/meta/ase-format-task.md +669 -125
- package/plugin/meta/ase-persona.md +3 -4
- package/plugin/meta/ase-skill.md +9 -0
- package/plugin/mods/ase-mods.ts +409 -0
- package/plugin/mods/claude-code.d.ts +13417 -0
- package/plugin/skills/ase-arch-analyze/SKILL.md +1 -1
- package/plugin/skills/ase-arch-analyze/help.md +2 -2
- package/plugin/skills/ase-code-analyze/SKILL.md +59 -8
- package/plugin/skills/ase-code-analyze/help.md +29 -4
- package/plugin/skills/ase-code-edit/SKILL.md +107 -181
- package/plugin/skills/ase-code-edit/help.md +118 -31
- package/plugin/skills/ase-code-lint/SKILL.md +64 -13
- package/plugin/skills/ase-code-lint/help.md +32 -2
- package/plugin/skills/ase-docs-proofread/SKILL.md +4 -4
- package/plugin/skills/ase-docs-proofread/help.md +1 -1
- package/plugin/skills/ase-docs-refine/SKILL.md +4 -4
- package/plugin/skills/ase-docs-shorten/SKILL.md +4 -4
- package/plugin/skills/ase-help-intent/data.md +2164 -1087
- package/plugin/skills/ase-help-intent/help.md +1 -1
- package/plugin/skills/ase-help-skill/catalog.md +12 -9
- package/plugin/skills/ase-meta-brainstorm/SKILL.md +1 -1
- package/plugin/skills/ase-meta-config/SKILL.md +3 -4
- package/plugin/skills/ase-meta-config/help.md +31 -10
- package/plugin/skills/ase-meta-mint/SKILL.md +5 -5
- package/plugin/skills/ase-meta-mint/help.md +10 -5
- package/plugin/skills/ase-meta-why/help.md +1 -1
- package/plugin/skills/ase-meta-workflow/SKILL.md +7 -7
- package/plugin/skills/ase-meta-workflow/help.md +4 -4
- package/plugin/skills/ase-meta-workflow/sample.md +3 -3
- package/plugin/skills/ase-meta-workflow/workflow.txt +22 -18
- package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/SKILL.md +4 -3
- package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/help.md +5 -5
- package/plugin/skills/{ase-meta-commit → ase-repo-commit}/SKILL.md +6 -6
- package/plugin/skills/{ase-meta-commit → ase-repo-commit}/help.md +5 -5
- package/plugin/skills/{ase-meta-diff → ase-repo-diff}/SKILL.md +6 -6
- package/plugin/skills/{ase-meta-diff → ase-repo-diff}/help.md +8 -8
- package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/SKILL.md +19 -19
- package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/help.md +10 -12
- package/plugin/skills/ase-repo-merge/SKILL.md +346 -0
- package/plugin/skills/ase-repo-merge/help.md +107 -0
- package/plugin/skills/ase-repo-resolve/SKILL.md +158 -0
- package/plugin/skills/ase-repo-resolve/help.md +102 -0
- package/plugin/skills/ase-repo-review/SKILL.md +406 -0
- package/plugin/skills/ase-repo-review/help.md +181 -0
- package/plugin/skills/ase-spec-edit/SKILL.md +117 -183
- package/plugin/skills/ase-spec-edit/help.md +107 -26
- package/plugin/skills/ase-sync-reconcile/help.md +1 -1
- package/plugin/skills/ase-task-assign/SKILL.md +111 -0
- package/plugin/skills/ase-task-assign/help.md +119 -0
- package/plugin/skills/ase-task-condense/SKILL.md +21 -21
- package/plugin/skills/ase-task-condense/help.md +12 -10
- package/plugin/skills/ase-task-delete/SKILL.md +1 -1
- package/plugin/skills/ase-task-dissect/SKILL.md +71 -48
- package/plugin/skills/ase-task-dissect/help.md +10 -9
- package/plugin/skills/ase-task-edit/SKILL.md +379 -94
- package/plugin/skills/ase-task-edit/help.md +95 -22
- package/plugin/skills/ase-task-estimate/SKILL.md +492 -0
- package/plugin/skills/ase-task-estimate/help.md +158 -0
- package/plugin/skills/ase-task-grill/SKILL.md +246 -138
- package/plugin/skills/ase-task-grill/help.md +129 -26
- package/plugin/skills/ase-task-id/SKILL.md +8 -1
- package/plugin/skills/ase-task-id/help.md +6 -3
- package/plugin/skills/ase-task-implement/SKILL.md +535 -145
- package/plugin/skills/ase-task-implement/help.md +138 -47
- package/plugin/skills/ase-task-integrate/SKILL.md +549 -0
- package/plugin/skills/ase-task-integrate/help.md +187 -0
- package/plugin/skills/ase-task-list/SKILL.md +55 -23
- package/plugin/skills/ase-task-list/help.md +45 -21
- package/plugin/skills/ase-task-reboot/SKILL.md +84 -43
- package/plugin/skills/ase-task-reboot/help.md +28 -8
- package/plugin/skills/ase-task-rename/SKILL.md +10 -2
- package/plugin/skills/ase-task-research/SKILL.md +386 -0
- package/plugin/skills/ase-task-research/help.md +102 -0
- package/plugin/skills/ase-task-status/SKILL.md +137 -0
- package/plugin/skills/ase-task-status/help.md +80 -0
- package/plugin/skills/ase-task-view/SKILL.md +49 -24
- package/plugin/skills/ase-task-view/help.md +19 -9
- package/plugin/agents/ase-meta-review.md +0 -187
- package/plugin/skills/ase-code-craft/SKILL.md +0 -264
- package/plugin/skills/ase-code-craft/help.md +0 -122
- package/plugin/skills/ase-code-refactor/SKILL.md +0 -232
- package/plugin/skills/ase-code-refactor/help.md +0 -109
- package/plugin/skills/ase-code-resolve/SKILL.md +0 -289
- package/plugin/skills/ase-code-resolve/help.md +0 -116
- package/plugin/skills/ase-meta-review/SKILL.md +0 -210
- package/plugin/skills/ase-meta-review/help.md +0 -96
- package/plugin/skills/ase-task-preflight/SKILL.md +0 -166
- package/plugin/skills/ase-task-preflight/help.md +0 -82
- /package/dst/{ase-ignore.js → ase-lib-ignore.js} +0 -0
- /package/dst/{ase-stdio.js → ase-lib-stdio.js} +0 -0
- /package/dst/{ase-kv.js → ase-service-kv.js} +0 -0
- /package/dst/{ase-sleep.js → ase-service-sleep.js} +0 -0
- /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
|
+
}
|