@fitlab-ai/agent-infra 0.7.0 → 0.7.1
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/bin/cli.ts +1 -1
- package/dist/bin/cli.js +1 -1
- package/dist/lib/builtin-tuis.js +45 -0
- package/dist/lib/defaults.json +3 -0
- package/dist/lib/init.js +62 -23
- package/dist/lib/prompt.js +49 -1
- package/dist/lib/sandbox/commands/enter.js +1 -1
- package/dist/lib/sandbox/commands/list-running.js +58 -13
- package/dist/lib/sandbox/commands/rebuild.js +3 -11
- package/dist/lib/sandbox/commands/rm.js +2 -0
- package/dist/lib/sandbox/image-prune.js +18 -0
- package/dist/lib/sandbox/task-resolver.js +18 -0
- package/dist/lib/update.js +59 -18
- package/lib/builtin-tuis.ts +55 -0
- package/lib/defaults.json +3 -0
- package/lib/init.ts +87 -35
- package/lib/prompt.ts +54 -1
- package/lib/sandbox/commands/enter.ts +1 -1
- package/lib/sandbox/commands/list-running.ts +69 -16
- package/lib/sandbox/commands/rebuild.ts +3 -12
- package/lib/sandbox/commands/rm.ts +3 -0
- package/lib/sandbox/image-prune.ts +23 -0
- package/lib/sandbox/task-resolver.ts +23 -1
- package/lib/update.ts +71 -30
- package/package.json +1 -1
- package/templates/.agents/README.en.md +32 -0
- package/templates/.agents/README.zh-CN.md +32 -0
- package/templates/.agents/rules/task-short-id.en.md +141 -0
- package/templates/.agents/rules/task-short-id.zh-CN.md +124 -0
- package/templates/.agents/scripts/task-short-id.js +713 -0
- package/templates/.agents/skills/analyze-task/SKILL.en.md +4 -0
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +4 -1
- package/templates/.agents/skills/block-task/SKILL.en.md +12 -0
- package/templates/.agents/skills/block-task/SKILL.zh-CN.md +12 -1
- package/templates/.agents/skills/cancel-task/SKILL.en.md +12 -0
- package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +12 -1
- package/templates/.agents/skills/check-task/SKILL.en.md +4 -0
- package/templates/.agents/skills/check-task/SKILL.zh-CN.md +4 -1
- package/templates/.agents/skills/close-codescan/SKILL.en.md +11 -0
- package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +11 -0
- package/templates/.agents/skills/close-dependabot/SKILL.en.md +11 -0
- package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +11 -0
- package/templates/.agents/skills/code-task/SKILL.en.md +4 -0
- package/templates/.agents/skills/code-task/SKILL.zh-CN.md +4 -1
- package/templates/.agents/skills/commit/SKILL.en.md +4 -0
- package/templates/.agents/skills/commit/SKILL.zh-CN.md +4 -0
- package/templates/.agents/skills/complete-task/SKILL.en.md +12 -0
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +12 -1
- package/templates/.agents/skills/create-pr/SKILL.en.md +4 -0
- package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +4 -0
- package/templates/.agents/skills/create-task/SKILL.en.md +14 -0
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +14 -1
- package/templates/.agents/skills/import-codescan/SKILL.en.md +14 -0
- package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +14 -0
- package/templates/.agents/skills/import-dependabot/SKILL.en.md +14 -0
- package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +14 -0
- package/templates/.agents/skills/import-issue/SKILL.en.md +14 -0
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +14 -0
- package/templates/.agents/skills/plan-task/SKILL.en.md +4 -0
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +4 -1
- package/templates/.agents/skills/restore-task/SKILL.en.md +12 -0
- package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +12 -1
- package/templates/.agents/skills/review-analysis/SKILL.en.md +4 -0
- package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +4 -1
- package/templates/.agents/skills/review-code/SKILL.en.md +4 -0
- package/templates/.agents/skills/review-code/SKILL.zh-CN.md +4 -1
- package/templates/.agents/skills/review-plan/SKILL.en.md +4 -0
- package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +4 -1
- package/templates/.agents/skills/update-agent-infra/SKILL.en.md +1 -0
- package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +1 -0
- package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +112 -21
- package/templates/.agents/templates/task.en.md +1 -0
- package/templates/.agents/templates/task.zh-CN.md +1 -0
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
|
+
|
|
5
|
+
const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
|
|
6
|
+
const SHORT_ID_RE = /^#\d+$/;
|
|
7
|
+
const REGISTRY_NAME = ".short-ids.json";
|
|
8
|
+
const LOCK_NAME = ".short-ids.json.lock";
|
|
9
|
+
const DEFAULT_LOCK_TIMEOUT_MS = 5000;
|
|
10
|
+
// Kept in sync with lib/defaults.json's task.shortIdLength. Used when there is
|
|
11
|
+
// no `--short-id-length` flag and no readable `task.shortIdLength` in
|
|
12
|
+
// .agents/.airc.json (e.g. the project upgraded but hasn't re-run
|
|
13
|
+
// ai update-agent-infra to backfill the field).
|
|
14
|
+
const DEFAULT_SHORT_ID_LENGTH = 2;
|
|
15
|
+
|
|
16
|
+
// process.stdout.write / process.stderr.write are non-blocking when the
|
|
17
|
+
// destination is a pipe (e.g. when spawned via child_process.spawnSync). On
|
|
18
|
+
// some platforms (notably macOS) the Node process can exit before the buffer
|
|
19
|
+
// flushes, leaving the parent with empty stdout. Use fs.writeSync to guarantee
|
|
20
|
+
// synchronous, fully-flushed writes — this is critical because the parent
|
|
21
|
+
// CLI/test code relies on stdout to carry the resolved task id / short id.
|
|
22
|
+
function writeStdout(text) {
|
|
23
|
+
fs.writeSync(1, text);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeStderr(text) {
|
|
27
|
+
fs.writeSync(2, text);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function usage() {
|
|
31
|
+
return [
|
|
32
|
+
"Usage: task-short-id.js <subcommand> [args]",
|
|
33
|
+
"",
|
|
34
|
+
"Subcommands:",
|
|
35
|
+
" alloc <task-id> Allocate short id for a task; writes registry + short_id to task.md",
|
|
36
|
+
" release <task-id> Release short id (idempotent; exit 0 if not present)",
|
|
37
|
+
" resolve <#N> Resolve short id to full task id",
|
|
38
|
+
" list Print registry JSON",
|
|
39
|
+
" list --verify Read-only check; exit 1 if active dir / registry / task.md disagree",
|
|
40
|
+
"",
|
|
41
|
+
"Options:",
|
|
42
|
+
" --active-dir <path> Override active dir (default: <repo>/.agents/workspace/active)",
|
|
43
|
+
" --short-id-length N Override configured width (default: from .airc.json or 2)"
|
|
44
|
+
].join("\n");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseArgs(argv) {
|
|
48
|
+
const args = { positional: [], activeDir: null, shortIdLength: null, verify: false, help: false };
|
|
49
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
50
|
+
const a = argv[i];
|
|
51
|
+
if (a === "--active-dir") {
|
|
52
|
+
args.activeDir = argv[++i];
|
|
53
|
+
} else if (a === "--short-id-length") {
|
|
54
|
+
args.shortIdLength = Number(argv[++i]);
|
|
55
|
+
} else if (a === "--verify") {
|
|
56
|
+
args.verify = true;
|
|
57
|
+
} else if (a === "-h" || a === "--help") {
|
|
58
|
+
args.help = true;
|
|
59
|
+
} else if (a.startsWith("--")) {
|
|
60
|
+
throw new Error(`Unknown option: ${a}`);
|
|
61
|
+
} else {
|
|
62
|
+
args.positional.push(a);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return args;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function findRepoRoot(start) {
|
|
69
|
+
let dir = path.resolve(start || process.cwd());
|
|
70
|
+
for (;;) {
|
|
71
|
+
if (fs.existsSync(path.join(dir, ".agents", ".airc.json"))) return dir;
|
|
72
|
+
const parent = path.dirname(dir);
|
|
73
|
+
if (parent === dir) return null;
|
|
74
|
+
dir = parent;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readShortIdLength(repoRoot, override) {
|
|
79
|
+
if (typeof override === "number" && Number.isFinite(override) && override >= 1) {
|
|
80
|
+
return override;
|
|
81
|
+
}
|
|
82
|
+
if (!repoRoot) return DEFAULT_SHORT_ID_LENGTH;
|
|
83
|
+
try {
|
|
84
|
+
const cfgPath = path.join(repoRoot, ".agents", ".airc.json");
|
|
85
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
86
|
+
const v = cfg && cfg.task && cfg.task.shortIdLength;
|
|
87
|
+
if (typeof v === "number" && Number.isFinite(v) && v >= 1) return v;
|
|
88
|
+
} catch {
|
|
89
|
+
// ignore
|
|
90
|
+
}
|
|
91
|
+
return DEFAULT_SHORT_ID_LENGTH;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function readRegistry(registryPath) {
|
|
95
|
+
if (!fs.existsSync(registryPath)) {
|
|
96
|
+
return { version: 1, ids: {} };
|
|
97
|
+
}
|
|
98
|
+
let raw;
|
|
99
|
+
try {
|
|
100
|
+
raw = fs.readFileSync(registryPath, "utf8");
|
|
101
|
+
} catch (e) {
|
|
102
|
+
writeStderr(`Error: cannot read registry ${registryPath}: ${e.message}\n`);
|
|
103
|
+
process.exit(2);
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const data = JSON.parse(raw);
|
|
107
|
+
if (!data || typeof data !== "object" || !data.ids || typeof data.ids !== "object") {
|
|
108
|
+
writeStderr(`Error: registry ${registryPath} has invalid schema\n`);
|
|
109
|
+
process.exit(2);
|
|
110
|
+
}
|
|
111
|
+
if (data.version !== 1) data.version = 1;
|
|
112
|
+
return data;
|
|
113
|
+
} catch (e) {
|
|
114
|
+
writeStderr(`Error: registry ${registryPath} is not valid JSON: ${e.message}\n`);
|
|
115
|
+
process.exit(2);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function writeRegistryAtomic(data, registryPath) {
|
|
120
|
+
const tmpPath = `${registryPath}.tmp.${process.pid}`;
|
|
121
|
+
fs.writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`);
|
|
122
|
+
fs.renameSync(tmpPath, registryPath);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function withRegistryLock(activeDir, fn, timeoutMs = DEFAULT_LOCK_TIMEOUT_MS) {
|
|
126
|
+
fs.mkdirSync(activeDir, { recursive: true });
|
|
127
|
+
const lockDir = path.join(activeDir, LOCK_NAME);
|
|
128
|
+
const start = Date.now();
|
|
129
|
+
for (;;) {
|
|
130
|
+
try {
|
|
131
|
+
fs.mkdirSync(lockDir, { recursive: false });
|
|
132
|
+
break;
|
|
133
|
+
} catch (e) {
|
|
134
|
+
if (e.code !== "EEXIST") throw e;
|
|
135
|
+
if (Date.now() - start > timeoutMs) {
|
|
136
|
+
writeStderr(`Error: registry lock timeout after ${timeoutMs}ms\n`);
|
|
137
|
+
process.exit(3);
|
|
138
|
+
}
|
|
139
|
+
const elapsed = Date.now() - start;
|
|
140
|
+
const wait = Math.min(500, 50 * Math.pow(2, Math.floor(elapsed / 200)));
|
|
141
|
+
const deadline = Date.now() + wait;
|
|
142
|
+
while (Date.now() < deadline) {
|
|
143
|
+
/* busy wait, ms-scale */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Register cleanup that runs even on process.exit (which skips try/finally).
|
|
148
|
+
const cleanup = () => {
|
|
149
|
+
try {
|
|
150
|
+
fs.rmdirSync(lockDir);
|
|
151
|
+
} catch {
|
|
152
|
+
/* lock-dir already removed */
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
process.once("exit", cleanup);
|
|
156
|
+
try {
|
|
157
|
+
return fn();
|
|
158
|
+
} finally {
|
|
159
|
+
process.removeListener("exit", cleanup);
|
|
160
|
+
cleanup();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function writeTaskMdShortId(taskMdPath, shortId) {
|
|
165
|
+
const content = fs.readFileSync(taskMdPath, "utf8");
|
|
166
|
+
let updated;
|
|
167
|
+
if (/^short_id:.*$/m.test(content)) {
|
|
168
|
+
updated = content.replace(/^short_id:.*$/m, `short_id: ${shortId}`);
|
|
169
|
+
} else {
|
|
170
|
+
updated = content.replace(/^(id:.*)$/m, `$1\nshort_id: ${shortId}`);
|
|
171
|
+
}
|
|
172
|
+
fs.writeFileSync(taskMdPath, updated);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function padShortId(n, shortIdLength) {
|
|
176
|
+
return String(n).padStart(shortIdLength, "0");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function allocateMinFreeInt(registry, shortIdLength) {
|
|
180
|
+
const maxN = Math.pow(10, shortIdLength) - 1;
|
|
181
|
+
for (let n = 1; n <= maxN; n += 1) {
|
|
182
|
+
if (!registry.ids[padShortId(n, shortIdLength)]) return n;
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parseShortIdArg(arg, shortIdLength) {
|
|
188
|
+
const re = new RegExp(`^#\\d{${shortIdLength}}$`);
|
|
189
|
+
if (!re.test(arg)) {
|
|
190
|
+
const example =
|
|
191
|
+
shortIdLength === 1 ? "'#1'" : shortIdLength === 2 ? "'#01'" : `'#${"0".repeat(shortIdLength - 1)}1'`;
|
|
192
|
+
writeStderr(
|
|
193
|
+
`Error: invalid short id format '${arg}', ` +
|
|
194
|
+
`expected #${"N".repeat(shortIdLength)} (${shortIdLength}-digit zero-padded; e.g. ${example})\n`
|
|
195
|
+
);
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
const key = arg.slice(1);
|
|
199
|
+
if (Number(key) === 0) {
|
|
200
|
+
writeStderr(
|
|
201
|
+
`Error: short id '${arg}' is invalid (#${"0".repeat(shortIdLength)} is reserved)\n`
|
|
202
|
+
);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
return key;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function planTransaction(registry, activeDir, shortIdLength) {
|
|
209
|
+
const maxN = Math.pow(10, shortIdLength) - 1;
|
|
210
|
+
|
|
211
|
+
// A1: active task id set
|
|
212
|
+
const activeTaskIds = new Set(
|
|
213
|
+
fs
|
|
214
|
+
.readdirSync(activeDir)
|
|
215
|
+
.filter((d) => TASK_ID_RE.test(d))
|
|
216
|
+
.filter((d) => fs.existsSync(path.join(activeDir, d, "task.md")))
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
// A2: stale entries
|
|
220
|
+
const pendingRegistryDeletes = [];
|
|
221
|
+
for (const [key, taskId] of Object.entries(registry.ids)) {
|
|
222
|
+
if (!activeTaskIds.has(taskId)) pendingRegistryDeletes.push(key);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const projectedIds = { ...registry.ids };
|
|
226
|
+
for (const key of pendingRegistryDeletes) delete projectedIds[key];
|
|
227
|
+
|
|
228
|
+
// A3: duplicate key detection (after stale cleanup)
|
|
229
|
+
const taskIdToKey = new Map();
|
|
230
|
+
for (const [key, taskId] of Object.entries(projectedIds)) {
|
|
231
|
+
if (taskIdToKey.has(taskId)) {
|
|
232
|
+
const existingKey = taskIdToKey.get(taskId);
|
|
233
|
+
writeStderr(
|
|
234
|
+
`Error: duplicate registry entries for taskId ${taskId} at keys [#${existingKey}, #${key}]; manual resolution required\n`
|
|
235
|
+
);
|
|
236
|
+
process.exit(2);
|
|
237
|
+
}
|
|
238
|
+
taskIdToKey.set(taskId, key);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// A4: classify each active task
|
|
242
|
+
const plannedRegistryWrites = [];
|
|
243
|
+
const plannedTaskMdWrites = [];
|
|
244
|
+
const pendingAlloc = [];
|
|
245
|
+
|
|
246
|
+
for (const taskId of activeTaskIds) {
|
|
247
|
+
const taskMdPath = path.join(activeDir, taskId, "task.md");
|
|
248
|
+
const originalStat = fs.statSync(taskMdPath);
|
|
249
|
+
const originalContent = fs.readFileSync(taskMdPath, "utf8");
|
|
250
|
+
const existing = originalContent.match(/^short_id:\s*(#\d+)\s*$/m);
|
|
251
|
+
|
|
252
|
+
if (existing) {
|
|
253
|
+
const declared = existing[1];
|
|
254
|
+
const n = declared.slice(1);
|
|
255
|
+
if (projectedIds[n] === taskId) continue; // 4a
|
|
256
|
+
if (taskIdToKey.has(taskId)) {
|
|
257
|
+
const registryKey = taskIdToKey.get(taskId);
|
|
258
|
+
writeStderr(
|
|
259
|
+
`Inconsistent: task ${taskId} declares ${declared} but registry holds it at #${registryKey}\n`
|
|
260
|
+
);
|
|
261
|
+
process.exit(2);
|
|
262
|
+
}
|
|
263
|
+
if (projectedIds[n] && projectedIds[n] !== taskId) {
|
|
264
|
+
writeStderr(
|
|
265
|
+
`Inconsistent: task ${taskId} declares ${declared} but registry maps ${declared} to ${projectedIds[n]}\n`
|
|
266
|
+
);
|
|
267
|
+
process.exit(2);
|
|
268
|
+
}
|
|
269
|
+
// 4d
|
|
270
|
+
plannedRegistryWrites.push({ key: n, taskId });
|
|
271
|
+
projectedIds[n] = taskId;
|
|
272
|
+
taskIdToKey.set(taskId, n);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (taskIdToKey.has(taskId)) {
|
|
277
|
+
// 4e
|
|
278
|
+
const registryKey = taskIdToKey.get(taskId);
|
|
279
|
+
plannedTaskMdWrites.push({
|
|
280
|
+
taskMdPath,
|
|
281
|
+
originalContent,
|
|
282
|
+
originalAtime: originalStat.atime,
|
|
283
|
+
originalMtime: originalStat.mtime,
|
|
284
|
+
shortId: `#${registryKey}`,
|
|
285
|
+
kind: "4e"
|
|
286
|
+
});
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// 4f: deferred
|
|
291
|
+
pendingAlloc.push({
|
|
292
|
+
taskId,
|
|
293
|
+
taskMdPath,
|
|
294
|
+
originalContent,
|
|
295
|
+
originalAtime: originalStat.atime,
|
|
296
|
+
originalMtime: originalStat.mtime
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// A5: capacity pre-check
|
|
301
|
+
const availableSlots = maxN - Object.keys(projectedIds).length;
|
|
302
|
+
if (pendingAlloc.length > availableSlots) {
|
|
303
|
+
writeStderr(
|
|
304
|
+
`Error: cold-start migration needs ${pendingAlloc.length} short id(s) but only ${availableSlots} ` +
|
|
305
|
+
`slot(s) available (capacity=${maxN}, in-use after stale-cleanup=${Object.keys(projectedIds).length}). ` +
|
|
306
|
+
`Archive some active tasks (complete-task / cancel-task / block-task) ` +
|
|
307
|
+
`or raise task.shortIdLength in .agents/.airc.json.\n`
|
|
308
|
+
);
|
|
309
|
+
process.exit(2);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
pendingAlloc.sort((a, b) => a.taskId.localeCompare(b.taskId));
|
|
313
|
+
|
|
314
|
+
for (const item of pendingAlloc) {
|
|
315
|
+
const n = allocateMinFreeInt({ ids: projectedIds }, shortIdLength);
|
|
316
|
+
if (n === null) {
|
|
317
|
+
throw new Error("Internal invariant: pendingAlloc capacity check failed");
|
|
318
|
+
}
|
|
319
|
+
const key = padShortId(n, shortIdLength);
|
|
320
|
+
projectedIds[key] = item.taskId;
|
|
321
|
+
taskIdToKey.set(item.taskId, key);
|
|
322
|
+
plannedRegistryWrites.push({ key, taskId: item.taskId });
|
|
323
|
+
plannedTaskMdWrites.push({
|
|
324
|
+
taskMdPath: item.taskMdPath,
|
|
325
|
+
originalContent: item.originalContent,
|
|
326
|
+
originalAtime: item.originalAtime,
|
|
327
|
+
originalMtime: item.originalMtime,
|
|
328
|
+
shortId: `#${key}`,
|
|
329
|
+
kind: "4f"
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Build transaction object
|
|
334
|
+
const tx = {
|
|
335
|
+
_registry: registry,
|
|
336
|
+
_activeDir: activeDir,
|
|
337
|
+
_registrySnapshot: { ...registry.ids },
|
|
338
|
+
_pendingRegistryDeletes: pendingRegistryDeletes,
|
|
339
|
+
_plannedRegistryWrites: plannedRegistryWrites,
|
|
340
|
+
_plannedTaskMdWrites: plannedTaskMdWrites,
|
|
341
|
+
_projectedIds: projectedIds,
|
|
342
|
+
_taskIdToKey: taskIdToKey,
|
|
343
|
+
_shortIdLength: shortIdLength,
|
|
344
|
+
_maxN: maxN,
|
|
345
|
+
|
|
346
|
+
planAlloc(taskId) {
|
|
347
|
+
const taskMdPath = path.join(activeDir, taskId, "task.md");
|
|
348
|
+
if (!fs.existsSync(taskMdPath)) {
|
|
349
|
+
throw new Error(`planAlloc: task.md not found for ${taskId}`);
|
|
350
|
+
}
|
|
351
|
+
if (this._taskIdToKey.has(taskId)) {
|
|
352
|
+
return this._taskIdToKey.get(taskId);
|
|
353
|
+
}
|
|
354
|
+
const inUse = Object.keys(this._projectedIds).length;
|
|
355
|
+
if (inUse >= this._maxN) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
`Error: short id width exhausted (current shortIdLength=${this._shortIdLength}, ` +
|
|
358
|
+
`${inUse}/${this._maxN} slots in use). Archive some active tasks or raise task.shortIdLength.`
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
const n = allocateMinFreeInt({ ids: this._projectedIds }, this._shortIdLength);
|
|
362
|
+
const key = padShortId(n, this._shortIdLength);
|
|
363
|
+
this._projectedIds[key] = taskId;
|
|
364
|
+
this._taskIdToKey.set(taskId, key);
|
|
365
|
+
this._plannedRegistryWrites.push({ key, taskId });
|
|
366
|
+
const originalStat = fs.statSync(taskMdPath);
|
|
367
|
+
const originalContent = fs.readFileSync(taskMdPath, "utf8");
|
|
368
|
+
// If task.md already declares the same short id (e.g. R-alloc replay), skip writing.
|
|
369
|
+
const existing = originalContent.match(/^short_id:\s*(#\d+)\s*$/m);
|
|
370
|
+
if (!existing || existing[1] !== `#${key}`) {
|
|
371
|
+
this._plannedTaskMdWrites.push({
|
|
372
|
+
taskMdPath,
|
|
373
|
+
originalContent,
|
|
374
|
+
originalAtime: originalStat.atime,
|
|
375
|
+
originalMtime: originalStat.mtime,
|
|
376
|
+
shortId: `#${key}`,
|
|
377
|
+
kind: "caller-alloc"
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
return key; // zero-padded; matches registry key
|
|
381
|
+
},
|
|
382
|
+
|
|
383
|
+
planRelease(taskId) {
|
|
384
|
+
const key = this._taskIdToKey.get(taskId);
|
|
385
|
+
if (!key) return; // idempotent
|
|
386
|
+
this._plannedRegistryWrites = this._plannedRegistryWrites.filter(
|
|
387
|
+
(w) => w.taskId !== taskId
|
|
388
|
+
);
|
|
389
|
+
this._plannedTaskMdWrites = this._plannedTaskMdWrites.filter(
|
|
390
|
+
(w) => path.basename(path.dirname(w.taskMdPath)) !== taskId
|
|
391
|
+
);
|
|
392
|
+
this._pendingRegistryDeletes.push(key);
|
|
393
|
+
delete this._projectedIds[key];
|
|
394
|
+
this._taskIdToKey.delete(taskId);
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
commit(registryPath) {
|
|
398
|
+
// B1: apply registry mutation in memory
|
|
399
|
+
for (const key of this._pendingRegistryDeletes) delete this._registry.ids[key];
|
|
400
|
+
for (const { key, taskId } of this._plannedRegistryWrites) {
|
|
401
|
+
this._registry.ids[key] = taskId;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const completedWrites = [];
|
|
405
|
+
const rollback = (reason) => {
|
|
406
|
+
for (const done of completedWrites.reverse()) {
|
|
407
|
+
try {
|
|
408
|
+
fs.writeFileSync(done.taskMdPath, done.originalContent);
|
|
409
|
+
fs.utimesSync(done.taskMdPath, done.originalAtime, done.originalMtime);
|
|
410
|
+
} catch {
|
|
411
|
+
/* best-effort */
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
this._registry.ids = this._registrySnapshot;
|
|
415
|
+
const tail =
|
|
416
|
+
completedWrites.length > 0
|
|
417
|
+
? `; rolled back ${completedWrites.length} prior task.md write(s)`
|
|
418
|
+
: "";
|
|
419
|
+
throw new Error(`${reason}${tail}`);
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// B2: write task.md per plan
|
|
423
|
+
for (const write of this._plannedTaskMdWrites) {
|
|
424
|
+
try {
|
|
425
|
+
writeTaskMdShortId(write.taskMdPath, write.shortId);
|
|
426
|
+
completedWrites.push(write);
|
|
427
|
+
} catch (e) {
|
|
428
|
+
rollback(`Failed to write short_id to ${write.taskMdPath}: ${e.message}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// B3: atomic registry persistence
|
|
433
|
+
try {
|
|
434
|
+
writeRegistryAtomic(this._registry, registryPath);
|
|
435
|
+
} catch (e) {
|
|
436
|
+
rollback(`Failed to persist registry to ${registryPath}: ${e.message}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
return tx;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function verifyRegistry(registry, activeDir) {
|
|
445
|
+
const activeTaskIds = new Set(
|
|
446
|
+
fs
|
|
447
|
+
.readdirSync(activeDir)
|
|
448
|
+
.filter((d) => TASK_ID_RE.test(d))
|
|
449
|
+
.filter((d) => fs.existsSync(path.join(activeDir, d, "task.md")))
|
|
450
|
+
);
|
|
451
|
+
const registryTaskIds = new Set(Object.values(registry.ids));
|
|
452
|
+
const taskmdShortIds = new Map();
|
|
453
|
+
for (const taskId of activeTaskIds) {
|
|
454
|
+
const taskMdPath = path.join(activeDir, taskId, "task.md");
|
|
455
|
+
const content = fs.readFileSync(taskMdPath, "utf8");
|
|
456
|
+
const m = content.match(/^short_id:\s*(#\d+)\s*$/m);
|
|
457
|
+
taskmdShortIds.set(taskId, m ? m[1] : null);
|
|
458
|
+
}
|
|
459
|
+
const missing_in_registry = [];
|
|
460
|
+
for (const taskId of activeTaskIds) {
|
|
461
|
+
if (!registryTaskIds.has(taskId)) {
|
|
462
|
+
missing_in_registry.push({ taskId, declared: taskmdShortIds.get(taskId) });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const missing_in_taskmd = [];
|
|
466
|
+
for (const [key, taskId] of Object.entries(registry.ids)) {
|
|
467
|
+
if (!activeTaskIds.has(taskId)) continue;
|
|
468
|
+
const declared = taskmdShortIds.get(taskId);
|
|
469
|
+
if (declared === null) {
|
|
470
|
+
missing_in_taskmd.push({ taskId, expected: `#${key}` });
|
|
471
|
+
} else if (declared !== `#${key}`) {
|
|
472
|
+
missing_in_taskmd.push({ taskId, expected: `#${key}`, declared });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const orphans_in_registry = [];
|
|
476
|
+
for (const [key, taskId] of Object.entries(registry.ids)) {
|
|
477
|
+
if (!activeTaskIds.has(taskId)) {
|
|
478
|
+
orphans_in_registry.push({ key: `#${key}`, taskId });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const taskIdToKeys = new Map();
|
|
482
|
+
for (const [key, taskId] of Object.entries(registry.ids)) {
|
|
483
|
+
if (!taskIdToKeys.has(taskId)) taskIdToKeys.set(taskId, []);
|
|
484
|
+
taskIdToKeys.get(taskId).push(key);
|
|
485
|
+
}
|
|
486
|
+
const duplicate_registry_keys = [];
|
|
487
|
+
for (const [taskId, keys] of taskIdToKeys) {
|
|
488
|
+
if (keys.length > 1) {
|
|
489
|
+
duplicate_registry_keys.push({ taskId, keys: keys.map((k) => `#${k}`) });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
missing_in_registry,
|
|
494
|
+
missing_in_taskmd,
|
|
495
|
+
orphans_in_registry,
|
|
496
|
+
duplicate_registry_keys
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function cmdAlloc(taskId, activeDir, registryPath, shortIdLength) {
|
|
501
|
+
if (!TASK_ID_RE.test(taskId)) {
|
|
502
|
+
writeStderr(`Error: invalid task id format '${taskId}'\n`);
|
|
503
|
+
process.exit(1);
|
|
504
|
+
}
|
|
505
|
+
return withRegistryLock(activeDir, () => {
|
|
506
|
+
const taskMdPath = path.join(activeDir, taskId, "task.md");
|
|
507
|
+
if (!fs.existsSync(taskMdPath)) {
|
|
508
|
+
writeStderr(`Error: task ${taskId} not found in ${activeDir} (no task.md)\n`);
|
|
509
|
+
process.exit(1);
|
|
510
|
+
}
|
|
511
|
+
const registry = readRegistry(registryPath);
|
|
512
|
+
const tx = planTransaction(registry, activeDir, shortIdLength);
|
|
513
|
+
let shortId;
|
|
514
|
+
try {
|
|
515
|
+
shortId = tx.planAlloc(taskId);
|
|
516
|
+
} catch (e) {
|
|
517
|
+
writeStderr(`${e.message}\n`);
|
|
518
|
+
process.exit(2);
|
|
519
|
+
}
|
|
520
|
+
try {
|
|
521
|
+
tx.commit(registryPath);
|
|
522
|
+
} catch (e) {
|
|
523
|
+
writeStderr(`${e.message}\n`);
|
|
524
|
+
process.exit(1);
|
|
525
|
+
}
|
|
526
|
+
// shortId is already zero-padded (returned by tx.planAlloc; matches registry key)
|
|
527
|
+
writeStdout(`#${shortId}\n`);
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function cmdRelease(taskId, activeDir, registryPath, shortIdLength) {
|
|
532
|
+
if (!TASK_ID_RE.test(taskId)) {
|
|
533
|
+
writeStderr(`Error: invalid task id format '${taskId}'\n`);
|
|
534
|
+
process.exit(1);
|
|
535
|
+
}
|
|
536
|
+
return withRegistryLock(activeDir, () => {
|
|
537
|
+
const registry = readRegistry(registryPath);
|
|
538
|
+
const tx = planTransaction(registry, activeDir, shortIdLength);
|
|
539
|
+
tx.planRelease(taskId);
|
|
540
|
+
try {
|
|
541
|
+
tx.commit(registryPath);
|
|
542
|
+
} catch (e) {
|
|
543
|
+
writeStderr(`${e.message}\n`);
|
|
544
|
+
process.exit(1);
|
|
545
|
+
}
|
|
546
|
+
// idempotent exit 0
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function cmdResolve(shortIdArg, activeDir, registryPath, shortIdLength) {
|
|
551
|
+
// Strict width match + reserved key check; on invalid arg, parseShortIdArg writes full
|
|
552
|
+
// stderr (including "expected #NN (N-digit zero-padded; e.g. '#01')") and exits 1.
|
|
553
|
+
const key = parseShortIdArg(shortIdArg, shortIdLength);
|
|
554
|
+
return withRegistryLock(activeDir, () => {
|
|
555
|
+
const registry = readRegistry(registryPath);
|
|
556
|
+
const tx = planTransaction(registry, activeDir, shortIdLength);
|
|
557
|
+
const taskId = tx._projectedIds[key];
|
|
558
|
+
if (!taskId) {
|
|
559
|
+
const hasPendingMutations =
|
|
560
|
+
tx._plannedRegistryWrites.length > 0 ||
|
|
561
|
+
tx._pendingRegistryDeletes.length > 0 ||
|
|
562
|
+
tx._plannedTaskMdWrites.length > 0;
|
|
563
|
+
if (hasPendingMutations) {
|
|
564
|
+
try {
|
|
565
|
+
tx.commit(registryPath);
|
|
566
|
+
} catch (e) {
|
|
567
|
+
writeStderr(`${e.message}\n`);
|
|
568
|
+
process.exit(1);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (Object.keys(tx._projectedIds).length === 0) {
|
|
572
|
+
writeStderr(
|
|
573
|
+
`Error: short id '#${key}' not found; active task registry is empty.\n`
|
|
574
|
+
);
|
|
575
|
+
} else {
|
|
576
|
+
writeStderr(
|
|
577
|
+
`Error: short id '#${key}' not found in active task registry ` +
|
|
578
|
+
`(it may have been cleaned up after archival; check 'task-short-id.js list').\n`
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
process.exit(1);
|
|
582
|
+
}
|
|
583
|
+
try {
|
|
584
|
+
tx.commit(registryPath);
|
|
585
|
+
} catch (e) {
|
|
586
|
+
writeStderr(`${e.message}\n`);
|
|
587
|
+
process.exit(1);
|
|
588
|
+
}
|
|
589
|
+
writeStdout(`${taskId}\n`);
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function cmdList(activeDir, registryPath, verify) {
|
|
594
|
+
if (!verify) {
|
|
595
|
+
const registry = readRegistry(registryPath);
|
|
596
|
+
writeStdout(`${JSON.stringify(registry, null, 2)}\n`);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const registry = readRegistry(registryPath);
|
|
600
|
+
if (!fs.existsSync(activeDir)) {
|
|
601
|
+
writeStdout("");
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
const diff = verifyRegistry(registry, activeDir);
|
|
605
|
+
const hasIssues =
|
|
606
|
+
diff.missing_in_registry.length > 0 ||
|
|
607
|
+
diff.missing_in_taskmd.length > 0 ||
|
|
608
|
+
diff.orphans_in_registry.length > 0 ||
|
|
609
|
+
diff.duplicate_registry_keys.length > 0;
|
|
610
|
+
if (hasIssues) {
|
|
611
|
+
writeStdout(`${JSON.stringify(diff, null, 2)}\n`);
|
|
612
|
+
process.exit(1);
|
|
613
|
+
}
|
|
614
|
+
// consistent: empty stdout, exit 0
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function main(argv) {
|
|
618
|
+
let args;
|
|
619
|
+
try {
|
|
620
|
+
args = parseArgs(argv);
|
|
621
|
+
} catch (e) {
|
|
622
|
+
writeStderr(`${e.message}\n${usage()}\n`);
|
|
623
|
+
process.exit(1);
|
|
624
|
+
}
|
|
625
|
+
if (args.help || args.positional.length === 0) {
|
|
626
|
+
writeStdout(`${usage()}\n`);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const subcommand = args.positional[0];
|
|
630
|
+
const repoRoot = findRepoRoot(process.cwd());
|
|
631
|
+
const activeDir = args.activeDir
|
|
632
|
+
? path.resolve(args.activeDir)
|
|
633
|
+
: repoRoot
|
|
634
|
+
? path.join(repoRoot, ".agents", "workspace", "active")
|
|
635
|
+
: null;
|
|
636
|
+
if (!activeDir) {
|
|
637
|
+
writeStderr(
|
|
638
|
+
`Error: cannot locate active dir (no .agents/.airc.json found above ${process.cwd()})\n`
|
|
639
|
+
);
|
|
640
|
+
process.exit(2);
|
|
641
|
+
}
|
|
642
|
+
const shortIdLength = readShortIdLength(repoRoot, args.shortIdLength);
|
|
643
|
+
const registryPath = path.join(activeDir, REGISTRY_NAME);
|
|
644
|
+
|
|
645
|
+
switch (subcommand) {
|
|
646
|
+
case "alloc":
|
|
647
|
+
if (!args.positional[1]) {
|
|
648
|
+
writeStderr(`Usage: alloc <task-id>\n`);
|
|
649
|
+
process.exit(1);
|
|
650
|
+
}
|
|
651
|
+
return cmdAlloc(args.positional[1], activeDir, registryPath, shortIdLength);
|
|
652
|
+
case "release":
|
|
653
|
+
if (!args.positional[1]) {
|
|
654
|
+
writeStderr(`Usage: release <task-id>\n`);
|
|
655
|
+
process.exit(1);
|
|
656
|
+
}
|
|
657
|
+
return cmdRelease(args.positional[1], activeDir, registryPath, shortIdLength);
|
|
658
|
+
case "resolve":
|
|
659
|
+
if (!args.positional[1]) {
|
|
660
|
+
writeStderr(`Usage: resolve <#N>\n`);
|
|
661
|
+
process.exit(1);
|
|
662
|
+
}
|
|
663
|
+
return cmdResolve(args.positional[1], activeDir, registryPath, shortIdLength);
|
|
664
|
+
case "list":
|
|
665
|
+
return cmdList(activeDir, registryPath, args.verify);
|
|
666
|
+
default:
|
|
667
|
+
writeStderr(`Unknown subcommand: ${subcommand}\n${usage()}\n`);
|
|
668
|
+
process.exit(1);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Compare canonicalized (symlink-resolved) paths so this script still runs as a
|
|
673
|
+
// CLI when invoked through a temp-dir symlink (notably /var/folders on macOS,
|
|
674
|
+
// which is a symlink to /private/var/folders; process.argv[1] keeps the
|
|
675
|
+
// symlinked path while import.meta.url is auto-resolved to the realpath).
|
|
676
|
+
const isCli = (() => {
|
|
677
|
+
const entry = process.argv[1];
|
|
678
|
+
if (!entry) return false;
|
|
679
|
+
try {
|
|
680
|
+
const realEntry = fs.realpathSync(entry);
|
|
681
|
+
const realModule = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
682
|
+
return realEntry === realModule;
|
|
683
|
+
} catch {
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
})();
|
|
687
|
+
|
|
688
|
+
if (isCli) {
|
|
689
|
+
main(process.argv.slice(2));
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
export {
|
|
693
|
+
TASK_ID_RE,
|
|
694
|
+
SHORT_ID_RE,
|
|
695
|
+
REGISTRY_NAME,
|
|
696
|
+
parseArgs,
|
|
697
|
+
findRepoRoot,
|
|
698
|
+
readShortIdLength,
|
|
699
|
+
readRegistry,
|
|
700
|
+
writeRegistryAtomic,
|
|
701
|
+
withRegistryLock,
|
|
702
|
+
writeTaskMdShortId,
|
|
703
|
+
padShortId,
|
|
704
|
+
parseShortIdArg,
|
|
705
|
+
allocateMinFreeInt,
|
|
706
|
+
planTransaction,
|
|
707
|
+
verifyRegistry,
|
|
708
|
+
cmdAlloc,
|
|
709
|
+
cmdRelease,
|
|
710
|
+
cmdResolve,
|
|
711
|
+
cmdList,
|
|
712
|
+
main
|
|
713
|
+
};
|