@felixdotgo/aidlc-kit 0.2.0 → 0.3.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/DEVELOPMENT.md +13 -26
- package/README.md +35 -39
- package/dist/assets/.agents/aidlc/orchestrator.md +1 -1
- package/dist/assets/.agents/aidlc/schemas/state.schema.json +2 -0
- package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +94 -48
- package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -4
- package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +21 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +52 -78
- package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +88 -14
- package/dist/assets/.agents/aidlc/scripts/lib/loop-env.mjs +279 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +20 -5
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +10 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +2 -1
- package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +3 -2
- package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +9 -3
- package/dist/assets/.agents/aidlc/scripts/lib/loop-holder.mjs +376 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +11 -3
- package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +67 -61
- package/dist/assets/.agents/aidlc/scripts/lib/loop-leases.mjs +268 -0
- package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +78 -17
- package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +42 -10
- package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +66 -9
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +16 -56
- package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +12 -6
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +14 -2
- package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-log.mjs +158 -0
- package/dist/assets/.agents/aidlc/scripts/lib/repo-head.mjs +22 -0
- package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +11 -0
- package/dist/assets/.agents/aidlc/scripts/lib/task-runtime-migrate.mjs +75 -0
- package/dist/assets/.agents/aidlc/scripts/lib/task-runtime.mjs +97 -0
- package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +87 -4
- package/dist/assets/.agents/aidlc/scripts/loop.cmd +101 -0
- package/dist/assets/.agents/aidlc/scripts/loop.sh +71 -0
- package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +10 -4
- package/dist/assets/.agents/aidlc/scripts/state.mjs +16 -8
- package/dist/assets/ui/changes.js +83 -0
- package/dist/assets/ui/common.js +664 -0
- package/dist/assets/ui/dashboard.css +281 -59
- package/dist/assets/ui/dashboard.js +77 -0
- package/dist/assets/ui/detail.js +159 -0
- package/dist/assets/ui/live-log.js +332 -0
- package/dist/services/aidlc-coordination/package.json +1 -1
- package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +11 -0
- package/dist/src/cli/argv.js +10 -4
- package/dist/src/cli/commands.js +52 -22
- package/dist/src/cli/loop-launcher.d.ts +47 -0
- package/dist/src/cli/loop-launcher.js +322 -0
- package/dist/src/cli.js +3 -0
- package/dist/src/installer.js +4 -2
- package/dist/src/model/records.d.ts +4 -0
- package/dist/src/state/validate.js +5 -0
- package/dist/src/task-ui.d.ts +31 -52
- package/dist/src/task-ui.js +25 -1562
- package/dist/src/ui/assets.d.ts +14 -0
- package/dist/src/ui/assets.js +45 -0
- package/dist/src/ui/changes.d.ts +83 -0
- package/dist/src/ui/changes.js +498 -0
- package/dist/src/ui/containers.d.ts +71 -0
- package/dist/src/ui/containers.js +214 -0
- package/dist/src/ui/daemon.d.ts +75 -0
- package/dist/src/ui/daemon.js +232 -0
- package/dist/src/ui/data.d.ts +42 -0
- package/dist/src/ui/data.js +89 -0
- package/dist/src/ui/docs.d.ts +13 -0
- package/dist/src/ui/docs.js +39 -0
- package/dist/src/ui/files.d.ts +3 -0
- package/dist/src/ui/files.js +20 -0
- package/dist/src/ui/http.d.ts +14 -0
- package/dist/src/ui/http.js +80 -0
- package/dist/src/ui/live.d.ts +5 -0
- package/dist/src/ui/live.js +43 -0
- package/dist/src/ui/markdown.d.ts +2 -0
- package/dist/src/ui/markdown.js +127 -0
- package/dist/src/ui/options.d.ts +48 -0
- package/dist/src/ui/options.js +81 -0
- package/dist/src/ui/security.d.ts +34 -0
- package/dist/src/ui/security.js +38 -0
- package/dist/src/ui/server.d.ts +39 -0
- package/dist/src/ui/server.js +347 -0
- package/dist/src/ui/sse.d.ts +14 -0
- package/dist/src/ui/sse.js +281 -0
- package/dist/src/ui/views/board.d.ts +7 -0
- package/dist/src/ui/views/board.js +131 -0
- package/dist/src/ui/views/changes.d.ts +19 -0
- package/dist/src/ui/views/changes.js +154 -0
- package/dist/src/ui/views/components.d.ts +56 -0
- package/dist/src/ui/views/components.js +113 -0
- package/dist/src/ui/views/docs.d.ts +15 -0
- package/dist/src/ui/views/docs.js +64 -0
- package/dist/src/ui/views/layout.d.ts +44 -0
- package/dist/src/ui/views/layout.js +233 -0
- package/dist/src/ui/views/task.d.ts +25 -0
- package/dist/src/ui/views/task.js +320 -0
- package/dist/src/upgrade.js +5 -2
- package/dist/src/workflow.d.ts +4 -0
- package/dist/src/workflow.js +33 -1
- package/docs/README.md +48 -33
- package/docs/architecture.md +101 -38
- package/docs/command-reference.md +269 -93
- package/docs/configuration.md +62 -42
- package/docs/coordination-service.md +85 -36
- package/docs/design-decisions.md +35 -31
- package/docs/development.md +111 -61
- package/docs/loop-local-run.md +135 -402
- package/docs/loop-runbook.md +84 -105
- package/docs/operating-workflow.md +55 -42
- package/docs/testing-and-release.md +70 -51
- package/docs/troubleshooting.md +165 -69
- package/package.json +1 -1
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-config environment for the local loop daemon.
|
|
3
|
+
*
|
|
4
|
+
* Merges three sources, highest priority first:
|
|
5
|
+
* 1. the invoking shell (`process.env`) — never overridden;
|
|
6
|
+
* 2. the AIDLC env file (`AIDLC_ENV_FILE`, default `.agents/loop.env`), a minimal dotenv;
|
|
7
|
+
* 3. values derived from the configured providers' own config files:
|
|
8
|
+
* - `AIDLC_LOOP_EVENT_LOG` → `.agents/data/loop/ui-events.jsonl` (dashboard live log);
|
|
9
|
+
* - `AIDLC_OPENCODE_MODEL` → `model` of the OpenCode config (project > OPENCODE_CONFIG > global);
|
|
10
|
+
* - worker credentials → OpenCode `auth.json`, Codex `$CODEX_HOME/auth.json`
|
|
11
|
+
* (`OPENAI_API_KEY`), Claude Code settings `env.ANTHROPIC_API_KEY` (local > project > user).
|
|
12
|
+
*
|
|
13
|
+
* Only providers listed in `loop.agents` are inspected. Secret values are never printed,
|
|
14
|
+
* logged, written to disk, or returned in a report; the report names their source only.
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { delimiter, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
20
|
+
import { isLoopProvider, OPENCODE_MODEL } from "./loop-drivers.mjs";
|
|
21
|
+
import { workerCredentialKeys } from "./loop-worker-docker.mjs";
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_ENV_FILE = ".agents/loop.env";
|
|
24
|
+
const KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
25
|
+
const MAX_FILE_BYTES = 1024 * 1024;
|
|
26
|
+
/** The only values a report may show; every other variable (any env-file key) is "set"/"missing". */
|
|
27
|
+
const DISPLAY_KEYS = new Set(["AIDLC_OPENCODE_MODEL", "AIDLC_LOOP_EVENT_LOG", "AIDLC_WORKER_DOCKER_IMAGE", "AIDLC_LOCAL_WORKTREES_ROOT", "AIDLC_ENV_FILE"]);
|
|
28
|
+
|
|
29
|
+
export const isSecretKey = (key) => !DISPLAY_KEYS.has(key);
|
|
30
|
+
|
|
31
|
+
/** Minimal dotenv: `KEY=VALUE`, optional `export `, `#` comments, single/double quotes. No expansion. */
|
|
32
|
+
export function parseEnvFile(text, label = "env file") {
|
|
33
|
+
const values = {};
|
|
34
|
+
const lines = String(text).replace(/^/, "").split(/\r?\n/);
|
|
35
|
+
lines.forEach((raw, index) => {
|
|
36
|
+
const line = raw.trim();
|
|
37
|
+
if (!line || line.startsWith("#")) return;
|
|
38
|
+
const match = /^(?:export\s+)?([^=\s]+)\s*=\s*(.*)$/.exec(line);
|
|
39
|
+
if (!match || !KEY.test(match[1])) throw new Error(`${label}:${index + 1}: expected KEY=VALUE`);
|
|
40
|
+
const [, key, rest] = match;
|
|
41
|
+
let value = rest;
|
|
42
|
+
const quote = value[0];
|
|
43
|
+
if (quote === '"' || quote === "'") {
|
|
44
|
+
const end = value.indexOf(quote, 1);
|
|
45
|
+
if (end < 0) throw new Error(`${label}:${index + 1}: unterminated ${quote === '"' ? "double" : "single"} quote`);
|
|
46
|
+
const tail = value.slice(end + 1).trim();
|
|
47
|
+
if (tail && !tail.startsWith("#")) throw new Error(`${label}:${index + 1}: unexpected text after the closing quote`);
|
|
48
|
+
value = value.slice(1, end);
|
|
49
|
+
if (quote === '"') value = value.replace(/\\n/g, "\n").replace(/\\"/g, '"');
|
|
50
|
+
} else {
|
|
51
|
+
value = value.replace(/\s+#.*$/, "").trim();
|
|
52
|
+
}
|
|
53
|
+
values[key] = value;
|
|
54
|
+
});
|
|
55
|
+
return values;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The env file to load: `AIDLC_ENV_FILE` (relative to root) or the default; `explicit` marks a user choice. */
|
|
59
|
+
export function resolveEnvFile(root, env = process.env) {
|
|
60
|
+
const chosen = typeof env.AIDLC_ENV_FILE === "string" && env.AIDLC_ENV_FILE.trim() ? env.AIDLC_ENV_FILE.trim() : "";
|
|
61
|
+
return { path: resolve(root, chosen || DEFAULT_ENV_FILE), explicit: Boolean(chosen) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const realOrSelf = (path) => { try { return realpathSync(path); } catch { return resolve(path); } };
|
|
65
|
+
const within = (base, target) => { const rel = relative(base, target); return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); };
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Reads a small regular file (symlinks are followed). With `confine`, a symlinked file must
|
|
69
|
+
* resolve inside the project or the home directory, both compared by their real paths so a
|
|
70
|
+
* project reached through a symlink (macOS /tmp, ~/code -> /mnt/...) still works.
|
|
71
|
+
*/
|
|
72
|
+
function readTrustedFile(path, { root, home }, { confine = false } = {}) {
|
|
73
|
+
if (!existsSync(path)) return undefined;
|
|
74
|
+
const real = realpathSync(path);
|
|
75
|
+
if (confine && lstatSync(path).isSymbolicLink() && !within(realOrSelf(root), real) && !(home && within(realOrSelf(home), real))) throw new Error(`${path} links outside the project and home directory`);
|
|
76
|
+
const stat = statSync(real);
|
|
77
|
+
if (!stat.isFile()) throw new Error(`${path} is not a regular file`);
|
|
78
|
+
if (stat.size > MAX_FILE_BYTES) throw new Error(`${path} is larger than 1 MiB`);
|
|
79
|
+
return readFileSync(real, "utf8");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** JSON with `//` and block comments and trailing commas (OpenCode `.jsonc`). Strings are kept intact. */
|
|
83
|
+
export function parseJsonc(text) {
|
|
84
|
+
let out = "";
|
|
85
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
86
|
+
const char = text[i];
|
|
87
|
+
if (char === '"') {
|
|
88
|
+
let j = i + 1;
|
|
89
|
+
while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
|
|
90
|
+
out += text.slice(i, j + 1); i = j; continue;
|
|
91
|
+
}
|
|
92
|
+
if (char === "/" && text[i + 1] === "/") { while (i < text.length && text[i] !== "\n") i += 1; out += "\n"; continue; }
|
|
93
|
+
if (char === "/" && text[i + 1] === "*") { const end = text.indexOf("*/", i + 2); i = end < 0 ? text.length : end + 1; continue; }
|
|
94
|
+
out += char;
|
|
95
|
+
}
|
|
96
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const readJson = (path, context, { jsonc = false } = {}) => {
|
|
100
|
+
try {
|
|
101
|
+
const text = readTrustedFile(path, context);
|
|
102
|
+
return text === undefined ? undefined : jsonc ? parseJsonc(text) : JSON.parse(text);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
// Never echo a parser message: V8 quotes the source text around the error, which may be a key.
|
|
105
|
+
const reason = error instanceof SyntaxError ? "not valid JSON" : error instanceof Error ? error.message : String(error);
|
|
106
|
+
context.problems.push({ level: "warning", key: "", message: `ignored ${path}: ${reason}` });
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Unique providers of `loop.agents` (lead first), and the worker subset. */
|
|
112
|
+
export function loopProviders(config) {
|
|
113
|
+
const lead = config?.loop?.agents?.lead;
|
|
114
|
+
const workers = Object.values(config?.loop?.agents?.workers ?? {}).filter(isLoopProvider);
|
|
115
|
+
return { lead: isLoopProvider(lead) ? lead : undefined, workers: [...new Set(workers)], all: [...new Set([...(isLoopProvider(lead) ? [lead] : []), ...workers])] };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const xdg = (env, name, home, fallback) => (env[name] && isAbsolute(env[name]) ? env[name] : join(home, fallback));
|
|
119
|
+
|
|
120
|
+
/** OpenCode `model`, following its documented precedence (later wins): global < OPENCODE_CONFIG < project. */
|
|
121
|
+
function opencodeModel(context) {
|
|
122
|
+
const { root, env, home } = context;
|
|
123
|
+
const configDir = join(xdg(env, "XDG_CONFIG_HOME", home, ".config"), "opencode");
|
|
124
|
+
const candidates = [
|
|
125
|
+
join(configDir, "opencode.json"), join(configDir, "opencode.jsonc"),
|
|
126
|
+
...(env.OPENCODE_CONFIG ? [resolve(root, env.OPENCODE_CONFIG)] : []),
|
|
127
|
+
join(root, "opencode.json"), join(root, "opencode.jsonc"),
|
|
128
|
+
];
|
|
129
|
+
let found;
|
|
130
|
+
for (const path of candidates) {
|
|
131
|
+
const config = readJson(path, context, { jsonc: true });
|
|
132
|
+
if (typeof config?.model === "string" && config.model.trim()) found = { value: config.model.trim(), source: `derived:${path}` };
|
|
133
|
+
}
|
|
134
|
+
return found;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Worker credentials a provider's own login/config can supply, as `{ key, value, source }`. */
|
|
138
|
+
function providerCredentials(provider, env, context) {
|
|
139
|
+
const { root, home } = context;
|
|
140
|
+
if (provider === "opencode") {
|
|
141
|
+
const path = join(xdg(env, "XDG_DATA_HOME", home, join(".local", "share")), "opencode", "auth.json");
|
|
142
|
+
const auth = readJson(path, context);
|
|
143
|
+
if (!auth || typeof auth !== "object") return [];
|
|
144
|
+
const prefix = String(env.AIDLC_OPENCODE_MODEL ?? "").split("/")[0];
|
|
145
|
+
const keys = workerCredentialKeys("opencode", env);
|
|
146
|
+
// The OpenCode Zen/Go key only stands in for OPENCODE_API_KEY, never for a provider's own key.
|
|
147
|
+
const ids = keys.includes("OPENCODE_API_KEY") ? [prefix, "opencode", "opencode-go"] : [prefix];
|
|
148
|
+
const entry = ids.map((id) => auth[id]).find((value) => value?.type === "api" && typeof value.key === "string" && value.key);
|
|
149
|
+
return entry ? keys.map((key) => ({ key, value: entry.key, source: `derived:${path}` })) : [];
|
|
150
|
+
}
|
|
151
|
+
if (provider === "codex") {
|
|
152
|
+
const path = join(env.CODEX_HOME && isAbsolute(env.CODEX_HOME) ? env.CODEX_HOME : join(home, ".codex"), "auth.json");
|
|
153
|
+
const auth = readJson(path, context);
|
|
154
|
+
if (typeof auth?.OPENAI_API_KEY === "string" && auth.OPENAI_API_KEY) return [{ key: "OPENAI_API_KEY", value: auth.OPENAI_API_KEY, source: `derived:${path}` }];
|
|
155
|
+
if (auth?.tokens) context.notes.push({ provider, message: "Codex is signed in with ChatGPT; Docker workers need an API key (codex login --with-api-key, or OPENAI_API_KEY in the env file)" });
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
if (provider === "claude") {
|
|
159
|
+
const userDir = env.CLAUDE_CONFIG_DIR && isAbsolute(env.CLAUDE_CONFIG_DIR) ? env.CLAUDE_CONFIG_DIR : join(home, ".claude");
|
|
160
|
+
let found = [];
|
|
161
|
+
for (const path of [join(userDir, "settings.json"), join(root, ".claude", "settings.json"), join(root, ".claude", "settings.local.json")]) {
|
|
162
|
+
const value = readJson(path, context)?.env?.ANTHROPIC_API_KEY;
|
|
163
|
+
if (typeof value === "string" && value) found = [{ key: "ANTHROPIC_API_KEY", value, source: `derived:${path}` }];
|
|
164
|
+
}
|
|
165
|
+
return found;
|
|
166
|
+
}
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** True when `command` resolves on PATH (PATHEXT-aware on Windows). */
|
|
171
|
+
export function hasCommand(command, env = process.env) {
|
|
172
|
+
const exts = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
|
|
173
|
+
return String(env.PATH ?? "").split(delimiter).filter(Boolean).some((dir) => exts.some((ext) => { try { return statSync(join(dir, command + ext)).isFile(); } catch { return false; } }));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Whether git would ignore the env file; outside a work tree there is nothing to commit. */
|
|
177
|
+
const gitignored = (root, envFile) => {
|
|
178
|
+
const rel = relative(root, envFile);
|
|
179
|
+
if (rel.startsWith("..") || isAbsolute(rel)) return true;
|
|
180
|
+
const result = spawnSync("git", ["check-ignore", "-q", "--", rel.split(sep).join("/")], { cwd: root, stdio: "ignore", timeout: 5000 });
|
|
181
|
+
return result.error ? true : result.status !== 1;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Resolves the daemon environment. `applied` holds only the keys to add to the
|
|
186
|
+
* process (file + derived); shell keys are left untouched. `problems` with level
|
|
187
|
+
* `error` must stop the daemon before it starts.
|
|
188
|
+
*/
|
|
189
|
+
export function loadLoopEnv({ root, config, env = process.env, home = homedir(), commandExists = hasCommand } = {}) {
|
|
190
|
+
root = resolve(root ?? ".");
|
|
191
|
+
const problems = [], notes = [];
|
|
192
|
+
const context = { root, env, home, problems, notes };
|
|
193
|
+
const providers = loopProviders(config);
|
|
194
|
+
const mode = config?.loop?.mode ?? "off";
|
|
195
|
+
const sources = {}, applied = {};
|
|
196
|
+
const merged = { ...env };
|
|
197
|
+
// Anything the shell exports, even an empty value, is never overridden.
|
|
198
|
+
const set = (key, value, source) => { if (merged[key] !== undefined) return false; merged[key] = value; applied[key] = value; sources[key] = source; return true; };
|
|
199
|
+
for (const key of Object.keys(env)) sources[key] = "shell";
|
|
200
|
+
|
|
201
|
+
const envFile = resolveEnvFile(root, env);
|
|
202
|
+
let fileKeys = [];
|
|
203
|
+
try {
|
|
204
|
+
const text = readTrustedFile(envFile.path, context, { confine: true });
|
|
205
|
+
if (text === undefined) {
|
|
206
|
+
if (envFile.explicit) problems.push({ level: "error", key: "AIDLC_ENV_FILE", message: `env file not found: ${envFile.path}` });
|
|
207
|
+
} else {
|
|
208
|
+
const values = parseEnvFile(text, relative(root, envFile.path) || envFile.path);
|
|
209
|
+
fileKeys = Object.keys(values);
|
|
210
|
+
for (const [key, value] of Object.entries(values)) if (value !== "") set(key, value, "file");
|
|
211
|
+
if (!gitignored(root, envFile.path)) problems.push({ level: "warning", key: "", message: `${relative(root, envFile.path)} is not git-ignored; add "loop.env" to .agents/.gitignore so credentials are never committed` });
|
|
212
|
+
}
|
|
213
|
+
} catch (error) {
|
|
214
|
+
problems.push({ level: "error", key: "AIDLC_ENV_FILE", message: error instanceof Error ? error.message : String(error) });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (mode !== "local" && mode !== "fleet") problems.push({ level: "error", key: "", message: 'loop.mode is off: set "loop": { "mode": "local", "agents": { "lead": "<codex|claude|opencode>", "workers": { "worker-a": "<provider>" } } } in .agents/config.json, or run: aidlc-kit init --loop-mode local' });
|
|
218
|
+
else if (!providers.lead || !providers.workers.length) problems.push({ level: "error", key: "", message: 'loop.agents needs a lead and at least one worker provider (codex, claude, or opencode) in .agents/config.json' });
|
|
219
|
+
|
|
220
|
+
set("AIDLC_LOOP_EVENT_LOG", join(root, ".agents", "data", "loop", "ui-events.jsonl"), "derived");
|
|
221
|
+
|
|
222
|
+
const keys = new Set(["AIDLC_LOOP_EVENT_LOG"]);
|
|
223
|
+
if (providers.all.includes("opencode")) {
|
|
224
|
+
keys.add("AIDLC_OPENCODE_MODEL");
|
|
225
|
+
if (!merged.AIDLC_OPENCODE_MODEL) { const found = opencodeModel(context); if (found) set("AIDLC_OPENCODE_MODEL", found.value, found.source); }
|
|
226
|
+
const model = merged.AIDLC_OPENCODE_MODEL;
|
|
227
|
+
if (model === "" && sources.AIDLC_OPENCODE_MODEL === "shell") problems.push({ level: "error", key: "AIDLC_OPENCODE_MODEL", message: "AIDLC_OPENCODE_MODEL is exported empty in the shell, which overrides the env file and opencode.json: unset it or set provider/model" });
|
|
228
|
+
else if (!model) problems.push({ level: "error", key: "AIDLC_OPENCODE_MODEL", message: `opencode needs a model: add AIDLC_OPENCODE_MODEL=<provider/model> to ${relative(root, envFile.path)} or set "model" in opencode.json (list models with: opencode models)` });
|
|
229
|
+
else if (!OPENCODE_MODEL.test(model)) problems.push({ level: "error", key: "AIDLC_OPENCODE_MODEL", message: `AIDLC_OPENCODE_MODEL must look like provider/model[#variant] (from ${sources.AIDLC_OPENCODE_MODEL})` });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const workerKeys = new Set();
|
|
233
|
+
for (const provider of providers.workers) {
|
|
234
|
+
const needed = workerCredentialKeys(provider, merged);
|
|
235
|
+
needed.forEach((key) => workerKeys.add(key));
|
|
236
|
+
if (needed.every((key) => merged[key])) { needed.forEach((key) => keys.add(key)); continue; }
|
|
237
|
+
for (const credential of providerCredentials(provider, merged, context)) set(credential.key, credential.value, credential.source);
|
|
238
|
+
for (const key of needed) {
|
|
239
|
+
keys.add(key);
|
|
240
|
+
if (!merged[key]) {
|
|
241
|
+
const note = notes.find((entry) => entry.provider === provider);
|
|
242
|
+
problems.push({ level: "warning", key, message: note ? note.message : `${provider} workers need ${key} for the build phase: add it to ${relative(root, envFile.path)}` });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (mode === "local" && !commandExists("docker", merged)) problems.push({ level: "warning", key: "", message: "docker was not found on PATH; local workers run in Docker during the build phase" });
|
|
248
|
+
for (const key of ["AIDLC_WORKER_DOCKER_IMAGE", "AIDLC_LOCAL_WORKTREES_ROOT", ...fileKeys]) if (merged[key]) keys.add(key);
|
|
249
|
+
|
|
250
|
+
const entries = [...keys].map((key) => {
|
|
251
|
+
const secret = isSecretKey(key);
|
|
252
|
+
const present = Boolean(merged[key]);
|
|
253
|
+
return { key, set: present, source: present ? sources[key] ?? "shell" : null, secret, ...(present && !secret ? { value: merged[key] } : {}) };
|
|
254
|
+
});
|
|
255
|
+
// Worker keys detected from another tool's config reach Docker workers only, never the
|
|
256
|
+
// host lead (a claude/codex lead would otherwise switch from its own login to API-key
|
|
257
|
+
// billing). Env-file keys are explicit, like shell exports, and reach the lead too.
|
|
258
|
+
const leadExclude = [...workerKeys].filter((key) => key in applied && String(sources[key]).startsWith("derived:"));
|
|
259
|
+
if (leadExclude.length) applied.AIDLC_LEAD_ENV_EXCLUDE = leadExclude.join(",");
|
|
260
|
+
return { root, mode, providers: providers.all, envFile: { path: envFile.path, explicit: envFile.explicit, loaded: fileKeys.length > 0 || existsSync(envFile.path) }, entries, problems, applied, ok: !problems.some((problem) => problem.level === "error") };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** A report safe to print: the `applied` values are dropped. */
|
|
264
|
+
export const publicReport = ({ applied: _applied, ...report }) => report;
|
|
265
|
+
|
|
266
|
+
export function formatLoopEnvReport(report) {
|
|
267
|
+
const lines = [];
|
|
268
|
+
lines.push(`Loop environment (${report.mode} mode)`);
|
|
269
|
+
lines.push(` providers : ${report.providers.length ? report.providers.join(", ") : "none configured"}`);
|
|
270
|
+
lines.push(` env file : ${report.envFile.path}${report.envFile.loaded ? "" : " (not found)"}`);
|
|
271
|
+
const width = Math.max(4, ...report.entries.map((entry) => entry.key.length));
|
|
272
|
+
for (const entry of report.entries) {
|
|
273
|
+
const shown = !entry.set ? "missing" : entry.secret ? "set" : entry.value;
|
|
274
|
+
lines.push(` ${entry.set ? "✓" : "✗"} ${entry.key.padEnd(width)} ${shown}${entry.source ? ` [${entry.source}]` : ""}`);
|
|
275
|
+
}
|
|
276
|
+
for (const problem of report.problems) lines.push(` ${problem.level === "error" ? "ERROR" : "WARN "} ${problem.message}`);
|
|
277
|
+
if (report.ok) lines.push(" ready: the daemon can start");
|
|
278
|
+
return lines.join("\n");
|
|
279
|
+
}
|
|
@@ -9,6 +9,7 @@ import { runFleetSupervisor } from "./loop-fleet-supervisor.mjs";
|
|
|
9
9
|
import { createFleetRemoteRepository } from "./loop-fleet-remote.mjs";
|
|
10
10
|
import { createLeadGitHandoff, createIsolatedWorkerGitHandoff } from "./loop-git.mjs";
|
|
11
11
|
import { workerDockerInvocation, workerCredentialKeys } from "./loop-worker-docker.mjs";
|
|
12
|
+
import { openWorkerLog } from "./loop-worker-log.mjs";
|
|
12
13
|
import { defaultWorkerImageTag, ensureWorkerImage, validWorkerImageTag } from "./loop-worker-image.mjs";
|
|
13
14
|
|
|
14
15
|
const required = (env, name) => { const value = env[name]; if (typeof value !== "string" || !value.trim()) throw new Error(`${name} is required`); return value; };
|
|
@@ -133,13 +134,23 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
|
|
|
133
134
|
if (workerAgent) await preflight(workerAgent);
|
|
134
135
|
const baseHandoff = createIsolatedWorkerGitHandoff({ root, worktreesRoot: resolve(required(env, "AIDLC_FLEET_WORKTREES_ROOT")), remote });
|
|
135
136
|
const sessions = new Map();
|
|
136
|
-
const
|
|
137
|
+
const dropSession = (key, current) => { current.log?.close(); rmSync(current.home,{recursive:true,force:true}); sessions.delete(key); };
|
|
138
|
+
const cleanupSession = (workspace) => { const current=sessions.get(workspace.root); if(current) dropSession(workspace.root, current); };
|
|
139
|
+
// Same container record and raw output tee as the local loop, written under
|
|
140
|
+
// this worker checkout's .agents/data/tasks/<task>/runtime/workers (the
|
|
141
|
+
// index entry carries this host's name). Best-effort: reported once, never fatal.
|
|
142
|
+
const openLog = (job) => {
|
|
143
|
+
const diagnostic = (error) => emit({ type: "diagnostic", taskId: job.taskId, itemId: job.itemId, message: `Worker log unavailable for assignment ${job.assignmentId}: ${error instanceof Error ? error.message : String(error)}` });
|
|
144
|
+
try { return openWorkerLog({ root, taskId: job.taskId, assignmentId: job.assignmentId, itemId: job.itemId, workerId, agent: job.agent, mode: "fleet", onError: diagnostic }); }
|
|
145
|
+
catch (error) { diagnostic(error); return null; }
|
|
146
|
+
};
|
|
137
147
|
const handoff = {
|
|
138
148
|
...baseHandoff,
|
|
139
149
|
async prepare(job) {
|
|
140
150
|
const workspace=await baseHandoff.prepare(job);
|
|
141
151
|
const home=mkdtempSync(join(tmpdir(),"aidlc-worker-home-")); chmodSync(home,0o700);
|
|
142
|
-
|
|
152
|
+
const labels = { "aidlc.task": job.taskId, "aidlc.item": job.itemId, "aidlc.worker": workerId, "aidlc.assignment": job.assignmentId };
|
|
153
|
+
sessions.set(workspace.root,{home,agent:job.agent,assignmentId:job.assignmentId,attempt:job.attempt,epoch:job.epoch,workerId,image,sessionId:null,labels,log:openLog(job),turns:0});
|
|
143
154
|
return workspace;
|
|
144
155
|
},
|
|
145
156
|
async finish(args) { try { return await baseHandoff.finish(args); } finally { cleanupSession(args.workspace); } },
|
|
@@ -151,13 +162,17 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
|
|
|
151
162
|
const credentialEnv = workerCredentialKeys(turn.agent, env);
|
|
152
163
|
const name = `aidlc-worker-${randomUUID()}`;
|
|
153
164
|
let started = false;
|
|
165
|
+
session.turns += 1;
|
|
166
|
+
session.log?.turnStart(name, session.turns);
|
|
154
167
|
try {
|
|
155
168
|
const result = await runAgentTurn({...turn, root:"/workspace", env, spawnProcess: (command,args,options) => {
|
|
156
|
-
const invocation=workerDockerInvocation({workspace:turn.root,image,command,args,credentialEnv,hostEnv:env,uid,gid,sessionHome:session.home});
|
|
169
|
+
const invocation=workerDockerInvocation({workspace:turn.root,image,command,args,credentialEnv,hostEnv:env,uid,gid,sessionHome:session.home,labels:session.labels});
|
|
157
170
|
const dockerArgs=invocation.args.filter((arg,index) => !(index===1 && arg==="--rm"));
|
|
158
171
|
dockerArgs.splice(1,0,"--name",name);
|
|
159
172
|
started = true;
|
|
160
|
-
|
|
173
|
+
const child = spawn(invocation.command,dockerArgs,{...options,cwd:turn.root,env:invocation.env});
|
|
174
|
+
if (session.log) { session.log.tee(child); child.once("close", (code) => session.log.turnEnd(code)); }
|
|
175
|
+
return child;
|
|
161
176
|
}});
|
|
162
177
|
session.sessionId=result.sessionId;
|
|
163
178
|
return result;
|
|
@@ -179,7 +194,7 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
|
|
|
179
194
|
"Complete the scoped item and report remaining risks. The supervisor handles the Git commit and push. End the final answer with a Conventional Commits message on its own line as AI-DLC-COMMIT: type(scope): subject (for example AI-DLC-COMMIT: docs(ui): add web UI improvement proposal), followed by AI-DLC-ITEM-COMPLETE on its own line.",
|
|
180
195
|
...(env.AIDLC_FLEET_CODE_ONLY_RETRY === "1" ? ["This assignment is declared code-only: perform no external side effects beyond the supervisor's Git handoff."] : [])
|
|
181
196
|
].join("\n"), onEvent: emit }); }
|
|
182
|
-
finally { for (const [workspace, entry] of sessions)
|
|
197
|
+
finally { for (const [workspace, entry] of sessions) dropSession(workspace, entry); }
|
|
183
198
|
emit(result);
|
|
184
199
|
if (once || result.status === "stopped") return result;
|
|
185
200
|
await pause(pollMs, signal, cancelSignal);
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { explainFleetConflict } from "./loop-holder.mjs";
|
|
2
3
|
|
|
3
4
|
const idempotency = () => randomUUID();
|
|
4
5
|
|
|
5
6
|
/** Map the coordination service's versioned MCP tools to the headless runner. */
|
|
6
7
|
export function createFleetRemoteRepository({ callTool, url, token, workspace, root, workerId }) {
|
|
7
8
|
if (typeof callTool !== "function" || !url || !token || !workspace || !root) throw new Error("Fleet remote requires MCP callTool, URL, token, workspace, and project root");
|
|
8
|
-
|
|
9
|
+
// A FleetConflict keeps its code/current for retry logic; its message gains
|
|
10
|
+
// a lease/claim explanation and the inspect command (original kept in parentheses).
|
|
11
|
+
const call = async (name, args) => {
|
|
12
|
+
try { return await callTool({ url, token, name, args: { workspace, ...args } }); }
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error?.code === "FLEET_CONFLICT" && typeof error.message === "string") error.message = explainFleetConflict(error.message);
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
9
18
|
let ownerToken;
|
|
10
19
|
let supervisorToken, supervisorEpoch, supervisorTask;
|
|
11
20
|
const fleet = () => call("fleet_get", {});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
3
|
+
import { explainFleetConflict } from "./loop-holder.mjs";
|
|
3
4
|
import { runFleetCoordinator, validateFleetLimits } from "./loop-fleet.mjs";
|
|
4
5
|
|
|
5
6
|
const taskDigest = (task) => createHash("sha256").update(JSON.stringify(task)).digest("hex");
|
|
@@ -65,7 +66,7 @@ export async function runFleetSupervisor({ repository, taskId, limits: inputLimi
|
|
|
65
66
|
await onEvent({ type: "gate", taskId, gate: result.gate, snapshotId: result.snapshotId, gateView: result.gateView });
|
|
66
67
|
if (untilStop) return { status: "gate", taskId, gate: result.gate, snapshotId: result.snapshotId, gateView: result.gateView };
|
|
67
68
|
}
|
|
68
|
-
if (failure) throw new Error(`Supervisor lease renewal failed: ${failure.message}`);
|
|
69
|
+
if (failure) throw new Error(`Supervisor lease renewal failed: ${explainFleetConflict(failure.message)}`);
|
|
69
70
|
return { status: "stopped", taskId };
|
|
70
71
|
} finally {
|
|
71
72
|
clearInterval(timer); await pending;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
6
6
|
import { readyTaskItems, formatGateView, pathsOverlap } from "./store.mjs";
|
|
7
7
|
import { isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.mjs";
|
|
8
|
+
import { explainFleetConflict, LEASES_COMMAND } from "./loop-holder.mjs";
|
|
8
9
|
|
|
9
10
|
export const FLEET_LIMITS = Object.freeze({ maxTurns: 12, turnTimeoutMs: 600_000, maxStallTurns: 2, maxWorkers: 4, leaseMs: 300_000, heartbeatMs: 60_000 });
|
|
10
11
|
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
@@ -111,7 +112,7 @@ async function withHeartbeat(renew, intervalMs, work) {
|
|
|
111
112
|
try {
|
|
112
113
|
const result = await work(abort.signal);
|
|
113
114
|
await pending;
|
|
114
|
-
if (failure) throw new Error(`Fleet lease renewal failed: ${failure.message}`);
|
|
115
|
+
if (failure) throw new Error(`Fleet lease renewal failed: ${explainFleetConflict(failure.message)}`);
|
|
115
116
|
return result;
|
|
116
117
|
} finally { clearInterval(timer); await pending; }
|
|
117
118
|
}
|
|
@@ -155,7 +156,7 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
|
|
|
155
156
|
const stopping = () => Boolean(stopSignal?.aborted || cancelSignal?.aborted);
|
|
156
157
|
const stopped = (reason) => ({ status: "stopped", taskId, reason, revision: snapshot.revision });
|
|
157
158
|
for (;;) {
|
|
158
|
-
if (leaseLost.aborted) throw new Error(
|
|
159
|
+
if (leaseLost.aborted) throw new Error(`Lead lease renewal failed: the lead lease (run_claim, lead epoch ${epoch}) for ${taskId} could not be renewed or the supervisor lease was lost; another lead may have taken over or the coordination service is unreachable. Inspect: ${LEASES_COMMAND} ${taskId}`);
|
|
159
160
|
if (stopping()) return stopped(cancelSignal?.aborted ? "cancel requested" : "stop requested");
|
|
160
161
|
if (Date.now() - startedAt >= limits.maxTurns * limits.turnTimeoutMs) return { status: "paused", taskId, reason: "coordinator wall-time limit reached" };
|
|
161
162
|
snapshot = validateSnapshot(await readTask({ taskId }), taskId);
|
|
@@ -79,13 +79,19 @@ function fetchBranch(root, remote, branch) {
|
|
|
79
79
|
function ancestor(root, base, head) {
|
|
80
80
|
try { git(root, "merge-base", "--is-ancestor", base, head); return true; } catch { return false; } /* intentional: non-git/non-worktree is treated as not clean */
|
|
81
81
|
}
|
|
82
|
-
// Host-local loop operational files (daemon lock/log, UI event sidecar)
|
|
83
|
-
//
|
|
82
|
+
// Host-local loop operational files (daemon lock/log, UI event sidecar) and
|
|
83
|
+
// per-task runtime data (worker container index and logs) live in the lead
|
|
84
|
+
// checkout but are never part of a handoff or integration.
|
|
84
85
|
const LOOP_OPERATIONAL_PREFIX = ".agents/data/loop/";
|
|
86
|
+
const TASK_RUNTIME_PATTERN = /^\.agents\/data\/tasks\/[^/]+\/runtime\//;
|
|
87
|
+
/** True for repository-relative paths the loop writes operationally
|
|
88
|
+
* (`.agents/data/loop/` and `.agents/data/tasks/<id>/runtime/`); such paths are
|
|
89
|
+
* ignored by clean checks and working-tree change snapshots. */
|
|
90
|
+
export const isLoopOperationalPath = (path) => typeof path === "string" && (path.startsWith(LOOP_OPERATIONAL_PREFIX) || TASK_RUNTIME_PATTERN.test(path));
|
|
85
91
|
function assertClean(root) {
|
|
86
92
|
// Not via git(): its trim() would eat the leading status column of the first row.
|
|
87
93
|
const status = execFileSync("git", ["status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"], { cwd: root, encoding: "utf8", timeout: 30_000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
88
|
-
const dirty = status.split("\0").filter(Boolean).map((row) => row.slice(3)).filter((path) => !path
|
|
94
|
+
const dirty = status.split("\0").filter(Boolean).map((row) => row.slice(3)).filter((path) => !isLoopOperationalPath(path));
|
|
89
95
|
if (dirty.length) throw new Error("Git worktree must be clean before handoff or integration");
|
|
90
96
|
}
|
|
91
97
|
function normaliseReport(job) {
|