atris 3.70.0 → 3.71.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/README.md +18 -0
- package/atris/skills/README.md +1 -0
- package/atris/skills/campaigns/SKILL.md +81 -0
- package/atris/skills/meta/SKILL.md +52 -8
- package/ax +226 -42
- package/bin/atris.js +38 -8
- package/commands/autoland.js +176 -7
- package/commands/autopilot.js +71 -11
- package/commands/campaign.js +779 -0
- package/commands/compile.js +19 -8
- package/commands/doctor.js +58 -0
- package/commands/dream.js +18 -3
- package/commands/engine.js +147 -5
- package/commands/improve.js +119 -9
- package/commands/init.js +18 -1
- package/commands/integrations.js +17 -5
- package/commands/interview.js +15 -3
- package/commands/land.js +22 -6
- package/commands/link.js +1 -0
- package/commands/member.js +359 -94
- package/commands/mission.js +249 -36
- package/commands/report.js +42 -8
- package/commands/run.js +9 -2
- package/commands/scout.js +9 -1
- package/commands/task.js +71 -17
- package/commands/team.js +212 -4
- package/commands/unknowns.js +15 -1
- package/commands/verify.js +46 -0
- package/commands/worktree.js +108 -15
- package/commands/write.js +12 -1
- package/commands/youtube.js +17 -2
- package/lib/accept-verify-gate.js +1 -0
- package/lib/account-bound.js +13 -0
- package/lib/auto-accept-certified.js +1 -0
- package/lib/autoland.js +13 -4
- package/lib/campaign-cadence.js +131 -0
- package/lib/campaign-core.js +330 -0
- package/lib/campaign-holdout.js +65 -0
- package/lib/campaign-loop.js +246 -0
- package/lib/campaign-missions.js +237 -0
- package/lib/campaign-new.js +251 -0
- package/lib/campaign-protect.js +75 -0
- package/lib/campaign-reading.js +147 -0
- package/lib/campaign-run-lock.js +59 -0
- package/lib/campaign-schema.js +196 -0
- package/lib/campaign-store.js +380 -0
- package/lib/campaign-sync.js +302 -0
- package/lib/campaign-trace.js +300 -0
- package/lib/conductor-artifacts.js +15 -1
- package/lib/daily-log.js +11 -1
- package/lib/dispatch-scout.js +18 -0
- package/lib/engine-ask.js +33 -3
- package/lib/engine-registry.js +2 -0
- package/lib/engine-resource.js +518 -0
- package/lib/fleet.js +125 -16
- package/lib/init-commit.js +80 -0
- package/lib/intents.js +8 -0
- package/lib/known-commands.js +1 -1
- package/lib/land-green.js +58 -7
- package/lib/landing-proof.js +352 -0
- package/lib/member-autoland.js +395 -18
- package/lib/member-board.js +7 -1
- package/lib/member-map-fold.js +126 -0
- package/lib/member-map-inbox.js +80 -0
- package/lib/member-progress.js +48 -1
- package/lib/member-report.js +127 -8
- package/lib/member-self-next-job.js +97 -0
- package/lib/member-task-pick.js +41 -14
- package/lib/member-up.js +249 -16
- package/lib/roster-prep.js +25 -2
- package/lib/runner-command.js +5 -0
- package/lib/task-db.js +47 -11
- package/lib/task-explanation.js +7 -0
- package/lib/task-list-keeper.js +8 -1
- package/lib/task-receipt.js +6 -1
- package/mcp/atris-mcp/index.mjs +212 -10
- package/package.json +2 -1
- package/scripts/det/engine-reading.js +37 -0
- package/scripts/det/ytnotes +29 -5
package/lib/member-up.js
CHANGED
|
@@ -4,27 +4,150 @@
|
|
|
4
4
|
// working. Each check it looks at every runnable member, and when one has no
|
|
5
5
|
// live run and a task with a runnable check fits it, starts
|
|
6
6
|
// `atris member go <name> --needs-check` so the member claims and runs that
|
|
7
|
-
// task. A member with no such task
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// task. A member with no such task writes its own next job from its standing
|
|
8
|
+
// mission when that mission names a check (lib/member-self-next-job.js), and
|
|
9
|
+
// starts on it; otherwise it stays idle. The clock, the spawner, the member
|
|
10
|
+
// list and the task picker are passed in, so tests drive it with fake time.
|
|
10
11
|
|
|
11
12
|
const { taskCheck } = require('./member-task-pick');
|
|
12
13
|
const { followBoard } = require('./member-board');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { spawnSync } = require('child_process');
|
|
13
17
|
|
|
14
18
|
const CHECK_MS = 60 * 1000;
|
|
15
19
|
const IDLE_CHECK_MS = 5 * 60 * 1000;
|
|
20
|
+
const IDLE_MAX_CHECK_MS = 30 * 60 * 1000;
|
|
16
21
|
const SUMMARY_MS = 10 * 60 * 1000;
|
|
22
|
+
// A run with no new step for this long is called out as stuck, once per stall.
|
|
23
|
+
const STALL_MS = 20 * 60 * 1000;
|
|
24
|
+
// How often member changes GitHub dropped from its merge line are put back.
|
|
25
|
+
// Each look can call GitHub, so it runs less often than the member check.
|
|
26
|
+
const REQUEUE_MS = 5 * 60 * 1000;
|
|
27
|
+
// Put-back outcomes worth a line: one went back in, or one was left for you.
|
|
28
|
+
const REQUEUE_SAID = new Set(['requeued', 'clash', 'gave-up', 'changed', 'moved', 'stalled', 'refreshed', 'refresh-failed']);
|
|
29
|
+
const MAINTENANCE_LOCK_MS = 2 * 60 * 1000;
|
|
30
|
+
const MERGE_QUIET_MS = 2 * 60 * 60 * 1000;
|
|
17
31
|
const IDLE_AFTER_EMPTY = 3;
|
|
18
32
|
const DEFAULT_MAX = 6;
|
|
19
33
|
// Exit code `member go --needs-check` uses when it found no task with a check
|
|
20
34
|
// to claim (another run took it first). Not a failure, just idle.
|
|
21
35
|
const IDLE_EXIT = 75;
|
|
36
|
+
// A member name that gets each job by itself, for the add-one hint.
|
|
37
|
+
const JOB_MEMBER = { build: 'builder', review: 'reviewer', search: 'researcher' };
|
|
38
|
+
|
|
39
|
+
function maintenanceGit(cwd, args) {
|
|
40
|
+
const out = spawnSync('git', args, {
|
|
41
|
+
cwd, encoding: 'utf8', timeout: 30000,
|
|
42
|
+
env: { ...process.env, GIT_NO_REPLACE_OBJECTS: '1', GIT_TERMINAL_PROMPT: '0' },
|
|
43
|
+
});
|
|
44
|
+
return { ok: out.status === 0, text: String(out.stdout || '').trim() };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Never move a developer's branch, a linked work folder, or any edited copy.
|
|
48
|
+
// Recheck after the fetch, which can take long enough for edits to arrive.
|
|
49
|
+
function refreshMemberCode(root) {
|
|
50
|
+
const safe = () => {
|
|
51
|
+
try { if (!fs.lstatSync(path.join(root, '.git')).isDirectory()) return false; } catch { return false; }
|
|
52
|
+
const detached = maintenanceGit(root, ['symbolic-ref', '-q', 'HEAD']);
|
|
53
|
+
const clean = maintenanceGit(root, ['status', '--porcelain', '--untracked-files=all']);
|
|
54
|
+
return !detached.ok && clean.ok && !clean.text;
|
|
55
|
+
};
|
|
56
|
+
if (!safe()) return null;
|
|
57
|
+
const before = maintenanceGit(root, ['rev-parse', '--verify', 'HEAD']);
|
|
58
|
+
const fetched = maintenanceGit(root, ['fetch', '-q', 'origin', '+refs/heads/master:refs/remotes/origin/master']);
|
|
59
|
+
if (!fetched.ok) return { state: 'refresh-failed', line: 'could not refresh the members\' code copy; will try again' };
|
|
60
|
+
const tip = maintenanceGit(root, ['rev-parse', '--verify', 'refs/remotes/origin/master^{commit}']);
|
|
61
|
+
const current = maintenanceGit(root, ['rev-parse', '--verify', 'HEAD']);
|
|
62
|
+
if (!before.ok || !tip.ok || !current.ok || current.text !== before.text || !safe()) return null;
|
|
63
|
+
if (tip.text === before.text) return null;
|
|
64
|
+
// A detached local commit is still someone's work. Only move forward.
|
|
65
|
+
if (!maintenanceGit(root, ['merge-base', '--is-ancestor', before.text, tip.text]).ok) return null;
|
|
66
|
+
const moved = maintenanceGit(root, ['checkout', '--detach', tip.text]);
|
|
67
|
+
return moved.ok
|
|
68
|
+
? { state: 'refreshed', line: 'the members\' code copy is current' }
|
|
69
|
+
: { state: 'refresh-failed', line: 'could not refresh the members\' code copy; will try again' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Shared by all windows of a project, including linked work folders. The
|
|
73
|
+
// completed-pass timestamp survives release, so a second window waits too.
|
|
74
|
+
function memberMaintenance({ cwd, codeRoot, now = Date.now(), requeueDropped, waitingTasks = () => [] }) {
|
|
75
|
+
const common = maintenanceGit(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
76
|
+
if (!common.ok) return { results: [] };
|
|
77
|
+
const dir = path.join(path.dirname(common.text), '.atris', 'state');
|
|
78
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
79
|
+
const lock = path.join(dir, 'member-up-maintenance.lock');
|
|
80
|
+
const file = path.join(dir, 'member-up-maintenance.json');
|
|
81
|
+
let fd;
|
|
82
|
+
try {
|
|
83
|
+
fd = fs.openSync(lock, 'wx');
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error.code !== 'EEXIST') throw error;
|
|
86
|
+
// A live owner always holds, even past the lease: GitHub may be slow.
|
|
87
|
+
// Unknown/malformed owners get a short grace period before recovery.
|
|
88
|
+
try {
|
|
89
|
+
const stat = fs.statSync(lock);
|
|
90
|
+
let owner;
|
|
91
|
+
try { owner = JSON.parse(fs.readFileSync(lock, 'utf8')); } catch {}
|
|
92
|
+
if (Number.isInteger(owner?.pid) && owner.pid > 0) {
|
|
93
|
+
try { process.kill(owner.pid, 0); return { results: [] }; }
|
|
94
|
+
catch (e) { if (e.code !== 'ESRCH') return { results: [] }; }
|
|
95
|
+
} else if (now - stat.mtimeMs < MAINTENANCE_LOCK_MS) return { results: [] };
|
|
96
|
+
if (fs.statSync(lock).ino !== stat.ino) return { results: [] };
|
|
97
|
+
fs.unlinkSync(lock);
|
|
98
|
+
fd = fs.openSync(lock, 'wx');
|
|
99
|
+
} catch { return { results: [] }; }
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, at: now }));
|
|
103
|
+
let state = {};
|
|
104
|
+
try { state = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch {}
|
|
105
|
+
if (Number.isFinite(state.checked_at) && now - state.checked_at < REQUEUE_MS) return { results: [] };
|
|
106
|
+
const save = () => {
|
|
107
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
108
|
+
fs.writeFileSync(tmp, JSON.stringify(state));
|
|
109
|
+
fs.renameSync(tmp, file);
|
|
110
|
+
};
|
|
111
|
+
state.checked_at = now;
|
|
112
|
+
save();
|
|
113
|
+
const out = requeueDropped();
|
|
114
|
+
const results = [...(out?.results || [])];
|
|
115
|
+
const merged = results.some((r) => r.state === 'merged');
|
|
116
|
+
const waiting = results.some((r) => !['merged', 'closed'].includes(r.state)) || waitingTasks().length > 0;
|
|
117
|
+
if (merged || !waiting) {
|
|
118
|
+
state.waiting_since = null;
|
|
119
|
+
state.quiet_said = false;
|
|
120
|
+
}
|
|
121
|
+
if (waiting && state.waiting_since == null) state.waiting_since = now;
|
|
122
|
+
if (waiting && !state.quiet_said && now - state.waiting_since >= MERGE_QUIET_MS) {
|
|
123
|
+
results.push({ state: 'stalled', line: 'jobs are waiting, but nothing has merged for two hours' });
|
|
124
|
+
state.quiet_said = true;
|
|
125
|
+
}
|
|
126
|
+
const refresh = codeRoot ? refreshMemberCode(codeRoot) : null;
|
|
127
|
+
if (refresh && (refresh.state !== 'refresh-failed' || !state.refresh_failed)) results.push(refresh);
|
|
128
|
+
state.refresh_failed = refresh?.state === 'refresh-failed';
|
|
129
|
+
save();
|
|
130
|
+
return { ...out, results };
|
|
131
|
+
} finally {
|
|
132
|
+
fs.closeSync(fd);
|
|
133
|
+
fs.unlinkSync(lock);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
22
136
|
|
|
23
137
|
function dayOf(ms) {
|
|
24
138
|
const d = new Date(ms);
|
|
25
139
|
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
|
|
26
140
|
}
|
|
27
141
|
|
|
142
|
+
// How long an idle member waits before its next check. The first few empty
|
|
143
|
+
// checks wait one round; after that the wait doubles from 5 minutes each
|
|
144
|
+
// empty check, up to 30 minutes. Finding work resets it.
|
|
145
|
+
function idleWaitMs(empty) {
|
|
146
|
+
if (!(empty >= IDLE_AFTER_EMPTY)) return 0;
|
|
147
|
+
const doublings = Math.min(empty - IDLE_AFTER_EMPTY, 10);
|
|
148
|
+
return Math.min(IDLE_CHECK_MS * 2 ** doublings, IDLE_MAX_CHECK_MS);
|
|
149
|
+
}
|
|
150
|
+
|
|
28
151
|
function oneLine(text) {
|
|
29
152
|
return String(text || '').replace(/\s+/g, ' ').trim();
|
|
30
153
|
}
|
|
@@ -38,9 +161,17 @@ function hasRunnableCheck(task) {
|
|
|
38
161
|
// deps:
|
|
39
162
|
// listMembers() -> [{ name, parked, human, sleeping, live }]
|
|
40
163
|
// pickTask(name, takenIds) -> { id, title } | null (looks only, never claims)
|
|
41
|
-
// spawn(name, onExit) -> any
|
|
164
|
+
// spawn(name, onExit, onClaim) -> any (claim confirms { id, title })
|
|
42
165
|
// now() -> ms
|
|
43
166
|
// print(line)
|
|
167
|
+
// jobOf(name) -> 'build' | 'review' | 'search' (optional)
|
|
168
|
+
// writeNextJob(name, { dryRun }) -> { task } | { skip } (optional; writes
|
|
169
|
+
// the member's next job from its mission when its list
|
|
170
|
+
// is empty; a dry run only says what it would write)
|
|
171
|
+
// waitingTasks() -> [{ id, title, job }] (optional; open tasks
|
|
172
|
+
// with a runnable check, any job)
|
|
173
|
+
// requeueDropped() -> { results: [{ state, line }] } (optional; puts member
|
|
174
|
+
// changes GitHub dropped from its merge line back in)
|
|
44
175
|
function createMemberUp(deps, options = {}) {
|
|
45
176
|
const max = Math.max(1, Number(options.max) || DEFAULT_MAX);
|
|
46
177
|
const dryRun = options.dryRun === true;
|
|
@@ -51,11 +182,14 @@ function createMemberUp(deps, options = {}) {
|
|
|
51
182
|
? new Set(options.only.map((name) => String(name).trim()).filter(Boolean))
|
|
52
183
|
: null;
|
|
53
184
|
const skippedUnknown = new Set();
|
|
185
|
+
const hintedJobs = new Set();
|
|
54
186
|
const state = new Map();
|
|
55
187
|
const running = new Map();
|
|
56
188
|
let doneToday = 0;
|
|
57
189
|
let doneDay = dayOf(deps.now());
|
|
58
190
|
let lastSummaryAt = deps.now();
|
|
191
|
+
let lastRequeueAt = -Infinity;
|
|
192
|
+
let requeueFailSaid = false;
|
|
59
193
|
// Members working right now: the runs this supervisor started plus any
|
|
60
194
|
// live run started some other way. Both count toward --max.
|
|
61
195
|
let liveElsewhere = new Set();
|
|
@@ -66,6 +200,20 @@ function createMemberUp(deps, options = {}) {
|
|
|
66
200
|
return state.get(name);
|
|
67
201
|
};
|
|
68
202
|
|
|
203
|
+
function claimed(name, run, task) {
|
|
204
|
+
if (running.get(name) !== run || run.announced || !task?.id || !task?.title) return;
|
|
205
|
+
run.announced = true;
|
|
206
|
+
run.taskId = task.id;
|
|
207
|
+
const s = seat(name);
|
|
208
|
+
const title = oneLine(task.title);
|
|
209
|
+
run.title = title;
|
|
210
|
+
if (s.idle) print(`${name} woke up for: ${title}`);
|
|
211
|
+
s.idle = false;
|
|
212
|
+
s.empty = 0;
|
|
213
|
+
s.nextCheckAt = 0;
|
|
214
|
+
print(`${name} started on: ${title}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
69
217
|
function finish(name, code, result = {}) {
|
|
70
218
|
if (!running.has(name)) return;
|
|
71
219
|
const run = running.get(name);
|
|
@@ -95,6 +243,29 @@ function createMemberUp(deps, options = {}) {
|
|
|
95
243
|
|
|
96
244
|
const idleLine = (name) => `${name} is idle, no task with a check`;
|
|
97
245
|
|
|
246
|
+
// Jobs waiting for a kind of work no awake member does, said once per kind:
|
|
247
|
+
// name a member who could be woken for it, or say to add one.
|
|
248
|
+
function otherKindHints(team, awake, taken) {
|
|
249
|
+
if (!deps.jobOf || !deps.waitingTasks) return;
|
|
250
|
+
const awakeJobs = new Set(awake.map((m) => deps.jobOf(m.name)));
|
|
251
|
+
const waiting = new Map();
|
|
252
|
+
for (const task of deps.waitingTasks() || []) {
|
|
253
|
+
if (!task || !task.job || (task.id && taken.has(task.id))) continue;
|
|
254
|
+
if (awakeJobs.has(task.job) || hintedJobs.has(task.job)) continue;
|
|
255
|
+
waiting.set(task.job, (waiting.get(task.job) || 0) + 1);
|
|
256
|
+
}
|
|
257
|
+
const awakeNames = new Set(awake.map((m) => m.name));
|
|
258
|
+
for (const [job, count] of waiting) {
|
|
259
|
+
hintedJobs.add(job);
|
|
260
|
+
const jobs = count === 1 ? `1 ${job} job is waiting` : `${count} ${job} jobs are waiting`;
|
|
261
|
+
const sleeper = team.find((m) => m && m.name && !m.human && !m.parked
|
|
262
|
+
&& !awakeNames.has(m.name) && deps.jobOf(m.name) === job);
|
|
263
|
+
print(sleeper
|
|
264
|
+
? `${jobs}, but nobody who does ${job} is awake. wake one: atris member wake ${sleeper.name} --keep`
|
|
265
|
+
: `${jobs}, but no member on the team does ${job}. add one: atris member create ${JOB_MEMBER[job] || job}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
98
269
|
function progress(event) {
|
|
99
270
|
const run = running.get(event.member);
|
|
100
271
|
if (!run || Date.parse(event.at) < run.startedAt) return;
|
|
@@ -104,6 +275,9 @@ function createMemberUp(deps, options = {}) {
|
|
|
104
275
|
}
|
|
105
276
|
if (event.kind !== 'step') return;
|
|
106
277
|
if (event.text.startsWith('editing ')) run.changed = event.text;
|
|
278
|
+
run.lastStepAt = Math.max(run.lastStepAt, Date.parse(event.at) || 0);
|
|
279
|
+
run.lastStep = oneLine(event.text);
|
|
280
|
+
run.stallSaid = false;
|
|
107
281
|
const time = new Date(event.at);
|
|
108
282
|
const hhmm = `${String(time.getHours()).padStart(2, '0')}:${String(time.getMinutes()).padStart(2, '0')}`;
|
|
109
283
|
print(` ${event.member} ${hhmm} ${oneLine(event.text)}`);
|
|
@@ -114,9 +288,60 @@ function createMemberUp(deps, options = {}) {
|
|
|
114
288
|
return `working ${working()}, idle ${idle}, done today ${doneToday}`;
|
|
115
289
|
}
|
|
116
290
|
|
|
291
|
+
// Say once when a run has shown no new step for STALL_MS, so a hung member
|
|
292
|
+
// does not hold a seat in silence. A new step lets a later stall be said again.
|
|
293
|
+
function callOutStalls(now) {
|
|
294
|
+
for (const [name, run] of running) {
|
|
295
|
+
const quietMs = now - run.lastStepAt;
|
|
296
|
+
if (run.stallSaid || quietMs < STALL_MS) continue;
|
|
297
|
+
run.stallSaid = true;
|
|
298
|
+
print(`${name} looks stuck: no progress for ${Math.floor(quietMs / 60000)} minutes on ${run.title}. `
|
|
299
|
+
+ `last step: ${run.lastStep || 'none yet'}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Every REQUEUE_MS, put back member changes GitHub dropped from its merge
|
|
304
|
+
// line, so nobody has to run atris land --requeue. A dry run never pushes.
|
|
305
|
+
// A failure is said once until a later look works.
|
|
306
|
+
function putBackDropped(now) {
|
|
307
|
+
if (dryRun || !deps.requeueDropped || now - lastRequeueAt < REQUEUE_MS) return;
|
|
308
|
+
lastRequeueAt = now;
|
|
309
|
+
let out;
|
|
310
|
+
try {
|
|
311
|
+
out = deps.requeueDropped();
|
|
312
|
+
requeueFailSaid = false;
|
|
313
|
+
} catch (error) {
|
|
314
|
+
if (!requeueFailSaid) print(`could not check github's merge line: ${oneLine(error && error.message || error)}`);
|
|
315
|
+
requeueFailSaid = true;
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
for (const result of (out && out.results) || []) {
|
|
319
|
+
if (result && REQUEUE_SAID.has(result.state)) print(oneLine(result.line));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// An empty list: the member writes its next job from its mission. The new
|
|
324
|
+
// task is the one it starts on; a skip or an error leaves it idle.
|
|
325
|
+
function ownNextJob(name, s) {
|
|
326
|
+
if (!deps.writeNextJob) return null;
|
|
327
|
+
let out;
|
|
328
|
+
try {
|
|
329
|
+
out = deps.writeNextJob(name, { dryRun });
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (!s.idle) print(`${name} could not write its own next job: ${oneLine(error && error.message || error)}`);
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
const task = out && out.task;
|
|
335
|
+
if (!task || !hasRunnableCheck(task)) return null;
|
|
336
|
+
print(`${name} had no job, so it ${dryRun ? 'would write' : 'wrote'} its own from its mission: ${oneLine(task.title)}`);
|
|
337
|
+
return task;
|
|
338
|
+
}
|
|
339
|
+
|
|
117
340
|
// One pass over the team. Returns the members it started (or would start).
|
|
118
341
|
function check() {
|
|
119
342
|
const now = deps.now();
|
|
343
|
+
callOutStalls(now);
|
|
344
|
+
putBackDropped(now);
|
|
120
345
|
const started = [];
|
|
121
346
|
const taken = new Set(Array.from(running.values()).map((run) => run.taskId).filter(Boolean));
|
|
122
347
|
const team = deps.listMembers();
|
|
@@ -138,12 +363,14 @@ function createMemberUp(deps, options = {}) {
|
|
|
138
363
|
const s = seat(name);
|
|
139
364
|
if (now < s.nextCheckAt) continue;
|
|
140
365
|
const picked = deps.pickTask(name, taken);
|
|
141
|
-
|
|
366
|
+
let task = picked && hasRunnableCheck(picked) ? picked : null;
|
|
367
|
+
if (!task) task = ownNextJob(name, s);
|
|
142
368
|
if (!task) {
|
|
143
369
|
s.empty += 1;
|
|
144
370
|
if (!s.idle && !dryRun) print(idleLine(name));
|
|
145
371
|
s.idle = true;
|
|
146
|
-
|
|
372
|
+
const wait = idleWaitMs(s.empty);
|
|
373
|
+
if (wait) s.nextCheckAt = now + wait;
|
|
147
374
|
continue;
|
|
148
375
|
}
|
|
149
376
|
const title = oneLine(task.title);
|
|
@@ -153,24 +380,23 @@ function createMemberUp(deps, options = {}) {
|
|
|
153
380
|
started.push({ name, task });
|
|
154
381
|
continue;
|
|
155
382
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
s.empty = 0;
|
|
159
|
-
s.nextCheckAt = 0;
|
|
160
|
-
running.set(name, { taskId: task.id || null, startedAt: now });
|
|
383
|
+
const run = { taskId: task.id || null, title, startedAt: now, lastStepAt: now, announced: false };
|
|
384
|
+
running.set(name, run);
|
|
161
385
|
try {
|
|
162
386
|
deps.spawn(name, (code, result) => {
|
|
163
387
|
deps.flushProgress?.();
|
|
164
|
-
finish(name, code, result);
|
|
165
|
-
});
|
|
388
|
+
if (running.get(name) === run) finish(name, code, result);
|
|
389
|
+
}, (claimedTask) => claimed(name, run, claimedTask));
|
|
166
390
|
} catch (error) {
|
|
167
391
|
running.delete(name);
|
|
168
392
|
print(`${name} failed to start: ${oneLine(error && error.message || error)}`);
|
|
169
393
|
continue;
|
|
170
394
|
}
|
|
171
|
-
print(`${name} started on: ${title}`);
|
|
172
395
|
started.push({ name, task });
|
|
173
396
|
}
|
|
397
|
+
try {
|
|
398
|
+
otherKindHints(team, members.filter((m) => m && m.name && !m.parked && !m.human && !m.sleeping), taken);
|
|
399
|
+
} catch {}
|
|
174
400
|
if (!dryRun && now - lastSummaryAt >= SUMMARY_MS) {
|
|
175
401
|
lastSummaryAt = now;
|
|
176
402
|
print(summary());
|
|
@@ -209,7 +435,7 @@ function memberUpResult(stdout, stderr = '', signal = '') {
|
|
|
209
435
|
: oneLine(stderr) || 'run ended without a checked task result' };
|
|
210
436
|
}
|
|
211
437
|
|
|
212
|
-
// Reads `member up` flags: --max N, --only a,b and --dry-run.
|
|
438
|
+
// Reads `member up` flags: --max N, --only a,b and --dry-run (or --plan).
|
|
213
439
|
// Returns { max, only, dryRun } or { error }.
|
|
214
440
|
function parseMemberUpArgs(args = []) {
|
|
215
441
|
const list = args.map((arg) => String(arg == null ? '' : arg));
|
|
@@ -219,7 +445,9 @@ function parseMemberUpArgs(args = []) {
|
|
|
219
445
|
const inline = list.find((arg) => arg.startsWith(`${flag}=`));
|
|
220
446
|
return inline == null ? null : inline.slice(flag.length + 1);
|
|
221
447
|
};
|
|
222
|
-
|
|
448
|
+
// --plan only plans, same as on a single wake: `member wake a --keep --plan`
|
|
449
|
+
// must never start a paid run.
|
|
450
|
+
const out = { max: undefined, only: null, dryRun: list.includes('--dry-run') || list.includes('--plan') };
|
|
223
451
|
const maxText = valueOf('--max');
|
|
224
452
|
if (maxText != null) {
|
|
225
453
|
const max = Number(maxText);
|
|
@@ -278,9 +506,14 @@ module.exports = {
|
|
|
278
506
|
DEFAULT_MAX,
|
|
279
507
|
IDLE_CHECK_MS,
|
|
280
508
|
IDLE_EXIT,
|
|
509
|
+
IDLE_MAX_CHECK_MS,
|
|
510
|
+
REQUEUE_MS,
|
|
511
|
+
STALL_MS,
|
|
281
512
|
SUMMARY_MS,
|
|
282
513
|
createMemberUp,
|
|
514
|
+
idleWaitMs,
|
|
283
515
|
memberUpResult,
|
|
516
|
+
memberMaintenance,
|
|
284
517
|
parseMemberUpArgs,
|
|
285
518
|
runMemberUp,
|
|
286
519
|
};
|
package/lib/roster-prep.js
CHANGED
|
@@ -19,6 +19,7 @@ const {
|
|
|
19
19
|
const { spawnSync } = require('child_process');
|
|
20
20
|
const { buildReadOnlyEngineInvocation, buildAskSpawnEnv, runAskProcess } = require('./engine-ask');
|
|
21
21
|
const { appendRosterRun, parseRunUsage } = require('./roster-runs');
|
|
22
|
+
const { startEngineRun } = require('./engine-resource');
|
|
22
23
|
|
|
23
24
|
// A brief is at most 300 lines and 20 KB: enough for a dozen file:line
|
|
24
25
|
// pointers with short excerpts, small enough that a heavy model reads it in
|
|
@@ -154,7 +155,7 @@ function defaultPrepAskSync({ engine, model, effort, prompt, cwd, timeoutMs }) {
|
|
|
154
155
|
else if (spawnError) reason = 'spawn_error';
|
|
155
156
|
else if (run.status !== 0) reason = `exit_${run.status == null ? 'unknown' : run.status}`;
|
|
156
157
|
else if (!hasOutput) reason = 'no_output';
|
|
157
|
-
return { ok: reason === 'ok', reason, timed_out: timedOut, stdout, stderr: spawnError ? `${stderr}${spawnError.message}` : stderr };
|
|
158
|
+
return { ok: reason === 'ok', reason, timed_out: timedOut, signal: run.signal || null, stdout, stderr: spawnError ? `${stderr}${spawnError.message}` : stderr };
|
|
158
159
|
}
|
|
159
160
|
|
|
160
161
|
function skipped(job, reason, extra = {}) {
|
|
@@ -211,7 +212,9 @@ function finishPrepPass(plan, answer, { startedMs, endedMs, task, root = process
|
|
|
211
212
|
reason = exit ? `${engine} exited ${exit[1]}`
|
|
212
213
|
: answer.reason === 'no_output' ? 'returned nothing'
|
|
213
214
|
: answer.reason === 'spawn_error' ? `${engine} could not start`
|
|
214
|
-
: `${engine}
|
|
215
|
+
: answer.signal ? `${engine} stopped by ${answer.signal}`
|
|
216
|
+
: `${engine} failed`;
|
|
217
|
+
if (answer.cause) reason = `${reason}; ${answer.cause}`;
|
|
215
218
|
} else if (!brief.text) {
|
|
216
219
|
outcome = 'failed';
|
|
217
220
|
reason = 'returned nothing';
|
|
@@ -239,6 +242,22 @@ function askFailure(error) {
|
|
|
239
242
|
return { ok: false, reason: 'runner_error', stderr: String(error && error.message || error), stdout: '' };
|
|
240
243
|
}
|
|
241
244
|
|
|
245
|
+
// Machine memory and load at both ends of the prep launch. The ask answers
|
|
246
|
+
// instead of throwing, so a failure is passed in the thrown shape, and a
|
|
247
|
+
// SIGKILL hands its cause back on the answer.
|
|
248
|
+
function startPrepReadings(plan, root) {
|
|
249
|
+
const endResource = startEngineRun(root || process.cwd(), { engine: plan.engine, task: `prep for ${plan.forLabel}` });
|
|
250
|
+
return (answer) => {
|
|
251
|
+
const end = endResource(answer.ok ? null : {
|
|
252
|
+
code: answer.timed_out ? 'ETIMEDOUT' : '',
|
|
253
|
+
signal: answer.signal,
|
|
254
|
+
stderr: answer.stderr,
|
|
255
|
+
message: answer.reason,
|
|
256
|
+
});
|
|
257
|
+
return end && end.cause && !answer.timed_out ? { ...answer, cause: end.cause } : answer;
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
242
261
|
// One prep pass for one task. Never throws: every way it can go wrong comes
|
|
243
262
|
// back as { ok: false, reason } so the heavy worker still runs.
|
|
244
263
|
async function runPrepPass({
|
|
@@ -250,12 +269,14 @@ async function runPrepPass({
|
|
|
250
269
|
const plan = planPrepPass(options);
|
|
251
270
|
if (plan.skip) return plan.skip;
|
|
252
271
|
const startedMs = clock();
|
|
272
|
+
const endReadings = startPrepReadings(plan, options.root);
|
|
253
273
|
let answer;
|
|
254
274
|
try {
|
|
255
275
|
answer = await ask(plan.request);
|
|
256
276
|
} catch (error) {
|
|
257
277
|
answer = askFailure(error);
|
|
258
278
|
}
|
|
279
|
+
answer = endReadings(answer || { ok: false, reason: 'no_answer', stdout: '' });
|
|
259
280
|
return finishPrepPass(plan, answer, { startedMs, endedMs: clock(), task: options.task, root: options.root, record });
|
|
260
281
|
}
|
|
261
282
|
|
|
@@ -269,12 +290,14 @@ function runPrepPassSync({
|
|
|
269
290
|
const plan = planPrepPass(options);
|
|
270
291
|
if (plan.skip) return plan.skip;
|
|
271
292
|
const startedMs = clock();
|
|
293
|
+
const endReadings = startPrepReadings(plan, options.root);
|
|
272
294
|
let answer;
|
|
273
295
|
try {
|
|
274
296
|
answer = ask(plan.request);
|
|
275
297
|
} catch (error) {
|
|
276
298
|
answer = askFailure(error);
|
|
277
299
|
}
|
|
300
|
+
answer = endReadings(answer || { ok: false, reason: 'no_answer', stdout: '' });
|
|
278
301
|
return finishPrepPass(plan, answer, { startedMs, endedMs: clock(), task: options.task, root: options.root, record });
|
|
279
302
|
}
|
|
280
303
|
|
package/lib/runner-command.js
CHANGED
|
@@ -43,6 +43,11 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
|
|
|
43
43
|
model: 'atris:fast',
|
|
44
44
|
commandTemplate: '{bin} --fast {prompt}',
|
|
45
45
|
}),
|
|
46
|
+
mercury: Object.freeze({
|
|
47
|
+
bin: 'ax',
|
|
48
|
+
model: 'inception:mercury-3-preview-1002',
|
|
49
|
+
commandTemplate: '{bin} --mercury {prompt}',
|
|
50
|
+
}),
|
|
46
51
|
// Guest engines: any installed headless coding CLI is a swappable worker.
|
|
47
52
|
// No commandTemplate on claude, the default spawn shape is already
|
|
48
53
|
// claude-compatible (bin -p prompt --model X --allowedTools ...).
|
package/lib/task-db.js
CHANGED
|
@@ -31,7 +31,7 @@ const dailyLog = require('./daily-log');
|
|
|
31
31
|
const { isGenericScratchRoot } = require('./scratch-root');
|
|
32
32
|
const { isDecisionTask } = require('./task-decision');
|
|
33
33
|
const { parseVerifyCommand } = require('./auto-accept-certified');
|
|
34
|
-
const { taskExplanation } = require('./task-explanation');
|
|
34
|
+
const { explicitDoneLooksLike, taskExplanation } = require('./task-explanation');
|
|
35
35
|
const { treeHashFor } = require('./tree-hash');
|
|
36
36
|
const { sleepSync } = require('./sleep-sync');
|
|
37
37
|
const { atrisHome } = require('./state-home');
|
|
@@ -559,6 +559,22 @@ function getTask(db, id) {
|
|
|
559
559
|
return { ...row, metadata, ...(unreadable ? { metadata_unreadable: true } : {}) };
|
|
560
560
|
}
|
|
561
561
|
|
|
562
|
+
// The commit a workspace sits on. Claim and ready stamp it so a landing can
|
|
563
|
+
// later be proven from the task's own change (lib/landing-proof.js) instead
|
|
564
|
+
// of whatever the checkout's HEAD happens to be at accept time.
|
|
565
|
+
function workspaceHead(workspaceRoot) {
|
|
566
|
+
if (!workspaceRoot) return null;
|
|
567
|
+
try {
|
|
568
|
+
const result = require('child_process').spawnSync('git', ['rev-parse', 'HEAD'], {
|
|
569
|
+
cwd: workspaceRoot, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
570
|
+
});
|
|
571
|
+
const sha = String(result.stdout || '').trim();
|
|
572
|
+
return result.status === 0 && /^[0-9a-f]{40}$/.test(sha) ? sha : null;
|
|
573
|
+
} catch {
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
562
578
|
// Atomic claim. Returns { claimed: true, row } only if THIS call won the row.
|
|
563
579
|
// Race-safe via single UPDATE with WHERE status='open' guard. SQLite serializes
|
|
564
580
|
// writes; busy_timeout absorbs contention. Caller must check `.claimed`.
|
|
@@ -577,13 +593,21 @@ function claimTask(db, { id, claimedBy }) {
|
|
|
577
593
|
`);
|
|
578
594
|
const result = withBusyRetry(() => stmt.run(claimedBy, now, now, id));
|
|
579
595
|
if (result.changes === 1) {
|
|
580
|
-
|
|
596
|
+
let row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
|
597
|
+
// First claim wins: a re-claim after a revision keeps the original start.
|
|
598
|
+
const startCommit = workspaceHead(row.workspace_root);
|
|
599
|
+
const claimedMetadata = row.metadata ? safeJSON(row.metadata) || {} : {};
|
|
600
|
+
if (startCommit && !claimedMetadata.start_commit) {
|
|
601
|
+
claimedMetadata.start_commit = startCommit;
|
|
602
|
+
withBusyRetry(() => db.prepare('UPDATE tasks SET metadata = ? WHERE id = ?').run(JSON.stringify(claimedMetadata), id));
|
|
603
|
+
row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
|
|
604
|
+
}
|
|
581
605
|
appendTaskEvent(db, {
|
|
582
606
|
taskId: id,
|
|
583
607
|
workspaceRoot: row.workspace_root,
|
|
584
608
|
actor: claimedBy,
|
|
585
609
|
eventType: 'claimed',
|
|
586
|
-
payload: { claimed_by: claimedBy },
|
|
610
|
+
payload: { claimed_by: claimedBy, ...(startCommit ? { start_commit: startCommit } : {}) },
|
|
587
611
|
});
|
|
588
612
|
const enriched = { ...row, metadata: row.metadata ? safeJSON(row.metadata) : null };
|
|
589
613
|
appendTaskWorkLog(db, enriched, { actor: claimedBy, title: 'Task claimed' });
|
|
@@ -595,7 +619,7 @@ function claimTask(db, { id, claimedBy }) {
|
|
|
595
619
|
return { claimed: false, reason: 'already_' + row.status, claimed_by: row.claimed_by };
|
|
596
620
|
}
|
|
597
621
|
|
|
598
|
-
function releaseTask(db, { id, actor }) {
|
|
622
|
+
function releaseTask(db, { id, actor, expectedUpdatedAt }) {
|
|
599
623
|
if (!id) throw new Error('id required');
|
|
600
624
|
if (!actor) throw new Error('actor required');
|
|
601
625
|
const row = getTask(db, id);
|
|
@@ -616,7 +640,8 @@ function releaseTask(db, { id, actor }) {
|
|
|
616
640
|
WHERE id = ?
|
|
617
641
|
AND status = 'claimed'
|
|
618
642
|
AND claimed_by = ?
|
|
619
|
-
|
|
643
|
+
AND (? IS NULL OR updated_at = ?)
|
|
644
|
+
`).run(now, id, actor, expectedUpdatedAt ?? null, expectedUpdatedAt ?? null));
|
|
620
645
|
if (result.changes !== 1) return { released: false, reason: 'stale_task_state', claimed_by: row.claimed_by || null };
|
|
621
646
|
const updated = getTask(db, id);
|
|
622
647
|
const event = appendTaskEvent(db, {
|
|
@@ -632,12 +657,14 @@ function releaseTask(db, { id, actor }) {
|
|
|
632
657
|
return { released: true, row: updated, event };
|
|
633
658
|
}
|
|
634
659
|
|
|
635
|
-
function reopenTask(db, { id, actor, reason, metadata: metadataPatch } = {}) {
|
|
660
|
+
function reopenTask(db, { id, actor, reason, metadata: metadataPatch, fromReview = false } = {}) {
|
|
636
661
|
if (!id) throw new Error('id required');
|
|
637
662
|
const row = getTask(db, id);
|
|
638
663
|
if (!row) return { reopened: false, reason: 'not_found' };
|
|
639
|
-
|
|
640
|
-
|
|
664
|
+
// A job marked done and waiting to merge can be sent back only on request.
|
|
665
|
+
const reviewReopen = fromReview && row.status === 'review';
|
|
666
|
+
if (!reviewReopen && OPEN_TASK_STATUSES.has(row.status)) return { reopened: false, reason: `already_${row.status}`, row };
|
|
667
|
+
if (!reviewReopen && !['done', 'failed', 'archived'].includes(row.status)) {
|
|
641
668
|
return { reopened: false, reason: `not_reopenable_${row.status}` };
|
|
642
669
|
}
|
|
643
670
|
|
|
@@ -912,7 +939,7 @@ function recordReviewPassActor(metadata, claimedBy, rawActor) {
|
|
|
912
939
|
return { builder: builder || null, independent };
|
|
913
940
|
}
|
|
914
941
|
|
|
915
|
-
function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, resultTrace, result: resultSentence, reason }) {
|
|
942
|
+
function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, resultTrace, result: resultSentence, reason, workRoot }) {
|
|
916
943
|
if (!id) throw new Error('id required');
|
|
917
944
|
const text = String(proof || '').trim();
|
|
918
945
|
if (!text) throw new Error('proof required');
|
|
@@ -940,6 +967,15 @@ function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, resul
|
|
|
940
967
|
const cleaned = cleanLandingText(landingInput[key]);
|
|
941
968
|
if (cleaned) metadata[`landing_${key}`] = cleaned;
|
|
942
969
|
}
|
|
970
|
+
// Only the builder's own ready names the work's commit. A reviewer or the
|
|
971
|
+
// autoland verifier re-runs the check from its own checkout; stamping that
|
|
972
|
+
// checkout's HEAD would name a commit the builder never made. No actor, or
|
|
973
|
+
// no builder on record, means nobody can vouch for HEAD, so nothing is stamped.
|
|
974
|
+
// A builder working in its own copy passes that copy as workRoot.
|
|
975
|
+
if (actor && row.claimed_by && actor === row.claimed_by && !reviewIntegrity.isReservedActor(actor)) {
|
|
976
|
+
const head = workspaceHead(workRoot || row.workspace_root);
|
|
977
|
+
if (head) metadata.work_commit = head;
|
|
978
|
+
}
|
|
943
979
|
const passRecord = recordReviewPassActor(metadata, row.claimed_by, actor);
|
|
944
980
|
if (reviewPassCount >= AGENT_CERTIFICATION_REVIEW_PASSES && passRecord.independent) {
|
|
945
981
|
metadata.agent_certified = true;
|
|
@@ -1302,7 +1338,7 @@ function stageTask(db, {
|
|
|
1302
1338
|
let stageOwner = requestedOwner || actorText || cleanStageText(row.claimed_by) || null;
|
|
1303
1339
|
let goalText = requestedGoal || cleanStageText(metadata.task_goal || metadata.goal_objective || metadata.objective || metadata.stage_goal);
|
|
1304
1340
|
let proofText = requestedProof || cleanStageText(metadata.verify || metadata.proof_needed);
|
|
1305
|
-
let exitText = requestedExit || cleanStageText(metadata.exit_condition);
|
|
1341
|
+
let exitText = requestedExit || cleanStageText(metadata.exit_condition) || cleanStageText(explicitDoneLooksLike(metadata));
|
|
1306
1342
|
|
|
1307
1343
|
function recordedDoPlan(currentRow, { claimedOwnerWins = false } = {}) {
|
|
1308
1344
|
const currentMetadata = currentRow && currentRow.metadata && typeof currentRow.metadata === 'object'
|
|
@@ -1314,7 +1350,7 @@ function stageTask(db, {
|
|
|
1314
1350
|
const caller = actorText || requestedOwner || null;
|
|
1315
1351
|
const recordedGoal = cleanStageText(currentMetadata.goal_objective || currentMetadata.objective || currentMetadata.stage_goal || currentMetadata.task_goal);
|
|
1316
1352
|
const recordedProof = cleanStageText(currentMetadata.verify || currentMetadata.proof_needed);
|
|
1317
|
-
const recordedExit = cleanStageText(currentMetadata.exit_condition);
|
|
1353
|
+
const recordedExit = cleanStageText(currentMetadata.exit_condition) || cleanStageText(explicitDoneLooksLike(currentMetadata));
|
|
1318
1354
|
const planRecorded = cleanStageText(currentMetadata.planned_at)
|
|
1319
1355
|
|| cleanStageText(currentMetadata.stage) === 'plan'
|
|
1320
1356
|
|| cleanStageText(currentMetadata.stage_plan_recorded_at);
|
package/lib/task-explanation.js
CHANGED
|
@@ -81,6 +81,12 @@ function explicitExplanationFields(metadata) {
|
|
|
81
81
|
return out;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// The author's own "done looks like" line, or empty when it was only derived.
|
|
85
|
+
// A job that states this has a finish line, so plan gates read it as one.
|
|
86
|
+
function explicitDoneLooksLike(metadata) {
|
|
87
|
+
return explicitExplanationFields(metadata).done_looks_like || '';
|
|
88
|
+
}
|
|
89
|
+
|
|
84
90
|
function explanationGoalText(task) {
|
|
85
91
|
const metadata = task && task.metadata || {};
|
|
86
92
|
return cleanText(metadata.task_goal || metadata.goal_objective || (task && task.objective) || metadata.objective);
|
|
@@ -219,6 +225,7 @@ module.exports = {
|
|
|
219
225
|
EXPLANATION_FIELDS,
|
|
220
226
|
EXPLANATION_LABELS,
|
|
221
227
|
NO_REASON_RECORDED,
|
|
228
|
+
explicitDoneLooksLike,
|
|
222
229
|
plainText,
|
|
223
230
|
taskExplanation,
|
|
224
231
|
explanationFieldsFromInput,
|
package/lib/task-list-keeper.js
CHANGED
|
@@ -7,6 +7,7 @@ const fs = require('fs');
|
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const taskDb = require('./task-db');
|
|
9
9
|
const { protectedLaneOf } = require('./auto-accept-certified');
|
|
10
|
+
const { isDecisionTask } = require('./task-decision');
|
|
10
11
|
|
|
11
12
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
12
13
|
const OPEN_STATUSES = new Set(['open', 'claimed', 'review']);
|
|
@@ -72,6 +73,12 @@ function isProtectedRow(row) {
|
|
|
72
73
|
return Boolean(protectedLaneOf({ ...row, metadata: parsedMetadata(row) }));
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
// A task tagged needs-human or decision is waiting on a person, not stuck.
|
|
77
|
+
// Their silence is not an answer, so idle time never puts it away.
|
|
78
|
+
function isHeldForPerson(row) {
|
|
79
|
+
return isDecisionTask({ ...row, metadata: parsedMetadata(row) });
|
|
80
|
+
}
|
|
81
|
+
|
|
75
82
|
function decisionFor(row, now, { includeDone = true } = {}) {
|
|
76
83
|
if (!row || row.status === 'archived') return null;
|
|
77
84
|
if (row.status === 'done') {
|
|
@@ -79,7 +86,7 @@ function decisionFor(row, now, { includeDone = true } = {}) {
|
|
|
79
86
|
return { reason: REASONS.done, fromDone: true, fromFailed: false };
|
|
80
87
|
}
|
|
81
88
|
if (!Object.prototype.hasOwnProperty.call(IDLE_LIMITS_MS, row.status)) return null;
|
|
82
|
-
if (OPEN_STATUSES.has(row.status) && isProtectedRow(row)) return null;
|
|
89
|
+
if (OPEN_STATUSES.has(row.status) && (isProtectedRow(row) || isHeldForPerson(row))) return null;
|
|
83
90
|
const at = Number(row.updated_at || row.created_at || 0);
|
|
84
91
|
if (!((now - at) > IDLE_LIMITS_MS[row.status])) return null;
|
|
85
92
|
return { reason: REASONS[row.status], fromDone: false, fromFailed: row.status === 'failed' };
|