atris 3.48.0 → 3.48.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/atris/CLAUDE.md +2 -0
- package/atris/skills/youtube/SKILL.md +2 -2
- package/bin/atris.js +5 -0
- package/commands/dream.js +1 -1
- package/commands/engine.js +12 -5
- package/commands/member.js +38 -1
- package/commands/task.js +94 -22
- package/commands/who.js +176 -0
- package/commands/worktree.js +7 -5
- package/lib/engine-registry.js +1 -0
- package/lib/fleet.js +108 -45
- package/lib/known-commands.js +1 -1
- package/lib/task-db.js +2 -2
- package/lib/workforce-presence.js +448 -0
- package/package.json +1 -1
package/atris/CLAUDE.md
CHANGED
|
@@ -100,6 +100,8 @@ The human approves work by reading, so how you report IS the product.
|
|
|
100
100
|
|
|
101
101
|
## Rules (Non‑Negotiable)
|
|
102
102
|
|
|
103
|
+
- YouTube link in a message: run `atris youtube notes <url>` before saying anything about the video. The transcript is the source; notes from model memory are fabrication.
|
|
104
|
+
|
|
103
105
|
- Plan = ASCII visualization + approval gate. Do not execute during planning.
|
|
104
106
|
- Execute step-by-step, verify as you go, update artifacts (`TODO.md`, `MAP.md`) when reality changes.
|
|
105
107
|
- Delete completed tasks (validator cleans to target state = 0).
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: youtube
|
|
3
|
-
description: "
|
|
4
|
-
version: 2.
|
|
3
|
+
description: "A YouTube link in any message routes here. Run atris youtube notes <url> FIRST: free, about 30 seconds, quotes verified against the transcript. Never summarize a video from model memory, that is fabrication. Use atris youtube process only to store it as queryable knowledge (5 credits). Triggers on: any youtube.com or youtu.be link, youtube, video, watch this, notes on this."
|
|
4
|
+
version: 2.4.0
|
|
5
5
|
tags:
|
|
6
6
|
- youtube
|
|
7
7
|
- research
|
package/bin/atris.js
CHANGED
|
@@ -513,6 +513,7 @@ function showHelp() {
|
|
|
513
513
|
console.log(' orb - Pick next moves while engine jobs work in the background');
|
|
514
514
|
console.log(' activate - Load Atris context');
|
|
515
515
|
console.log(' radar - Show live agents joined with tasks, missions, and worktrees');
|
|
516
|
+
console.log(' who - Show local engines and team members working, waiting, done, or stale');
|
|
516
517
|
console.log(' stream - Watch the whole team work live in one terminal');
|
|
517
518
|
console.log(' team - One team view: members, roles, engine assignments (presence for live)');
|
|
518
519
|
console.log(' watch - Turn one sentence into an always-on background watcher');
|
|
@@ -1829,6 +1830,10 @@ if (command === 'init') {
|
|
|
1829
1830
|
Promise.resolve(require('../commands/team').teamCommand(process.argv.slice(3)))
|
|
1830
1831
|
.then((code) => process.exit(code || 0))
|
|
1831
1832
|
.catch((err) => { console.error(`\nerror: ${err.message || err}`); process.exit(1); });
|
|
1833
|
+
} else if (command === 'who') {
|
|
1834
|
+
Promise.resolve(require('../commands/who').whoCommand(process.argv.slice(3)))
|
|
1835
|
+
.then((code) => process.exit(code || 0))
|
|
1836
|
+
.catch((err) => { console.error(`\nerror: ${err.message || err}`); process.exit(1); });
|
|
1832
1837
|
} else if (command === 'wish') {
|
|
1833
1838
|
Promise.resolve(require('../commands/wish').wishCommand(process.argv.slice(3)))
|
|
1834
1839
|
.then((code) => process.exit(code || 0))
|
package/commands/dream.js
CHANGED
|
@@ -10,7 +10,7 @@ const {
|
|
|
10
10
|
|
|
11
11
|
const DREAMS_FILE = ['.atris', 'state', 'dreams.jsonl'];
|
|
12
12
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
13
|
-
const DREAM_TIMEOUT_MS = 60 * 1000;
|
|
13
|
+
const DREAM_TIMEOUT_MS = 5 * 60 * 1000;
|
|
14
14
|
const MAX_CONTEXT_CHARS = 7000;
|
|
15
15
|
const MAX_SECTION_CHARS = 2400;
|
|
16
16
|
|
package/commands/engine.js
CHANGED
|
@@ -37,10 +37,11 @@ const {
|
|
|
37
37
|
resolveEngineForRoleRanked,
|
|
38
38
|
requireEngineBin,
|
|
39
39
|
engineDoctorReport,
|
|
40
|
+
engineFailureHealthStatus,
|
|
40
41
|
setEngineOverrides,
|
|
41
42
|
setEngineHealth,
|
|
42
43
|
} = require('../lib/engine-registry');
|
|
43
|
-
const {
|
|
44
|
+
const { DISPATCH_CAPABLE, runDispatchFlight } = require('../lib/fleet');
|
|
44
45
|
const {
|
|
45
46
|
buildReadOnlyEngineInvocation,
|
|
46
47
|
runEngineAskCommand,
|
|
@@ -1379,12 +1380,12 @@ function parseDispatchArgs(args) {
|
|
|
1379
1380
|
function runDispatchCommand(args, root) {
|
|
1380
1381
|
const { taskIds, engine, promptFile, base, json, yolo } = parseDispatchArgs(args);
|
|
1381
1382
|
if (!taskIds.length || !engine) {
|
|
1382
|
-
console.error('usage: atris engine dispatch <task-id> [<task-id> ...] --engine
|
|
1383
|
+
console.error('usage: atris engine dispatch <task-id> [<task-id> ...] --engine <engine> [--prompt-file <f>] [--yolo]');
|
|
1383
1384
|
return 2;
|
|
1384
1385
|
}
|
|
1385
1386
|
const canonical = canonicalEngineName(engine);
|
|
1386
|
-
if (!canonical || !
|
|
1387
|
-
console.error(`engine dispatch: --engine must be one of ${
|
|
1387
|
+
if (!canonical || !DISPATCH_CAPABLE.includes(canonical)) {
|
|
1388
|
+
console.error(`engine dispatch: --engine must be one of ${DISPATCH_CAPABLE.join(', ')}`);
|
|
1388
1389
|
return 2;
|
|
1389
1390
|
}
|
|
1390
1391
|
// Argument-shape errors surface before environment errors: --prompt-file
|
|
@@ -1407,7 +1408,13 @@ function runDispatchCommand(args, root) {
|
|
|
1407
1408
|
try {
|
|
1408
1409
|
requireEngineBin(canonical);
|
|
1409
1410
|
} catch (err) {
|
|
1410
|
-
|
|
1411
|
+
const status = engineFailureHealthStatus({ status: 'errored', reason: err.message });
|
|
1412
|
+
if (status) {
|
|
1413
|
+
try { setEngineHealth(canonical, status, root); } catch { /* best effort */ }
|
|
1414
|
+
}
|
|
1415
|
+
console.error(canonical === 'fable'
|
|
1416
|
+
? `fable handoff failed: ${err.message}`
|
|
1417
|
+
: `engine dispatch: ${err.message}`);
|
|
1411
1418
|
return 2;
|
|
1412
1419
|
}
|
|
1413
1420
|
return runDispatchFlight({ root, taskIds, engine: canonical, prompt: promptOverride, yolo, ...(base ? { checkoutBase: base } : {}) }).then((flight) => {
|
package/commands/member.js
CHANGED
|
@@ -7288,6 +7288,7 @@ function composeWakeDecision(ctx, verdict) {
|
|
|
7288
7288
|
state: ctx.state,
|
|
7289
7289
|
goal: verdict.goal,
|
|
7290
7290
|
current_experiment: verdict.current_experiment,
|
|
7291
|
+
now_file: ctx.nowFile,
|
|
7291
7292
|
};
|
|
7292
7293
|
if ('autonomous_problem' in verdict) result.autonomous_problem = verdict.autonomous_problem;
|
|
7293
7294
|
result.checks = ctx.checks;
|
|
@@ -7471,6 +7472,11 @@ const WAKE_DECISION_RULES = [
|
|
|
7471
7472
|
|
|
7472
7473
|
function wakeDecision(name, paths, { force = false, runtimeKind = memberRuntimeKind(name) } = {}) {
|
|
7473
7474
|
const purpose = missionPurpose(paths);
|
|
7475
|
+
const nowPath = path.join(paths.memberDir, 'now.md');
|
|
7476
|
+
const nowFile = fs.existsSync(nowPath) ? {
|
|
7477
|
+
path: path.relative(process.cwd(), nowPath),
|
|
7478
|
+
excerpt: String(purpose.nowText || '').slice(0, 500),
|
|
7479
|
+
} : null;
|
|
7474
7480
|
const steering = readSteeringMemory(paths, name);
|
|
7475
7481
|
const state = loadMemberGoals(name, paths);
|
|
7476
7482
|
const goal = activeGoal(state);
|
|
@@ -7519,7 +7525,7 @@ function wakeDecision(name, paths, { force = false, runtimeKind = memberRuntimeK
|
|
|
7519
7525
|
next_command: wakeScores.selected.next_command,
|
|
7520
7526
|
} : null;
|
|
7521
7527
|
|
|
7522
|
-
const ctx = { name, force, purpose, steering, state, goal, current, blocked, evidence, workspace, checks, wakeScores };
|
|
7528
|
+
const ctx = { name, force, purpose, nowFile, steering, state, goal, current, blocked, evidence, workspace, checks, wakeScores };
|
|
7523
7529
|
for (const rule of WAKE_DECISION_RULES) {
|
|
7524
7530
|
const verdict = rule(ctx);
|
|
7525
7531
|
if (verdict) return composeWakeDecision(ctx, verdict);
|
|
@@ -7624,6 +7630,11 @@ async function runMemberWake(name, { execute = false, confirmed = false, force =
|
|
|
7624
7630
|
nextCommand = `atris member review ${name} ${experiment.id} --accept --proof "..." --value 4`;
|
|
7625
7631
|
}
|
|
7626
7632
|
|
|
7633
|
+
const goals = (state.goals || []).map((item) => ({
|
|
7634
|
+
id: item.id || null,
|
|
7635
|
+
title: item.title || null,
|
|
7636
|
+
status: item.status || null,
|
|
7637
|
+
}));
|
|
7627
7638
|
const receiptPayload = {
|
|
7628
7639
|
schema: 'atris.member_wake.v1',
|
|
7629
7640
|
created_at: now,
|
|
@@ -7636,6 +7647,8 @@ async function runMemberWake(name, { execute = false, confirmed = false, force =
|
|
|
7636
7647
|
ask: planned.ask || null,
|
|
7637
7648
|
next_command: nextCommand,
|
|
7638
7649
|
mission: planned.mission,
|
|
7650
|
+
now_file: planned.now_file,
|
|
7651
|
+
goals,
|
|
7639
7652
|
steering: planned.steering,
|
|
7640
7653
|
evidence: planned.evidence,
|
|
7641
7654
|
checks: planned.checks,
|
|
@@ -7692,6 +7705,8 @@ async function runMemberWake(name, { execute = false, confirmed = false, force =
|
|
|
7692
7705
|
ask: planned.ask || null,
|
|
7693
7706
|
next_command: nextCommand,
|
|
7694
7707
|
mission: planned.mission,
|
|
7708
|
+
now_file: planned.now_file,
|
|
7709
|
+
goals,
|
|
7695
7710
|
steering: planned.steering,
|
|
7696
7711
|
evidence: planned.evidence,
|
|
7697
7712
|
checks: planned.checks,
|
|
@@ -8972,6 +8987,25 @@ async function memberImprovements(businessId, ...args) {
|
|
|
8972
8987
|
|
|
8973
8988
|
// --- Command Dispatcher ---
|
|
8974
8989
|
|
|
8990
|
+
async function memberChat(name, ...rest) {
|
|
8991
|
+
const paths = requireMemberDir(name);
|
|
8992
|
+
const flags = rest.filter((a) => String(a).startsWith('--'));
|
|
8993
|
+
const words = rest.filter((a) => !String(a).startsWith('--'));
|
|
8994
|
+
const fromFlag = flags.find((f) => f.startsWith('--from='));
|
|
8995
|
+
const from = fromFlag ? fromFlag.slice('--from='.length) : (process.env.USER || 'terminal');
|
|
8996
|
+
const text = words.join(' ').trim();
|
|
8997
|
+
if (!text) {
|
|
8998
|
+
console.error('Usage: atris member chat <name> "your message" [--from=<sender>]');
|
|
8999
|
+
process.exit(1);
|
|
9000
|
+
}
|
|
9001
|
+
// Append-only inbox the Atris desktop polls into the member's desk chat.
|
|
9002
|
+
// Contract shared with obelisk src/lib/memberChatInbox.cjs: one JSON object per line.
|
|
9003
|
+
const line = JSON.stringify({ at: new Date().toISOString(), from, text });
|
|
9004
|
+
fs.appendFileSync(path.join(paths.memberDir, 'chat-inbox.jsonl'), `${line}\n`, 'utf8');
|
|
9005
|
+
console.log(`Sent to ${paths.storageName || name}'s desk. It appears in Atris Desktop within about 15 seconds when the app is open.`);
|
|
9006
|
+
return { ok: true };
|
|
9007
|
+
}
|
|
9008
|
+
|
|
8975
9009
|
async function memberCommand(subcommand, ...args) {
|
|
8976
9010
|
// Subcommands that take a member name as args[0] otherwise treat `--help` as
|
|
8977
9011
|
// a name and error with "Member '--help' not found". `create`/`new` handle
|
|
@@ -9003,6 +9037,8 @@ async function memberCommand(subcommand, ...args) {
|
|
|
9003
9037
|
case 'goal-from-score':
|
|
9004
9038
|
case 'score-goal':
|
|
9005
9039
|
return memberGoalFromScore(args[0], ...args.slice(1));
|
|
9040
|
+
case 'chat':
|
|
9041
|
+
return memberChat(args[0], ...args.slice(1));
|
|
9006
9042
|
case 'tick':
|
|
9007
9043
|
return memberTick(args[0], ...args.slice(1));
|
|
9008
9044
|
case 'wake':
|
|
@@ -9046,6 +9082,7 @@ async function memberCommand(subcommand, ...args) {
|
|
|
9046
9082
|
console.log('');
|
|
9047
9083
|
console.log('Subcommands:');
|
|
9048
9084
|
console.log(' create <name> Scaffold a new team member (MEMBER.md + dirs) [--push]');
|
|
9085
|
+
console.log(' chat <name> "..." Send a message to this member\'s desk in Atris Desktop');
|
|
9049
9086
|
console.log(' list Show all team members');
|
|
9050
9087
|
console.log(' activate <name> Symlink member skills, show context and permissions');
|
|
9051
9088
|
console.log(' upgrade <name> Convert flat file (name.md) to directory format');
|
package/commands/task.js
CHANGED
|
@@ -6028,25 +6028,66 @@ function cmdStatus(args) {
|
|
|
6028
6028
|
if (history) console.log(`history feed ${status.swarlo.feed.length} event${status.swarlo.feed.length === 1 ? '' : 's'}`);
|
|
6029
6029
|
}
|
|
6030
6030
|
|
|
6031
|
+
function readProjectionFile(workspaceRoot) {
|
|
6032
|
+
const candidatePaths = [
|
|
6033
|
+
workspaceRoot ? path.join(workspaceRoot, '.atris', 'state', 'tasks.projection.json') : null,
|
|
6034
|
+
path.resolve('.atris', 'state', 'tasks.projection.json'),
|
|
6035
|
+
].filter(Boolean);
|
|
6036
|
+
for (const candidate of candidatePaths) {
|
|
6037
|
+
try {
|
|
6038
|
+
if (fs.existsSync(candidate)) {
|
|
6039
|
+
const content = fs.readFileSync(candidate, 'utf8');
|
|
6040
|
+
const parsed = JSON.parse(content);
|
|
6041
|
+
if (parsed && Array.isArray(parsed.tasks)) return parsed;
|
|
6042
|
+
}
|
|
6043
|
+
} catch {}
|
|
6044
|
+
}
|
|
6045
|
+
return null;
|
|
6046
|
+
}
|
|
6047
|
+
|
|
6031
6048
|
function resolveTaskRef(taskDb, db, ref) {
|
|
6032
6049
|
const token = String(ref || '').trim();
|
|
6033
6050
|
if (!token) return { ok: false, reason: 'missing' };
|
|
6034
|
-
const exact = taskDb.getTask(db, token);
|
|
6051
|
+
const exact = db ? taskDb.getTask(db, token) : null;
|
|
6035
6052
|
if (exact) return { ok: true, id: exact.id, row: exact };
|
|
6036
6053
|
const normalized = taskDb.normalizeTaskRef ? taskDb.normalizeTaskRef(token) : token.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
|
6037
|
-
const
|
|
6038
|
-
|
|
6039
|
-
const
|
|
6040
|
-
|
|
6041
|
-
const
|
|
6042
|
-
const
|
|
6043
|
-
|
|
6044
|
-
|
|
6045
|
-
|
|
6046
|
-
|
|
6047
|
-
|
|
6048
|
-
|
|
6049
|
-
|
|
6054
|
+
const wsRoot = taskDb.workspaceRoot ? taskDb.workspaceRoot() : process.cwd();
|
|
6055
|
+
|
|
6056
|
+
const proj = readProjectionFile(wsRoot);
|
|
6057
|
+
if (proj && Array.isArray(proj.tasks)) {
|
|
6058
|
+
const seen = new Set();
|
|
6059
|
+
const matches = proj.tasks.filter(r => {
|
|
6060
|
+
const id = String(r.id || '').toUpperCase();
|
|
6061
|
+
const display = taskDb.normalizeTaskRef ? taskDb.normalizeTaskRef(r.display_id) : String(r.display_id || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
|
6062
|
+
const legacy = taskDb.normalizeTaskRef ? taskDb.normalizeTaskRef(r.legacy_ref) : String(r.legacy_ref || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
|
6063
|
+
const matched = id.startsWith(normalized) || (display && display === normalized) || (legacy && legacy === normalized);
|
|
6064
|
+
if (!matched || seen.has(r.id)) return false;
|
|
6065
|
+
seen.add(r.id);
|
|
6066
|
+
return true;
|
|
6067
|
+
});
|
|
6068
|
+
if (matches.length === 1) {
|
|
6069
|
+
const match = matches[0];
|
|
6070
|
+
const row = db ? (taskDb.getTask(db, match.id) || match) : match;
|
|
6071
|
+
return { ok: true, id: match.id, row };
|
|
6072
|
+
}
|
|
6073
|
+
if (matches.length > 1) return { ok: false, reason: 'ambiguous', matches };
|
|
6074
|
+
}
|
|
6075
|
+
|
|
6076
|
+
if (db) {
|
|
6077
|
+
const rows = taskDb.withTaskDisplayRefs(taskDb.listTasks(db, { workspaceRoot: wsRoot }));
|
|
6078
|
+
const seen = new Set();
|
|
6079
|
+
const matches = rows.filter(r => {
|
|
6080
|
+
const id = String(r.id || '').toUpperCase();
|
|
6081
|
+
const display = taskDb.normalizeTaskRef ? taskDb.normalizeTaskRef(r.display_id) : String(r.display_id || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
|
6082
|
+
const legacy = taskDb.normalizeTaskRef ? taskDb.normalizeTaskRef(r.legacy_ref) : String(r.legacy_ref || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
|
6083
|
+
const matched = id.startsWith(normalized) || (display && display === normalized) || (legacy && legacy === normalized);
|
|
6084
|
+
if (!matched || seen.has(r.id)) return false;
|
|
6085
|
+
seen.add(r.id);
|
|
6086
|
+
return true;
|
|
6087
|
+
});
|
|
6088
|
+
if (matches.length === 1) return { ok: true, id: matches[0].id, row: matches[0] };
|
|
6089
|
+
if (matches.length > 1) return { ok: false, reason: 'ambiguous', matches };
|
|
6090
|
+
}
|
|
6050
6091
|
return { ok: false, reason: 'not_found' };
|
|
6051
6092
|
}
|
|
6052
6093
|
|
|
@@ -6067,7 +6108,7 @@ function workspaceRefRows(taskDb, db, options = {}) {
|
|
|
6067
6108
|
}
|
|
6068
6109
|
|
|
6069
6110
|
function renderTaskDesk(rows, refRows = rows) {
|
|
6070
|
-
const displayRows = getTaskDb().withTaskDisplayRefs(rows, refRows);
|
|
6111
|
+
const displayRows = (rows && rows.length && rows[0].display_id) ? rows : getTaskDb().withTaskDisplayRefs(rows, refRows);
|
|
6071
6112
|
const active = displayRows.filter(r => r.status !== 'done' && r.status !== 'archived');
|
|
6072
6113
|
const done = displayRows.filter(r => r.status === 'done');
|
|
6073
6114
|
if (rows.length === 0) {
|
|
@@ -6078,7 +6119,7 @@ function renderTaskDesk(rows, refRows = rows) {
|
|
|
6078
6119
|
console.log('TASK DESK');
|
|
6079
6120
|
console.log('');
|
|
6080
6121
|
for (const r of active.slice(0, 12)) {
|
|
6081
|
-
const explanation = taskExplanation(r);
|
|
6122
|
+
const explanation = r.explanation || taskExplanation(r);
|
|
6082
6123
|
const owner = r.claimed_by ? ` @${r.claimed_by}` : '';
|
|
6083
6124
|
const assigned = !r.claimed_by && taskAssignee(r) ? ` -> ${taskAssignee(r)}` : '';
|
|
6084
6125
|
const tag = r.tag ? ` #${r.tag}` : '';
|
|
@@ -6415,7 +6456,17 @@ function cmdHome(args) {
|
|
|
6415
6456
|
workspaceRoot,
|
|
6416
6457
|
limit: all ? null : 200,
|
|
6417
6458
|
});
|
|
6418
|
-
|
|
6459
|
+
let projection;
|
|
6460
|
+
let outPath;
|
|
6461
|
+
const existingProj = readProjectionFile(workspaceRoot);
|
|
6462
|
+
if (rows.length === 0 && existingProj && Array.isArray(existingProj.tasks) && existingProj.tasks.length > 0) {
|
|
6463
|
+
projection = existingProj;
|
|
6464
|
+
outPath = path.resolve(path.join(workspaceRoot || '.', '.atris', 'state', 'tasks.projection.json'));
|
|
6465
|
+
} else {
|
|
6466
|
+
const written = writeDefaultProjection(taskDb, db, { all, everywhere });
|
|
6467
|
+
projection = written.projection;
|
|
6468
|
+
outPath = written.outPath;
|
|
6469
|
+
}
|
|
6419
6470
|
if (wantsJson(args)) {
|
|
6420
6471
|
printJson({
|
|
6421
6472
|
ok: true,
|
|
@@ -6427,7 +6478,7 @@ function cmdHome(args) {
|
|
|
6427
6478
|
});
|
|
6428
6479
|
return;
|
|
6429
6480
|
}
|
|
6430
|
-
renderTaskDesk(
|
|
6481
|
+
renderTaskDesk(projection.tasks);
|
|
6431
6482
|
}
|
|
6432
6483
|
|
|
6433
6484
|
function cmdList(args) {
|
|
@@ -7382,7 +7433,14 @@ function cmdInspect(args) {
|
|
|
7382
7433
|
const db = taskDb.open();
|
|
7383
7434
|
const taskId = requireTaskId(taskDb, db, ref, 'atris task inspect');
|
|
7384
7435
|
const projection = enrichTaskProjection(taskDb.taskProjection(db, { taskId }));
|
|
7385
|
-
|
|
7436
|
+
let task = projection.tasks[0];
|
|
7437
|
+
if (!task) {
|
|
7438
|
+
const wsRoot = scopedWorkspaceRoot(taskDb, args) || process.cwd();
|
|
7439
|
+
const proj = readProjectionFile(wsRoot);
|
|
7440
|
+
if (proj && Array.isArray(proj.tasks)) {
|
|
7441
|
+
task = proj.tasks.find(t => t.id === taskId) || null;
|
|
7442
|
+
}
|
|
7443
|
+
}
|
|
7386
7444
|
if (!task) {
|
|
7387
7445
|
failTask('atris task inspect', 'not_found', `task not found: ${ref}`, 1);
|
|
7388
7446
|
}
|
|
@@ -7411,7 +7469,14 @@ function cmdShow(args) {
|
|
|
7411
7469
|
const db = taskDb.open();
|
|
7412
7470
|
const taskId = requireTaskId(taskDb, db, id, 'atris task show');
|
|
7413
7471
|
const projection = enrichTaskProjection(taskDb.taskProjection(db, { taskId }));
|
|
7414
|
-
|
|
7472
|
+
let task = projection.tasks[0];
|
|
7473
|
+
if (!task) {
|
|
7474
|
+
const wsRoot = scopedWorkspaceRoot(taskDb, args) || process.cwd();
|
|
7475
|
+
const proj = readProjectionFile(wsRoot);
|
|
7476
|
+
if (proj && Array.isArray(proj.tasks)) {
|
|
7477
|
+
task = proj.tasks.find(t => t.id === taskId) || null;
|
|
7478
|
+
}
|
|
7479
|
+
}
|
|
7415
7480
|
if (!task) {
|
|
7416
7481
|
console.error(`task not found: ${id}`);
|
|
7417
7482
|
process.exit(1);
|
|
@@ -7550,7 +7615,14 @@ function cmdReviewChat(args) {
|
|
|
7550
7615
|
|
|
7551
7616
|
function taskDetail(taskDb, db, taskId) {
|
|
7552
7617
|
const detailedProjection = taskDb.taskProjection(db, { taskId });
|
|
7553
|
-
|
|
7618
|
+
let detailedTask = detailedProjection.tasks[0] || null;
|
|
7619
|
+
if (!detailedTask) {
|
|
7620
|
+
const wsRoot = taskDb.workspaceRoot ? taskDb.workspaceRoot() : process.cwd();
|
|
7621
|
+
const proj = readProjectionFile(wsRoot);
|
|
7622
|
+
if (proj && Array.isArray(proj.tasks)) {
|
|
7623
|
+
detailedTask = proj.tasks.find(t => t.id === taskId) || null;
|
|
7624
|
+
}
|
|
7625
|
+
}
|
|
7554
7626
|
if (!detailedTask) return null;
|
|
7555
7627
|
const workspaceRoot = detailedTask.workspace_root || taskDb.workspaceRoot();
|
|
7556
7628
|
const contextProjection = enrichTaskProjection(taskDb.taskProjection(db, {
|
|
@@ -7558,7 +7630,7 @@ function taskDetail(taskDb, db, taskId) {
|
|
|
7558
7630
|
limit: 5000,
|
|
7559
7631
|
}));
|
|
7560
7632
|
const enrichedTask = contextProjection.tasks.find(task => task.id === detailedTask.id) || null;
|
|
7561
|
-
if (!enrichedTask) return enrichTaskProjection(detailedProjection).tasks[0] ||
|
|
7633
|
+
if (!enrichedTask) return enrichTaskProjection(detailedProjection).tasks[0] || detailedTask;
|
|
7562
7634
|
return {
|
|
7563
7635
|
...enrichedTask,
|
|
7564
7636
|
current_version: detailedTask.current_version,
|
package/commands/who.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { execFileSync } = require('node:child_process');
|
|
6
|
+
|
|
7
|
+
const { repoRoot } = require('./stream');
|
|
8
|
+
const {
|
|
9
|
+
buildWorkforcePresence,
|
|
10
|
+
isFinishedReceipt,
|
|
11
|
+
parsePsOutput,
|
|
12
|
+
receiptEngine,
|
|
13
|
+
renderWorkforcePresence,
|
|
14
|
+
} = require('../lib/workforce-presence');
|
|
15
|
+
|
|
16
|
+
function readJson(file, fsModule = fs, fallback = null) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fsModule.readFileSync(file, 'utf8'));
|
|
19
|
+
} catch {
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readJsonLines(file, fsModule = fs) {
|
|
25
|
+
let text;
|
|
26
|
+
try {
|
|
27
|
+
text = fsModule.readFileSync(file, 'utf8');
|
|
28
|
+
} catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
return String(text)
|
|
32
|
+
.split(/\r?\n/)
|
|
33
|
+
.map((line) => line.trim())
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.map((line) => {
|
|
36
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
37
|
+
})
|
|
38
|
+
.filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function collectTasks(root, fsModule = fs) {
|
|
42
|
+
const payload = readJson(path.join(root, '.atris', 'state', 'tasks.projection.json'), fsModule, {});
|
|
43
|
+
return Array.isArray(payload?.tasks) ? payload.tasks : [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function collectMissions(root, fsModule = fs) {
|
|
47
|
+
const rows = readJsonLines(path.join(root, '.atris', 'state', 'missions.jsonl'), fsModule);
|
|
48
|
+
const latestById = new Map();
|
|
49
|
+
for (const row of rows) {
|
|
50
|
+
const id = String(row?.id || '').trim();
|
|
51
|
+
if (id) latestById.set(id, row);
|
|
52
|
+
}
|
|
53
|
+
return [...latestById.values()];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function collectReceipts(root, fsModule = fs) {
|
|
57
|
+
const runsDir = path.join(root, 'atris', 'runs');
|
|
58
|
+
let names;
|
|
59
|
+
try {
|
|
60
|
+
names = fsModule.readdirSync(runsDir).filter((name) => name.endsWith('.json')).sort();
|
|
61
|
+
} catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
const receipts = [];
|
|
65
|
+
for (const name of names) {
|
|
66
|
+
const receiptPath = path.join(runsDir, name);
|
|
67
|
+
const receipt = readJson(receiptPath, fsModule);
|
|
68
|
+
if (!receipt || !receiptEngine(receipt)) continue;
|
|
69
|
+
let mtimeMs = 0;
|
|
70
|
+
try { mtimeMs = fsModule.statSync(receiptPath).mtimeMs; } catch {}
|
|
71
|
+
receipts.push({ name, path: receiptPath, mtimeMs, receipt });
|
|
72
|
+
}
|
|
73
|
+
return receipts;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function collectProcesses(deps = {}) {
|
|
77
|
+
if (Array.isArray(deps.processes)) return deps.processes;
|
|
78
|
+
const execFile = deps.execFile || execFileSync;
|
|
79
|
+
try {
|
|
80
|
+
const output = execFile('ps', ['-eo', 'pid=,ppid=,lstart=,command='], {
|
|
81
|
+
encoding: 'utf8',
|
|
82
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
83
|
+
});
|
|
84
|
+
return parsePsOutput(output);
|
|
85
|
+
} catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function collectWorkforcePresence(deps = {}) {
|
|
91
|
+
const fsModule = deps.fs || fs;
|
|
92
|
+
const root = deps.root || repoRoot(deps.cwd || process.cwd());
|
|
93
|
+
const receipts = Array.isArray(deps.receipts) ? deps.receipts : collectReceipts(root, fsModule);
|
|
94
|
+
return buildWorkforcePresence({
|
|
95
|
+
nowMs: typeof deps.now === 'function' ? deps.now() : Date.now(),
|
|
96
|
+
staleAfterMs: deps.staleAfterMs,
|
|
97
|
+
processes: collectProcesses(deps),
|
|
98
|
+
tasks: Array.isArray(deps.tasks) ? deps.tasks : collectTasks(root, fsModule),
|
|
99
|
+
missions: Array.isArray(deps.missions) ? deps.missions : collectMissions(root, fsModule),
|
|
100
|
+
receipts,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function archiveFinishedRuns(root, receipts, fsModule = fs) {
|
|
105
|
+
const archiveDir = path.join(root, 'atris', 'runs', 'archive');
|
|
106
|
+
const finished = receipts.filter((entry) => isFinishedReceipt(entry.receipt));
|
|
107
|
+
const summary = {
|
|
108
|
+
schema: 'atris.workforce_clear.v1',
|
|
109
|
+
archive_dir: path.relative(root, archiveDir),
|
|
110
|
+
archived: 0,
|
|
111
|
+
failed: [],
|
|
112
|
+
};
|
|
113
|
+
if (!finished.length) return summary;
|
|
114
|
+
fsModule.mkdirSync(archiveDir, { recursive: true });
|
|
115
|
+
for (const entry of finished) {
|
|
116
|
+
const destination = path.join(archiveDir, entry.name);
|
|
117
|
+
if (fsModule.existsSync(destination)) {
|
|
118
|
+
summary.failed.push({ receipt: entry.name, reason: 'archive destination already exists' });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
fsModule.renameSync(entry.path, destination);
|
|
123
|
+
summary.archived += 1;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
summary.failed.push({ receipt: entry.name, reason: error.message || String(error) });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return summary;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function helpText() {
|
|
132
|
+
return [
|
|
133
|
+
'atris who - show local engines and team members from live process and final run state',
|
|
134
|
+
'',
|
|
135
|
+
'usage: atris who [--json]',
|
|
136
|
+
'usage: atris who --clear [--json]',
|
|
137
|
+
'',
|
|
138
|
+
'--clear archives finished run receipts under atris/runs/archive/.',
|
|
139
|
+
].join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function whoCommand(args = [], deps = {}) {
|
|
143
|
+
const write = deps.write || process.stdout.write.bind(process.stdout);
|
|
144
|
+
const error = deps.error || process.stderr.write.bind(process.stderr);
|
|
145
|
+
if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
|
|
146
|
+
write(`${helpText()}\n`);
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
if (args.some((arg) => !['--json', '--clear'].includes(arg))) {
|
|
150
|
+
error('usage: atris who [--json] [--clear]\n');
|
|
151
|
+
return 2;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const fsModule = deps.fs || fs;
|
|
155
|
+
const root = deps.root || repoRoot(deps.cwd || process.cwd());
|
|
156
|
+
const receipts = Array.isArray(deps.receipts) ? deps.receipts : collectReceipts(root, fsModule);
|
|
157
|
+
if (args.includes('--clear')) {
|
|
158
|
+
const summary = archiveFinishedRuns(root, receipts, fsModule);
|
|
159
|
+
if (args.includes('--json')) write(`${JSON.stringify(summary, null, 2)}\n`);
|
|
160
|
+
else if (summary.failed.length) write(`archived ${summary.archived} finished runs; ${summary.failed.length} could not be archived.\n`);
|
|
161
|
+
else if (summary.archived) write(`archived ${summary.archived} finished run${summary.archived === 1 ? '' : 's'} in ${summary.archive_dir}.\n`);
|
|
162
|
+
else write('no finished runs to clear.\n');
|
|
163
|
+
return summary.failed.length ? 1 : 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const presence = collectWorkforcePresence({ ...deps, root, receipts });
|
|
167
|
+
const output = args.includes('--json')
|
|
168
|
+
? JSON.stringify(presence, null, 2)
|
|
169
|
+
: renderWorkforcePresence(presence);
|
|
170
|
+
write(`${output}\n`);
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = {
|
|
175
|
+
whoCommand,
|
|
176
|
+
};
|
package/commands/worktree.js
CHANGED
|
@@ -160,6 +160,8 @@ function listWorktrees(root = repoRoot()) {
|
|
|
160
160
|
// Duplicate flight guard. On 2026-08-07 two agents were dispatched onto the
|
|
161
161
|
// same map rewrite 44 seconds apart: fleet dispatch claims a task first, but a
|
|
162
162
|
// direct `worktree start` had no pre-check beyond the target path existing.
|
|
163
|
+
// Refuse only the same task slug. A shared word on an unrelated flight is not
|
|
164
|
+
// a collision (dispatch-cli-900 must not block dispatch-cli-901).
|
|
163
165
|
const AGENT_FLIGHT_NAME_PATTERN = /^codex\/(.+)-(\d{8}-\d{6})$/;
|
|
164
166
|
const FLIGHT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
165
167
|
const FLIGHT_STOPWORDS = new Set([
|
|
@@ -234,10 +236,10 @@ function inFlightAgentFlights({ root = repoRoot(), now = new Date(), windowMs =
|
|
|
234
236
|
return [...flights.values()].sort((a, b) => b.stampMs - a.stampMs);
|
|
235
237
|
}
|
|
236
238
|
|
|
237
|
-
function collidingFlights(flights,
|
|
238
|
-
const wanted =
|
|
239
|
-
if (!wanted
|
|
240
|
-
return flights.filter((flight) => flight.
|
|
239
|
+
function collidingFlights(flights, taskSlug) {
|
|
240
|
+
const wanted = String(taskSlug || '').trim().toLowerCase();
|
|
241
|
+
if (!wanted) return [];
|
|
242
|
+
return flights.filter((flight) => String(flight.taskSlug || '').toLowerCase() === wanted);
|
|
241
243
|
}
|
|
242
244
|
|
|
243
245
|
function describeFlightAge(flight, nowMs = Date.now()) {
|
|
@@ -511,7 +513,7 @@ function startWorktree(args) {
|
|
|
511
513
|
const active = flights.filter((flight) => flight.kind === 'worktree');
|
|
512
514
|
const repoName = path.basename(findPrimaryRoot(root));
|
|
513
515
|
console.log(`flights: ${active.length} active agent ${active.length === 1 ? 'worktree' : 'worktrees'} for ${repoName}`);
|
|
514
|
-
const collisions = collidingFlights(flights,
|
|
516
|
+
const collisions = collidingFlights(flights, slugify(task, 'task', 36));
|
|
515
517
|
if (collisions.length) {
|
|
516
518
|
const label = force ? 'warning' : 'refusing';
|
|
517
519
|
for (const flight of collisions) {
|
package/lib/engine-registry.js
CHANGED
|
@@ -33,6 +33,7 @@ function engineFailureHealthStatus(result) {
|
|
|
33
33
|
if (/usage[ _-]?limit|purchase more credits|insufficient credits|credit(?:s)?[ _-]?(?:out|limit)|rate[ _-]?limit|not authenticated|please log in|login required|auth(?:entication)?[ _-]?expired|payment required|subscription/.test(signalText)) {
|
|
34
34
|
return 'credit_out';
|
|
35
35
|
}
|
|
36
|
+
if (/not installed|command not found|\benoent\b/.test(signalText)) return 'not_installed';
|
|
36
37
|
if (/timeout|model-unavailable/.test(signalText)) return 'not_installed';
|
|
37
38
|
return 'error';
|
|
38
39
|
}
|
package/lib/fleet.js
CHANGED
|
@@ -208,7 +208,7 @@ function buildEngineCommand(engineName, promptFile, { yolo = false, sealed = fal
|
|
|
208
208
|
if (sealed && engineName === 'codex') {
|
|
209
209
|
cmd = cmd.replace(/\bexec\b/, 'exec --sandbox workspace-write --ephemeral --ignore-user-config --ignore-rules');
|
|
210
210
|
}
|
|
211
|
-
if (sealed && engineName === 'claude') {
|
|
211
|
+
if (sealed && (engineName === 'claude' || engineName === 'fable')) {
|
|
212
212
|
cmd = `${cmd} --safe-mode --no-session-persistence --permission-mode acceptEdits --settings '${JSON.stringify({ sandbox: { enabled: true, autoAllowBashIfSandboxed: true } })}'`;
|
|
213
213
|
}
|
|
214
214
|
if (sealed && engineName === 'cursor') cmd = `${cmd} --sandbox enabled`;
|
|
@@ -218,7 +218,7 @@ function buildEngineCommand(engineName, promptFile, { yolo = false, sealed = fal
|
|
|
218
218
|
}
|
|
219
219
|
if (engineName === 'devin') return cmd.replace(/^devin -p /, 'devin -p --permission-mode dangerous ');
|
|
220
220
|
if (yolo && engineName === 'codex') cmd = cmd.replace(/\bexec\b/, `exec ${YOLO_ENGINE_FLAGS.codex}`);
|
|
221
|
-
if (yolo && engineName === 'claude') cmd = `${cmd} ${YOLO_ENGINE_FLAGS.claude}`;
|
|
221
|
+
if (yolo && (engineName === 'claude' || engineName === 'fable')) cmd = `${cmd} ${YOLO_ENGINE_FLAGS.claude}`;
|
|
222
222
|
if (engineName === 'codex') cmd = wrapCodexWithWatchdog(cmd, watchdogPath, watchdogReceiptPath);
|
|
223
223
|
return cmd;
|
|
224
224
|
} finally {
|
|
@@ -561,6 +561,25 @@ function detectDeadEngineDispatch(result) {
|
|
|
561
561
|
return { reason: 'nonzero_exit', exitCode };
|
|
562
562
|
}
|
|
563
563
|
|
|
564
|
+
function plainDispatchFailureCause(result, failure = {}) {
|
|
565
|
+
const reason = String(failure.reason || failure.stage || '').trim();
|
|
566
|
+
const output = dispatchResultOutput(result);
|
|
567
|
+
if (reason === 'no_output') return 'the engine returned no output';
|
|
568
|
+
if (/not authenticated|please log in|login required|auth(?:entication)?[ _-]?expired/i.test(output)) {
|
|
569
|
+
return 'the engine login expired';
|
|
570
|
+
}
|
|
571
|
+
if (/spawn[^\n]*enoent|enoent[^\n]*spawn|failed to spawn/i.test(output)) {
|
|
572
|
+
return 'the engine could not start';
|
|
573
|
+
}
|
|
574
|
+
if (reason === 'timeout') return 'the engine timed out';
|
|
575
|
+
if (reason === 'cancelled') return 'the engine run was cancelled';
|
|
576
|
+
if (reason === 'unknown') return 'the engine exited without a status';
|
|
577
|
+
if (reason === 'signalled') return `the engine stopped with ${failure.signal || 'a signal'}`;
|
|
578
|
+
const detail = String(failure.detail || output || '').trim().split('\n')[0].trim();
|
|
579
|
+
if (detail) return detail.replace(/[.]+$/, '');
|
|
580
|
+
return (reason || 'the engine failed').replace(/_/g, ' ');
|
|
581
|
+
}
|
|
582
|
+
|
|
564
583
|
function recordDispatchEngineHealth(result, failure, root) {
|
|
565
584
|
if (!result || !result.engine) return null;
|
|
566
585
|
const status = failure
|
|
@@ -1081,6 +1100,7 @@ module.exports = {
|
|
|
1081
1100
|
shipWithRetry,
|
|
1082
1101
|
shipFailureDetail,
|
|
1083
1102
|
get FLEET_CAPABLE() { return FLEET_CAPABLE; },
|
|
1103
|
+
get DISPATCH_CAPABLE() { return DISPATCH_CAPABLE; },
|
|
1084
1104
|
get runFleetFlight() { return runFleetFlight; },
|
|
1085
1105
|
get focusedCheck() { return focusedCheck; },
|
|
1086
1106
|
get dispatchCheck() { return dispatchCheck; },
|
|
@@ -1122,6 +1142,7 @@ module.exports = {
|
|
|
1122
1142
|
// Engines that can edit a repo headlessly. atris-fast (ax) is a chat lane,
|
|
1123
1143
|
// not a repo worker — it keeps owning normal mission ticks, not fleet builds.
|
|
1124
1144
|
const FLEET_CAPABLE = ['claude', 'codex', 'cursor', 'devin', 'grok'];
|
|
1145
|
+
const DISPATCH_CAPABLE = [...FLEET_CAPABLE, 'fable'];
|
|
1125
1146
|
|
|
1126
1147
|
let receiptSequence = 0;
|
|
1127
1148
|
function nowStamp() {
|
|
@@ -2658,6 +2679,18 @@ async function runFleetFlight({
|
|
|
2658
2679
|
// ---------------------------------------------------------------------------
|
|
2659
2680
|
// T5 — one-command dispatch: `atris engine dispatch <task-id> --engine <name>`
|
|
2660
2681
|
|
|
2682
|
+
// A claim taken before the engine starts must not stay held if the flight
|
|
2683
|
+
// refuses or errors first. Release through the same task plane the claim used.
|
|
2684
|
+
function releaseUnstartedDispatchClaim(cli, { taskId, actor, detail }) {
|
|
2685
|
+
const released = cli(['task', 'release', taskId, '--as', actor]);
|
|
2686
|
+
const releasedOk = Boolean(released && released.status === 0);
|
|
2687
|
+
const suffix = releasedOk
|
|
2688
|
+
? 'claim released, safe to retry'
|
|
2689
|
+
: `claim release failed: ${String(released && (released.stderr || released.stdout) || 'unknown').trim().slice(0, 80)}`;
|
|
2690
|
+
const base = String(detail || '').trim().slice(0, 200);
|
|
2691
|
+
return { released: releasedOk, detail: base ? `${base}. ${suffix}` : suffix };
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2661
2694
|
// The manual version of this loop took 6 Bash calls per task the night this
|
|
2662
2695
|
// was written: claim, worktree start, prompt file, engine -p, verify, ship.
|
|
2663
2696
|
// One or more explicit task ids build in parallel isolated worktrees on ONE
|
|
@@ -2693,8 +2726,8 @@ async function runDispatchFlight({
|
|
|
2693
2726
|
scoutAsk = null,
|
|
2694
2727
|
} = {}) {
|
|
2695
2728
|
if (!engine) throw new Error('runDispatchFlight: engine is required');
|
|
2696
|
-
if (!
|
|
2697
|
-
throw new Error(`runDispatchFlight: engine "${engine}" cannot build headlessly (capable: ${
|
|
2729
|
+
if (!DISPATCH_CAPABLE.includes(engine)) {
|
|
2730
|
+
throw new Error(`runDispatchFlight: engine "${engine}" cannot build headlessly (capable: ${DISPATCH_CAPABLE.join(', ')})`);
|
|
2698
2731
|
}
|
|
2699
2732
|
const ids = [...new Set((taskIds || []).map((id) => String(id).trim()).filter(Boolean))];
|
|
2700
2733
|
if (!ids.length) throw new Error('runDispatchFlight: at least one task id is required');
|
|
@@ -2760,51 +2793,71 @@ async function runDispatchFlight({
|
|
|
2760
2793
|
log(` x ${taskId} claim failed`);
|
|
2761
2794
|
continue;
|
|
2762
2795
|
}
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2796
|
+
try {
|
|
2797
|
+
let landingWorktreePath = '';
|
|
2798
|
+
let remoteBoundary = null;
|
|
2799
|
+
if (enforceRemoteBoundary) {
|
|
2800
|
+
remoteBoundary = prepareReviewSandbox({ root, taskId, engine });
|
|
2801
|
+
} else {
|
|
2802
|
+
const started = cli(['worktree', 'start', '--agent', engine, '--task', `dispatch-${taskId.toLowerCase()}`, ...startBaseArgs]);
|
|
2803
|
+
const wt = (started.stdout.match(/next: cd (.+)/) || [])[1];
|
|
2804
|
+
if (!wt) {
|
|
2805
|
+
const released = releaseUnstartedDispatchClaim(cli, {
|
|
2806
|
+
taskId,
|
|
2807
|
+
actor: taskActor,
|
|
2808
|
+
detail: String(started.stderr || '').slice(0, 200),
|
|
2809
|
+
});
|
|
2810
|
+
flight.paused.push({ task: taskId, stage: 'worktree_start', detail: released.detail });
|
|
2811
|
+
log(` ✗ ${taskId} worktree start failed`);
|
|
2812
|
+
continue;
|
|
2813
|
+
}
|
|
2814
|
+
landingWorktreePath = wt.trim();
|
|
2815
|
+
}
|
|
2816
|
+
if (enforceRemoteBoundary && (!remoteBoundary || remoteBoundary.ok !== true)) {
|
|
2817
|
+
const released = releaseUnstartedDispatchClaim(cli, {
|
|
2818
|
+
taskId,
|
|
2819
|
+
actor: taskActor,
|
|
2820
|
+
detail: String(remoteBoundary && remoteBoundary.detail || 'could not prepare a sealed review sandbox').slice(-500),
|
|
2821
|
+
});
|
|
2822
|
+
flight.paused.push({
|
|
2823
|
+
task: taskId,
|
|
2824
|
+
stage: 'remote_quarantine',
|
|
2825
|
+
detail: released.detail,
|
|
2826
|
+
worktree: null,
|
|
2827
|
+
});
|
|
2828
|
+
log(` paused ${taskId} because its sealed review sandbox could not be prepared`);
|
|
2773
2829
|
continue;
|
|
2774
2830
|
}
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2831
|
+
const worktreePath = enforceRemoteBoundary ? remoteBoundary.worktreePath : landingWorktreePath;
|
|
2832
|
+
const startCommit = yolo ? readStartCommit({ worktreePath }) : '';
|
|
2833
|
+
const basePrompt = promptOverride || buildFleetPrompt(task, { worktreePath, yolo });
|
|
2834
|
+
const safetyPrompt = reviewOnly
|
|
2835
|
+
? `${basePrompt}\n\nSafety boundary: edit and test this checkout only. Do not push, merge, deploy, publish, send messages, change cloud state, install dependencies, or use credentials.`
|
|
2836
|
+
: basePrompt;
|
|
2837
|
+
const trustedPrompt = trustedVerifier
|
|
2838
|
+
? `${safetyPrompt}\n\nTrusted verifier (run it bare before reporting done): ${trustedVerifier}`
|
|
2839
|
+
: promptOverride;
|
|
2840
|
+
prepared.push({
|
|
2841
|
+
task,
|
|
2842
|
+
taskId,
|
|
2843
|
+
worktreePath,
|
|
2844
|
+
landingWorktreePath,
|
|
2845
|
+
engine,
|
|
2846
|
+
remoteBoundary,
|
|
2847
|
+
remoteMasterBefore: remoteBoundary ? remoteBoundary.protectedMaster : '',
|
|
2848
|
+
startCommit,
|
|
2849
|
+
...(trustedPrompt ? { prompt: trustedPrompt } : {}),
|
|
2783
2850
|
});
|
|
2784
|
-
log(`
|
|
2785
|
-
|
|
2851
|
+
log(` building ${taskId} in ${path.basename(worktreePath)}`);
|
|
2852
|
+
} catch (err) {
|
|
2853
|
+
const released = releaseUnstartedDispatchClaim(cli, {
|
|
2854
|
+
taskId,
|
|
2855
|
+
actor: taskActor,
|
|
2856
|
+
detail: String(err && err.message || err).slice(0, 200),
|
|
2857
|
+
});
|
|
2858
|
+
flight.paused.push({ task: taskId, stage: 'prepare', detail: released.detail });
|
|
2859
|
+
log(` ✗ ${taskId} prepare failed`);
|
|
2786
2860
|
}
|
|
2787
|
-
const worktreePath = enforceRemoteBoundary ? remoteBoundary.worktreePath : landingWorktreePath;
|
|
2788
|
-
const startCommit = yolo ? readStartCommit({ worktreePath }) : '';
|
|
2789
|
-
const basePrompt = promptOverride || buildFleetPrompt(task, { worktreePath, yolo });
|
|
2790
|
-
const safetyPrompt = reviewOnly
|
|
2791
|
-
? `${basePrompt}\n\nSafety boundary: edit and test this checkout only. Do not push, merge, deploy, publish, send messages, change cloud state, install dependencies, or use credentials.`
|
|
2792
|
-
: basePrompt;
|
|
2793
|
-
const trustedPrompt = trustedVerifier
|
|
2794
|
-
? `${safetyPrompt}\n\nTrusted verifier (run it bare before reporting done): ${trustedVerifier}`
|
|
2795
|
-
: promptOverride;
|
|
2796
|
-
prepared.push({
|
|
2797
|
-
task,
|
|
2798
|
-
taskId,
|
|
2799
|
-
worktreePath,
|
|
2800
|
-
landingWorktreePath,
|
|
2801
|
-
engine,
|
|
2802
|
-
remoteBoundary,
|
|
2803
|
-
remoteMasterBefore: remoteBoundary ? remoteBoundary.protectedMaster : '',
|
|
2804
|
-
startCommit,
|
|
2805
|
-
...(trustedPrompt ? { prompt: trustedPrompt } : {}),
|
|
2806
|
-
});
|
|
2807
|
-
log(` building ${taskId} in ${path.basename(worktreePath)}`);
|
|
2808
2861
|
}
|
|
2809
2862
|
|
|
2810
2863
|
const dispatch = dispatcher || ((entry) => new Promise((resolve) => {
|
|
@@ -2828,6 +2881,10 @@ async function runDispatchFlight({
|
|
|
2828
2881
|
}));
|
|
2829
2882
|
const restaffState = { used: false };
|
|
2830
2883
|
|
|
2884
|
+
if (engine === 'fable' && prepared.length) {
|
|
2885
|
+
log(` fable handoff started: receipt ${path.relative(root, receiptPath)}`);
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2831
2888
|
const results = await Promise.all(prepared.map((entry) => {
|
|
2832
2889
|
const startedAtMs = Date.now();
|
|
2833
2890
|
return dispatchEntryWithRestaff({
|
|
@@ -3343,6 +3400,12 @@ async function runDispatchFlight({
|
|
|
3343
3400
|
flight.finished_at = new Date().toISOString();
|
|
3344
3401
|
writeDispatchReceipt(flight, receiptPath, { ids, reviewOnly, enforceRemoteBoundary });
|
|
3345
3402
|
log('');
|
|
3403
|
+
if (engine === 'fable' && flight.paused.length) {
|
|
3404
|
+
const paused = flight.paused[0];
|
|
3405
|
+
const failed = results.find(({ entry }) => entry.taskId === paused.task);
|
|
3406
|
+
const cause = plainDispatchFailureCause(failed && failed.result, paused);
|
|
3407
|
+
log(` fable handoff failed: ${cause}. receipt: ${path.relative(root, receiptPath)}`);
|
|
3408
|
+
}
|
|
3346
3409
|
const completedLabel = reviewOnly ? `${flight.ready.length} proof ready` : `${flight.landed.length} landed`;
|
|
3347
3410
|
log(` dispatch over: ${completedLabel}, ${flight.paused.length} paused - receipt: ${path.relative(root, flight.receipt)}`);
|
|
3348
3411
|
log('');
|
package/lib/known-commands.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const knownCommands = ['init', 'log', 'logs', 'wish', 'ask', 'approve', 'stop', 'ready', 'check', 'drill', 'dream', 'now', 'goal', 'wtf', 'founder', 'orb', 'radar', 'stream', 'ctop', 'launchpad', 'status', 'analytics', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', '_start', 'plan', 'do', 'review', 'release',
|
|
3
|
+
const knownCommands = ['init', 'log', 'logs', 'wish', 'ask', 'approve', 'stop', 'ready', 'check', 'drill', 'dream', 'now', 'goal', 'wtf', 'founder', 'orb', 'radar', 'who', 'stream', 'ctop', 'launchpad', 'status', 'analytics', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', '_start', 'plan', 'do', 'review', 'release',
|
|
4
4
|
'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
|
|
5
5
|
'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube',
|
|
6
6
|
'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'sync-checkout',
|
package/lib/task-db.js
CHANGED
|
@@ -369,8 +369,8 @@ function withTaskDisplayRefs(rows, refRows = rows) {
|
|
|
369
369
|
const ids = sorted.map(row => row && row.id);
|
|
370
370
|
sorted.forEach((row, index) => {
|
|
371
371
|
refs.set(row.id, {
|
|
372
|
-
display_id: taskDisplayRef(row, index),
|
|
373
|
-
legacy_ref: shortestUniqueTaskRef(row.id, ids, 8),
|
|
372
|
+
display_id: row.display_id || taskDisplayRef(row, index),
|
|
373
|
+
legacy_ref: row.legacy_ref || shortestUniqueTaskRef(row.id, ids, 8),
|
|
374
374
|
});
|
|
375
375
|
});
|
|
376
376
|
}
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
|
|
4
|
+
const PROCESS_START_TOLERANCE_MS = 30 * 60 * 1000;
|
|
5
|
+
const ACTIVE_TASK_STATUSES = new Set(['claimed', 'do', 'doing', 'in_progress', 'review']);
|
|
6
|
+
const ACTIVE_MISSION_STATUSES = new Set(['planning', 'active', 'running', 'ready']);
|
|
7
|
+
const RUNNING_RECEIPT_STATUSES = new Set(['active', 'in_progress', 'running', 'started', 'working']);
|
|
8
|
+
const TERMINAL_RECEIPT_STATUSES = new Set([
|
|
9
|
+
'cancelled',
|
|
10
|
+
'completed',
|
|
11
|
+
'done',
|
|
12
|
+
'failed',
|
|
13
|
+
'landed',
|
|
14
|
+
'no_output',
|
|
15
|
+
'passed',
|
|
16
|
+
'presumed_dead',
|
|
17
|
+
'succeeded',
|
|
18
|
+
'timed_out',
|
|
19
|
+
]);
|
|
20
|
+
const WORK_TOKEN_STOP_WORDS = new Set([
|
|
21
|
+
'agent', 'build', 'building', 'engine', 'local', 'mission', 'process', 'running', 'task', 'working',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
function timestampMs(value) {
|
|
25
|
+
if (value == null || value === '') return 0;
|
|
26
|
+
if (typeof value === 'number') {
|
|
27
|
+
if (!Number.isFinite(value)) return 0;
|
|
28
|
+
return value > 1000000000000 ? value : value * 1000;
|
|
29
|
+
}
|
|
30
|
+
const numeric = Number(value);
|
|
31
|
+
if (Number.isFinite(numeric) && String(value).trim()) return timestampMs(numeric);
|
|
32
|
+
const parsed = Date.parse(String(value));
|
|
33
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isoTimestamp(value) {
|
|
37
|
+
const ms = timestampMs(value);
|
|
38
|
+
return ms ? new Date(ms).toISOString() : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function ageSeconds(value, nowMs) {
|
|
42
|
+
const ms = timestampMs(value);
|
|
43
|
+
return ms ? Math.max(0, Math.floor((nowMs - ms) / 1000)) : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeEngine(value) {
|
|
47
|
+
const engine = String(value || '').trim().toLowerCase();
|
|
48
|
+
if (!engine) return '';
|
|
49
|
+
if (engine === 'cursor-agent' || engine === 'cursor agent') return 'cursor';
|
|
50
|
+
if (engine === 'claude-code') return 'claude';
|
|
51
|
+
return engine.replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function engineForCommand(command) {
|
|
55
|
+
const text = String(command || '');
|
|
56
|
+
if (/ChatGPT\.app|Codex Framework\.framework|Claude\.app/.test(text)) return '';
|
|
57
|
+
const executable = text.trim().split(/\s+/)[0] || '';
|
|
58
|
+
const name = executable.split('/').pop().toLowerCase();
|
|
59
|
+
if (name === 'cursor-agent') return 'cursor';
|
|
60
|
+
if (/^codex(?:-|$)/.test(name)) return 'codex';
|
|
61
|
+
if (name === 'grok') return 'grok';
|
|
62
|
+
if (name === 'devin') return 'devin';
|
|
63
|
+
if (name === 'droid') return 'droid';
|
|
64
|
+
if (name === 'agy') return 'agy';
|
|
65
|
+
if (name === 'claude' && /(^|\s)(?:-p|--print)(?:\s|$)/.test(text)) return 'claude';
|
|
66
|
+
return '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parsePsOutput(text) {
|
|
70
|
+
const allRows = [];
|
|
71
|
+
for (const line of String(text || '').split(/\r?\n/)) {
|
|
72
|
+
const parts = line.trim().split(/\s+/);
|
|
73
|
+
if (parts.length < 8) continue;
|
|
74
|
+
const pid = Number(parts[0]);
|
|
75
|
+
const ppid = Number(parts[1]);
|
|
76
|
+
const command = parts.slice(7).join(' ');
|
|
77
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
78
|
+
const started = Date.parse(parts.slice(2, 7).join(' '));
|
|
79
|
+
allRows.push({
|
|
80
|
+
pid,
|
|
81
|
+
ppid: Number.isInteger(ppid) && ppid > 0 ? ppid : null,
|
|
82
|
+
command,
|
|
83
|
+
started_at: Number.isFinite(started) ? new Date(started).toISOString() : null,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const byPid = new Map(allRows.map((row) => [row.pid, row]));
|
|
87
|
+
const engineRows = allRows
|
|
88
|
+
.map((row) => ({ ...row, engine: engineForCommand(row.command) }))
|
|
89
|
+
.filter((row) => row.engine);
|
|
90
|
+
const parentPids = new Set(engineRows.map((row) => row.ppid).filter(Boolean));
|
|
91
|
+
return engineRows
|
|
92
|
+
.filter((row) => !parentPids.has(row.pid))
|
|
93
|
+
.map((row) => {
|
|
94
|
+
const ancestorPids = [];
|
|
95
|
+
let parent = row.ppid;
|
|
96
|
+
while (parent && !ancestorPids.includes(parent) && ancestorPids.length < 64) {
|
|
97
|
+
ancestorPids.push(parent);
|
|
98
|
+
parent = byPid.get(parent)?.ppid || null;
|
|
99
|
+
}
|
|
100
|
+
return { ...row, ancestor_pids: ancestorPids };
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function normalizeProcesses(processes) {
|
|
105
|
+
const byPid = new Map();
|
|
106
|
+
for (const row of Array.isArray(processes) ? processes : []) {
|
|
107
|
+
const pid = Number(row?.pid);
|
|
108
|
+
const engine = normalizeEngine(row?.engine) || engineForCommand(row?.command);
|
|
109
|
+
if (!Number.isInteger(pid) || pid <= 0 || !engine) continue;
|
|
110
|
+
byPid.set(pid, {
|
|
111
|
+
pid,
|
|
112
|
+
ppid: Number(row?.ppid) || null,
|
|
113
|
+
engine,
|
|
114
|
+
command: String(row?.command || ''),
|
|
115
|
+
started_at: isoTimestamp(row?.started_at || row?.start || row?.at),
|
|
116
|
+
ancestor_pids: (Array.isArray(row?.ancestor_pids) ? row.ancestor_pids : [])
|
|
117
|
+
.map(Number)
|
|
118
|
+
.filter((value) => Number.isInteger(value) && value > 0),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return [...byPid.values()].sort((left, right) => left.pid - right.pid);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function taskRef(task) {
|
|
125
|
+
return String(task?.display_id || task?.legacy_ref || task?.id || '').trim();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function taskOwner(task) {
|
|
129
|
+
return String(task?.claimed_by || task?.assigned_to || task?.metadata?.assigned_to || '').trim();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function taskActivity(task) {
|
|
133
|
+
return task?.updated_at || task?.claimed_at || task?.created_at || null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function receiptTaskRefs(receipt) {
|
|
137
|
+
const values = [receipt?.task_id, receipt?.task];
|
|
138
|
+
if (Array.isArray(receipt?.tasks)) values.push(...receipt.tasks);
|
|
139
|
+
if (Array.isArray(receipt?.task_ids)) values.push(...receipt.task_ids);
|
|
140
|
+
if (Array.isArray(receipt?.results)) values.push(...receipt.results.map((row) => row?.task || row?.task_id));
|
|
141
|
+
return [...new Set(values.map((value) => {
|
|
142
|
+
if (value && typeof value === 'object') return value.display_id || value.id || value.task;
|
|
143
|
+
return value;
|
|
144
|
+
}).map((value) => String(value || '').trim()).filter(Boolean))];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function receiptEngine(receipt) {
|
|
148
|
+
return normalizeEngine(
|
|
149
|
+
receipt?.engine
|
|
150
|
+
|| receipt?.engines?.[0]
|
|
151
|
+
|| receipt?.results?.find((row) => row?.engine)?.engine,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function receiptStatus(receipt) {
|
|
156
|
+
return String(receipt?.status || '').trim().toLowerCase();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isRunningReceipt(receipt) {
|
|
160
|
+
return !receipt?.finished_at && RUNNING_RECEIPT_STATUSES.has(receiptStatus(receipt));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isFinishedReceipt(receipt) {
|
|
164
|
+
if (!receipt || isRunningReceipt(receipt)) return false;
|
|
165
|
+
return Boolean(receipt.finished_at || TERMINAL_RECEIPT_STATUSES.has(receiptStatus(receipt)));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function receiptStartedAt(receipt, fallback) {
|
|
169
|
+
return receipt?.started_at || receipt?.at || receipt?.created_at || fallback || null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function receiptFinishedAt(receipt, fallback) {
|
|
173
|
+
return receipt?.finished_at || receipt?.completed_at || receipt?.updated_at || fallback || null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function finalResult(receipt) {
|
|
177
|
+
if (typeof receipt?.result === 'string') return receipt.result.trim();
|
|
178
|
+
if (receipt?.result && typeof receipt.result === 'object') {
|
|
179
|
+
const kind = String(receipt.result.kind || '').trim();
|
|
180
|
+
if (typeof receipt.result.passed === 'boolean') return `${kind || 'result'} ${receipt.result.passed ? 'passed' : 'failed'}`;
|
|
181
|
+
if (kind) return kind;
|
|
182
|
+
}
|
|
183
|
+
if (receipt?.summary && typeof receipt.summary === 'object') {
|
|
184
|
+
const answered = Number(receipt.summary.answered) || 0;
|
|
185
|
+
const failed = Number(receipt.summary.failed) || 0;
|
|
186
|
+
if (answered || failed) return `${answered} answered, ${failed} failed`;
|
|
187
|
+
}
|
|
188
|
+
return receiptStatus(receipt) || 'finished';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function taskLookup(tasks) {
|
|
192
|
+
const byRef = new Map();
|
|
193
|
+
for (const task of tasks) {
|
|
194
|
+
for (const ref of [task?.id, task?.display_id, task?.legacy_ref]) {
|
|
195
|
+
const key = String(ref || '').trim().toLowerCase();
|
|
196
|
+
if (key) byRef.set(key, task);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return byRef;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function firstTaskForRefs(refs, byRef) {
|
|
203
|
+
for (const ref of refs) {
|
|
204
|
+
const task = byRef.get(String(ref).toLowerCase());
|
|
205
|
+
if (task) return task;
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function rowTask(refs, task) {
|
|
211
|
+
return taskRef(task) || refs[0] || null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function baseRow({ member, task, title, engine, source, at, nowMs }) {
|
|
215
|
+
return {
|
|
216
|
+
member: member || null,
|
|
217
|
+
task: task || null,
|
|
218
|
+
title: title || null,
|
|
219
|
+
engine: engine || null,
|
|
220
|
+
source,
|
|
221
|
+
at: isoTimestamp(at),
|
|
222
|
+
age_seconds: ageSeconds(at, nowMs),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function buildWorkforcePresence(input = {}) {
|
|
227
|
+
const nowMs = timestampMs(input.nowMs ?? input.now ?? Date.now()) || Date.now();
|
|
228
|
+
const staleAfterMs = Number(input.staleAfterMs) > 0 ? Number(input.staleAfterMs) : DEFAULT_STALE_AFTER_MS;
|
|
229
|
+
const tasks = (Array.isArray(input.tasks) ? input.tasks : [])
|
|
230
|
+
.filter((task) => ACTIVE_TASK_STATUSES.has(String(task?.status || '').toLowerCase()) && taskOwner(task));
|
|
231
|
+
const missions = (Array.isArray(input.missions) ? input.missions : [])
|
|
232
|
+
.filter((mission) => ACTIVE_MISSION_STATUSES.has(String(mission?.status || '').toLowerCase()));
|
|
233
|
+
const receipts = Array.isArray(input.receipts) ? input.receipts : [];
|
|
234
|
+
const processes = normalizeProcesses(input.processes);
|
|
235
|
+
const byTaskRef = taskLookup(tasks);
|
|
236
|
+
const usedPids = new Set();
|
|
237
|
+
const representedTasks = new Set();
|
|
238
|
+
const working = [];
|
|
239
|
+
const waiting = [];
|
|
240
|
+
const done = [];
|
|
241
|
+
const stale = [];
|
|
242
|
+
|
|
243
|
+
const claimProcess = (engine, pid, expectedStart) => {
|
|
244
|
+
const wantedPid = Number(pid);
|
|
245
|
+
if (Number.isInteger(wantedPid) && wantedPid > 0) {
|
|
246
|
+
const exact = processes.find((row) => (
|
|
247
|
+
row.pid === wantedPid || row.ancestor_pids.includes(wantedPid)
|
|
248
|
+
) && row.engine === engine && !usedPids.has(row.pid) && (
|
|
249
|
+
!timestampMs(expectedStart)
|
|
250
|
+
|| !timestampMs(row.started_at)
|
|
251
|
+
|| Math.abs(timestampMs(row.started_at) - timestampMs(expectedStart)) <= PROCESS_START_TOLERANCE_MS
|
|
252
|
+
));
|
|
253
|
+
if (exact) usedPids.add(exact.pid);
|
|
254
|
+
return exact || null;
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
};
|
|
258
|
+
const workTokens = (value) => new Set(
|
|
259
|
+
(String(value || '').toLowerCase().match(/[a-z][a-z0-9]{4,}/g) || [])
|
|
260
|
+
.filter((token) => !WORK_TOKEN_STOP_WORDS.has(token)),
|
|
261
|
+
);
|
|
262
|
+
const processMatchesWork = (row, refs, title) => {
|
|
263
|
+
const command = String(row.command || '').toLowerCase();
|
|
264
|
+
if (refs.some((ref) => command.includes(String(ref).toLowerCase()))) return true;
|
|
265
|
+
const titleTokens = workTokens(title);
|
|
266
|
+
const commandTokens = workTokens(command);
|
|
267
|
+
return [...titleTokens].some((token) => commandTokens.has(token));
|
|
268
|
+
};
|
|
269
|
+
const claimMatchingProcess = (engine, refs, title) => {
|
|
270
|
+
const candidate = processes.find((row) => (
|
|
271
|
+
(!engine || row.engine === engine)
|
|
272
|
+
&& !usedPids.has(row.pid)
|
|
273
|
+
&& processMatchesWork(row, refs, title)
|
|
274
|
+
));
|
|
275
|
+
if (candidate) usedPids.add(candidate.pid);
|
|
276
|
+
return candidate || null;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
for (const entry of receipts) {
|
|
280
|
+
const receipt = entry?.receipt || entry;
|
|
281
|
+
const engine = receiptEngine(receipt);
|
|
282
|
+
if (!engine) continue;
|
|
283
|
+
const refs = receiptTaskRefs(receipt);
|
|
284
|
+
const task = firstTaskForRefs(refs, byTaskRef);
|
|
285
|
+
const member = String(receipt?.member || receipt?.owner || receipt?.actor || taskOwner(task) || '').trim();
|
|
286
|
+
const taskValue = rowTask(refs, task);
|
|
287
|
+
const startedAt = receiptStartedAt(receipt, entry?.mtimeMs);
|
|
288
|
+
const common = baseRow({
|
|
289
|
+
member,
|
|
290
|
+
task: taskValue,
|
|
291
|
+
title: task?.title || receipt?.objective || '',
|
|
292
|
+
engine,
|
|
293
|
+
source: 'receipt',
|
|
294
|
+
at: startedAt,
|
|
295
|
+
nowMs,
|
|
296
|
+
});
|
|
297
|
+
if (isRunningReceipt(receipt)) {
|
|
298
|
+
const processRow = claimProcess(engine, receipt.pid, startedAt)
|
|
299
|
+
|| claimMatchingProcess(engine, refs, task?.title || receipt?.objective || '');
|
|
300
|
+
const row = {
|
|
301
|
+
...common,
|
|
302
|
+
pid: processRow?.pid || Number(receipt.pid) || null,
|
|
303
|
+
receipt: entry?.name || receipt?.receipt || null,
|
|
304
|
+
};
|
|
305
|
+
if (processRow) working.push(row);
|
|
306
|
+
else if (Number(receipt.pid) > 0 || nowMs - timestampMs(startedAt) > staleAfterMs) {
|
|
307
|
+
stale.push({ ...row, reason: 'run has no live process' });
|
|
308
|
+
} else {
|
|
309
|
+
waiting.push({ ...row, reason: 'run has not started a local process' });
|
|
310
|
+
}
|
|
311
|
+
if (taskValue) representedTasks.add(String(taskValue).toLowerCase());
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (isFinishedReceipt(receipt)) {
|
|
315
|
+
done.push({
|
|
316
|
+
...common,
|
|
317
|
+
at: isoTimestamp(receiptFinishedAt(receipt, entry?.mtimeMs)),
|
|
318
|
+
age_seconds: ageSeconds(receiptFinishedAt(receipt, entry?.mtimeMs), nowMs),
|
|
319
|
+
run_status: receiptStatus(receipt) || 'finished',
|
|
320
|
+
result: finalResult(receipt),
|
|
321
|
+
receipt: entry?.name || receipt?.receipt || null,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
for (const mission of missions) {
|
|
327
|
+
const refs = Array.isArray(mission?.task_ids) ? mission.task_ids.map(String) : [];
|
|
328
|
+
if (refs.some((ref) => representedTasks.has(ref.toLowerCase()))) continue;
|
|
329
|
+
const task = firstTaskForRefs(refs, byTaskRef);
|
|
330
|
+
const engine = normalizeEngine(mission?.runner || mission?.engine || mission?.executed_by);
|
|
331
|
+
const member = String(mission?.owner || mission?.member || taskOwner(task) || '').trim();
|
|
332
|
+
const at = mission?.last_tick_at || mission?.updated_at || mission?.created_at;
|
|
333
|
+
const common = baseRow({
|
|
334
|
+
member,
|
|
335
|
+
task: rowTask(refs, task),
|
|
336
|
+
title: task?.title || mission?.objective || mission?.name || '',
|
|
337
|
+
engine,
|
|
338
|
+
source: 'mission',
|
|
339
|
+
at,
|
|
340
|
+
nowMs,
|
|
341
|
+
});
|
|
342
|
+
const processRow = engine
|
|
343
|
+
? claimProcess(engine, mission?.pid, at) || claimMatchingProcess(engine, refs, common.title)
|
|
344
|
+
: null;
|
|
345
|
+
if (processRow) working.push({ ...common, pid: processRow.pid, mission: mission?.id || null });
|
|
346
|
+
else if (nowMs - timestampMs(at) > staleAfterMs) stale.push({ ...common, reason: 'mission has no live process', mission: mission?.id || null });
|
|
347
|
+
else waiting.push({ ...common, reason: 'mission is waiting for a local process', mission: mission?.id || null });
|
|
348
|
+
for (const ref of refs) representedTasks.add(ref.toLowerCase());
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
for (const task of tasks) {
|
|
352
|
+
const ref = taskRef(task);
|
|
353
|
+
if (representedTasks.has(ref.toLowerCase())) continue;
|
|
354
|
+
const engine = normalizeEngine(task?.executed_by || task?.metadata?.executed_by || task?.metadata?.engine);
|
|
355
|
+
const at = taskActivity(task);
|
|
356
|
+
const common = baseRow({
|
|
357
|
+
member: taskOwner(task),
|
|
358
|
+
task: ref,
|
|
359
|
+
title: task?.title || '',
|
|
360
|
+
engine,
|
|
361
|
+
source: 'task',
|
|
362
|
+
at,
|
|
363
|
+
nowMs,
|
|
364
|
+
});
|
|
365
|
+
const processRow = claimProcess(engine, task?.pid || task?.metadata?.pid, at)
|
|
366
|
+
|| claimMatchingProcess(engine, [ref], '');
|
|
367
|
+
if (processRow) working.push({ ...common, engine: processRow.engine, pid: processRow.pid });
|
|
368
|
+
else if (nowMs - timestampMs(at) > staleAfterMs) stale.push({ ...common, reason: 'claim is older than seven days with no live process' });
|
|
369
|
+
else waiting.push({ ...common, reason: 'claim has no live process yet' });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const unowned = processes
|
|
373
|
+
.filter((row) => !usedPids.has(row.pid))
|
|
374
|
+
.map((row) => ({
|
|
375
|
+
engine: row.engine,
|
|
376
|
+
pid: row.pid,
|
|
377
|
+
command: row.command,
|
|
378
|
+
started_at: row.started_at,
|
|
379
|
+
age_seconds: ageSeconds(row.started_at, nowMs),
|
|
380
|
+
reason: 'no matching claim, mission, or run receipt',
|
|
381
|
+
}));
|
|
382
|
+
|
|
383
|
+
const newestFirst = (left, right) => timestampMs(right.at || right.started_at) - timestampMs(left.at || left.started_at);
|
|
384
|
+
working.sort(newestFirst);
|
|
385
|
+
waiting.sort(newestFirst);
|
|
386
|
+
done.sort(newestFirst);
|
|
387
|
+
stale.sort(newestFirst);
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
schema: 'atris.workforce_presence.v1',
|
|
391
|
+
generated_at: new Date(nowMs).toISOString(),
|
|
392
|
+
stale_after_seconds: Math.round(staleAfterMs / 1000),
|
|
393
|
+
totals: {
|
|
394
|
+
working: working.length,
|
|
395
|
+
waiting: waiting.length,
|
|
396
|
+
done: done.length,
|
|
397
|
+
stale: stale.length,
|
|
398
|
+
unowned: unowned.length,
|
|
399
|
+
},
|
|
400
|
+
working,
|
|
401
|
+
waiting,
|
|
402
|
+
done,
|
|
403
|
+
stale,
|
|
404
|
+
unowned,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function formatAge(seconds) {
|
|
409
|
+
if (seconds == null) return 'age unknown';
|
|
410
|
+
if (seconds < 60) return `${seconds}s`;
|
|
411
|
+
const minutes = Math.floor(seconds / 60);
|
|
412
|
+
if (minutes < 60) return `${minutes}m`;
|
|
413
|
+
const hours = Math.floor(minutes / 60);
|
|
414
|
+
if (hours < 48) return `${hours}h`;
|
|
415
|
+
return `${Math.floor(hours / 24)}d`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function rowSubject(row) {
|
|
419
|
+
const member = row.member || 'unassigned';
|
|
420
|
+
const task = row.task || row.title || 'local work';
|
|
421
|
+
return `${member}: ${row.engine || 'unknown engine'} on ${task}`;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function renderWorkforcePresence(presence) {
|
|
425
|
+
const lines = [];
|
|
426
|
+
const section = (name, rows, render, limit = rows.length) => {
|
|
427
|
+
lines.push(`${name}:`);
|
|
428
|
+
if (!rows.length) lines.push(' none');
|
|
429
|
+
else rows.slice(0, limit).forEach((row) => lines.push(` ${render(row)}`));
|
|
430
|
+
if (rows.length > limit) lines.push(` ${rows.length - limit} more; clear finished runs with atris who --clear`);
|
|
431
|
+
};
|
|
432
|
+
section('working', presence.working, (row) => `${rowSubject(row)} (${formatAge(row.age_seconds)}, pid ${row.pid || '?'})`);
|
|
433
|
+
section('waiting', presence.waiting, (row) => `${rowSubject(row)} (${formatAge(row.age_seconds)}), ${row.reason}`);
|
|
434
|
+
section('done', presence.done, (row) => `${rowSubject(row)} (${formatAge(row.age_seconds)}), ${row.run_status}: ${row.result}`, 10);
|
|
435
|
+
section('stale', presence.stale, (row) => `${rowSubject(row)} (${formatAge(row.age_seconds)}), ${row.reason}`);
|
|
436
|
+
section('unowned', presence.unowned, (row) => `${row.engine} pid ${row.pid} (${formatAge(row.age_seconds)}), ${row.reason}`);
|
|
437
|
+
const totals = presence.totals;
|
|
438
|
+
lines.push(`totals: ${totals.working} working, ${totals.waiting} waiting, ${totals.done} done, ${totals.stale} stale, ${totals.unowned} unowned`);
|
|
439
|
+
return lines.join('\n');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
module.exports = {
|
|
443
|
+
buildWorkforcePresence,
|
|
444
|
+
isFinishedReceipt,
|
|
445
|
+
parsePsOutput,
|
|
446
|
+
receiptEngine,
|
|
447
|
+
renderWorkforcePresence,
|
|
448
|
+
};
|