atris 3.69.0 → 3.70.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 +22 -1
- package/commands/member.js +78 -9
- package/commands/mission.js +83 -5
- package/commands/task.js +63 -1
- package/commands/worktree.js +42 -5
- package/lib/engine-ask.js +2 -1
- package/lib/engine-validate.js +189 -9
- package/lib/member-autoland.js +100 -8
- package/lib/member-board.js +80 -10
- package/lib/member-report.js +314 -0
- package/lib/member-scaffold.js +1 -2
- package/lib/member-switches.js +7 -0
- package/lib/task-db.js +16 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -151,7 +151,28 @@ atris member board --watch
|
|
|
151
151
|
tail -f ~/.atris/progress/executor.log
|
|
152
152
|
```
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
When a member finishes, ask for its result: what it changed, its receipt, and one number. Each member's copy of the repo stays until its work lands, then tidy puts it away.
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
atris member result executor
|
|
158
|
+
atris member tidy --dry-run
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The older words still work as aliases: `dispatch` and `go` do what run does, and `up` does what wake does.
|
|
162
|
+
|
|
163
|
+
### How member work gets checked and lands
|
|
164
|
+
|
|
165
|
+
Every member job ends with its own check, a command that must pass. Then a second engine reviews the change, and it is never the engine that did the work.
|
|
166
|
+
|
|
167
|
+
Work lands on its own only after you switch self-landing on, once. From then on Atris pushes the branch, opens a pull request, and turns on GitHub auto-merge, so GitHub merges it when the branch is up to date and CI passes.
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
atris autoland on
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Anything else keeps the work on its branch, with one plain line saying why. That covers a failed check or review, unsaved changes, a clash with the main branch, and work that touches a protected area like payments, deploys, or credentials.
|
|
174
|
+
|
|
175
|
+
A job that fails three times in a row stops and waits for a person.
|
|
155
176
|
|
|
156
177
|
## Chat With Atris 2
|
|
157
178
|
|
package/commands/member.js
CHANGED
|
@@ -51,8 +51,7 @@ function ensureMemberLog(memberDir, { name, role, description, source = 'cli' }
|
|
|
51
51
|
description ? `- mission: ${description}` : '',
|
|
52
52
|
`- source: ${source}`,
|
|
53
53
|
'- status: ready_for_room',
|
|
54
|
-
|
|
55
|
-
].filter(Boolean).join('\n');
|
|
54
|
+
].filter(Boolean).join('\n') + '\n';
|
|
56
55
|
fs.writeFileSync(logPath, content, 'utf8');
|
|
57
56
|
return logPath;
|
|
58
57
|
}
|
|
@@ -915,6 +914,17 @@ function memberRunBudgetSeconds(args = []) {
|
|
|
915
914
|
return null;
|
|
916
915
|
}
|
|
917
916
|
|
|
917
|
+
// A job with no time given gets 15 minutes, or the roster's limit for this
|
|
918
|
+
// member's engine plus ten minutes for the review, whichever is longer.
|
|
919
|
+
function memberRunDefaultWallSeconds(name, cwd = process.cwd()) {
|
|
920
|
+
let cap = 0;
|
|
921
|
+
try {
|
|
922
|
+
const picked = require('../lib/member-engine').memberRosterEngine(name, cwd);
|
|
923
|
+
cap = Number(picked && picked.engine && picked.engine.roster_max_seconds) || 0;
|
|
924
|
+
} catch {}
|
|
925
|
+
return Math.max(900, cap ? cap + 600 : 0);
|
|
926
|
+
}
|
|
927
|
+
|
|
918
928
|
function pushFlagValue(out, args, name) {
|
|
919
929
|
const value = readFlag(args, name, '');
|
|
920
930
|
if (value) out.push(name, String(value));
|
|
@@ -1040,7 +1050,7 @@ function memberRunCli(args, cwd, { live = false, missionId = '' } = {}) {
|
|
|
1040
1050
|
: null;
|
|
1041
1051
|
const receipt = receiptName ? readJson(path.join(runsDir, receiptName), null) : null;
|
|
1042
1052
|
return { status: result.status, payload: receipt?.result?.kind === 'mission_run_summary'
|
|
1043
|
-
? { ran_ticks: receipt.result.ran_ticks } : null, stderr: '', errorReported: result.status !== 0 };
|
|
1053
|
+
? { ran_ticks: receipt.result.ran_ticks, ticks: receipt.result.ticks } : null, stderr: '', errorReported: result.status !== 0 };
|
|
1044
1054
|
}
|
|
1045
1055
|
let payload = null;
|
|
1046
1056
|
try {
|
|
@@ -1235,6 +1245,15 @@ function memberRun(name, ...args) {
|
|
|
1235
1245
|
return memberRunWith(name, args);
|
|
1236
1246
|
}
|
|
1237
1247
|
|
|
1248
|
+
// One plain line for why the reviewing engine refused the work.
|
|
1249
|
+
function reviewRefusalLine(refusal) {
|
|
1250
|
+
if (refusal.timed_out) return 'the reviewer ran out of time';
|
|
1251
|
+
const text = String(refusal.output || refusal.reason || '');
|
|
1252
|
+
const verdict = text.match(/VERDICT:\s*FAIL\s*[;:]\s*(.+)/i);
|
|
1253
|
+
const line = verdict ? verdict[1] : (text.split(/\r?\n/).find((l) => l.trim()) || refusal.reason || 'no reason given');
|
|
1254
|
+
return line.trim().slice(0, 200);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1238
1257
|
function memberRunWith(name, args, { quiet = false } = {}) {
|
|
1239
1258
|
if (!name || name === '--help' || name === '-h' || hasFlag(args, '--help') || hasFlag(args, '-h')) {
|
|
1240
1259
|
console.log('usage: atris member run <name> ["mission text"] [--verify "<cmd>"] [--minutes N|--hours N] [--json]');
|
|
@@ -1338,7 +1357,7 @@ function memberRunWith(name, args, { quiet = false } = {}) {
|
|
|
1338
1357
|
if (!readFlag(runArgs, '--max-ticks', '')) {
|
|
1339
1358
|
runArgs.push('--max-ticks', budgetSeconds ? String(Math.max(4, Math.ceil(budgetSeconds / 300))) : '1');
|
|
1340
1359
|
}
|
|
1341
|
-
if (!readFlag(runArgs, '--max-wall', '')) runArgs.push('--max-wall', String(budgetSeconds ||
|
|
1360
|
+
if (!readFlag(runArgs, '--max-wall', '')) runArgs.push('--max-wall', String(budgetSeconds || memberRunDefaultWallSeconds(name, cwd)));
|
|
1342
1361
|
const caller = callerRuntime(args);
|
|
1343
1362
|
const runnerSwap = memberRunRunnerSwap(mission, args, caller);
|
|
1344
1363
|
if (runnerSwap) runArgs.push('--engine', runnerSwap.to);
|
|
@@ -1351,7 +1370,12 @@ function memberRunWith(name, args, { quiet = false } = {}) {
|
|
|
1351
1370
|
const waitingOnNativeGoal = caller === 'codex' && payload.requires_native_goal_start === true
|
|
1352
1371
|
&& memberRunUsesCallerSession(finalMission.runner);
|
|
1353
1372
|
const noWorkStepExpected = hasFlag(runArgs, '--detach') || hasFlag(runArgs, '--dry-run');
|
|
1354
|
-
|
|
1373
|
+
// The work ran but its check failed: the run failed, even though a work
|
|
1374
|
+
// step happened. Scripts and the caller read this from the exit code.
|
|
1375
|
+
const lastRanTick = [...(Array.isArray(payload.ticks) ? payload.ticks : [])].reverse().find((tick) => tick && tick.ran === true);
|
|
1376
|
+
const checkFailed = started && Boolean(String(finalMission.verifier || '').trim())
|
|
1377
|
+
&& lastRanTick?.verifier_passed === false && finalMission.status !== 'complete';
|
|
1378
|
+
const ok = ((run.status === 0 && payload.ok !== false && (started || noWorkStepExpected)) || waitingOnNativeGoal) && !checkFailed;
|
|
1355
1379
|
const reviewAsk = memberRunReviewAsk(name, payload, finalMission, cwd);
|
|
1356
1380
|
const result = { ...payload, ok, started, state: finalMission.status, mission_id: mission.id,
|
|
1357
1381
|
mission: finalMission, node_modules_linked: nodeModulesLinked, node_modules_source: nodeModulesSource,
|
|
@@ -1385,7 +1409,13 @@ function memberRunWith(name, args, { quiet = false } = {}) {
|
|
|
1385
1409
|
result.autoland = require('../lib/member-autoland').autolandMemberRun({ mission: finalMission, cwd, summary, workerEngine });
|
|
1386
1410
|
if (!asJson && !quiet) console.log(result.autoland.line);
|
|
1387
1411
|
}
|
|
1388
|
-
if (
|
|
1412
|
+
if (checkFailed) {
|
|
1413
|
+
result.check_failed = true;
|
|
1414
|
+
const refusal = lastRanTick?.referee_result?.passed === false ? lastRanTick.referee_result : null;
|
|
1415
|
+
result.error = refusal
|
|
1416
|
+
? `turned down in review: ${reviewRefusalLine(refusal)}`
|
|
1417
|
+
: `the work ran but its check failed: ${finalMission.verifier || 'completion check'}`;
|
|
1418
|
+
} else if (!ok) {
|
|
1389
1419
|
result.error = result.ask || payload.detail || payload.error || payload.reason || payload.next_action
|
|
1390
1420
|
|| run.stderr || 'mission did not run a work step';
|
|
1391
1421
|
}
|
|
@@ -4256,7 +4286,10 @@ function parseFrontmatter(content) {
|
|
|
4256
4286
|
} else if (val.startsWith('[') && val.endsWith(']')) {
|
|
4257
4287
|
result[currentKey] = val.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
|
|
4258
4288
|
} else {
|
|
4259
|
-
|
|
4289
|
+
// Unwrap only a matching outer pair: `verify: node -e "process.exit(0)"`
|
|
4290
|
+
// must keep its closing quote or the check is a shell syntax error.
|
|
4291
|
+
const quoted = val.match(/^(["'])([\s\S]*)\1$/);
|
|
4292
|
+
result[currentKey] = quoted ? quoted[2] : val;
|
|
4260
4293
|
}
|
|
4261
4294
|
}
|
|
4262
4295
|
}
|
|
@@ -10463,12 +10496,43 @@ function memberGoFinishTask(owner, task, result, thrown = null) {
|
|
|
10463
10496
|
const ready = taskDb.readyTask(db, { id: task.id, actor: owner, proof });
|
|
10464
10497
|
if (ready.ready !== false) return { id: task.id, title: task.title, outcome: 'review', receipt: receipt || null };
|
|
10465
10498
|
}
|
|
10499
|
+
const ticks = Array.isArray(result && result.ticks) ? result.ticks : [];
|
|
10500
|
+
const lastReason = String((ticks[ticks.length - 1] || {}).reason || (ticks[ticks.length - 1] || {}).error || '');
|
|
10501
|
+
const outOfTime = /timeout|wall-exceeded/.test(lastReason);
|
|
10466
10502
|
const why = thrown ? thrown.message
|
|
10503
|
+
: outOfTime ? 'ran out of time before the work finished'
|
|
10467
10504
|
: (result && result.error) || (result && result.started ? 'the check did not pass' : 'the run did not start');
|
|
10468
10505
|
const reason = String(why || 'the run did not finish').split(/\r?\n/)[0].trim().slice(0, 200);
|
|
10469
10506
|
try { taskDb.noteTask(db, { id: task.id, actor: owner, content: `Released by ${owner}: ${reason}` }); } catch {}
|
|
10507
|
+
// Third failure in a row: hold the task for a person instead of letting
|
|
10508
|
+
// members pick it up again forever.
|
|
10509
|
+
let held = false;
|
|
10510
|
+
if (memberGoFailStreak(taskDb, db, task.id) >= MEMBER_GO_STRIKES) {
|
|
10511
|
+
try {
|
|
10512
|
+
held = taskDb.tagTask(db, { id: task.id, actor: owner, add: ['needs-human'] }).tagged === true;
|
|
10513
|
+
if (held) taskDb.noteTask(db, { id: task.id, actor: owner,
|
|
10514
|
+
content: `Held for a person: failed ${MEMBER_GO_STRIKES} times in a row. Last reason: ${reason}` });
|
|
10515
|
+
} catch {}
|
|
10516
|
+
}
|
|
10470
10517
|
const released = taskDb.releaseTask(db, { id: task.id, actor: owner });
|
|
10471
|
-
|
|
10518
|
+
const outcome = released.released ? (held ? 'needs_human' : 'released') : 'left_claimed';
|
|
10519
|
+
return { id: task.id, title: task.title, outcome, reason };
|
|
10520
|
+
}
|
|
10521
|
+
|
|
10522
|
+
const MEMBER_GO_STRIKES = 3;
|
|
10523
|
+
|
|
10524
|
+
// Failed runs on a task since its last pass, review, reopen, or a person
|
|
10525
|
+
// clearing its hold.
|
|
10526
|
+
function memberGoFailStreak(taskDb, db, taskId) {
|
|
10527
|
+
let streak = 0;
|
|
10528
|
+
for (const event of taskDb.listTaskEvents(db, { taskId })) {
|
|
10529
|
+
const type = event.event_type;
|
|
10530
|
+
const payload = event.payload || {};
|
|
10531
|
+
if (type === 'message' && /^Released by \S+: /.test(String(payload.content || ''))) streak += 1;
|
|
10532
|
+
else if (['proof_ready', 'reviewed', 'reopened'].includes(type)) streak = 0;
|
|
10533
|
+
else if (type === 'task_tags_updated' && (payload.removed || []).includes('needs-human')) streak = 0;
|
|
10534
|
+
}
|
|
10535
|
+
return streak;
|
|
10472
10536
|
}
|
|
10473
10537
|
|
|
10474
10538
|
async function memberSteerOrDispatch(...rawArgs) {
|
|
@@ -10651,6 +10715,7 @@ async function memberSteerOrDispatchWith({ pullTask = false } = {}, rawArgs = []
|
|
|
10651
10715
|
? { task, work_step: { summary: result?.work_step?.summary || null }, error: result?.error || null }
|
|
10652
10716
|
: merged));
|
|
10653
10717
|
else if (task.outcome === 'review') console.log(`Task "${task.title}" is ready for review.`);
|
|
10718
|
+
else if (task.outcome === 'needs_human') console.log(`Task "${task.title}" failed three times in a row and now waits for a person: ${task.reason}`);
|
|
10654
10719
|
else console.log(`Task "${task.title}" went back on the list: ${task.reason}`);
|
|
10655
10720
|
return merged;
|
|
10656
10721
|
}
|
|
@@ -11239,7 +11304,7 @@ async function memberCommand(subcommand, ...args) {
|
|
|
11239
11304
|
const HELP_AWARE_SUBCOMMANDS = new Set([
|
|
11240
11305
|
'create', 'new', 'run', 'install', 'result', 'dispatch', 'route', 'feed', 'stream',
|
|
11241
11306
|
'card', 'cards', 'go', 'work', 'up', 'steer', 'sleep', 'rest', '1on1', 'interview',
|
|
11242
|
-
'room', 'ui', 'day', 'standup', 'handoff', 'handoffs',
|
|
11307
|
+
'room', 'ui', 'day', 'standup', 'handoff', 'handoffs', 'report',
|
|
11243
11308
|
]);
|
|
11244
11309
|
const explicitHelp = subcommand === 'help' || subcommand === '--help' || subcommand === '-h'
|
|
11245
11310
|
|| (!HELP_AWARE_SUBCOMMANDS.has(subcommand) && (args[0] === '-h' || args[0] === '--help'));
|
|
@@ -11345,6 +11410,8 @@ async function memberCommand(subcommand, ...args) {
|
|
|
11345
11410
|
return memberHeartbeat(args[0], ...args.slice(1));
|
|
11346
11411
|
case 'improvements':
|
|
11347
11412
|
return memberImprovements(args[0], ...args.slice(1));
|
|
11413
|
+
case 'report':
|
|
11414
|
+
return require('../lib/member-report').memberReportCommand(...args);
|
|
11348
11415
|
case 'history':
|
|
11349
11416
|
return memberHistory(args[0], ...args.slice(1));
|
|
11350
11417
|
case 'supervisor':
|
|
@@ -11406,6 +11473,7 @@ async function memberCommand(subcommand, ...args) {
|
|
|
11406
11473
|
console.log(' wake <name> Decide tick/wait/ask/stop, then run one bounded work step on the roster engine (--plan to only decide; --keep keeps it working)');
|
|
11407
11474
|
console.log(' run "<job>" Pick the best member for the job and run it (same as dispatch "<job>" --ignite)');
|
|
11408
11475
|
console.log(' run <name> ["..."] Start a budgeted member mission, or run its active Mission Runtime');
|
|
11476
|
+
console.log(' report [--since 2h] Short summary per member: merged, finished, turned down, done but not merged yet [--json]');
|
|
11409
11477
|
console.log(' result <name> Newest run result: run record, receipt, artifacts, number [--since <iso>] [--json]');
|
|
11410
11478
|
console.log(' dispatch "<idea>" Route an idea to the best specialist member and start work');
|
|
11411
11479
|
console.log(' up [--max N] [--only a,b] Keep the team working: start free members on fitting tasks every minute (wake 5 / wake a b)');
|
|
@@ -11469,6 +11537,7 @@ async function memberCommand(subcommand, ...args) {
|
|
|
11469
11537
|
}
|
|
11470
11538
|
|
|
11471
11539
|
module.exports = {
|
|
11540
|
+
memberRunDefaultWallSeconds,
|
|
11472
11541
|
memberRunWantsReferee,
|
|
11473
11542
|
proposalPromptForGoal,
|
|
11474
11543
|
memberGoFinishTask,
|
package/commands/mission.js
CHANGED
|
@@ -434,7 +434,13 @@ function missionTickTimeoutMs(mission, remainingMs) {
|
|
|
434
434
|
|
|
435
435
|
function resolveMissionTickRunner(mission, root = process.cwd(), options = {}) {
|
|
436
436
|
if (String(mission && mission.runner || '').trim().toLowerCase() !== MISSION_AUTO_RUNNER) {
|
|
437
|
-
|
|
437
|
+
// A named engine still gets the roster's time limit for that engine.
|
|
438
|
+
const runner = String(mission && mission.runner || '').trim().toLowerCase();
|
|
439
|
+
const pin = runner && !(Number(mission && mission.roster_max_seconds) > 0)
|
|
440
|
+
? require('../lib/engine-registry').rosterPinFor('executor', runner, root, options)
|
|
441
|
+
: {};
|
|
442
|
+
const limited = pin.roster_max_seconds ? { ...mission, roster_max_seconds: pin.roster_max_seconds } : mission;
|
|
443
|
+
return { mission: limited, engine_id: null, requested_engine: null, engine_fallback_reason: null };
|
|
438
444
|
}
|
|
439
445
|
// A mission owned by a team member runs on that member's roster pick. An
|
|
440
446
|
// explicit preferred engine (or a run's --engine) still wins, and with no
|
|
@@ -1723,7 +1729,18 @@ function appendMemberLog(owner, title, fields = {}, root = process.cwd()) {
|
|
|
1723
1729
|
.map(([key, value]) => `- ${key}: ${String(value).replace(/\n/g, ' ')}`),
|
|
1724
1730
|
'',
|
|
1725
1731
|
];
|
|
1726
|
-
|
|
1732
|
+
let lead = '';
|
|
1733
|
+
try {
|
|
1734
|
+
const size = fs.statSync(logPath).size;
|
|
1735
|
+
if (size > 0) {
|
|
1736
|
+
const fd = fs.openSync(logPath, 'r');
|
|
1737
|
+
const last = Buffer.alloc(1);
|
|
1738
|
+
fs.readSync(fd, last, 0, 1, size - 1);
|
|
1739
|
+
fs.closeSync(fd);
|
|
1740
|
+
if (last[0] !== 0x0a) lead = '\n';
|
|
1741
|
+
}
|
|
1742
|
+
} catch {}
|
|
1743
|
+
fs.appendFileSync(logPath, lead + rows.join('\n'), 'utf8');
|
|
1727
1744
|
return logPath;
|
|
1728
1745
|
}
|
|
1729
1746
|
|
|
@@ -3910,6 +3927,30 @@ function readTasteReviewHistory(root, limit = 4) {
|
|
|
3910
3927
|
return { accepted, revised };
|
|
3911
3928
|
}
|
|
3912
3929
|
|
|
3930
|
+
// Why the mission's own task was last sent back, or '' when it was not. Ticks
|
|
3931
|
+
// run in a worktree, so fall back to the main checkout's task projection.
|
|
3932
|
+
function readBoundTaskTurnDown(mission, root) {
|
|
3933
|
+
const bound = mission?.xp_task || mission?.selected_target_task || null;
|
|
3934
|
+
if (!bound) return '';
|
|
3935
|
+
const keys = new Set([bound.task_id, bound.id, bound.ref, bound.task_ref].filter(Boolean).map(String));
|
|
3936
|
+
if (!keys.size) return '';
|
|
3937
|
+
const roots = [root];
|
|
3938
|
+
const common = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: root, encoding: 'utf8' });
|
|
3939
|
+
if (common.status === 0 && String(common.stdout || '').trim()) roots.push(path.dirname(String(common.stdout).trim()));
|
|
3940
|
+
for (const dir of roots) {
|
|
3941
|
+
const projection = readJson(path.join(dir, '.atris', 'state', 'tasks.projection.json'));
|
|
3942
|
+
const tasks = Array.isArray(projection?.tasks) ? projection.tasks : [];
|
|
3943
|
+
const task = tasks.find((row) => row && [row.id, row.display_id, row.legacy_ref].some((key) => key && keys.has(String(key))));
|
|
3944
|
+
if (!task) continue;
|
|
3945
|
+
const metadata = task.metadata && typeof task.metadata === 'object' ? task.metadata : {};
|
|
3946
|
+
if (metadata.approval_status && metadata.approval_status !== 'revise') return '';
|
|
3947
|
+
const events = (Array.isArray(task.events) ? task.events : []).filter((event) => event?.event_type === 'revision_requested');
|
|
3948
|
+
const note = metadata.human_revision_note || events[events.length - 1]?.payload?.note || '';
|
|
3949
|
+
return missionTasteSnippet(String(note), 600);
|
|
3950
|
+
}
|
|
3951
|
+
return '';
|
|
3952
|
+
}
|
|
3953
|
+
|
|
3913
3954
|
function missionTasteMemorySignals(text) {
|
|
3914
3955
|
const definitions = [
|
|
3915
3956
|
['plain_language', 'the operator asks for plain English', /\b(plain|jargon|feynman|understand|simple|clarify|clarity)\b/i],
|
|
@@ -8938,6 +8979,16 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
|
|
|
8938
8979
|
checkFeedback,
|
|
8939
8980
|
]
|
|
8940
8981
|
: [];
|
|
8982
|
+
let turnDown = '';
|
|
8983
|
+
try { turnDown = readBoundTaskTurnDown(mission, mission.worktree_root || mission.worktree?.path || cwd); } catch {}
|
|
8984
|
+
const turnDownLines = turnDown
|
|
8985
|
+
? [
|
|
8986
|
+
``,
|
|
8987
|
+
`## why the last try was turned down`,
|
|
8988
|
+
`the reviewer sent this task back. fix this before anything else.`,
|
|
8989
|
+
turnDown,
|
|
8990
|
+
]
|
|
8991
|
+
: [];
|
|
8941
8992
|
const preloadedContextLines = buildPreloadedMemberContextLines(mission, cwd);
|
|
8942
8993
|
const lines = [
|
|
8943
8994
|
`# Mission Tick ${tickIndex}/${maxTicks}`,
|
|
@@ -8953,6 +9004,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
|
|
|
8953
9004
|
`**Last tick:** ${mission.last_tick_at || 'never'}`,
|
|
8954
9005
|
...missionBudgetPromptLines(mission),
|
|
8955
9006
|
...checkFeedbackLines,
|
|
9007
|
+
...turnDownLines,
|
|
8956
9008
|
...(sharedProcess ? ['', sharedProcess] : []),
|
|
8957
9009
|
...preloadedContextLines,
|
|
8958
9010
|
``,
|
|
@@ -9241,7 +9293,16 @@ function spawnGenericRunnerTick(mission, opts) {
|
|
|
9241
9293
|
}
|
|
9242
9294
|
|
|
9243
9295
|
const startedAt = Date.now();
|
|
9244
|
-
const
|
|
9296
|
+
const progressMember = mission.owner || 'mission';
|
|
9297
|
+
// A codex run opens its shell script with the run marker as a no-op, so
|
|
9298
|
+
// member stop can prove the shell leading this group is this run. Only
|
|
9299
|
+
// names member stop accepts go into the shell line.
|
|
9300
|
+
const runToken = lastMessageFile && /^[A-Za-z0-9._-]+$/.test(progressMember) ? newRunToken() : '';
|
|
9301
|
+
const markerPrefix = runToken ? `: ${runMarker(progressMember, runToken)}; ` : '';
|
|
9302
|
+
// The trailing exit keeps a shell like dash from replacing itself with
|
|
9303
|
+
// codex, which would take the marker out of the process list.
|
|
9304
|
+
const markerSuffix = runToken ? '; exit $?' : '';
|
|
9305
|
+
const guardedCommand = `${markerPrefix}PATH=${shellQuote(gitGuard.pathPrefix)}:$PATH; export PATH; ${cmd}${markerSuffix}`;
|
|
9245
9306
|
const proc = spawn('sh', ['-lc', guardedCommand], {
|
|
9246
9307
|
cwd,
|
|
9247
9308
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -9249,9 +9310,11 @@ function spawnGenericRunnerTick(mission, opts) {
|
|
|
9249
9310
|
env: { ...gitGuard.env, ATRIS_DISPATCH_DEPTH: String(dispatchDepth.depth + 1) },
|
|
9250
9311
|
});
|
|
9251
9312
|
updateMissionLockOwner(missionLock, proc.pid, { phase: 'tick' });
|
|
9252
|
-
const progressMember = mission.owner || 'mission';
|
|
9253
9313
|
const seenItemIds = new Set();
|
|
9254
|
-
if (lastMessageFile)
|
|
9314
|
+
if (lastMessageFile) {
|
|
9315
|
+
const tag = runToken ? { pid: proc.pid, engine: 'codex', run: runToken } : {};
|
|
9316
|
+
appendProgress(progressMember, startedLine(mission.objective, tag), mission.id);
|
|
9317
|
+
}
|
|
9255
9318
|
let stdout = '';
|
|
9256
9319
|
let eventBuf = '';
|
|
9257
9320
|
let lastAgentMessage = '';
|
|
@@ -9671,6 +9734,20 @@ function cappedClaudeReceiptText(text, limit = 4000) {
|
|
|
9671
9734
|
return clean.slice(0, limit - 16).trimEnd() + '\n...[truncated]';
|
|
9672
9735
|
}
|
|
9673
9736
|
|
|
9737
|
+
// The receipt keeps a capped copy of the runner's report. When the report is
|
|
9738
|
+
// longer than that, the whole text goes to its own file beside the receipts
|
|
9739
|
+
// so nothing the member wrote is lost.
|
|
9740
|
+
function saveFullRunnerReport(mission, text, root = process.cwd()) {
|
|
9741
|
+
const clean = String(text || '').trim();
|
|
9742
|
+
if (!clean || cappedClaudeReceiptText(clean) === clean) return null;
|
|
9743
|
+
const runsDir = statePaths(root).runsDir;
|
|
9744
|
+
fs.mkdirSync(runsDir, { recursive: true });
|
|
9745
|
+
const safeTime = stampIso().replace(/[:.]/g, '-');
|
|
9746
|
+
const reportPath = path.join(runsDir, `mission-${mission.id}-${safeTime}-report.md`);
|
|
9747
|
+
fs.writeFileSync(reportPath, clean + '\n', 'utf8');
|
|
9748
|
+
return path.relative(root, reportPath);
|
|
9749
|
+
}
|
|
9750
|
+
|
|
9674
9751
|
async function parseAndValidateMissionRunPhase(args) {
|
|
9675
9752
|
const asJson = wantsJson(args);
|
|
9676
9753
|
const context = {
|
|
@@ -10296,6 +10373,7 @@ async function executeMissionRunTicksPhase(context) {
|
|
|
10296
10373
|
brief_id: claudeResult.brief_id || null,
|
|
10297
10374
|
summary: claudeResult.summary,
|
|
10298
10375
|
receipt_text: claudeResult.receipt_text,
|
|
10376
|
+
report_path: saveFullRunnerReport(mission, claudeResult.result, cwd),
|
|
10299
10377
|
stop_reason: claudeResult.stop_reason,
|
|
10300
10378
|
api_equivalent_estimate: claudeResult.api_equivalent_estimate,
|
|
10301
10379
|
duration_total_ms: claudeResult.duration_total_ms,
|
package/commands/task.js
CHANGED
|
@@ -253,6 +253,7 @@ atris task - durable local task state (SQLite, gitignored)
|
|
|
253
253
|
atris task archive <id> --reason "..." [--from-failed]
|
|
254
254
|
Sweep off-roadmap/duplicate work as archived (not failed);
|
|
255
255
|
--from-failed opts in to relabel a fail-closed row (never done)
|
|
256
|
+
atris task reopen <id> [--reason "..."] Send a done, failed, or archived task back to open
|
|
256
257
|
atris task clear-done [--before <days>] [--dry-run] [--json] Archive completed rows, oldest first
|
|
257
258
|
atris task prune-test-junk [--yes] [--vacuum] [--json]
|
|
258
259
|
Count rows test runs left from temp folders; --yes backs up the db, then removes them
|
|
@@ -6243,6 +6244,27 @@ function cmdAdd(args) {
|
|
|
6243
6244
|
const taskDb = getTaskDb();
|
|
6244
6245
|
const db = taskDb.open();
|
|
6245
6246
|
const ws = taskDb.workspaceRoot();
|
|
6247
|
+
// Filing a job that is already open or claimed hands back that job instead
|
|
6248
|
+
// of making a copy. Work already handed in for review can be filed again.
|
|
6249
|
+
const existing = taskDb.findUnfinishedTaskByTitle(db, { workspaceRoot: ws, title });
|
|
6250
|
+
if (existing) {
|
|
6251
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db);
|
|
6252
|
+
const task = compactTaskFromProjection(projection, existing.id);
|
|
6253
|
+
if (wantsJson(args)) {
|
|
6254
|
+
printJson({
|
|
6255
|
+
ok: true,
|
|
6256
|
+
action: 'reused',
|
|
6257
|
+
task_id: existing.id,
|
|
6258
|
+
inserted: false,
|
|
6259
|
+
operator_title_warning: null,
|
|
6260
|
+
projection_path: outPath,
|
|
6261
|
+
task,
|
|
6262
|
+
});
|
|
6263
|
+
return;
|
|
6264
|
+
}
|
|
6265
|
+
console.log(`${taskRef(task)}\t${existing.title}\t(already on the list, reused)`);
|
|
6266
|
+
return;
|
|
6267
|
+
}
|
|
6246
6268
|
const operatorTitleWarning = warnIfTaskTitleNeedsOperatorWhy(title);
|
|
6247
6269
|
// Generation throttle, the named root cause is generation > human-review rate. An AGENT cannot keep
|
|
6248
6270
|
// minting tasks while a wall of certified-but-unaccepted work waits; that treadmill is what accept-group
|
|
@@ -9738,6 +9760,45 @@ function cmdArchive(args) {
|
|
|
9738
9760
|
}
|
|
9739
9761
|
}
|
|
9740
9762
|
|
|
9763
|
+
// Send a done, failed, or archived task back to open so it can be claimed
|
|
9764
|
+
// again. Review sign-offs are cleared; the old status and reason are kept.
|
|
9765
|
+
function cmdReopen(args) {
|
|
9766
|
+
const pos = positional(args);
|
|
9767
|
+
const id = pos[0];
|
|
9768
|
+
if (!id) failTask('atris task reopen', 'missing_id', 'task id required');
|
|
9769
|
+
const reason = textFlag(args, ['--reason']) || 'reopened';
|
|
9770
|
+
const taskDb = getTaskDb();
|
|
9771
|
+
const db = taskDb.open();
|
|
9772
|
+
const taskId = requireTaskId(taskDb, db, id, 'atris task reopen');
|
|
9773
|
+
const actor = String(flag(args, '--as') || DEFAULT_OWNER);
|
|
9774
|
+
const result = taskDb.reopenTask(db, { id: taskId, actor, reason });
|
|
9775
|
+
if (!result.reopened) {
|
|
9776
|
+
const detail = `reopen failed: ${taskId} ${result.reason}`;
|
|
9777
|
+
if (wantsJson(args)) {
|
|
9778
|
+
printJson({ ok: false, command: 'atris task reopen', reason: result.reason, task_id: taskId, detail });
|
|
9779
|
+
process.exit(1);
|
|
9780
|
+
}
|
|
9781
|
+
console.error(detail);
|
|
9782
|
+
process.exit(1);
|
|
9783
|
+
}
|
|
9784
|
+
const reopenedFrom = result.row && result.row.metadata && result.row.metadata.reopened_from || null;
|
|
9785
|
+
const { projection, outPath } = writeDefaultProjection(taskDb, db);
|
|
9786
|
+
const task = compactTaskFromProjection(projection, taskId);
|
|
9787
|
+
if (wantsJson(args)) {
|
|
9788
|
+
printJson({
|
|
9789
|
+
ok: true,
|
|
9790
|
+
action: 'reopened',
|
|
9791
|
+
task_id: taskId,
|
|
9792
|
+
reopened_from: reopenedFrom,
|
|
9793
|
+
reason,
|
|
9794
|
+
projection_path: outPath,
|
|
9795
|
+
task,
|
|
9796
|
+
});
|
|
9797
|
+
return;
|
|
9798
|
+
}
|
|
9799
|
+
console.log(`reopened ${taskRef(task)}${reopenedFrom ? ` (was ${reopenedFrom})` : ''}: ${reason}`);
|
|
9800
|
+
}
|
|
9801
|
+
|
|
9741
9802
|
function taskCompletionTime(row) {
|
|
9742
9803
|
const doneAt = Number(row && row.done_at);
|
|
9743
9804
|
if (Number.isFinite(doneAt) && doneAt > 0) return doneAt;
|
|
@@ -13475,6 +13536,7 @@ async function runTaskCommand(args) {
|
|
|
13475
13536
|
case 'finish': return cmdFinish(rest);
|
|
13476
13537
|
case 'fail': return cmdDone([...rest, '--failed']);
|
|
13477
13538
|
case 'archive': return cmdArchive(rest);
|
|
13539
|
+
case 'reopen': return cmdReopen(rest);
|
|
13478
13540
|
case 'clear-done': return cmdClearDone(rest);
|
|
13479
13541
|
case 'prune-test-junk': return cmdPruneTestJunk(rest);
|
|
13480
13542
|
case 'keep': return cmdKeep(rest);
|
|
@@ -13521,7 +13583,7 @@ const MUTATING_TASK_COMMANDS = new Set([
|
|
|
13521
13583
|
'continue-work', 'continue', 'chat', 'note', 'say', 'retitle', 'tag', 'tags', 'step',
|
|
13522
13584
|
'ready', 'result', 'accept', 'landing', 'land-review', 'auto-accept-certified',
|
|
13523
13585
|
'auto-accept', 'sweep', 'audit', 'certify-verified', 'accept-group', 'revise',
|
|
13524
|
-
'done', 'finish', 'fail', 'archive', 'clear-done', 'reap-mission-blockers', 'reap-blockers', 'relabel-archived', 'review', 'import',
|
|
13586
|
+
'done', 'finish', 'fail', 'archive', 'reopen', 'clear-done', 'reap-mission-blockers', 'reap-blockers', 'relabel-archived', 'review', 'import',
|
|
13525
13587
|
'setup', 'review-lane-act', 'review-act', 'act-review', 'review-lane-loop',
|
|
13526
13588
|
'review-loop', 'loop-review', 'review-lane-run', 'review-run', 'run-review',
|
|
13527
13589
|
]);
|
package/commands/worktree.js
CHANGED
|
@@ -159,6 +159,26 @@ function branchName(member, task, now = new Date()) {
|
|
|
159
159
|
return `codex/${slugify(member, 'member', 24)}-${slugify(task, 'task', 36)}-${stamp(now)}`;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
// The newest earlier run of this same member+job, if its commits never reached
|
|
163
|
+
// the base, so a retry continues that work instead of redoing it. Only the
|
|
164
|
+
// newest run counts: once it landed or did nothing, anything older is stale.
|
|
165
|
+
function lastRunBranch(root, owner, task, base, skip) {
|
|
166
|
+
const prefix = `codex/${slugify(owner, 'member', 24)}-${slugify(task, 'task', 36)}-`;
|
|
167
|
+
const listed = runGit(['for-each-ref', '--format=%(refname:short)', `refs/heads/${prefix}*`], { cwd: root, check: false });
|
|
168
|
+
if (listed.status !== 0) return null;
|
|
169
|
+
// Only exact runs of this job (stamp plus optional same-second number), so
|
|
170
|
+
// "fix-login" never matches "fix-login-page".
|
|
171
|
+
const runs = listed.stdout.split('\n').map((name) => name.trim())
|
|
172
|
+
.map((name) => ({ name, run: name.slice(prefix.length).match(/^(\d{8}-\d{6})(?:-(\d+))?$/) }))
|
|
173
|
+
.filter(({ name, run }) => run && name !== skip)
|
|
174
|
+
.sort((a, b) => b.run[1].localeCompare(a.run[1]) || Number(b.run[2] || 1) - Number(a.run[2] || 1));
|
|
175
|
+
if (!runs.length) return null;
|
|
176
|
+
const newest = runs[0].name;
|
|
177
|
+
const cherry = runGit(['cherry', base, newest], { cwd: root, check: false });
|
|
178
|
+
if (cherry.status !== 0) return null;
|
|
179
|
+
return cherry.stdout.split('\n').some((line) => line.startsWith('+')) ? newest : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
162
182
|
function defaultWorktreePath(root, member, task, now = new Date()) {
|
|
163
183
|
const name = `${slugify(member, 'member', 24)}-${slugify(task, 'task', 36)}-${stamp(now)}`;
|
|
164
184
|
return path.join(path.dirname(root), '.agent-worktrees', path.basename(root), name);
|
|
@@ -529,8 +549,18 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
|
|
|
529
549
|
reapedBeforeStart = cleaned.removed;
|
|
530
550
|
cachePrunedBeforeStart = cleaned.cachePruned;
|
|
531
551
|
} catch {}
|
|
532
|
-
|
|
533
|
-
|
|
552
|
+
let branch = branchOverride || branchName(owner, task, now);
|
|
553
|
+
let target = path.resolve(pathOverride || defaultWorktreePath(root, owner, task, now));
|
|
554
|
+
// Two runs of the same job in the same second get the same name; number
|
|
555
|
+
// the later one instead of failing it.
|
|
556
|
+
if (!pathOverride && !branchOverride) {
|
|
557
|
+
const baseBranch = branch;
|
|
558
|
+
const baseTarget = target;
|
|
559
|
+
for (let n = 2; fs.existsSync(target) && n < 100; n += 1) {
|
|
560
|
+
branch = `${baseBranch}-${n}`;
|
|
561
|
+
target = `${baseTarget}-${n}`;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
534
564
|
// Agent/member worktrees always cut from the mainline: honoring the
|
|
535
565
|
// launcher checkout's upstream let a dispatch inherit another session's
|
|
536
566
|
// in-progress feature branch (2026-07-09: an auth fix arrived carrying 13
|
|
@@ -540,9 +570,15 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
|
|
|
540
570
|
if (fs.existsSync(target)) throw new Error(`worktree path already exists: ${target}`);
|
|
541
571
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
542
572
|
const staleBase = refreshRemoteRef(root, checkoutBase);
|
|
573
|
+
// A retry of the same job starts from the last run's unlanded commits; it
|
|
574
|
+
// still ships to the mainline.
|
|
575
|
+
const resumedFrom = (!pathOverride && !branchOverride)
|
|
576
|
+
? lastRunBranch(root, owner, task, checkoutBase, branch)
|
|
577
|
+
: null;
|
|
578
|
+
const startPoint = resumedFrom || checkoutBase;
|
|
543
579
|
const ref = `refs/heads/${branch}`;
|
|
544
580
|
const existed = runGit(['show-ref', '--verify', '--quiet', ref], { cwd: root, check: false }).status !== 1;
|
|
545
|
-
const baseCommit = runGit(['rev-parse', '--verify', `${
|
|
581
|
+
const baseCommit = runGit(['rev-parse', '--verify', `${startPoint}^{commit}`], { cwd: root, check: false });
|
|
546
582
|
const startCommit = baseCommit.status === 0 ? baseCommit.stdout.trim() : '';
|
|
547
583
|
// With no local copy the start fails on its own; only a real cut gets the warning.
|
|
548
584
|
if (staleBase && startCommit) {
|
|
@@ -562,7 +598,7 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
|
|
|
562
598
|
if (!fs.existsSync(target)) runGit(['update-ref', '-d', ref, startCommit], { cwd: root });
|
|
563
599
|
};
|
|
564
600
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
565
|
-
const added = runGit(['worktree', 'add', '-b', branch, target,
|
|
601
|
+
const added = runGit(['worktree', 'add', '-b', branch, target, startPoint], { cwd: root, check: false });
|
|
566
602
|
if (added.status === 0) break;
|
|
567
603
|
// Git can create the branch before failing to write its tracking config.
|
|
568
604
|
// Drop only that untouched ref, never an existing branch or a checkout.
|
|
@@ -587,6 +623,7 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
|
|
|
587
623
|
branch,
|
|
588
624
|
base: shipBase,
|
|
589
625
|
checkout_base: checkoutBase,
|
|
626
|
+
resumed_from: resumedFrom,
|
|
590
627
|
workspace_root: root,
|
|
591
628
|
created_at: now.toISOString(),
|
|
592
629
|
}, null, 2) + '\n',
|
|
@@ -596,7 +633,7 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
|
|
|
596
633
|
discardFailedStart();
|
|
597
634
|
throw error;
|
|
598
635
|
}
|
|
599
|
-
return { path: target, branch, base: shipBase, checkoutBase, owner, reapedBeforeStart, cachePrunedBeforeStart };
|
|
636
|
+
return { path: target, branch, base: shipBase, checkoutBase, resumedFrom, owner, reapedBeforeStart, cachePrunedBeforeStart };
|
|
600
637
|
}
|
|
601
638
|
|
|
602
639
|
function startWorktree(args) {
|
package/lib/engine-ask.js
CHANGED
|
@@ -415,6 +415,7 @@ function runAskProcess(invocation, {
|
|
|
415
415
|
killProcess = process.kill.bind(process),
|
|
416
416
|
signal = null,
|
|
417
417
|
onOutputChunk = null,
|
|
418
|
+
env = null,
|
|
418
419
|
} = {}) {
|
|
419
420
|
return new Promise((resolve) => {
|
|
420
421
|
const startedAt = Date.now();
|
|
@@ -502,7 +503,7 @@ function runAskProcess(invocation, {
|
|
|
502
503
|
try {
|
|
503
504
|
child = spawnProcess(invocation.bin, invocation.args, {
|
|
504
505
|
cwd,
|
|
505
|
-
env: buildAskSpawnEnv(),
|
|
506
|
+
env: buildAskSpawnEnv(env || invocation.env || process.env),
|
|
506
507
|
detached: process.platform !== 'win32',
|
|
507
508
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
508
509
|
});
|