atris 3.69.0 → 3.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -1
- 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 +26 -6
- package/commands/autoland.js +176 -7
- package/commands/autopilot.js +71 -11
- package/commands/campaign.js +759 -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/interview.js +15 -3
- package/commands/land.js +22 -6
- package/commands/link.js +1 -0
- package/commands/member.js +433 -99
- package/commands/mission.js +332 -41
- package/commands/report.js +42 -8
- package/commands/run.js +9 -2
- package/commands/scout.js +9 -1
- package/commands/task.js +132 -16
- package/commands/team.js +212 -4
- package/commands/unknowns.js +15 -1
- package/commands/verify.js +46 -0
- package/commands/worktree.js +148 -18
- package/commands/write.js +12 -1
- package/commands/youtube.js +17 -2
- package/lib/accept-verify-gate.js +1 -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 +242 -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-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 +35 -4
- package/lib/engine-registry.js +2 -0
- package/lib/engine-resource.js +518 -0
- package/lib/engine-validate.js +189 -9
- 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 +479 -10
- package/lib/member-board.js +86 -10
- 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 +433 -0
- package/lib/member-scaffold.js +1 -2
- package/lib/member-self-next-job.js +97 -0
- package/lib/member-switches.js +7 -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 +63 -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');
|
|
@@ -454,6 +454,21 @@ function addTask(db, { title, tag, workspaceRoot: ws, sourceKey: sk, metadata, s
|
|
|
454
454
|
return { id, inserted: true };
|
|
455
455
|
}
|
|
456
456
|
|
|
457
|
+
// Same job = same title ignoring case and spacing. Only jobs not yet handed in
|
|
458
|
+
// (open or claimed) count, so a job in review, done, or failed can be filed again.
|
|
459
|
+
function normalizeTaskTitle(title) {
|
|
460
|
+
return String(title || '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function findUnfinishedTaskByTitle(db, { workspaceRoot: ws, title }) {
|
|
464
|
+
const wanted = normalizeTaskTitle(title);
|
|
465
|
+
if (!wanted) return null;
|
|
466
|
+
const rows = db.prepare(
|
|
467
|
+
"SELECT id, title FROM tasks WHERE workspace_root = ? AND status IN ('open', 'claimed') ORDER BY created_at ASC"
|
|
468
|
+
).all(ws);
|
|
469
|
+
return rows.find((row) => normalizeTaskTitle(row.title) === wanted) || null;
|
|
470
|
+
}
|
|
471
|
+
|
|
457
472
|
// A main checkout's view also covers its linked git worktrees, whose tasks keep
|
|
458
473
|
// the worktree as workspace_root so checks run where the work lives. Only a
|
|
459
474
|
// main checkout with linked worktrees spawns git; any error means exact match.
|
|
@@ -544,6 +559,22 @@ function getTask(db, id) {
|
|
|
544
559
|
return { ...row, metadata, ...(unreadable ? { metadata_unreadable: true } : {}) };
|
|
545
560
|
}
|
|
546
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
|
+
|
|
547
578
|
// Atomic claim. Returns { claimed: true, row } only if THIS call won the row.
|
|
548
579
|
// Race-safe via single UPDATE with WHERE status='open' guard. SQLite serializes
|
|
549
580
|
// writes; busy_timeout absorbs contention. Caller must check `.claimed`.
|
|
@@ -562,13 +593,21 @@ function claimTask(db, { id, claimedBy }) {
|
|
|
562
593
|
`);
|
|
563
594
|
const result = withBusyRetry(() => stmt.run(claimedBy, now, now, id));
|
|
564
595
|
if (result.changes === 1) {
|
|
565
|
-
|
|
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
|
+
}
|
|
566
605
|
appendTaskEvent(db, {
|
|
567
606
|
taskId: id,
|
|
568
607
|
workspaceRoot: row.workspace_root,
|
|
569
608
|
actor: claimedBy,
|
|
570
609
|
eventType: 'claimed',
|
|
571
|
-
payload: { claimed_by: claimedBy },
|
|
610
|
+
payload: { claimed_by: claimedBy, ...(startCommit ? { start_commit: startCommit } : {}) },
|
|
572
611
|
});
|
|
573
612
|
const enriched = { ...row, metadata: row.metadata ? safeJSON(row.metadata) : null };
|
|
574
613
|
appendTaskWorkLog(db, enriched, { actor: claimedBy, title: 'Task claimed' });
|
|
@@ -580,7 +619,7 @@ function claimTask(db, { id, claimedBy }) {
|
|
|
580
619
|
return { claimed: false, reason: 'already_' + row.status, claimed_by: row.claimed_by };
|
|
581
620
|
}
|
|
582
621
|
|
|
583
|
-
function releaseTask(db, { id, actor }) {
|
|
622
|
+
function releaseTask(db, { id, actor, expectedUpdatedAt }) {
|
|
584
623
|
if (!id) throw new Error('id required');
|
|
585
624
|
if (!actor) throw new Error('actor required');
|
|
586
625
|
const row = getTask(db, id);
|
|
@@ -601,7 +640,8 @@ function releaseTask(db, { id, actor }) {
|
|
|
601
640
|
WHERE id = ?
|
|
602
641
|
AND status = 'claimed'
|
|
603
642
|
AND claimed_by = ?
|
|
604
|
-
|
|
643
|
+
AND (? IS NULL OR updated_at = ?)
|
|
644
|
+
`).run(now, id, actor, expectedUpdatedAt ?? null, expectedUpdatedAt ?? null));
|
|
605
645
|
if (result.changes !== 1) return { released: false, reason: 'stale_task_state', claimed_by: row.claimed_by || null };
|
|
606
646
|
const updated = getTask(db, id);
|
|
607
647
|
const event = appendTaskEvent(db, {
|
|
@@ -617,12 +657,14 @@ function releaseTask(db, { id, actor }) {
|
|
|
617
657
|
return { released: true, row: updated, event };
|
|
618
658
|
}
|
|
619
659
|
|
|
620
|
-
function reopenTask(db, { id, actor, reason, metadata: metadataPatch } = {}) {
|
|
660
|
+
function reopenTask(db, { id, actor, reason, metadata: metadataPatch, fromReview = false } = {}) {
|
|
621
661
|
if (!id) throw new Error('id required');
|
|
622
662
|
const row = getTask(db, id);
|
|
623
663
|
if (!row) return { reopened: false, reason: 'not_found' };
|
|
624
|
-
|
|
625
|
-
|
|
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)) {
|
|
626
668
|
return { reopened: false, reason: `not_reopenable_${row.status}` };
|
|
627
669
|
}
|
|
628
670
|
|
|
@@ -897,7 +939,7 @@ function recordReviewPassActor(metadata, claimedBy, rawActor) {
|
|
|
897
939
|
return { builder: builder || null, independent };
|
|
898
940
|
}
|
|
899
941
|
|
|
900
|
-
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 }) {
|
|
901
943
|
if (!id) throw new Error('id required');
|
|
902
944
|
const text = String(proof || '').trim();
|
|
903
945
|
if (!text) throw new Error('proof required');
|
|
@@ -925,6 +967,15 @@ function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, resul
|
|
|
925
967
|
const cleaned = cleanLandingText(landingInput[key]);
|
|
926
968
|
if (cleaned) metadata[`landing_${key}`] = cleaned;
|
|
927
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
|
+
}
|
|
928
979
|
const passRecord = recordReviewPassActor(metadata, row.claimed_by, actor);
|
|
929
980
|
if (reviewPassCount >= AGENT_CERTIFICATION_REVIEW_PASSES && passRecord.independent) {
|
|
930
981
|
metadata.agent_certified = true;
|
|
@@ -1287,7 +1338,7 @@ function stageTask(db, {
|
|
|
1287
1338
|
let stageOwner = requestedOwner || actorText || cleanStageText(row.claimed_by) || null;
|
|
1288
1339
|
let goalText = requestedGoal || cleanStageText(metadata.task_goal || metadata.goal_objective || metadata.objective || metadata.stage_goal);
|
|
1289
1340
|
let proofText = requestedProof || cleanStageText(metadata.verify || metadata.proof_needed);
|
|
1290
|
-
let exitText = requestedExit || cleanStageText(metadata.exit_condition);
|
|
1341
|
+
let exitText = requestedExit || cleanStageText(metadata.exit_condition) || cleanStageText(explicitDoneLooksLike(metadata));
|
|
1291
1342
|
|
|
1292
1343
|
function recordedDoPlan(currentRow, { claimedOwnerWins = false } = {}) {
|
|
1293
1344
|
const currentMetadata = currentRow && currentRow.metadata && typeof currentRow.metadata === 'object'
|
|
@@ -1299,7 +1350,7 @@ function stageTask(db, {
|
|
|
1299
1350
|
const caller = actorText || requestedOwner || null;
|
|
1300
1351
|
const recordedGoal = cleanStageText(currentMetadata.goal_objective || currentMetadata.objective || currentMetadata.stage_goal || currentMetadata.task_goal);
|
|
1301
1352
|
const recordedProof = cleanStageText(currentMetadata.verify || currentMetadata.proof_needed);
|
|
1302
|
-
const recordedExit = cleanStageText(currentMetadata.exit_condition);
|
|
1353
|
+
const recordedExit = cleanStageText(currentMetadata.exit_condition) || cleanStageText(explicitDoneLooksLike(currentMetadata));
|
|
1303
1354
|
const planRecorded = cleanStageText(currentMetadata.planned_at)
|
|
1304
1355
|
|| cleanStageText(currentMetadata.stage) === 'plan'
|
|
1305
1356
|
|| cleanStageText(currentMetadata.stage_plan_recorded_at);
|
|
@@ -2304,6 +2355,7 @@ module.exports = {
|
|
|
2304
2355
|
sourceKey,
|
|
2305
2356
|
normalizeTitle,
|
|
2306
2357
|
addTask,
|
|
2358
|
+
findUnfinishedTaskByTitle,
|
|
2307
2359
|
getTask,
|
|
2308
2360
|
listTasks,
|
|
2309
2361
|
listTasksCached,
|
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,
|