atris 3.32.0 → 3.33.3
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/README.md +43 -1
- package/ax +364 -11
- package/bin/atris.js +53 -4
- package/commands/autoland.js +8 -2
- package/commands/engine.js +299 -0
- package/commands/integrations.js +147 -0
- package/commands/member.js +63 -10
- package/commands/mission.js +119 -18
- package/commands/task.js +173 -14
- package/commands/truth.js +70 -10
- package/lib/fleet.js +354 -0
- package/lib/inspect-fields.js +174 -0
- package/lib/runner-command.js +24 -0
- package/lib/task-db.js +38 -0
- package/package.json +2 -1
- package/scripts/agent_worktree.py +72 -0
package/lib/fleet.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The fleet conductor's primitives. Humble name outside (`atris mission run
|
|
4
|
+
// --fleet`), the full loop inside: staff every idle engine on claimable
|
|
5
|
+
// safe-lane tasks, one mission per task, one worktree per engine, land
|
|
6
|
+
// arrivals serially. Proven by hand on 2026-07-02 (PRs #191-193, three
|
|
7
|
+
// engines) before any of this code existed — each function here is one thing
|
|
8
|
+
// the orchestrator did manually that day.
|
|
9
|
+
//
|
|
10
|
+
// INVARIANT: the fleet has NO state file. Flight state IS missions +
|
|
11
|
+
// worktrees + receipts. Kill a flight mid-run and nothing is orphaned — it
|
|
12
|
+
// is just missions in flight, resumable the normal way.
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { spawnSync } = require('child_process');
|
|
17
|
+
const { RUNNER_PROFILE_DEFS, buildRunnerCommand } = require('./runner-command');
|
|
18
|
+
|
|
19
|
+
// Lanes a fleet may never staff on its own: the human keeps irreversible
|
|
20
|
+
// calls. Mirrors the autoland denied lanes.
|
|
21
|
+
const DENIED_TAGS = ['billing', 'deploy', 'security', 'customer', 'external', 'feedback', 'voice'];
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// T1 — dispatch primitive
|
|
25
|
+
|
|
26
|
+
// A task's own text is its spec: the board convention writes "Done: ..." and
|
|
27
|
+
// "Check: ..." into the title/description. Extract both so the dispatch
|
|
28
|
+
// prompt is generated, never hand-written.
|
|
29
|
+
function parseDoneCheck(text) {
|
|
30
|
+
const s = String(text || '');
|
|
31
|
+
const done = (s.match(/Done:\s*([^]*?)(?=Check:|$)/i) || [])[1];
|
|
32
|
+
const check = (s.match(/Check:\s*([^]*?)$/i) || [])[1];
|
|
33
|
+
return {
|
|
34
|
+
done: done ? done.trim().replace(/\s+/g, ' ') : '',
|
|
35
|
+
check: check ? check.trim().replace(/\s+/g, ' ') : '',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The bounded prompt every engine gets. Same contract the manual flight used:
|
|
40
|
+
// isolated worktree, commit never push, MAP first, focused verify, report.
|
|
41
|
+
function buildFleetPrompt(task, { worktreePath } = {}) {
|
|
42
|
+
const ref = task.display_id || task.id || 'TASK';
|
|
43
|
+
const title = String(task.title || '').trim();
|
|
44
|
+
const { done, check } = parseDoneCheck(title);
|
|
45
|
+
const lines = [
|
|
46
|
+
`You are working task ${ref} in this repo checkout (an isolated git worktree${worktreePath ? ` at ${worktreePath}` : ''} — commit here, NEVER push).`,
|
|
47
|
+
'',
|
|
48
|
+
`Task: ${title}`,
|
|
49
|
+
'',
|
|
50
|
+
];
|
|
51
|
+
if (done) lines.push(`Done criteria: ${done}`, '');
|
|
52
|
+
if (check) lines.push(`Check: ${check}`, '');
|
|
53
|
+
lines.push(
|
|
54
|
+
'Rules:',
|
|
55
|
+
'- Read atris/MAP.md first to locate the code; never guess file locations.',
|
|
56
|
+
'- Run git status first. Stage ONLY files you changed. Never revert or touch files another agent modified.',
|
|
57
|
+
'- Include or update a focused regression test; run it with node --test before committing.',
|
|
58
|
+
'- Commit on the current branch with a clear message. Do not push. Do not create branches.',
|
|
59
|
+
'',
|
|
60
|
+
'Final report (plain text): files changed, test command + result, commit sha (or say the commit failed and why).'
|
|
61
|
+
);
|
|
62
|
+
return lines.join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Shell command that runs one engine on one prompt file, from the worktree.
|
|
66
|
+
// Rides the same profile definitions the whole CLI uses — no fleet-only
|
|
67
|
+
// spawn shapes.
|
|
68
|
+
// Build work needs file tools: allowedTools reaches the default claude-shaped
|
|
69
|
+
// spawn; template engines (cursor/codex/devin) ignore it — their CLIs manage
|
|
70
|
+
// their own permissions.
|
|
71
|
+
const FLEET_ALLOWED_TOOLS = 'Bash,Read,Edit,Write,Grep,Glob';
|
|
72
|
+
|
|
73
|
+
function buildEngineCommand(engineName, promptFile) {
|
|
74
|
+
if (!RUNNER_PROFILE_DEFS[engineName]) throw new Error(`unknown engine "${engineName}"`);
|
|
75
|
+
const prev = process.env.ATRIS_RUNNER_PROFILE;
|
|
76
|
+
process.env.ATRIS_RUNNER_PROFILE = engineName;
|
|
77
|
+
try {
|
|
78
|
+
const cmd = buildRunnerCommand({ promptFile, allowedTools: FLEET_ALLOWED_TOOLS });
|
|
79
|
+
// devin's default is read-only for writes; fleet builds ALWAYS run in an
|
|
80
|
+
// isolated worktree, so the conductor grants write permission here and
|
|
81
|
+
// only here (the profile itself stays safe for non-worktree ticks).
|
|
82
|
+
if (engineName === 'devin') return cmd.replace(/^devin -p /, 'devin -p --permission-mode dangerous ');
|
|
83
|
+
return cmd;
|
|
84
|
+
} finally {
|
|
85
|
+
if (prev === undefined) delete process.env.ATRIS_RUNNER_PROFILE;
|
|
86
|
+
else process.env.ATRIS_RUNNER_PROFILE = prev;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Run one engine on one task in one worktree. Blocking; the conductor runs
|
|
91
|
+
// dispatches in parallel via child processes, not threads. `runner` is
|
|
92
|
+
// injectable for tests.
|
|
93
|
+
function dispatchToEngine({ task, engine, worktreePath, timeoutMs = 900000, runner = null }) {
|
|
94
|
+
const prompt = buildFleetPrompt(task, { worktreePath });
|
|
95
|
+
const promptFile = path.join(worktreePath, '.atris', `fleet-prompt-${task.display_id || 'task'}.md`);
|
|
96
|
+
fs.mkdirSync(path.dirname(promptFile), { recursive: true });
|
|
97
|
+
fs.writeFileSync(promptFile, prompt);
|
|
98
|
+
const command = buildEngineCommand(engine, promptFile);
|
|
99
|
+
const exec = runner || ((cmd) => spawnSync('sh', ['-c', cmd], {
|
|
100
|
+
cwd: worktreePath,
|
|
101
|
+
encoding: 'utf8',
|
|
102
|
+
timeout: timeoutMs,
|
|
103
|
+
}));
|
|
104
|
+
const result = exec(command);
|
|
105
|
+
return {
|
|
106
|
+
task: task.display_id || task.id,
|
|
107
|
+
engine,
|
|
108
|
+
worktreePath,
|
|
109
|
+
command,
|
|
110
|
+
exitCode: result.status,
|
|
111
|
+
report: String(result.stdout || '').slice(-8000),
|
|
112
|
+
stderr: String(result.stderr || '').slice(-2000),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// T2 — staffing
|
|
118
|
+
|
|
119
|
+
function taskTags(task) {
|
|
120
|
+
const fromTags = Array.isArray(task.tags) ? task.tags : [];
|
|
121
|
+
const fromTitle = (String(task.title || '').match(/#([a-z0-9-]+)/gi) || []).map((t) => t.slice(1));
|
|
122
|
+
return [...fromTags, ...fromTitle].map((t) => String(t).toLowerCase());
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isSafeLane(task) {
|
|
126
|
+
const tags = taskTags(task);
|
|
127
|
+
return !tags.some((t) => DENIED_TAGS.includes(t));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Rough file-surface guess from a task's text: paths and bare `commands/x.js`
|
|
131
|
+
// style mentions. Used only to keep concurrent picks disjoint — the merge
|
|
132
|
+
// conflict on 2026-07-02 (two engines in commands/mission.js) is the tax this
|
|
133
|
+
// avoids.
|
|
134
|
+
function fileSurface(task) {
|
|
135
|
+
const text = String(task.title || '');
|
|
136
|
+
const matches = text.match(/\b(?:commands|lib|bin|test|scripts)\/[A-Za-z0-9._/-]+/g) || [];
|
|
137
|
+
return [...new Set(matches)];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function surfacesOverlap(a, b) {
|
|
141
|
+
return a.some((f) => b.includes(f));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Pick up to `slots` open, safe-lane, mutually file-disjoint tasks. Tasks
|
|
145
|
+
// whose surface cannot be guessed are allowed one at a time (the unknown
|
|
146
|
+
// surface could overlap anything).
|
|
147
|
+
function staffFlight(tasks, { slots = 3 } = {}) {
|
|
148
|
+
const open = (tasks || []).filter((t) => t && t.status === 'open' && isSafeLane(t));
|
|
149
|
+
const picked = [];
|
|
150
|
+
let blindPicked = false;
|
|
151
|
+
for (const task of open) {
|
|
152
|
+
if (picked.length >= slots) break;
|
|
153
|
+
const surface = fileSurface(task);
|
|
154
|
+
if (surface.length === 0) {
|
|
155
|
+
if (blindPicked) continue;
|
|
156
|
+
blindPicked = true;
|
|
157
|
+
picked.push({ task, surface });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (picked.some((p) => surfacesOverlap(p.surface, surface))) continue;
|
|
161
|
+
picked.push({ task, surface });
|
|
162
|
+
}
|
|
163
|
+
return picked.map((p, i) => ({ task: p.task, surface: p.surface, slot: i }));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Pair staffed tasks with installed engines, round-robin. `engines` comes
|
|
167
|
+
// from commands/engine.js roster (installed only).
|
|
168
|
+
function assignEngines(staffed, engines) {
|
|
169
|
+
const pool = (engines || []).filter(Boolean);
|
|
170
|
+
if (!pool.length) return [];
|
|
171
|
+
return staffed.map((entry, i) => ({ ...entry, engine: pool[i % pool.length] }));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// T3 — serial landing lane
|
|
176
|
+
|
|
177
|
+
// Land one arrival: rebase onto latest base first; a conflict pauses the
|
|
178
|
+
// landing (never auto-resolve) and reports which files collided so the
|
|
179
|
+
// conductor or a human can take it. `git` is injectable for tests.
|
|
180
|
+
function landArrival({ worktreePath, git = null }) {
|
|
181
|
+
const run = git || ((args) => spawnSync('git', args, { cwd: worktreePath, encoding: 'utf8' }));
|
|
182
|
+
const fetch = run(['fetch', 'origin']);
|
|
183
|
+
if (fetch.status !== 0) return { ok: false, stage: 'fetch', detail: String(fetch.stderr || '').trim() };
|
|
184
|
+
const rebase = run(['rebase', 'origin/master']);
|
|
185
|
+
if (rebase.status !== 0) {
|
|
186
|
+
const conflicted = run(['diff', '--name-only', '--diff-filter=U']);
|
|
187
|
+
run(['rebase', '--abort']);
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
stage: 'rebase_conflict',
|
|
191
|
+
conflicts: String(conflicted.stdout || '').trim().split('\n').filter(Boolean),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return { ok: true, stage: 'rebased' };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = {
|
|
198
|
+
DENIED_TAGS,
|
|
199
|
+
get FLEET_CAPABLE() { return FLEET_CAPABLE; },
|
|
200
|
+
get runFleetFlight() { return runFleetFlight; },
|
|
201
|
+
get focusedCheck() { return focusedCheck; },
|
|
202
|
+
parseDoneCheck,
|
|
203
|
+
buildFleetPrompt,
|
|
204
|
+
buildEngineCommand,
|
|
205
|
+
dispatchToEngine,
|
|
206
|
+
taskTags,
|
|
207
|
+
isSafeLane,
|
|
208
|
+
fileSurface,
|
|
209
|
+
surfacesOverlap,
|
|
210
|
+
staffFlight,
|
|
211
|
+
assignEngines,
|
|
212
|
+
landArrival,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// T4 — the conductor: one flight, watchable, receipted
|
|
217
|
+
|
|
218
|
+
// Engines that can edit a repo headlessly. atris-fast (ax) is a chat lane,
|
|
219
|
+
// not a repo worker — it keeps owning normal mission ticks, not fleet builds.
|
|
220
|
+
const FLEET_CAPABLE = ['claude', 'codex', 'cursor', 'devin'];
|
|
221
|
+
|
|
222
|
+
function nowStamp() {
|
|
223
|
+
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function readProjectionTasks(root) {
|
|
227
|
+
try {
|
|
228
|
+
const raw = fs.readFileSync(path.join(root, '.atris', 'state', 'tasks.projection.json'), 'utf8');
|
|
229
|
+
const projection = JSON.parse(raw);
|
|
230
|
+
return Array.isArray(projection.tasks) ? projection.tasks : [];
|
|
231
|
+
} catch {
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function defaultOwnCli(root) {
|
|
237
|
+
const bin = path.resolve(__dirname, '..', 'bin', 'atris.js');
|
|
238
|
+
return (cliArgs, cwd = root) => {
|
|
239
|
+
const result = spawnSync(process.execPath, [bin, ...cliArgs], { cwd, encoding: 'utf8', timeout: 600000 });
|
|
240
|
+
return { status: result.status, stdout: String(result.stdout || ''), stderr: String(result.stderr || '') };
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Extract a runnable focused check from the task's Check: line; the wide
|
|
245
|
+
// gates (npm test, CI) stay at the ship gate, not per-arrival.
|
|
246
|
+
function focusedCheck(task) {
|
|
247
|
+
const { check } = parseDoneCheck(task.title);
|
|
248
|
+
const m = check.match(/node --test [^,;.]+/);
|
|
249
|
+
return m ? m[0].trim() : '';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// One flight. Staff -> dispatch in parallel -> land serially -> receipt.
|
|
253
|
+
// No fleet state file: progress is narrated via `log`, durability lives in
|
|
254
|
+
// worktrees, task claims, and the receipt written at the end.
|
|
255
|
+
async function runFleetFlight({
|
|
256
|
+
root = process.cwd(),
|
|
257
|
+
slots = 3,
|
|
258
|
+
engines = null,
|
|
259
|
+
dryRun = false,
|
|
260
|
+
log = console.log,
|
|
261
|
+
ownCli = null,
|
|
262
|
+
dispatcher = null,
|
|
263
|
+
lander = null,
|
|
264
|
+
} = {}) {
|
|
265
|
+
const cli = ownCli || defaultOwnCli(root);
|
|
266
|
+
const roster = engines || (() => {
|
|
267
|
+
const { roster: fullRoster } = require('../commands/engine');
|
|
268
|
+
return fullRoster(root).filter((e) => e.installed && FLEET_CAPABLE.includes(e.name)).map((e) => e.name);
|
|
269
|
+
})();
|
|
270
|
+
|
|
271
|
+
const staffed = assignEngines(staffFlight(readProjectionTasks(root), { slots }), roster);
|
|
272
|
+
const flight = {
|
|
273
|
+
at: new Date().toISOString(),
|
|
274
|
+
root,
|
|
275
|
+
slots,
|
|
276
|
+
roster,
|
|
277
|
+
staffed: staffed.map((s) => ({ task: s.task.display_id, title: String(s.task.title || '').slice(0, 140), engine: s.engine, surface: s.surface })),
|
|
278
|
+
results: [],
|
|
279
|
+
landed: [],
|
|
280
|
+
paused: [],
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
log('');
|
|
284
|
+
log(` fleet — ${roster.length} engine${roster.length === 1 ? '' : 's'} ready, ${staffed.length} task${staffed.length === 1 ? '' : 's'} staffed`);
|
|
285
|
+
for (const s of staffed) log(` ${s.engine.padEnd(8)} → ${s.task.display_id} ${String(s.task.title || '').slice(0, 80)}`);
|
|
286
|
+
log('');
|
|
287
|
+
|
|
288
|
+
if (dryRun || staffed.length === 0) {
|
|
289
|
+
flight.dry_run = true;
|
|
290
|
+
return flight;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Claim + cut a worktree per assignment, then dispatch all in parallel.
|
|
294
|
+
const dispatch = dispatcher || ((entry) => new Promise((resolve) => {
|
|
295
|
+
resolve(dispatchToEngine({ task: entry.task, engine: entry.engine, worktreePath: entry.worktreePath }));
|
|
296
|
+
}));
|
|
297
|
+
|
|
298
|
+
const prepared = [];
|
|
299
|
+
for (const entry of staffed) {
|
|
300
|
+
cli(['task', 'claim', String(entry.task.display_id), '--as', `fleet-${entry.engine}`]);
|
|
301
|
+
const started = cli(['worktree', 'start', '--agent', entry.engine, '--task', `fleet-${String(entry.task.display_id).toLowerCase()}`]);
|
|
302
|
+
const wt = (started.stdout.match(/next: cd (.+)/) || [])[1];
|
|
303
|
+
if (!wt) {
|
|
304
|
+
flight.paused.push({ task: entry.task.display_id, stage: 'worktree_start', detail: started.stderr.slice(0, 200) });
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
prepared.push({ ...entry, worktreePath: wt.trim() });
|
|
308
|
+
log(` ${entry.engine.padEnd(8)} building ${entry.task.display_id} in ${path.basename(wt.trim())}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const results = await Promise.all(prepared.map((entry) =>
|
|
312
|
+
dispatch(entry).then((r) => ({ entry, result: r })).catch((err) => ({ entry, result: { exitCode: 1, report: '', stderr: String(err && err.message || err) } }))
|
|
313
|
+
));
|
|
314
|
+
flight.results = results.map(({ entry, result }) => ({ task: entry.task.display_id, engine: entry.engine, exitCode: result.exitCode }));
|
|
315
|
+
|
|
316
|
+
// Land serially: rebase-before-ship, conflict pauses (never auto-resolve).
|
|
317
|
+
const land = lander || (({ entry }) => {
|
|
318
|
+
const rebased = landArrival({ worktreePath: entry.worktreePath });
|
|
319
|
+
if (!rebased.ok) return rebased;
|
|
320
|
+
const check = focusedCheck(entry.task) || 'git log -1 --oneline';
|
|
321
|
+
const shipped = cli(['worktree', 'ship', '--message', `${String(entry.task.title || '').split(/[.:]/)[0].slice(0, 90)} (${entry.task.display_id}, built by ${entry.engine})`, '--verify', check, '--merge'], entry.worktreePath);
|
|
322
|
+
if (shipped.status !== 0 || !/done: worktree shipped/.test(shipped.stdout)) {
|
|
323
|
+
return { ok: false, stage: 'ship', detail: (shipped.stderr || shipped.stdout).slice(-300) };
|
|
324
|
+
}
|
|
325
|
+
return { ok: true, stage: 'shipped' };
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
for (const { entry, result } of results) {
|
|
329
|
+
if (result.exitCode !== 0) {
|
|
330
|
+
flight.paused.push({ task: entry.task.display_id, engine: entry.engine, stage: 'build', detail: (result.stderr || '').slice(0, 200) });
|
|
331
|
+
log(` ${entry.engine.padEnd(8)} ✗ build failed ${entry.task.display_id} — worktree kept for takeover`);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
log(` ${entry.engine.padEnd(8)} landing ${entry.task.display_id}...`);
|
|
335
|
+
const landed = land({ entry, result });
|
|
336
|
+
if (landed.ok) {
|
|
337
|
+
flight.landed.push({ task: entry.task.display_id, engine: entry.engine });
|
|
338
|
+
cli(['task', 'ready', String(entry.task.display_id), '--proof', `Built by ${entry.engine} engine in fleet flight, landed via worktree ship gate (rebase-before-ship, verify re-run). Report tail: ${String(result.report || '').slice(-300).replace(/\n/g, ' ')}`, '--as', `fleet-${entry.engine}`]);
|
|
339
|
+
log(` ${entry.engine.padEnd(8)} ✓ landed ${entry.task.display_id}`);
|
|
340
|
+
} else {
|
|
341
|
+
flight.paused.push({ task: entry.task.display_id, engine: entry.engine, ...landed });
|
|
342
|
+
log(` ${entry.engine.padEnd(8)} ⏸ paused ${entry.task.display_id} at ${landed.stage}${landed.conflicts ? ` (${landed.conflicts.join(', ')})` : ''} — worktree kept`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const receiptDir = path.join(root, 'atris', 'runs');
|
|
347
|
+
fs.mkdirSync(receiptDir, { recursive: true });
|
|
348
|
+
flight.receipt = path.join(receiptDir, `fleet-${nowStamp()}.json`);
|
|
349
|
+
fs.writeFileSync(flight.receipt, `${JSON.stringify(flight, null, 2)}\n`);
|
|
350
|
+
log('');
|
|
351
|
+
log(` flight over: ${flight.landed.length} landed, ${flight.paused.length} paused · receipt: ${path.relative(root, flight.receipt)}`);
|
|
352
|
+
log('');
|
|
353
|
+
return flight;
|
|
354
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const MISSION_INSPECT_FIELDS = new Set(['status', 'runner', 'ack', 'pings']);
|
|
4
|
+
|
|
5
|
+
const TASK_INSPECT_FIELDS = new Set([
|
|
6
|
+
'status',
|
|
7
|
+
'title',
|
|
8
|
+
'claimed_by',
|
|
9
|
+
'tag',
|
|
10
|
+
'review',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
function parseFieldList(raw) {
|
|
14
|
+
return String(raw || '')
|
|
15
|
+
.split(',')
|
|
16
|
+
.map((part) => part.trim().toLowerCase())
|
|
17
|
+
.filter(Boolean);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readFieldsFlag(args, flagName = '--fields') {
|
|
21
|
+
const index = args.indexOf(flagName);
|
|
22
|
+
if (index === -1) return null;
|
|
23
|
+
const raw = args[index + 1];
|
|
24
|
+
if (!raw || String(raw).startsWith('--')) {
|
|
25
|
+
return { error: `${flagName} requires a comma-separated field list` };
|
|
26
|
+
}
|
|
27
|
+
const fields = parseFieldList(raw);
|
|
28
|
+
if (!fields.length) {
|
|
29
|
+
return { error: `${flagName} requires at least one field` };
|
|
30
|
+
}
|
|
31
|
+
return { fields };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function stripInspectArgs(args, { flagName = '--fields' } = {}) {
|
|
35
|
+
const out = [];
|
|
36
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
37
|
+
const arg = String(args[i]);
|
|
38
|
+
if (arg === '--json') continue;
|
|
39
|
+
if (arg === flagName) {
|
|
40
|
+
i += 1;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
out.push(args[i]);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function validateFields(fields, allowed, label) {
|
|
49
|
+
const unknown = fields.filter((field) => !allowed.has(field));
|
|
50
|
+
if (!unknown.length) return null;
|
|
51
|
+
return `Unknown ${label} inspect field${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}. Allowed: ${[...allowed].join(', ')}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function missionAckInspectValue(mission) {
|
|
55
|
+
const ack = mission && mission.native_goal_ack;
|
|
56
|
+
if (!ack || typeof ack !== 'object') {
|
|
57
|
+
return {
|
|
58
|
+
acknowledged: false,
|
|
59
|
+
status: null,
|
|
60
|
+
runtime: null,
|
|
61
|
+
objective: null,
|
|
62
|
+
acknowledged_at: null,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
acknowledged: true,
|
|
67
|
+
status: ack.status || null,
|
|
68
|
+
runtime: ack.runtime || null,
|
|
69
|
+
objective: ack.objective || null,
|
|
70
|
+
acknowledged_at: ack.acknowledged_at || null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function missionPingsInspectValue(mission) {
|
|
75
|
+
const pings = Array.isArray(mission && mission.pings) ? mission.pings.filter(Boolean) : [];
|
|
76
|
+
const pending = pings.filter((ping) => ping && !ping.consumed_at).length;
|
|
77
|
+
return {
|
|
78
|
+
total: pings.length,
|
|
79
|
+
pending,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function missionInspectFieldValues(mission, fields) {
|
|
84
|
+
const values = {};
|
|
85
|
+
for (const field of fields) {
|
|
86
|
+
switch (field) {
|
|
87
|
+
case 'status':
|
|
88
|
+
values.status = mission.status || null;
|
|
89
|
+
break;
|
|
90
|
+
case 'runner': {
|
|
91
|
+
const runner = mission.runner || 'manual';
|
|
92
|
+
values.runner = mission.model ? `${runner} (${mission.model})` : runner;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
case 'ack':
|
|
96
|
+
values.ack = missionAckInspectValue(mission);
|
|
97
|
+
break;
|
|
98
|
+
case 'pings':
|
|
99
|
+
values.pings = missionPingsInspectValue(mission);
|
|
100
|
+
break;
|
|
101
|
+
default:
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return values;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function formatInspectScalar(value) {
|
|
109
|
+
if (value === null || value === undefined) return 'null';
|
|
110
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
111
|
+
if (typeof value === 'number') return String(value);
|
|
112
|
+
if (typeof value === 'string') return value;
|
|
113
|
+
return JSON.stringify(value);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function formatInspectFieldText(field, value) {
|
|
117
|
+
if (field === 'ack') {
|
|
118
|
+
if (!value || value.acknowledged !== true) return 'unacknowledged';
|
|
119
|
+
const bits = [value.status || 'acknowledged'];
|
|
120
|
+
if (value.acknowledged_at) bits.push(`@ ${value.acknowledged_at}`);
|
|
121
|
+
return bits.join(' ');
|
|
122
|
+
}
|
|
123
|
+
if (field === 'pings') {
|
|
124
|
+
if (!value || typeof value !== 'object') return '0';
|
|
125
|
+
if (value.pending === value.total) return String(value.pending);
|
|
126
|
+
return `${value.pending} pending / ${value.total} total`;
|
|
127
|
+
}
|
|
128
|
+
if (field === 'runner' && value && typeof value === 'object') {
|
|
129
|
+
return value.model ? `${value.runner} (${value.model})` : String(value.runner || 'manual');
|
|
130
|
+
}
|
|
131
|
+
if (field === 'review' && value && typeof value === 'object') {
|
|
132
|
+
return JSON.stringify(value);
|
|
133
|
+
}
|
|
134
|
+
return formatInspectScalar(value);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function inspectTextLines(fields, values) {
|
|
138
|
+
if (fields.length === 1) {
|
|
139
|
+
const field = fields[0];
|
|
140
|
+
return [formatInspectFieldText(field, values[field])];
|
|
141
|
+
}
|
|
142
|
+
return fields.map((field) => `${field}: ${formatInspectFieldText(field, values[field])}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function buildInspectPayload({
|
|
146
|
+
action,
|
|
147
|
+
idKey,
|
|
148
|
+
idValue,
|
|
149
|
+
fields,
|
|
150
|
+
values,
|
|
151
|
+
}) {
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
action,
|
|
155
|
+
[idKey]: idValue,
|
|
156
|
+
fields: values,
|
|
157
|
+
requested_fields: fields,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = {
|
|
162
|
+
MISSION_INSPECT_FIELDS,
|
|
163
|
+
TASK_INSPECT_FIELDS,
|
|
164
|
+
parseFieldList,
|
|
165
|
+
readFieldsFlag,
|
|
166
|
+
stripInspectArgs,
|
|
167
|
+
validateFields,
|
|
168
|
+
missionAckInspectValue,
|
|
169
|
+
missionPingsInspectValue,
|
|
170
|
+
missionInspectFieldValues,
|
|
171
|
+
formatInspectFieldText,
|
|
172
|
+
inspectTextLines,
|
|
173
|
+
buildInspectPayload,
|
|
174
|
+
};
|
package/lib/runner-command.js
CHANGED
|
@@ -16,11 +16,35 @@ const DEFAULT_CLAUDE_RUNNER_BIN = 'claude';
|
|
|
16
16
|
// canonical config but are kept OUT of operator-facing lists so config errors
|
|
17
17
|
// stay honest: "one of: atris-fast", not three duplicate spellings.
|
|
18
18
|
const RUNNER_PROFILE_DEFS = Object.freeze({
|
|
19
|
+
// House engine first: the default intelligence is our own.
|
|
19
20
|
'atris-fast': Object.freeze({
|
|
20
21
|
bin: 'ax',
|
|
21
22
|
model: 'atris:fast',
|
|
22
23
|
commandTemplate: '{bin} --fast {prompt}',
|
|
23
24
|
}),
|
|
25
|
+
// Guest engines: any installed headless coding CLI is a swappable worker.
|
|
26
|
+
// No commandTemplate on claude — the default spawn shape is already
|
|
27
|
+
// claude-compatible (bin -p prompt --model X --allowedTools ...).
|
|
28
|
+
claude: Object.freeze({
|
|
29
|
+
bin: 'claude',
|
|
30
|
+
model: '',
|
|
31
|
+
commandTemplate: '',
|
|
32
|
+
}),
|
|
33
|
+
codex: Object.freeze({
|
|
34
|
+
bin: 'codex',
|
|
35
|
+
model: '',
|
|
36
|
+
commandTemplate: '{bin} exec {prompt}',
|
|
37
|
+
}),
|
|
38
|
+
cursor: Object.freeze({
|
|
39
|
+
bin: 'cursor-agent',
|
|
40
|
+
model: '',
|
|
41
|
+
commandTemplate: '{bin} --trust -p {prompt}',
|
|
42
|
+
}),
|
|
43
|
+
devin: Object.freeze({
|
|
44
|
+
bin: 'devin',
|
|
45
|
+
model: '',
|
|
46
|
+
commandTemplate: '{bin} -p -- {prompt}',
|
|
47
|
+
}),
|
|
24
48
|
});
|
|
25
49
|
|
|
26
50
|
// Alias -> canonical profile name. Every alias resolves to the same config as
|
package/lib/task-db.js
CHANGED
|
@@ -469,6 +469,43 @@ function claimTask(db, { id, claimedBy }) {
|
|
|
469
469
|
return { claimed: false, reason: 'already_' + row.status, claimed_by: row.claimed_by };
|
|
470
470
|
}
|
|
471
471
|
|
|
472
|
+
function releaseTask(db, { id, actor }) {
|
|
473
|
+
if (!id) throw new Error('id required');
|
|
474
|
+
if (!actor) throw new Error('actor required');
|
|
475
|
+
const row = getTask(db, id);
|
|
476
|
+
if (!row) return { released: false, reason: 'not_found' };
|
|
477
|
+
if (row.status !== 'claimed') {
|
|
478
|
+
return { released: false, reason: `already_${row.status}`, claimed_by: row.claimed_by || null };
|
|
479
|
+
}
|
|
480
|
+
if (row.claimed_by !== actor) {
|
|
481
|
+
return { released: false, reason: 'held_by_other', claimed_by: row.claimed_by || null };
|
|
482
|
+
}
|
|
483
|
+
const now = Date.now();
|
|
484
|
+
const result = withBusyRetry(() => db.prepare(`
|
|
485
|
+
UPDATE tasks
|
|
486
|
+
SET status = 'open',
|
|
487
|
+
claimed_by = NULL,
|
|
488
|
+
claimed_at = NULL,
|
|
489
|
+
updated_at = ?
|
|
490
|
+
WHERE id = ?
|
|
491
|
+
AND status = 'claimed'
|
|
492
|
+
AND claimed_by = ?
|
|
493
|
+
`).run(now, id, actor));
|
|
494
|
+
if (result.changes !== 1) return { released: false, reason: 'stale_task_state', claimed_by: row.claimed_by || null };
|
|
495
|
+
const updated = getTask(db, id);
|
|
496
|
+
const event = appendTaskEvent(db, {
|
|
497
|
+
taskId: id,
|
|
498
|
+
workspaceRoot: updated.workspace_root,
|
|
499
|
+
actor,
|
|
500
|
+
eventType: 'claim_released',
|
|
501
|
+
payload: {
|
|
502
|
+
released_by: actor,
|
|
503
|
+
previous_claimed_by: row.claimed_by || null,
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
return { released: true, row: updated, event };
|
|
507
|
+
}
|
|
508
|
+
|
|
472
509
|
function doneTask(db, { id, status, actor, allowReview = false, action, proof } = {}) {
|
|
473
510
|
if (!id) throw new Error('id required');
|
|
474
511
|
const final = status || 'done';
|
|
@@ -1721,6 +1758,7 @@ module.exports = {
|
|
|
1721
1758
|
getTask,
|
|
1722
1759
|
listTasks,
|
|
1723
1760
|
claimTask,
|
|
1761
|
+
releaseTask,
|
|
1724
1762
|
backlogTask,
|
|
1725
1763
|
clearPlanTasks,
|
|
1726
1764
|
doneTask,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "atris",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.33.3",
|
|
4
4
|
"main": "bin/atris.js",
|
|
5
5
|
"bin": {
|
|
6
6
|
"atris": "bin/atris.js",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"cli/*.py",
|
|
13
13
|
"commands/",
|
|
14
14
|
"decks/",
|
|
15
|
+
"scripts/agent_worktree.py",
|
|
15
16
|
"utils/",
|
|
16
17
|
"lib/",
|
|
17
18
|
"templates/",
|