atris 3.73.0 → 3.74.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/commands/member.js +141 -7
- package/commands/mission.js +156 -65
- package/commands/slop.js +8 -2
- package/lib/falsifier-probe.js +2 -1
- package/lib/gm-bet.js +84 -25
- package/lib/gm-phone-page.js +386 -98
- package/lib/gm-phone.js +27 -3
- package/lib/handoff-wake.js +180 -0
- package/lib/member-up.js +18 -3
- package/lib/mission-protected-lane.js +38 -6
- package/lib/spawn-env.js +92 -3
- package/lib/task-proof.js +2 -1
- package/lib/task-receipt.js +3 -2
- package/lib/yes-lock.js +633 -81
- package/lib/zen-fleet.js +2 -2
- package/lib/zen-workspace.js +6 -1
- package/lib/zen.js +9 -0
- package/package.json +2 -1
package/commands/member.js
CHANGED
|
@@ -777,6 +777,8 @@ const MEMBER_RUN_START_VALUE_FLAGS = [
|
|
|
777
777
|
'--truth',
|
|
778
778
|
'--proof',
|
|
779
779
|
'--base',
|
|
780
|
+
'--yes-lock',
|
|
781
|
+
'--yes-lock-parent',
|
|
780
782
|
];
|
|
781
783
|
|
|
782
784
|
const MEMBER_RUN_START_BOOLEAN_FLAGS = [
|
|
@@ -1000,6 +1002,12 @@ function buildMemberRunStartArgs(owner, missionText, args = [], cwd = process.cw
|
|
|
1000
1002
|
pushFlagValue(startArgs, args, '--minutes');
|
|
1001
1003
|
pushFlagValue(startArgs, args, '--hours');
|
|
1002
1004
|
pushFlagValue(startArgs, args, '--base');
|
|
1005
|
+
// a run inside a woken member's run keeps its seal: mission start reads it
|
|
1006
|
+
// from the environment, never from these words
|
|
1007
|
+
if (!process.env.ATRIS_HANDOFF_YES_LOCK) {
|
|
1008
|
+
pushFlagValue(startArgs, args, '--yes-lock');
|
|
1009
|
+
pushFlagValue(startArgs, args, '--yes-lock-parent');
|
|
1010
|
+
}
|
|
1003
1011
|
// Member runs choose and drive work immediately, so an absent check must not
|
|
1004
1012
|
// leave autonomous callers with a mission that never ran. A check inferred
|
|
1005
1013
|
// from the mission text counts as named.
|
|
@@ -1297,6 +1305,10 @@ function memberRunWith(name, args, { quiet = false, closeTaskIds = [] } = {}) {
|
|
|
1297
1305
|
console.error(`${name} is parked; running anyway`);
|
|
1298
1306
|
}
|
|
1299
1307
|
} catch { /* an unreadable card is requireMemberDir's problem, not this notice's */ }
|
|
1308
|
+
if (args.filter(require('./mission').isYesLockWord).length > 1) {
|
|
1309
|
+
return printMemberRunResult({ started: false, state: null, mission_id: null,
|
|
1310
|
+
error: 'member run got more than one --yes-lock, so it is not clear which seal applies; nothing started' }, asJson, { quiet });
|
|
1311
|
+
}
|
|
1300
1312
|
const requestedVerifier = readFlag(args, '--verify', '') || memberRunConfiguredVerifier(paths);
|
|
1301
1313
|
const missionText = memberRunMissionText(args);
|
|
1302
1314
|
const hasMissionOverride = Boolean(readFlag(args, '--mission', '') || readFlag(args, '--mission-id', ''));
|
|
@@ -1362,7 +1374,7 @@ function memberRunWith(name, args, { quiet = false, closeTaskIds = [] } = {}) {
|
|
|
1362
1374
|
const missionWordIndices = missionText && !hasMissionOverride ? new Set(memberRunMissionWordIndices(args)) : new Set();
|
|
1363
1375
|
const executionArgs = args.filter((_, index) => !missionWordIndices.has(index));
|
|
1364
1376
|
const runArgs = stripKnownFlags(executionArgs,
|
|
1365
|
-
['--mission', '--mission-id', '--minutes', '--hours', '--industry', '--domain', '--value', '--outcome', '--truth', '--proof', '--base'],
|
|
1377
|
+
['--mission', '--mission-id', '--minutes', '--hours', '--industry', '--domain', '--value', '--outcome', '--truth', '--proof', '--base', '--yes-lock', '--yes-lock-parent'],
|
|
1366
1378
|
['--worktree', '--shared-checkout', '--no-worktree', '--choose-work', '--auto', '--ignite', '--wait', '--sync']);
|
|
1367
1379
|
if (ignite && !hasFlag(runArgs, '--complete-on-pass')) runArgs.push('--complete-on-pass');
|
|
1368
1380
|
if (memberRunWantsReferee(runArgs, ignite)) runArgs.push('--referee');
|
|
@@ -1374,6 +1386,16 @@ function memberRunWith(name, args, { quiet = false, closeTaskIds = [] } = {}) {
|
|
|
1374
1386
|
const caller = callerRuntime(args);
|
|
1375
1387
|
const runnerSwap = memberRunRunnerSwap(mission, args, caller);
|
|
1376
1388
|
if (runnerSwap) runArgs.push('--engine', runnerSwap.to);
|
|
1389
|
+
// A run in the foreground (wake, go, run by name) is noted on the start
|
|
1390
|
+
// list under the shared lock, so member up and handoffs count it while it
|
|
1391
|
+
// works. A detached run is noted by whoever started it.
|
|
1392
|
+
if (!hasFlag(runArgs, '--detach') && !hasFlag(runArgs, '--dry-run')) {
|
|
1393
|
+
try {
|
|
1394
|
+
const stateDir = path.join(require('./mission').resolveMemberWorkspaceRoot(process.cwd()), '.atris', 'state');
|
|
1395
|
+
require('../lib/handoff-wake').withWakeReservation(stateDir,
|
|
1396
|
+
({ record }) => record({ member: name, mission_id: mission.id, pid: process.pid }), { waitMs: 30000 });
|
|
1397
|
+
} catch {}
|
|
1398
|
+
}
|
|
1377
1399
|
const run = memberRunCli(['mission', 'run', mission.id, ...runArgs], cwd, { live: !asJson && !quiet, missionId: mission.id });
|
|
1378
1400
|
const payload = run.payload || {};
|
|
1379
1401
|
const finalMission = payload.mission || require('./mission').resolveMission(mission.id, cwd) || mission;
|
|
@@ -11134,6 +11156,8 @@ async function memberUp(...args) {
|
|
|
11134
11156
|
pickTask: (name, taken) => memberUpPick(root, name, taken),
|
|
11135
11157
|
writeNextJob: (name, opts) => memberUpWriteNextJob(root, name, opts),
|
|
11136
11158
|
spawn: (name, onExit, onClaim) => memberUpSpawn(root, name, onExit, onClaim),
|
|
11159
|
+
// the start lock handoffs share, so both count what the other started
|
|
11160
|
+
reserve: (fn) => require('../lib/handoff-wake').withWakeReservation(path.join(root, '.atris', 'state'), fn),
|
|
11137
11161
|
now: () => Date.now(),
|
|
11138
11162
|
jobOf: (name) => memberGoJob(name, root),
|
|
11139
11163
|
waitingTasks: () => memberUpWaiting(root),
|
|
@@ -11352,10 +11376,108 @@ function collectMemberOneOnOnes(root = process.cwd(), slugs = []) {
|
|
|
11352
11376
|
return queue;
|
|
11353
11377
|
}
|
|
11354
11378
|
|
|
11379
|
+
// The env a woken member starts with: its own plain-terminal run, not part of
|
|
11380
|
+
// the session or mission driver that handed off (those markers would turn the
|
|
11381
|
+
// run into proof-only mode, a native-goal wait, or a child of the old driver).
|
|
11382
|
+
const HANDOFF_WAKE_SCRUB_ENV = [
|
|
11383
|
+
'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CODEX_SANDBOX', 'CURSOR_AGENT', 'DEVIN_SESSION_ID',
|
|
11384
|
+
'CODEX_THREAD_ID', 'CODEX_SESSION_ID', 'ATRIS_AGENT_ID',
|
|
11385
|
+
'ATRIS_MISSION_DRIVER_DETACHED', 'ATRIS_MISSION_DRIVER_PARENT_PID',
|
|
11386
|
+
];
|
|
11387
|
+
|
|
11388
|
+
// Start the receiver of a handoff on the handed-off step: the same bounded
|
|
11389
|
+
// work step `atris member wake <name>` runs (member run, 15 minutes), but
|
|
11390
|
+
// detached, so the member handing off is not held while the next one works.
|
|
11391
|
+
function startHandoffReceiver(root, to, step, parentMissionId = null) {
|
|
11392
|
+
const env = { ...process.env, ATRIS_CALLER_RUNTIME: 'terminal' };
|
|
11393
|
+
for (const name of HANDOFF_WAKE_SCRUB_ENV) delete env[name];
|
|
11394
|
+
// The handing-off mission's git guard (a temp folder of wrappers first on
|
|
11395
|
+
// PATH) belongs to that mission; the woken run sets up its own.
|
|
11396
|
+
env.PATH = String(env.PATH || '').split(path.delimiter)
|
|
11397
|
+
.filter((dir) => !path.basename(dir).startsWith(require('../lib/mission-protected-lane').GUARD_DIR_PREFIX))
|
|
11398
|
+
.join(path.delimiter);
|
|
11399
|
+
// Every woken run is under the fixed handoff seal, whatever handed off:
|
|
11400
|
+
// outward commands and merging to master wait for the owner's yes. The
|
|
11401
|
+
// parent only says where its yes cards show.
|
|
11402
|
+
// The seal goes by environment only, never on the command line the step
|
|
11403
|
+
// text shares, and every job the woken run starts inherits it. A step that
|
|
11404
|
+
// starts with "-" is quoted so it can never read as an option.
|
|
11405
|
+
env.ATRIS_HANDOFF_YES_LOCK = JSON.stringify(require('../lib/yes-lock').handoffSeal());
|
|
11406
|
+
if (parentMissionId) env.ATRIS_HANDOFF_YES_LOCK_PARENT = parentMissionId;
|
|
11407
|
+
else delete env.ATRIS_HANDOFF_YES_LOCK_PARENT;
|
|
11408
|
+
const stepText = /^\s*-/.test(String(step)) ? `step: ${String(step).trim()}` : String(step);
|
|
11409
|
+
const res = require('child_process').spawnSync(process.execPath, [
|
|
11410
|
+
path.join(__dirname, '..', 'bin', 'atris.js'),
|
|
11411
|
+
'member', 'run', to, stepText, '--minutes', '15', '--no-verify', '--detach', '--json',
|
|
11412
|
+
], { cwd: root, env, encoding: 'utf8', timeout: 120000 });
|
|
11413
|
+
let payload = null;
|
|
11414
|
+
const lines = String(res.stdout || '').trim().split('\n');
|
|
11415
|
+
for (let i = lines.length - 1; i >= 0 && !payload; i -= 1) {
|
|
11416
|
+
try { payload = JSON.parse(lines.slice(i).join('\n')); } catch {}
|
|
11417
|
+
}
|
|
11418
|
+
const ok = res.status === 0 && Boolean(payload && payload.ok !== false && payload.mission_id);
|
|
11419
|
+
return {
|
|
11420
|
+
ok,
|
|
11421
|
+
mission_id: payload ? payload.mission_id || null : null,
|
|
11422
|
+
pid: payload ? payload.pid || null : null,
|
|
11423
|
+
worktree: payload && payload.worktree ? payload.worktree.path || null : null,
|
|
11424
|
+
error: ok ? null : String((payload && payload.error) || res.stderr || (res.error && res.error.message) || `member run exited ${res.status}`),
|
|
11425
|
+
};
|
|
11426
|
+
}
|
|
11427
|
+
|
|
11428
|
+
// Whether to wake the receiver, and the outcome. Under the test runner a
|
|
11429
|
+
// handoff only records unless ATRIS_HANDOFF_WAKE=1 asks, so suites that hand
|
|
11430
|
+
// off never start a real engine by accident.
|
|
11431
|
+
function wakeOnHandoff(handoff, root, args) {
|
|
11432
|
+
const testRun = Boolean(process.env.NODE_TEST_CONTEXT) && process.env.ATRIS_HANDOFF_WAKE !== '1';
|
|
11433
|
+
if (hasFlag(args, '--no-wake') || testRun) {
|
|
11434
|
+
return {
|
|
11435
|
+
status: 'not_started',
|
|
11436
|
+
reason: 'not_asked',
|
|
11437
|
+
text: `${handoff.to} was not started; the handoff is only written down. to start it: atris member wake ${handoff.to}`,
|
|
11438
|
+
};
|
|
11439
|
+
}
|
|
11440
|
+
const { wakeHandoffReceiver, withWakeReservation } = require('../lib/handoff-wake');
|
|
11441
|
+
const parent = handoffParentMission(handoff, root);
|
|
11442
|
+
const outcome = wakeHandoffReceiver(handoff, {
|
|
11443
|
+
listMembers: () => memberUpTeam(root),
|
|
11444
|
+
awake: (name) => {
|
|
11445
|
+
try { return require('../lib/member-switches').isMemberAwake(name, root); } catch { return true; }
|
|
11446
|
+
},
|
|
11447
|
+
engine: (name) => require('../lib/member-engine').memberRosterEngine(name, root),
|
|
11448
|
+
start: (name, step) => startHandoffReceiver(root, name, step, parent),
|
|
11449
|
+
reserve: (fn) => withWakeReservation(path.join(root, '.atris', 'state'), fn),
|
|
11450
|
+
});
|
|
11451
|
+
return outcome.status === 'started' ? { ...outcome, yes_lock: 'handoff', yes_lock_parent: parent } : outcome;
|
|
11452
|
+
}
|
|
11453
|
+
|
|
11454
|
+
// The job a handoff came from, only to show the woken run's yes cards on it:
|
|
11455
|
+
// the mission whose guard this command runs under (a handoff from inside a
|
|
11456
|
+
// mission tick), else the job a [bet-...] tag in the step names. null when
|
|
11457
|
+
// neither is found; what the woken run holds never depends on it.
|
|
11458
|
+
function handoffParentMission(handoff, root) {
|
|
11459
|
+
const fromGuard = require('../lib/mission-protected-lane').guardMissionOnPath(process.env.PATH);
|
|
11460
|
+
if (fromGuard) return fromGuard;
|
|
11461
|
+
const bets = Array.from(String(handoff.summary || '').matchAll(/\[(bet-[A-Za-z0-9._-]+)\]/g)).map((m) => m[1]);
|
|
11462
|
+
if (!bets.length) return null;
|
|
11463
|
+
const dir = path.join(require('../lib/state-home').atrisHome(), 'state', 'yes-locks');
|
|
11464
|
+
let files = [];
|
|
11465
|
+
try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')); } catch {}
|
|
11466
|
+
for (const name of files) {
|
|
11467
|
+
try {
|
|
11468
|
+
const data = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
|
|
11469
|
+
if (data && data.mission_id && bets.includes(data.bet_id)) return String(data.mission_id);
|
|
11470
|
+
} catch {}
|
|
11471
|
+
}
|
|
11472
|
+
return null;
|
|
11473
|
+
}
|
|
11474
|
+
|
|
11355
11475
|
function memberHandoff(from, to, ...args) {
|
|
11356
11476
|
if (from === '--help' || from === '-h' || hasFlag(args, '--help') || hasFlag(args, '-h')) {
|
|
11357
|
-
console.log('Usage: atris member handoff [<from> <to> "<slice>"] [--json]');
|
|
11477
|
+
console.log('Usage: atris member handoff [<from> <to> "<slice>"] [--no-wake] [--json]');
|
|
11358
11478
|
console.log('Passes a completed slice from one member to the next specialist and queues their next_push.');
|
|
11479
|
+
console.log('The next member is started on the slice when it is not already working, unless the team is at its limit.');
|
|
11480
|
+
console.log('--no-wake only writes the handoff down.');
|
|
11359
11481
|
return;
|
|
11360
11482
|
}
|
|
11361
11483
|
const root = process.cwd();
|
|
@@ -11374,17 +11496,21 @@ function memberHandoff(from, to, ...args) {
|
|
|
11374
11496
|
}
|
|
11375
11497
|
const targetTo = sliceWords[0];
|
|
11376
11498
|
const summary = sliceWords.slice(1).join(' ').trim();
|
|
11377
|
-
const { recordMemberHandoff } = require('./mission');
|
|
11378
|
-
const
|
|
11379
|
-
if (!
|
|
11499
|
+
const { recordMemberHandoff, updateMemberHandoff, resolveMemberWorkspaceRoot } = require('./mission');
|
|
11500
|
+
const recorded = recordMemberHandoff(from, targetTo, summary, root);
|
|
11501
|
+
if (!recorded) {
|
|
11380
11502
|
console.error(`Could not record handoff from ${from} to ${targetTo}. Check member names.`);
|
|
11381
11503
|
process.exitCode = 1;
|
|
11382
11504
|
return null;
|
|
11383
11505
|
}
|
|
11506
|
+
const parentRoot = resolveMemberWorkspaceRoot(root);
|
|
11507
|
+
// the stored summary is cut at 180 characters; the wake gets the whole step
|
|
11508
|
+
const wake = { ...wakeOnHandoff({ ...recorded, summary }, parentRoot, args), at: new Date().toISOString() };
|
|
11509
|
+
const handoff = updateMemberHandoff(recorded.id, { wake }, parentRoot) || { ...recorded, wake };
|
|
11384
11510
|
const lines = [
|
|
11385
11511
|
`Handoff recorded: ${handoff.from} -> ${handoff.to}`,
|
|
11386
11512
|
`Slice: ${handoff.summary}`,
|
|
11387
|
-
|
|
11513
|
+
wake.text,
|
|
11388
11514
|
];
|
|
11389
11515
|
printJsonOrText({ ok: true, action: 'member_handoff', handoff }, lines, asJson);
|
|
11390
11516
|
return handoff;
|
|
@@ -11773,10 +11899,18 @@ async function memberCommand(subcommand, ...args) {
|
|
|
11773
11899
|
// more often than a job. Starting a paid run on it costs money and
|
|
11774
11900
|
// does nothing useful, so name the miss and stop. Jobs keep working
|
|
11775
11901
|
// as more than one word, or through `atris member run "<job>"`.
|
|
11776
|
-
const
|
|
11902
|
+
const extraWordList = args.filter((a) => !String(a).startsWith('-'));
|
|
11903
|
+
const extraWords = extraWordList.length;
|
|
11777
11904
|
if (!/\s/.test(subcommand.trim()) && extraWords === 0) {
|
|
11778
11905
|
return memberUnknownWord(subcommand);
|
|
11779
11906
|
}
|
|
11907
|
+
// `atris member inbox validator`: an unknown word followed by a member
|
|
11908
|
+
// name is a command shape (verb, then who), not a job. It once started
|
|
11909
|
+
// a mission called "inbox validator"; name the miss instead.
|
|
11910
|
+
if (!/\s/.test(subcommand.trim()) && extraWords === 1
|
|
11911
|
+
&& isKnownMemberSlug(String(extraWordList[0]).trim(), process.cwd())) {
|
|
11912
|
+
return memberUnknownWord(subcommand);
|
|
11913
|
+
}
|
|
11780
11914
|
return memberSteerOrDispatch(subcommand, ...args);
|
|
11781
11915
|
}
|
|
11782
11916
|
console.log('run "<job>" pick the right member and run it: atris member run "fix the flaky login test"');
|
package/commands/mission.js
CHANGED
|
@@ -1905,15 +1905,58 @@ function recordMemberHandoff(fromMember, toMember, sliceSummary, root = process.
|
|
|
1905
1905
|
next_command: `atris member go ${to}`,
|
|
1906
1906
|
created_at: now,
|
|
1907
1907
|
};
|
|
1908
|
+
const handoffsFile = path.join(parentRoot, '.atris', 'state', 'member-handoffs.jsonl');
|
|
1909
|
+
try {
|
|
1910
|
+
withHandoffsLock(handoffsFile, () => fs.appendFileSync(handoffsFile, `${JSON.stringify(handoff)}\n`, 'utf8'));
|
|
1911
|
+
} catch {
|
|
1912
|
+
return null;
|
|
1913
|
+
}
|
|
1908
1914
|
try {
|
|
1909
|
-
const handoffsFile = path.join(parentRoot, '.atris', 'state', 'member-handoffs.jsonl');
|
|
1910
|
-
fs.mkdirSync(path.dirname(handoffsFile), { recursive: true });
|
|
1911
|
-
fs.appendFileSync(handoffsFile, `${JSON.stringify(handoff)}\n`, 'utf8');
|
|
1912
1915
|
autoQueueMemberNextPush(to, `${from} handoff: ${cleanSummary}`, `Baton pass from ${from}`, parentRoot);
|
|
1913
1916
|
} catch {}
|
|
1914
1917
|
return handoff;
|
|
1915
1918
|
}
|
|
1916
1919
|
|
|
1920
|
+
// Every write to member-handoffs.jsonl (append or whole-file rewrite) holds
|
|
1921
|
+
// one lock file beside it, so concurrent handoffs never lose a row.
|
|
1922
|
+
// (Codex review round 3 on #1483.)
|
|
1923
|
+
function withHandoffsLock(handoffsFile, fn) {
|
|
1924
|
+
fs.mkdirSync(path.dirname(handoffsFile), { recursive: true });
|
|
1925
|
+
const lockFile = path.join(path.dirname(handoffsFile), 'member-handoffs.lock');
|
|
1926
|
+
return require('../lib/handoff-wake').withFileLock(lockFile, fn, { staleMs: 60 * 1000, waitMs: 30 * 1000 });
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
function rewriteHandoffs(handoffsFile, lines) {
|
|
1930
|
+
const tmp = `${handoffsFile}.${process.pid}.tmp`;
|
|
1931
|
+
fs.writeFileSync(tmp, `${lines.join('\n')}\n`, 'utf8');
|
|
1932
|
+
fs.renameSync(tmp, handoffsFile);
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
// Merge `patch` into the newest handoff row with this id. Returns the
|
|
1936
|
+
// updated row, or null when it is not on file.
|
|
1937
|
+
function updateMemberHandoff(handoffId, patch, root = process.cwd()) {
|
|
1938
|
+
const id = String(handoffId || '');
|
|
1939
|
+
if (!id || !patch) return null;
|
|
1940
|
+
const handoffsFile = path.join(resolveMemberWorkspaceRoot(root), '.atris', 'state', 'member-handoffs.jsonl');
|
|
1941
|
+
try {
|
|
1942
|
+
return withHandoffsLock(handoffsFile, () => {
|
|
1943
|
+
const lines = fs.readFileSync(handoffsFile, 'utf8').split(/\r?\n/).filter((l) => l.trim());
|
|
1944
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
1945
|
+
let row = null;
|
|
1946
|
+
try { row = JSON.parse(lines[i]); } catch {}
|
|
1947
|
+
if (!row || row.id !== id) continue;
|
|
1948
|
+
const updated = { ...row, ...patch };
|
|
1949
|
+
lines[i] = JSON.stringify(updated);
|
|
1950
|
+
rewriteHandoffs(handoffsFile, lines);
|
|
1951
|
+
return updated;
|
|
1952
|
+
}
|
|
1953
|
+
return null;
|
|
1954
|
+
});
|
|
1955
|
+
} catch {
|
|
1956
|
+
return null;
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1917
1960
|
function closeMemberHandoffsForReceiver(receiverMember, root = process.cwd()) {
|
|
1918
1961
|
const receiver = String(receiverMember || '').trim().toLowerCase();
|
|
1919
1962
|
if (!receiver) return 0;
|
|
@@ -1921,28 +1964,30 @@ function closeMemberHandoffsForReceiver(receiverMember, root = process.cwd()) {
|
|
|
1921
1964
|
const handoffsFile = path.join(parentRoot, '.atris', 'state', 'member-handoffs.jsonl');
|
|
1922
1965
|
if (!fs.existsSync(handoffsFile)) return 0;
|
|
1923
1966
|
try {
|
|
1924
|
-
|
|
1925
|
-
let closedCount = 0;
|
|
1926
|
-
const now = stampIso();
|
|
1927
|
-
const updatedLines = rawLines.map((line) => {
|
|
1928
|
-
try {
|
|
1929
|
-
const row = JSON.parse(line);
|
|
1930
|
-
if (row && String(row.to || '').toLowerCase() === receiver && (row.status || 'open') === 'open') {
|
|
1931
|
-
closedCount++;
|
|
1932
|
-
return JSON.stringify({ ...row, status: 'closed', closed_at: now });
|
|
1933
|
-
}
|
|
1934
|
-
} catch {}
|
|
1935
|
-
return line;
|
|
1936
|
-
});
|
|
1937
|
-
if (closedCount > 0) {
|
|
1938
|
-
fs.writeFileSync(handoffsFile, `${updatedLines.join('\n')}\n`, 'utf8');
|
|
1939
|
-
}
|
|
1940
|
-
return closedCount;
|
|
1967
|
+
return withHandoffsLock(handoffsFile, () => closeHandoffRows(handoffsFile, receiver));
|
|
1941
1968
|
} catch {
|
|
1942
1969
|
return 0;
|
|
1943
1970
|
}
|
|
1944
1971
|
}
|
|
1945
1972
|
|
|
1973
|
+
function closeHandoffRows(handoffsFile, receiver) {
|
|
1974
|
+
const rawLines = fs.readFileSync(handoffsFile, 'utf8').split(/\r?\n/).filter((l) => l.trim());
|
|
1975
|
+
let closedCount = 0;
|
|
1976
|
+
const now = stampIso();
|
|
1977
|
+
const updatedLines = rawLines.map((line) => {
|
|
1978
|
+
try {
|
|
1979
|
+
const row = JSON.parse(line);
|
|
1980
|
+
if (row && String(row.to || '').toLowerCase() === receiver && (row.status || 'open') === 'open') {
|
|
1981
|
+
closedCount++;
|
|
1982
|
+
return JSON.stringify({ ...row, status: 'closed', closed_at: now });
|
|
1983
|
+
}
|
|
1984
|
+
} catch {}
|
|
1985
|
+
return line;
|
|
1986
|
+
});
|
|
1987
|
+
if (closedCount > 0) rewriteHandoffs(handoffsFile, updatedLines);
|
|
1988
|
+
return closedCount;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1946
1991
|
function memberMissionFile(owner, root = process.cwd()) {
|
|
1947
1992
|
const dir = memberDir(owner, root);
|
|
1948
1993
|
if (!dir) return null;
|
|
@@ -3217,6 +3262,7 @@ function missionFromArgs(args) {
|
|
|
3217
3262
|
'--task',
|
|
3218
3263
|
'--ask',
|
|
3219
3264
|
'--yes-lock',
|
|
3265
|
+
'--yes-lock-parent',
|
|
3220
3266
|
'--campaign',
|
|
3221
3267
|
'--reserve',
|
|
3222
3268
|
'--mission-id',
|
|
@@ -3260,7 +3306,18 @@ function missionFromArgs(args) {
|
|
|
3260
3306
|
const taskIds = readRepeatedFlag(args, '--task');
|
|
3261
3307
|
// MEMBER.md approval-required becomes an open human ask the owner must clear.
|
|
3262
3308
|
const humanAsks = [...readRepeatedFlag(args, '--ask'), ...memberApprovalAsks(owner, process.cwd(), objective)];
|
|
3263
|
-
|
|
3309
|
+
// The seal is never read from text a caller passes through: two
|
|
3310
|
+
// --yes-lock words (one could be the objective) refuse the start, and a
|
|
3311
|
+
// run woken by a handoff carries its seal in the environment, which every
|
|
3312
|
+
// job it starts inherits. (Codex review round 6 on #1483.)
|
|
3313
|
+
if (args.filter(isYesLockWord).length > 1) {
|
|
3314
|
+
exitMissionError('mission start got more than one --yes-lock, so it is not clear which seal applies; nothing started', 2, wantsJson(args));
|
|
3315
|
+
}
|
|
3316
|
+
const handoffSeal = String(process.env.ATRIS_HANDOFF_YES_LOCK || '').trim();
|
|
3317
|
+
const yesLocks = readYesLockSeal(handoffSeal || readFlag(args, '--yes-lock', ''), wantsJson(args));
|
|
3318
|
+
const yesLockParent = yesLocks.length
|
|
3319
|
+
? String((handoffSeal ? process.env.ATRIS_HANDOFF_YES_LOCK_PARENT : readFlag(args, '--yes-lock-parent', '')) || '').trim()
|
|
3320
|
+
: '';
|
|
3264
3321
|
const drive = missionDrive(readFlag(args, '--destination', ''));
|
|
3265
3322
|
const alwaysOn = hasFlag(args, '--always-on');
|
|
3266
3323
|
const xpTaskEnabled = hasFlag(args, '--xp-task') || hasFlag(args, '--agent-xp');
|
|
@@ -3299,6 +3356,9 @@ function missionFromArgs(args) {
|
|
|
3299
3356
|
human_asks: humanAsks,
|
|
3300
3357
|
// the sealed owner steps: the guard holds what each locks until the owner says yes
|
|
3301
3358
|
...(yesLocks.length ? { yes_locks: { steps: yesLocks, sealed_at: stampIso() } } : {}),
|
|
3359
|
+
// a run woken by a handoff: the job it came from, only to show its yes
|
|
3360
|
+
// cards there (lib/yes-lock.js handoffYesCards)
|
|
3361
|
+
...(yesLockParent ? { yes_lock_parent: yesLockParent } : {}),
|
|
3302
3362
|
...(campaignId ? { campaign_id: campaignId } : {}),
|
|
3303
3363
|
...(drive ? { drive } : {}),
|
|
3304
3364
|
next_action: verifier ? 'run verifier with `atris mission tick <id> --verify`' : 'define verifier or run next task',
|
|
@@ -3319,7 +3379,12 @@ function missionFromArgs(args) {
|
|
|
3319
3379
|
|
|
3320
3380
|
// --yes-lock '[{"step":3,"text":"...","categories":["publish"]}]': the owner
|
|
3321
3381
|
// steps a job may not do before the owner says yes. Bad input stops the start.
|
|
3322
|
-
const YES_LOCK_CATEGORIES = new Set(['publish', 'tag', 'post', 'send', 'merge', 'deploy']);
|
|
3382
|
+
const YES_LOCK_CATEGORIES = new Set(['publish', 'tag', 'post', 'send', 'merge', 'deploy', 'spend']);
|
|
3383
|
+
function isYesLockWord(arg) {
|
|
3384
|
+
const text = String(arg || '').trim();
|
|
3385
|
+
return text === '--yes-lock' || text.startsWith('--yes-lock=');
|
|
3386
|
+
}
|
|
3387
|
+
|
|
3323
3388
|
function readYesLockSeal(raw, asJson) {
|
|
3324
3389
|
if (!raw) return [];
|
|
3325
3390
|
let list;
|
|
@@ -3329,7 +3394,7 @@ function readYesLockSeal(raw, asJson) {
|
|
|
3329
3394
|
exitMissionError('--yes-lock takes a JSON list of {step, text, categories}', 2, asJson);
|
|
3330
3395
|
}
|
|
3331
3396
|
const ok = Array.isArray(list) && list.every(l => l && Number.isInteger(l.step) && l.step > 0 && Array.isArray(l.categories) && l.categories.length && l.categories.every(c => YES_LOCK_CATEGORIES.has(c)));
|
|
3332
|
-
if (!ok) exitMissionError('--yes-lock takes a JSON list of {step, text, categories}, with categories from publish, tag, post, send, merge, deploy', 2, asJson);
|
|
3397
|
+
if (!ok) exitMissionError('--yes-lock takes a JSON list of {step, text, categories}, with categories from publish, tag, post, send, merge, deploy, spend', 2, asJson);
|
|
3333
3398
|
return list.map(l => ({ step: l.step, text: String(l.text || '').slice(0, 200), categories: [...new Set(l.categories)] }));
|
|
3334
3399
|
}
|
|
3335
3400
|
|
|
@@ -4512,6 +4577,15 @@ function normalizedObjective(text) {
|
|
|
4512
4577
|
return String(text || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
4513
4578
|
}
|
|
4514
4579
|
|
|
4580
|
+
// True when `existing` runs under the lock `wanted` carries: the start
|
|
4581
|
+
// carries none, or the same seal.
|
|
4582
|
+
function lockCovered(existing, wanted) {
|
|
4583
|
+
const want = (wanted && wanted.yes_locks && wanted.yes_locks.steps) || [];
|
|
4584
|
+
if (!want.length) return true;
|
|
4585
|
+
const have = (existing && existing.yes_locks && existing.yes_locks.steps) || [];
|
|
4586
|
+
return JSON.stringify(have) === JSON.stringify(want);
|
|
4587
|
+
}
|
|
4588
|
+
|
|
4515
4589
|
function findActiveTwinMission(objective, owner, root = process.cwd()) {
|
|
4516
4590
|
const wantObjective = normalizedObjective(objective);
|
|
4517
4591
|
const wantOwner = String(owner || '').trim().toLowerCase();
|
|
@@ -4547,6 +4621,11 @@ function startMission(args, options = {}) {
|
|
|
4547
4621
|
const idLikeObjective = String(mission.objective || '').trim();
|
|
4548
4622
|
if (/^mission-\d{4}-\d{2}-\d{2}-/.test(idLikeObjective)) {
|
|
4549
4623
|
const existing = resolveMission(idLikeObjective);
|
|
4624
|
+
if (existing && !lockCovered(existing, mission)) {
|
|
4625
|
+
// A start carrying a yes-lock never resumes a job under a weaker lock:
|
|
4626
|
+
// it would run without it. (Codex review round 4 on #1483.)
|
|
4627
|
+
exitMissionError(`${existing.id} is not under the yes-lock this start carries, so resuming it would run without the yes-lock; start a new job instead`, 2, asJson);
|
|
4628
|
+
}
|
|
4550
4629
|
if (existing) {
|
|
4551
4630
|
printJsonOrText(
|
|
4552
4631
|
{ ok: true, action: 'mission_recovered', recovered: true, mission: existing, note: 'objective looked like a mission id; recovered the existing mission instead of creating a new one' },
|
|
@@ -4560,55 +4639,57 @@ function startMission(args, options = {}) {
|
|
|
4560
4639
|
}
|
|
4561
4640
|
exitMissingMission(idLikeObjective, 1, asJson);
|
|
4562
4641
|
}
|
|
4563
|
-
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
action: 'mission_destination_change_proposed',
|
|
4585
|
-
reused: true,
|
|
4586
|
-
operator_gate_required: true,
|
|
4587
|
-
mission: saved,
|
|
4588
|
-
proposal,
|
|
4589
|
-
},
|
|
4590
|
-
[
|
|
4591
|
-
`Destination change proposed for ${saved.id}.`,
|
|
4592
|
-
`Current destination: ${saved.drive.destination}`,
|
|
4593
|
-
`Proposed destination: ${proposal.destination}`,
|
|
4594
|
-
`Operator gate: ${proposal.reason}.`,
|
|
4595
|
-
],
|
|
4596
|
-
asJson,
|
|
4597
|
-
);
|
|
4598
|
-
return;
|
|
4599
|
-
}
|
|
4642
|
+
// A start that carries a yes-lock never reuses a twin under a different
|
|
4643
|
+
// lock (or none): the reused job would run without it. It gets a fresh
|
|
4644
|
+
// job under the lock instead. (Codex review rounds 3 and 4 on #1483.)
|
|
4645
|
+
const twinCandidate = hasFlag(args, '--duplicate') ? null : findActiveTwinMission(mission.objective, mission.owner);
|
|
4646
|
+
if (twinCandidate && lockCovered(twinCandidate, mission)) {
|
|
4647
|
+
const twin = twinCandidate;
|
|
4648
|
+
const requestedDestination = mission.drive?.destination || '';
|
|
4649
|
+
if (requestedDestination && twin.drive?.destination && requestedDestination !== twin.drive.destination) {
|
|
4650
|
+
const proposed = proposeMissionDestinationChange(twin, requestedDestination);
|
|
4651
|
+
const proposal = proposed.drive.pending_destination_proposal;
|
|
4652
|
+
const missionRoot = twin.worktree_root || process.cwd();
|
|
4653
|
+
const { mission: saved } = saveMission(
|
|
4654
|
+
proposed,
|
|
4655
|
+
missionRoot,
|
|
4656
|
+
'mission_destination_change_proposed',
|
|
4657
|
+
{
|
|
4658
|
+
current_destination: twin.drive.destination,
|
|
4659
|
+
proposed_destination: proposal.destination,
|
|
4660
|
+
reason: proposal.reason,
|
|
4661
|
+
},
|
|
4662
|
+
);
|
|
4600
4663
|
printJsonOrText(
|
|
4601
|
-
{
|
|
4664
|
+
{
|
|
4665
|
+
ok: true,
|
|
4666
|
+
action: 'mission_destination_change_proposed',
|
|
4667
|
+
reused: true,
|
|
4668
|
+
operator_gate_required: true,
|
|
4669
|
+
mission: saved,
|
|
4670
|
+
proposal,
|
|
4671
|
+
},
|
|
4602
4672
|
[
|
|
4603
|
-
`
|
|
4604
|
-
`
|
|
4605
|
-
`
|
|
4606
|
-
`
|
|
4673
|
+
`Destination change proposed for ${saved.id}.`,
|
|
4674
|
+
`Current destination: ${saved.drive.destination}`,
|
|
4675
|
+
`Proposed destination: ${proposal.destination}`,
|
|
4676
|
+
`Operator gate: ${proposal.reason}.`,
|
|
4607
4677
|
],
|
|
4608
4678
|
asJson,
|
|
4609
4679
|
);
|
|
4610
4680
|
return;
|
|
4611
4681
|
}
|
|
4682
|
+
printJsonOrText(
|
|
4683
|
+
{ ok: true, action: 'mission_reused', reused: true, mission: twin, note: 'an active mission with this objective and owner already exists; resumed instead of cloning (pass --duplicate to force a second one)' },
|
|
4684
|
+
[
|
|
4685
|
+
`Already active: ${twin.id} (${twin.status})`,
|
|
4686
|
+
`Same objective, same owner, reusing it instead of starting a clone.`,
|
|
4687
|
+
`Resume: atris mission run ${twin.id}`,
|
|
4688
|
+
`Really want a second one: re-run with --duplicate`,
|
|
4689
|
+
],
|
|
4690
|
+
asJson,
|
|
4691
|
+
);
|
|
4692
|
+
return;
|
|
4612
4693
|
}
|
|
4613
4694
|
// Intake gate: a mission without a verifier is a planning wish, and planning
|
|
4614
4695
|
// wishes silt up the queue (11 swept on 2026-07-06 with zero ticks). Refuse
|
|
@@ -4736,6 +4817,13 @@ function startMission(args, options = {}) {
|
|
|
4736
4817
|
lane: saved.lane,
|
|
4737
4818
|
verifier: saved.verifier,
|
|
4738
4819
|
});
|
|
4820
|
+
if (saved.yes_lock_parent && saved.yes_locks && saved.yes_locks.steps) {
|
|
4821
|
+
// the lock record names the job it came from, before any command is held
|
|
4822
|
+
try {
|
|
4823
|
+
require('../lib/yes-lock').writeYesLocks(path.join(require('../lib/state-home').atrisHome(), 'state'), saved.id,
|
|
4824
|
+
{ seal: saved.yes_locks.steps, parentMissionId: saved.yes_lock_parent });
|
|
4825
|
+
} catch {}
|
|
4826
|
+
}
|
|
4739
4827
|
const worktreeBaseline = captureMissionWorktreeBaseline(saved, process.cwd(), codeBaseline);
|
|
4740
4828
|
const nextTickCommand = `atris mission tick ${saved.id}${saved.verifier ? ' --verify' : ''}`;
|
|
4741
4829
|
const payload = {
|
|
@@ -12807,6 +12895,9 @@ module.exports = {
|
|
|
12807
12895
|
usefulClaudeReceiptSummary,
|
|
12808
12896
|
autoQueueMemberNextPush,
|
|
12809
12897
|
recordMemberHandoff,
|
|
12898
|
+
updateMemberHandoff,
|
|
12899
|
+
resolveMemberWorkspaceRoot,
|
|
12900
|
+
isYesLockWord,
|
|
12810
12901
|
closeMemberHandoffsForReceiver,
|
|
12811
12902
|
cappedClaudeReceiptText,
|
|
12812
12903
|
extractLayerFromReceiptText,
|
package/commands/slop.js
CHANGED
|
@@ -418,9 +418,15 @@ function findDeadCode(root = process.cwd(), opts = {}) {
|
|
|
418
418
|
const candidates = scanDirs.flatMap((d) => listJsFiles(d));
|
|
419
419
|
if (!candidates.length) return { root, candidates: 0, dead: [], testOnly: [] };
|
|
420
420
|
|
|
421
|
-
// 1) reachability BFS from entrypoints over static edges
|
|
421
|
+
// 1) reachability BFS from entrypoints over static edges. A file that runs
|
|
422
|
+
// itself when started directly (`node lib/x.js`, guarded by
|
|
423
|
+
// `require.main === module`) is an entrypoint too, even if only a test
|
|
424
|
+
// imports it.
|
|
422
425
|
const reached = new Set();
|
|
423
|
-
const
|
|
426
|
+
const runsItself = (f) => {
|
|
427
|
+
try { return /\brequire\.main\s*===\s*module\b/.test(fs.readFileSync(f, 'utf8')); } catch { return false; }
|
|
428
|
+
};
|
|
429
|
+
const queue = [...deadEntrypoints(root), ...candidates.filter(runsItself)];
|
|
424
430
|
while (queue.length) {
|
|
425
431
|
const f = queue.pop();
|
|
426
432
|
if (reached.has(f)) continue;
|
package/lib/falsifier-probe.js
CHANGED
|
@@ -52,7 +52,8 @@ function probeVerifierCanFail({ command, runner, tmpRoot } = {}) {
|
|
|
52
52
|
// like it cannot fail. Same strip as verifyCommandEnv.
|
|
53
53
|
const env = { ...process.env };
|
|
54
54
|
delete env.NODE_TEST_CONTEXT;
|
|
55
|
-
|
|
55
|
+
// a login shell rebuilds PATH; a mission guard's wrappers go back first
|
|
56
|
+
const result = run('bash', ['-lc', `${require('./spawn-env').shellGuardLine(env)}${cmd}`], {
|
|
56
57
|
cwd: dir,
|
|
57
58
|
encoding: 'utf8',
|
|
58
59
|
timeout: PROBE_TIMEOUT_MS,
|