@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,772 @@
|
|
|
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 path from "node:path";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import YAML, { isScalar } from "yaml";
|
|
11
|
+
import textframe from "textframe";
|
|
12
|
+
import chalk from "chalk";
|
|
13
|
+
import { writeStdout } from "./ase-lib-stdio.js";
|
|
14
|
+
import { DaemonCommand } from "./ase-lib-daemon-cli.js";
|
|
15
|
+
import { DEFAULT_ADDRESS, connectHost, isLocal } from "./ase-lib-server-cli.js";
|
|
16
|
+
import { isLoopback } from "./ase-lib-server-bind.js";
|
|
17
|
+
import { Config } from "./ase-config-core.js";
|
|
18
|
+
import { configSchema } from "./ase-config-schema.js";
|
|
19
|
+
import { parseScope } from "./ase-config-scope.js";
|
|
20
|
+
import { parseRelayURL, redactURL } from "./ase-agent-relay-client.js";
|
|
21
|
+
import { pidAlive, pidStart } from "./ase-agent-runtime-exec.js";
|
|
22
|
+
import { relaySchema, relayTls } from "./ase-agent-relay-server-cli.js";
|
|
23
|
+
import { flattenDefinition } from "./ase-agent-definition-core.js";
|
|
24
|
+
import { processDir, readState, writeState, stateAlive, validateName, agentDefaults } from "./ase-agent-process.js";
|
|
25
|
+
/* the minimum and maximum delay of restarting an exited member (doubled on each restart
|
|
26
|
+
in a row), the run duration after which a member counts as stable again (resetting its
|
|
27
|
+
delay), and the maximum durations of the startup of the agent relay and of the agents */
|
|
28
|
+
const RESTART_MIN_MS = 1000;
|
|
29
|
+
const RESTART_MAX_MS = 5 * 60 * 1000;
|
|
30
|
+
const STABLE_MS = 10 * 60 * 1000;
|
|
31
|
+
const RELAY_STARTUP_MS = 10 * 1000;
|
|
32
|
+
const AGENT_STARTUP_MS = 30 * 1000;
|
|
33
|
+
/* the environment variable marking the detached fleet process */
|
|
34
|
+
const SERVE_ENV = "ASE_AGENT_FLEET_SERVE";
|
|
35
|
+
/* the colors of the names of the fleet and its members prefixing their lines
|
|
36
|
+
in the shared log: bold red for the fleet, red for the relay, and blue for all agents */
|
|
37
|
+
const tint = (name) => name === "fleet" ? chalk.red.bold(name) : name === "relay" ? chalk.red(name) : chalk.blue(name);
|
|
38
|
+
/* the prefix of a line in the log of a fleet: the name of the fleet ("fleet") or of
|
|
39
|
+
its member, padded to the width of the longest name, followed by a separator */
|
|
40
|
+
const logPrefix = (name, width, colored) => `${colored ? tint(name) : name}${" ".repeat(Math.max(width - name.length, 0))} | `;
|
|
41
|
+
const logPrefixRe = /^([A-Za-z0-9][A-Za-z0-9_-]*)( *\| )/;
|
|
42
|
+
const logLevelRe = /^([^|]*\| [^:]+: \[[^\]]+\]: )\[(ERROR|WARNING|INFO|DEBUG)\]/;
|
|
43
|
+
/* the options of the section "fleet" (those of "ase agent fleet start"), of the section
|
|
44
|
+
"relay" (those of "ase agent relay start"), and of the section "default" (shared by all
|
|
45
|
+
agents, and applied to an agent only if it supports them) */
|
|
46
|
+
const fleetKeys = ["foreground"];
|
|
47
|
+
const relayKeys = ["address", "port", "token", "cors", "tls-cert", "tls-key"];
|
|
48
|
+
const defaultKeys = ["tool", "parallel", "timeout", "repo", "tempdir", "agent-relay"];
|
|
49
|
+
/* the options not allowed in the section of an agent, as the fleet decides on them */
|
|
50
|
+
const deniedKeys = ["name", "one-shot", "foreground", "help"];
|
|
51
|
+
/* the state of the planned tasks per task lifecycle model, as estimated by the estimator */
|
|
52
|
+
const initPlanned = { solo: "OPEN", team: "PLANNING", enterprise: "PLANNED" };
|
|
53
|
+
/* the initial configuration of a fleet (see "ase agent fleet init") for a task lifecycle
|
|
54
|
+
model: the agent relay, the edit request agent, and the effort estimation agent for all
|
|
55
|
+
models, plus the four phase agents for "enterprise" resp. an ad-hoc implementation agent
|
|
56
|
+
for "team" (as the built-in phase agents support "enterprise" only), while "solo" has no
|
|
57
|
+
activity states and hence no phase agents at all */
|
|
58
|
+
const initConfig = (tool, lifecycle) => [
|
|
59
|
+
textframe `
|
|
60
|
+
##
|
|
61
|
+
## ASE Agent Fleet Configuration
|
|
62
|
+
## (for the "${lifecycle}" task lifecycle model)
|
|
63
|
+
##
|
|
64
|
+
|
|
65
|
+
# the agent relay, passing the live presence of the agents to the task boards
|
|
66
|
+
# (the options not given fall back to the configured "project.relay.url",
|
|
67
|
+
# "project.relay.crt", and "project.relay.key")
|
|
68
|
+
relay:
|
|
69
|
+
#address: <addr> # address to bind to
|
|
70
|
+
#port: <port> # port to bind to
|
|
71
|
+
#token: <token> # bearer token to expect
|
|
72
|
+
#tls-cert: <file> # PEM certificate (chain) to serve HTTPS with
|
|
73
|
+
#tls-key: <file> # PEM private key to serve HTTPS with
|
|
74
|
+
|
|
75
|
+
# the default options of all agents
|
|
76
|
+
# (each applied only to the agents supporting it, where an empty value omits it)
|
|
77
|
+
default:
|
|
78
|
+
tool: ${tool.padEnd(16)}# agent tool ("claude", "copilot", or "codex")
|
|
79
|
+
parallel: 1 # maximum number of tasks processed in parallel
|
|
80
|
+
timeout: 60m # maximum duration of processing a task (<number><unit> with unit "s", "m", or "h")
|
|
81
|
+
#repo: <url> # Git repository to clone temporarily for the tasks without "worktree"
|
|
82
|
+
#tempdir: <dir> # directory for the temporary files like the clone of "repo"
|
|
83
|
+
|
|
84
|
+
# the editing request agent: processing the editing requests of the task boards
|
|
85
|
+
editor:
|
|
86
|
+
parallel: 1 # maximum number of tasks processed in parallel
|
|
87
|
+
|
|
88
|
+
# the effort estimation agent: estimating the effort of the unestimated tasks
|
|
89
|
+
# (${initPlanned[lifecycle]}, unclaimed), intended for their final estimation
|
|
90
|
+
estimator:
|
|
91
|
+
mode: phase # "phase" (unestimated planned tasks) or "request" (on request)
|
|
92
|
+
parallel: 1 # maximum number of tasks processed in parallel
|
|
93
|
+
|
|
94
|
+
# the effort estimation request agent: (re-)estimating the effort of the tasks in any unfinished
|
|
95
|
+
# status on request (attached via "ase task request <id> estimate <prompt>"), intended for dry-runs
|
|
96
|
+
#estimator-request:
|
|
97
|
+
# kind: estimator # agent kind
|
|
98
|
+
# mode: request # "phase" (unestimated planned tasks) or "request" (on request)
|
|
99
|
+
# parallel: 1 # maximum number of tasks processed in parallel
|
|
100
|
+
|
|
101
|
+
# the research request agent: researching a topic (or the plan itself) for the tasks in any
|
|
102
|
+
# unfinished status on request (attached via "ase task request <id> research [<topic>]"),
|
|
103
|
+
# intended for topic-driven research
|
|
104
|
+
#researcher-request:
|
|
105
|
+
# kind: researcher # agent kind
|
|
106
|
+
# mode: request # "phase" (assigned tasks) or "request" (on request)
|
|
107
|
+
# parallel: 1 # maximum number of tasks processed in parallel
|
|
108
|
+
`,
|
|
109
|
+
lifecycle === "solo" ? textframe `
|
|
110
|
+
# no phase agents, as the "solo" task lifecycle model has no activity states: its tasks
|
|
111
|
+
# (OPEN > CLOSED) are driven interactively, while the agents above only assist on them
|
|
112
|
+
` : "",
|
|
113
|
+
lifecycle === "team" ? textframe `
|
|
114
|
+
# the implementation phase agent (PLANNING > IMPLEMENTING > IMPLEMENTED), as an ad-hoc agent
|
|
115
|
+
# (as the built-in "implementer" supports the "enterprise" task lifecycle model only),
|
|
116
|
+
# claiming only the tasks explicitly assigned to "developer" (as the other ones in
|
|
117
|
+
# PLANNING are still planned), and stalling a task on failure (STALLED)
|
|
118
|
+
developer:
|
|
119
|
+
kind: adhoc # agent kind
|
|
120
|
+
type: phase # processing type ("phase" or "request")
|
|
121
|
+
lifecycle: team # supported task lifecycle model
|
|
122
|
+
workdir: changeset # implement in the changeset of the task
|
|
123
|
+
parallel: 1 # maximum number of tasks processed in parallel
|
|
124
|
+
state:
|
|
125
|
+
incoming: PLANNING # state of the tasks to claim
|
|
126
|
+
activity: IMPLEMENTING # state during the processing
|
|
127
|
+
success: IMPLEMENTED # state on success
|
|
128
|
+
failure: STALLED # state on failure
|
|
129
|
+
assign:
|
|
130
|
+
required: true # claim only the tasks assigned to "developer"
|
|
131
|
+
prompt:
|
|
132
|
+
skill: ase-task-implement
|
|
133
|
+
args: "--next DONE{?changeset-type=branch: --changeset worktree}{?changeset-worktree: --worktree {changeset-worktree}} {id}"
|
|
134
|
+
success:
|
|
135
|
+
criterion: [ status ] # success only on the move into IMPLEMENTED by the skill
|
|
136
|
+
attach:
|
|
137
|
+
changeset: changeset # kind of the attachment storing the change set snapshot
|
|
138
|
+
finish:
|
|
139
|
+
commit: true # commit and publish the work on a "branch:" changeset
|
|
140
|
+
` : "",
|
|
141
|
+
lifecycle === "enterprise" ? textframe `
|
|
142
|
+
# the planning phase agents, each processing only the tasks explicitly assigned to it
|
|
143
|
+
# (DRAFTED > PLANNING > DRAFTED, unassigned afterwards)
|
|
144
|
+
rebooter: # re-create the plan from scratch
|
|
145
|
+
parallel: 1 # maximum number of tasks processed in parallel
|
|
146
|
+
condenser: # compress the wording of the plan
|
|
147
|
+
parallel: 1 # maximum number of tasks processed in parallel
|
|
148
|
+
researcher: # research and ground the plan content
|
|
149
|
+
mode: phase # "phase" (assigned tasks) or "request" (on request)
|
|
150
|
+
parallel: 1 # maximum number of tasks processed in parallel
|
|
151
|
+
|
|
152
|
+
# the implementation phase agent (PLANNED > IMPLEMENTING > IMPLEMENTED)
|
|
153
|
+
implementer:
|
|
154
|
+
mode: implement # "implement" (into changeset) or "draft" (into attachment)
|
|
155
|
+
parallel: 2 # maximum number of tasks processed in parallel
|
|
156
|
+
#params: { effort-wait: false } # do not wait for the effort estimation (without "estimator")
|
|
157
|
+
|
|
158
|
+
# the qualification phase agent (IMPLEMENTED > QUALIFYING > IMPLEMENTED)
|
|
159
|
+
qualifier:
|
|
160
|
+
mode: judge # "judge" (verdict tag only) or "qualify" (also decide status)
|
|
161
|
+
parallel: 2 # maximum number of tasks processed in parallel
|
|
162
|
+
|
|
163
|
+
# the integration phase agent (QUALIFIED > INTEGRATING > INTEGRATED)
|
|
164
|
+
integrator:
|
|
165
|
+
mode: squash # Git integration operation ("merge", "rebase", or "squash")
|
|
166
|
+
` : "",
|
|
167
|
+
textframe `
|
|
168
|
+
# an agent of a definition file ".ase/agents/<kind>.{yaml,ts,js}" of the project (or of
|
|
169
|
+
# the user) is configured by a section named after its kind (or with "kind: <kind>"),
|
|
170
|
+
# while an ad-hoc agent is entirely configured by its section (see "ase agent adhoc start"),
|
|
171
|
+
# e.g. one processing the requests "lint" (attached via "ase task request <id> lint <prompt>")
|
|
172
|
+
#lint:
|
|
173
|
+
# kind: adhoc # agent kind
|
|
174
|
+
# type: request # processing type ("phase" or "request")
|
|
175
|
+
# #definition: <file> # YAML, TypeScript, or JavaScript definition file (relative to this file)
|
|
176
|
+
# #params: { <name>: <value> } # values of the parameters declared by the definition
|
|
177
|
+
# prompt:
|
|
178
|
+
# text: "Lint the source code of task {id} with focus on: {prompt}"
|
|
179
|
+
# success:
|
|
180
|
+
# criterion: [ exit, clean ] # success criteria ("exit", "clean", "changed", ...)
|
|
181
|
+
# attach:
|
|
182
|
+
# output: lint-report # kind of the attachment storing the output
|
|
183
|
+
`
|
|
184
|
+
].filter((block) => block !== "").join("\n");
|
|
185
|
+
const isSection = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
186
|
+
/* whether an option is given in a configuration section (where "false" or an empty value omits it) */
|
|
187
|
+
const given = (values, key) => values[key] !== undefined && values[key] !== null && values[key] !== false;
|
|
188
|
+
/* the command-line arguments of the options of a configuration section, given by
|
|
189
|
+
their long option names, where "true" enables a flag, "false" or an empty value
|
|
190
|
+
omits an option, a list of values repeats an option, and the map "params" repeats
|
|
191
|
+
the option "--params <name>=<value>" per parameter */
|
|
192
|
+
const sectionArgs = (section, values) => Object.entries(values).flatMap(([key, value]) => {
|
|
193
|
+
if (!/^[a-z][a-z0-9-]*$/.test(key))
|
|
194
|
+
throw new Error(`fleet: section "${section}": invalid option name "${key}"`);
|
|
195
|
+
if (value === false || value === null)
|
|
196
|
+
return [];
|
|
197
|
+
if (value === true)
|
|
198
|
+
return [`--${key}`];
|
|
199
|
+
if (key === "params" && isSection(value))
|
|
200
|
+
return Object.entries(value).flatMap(([name, item]) => {
|
|
201
|
+
if (item === null || item === undefined)
|
|
202
|
+
return [];
|
|
203
|
+
if (typeof item !== "string" && typeof item !== "number" && typeof item !== "boolean")
|
|
204
|
+
throw new Error(`fleet: section "${section}": invalid value of parameter "${name}"`);
|
|
205
|
+
return [`--${key}`, `${name}=${String(item)}`];
|
|
206
|
+
});
|
|
207
|
+
return (Array.isArray(value) ? value : [value]).flatMap((item) => {
|
|
208
|
+
if (typeof item !== "string" && typeof item !== "number")
|
|
209
|
+
throw new Error(`fleet: section "${section}": invalid value of option "${key}"`);
|
|
210
|
+
return [`--${key}`, String(item)];
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
/* CLI command group "ase agent fleet", managing a fleet of agents (and optionally the
|
|
214
|
+
agent relay they report to) of a configuration file through a supervising process,
|
|
215
|
+
which runs each member as its own child process and restarts it whenever it exits */
|
|
216
|
+
export default class AgentFleetCommand extends DaemonCommand {
|
|
217
|
+
kinds;
|
|
218
|
+
label = "fleet";
|
|
219
|
+
words = "agent fleet";
|
|
220
|
+
description = "Manage a fleet of agents (and of the agent relay they report to), " +
|
|
221
|
+
"running each in its own process and restarting it whenever it exits";
|
|
222
|
+
constructor(log, kinds) {
|
|
223
|
+
super(log);
|
|
224
|
+
this.kinds = kinds;
|
|
225
|
+
}
|
|
226
|
+
/* the agent relay of a fleet by the options <values> of its section "relay"
|
|
227
|
+
(completed in place by the configured agent relay) */
|
|
228
|
+
relayPlan(values) {
|
|
229
|
+
/* read the configured agent relay URL and token */
|
|
230
|
+
const cfg = new Config("config", configSchema, this.log);
|
|
231
|
+
cfg.read();
|
|
232
|
+
const scoped = (key) => {
|
|
233
|
+
const entry = cfg.getScoped(key);
|
|
234
|
+
return entry !== undefined ? String(isScalar(entry.value) ? entry.value.value : entry.value) : null;
|
|
235
|
+
};
|
|
236
|
+
/* let the options "address", "port", and "token" not given fall back to those of
|
|
237
|
+
the configured "project.relay.url" (if it addresses this host), so the agent
|
|
238
|
+
relay serves exactly the URL the task boards follow (where a deviating option
|
|
239
|
+
is warned about), and let it decide on HTTP or HTTPS */
|
|
240
|
+
let token = null;
|
|
241
|
+
let insecure = false;
|
|
242
|
+
let secure = null;
|
|
243
|
+
const configured = scoped("project.relay.url");
|
|
244
|
+
if (configured !== null) {
|
|
245
|
+
const label = redactURL(configured);
|
|
246
|
+
const spec = parseRelayURL(configured);
|
|
247
|
+
const host = spec.url.hostname.replace(/^\[|\]$/g, "");
|
|
248
|
+
if (!isLocal(host))
|
|
249
|
+
this.log.write("warning", `fleet: configured "project.relay.url" "${label}" ` +
|
|
250
|
+
"addresses no local address -- not adopted by the agent relay of the fleet " +
|
|
251
|
+
"(the task boards following it will not see the agents of the fleet)");
|
|
252
|
+
else {
|
|
253
|
+
const deviates = (key) => this.log.write("warning", `fleet: section "relay": option "${key}" deviates ` +
|
|
254
|
+
`from the configured "project.relay.url" "${label}" ` +
|
|
255
|
+
"(the task boards following it will not see the agents of the fleet)");
|
|
256
|
+
if (!given(values, "address"))
|
|
257
|
+
values.address = host;
|
|
258
|
+
else if (![host, "0.0.0.0", "::"].includes(String(values.address)))
|
|
259
|
+
deviates("address");
|
|
260
|
+
if (!given(values, "port"))
|
|
261
|
+
values.port = spec.url.port;
|
|
262
|
+
else if (String(values.port) !== spec.url.port)
|
|
263
|
+
deviates("port");
|
|
264
|
+
if (!given(values, "token")) {
|
|
265
|
+
const env = process.env.ASE_AGENT_RELAY_TOKEN;
|
|
266
|
+
token = spec.embedded ?? (env !== undefined && env !== "" ? env : scoped("project.relay.token"));
|
|
267
|
+
}
|
|
268
|
+
if (!spec.secure && given(values, "tls-cert"))
|
|
269
|
+
deviates("tls-cert");
|
|
270
|
+
insecure = spec.insecure;
|
|
271
|
+
secure = spec.secure;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/* let the options "tls-cert" and "tls-key" not given fall back to the configured
|
|
275
|
+
"project.relay.crt" and "project.relay.key" (as the agent relay itself would),
|
|
276
|
+
where an adopted "project.relay.url" selecting HTTPS requires both */
|
|
277
|
+
const tls = relayTls(cfg);
|
|
278
|
+
if (!given(values, "tls-cert") && tls.cert !== null)
|
|
279
|
+
values["tls-cert"] = tls.cert;
|
|
280
|
+
if (!given(values, "tls-key") && tls.key !== null)
|
|
281
|
+
values["tls-key"] = tls.key;
|
|
282
|
+
if (secure === true && (!given(values, "tls-cert") || !given(values, "tls-key")))
|
|
283
|
+
throw new Error("fleet: configured \"project.relay.url\" selects HTTPS, but no TLS certificate and key " +
|
|
284
|
+
"(set the options \"tls-cert\" and \"tls-key\" of the section \"relay\", " +
|
|
285
|
+
"or \"project.relay.crt\" and \"project.relay.key\")");
|
|
286
|
+
return { args: sectionArgs("relay", values), token, insecure };
|
|
287
|
+
}
|
|
288
|
+
/* load and validate the configuration file (or the configuration <text> of it), where the
|
|
289
|
+
options of each agent are validated by parsing them with the "start" command of a fresh agent */
|
|
290
|
+
async load(file, text) {
|
|
291
|
+
let data;
|
|
292
|
+
try {
|
|
293
|
+
data = YAML.parse(text ?? fs.readFileSync(file, "utf8"));
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
throw new Error(`fleet: cannot read configuration "${file}": ` +
|
|
297
|
+
`${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
298
|
+
}
|
|
299
|
+
if (!isSection(data))
|
|
300
|
+
throw new Error(`fleet: configuration "${file}" carries no sections`);
|
|
301
|
+
const section = (name, keys) => {
|
|
302
|
+
const value = data[name];
|
|
303
|
+
if (value === null || value === undefined)
|
|
304
|
+
return {};
|
|
305
|
+
if (!isSection(value))
|
|
306
|
+
throw new Error(`fleet: section "${name}" carries no options`);
|
|
307
|
+
for (const key of Object.keys(value))
|
|
308
|
+
if (keys !== undefined && !keys.includes(key))
|
|
309
|
+
throw new Error(`fleet: section "${name}": unsupported option "${key}" ` +
|
|
310
|
+
`(expected ${keys.map((k) => `"${k}"`).join(", ")})`);
|
|
311
|
+
return value;
|
|
312
|
+
};
|
|
313
|
+
/* the options of the fleet itself */
|
|
314
|
+
const fleet = section("fleet", fleetKeys);
|
|
315
|
+
const foreground = fleet.foreground ?? false;
|
|
316
|
+
if (typeof foreground !== "boolean")
|
|
317
|
+
throw new Error("fleet: section \"fleet\": invalid value of option \"foreground\" (expected a boolean)");
|
|
318
|
+
/* the default options of the agents */
|
|
319
|
+
const defaults = section("default", defaultKeys);
|
|
320
|
+
/* the options of the agent relay */
|
|
321
|
+
let relay = null;
|
|
322
|
+
if (Object.hasOwn(data, "relay")) {
|
|
323
|
+
const values = { ...section("relay", relayKeys) };
|
|
324
|
+
if (given(defaults, "agent-relay"))
|
|
325
|
+
throw new Error("fleet: section \"default\": option \"agent-relay\" not allowed with the section \"relay\"");
|
|
326
|
+
relay = this.relayPlan(values);
|
|
327
|
+
}
|
|
328
|
+
/* the agents, named by their sections */
|
|
329
|
+
const kinds = [...this.kinds.keys()];
|
|
330
|
+
const agents = [];
|
|
331
|
+
const serial = new Map();
|
|
332
|
+
for (const name of Object.keys(data)) {
|
|
333
|
+
if (name === "fleet" || name === "relay" || name === "default")
|
|
334
|
+
continue;
|
|
335
|
+
const { kind = name, ...options } = section(name);
|
|
336
|
+
if (typeof kind !== "string" || !this.kinds.has(kind))
|
|
337
|
+
throw new Error(`fleet: section "${name}": unknown agent kind "${String(kind)}" ` +
|
|
338
|
+
`(expected ${kinds.map((name) => `"${name}"`).join(", ")})`);
|
|
339
|
+
validateName(name, kind, kinds);
|
|
340
|
+
/* the options of the definition of an agent can be nested by group, and the
|
|
341
|
+
definition file of an ad-hoc agent (but not a built-in definition) is
|
|
342
|
+
relative to the configuration file */
|
|
343
|
+
const values = flattenDefinition(options, `fleet: section "${name}"`);
|
|
344
|
+
if (typeof values.definition === "string" && !values.definition.startsWith("builtin:"))
|
|
345
|
+
values.definition = path.resolve(path.dirname(file), values.definition);
|
|
346
|
+
for (const key of [...deniedKeys, ...(relay !== null ? ["agent-relay"] : [])])
|
|
347
|
+
if (given(values, key))
|
|
348
|
+
throw new Error(`fleet: section "${name}": option "${key}" not allowed in a fleet` +
|
|
349
|
+
(key === "agent-relay" ? " with the section \"relay\"" : ""));
|
|
350
|
+
const agent = this.kinds.get(kind)();
|
|
351
|
+
const cmd = agent.command();
|
|
352
|
+
const known = new Set(cmd.options.map((option) => option.long));
|
|
353
|
+
const shared = Object.fromEntries(Object.entries(defaults).filter(([key]) => known.has(`--${key}`)));
|
|
354
|
+
const args = [...sectionArgs("default", shared), ...sectionArgs(name, values),
|
|
355
|
+
...(name !== kind ? ["--name", name] : [])];
|
|
356
|
+
cmd.configureOutput({ writeOut: () => { }, writeErr: () => { } });
|
|
357
|
+
try {
|
|
358
|
+
cmd.parse(args, { from: "user" });
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
throw new Error(`fleet: section "${name}": ` +
|
|
362
|
+
`${(err instanceof Error ? err.message : String(err)).replace(/^error: /, "")}`, { cause: err });
|
|
363
|
+
}
|
|
364
|
+
agents.push({ name, kind, args });
|
|
365
|
+
/* the resolved definition of the agent, validating it */
|
|
366
|
+
let def;
|
|
367
|
+
try {
|
|
368
|
+
def = await agent.definition(cmd.opts(), name);
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
throw new Error(`fleet: section "${name}": ` +
|
|
372
|
+
`${(err instanceof Error ? err.message : String(err)).replace(/^agent: /, "")}`, { cause: err });
|
|
373
|
+
}
|
|
374
|
+
/* the tag of an agent processing tasks serially only */
|
|
375
|
+
const tag = def.serial ? def.tag.name : "";
|
|
376
|
+
const other = serial.get(tag);
|
|
377
|
+
if (tag !== "" && other !== undefined)
|
|
378
|
+
throw new Error(`fleet: sections "${other}" and "${name}" both process the tasks of tag "${tag}" serially only ` +
|
|
379
|
+
"-- cannot run both");
|
|
380
|
+
if (tag !== "")
|
|
381
|
+
serial.set(tag, name);
|
|
382
|
+
}
|
|
383
|
+
if (agents.length === 0)
|
|
384
|
+
throw new Error(`fleet: configuration "${file}" defines no agents`);
|
|
385
|
+
/* an agent of a built-in kind processing tasks serially only cannot be run multiple
|
|
386
|
+
times (even with different tags, e.g. as merging into the same target branches) */
|
|
387
|
+
for (const [kind, create] of this.kinds) {
|
|
388
|
+
const count = agents.filter((agent) => agent.kind === kind).length;
|
|
389
|
+
if (count > 1 && create().serialOnly)
|
|
390
|
+
throw new Error(`fleet: ${kind} processes tasks serially only -- cannot run ${count} agents of it`);
|
|
391
|
+
}
|
|
392
|
+
return { foreground, relay, agents };
|
|
393
|
+
}
|
|
394
|
+
/* read the per-user configuration of the agent relay */
|
|
395
|
+
relayConfig() {
|
|
396
|
+
const cfg = new Config("relay", relaySchema, this.log, parseScope("user"));
|
|
397
|
+
cfg.read();
|
|
398
|
+
return (key) => {
|
|
399
|
+
const val = cfg.get(key);
|
|
400
|
+
return val === undefined || val === null ? "" : String(isScalar(val) ? val.value : val);
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/* the URL and the token of the agent relay run by the process <pid> (once it persisted its
|
|
404
|
+
runtime facts), where the certificate verification is skipped on request (<insecure>) or
|
|
405
|
+
on a loopback address (as a certificate rarely covers it), else null */
|
|
406
|
+
relayUrl(pid, insecure) {
|
|
407
|
+
const scalar = this.relayConfig();
|
|
408
|
+
if (scalar("pid") !== String(pid))
|
|
409
|
+
return null;
|
|
410
|
+
const host = connectHost(scalar("address") || DEFAULT_ADDRESS);
|
|
411
|
+
const tls = scalar("tls.cert") !== "";
|
|
412
|
+
const url = `${tls ? "ases" : "ase"}://${host}:${scalar("port")}` +
|
|
413
|
+
(tls && (insecure || isLoopback(host.replace(/^\[|\]$/g, ""))) ? "?insecure" : "");
|
|
414
|
+
return { url, token: scalar("token") };
|
|
415
|
+
}
|
|
416
|
+
/* supervise the fleet: start the agent relay (if any) and, reporting to it, all agents,
|
|
417
|
+
each as its own child process, and restart each one exited with an exponential backoff,
|
|
418
|
+
until a terminating signal arrives, which stops first all agents and then the relay */
|
|
419
|
+
async supervise(file, plan) {
|
|
420
|
+
const stateFile = path.join(processDir(), "fleet.yaml");
|
|
421
|
+
const entry = fileURLToPath(new URL("./ase.js", import.meta.url));
|
|
422
|
+
const start = await pidStart(process.pid);
|
|
423
|
+
const member = (name, kind, args, env = {}) => ({ name, kind, args, env, child: null, start: "", since: 0, delay: RESTART_MIN_MS, restarts: 0, timer: undefined, signalled: false });
|
|
424
|
+
const members = [
|
|
425
|
+
...(plan.relay !== null ? [member("relay", "relay", ["agent", "relay", "start", "-f", ...plan.relay.args], plan.relay.token !== null ? { ASE_AGENT_RELAY_TOKEN: plan.relay.token } : {})] : []),
|
|
426
|
+
...plan.agents.map((agent) => member(agent.name, agent.kind, ["agent", agent.kind, "start", "-f", ...agent.args]))
|
|
427
|
+
];
|
|
428
|
+
let ready = false;
|
|
429
|
+
let stopping = false;
|
|
430
|
+
/* prefix each line in the shared log with the name of the fleet resp. of the member
|
|
431
|
+
writing it (colored on a terminal only), where each member gets its prefix passed
|
|
432
|
+
through the environment (as it writes into the shared log on its own) */
|
|
433
|
+
const width = Math.max(...["fleet", ...members.map((m) => m.name)].map((name) => name.length));
|
|
434
|
+
const colored = process.stderr.isTTY === true;
|
|
435
|
+
this.log.prefix(logPrefix("fleet", width, colored));
|
|
436
|
+
this.log.write("info", `${this.label}: starting (process ${process.pid}) with configuration "${file}"`);
|
|
437
|
+
const save = () => {
|
|
438
|
+
writeState(stateFile, {
|
|
439
|
+
pid: process.pid, start, config: file, ready,
|
|
440
|
+
members: Object.fromEntries(members.map((m) => [m.name, { kind: m.kind, pid: m.child?.pid ?? null, start: m.start, restarts: m.restarts }]))
|
|
441
|
+
});
|
|
442
|
+
};
|
|
443
|
+
/* terminate the members in order: first all agents, then the agent relay (as the agents
|
|
444
|
+
remove their presence from it on their termination), and finish once all are gone */
|
|
445
|
+
let finish = (_code) => { };
|
|
446
|
+
const finished = new Promise((resolve) => { finish = resolve; });
|
|
447
|
+
const proceed = () => {
|
|
448
|
+
const agents = members.filter((m) => m.kind !== "relay" && m.child !== null);
|
|
449
|
+
const pending = agents.length > 0 ? agents : members.filter((m) => m.child !== null);
|
|
450
|
+
for (const m of pending)
|
|
451
|
+
if (!m.signalled) {
|
|
452
|
+
m.signalled = true;
|
|
453
|
+
m.child.kill("SIGTERM");
|
|
454
|
+
}
|
|
455
|
+
if (pending.length === 0) {
|
|
456
|
+
fs.rmSync(stateFile, { force: true });
|
|
457
|
+
this.log.write("info", `${this.label}: terminated`);
|
|
458
|
+
finish(0);
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
/* stop the fleet on a terminating signal, where a repeated signal is forwarded
|
|
462
|
+
to all remaining members (terminating the agents at once, see "ase agent") */
|
|
463
|
+
const shutdown = (signal) => {
|
|
464
|
+
if (stopping) {
|
|
465
|
+
this.log.write("warning", `${this.label}: interrupted again (received ${signal}) -- forwarding it to the members`);
|
|
466
|
+
for (const m of members)
|
|
467
|
+
m.child?.kill("SIGTERM");
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
stopping = true;
|
|
471
|
+
this.log.write("info", `${this.label}: interrupted (received ${signal})`);
|
|
472
|
+
for (const m of members)
|
|
473
|
+
clearTimeout(m.timer);
|
|
474
|
+
proceed();
|
|
475
|
+
};
|
|
476
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
477
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
478
|
+
process.on("SIGHUP", () => shutdown("SIGHUP"));
|
|
479
|
+
process.on("SIGQUIT", () => shutdown("SIGQUIT"));
|
|
480
|
+
/* schedule the restart of an exited member (with a delay doubled on each restart
|
|
481
|
+
in a row up to its maximum, and reset once it ran stable) */
|
|
482
|
+
const restart = (m, reason) => {
|
|
483
|
+
if (Date.now() - m.since >= STABLE_MS)
|
|
484
|
+
m.delay = RESTART_MIN_MS;
|
|
485
|
+
this.log.write("warning", `${this.label}: ${m.name} ${reason} -- restarting it in ${m.delay / 1000}s`);
|
|
486
|
+
m.timer = setTimeout(() => {
|
|
487
|
+
m.restarts++;
|
|
488
|
+
this.log.write("info", `${this.label}: restarting ${m.name} (restart #${m.restarts})`);
|
|
489
|
+
launch(m);
|
|
490
|
+
}, m.delay);
|
|
491
|
+
m.delay = Math.min(m.delay * 2, RESTART_MAX_MS);
|
|
492
|
+
};
|
|
493
|
+
/* launch a member as a child process in its own process group (so an interrupt in
|
|
494
|
+
the terminal reaches the fleet only, which terminates its members in order), sharing
|
|
495
|
+
the output of the fleet, and, after the initial startup of all members, restart it
|
|
496
|
+
whenever it exits, until the fleet is stopped (a member exiting during the initial
|
|
497
|
+
startup of the others is restarted once the fleet is ready) */
|
|
498
|
+
const launch = (m) => {
|
|
499
|
+
const child = spawn(process.execPath, [entry, "-l", this.log.logLevel(), ...m.args], {
|
|
500
|
+
detached: true,
|
|
501
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
502
|
+
env: { ...process.env, ...m.env, ASE_LOG_PREFIX: logPrefix(m.name, width, colored) }
|
|
503
|
+
});
|
|
504
|
+
m.child = child;
|
|
505
|
+
m.start = "";
|
|
506
|
+
m.since = Date.now();
|
|
507
|
+
m.timer = undefined;
|
|
508
|
+
m.signalled = false;
|
|
509
|
+
pidStart(child.pid ?? 0).then((start) => {
|
|
510
|
+
if (m.child === child) {
|
|
511
|
+
m.start = start;
|
|
512
|
+
save();
|
|
513
|
+
}
|
|
514
|
+
}).catch(() => { });
|
|
515
|
+
child.on("error", (err) => this.log.write("error", `${this.label}: ${m.name} failed (${err.message})`));
|
|
516
|
+
child.once("exit", (code, signal) => {
|
|
517
|
+
m.child = null;
|
|
518
|
+
m.start = "";
|
|
519
|
+
if (stopping) {
|
|
520
|
+
proceed();
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (ready)
|
|
524
|
+
restart(m, `exited (${signal ?? `code ${code}`})`);
|
|
525
|
+
else
|
|
526
|
+
this.log.write("warning", `${this.label}: ${m.name} exited (${signal ?? `code ${code}`}) ` +
|
|
527
|
+
"during the startup of the fleet");
|
|
528
|
+
save();
|
|
529
|
+
});
|
|
530
|
+
save();
|
|
531
|
+
return child;
|
|
532
|
+
};
|
|
533
|
+
/* start the agent relay first (if any), as the agents report to it, and then all
|
|
534
|
+
agents, awaiting the successful startup of each one, where the agents get the URL
|
|
535
|
+
of the agent relay and, through the environment, its actual token (as they would
|
|
536
|
+
otherwise resolve one on their own, potentially deviating from it) */
|
|
537
|
+
let failure = null;
|
|
538
|
+
let target = null;
|
|
539
|
+
const relay = members.find((m) => m.kind === "relay");
|
|
540
|
+
if (relay !== undefined) {
|
|
541
|
+
const child = launch(relay);
|
|
542
|
+
const fail = await this.awaitChild(child, () => (target = this.relayUrl(child.pid, plan.relay?.insecure ?? false)) !== null, RELAY_STARTUP_MS);
|
|
543
|
+
if (fail !== null)
|
|
544
|
+
failure = `relay ${fail}`;
|
|
545
|
+
}
|
|
546
|
+
if (failure === null && !stopping) {
|
|
547
|
+
const agents = members.filter((m) => m.kind !== "relay");
|
|
548
|
+
const fails = await Promise.all(agents.map((m) => {
|
|
549
|
+
if (target !== null) {
|
|
550
|
+
m.args.push("--agent-relay", target.url);
|
|
551
|
+
if (target.token !== "")
|
|
552
|
+
m.env.ASE_AGENT_RELAY_TOKEN = target.token;
|
|
553
|
+
}
|
|
554
|
+
const child = launch(m);
|
|
555
|
+
const state = path.join(processDir(), `${m.name}.yaml`);
|
|
556
|
+
return this.awaitChild(child, () => readState(state)?.pid === child.pid, AGENT_STARTUP_MS)
|
|
557
|
+
.then((fail) => fail !== null ? `${m.name} ${fail}` : null);
|
|
558
|
+
}));
|
|
559
|
+
failure = fails.find((fail) => fail !== null) ?? null;
|
|
560
|
+
}
|
|
561
|
+
/* on a failed startup stop all members again */
|
|
562
|
+
if (failure !== null && !stopping) {
|
|
563
|
+
stopping = true;
|
|
564
|
+
proceed();
|
|
565
|
+
await finished;
|
|
566
|
+
throw new Error(`${this.label}: ${failure}`);
|
|
567
|
+
}
|
|
568
|
+
if (!stopping) {
|
|
569
|
+
ready = true;
|
|
570
|
+
for (const m of members)
|
|
571
|
+
if (m.child === null && m.timer === undefined)
|
|
572
|
+
restart(m, "exited during the startup of the fleet");
|
|
573
|
+
save();
|
|
574
|
+
this.log.write("info", `${this.label}: started ${members.map((m) => m.name).join(", ")}`);
|
|
575
|
+
}
|
|
576
|
+
return finished;
|
|
577
|
+
}
|
|
578
|
+
/* stop the orphaned members of a fleet whose process is gone without stopping them (e.g.
|
|
579
|
+
killed by SIGKILL, as the members run in their own process groups), in order: first all
|
|
580
|
+
agents, then the agent relay, where a member counts as orphaned only if its own process
|
|
581
|
+
state (resp. the per-user relay configuration) still records the process of the fleet
|
|
582
|
+
state (and, if recorded, the process still has the start time recorded in the fleet state,
|
|
583
|
+
so a process id reused meanwhile by an unrelated process does not count), and resolve
|
|
584
|
+
to the names of the stopped members */
|
|
585
|
+
async reap(state) {
|
|
586
|
+
const dir = processDir();
|
|
587
|
+
const members = Object.entries(state.members ?? {}).flatMap(([name, m]) => m.pid !== null ? [{ name, kind: m.kind, pid: m.pid, start: m.start ?? "" }] : []);
|
|
588
|
+
const stopped = [];
|
|
589
|
+
await Promise.all(members.filter((m) => m.kind !== "relay").map(async (m) => {
|
|
590
|
+
const file = path.join(dir, `${m.name}.yaml`);
|
|
591
|
+
const agent = readState(file);
|
|
592
|
+
if (agent === null || agent.pid !== m.pid || !(await stateAlive(agent)))
|
|
593
|
+
return;
|
|
594
|
+
this.log.write("info", `${this.label}: stopping orphaned ${m.name} (process ${m.pid})`);
|
|
595
|
+
if (await this.terminate(m.pid, 5 * 60 * 1000))
|
|
596
|
+
stopped.push(m.name);
|
|
597
|
+
fs.rmSync(file, { force: true });
|
|
598
|
+
}));
|
|
599
|
+
const relay = members.find((m) => m.kind === "relay");
|
|
600
|
+
if (relay !== undefined && Number(this.relayConfig()("pid")) === relay.pid
|
|
601
|
+
&& await stateAlive({ pid: relay.pid, start: relay.start })) {
|
|
602
|
+
this.log.write("info", `${this.label}: stopping orphaned ${relay.name} (process ${relay.pid})`);
|
|
603
|
+
if (await this.terminate(relay.pid, 5000))
|
|
604
|
+
stopped.push(relay.name);
|
|
605
|
+
}
|
|
606
|
+
return stopped;
|
|
607
|
+
}
|
|
608
|
+
/* init flow: create the initial configuration file for a task lifecycle model (default:
|
|
609
|
+
the configured one), validated like on start, where an already existing configuration
|
|
610
|
+
file is overwritten only on request */
|
|
611
|
+
async doInit(opts) {
|
|
612
|
+
const file = path.resolve(opts.config ?? path.join(path.dirname(processDir()), "fleet.yaml"));
|
|
613
|
+
const tool = opts.tool ?? agentDefaults().tool;
|
|
614
|
+
if (!["claude", "copilot", "codex"].includes(tool))
|
|
615
|
+
throw new Error(`${this.label}: invalid agent tool "${tool}" (expected "claude", "copilot", or "codex")`);
|
|
616
|
+
let lifecycle = opts.lifecycle;
|
|
617
|
+
if (lifecycle === undefined) {
|
|
618
|
+
const cfg = new Config("config", configSchema, this.log);
|
|
619
|
+
cfg.read();
|
|
620
|
+
const value = cfg.get("project.task.lifecycle");
|
|
621
|
+
lifecycle = String((isScalar(value) ? value.value : value) ?? "") || "solo";
|
|
622
|
+
}
|
|
623
|
+
if (!Object.hasOwn(initPlanned, lifecycle))
|
|
624
|
+
throw new Error(`${this.label}: invalid task lifecycle model "${lifecycle}" ` +
|
|
625
|
+
"(expected \"solo\", \"team\", or \"enterprise\")");
|
|
626
|
+
if (fs.existsSync(file) && !opts.force)
|
|
627
|
+
throw new Error(`${this.label}: configuration "${file}" already exists (overwrite it with "--force")`);
|
|
628
|
+
const text = initConfig(tool, lifecycle);
|
|
629
|
+
await this.load(file, text);
|
|
630
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
631
|
+
fs.writeFileSync(file, text, "utf8");
|
|
632
|
+
this.log.write("info", `${this.label}: created configuration "${file}" for task lifecycle model "${lifecycle}" ` +
|
|
633
|
+
`(start the fleet with "ase agent fleet start${opts.config !== undefined ? ` -c ${opts.config}` : ""}")`);
|
|
634
|
+
return 0;
|
|
635
|
+
}
|
|
636
|
+
/* start flow: validate the configuration and detach the fleet (or run it in the foreground or as the detached process) */
|
|
637
|
+
async doStart(opts) {
|
|
638
|
+
const dir = processDir();
|
|
639
|
+
const stateFile = path.join(dir, "fleet.yaml");
|
|
640
|
+
const file = path.resolve(opts.config ?? path.join(path.dirname(dir), "fleet.yaml"));
|
|
641
|
+
const serve = process.env[SERVE_ENV] === "1";
|
|
642
|
+
Reflect.deleteProperty(process.env, SERVE_ENV);
|
|
643
|
+
/* validate the configuration, where the fleet runs in the foreground
|
|
644
|
+
if requested either on the command line or in the configuration */
|
|
645
|
+
const plan = await this.load(file);
|
|
646
|
+
const foreground = opts.foreground === true || plan.foreground;
|
|
647
|
+
/* idempotent start: an already running fleet is left alone */
|
|
648
|
+
const state = readState(stateFile);
|
|
649
|
+
if (state !== null && await stateAlive(state)) {
|
|
650
|
+
if (foreground || serve)
|
|
651
|
+
throw new Error(`${this.label}: already running (pid ${state.pid}) (stop it first with "ase agent fleet stop")`);
|
|
652
|
+
this.log.write("info", `${this.label}: already running (pid ${state.pid})`);
|
|
653
|
+
return 0;
|
|
654
|
+
}
|
|
655
|
+
/* stop the orphaned members of a fleet gone without stopping them,
|
|
656
|
+
as they would block the startup of the members of the same names */
|
|
657
|
+
if (state !== null) {
|
|
658
|
+
await this.reap(state);
|
|
659
|
+
fs.rmSync(stateFile, { force: true });
|
|
660
|
+
}
|
|
661
|
+
/* the per-user agent relay is owned by the fleet and hence
|
|
662
|
+
must not run already (e.g. started standalone or by another fleet) */
|
|
663
|
+
if (plan.relay !== null) {
|
|
664
|
+
const pid = Number(this.relayConfig()("pid"));
|
|
665
|
+
if (pidAlive(pid))
|
|
666
|
+
throw new Error(`${this.label}: agent relay already running (pid ${pid}) ` +
|
|
667
|
+
"(stop it first with \"ase agent relay stop\", or remove the section \"relay\")");
|
|
668
|
+
}
|
|
669
|
+
/* spawn the fleet detached, logging into its log file, and await the startup of all its members */
|
|
670
|
+
if (!foreground && !serve) {
|
|
671
|
+
const logFile = path.join(dir, "fleet.log");
|
|
672
|
+
const child = this.spawnDetached(["-l", this.log.logLevel(), "agent", "fleet", "start", "-c", file], { [SERVE_ENV]: "1" }, logFile);
|
|
673
|
+
await this.awaitDetached(child, logFile, () => {
|
|
674
|
+
const s = readState(stateFile);
|
|
675
|
+
return s !== null && s.pid === child.pid && s.ready;
|
|
676
|
+
}, RELAY_STARTUP_MS + AGENT_STARTUP_MS + 10 * 1000);
|
|
677
|
+
this.log.write("info", `${this.label}: started (pid ${child.pid}), logging to "${logFile}"`);
|
|
678
|
+
return 0;
|
|
679
|
+
}
|
|
680
|
+
/* run the fleet in this process */
|
|
681
|
+
return this.supervise(file, plan);
|
|
682
|
+
}
|
|
683
|
+
/* status flow: report whether the fleet is running, and the state of its members */
|
|
684
|
+
async doStatus() {
|
|
685
|
+
const state = readState(path.join(processDir(), "fleet.yaml"));
|
|
686
|
+
if (state === null || !(await stateAlive(state))) {
|
|
687
|
+
await writeStdout(`${this.label}: not running\n`);
|
|
688
|
+
return 1;
|
|
689
|
+
}
|
|
690
|
+
await writeStdout(`${this.label}: running (pid ${state.pid}, configuration "${state.config}")` +
|
|
691
|
+
(state.ready ? "" : ", starting up") + "\n" +
|
|
692
|
+
Object.entries(state.members ?? {}).map(([name, m]) => ` ${name} (${m.kind}): ${m.pid !== null ? `running (pid ${m.pid})` : "awaiting its restart"}` +
|
|
693
|
+
(m.restarts > 0 ? `, restarted ${m.restarts} times` : "") + "\n").join(""));
|
|
694
|
+
return 0;
|
|
695
|
+
}
|
|
696
|
+
/* stop flow: terminate the fleet process and wait until it (and hence all its members) is gone,
|
|
697
|
+
or, with the fleet process already gone, stop its orphaned members */
|
|
698
|
+
async doStop() {
|
|
699
|
+
const stateFile = path.join(processDir(), "fleet.yaml");
|
|
700
|
+
const state = readState(stateFile);
|
|
701
|
+
if (state === null || !(await stateAlive(state))) {
|
|
702
|
+
const stopped = state !== null ? await this.reap(state) : [];
|
|
703
|
+
this.log.write("info", `${this.label}: not running` +
|
|
704
|
+
(stopped.length > 0 ? ` (stopped its orphaned members ${stopped.join(", ")})` : ""));
|
|
705
|
+
fs.rmSync(stateFile, { force: true });
|
|
706
|
+
return 0;
|
|
707
|
+
}
|
|
708
|
+
if (await this.terminate(state.pid, 5 * 60 * 1000))
|
|
709
|
+
this.log.write("info", `${this.label}: stopped (process ${state.pid})`);
|
|
710
|
+
else
|
|
711
|
+
this.log.write("info", `${this.label}: not running (process ${state.pid} already gone)`);
|
|
712
|
+
fs.rmSync(stateFile, { force: true });
|
|
713
|
+
return 0;
|
|
714
|
+
}
|
|
715
|
+
/* register the CLI sub-command group and its commands
|
|
716
|
+
"init", "start", "status", "log", and "stop" below the CLI command <parent> */
|
|
717
|
+
register(parent) {
|
|
718
|
+
const group = this.group(parent);
|
|
719
|
+
/* register CLI sub-command "init" */
|
|
720
|
+
group
|
|
721
|
+
.command("init")
|
|
722
|
+
.description("Create the initial configuration file for a task lifecycle model, " +
|
|
723
|
+
"with the agent relay, the edit request agent, and the effort estimation agent, " +
|
|
724
|
+
"plus the four phase agents for \"enterprise\" resp. an ad-hoc implementation agent for \"team\"")
|
|
725
|
+
.option("-c, --config <file>", "YAML configuration file to create (default: \".ase/fleet.yaml\")")
|
|
726
|
+
.option("-t, --tool <tool>", "agent tool of all agents (\"claude\", \"copilot\", or \"codex\"; " +
|
|
727
|
+
"default: $ASE_TOOL, else \"claude\")")
|
|
728
|
+
.option("--lifecycle <model>", "task lifecycle model (\"solo\", \"team\", or \"enterprise\"; " +
|
|
729
|
+
"default: configured \"project.task.lifecycle\")")
|
|
730
|
+
.option("--force", "overwrite an already existing configuration file")
|
|
731
|
+
.action(async (opts) => {
|
|
732
|
+
process.exitCode = await this.doInit(opts);
|
|
733
|
+
});
|
|
734
|
+
/* register CLI sub-command "start" */
|
|
735
|
+
group
|
|
736
|
+
.command("start")
|
|
737
|
+
.description("Start the fleet of the configuration file in the background " +
|
|
738
|
+
"(idempotent if already running) or in the foreground")
|
|
739
|
+
.option("-c, --config <file>", "YAML configuration file with the optional section \"fleet\" " +
|
|
740
|
+
"(options of the fleet itself), the optional section \"relay\" (options of \"ase agent relay start\"), " +
|
|
741
|
+
"the optional section \"default\" (default options of all agents), " +
|
|
742
|
+
"and one section per agent, named by the agent name, with the agent options and an optional " +
|
|
743
|
+
"\"kind\" (default: the section name) (default: \".ase/fleet.yaml\")")
|
|
744
|
+
.option("-f, --foreground", "run the fleet in the foreground, logging to the terminal, " +
|
|
745
|
+
"until it is interrupted or stopped (default: option \"foreground\" of section \"fleet\")")
|
|
746
|
+
.action(async (opts) => {
|
|
747
|
+
process.exitCode = await this.doStart(opts);
|
|
748
|
+
});
|
|
749
|
+
/* register CLI sub-command "status" */
|
|
750
|
+
group
|
|
751
|
+
.command("status")
|
|
752
|
+
.description("Report whether the fleet is running, and the state of its members")
|
|
753
|
+
.action(async () => {
|
|
754
|
+
process.exitCode = await this.doStatus();
|
|
755
|
+
});
|
|
756
|
+
/* register CLI sub-command "log" (of the fleet and all its members,
|
|
757
|
+
each line prefixed with their name, where the name and the level tag
|
|
758
|
+
are colored on a terminal only) */
|
|
759
|
+
this.registerLog(group, () => path.join(processDir(), "fleet.log"), undefined, process.stdout.isTTY === true ?
|
|
760
|
+
(line) => line
|
|
761
|
+
.replace(logLevelRe, (_match, head, level) => `${head}${this.log.tag(level.toLowerCase(), true)}`)
|
|
762
|
+
.replace(logPrefixRe, (_match, name, sep) => `${tint(name)}${sep}`) :
|
|
763
|
+
undefined);
|
|
764
|
+
/* register CLI sub-command "stop" */
|
|
765
|
+
group
|
|
766
|
+
.command("stop")
|
|
767
|
+
.description("Stop the fleet with all its members")
|
|
768
|
+
.action(async () => {
|
|
769
|
+
process.exitCode = await this.doStop();
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
}
|