atris 3.74.27 → 3.74.28

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/lib/fleet.js CHANGED
@@ -44,6 +44,7 @@ const { buildVerifiedScoutPack, appendVerifiedScoutPack } = require('./dispatch-
44
44
  const { runPrepPass, withPrepBrief, prepRecordFields } = require('./roster-prep');
45
45
  const { mapNotesPath } = require('./map-refs');
46
46
  const { SHARED_BRIEF_RULES } = require('./brief-rules');
47
+ const { PROOF_NOT_SAVED } = require('./release-kind');
47
48
  const {
48
49
  applySecretGrantEnvironment,
49
50
  buildGatewaySupervisorScript,
@@ -57,6 +58,16 @@ const { recordEngineResource } = require('./engine-resource');
57
58
  // calls. Mirrors the autoland denied lanes.
58
59
  const DENIED_TAGS = ['billing', 'money', 'payments', 'deploy', 'security', 'customer', 'external', 'feedback'];
59
60
 
61
+ // The protected lane a task sits in (a denied tag on the task or its todo
62
+ // tags), or null. The ship gate and the dispatch flight both ask this, so they
63
+ // cannot disagree about which work a person must land.
64
+ function protectedLaneOfTask(task) {
65
+ if (!task || typeof task !== 'object') return null;
66
+ const todoTags = task.metadata && Array.isArray(task.metadata.todo_tags) ? task.metadata.todo_tags : [];
67
+ const tags = [task.tag, ...todoTags].filter(Boolean).map((tag) => String(tag).toLowerCase());
68
+ return tags.find((tag) => DENIED_TAGS.includes(tag)) || null;
69
+ }
70
+
60
71
  // ---------------------------------------------------------------------------
61
72
  // T1: dispatch primitive
62
73
 
@@ -844,9 +855,11 @@ function rosterAttemptRecord(engineName, pin, startedMs, endedMs, result, prep =
844
855
  };
845
856
  }
846
857
 
847
- // After landing: the last attempt landed only when its task is in the landed
848
- // or proof-ready rows; otherwise it failed at the stage that paused it. Every
849
- // earlier attempt keeps its own outcome and names who took over.
858
+ // After landing: the last attempt landed only when its task is in the landed or
859
+ // proof-ready rows. A task whose pull request waits for a person is held: not a
860
+ // landing until someone lands it, and not a failure either. Otherwise it failed
861
+ // at the stage that paused it. Every earlier attempt keeps its own outcome and
862
+ // names who took over.
850
863
  function recordFlightRosterRuns(root, flight, results, { job = 'build', member = '', source = '' } = {}) {
851
864
  for (const { entry, result } of results || []) {
852
865
  const attempts = (result && Array.isArray(result.attempts) ? result.attempts : []).map((attempt) => ({ ...attempt }));
@@ -855,9 +868,15 @@ function recordFlightRosterRuns(root, flight, results, { job = 'build', member =
855
868
  const sameTask = (row) => row && String(row.task) === taskId;
856
869
  const last = attempts[attempts.length - 1];
857
870
  if (last.outcome === 'landed') {
871
+ const held = (flight.held_for_review || []).find(sameTask);
858
872
  const landed = (flight.landed || []).some(sameTask) || (flight.ready || []).some(sameTask);
859
873
  const paused = (flight.paused || []).find(sameTask);
860
- if (!landed) {
874
+ if (held) {
875
+ last.outcome = 'held';
876
+ last.detail = held.review_recorded === false
877
+ ? `pull request ${held.pull_request.url} is open but its proof could not be saved`
878
+ : `waiting for a person to land ${held.pull_request.url}`;
879
+ } else if (!landed) {
861
880
  last.outcome = 'failed';
862
881
  last.detail = paused
863
882
  ? `paused at ${String(paused.stage || 'landing').replace(/_/g, ' ')}${paused.detail ? `: ${String(paused.detail).split('\n')[0]}` : ''}`
@@ -1505,16 +1524,37 @@ function landedMergeFlags(merge) {
1505
1524
  return flags;
1506
1525
  }
1507
1526
 
1508
- function fleetShipArgs(entry, check, targetRef = resolveDispatchLandTarget(entry.worktreePath || process.cwd())) {
1527
+ // task ready flags that name the pull request left open for a person to land.
1528
+ // No merge commit, so the job record shows it as waiting, not landed.
1529
+ function openPullRequestFlags(pullRequest) {
1530
+ if (!pullRequest) return [];
1531
+ const flags = [];
1532
+ if (pullRequest.number) flags.push('--pr-number', String(pullRequest.number));
1533
+ if (pullRequest.url) flags.push('--pr-url', pullRequest.url);
1534
+ return flags;
1535
+ }
1536
+
1537
+ // merge=false pushes the branch and opens the pull request without merging it.
1538
+ function fleetShipArgs(entry, check, targetRef = resolveDispatchLandTarget(entry.worktreePath || process.cwd()), { merge = true } = {}) {
1509
1539
  return [
1510
1540
  'worktree', 'ship',
1511
1541
  '--message', `${String(entry.task.title || '').split(/[.:]/)[0].slice(0, 90)} (${entry.task.display_id}, built by ${entry.engine})`,
1512
1542
  '--verify', check,
1513
1543
  '--target', targetRef,
1514
- '--merge',
1544
+ ...(merge ? ['--merge'] : []),
1515
1545
  ];
1516
1546
  }
1517
1547
 
1548
+ // The open pull request worktree ship printed when it was not asked to merge:
1549
+ // `pr: <url>` for a new one, `pr: <number> <url>` for one that was already open.
1550
+ function shipPullRequest(stdout) {
1551
+ const line = String(stdout || '').match(/^pr: (?:(\d+) )?(https?:\/\/\S+)\s*$/m);
1552
+ if (!line) return null;
1553
+ const url = line[2];
1554
+ const number = Number(line[1] || (url.match(/\/pull\/(\d+)/) || [])[1]);
1555
+ return { url, ...(Number.isInteger(number) && number > 0 ? { number } : {}) };
1556
+ }
1557
+
1518
1558
  // A fleet build worktree is ephemeral. If any process inside one runs
1519
1559
  // `npm link` (which repoints the global `atris` package symlink at that
1520
1560
  // worktree), then removing the worktree at teardown leaves the global `atris`
@@ -1645,6 +1685,8 @@ function landArrival({ worktreePath, targetRef = resolveDispatchLandTarget(workt
1645
1685
 
1646
1686
  module.exports = {
1647
1687
  DENIED_TAGS,
1688
+ protectedLaneOfTask,
1689
+ shipPullRequest,
1648
1690
  DEAD_ENGINE_OUTPUT_PATTERNS,
1649
1691
  releasePausedClaimsWithoutWork,
1650
1692
  reviewOnlyEngineEnvironment,
@@ -1653,6 +1695,7 @@ module.exports = {
1653
1695
  shipFailureDetail,
1654
1696
  shipMergeRecord,
1655
1697
  landedMergeFlags,
1698
+ openPullRequestFlags,
1656
1699
  get FLEET_CAPABLE() { return FLEET_CAPABLE; },
1657
1700
  get DISPATCH_CAPABLE() { return DISPATCH_CAPABLE; },
1658
1701
  get runFleetFlight() { return runFleetFlight; },
@@ -2820,7 +2863,9 @@ async function validateWithAttempts({
2820
2863
 
2821
2864
  function dispatchReceiptResult(flight, { ids, reviewOnly, enforceRemoteBoundary }) {
2822
2865
  const validatorResult = flight.validator_result || flight.result?.validator_result;
2823
- const successfulRows = reviewOnly ? flight.ready : flight.landed;
2866
+ // Work on a pull request waiting for a person is neither landed nor failed.
2867
+ const held = flight.held_for_review || [];
2868
+ const successfulRows = reviewOnly ? flight.ready : [...flight.landed, ...held];
2824
2869
  const successfulVerifierRows = successfulRows
2825
2870
  .map((row) => row.verifier_result)
2826
2871
  .filter((row) => row && typeof row === 'object');
@@ -2842,12 +2887,13 @@ function dispatchReceiptResult(flight, { ids, reviewOnly, enforceRemoteBoundary
2842
2887
  && validatorResult.engine !== validatorResult.executor_engine;
2843
2888
  const passed = basePassed && (!oneLapReview || validatorPassed === true);
2844
2889
  const result = {
2845
- kind: reviewOnly ? 'dispatch_review_ready' : 'dispatch_landed',
2890
+ kind: reviewOnly ? 'dispatch_review_ready' : (held.length && !flight.landed.length ? 'dispatch_held_for_review' : 'dispatch_landed'),
2846
2891
  passed,
2847
2892
  verifier_result: verifierRows.length === 1
2848
2893
  ? verifierRows[0]
2849
2894
  : { passed, checks: verifierRows },
2850
2895
  };
2896
+ if (!reviewOnly && held.length) result.held_for_review = held.length;
2851
2897
  if (reviewOnly) {
2852
2898
  result.master_boundary_enforced = enforceRemoteBoundary;
2853
2899
  result.master_unchanged = enforceRemoteBoundary
@@ -2914,7 +2960,10 @@ function defaultOwnCli(root) {
2914
2960
 
2915
2961
  // A merge and its task handoff are separate writes. Keep the merge on disk
2916
2962
  // before attempting the handoff, and never report success if that write fails.
2917
- function recordLandedTaskReview(cli, args, { flight, landedRow, worktree, save }) {
2963
+ // heldForReview is the same handoff for a pull request left open for a person:
2964
+ // if it fails, the paused row carries the pull request so the caller can let the
2965
+ // claim go with its link as the reason.
2966
+ function recordLandedTaskReview(cli, args, { flight, landedRow, worktree, save, heldForReview = false }) {
2918
2967
  landedRow.review_recorded = false;
2919
2968
  save();
2920
2969
  let result;
@@ -2926,10 +2975,12 @@ function recordLandedTaskReview(cli, args, { flight, landedRow, worktree, save }
2926
2975
  task: landedRow.task,
2927
2976
  engine: landedRow.engine,
2928
2977
  stage: 'task_ready',
2929
- landed: true,
2978
+ ...(heldForReview ? { held_for_review: true, pull_request: landedRow.pull_request } : { landed: true }),
2930
2979
  worktree,
2931
2980
  detail: headTail(String(result && (result.stderr || result.stdout) || 'task ready failed')),
2932
- next_action: 'record fresh task proof with task ready; do not rebuild or merge again',
2981
+ next_action: heldForReview
2982
+ ? 'record fresh task proof with task ready; do not rebuild, and leave the pull request open'
2983
+ : 'record fresh task proof with task ready; do not rebuild or merge again',
2933
2984
  });
2934
2985
  save();
2935
2986
  return false;
@@ -3234,22 +3285,40 @@ async function runFleetFlight({
3234
3285
  const prepared = [];
3235
3286
  const leaseDb = require('./task-db');
3236
3287
  const leaseConnection = leaseDb.open();
3288
+ const workspace = leaseDb.workspaceRoot(root);
3237
3289
  const leased = new Map();
3290
+ // What each claimed job is called to the task command, and who holds it, by display id. Only
3291
+ // jobs this flight claimed are in them, so a listed job it refused cannot be mistaken for one.
3292
+ const claimedRefs = new Map();
3293
+ const claimedActors = new Map();
3238
3294
  const leaseTimer = setInterval(() => {
3239
3295
  for (const [id, holder] of leased) leaseDb.renewLease(leaseConnection, { id, holder, pid: process.pid });
3240
3296
  }, 5 * 60000);
3241
3297
  leaseTimer.unref();
3242
3298
  try {
3243
3299
  for (const entry of staffed) {
3244
- const claimed = cli(['task', 'claim', String(entry.task.display_id), '--as', `fleet-${entry.engine}`, '--lease-minutes', '60', '--lease-pid', String(process.pid)]);
3300
+ // Only this workspace's own jobs. The list can name a job in another workspace, and the store
3301
+ // takes its row id as readily as one of ours, so the entry's row is asked before anything is claimed.
3302
+ if (jobOutsideWorkspace(leaseDb, entry.task, workspace)) {
3303
+ const detail = namesAnotherWorkspace(entry.task.display_id);
3304
+ flight.paused.push({ task: entry.task.display_id, engine: entry.engine, stage: 'other_workspace', detail });
3305
+ log(` ${entry.engine.padEnd(8)} ✗ ${detail}`);
3306
+ continue;
3307
+ }
3308
+ // The row the list named is the row this claims, leases, proves and lets go of: named once,
3309
+ // by its row id, at every step (see jobRef).
3310
+ const rowRef = jobRef(entry.task, entry.task.display_id);
3311
+ const claimed = cli(['task', 'claim', rowRef, '--as', `fleet-${entry.engine}`, '--lease-minutes', '60', '--lease-pid', String(process.pid)]);
3245
3312
  if (!claimed || claimed.status !== 0) {
3246
3313
  const detail = String(claimed && (claimed.stderr || claimed.stdout) || 'claim failed').trim().slice(0, 300);
3247
3314
  flight.paused.push({ task: entry.task.display_id, engine: entry.engine, stage: 'claim', detail });
3248
3315
  log(` ${entry.engine.padEnd(8)} ⏸ paused ${entry.task.display_id} at claim${detail ? `, ${detail}` : ''}`);
3249
3316
  continue;
3250
3317
  }
3251
- const leaseId = entry.task.id || entry.task.display_id;
3318
+ const leaseId = rowRef;
3252
3319
  const holder = `fleet-${entry.engine}`;
3320
+ claimedRefs.set(String(entry.task.display_id), rowRef);
3321
+ claimedActors.set(String(entry.task.display_id), holder);
3253
3322
  const leaseSet = leaseDb.setLease(leaseConnection, { id: leaseId, holder });
3254
3323
  if (!leaseSet && (!ownCli || leaseDb.getTask(leaseConnection, leaseId))) throw new Error('could not set claim lease');
3255
3324
  if (leaseSet) leased.set(leaseId, holder);
@@ -3330,7 +3399,7 @@ async function runFleetFlight({
3330
3399
  flight.landed.push(landedRow);
3331
3400
  const restaffProof = result.restaffed ? ` Restaffed from ${result.restaffed.from} to ${result.restaffed.to} (${result.restaffed.reason}).` : '';
3332
3401
  const reviewRecorded = recordLandedTaskReview(cli, [
3333
- 'task', 'ready', String(entry.task.display_id),
3402
+ 'task', 'ready', jobRef(entry.task, entry.task.display_id),
3334
3403
  '--proof', `Built by ${activeEngine} engine in fleet flight.${restaffProof} Landed via worktree ship gate (rebase-before-ship, verify re-run). Receipt saved at ${path.relative(root, receiptPath)}. Report tail: ${headTail(String(result.report || '').replace(/\n/g, ' '))}`,
3335
3404
  '--result', 'Operators can now review fleet-shipped work faster because the worktree was verified before landing.',
3336
3405
  ...landedMergeFlags(landed.merge),
@@ -3365,7 +3434,8 @@ async function runFleetFlight({
3365
3434
  releasePausedClaimsWithoutWork(cli, {
3366
3435
  paused: flight.paused,
3367
3436
  worktreeByTask: new Map(prepared.map((entry) => [String(entry.task.display_id), entry.worktreePath])),
3368
- actorByTask: new Map(staffed.map((entry) => [String(entry.task.display_id), `fleet-${entry.engine}`])),
3437
+ actorByTask: claimedActors,
3438
+ refByTask: claimedRefs,
3369
3439
  baseRef: landTarget,
3370
3440
  log,
3371
3441
  });
@@ -3390,10 +3460,47 @@ async function runFleetFlight({
3390
3460
  // ---------------------------------------------------------------------------
3391
3461
  // T5, one-command dispatch: `atris engine dispatch <task-id> --engine <name>`
3392
3462
 
3463
+ // Why the flight let a claim go, in one plain line. It is saved on the release
3464
+ // so the rating can tell good work that stalled from a plain drop.
3465
+ function pausedStageLabel(stage) {
3466
+ return String(stage || 'an unknown step').replace(/_failed$/, '').replace(/_/g, ' ');
3467
+ }
3468
+
3469
+ function releaseReason(lead, detail) {
3470
+ const firstLine = String(detail || '').split(/\r?\n/).map((line) => line.trim()).find(Boolean) || '';
3471
+ return (firstLine ? `${lead}: ${firstLine}` : lead).slice(0, 300);
3472
+ }
3473
+
3474
+ // Pauses where the engine never got to finish its turn.
3475
+ const ENGINE_DID_NOT_FINISH = new Set(['worktree_start', 'remote_quarantine', 'prepare', 'build']);
3476
+
3477
+ // The word a flight uses to name a job to the task command at every step: its row id when the
3478
+ // task command (or the task list) gave one, else the display id the flight has. The display id a
3479
+ // person reads is not a key. The same folder name in another place gives the same ids, and the
3480
+ // list that gives an id its meaning can be written again between two commands, so a command that
3481
+ // finds a job by it again can land on another job. It names a job only when no row id came back,
3482
+ // as with a stand-in task plane.
3483
+ function jobRef(task, displayId) {
3484
+ return task && task.id ? String(task.id) : String(displayId);
3485
+ }
3486
+
3487
+ // A flight works only on jobs in its own workspace, and the store takes a row id from any
3488
+ // workspace, so the flight asks whose workspace the row is in before it claims anything. That is
3489
+ // the store's own rule for what a workspace holds (rowInWorkspace, the one its workspace listing
3490
+ // uses): the workspace, spelled the way the store spells it, and the git worktrees linked to it.
3491
+ // A row with no workspace is in no workspace's listing, so it is not inside this one. A job shown
3492
+ // with no row id has no row to ask about; it is named by its display id at every step.
3493
+ function jobOutsideWorkspace(taskDb, task, workspace) {
3494
+ return Boolean(task && task.id) && !taskDb.rowInWorkspace(task, workspace);
3495
+ }
3496
+
3497
+ const namesAnotherWorkspace = (displayId) => `${displayId} names a job in another workspace`;
3498
+
3393
3499
  // A claim taken before the engine starts must not stay held if the flight
3394
- // refuses or errors first. Release through the same task plane the claim used.
3395
- function releaseUnstartedDispatchClaim(cli, { taskId, actor, detail }) {
3396
- const released = cli(['task', 'release', taskId, '--as', actor]);
3500
+ // refuses or errors first. Release through the same task plane the claim used,
3501
+ // naming the job by ref: the row id the claim named.
3502
+ function releaseUnstartedDispatchClaim(cli, { taskId, ref = taskId, actor, detail, stage }) {
3503
+ const released = cli(['task', 'release', ref, '--as', actor, '--reason', releaseReason(`paused at ${pausedStageLabel(stage)}`, detail)]);
3397
3504
  const releasedOk = Boolean(released && released.status === 0);
3398
3505
  const suffix = releasedOk
3399
3506
  ? 'claim released, safe to retry'
@@ -3413,38 +3520,122 @@ function releaseUnstartedDispatchClaim(cli, { taskId, actor, detail }) {
3413
3520
  // nothing is working the task any more, and a kept claim made the retry
3414
3521
  // fail at "claim failed". The work stays on its branch and the retry's
3415
3522
  // worktree start resumes from it.
3416
- function releasePausedClaimsWithoutWork(cli, { paused, worktreeByTask, actorByTask, baseRef = '', log = () => {}, keepClaimsWithWork = true }) {
3523
+ //
3524
+ // refByTask names each claimed job by the row id the flight claimed it with, so the release
3525
+ // lands on that row even if the display id a person reads now means another job. A job with
3526
+ // no entry there is named by the display id in its pause row.
3527
+ function releasePausedClaimsWithoutWork(cli, { paused, worktreeByTask, actorByTask, refByTask = new Map(), baseRef = '', log = () => {}, keepClaimsWithWork = true }) {
3417
3528
  for (const row of paused || []) {
3418
- if (row.stage === 'claim' || row.stage === 'task_lookup' || row.landed === true) continue; // no claim, or work already landed
3529
+ // No claim (the job was never found, was refused as another workspace's, or the claim
3530
+ // failed), work already landed, or work waiting on a pull request for a person (the flight
3531
+ // let that claim go itself when saving its proof failed).
3532
+ if (row.stage === 'claim' || row.stage === 'task_lookup' || row.stage === 'other_workspace' || row.landed === true || row.held_for_review === true) continue;
3419
3533
  if (row.claim_released !== undefined) continue;
3420
3534
  const taskId = String(row.task || '');
3421
3535
  const actor = actorByTask.get(taskId);
3422
3536
  if (!taskId || !actor) continue;
3423
3537
  const worktreePath = row.worktree || worktreeByTask.get(taskId) || '';
3424
- if (!keepClaimsWithWork) {
3425
- const released = cli(['task', 'release', taskId, '--as', actor]);
3426
- row.claim_released = Boolean(released && released.status === 0);
3427
- if (row.claim_released) log(` released claim on ${taskId} so a retry can pick it up${worktreePath ? `; any work stays in ${path.basename(worktreePath)}` : ''}`);
3428
- continue;
3429
- }
3430
- if (worktreePath && fs.existsSync(worktreePath)) {
3431
- const resolvedBase = baseRef || defaultMainlineBase(worktreePath);
3432
- if (!baseRef && resolvedBase === 'HEAD') continue; // no known start point: preserve the claim
3433
- const ahead = spawnSync('git', ['-C', worktreePath, 'rev-list', '--count', `${resolvedBase}..HEAD`], { encoding: 'utf8' });
3434
- if (ahead.status !== 0 || parseInt(String(ahead.stdout).trim(), 10) > 0) continue;
3435
- // -uall lists untracked files one by one; the default collapses a fully
3436
- // untracked .atris/ into "?? .atris/", which the allowlist cannot judge.
3437
- const status = spawnSync('git', ['-C', worktreePath, 'status', '--porcelain', '-uall'], { encoding: 'utf8' });
3438
- if (status.status !== 0) continue;
3439
- const dirt = String(status.stdout || '').split('\n').filter(Boolean).filter((line) => !isConductorStatusLine(line));
3440
- if (dirt.length > 0) continue;
3441
- } else if (row.stage !== 'worktree_start' && row.stage !== 'remote_quarantine' && row.stage !== 'prepare') {
3442
- continue; // worktree gone and stage ambiguous: leave the claim for a human
3443
- }
3444
- const released = cli(['task', 'release', taskId, '--as', actor]);
3538
+ const noWork = pausedWorktreeHasNoWork(row, worktreePath, baseRef);
3539
+ if (keepClaimsWithWork && !noWork) continue;
3540
+ // "No work" is for an engine that finished its turn and changed nothing. A
3541
+ // pause before that, or a dead engine, keeps its own reason: it is not a
3542
+ // worker saying the job was already done.
3543
+ const reason = noWork && !ENGINE_DID_NOT_FINISH.has(row.stage)
3544
+ ? 'engine produced no work'
3545
+ : releaseReason(`paused at ${pausedStageLabel(row.stage)}`, row.stage === 'build' ? row.reason || row.detail : row.detail || row.reason);
3546
+ const released = cli(['task', 'release', refByTask.get(taskId) || taskId, '--as', actor, '--reason', reason]);
3445
3547
  row.claim_released = Boolean(released && released.status === 0);
3446
- if (row.claim_released) log(` released claim on ${taskId} (engine produced no work)`);
3548
+ if (!row.claim_released) continue;
3549
+ log(keepClaimsWithWork
3550
+ ? ` released claim on ${taskId} (engine produced no work)`
3551
+ : ` released claim on ${taskId} so a retry can pick it up${worktreePath ? `; any work stays in ${path.basename(worktreePath)}` : ''}`);
3552
+ }
3553
+ }
3554
+
3555
+ // A pull request is open for a person, but the proof that says so could not be
3556
+ // saved. Left alone, the claim would keep its 60 minute lease and then a sweep
3557
+ // would expire it as a plain drop, with the pull request still open. Let it go
3558
+ // now, under the name that holds it, and put the link in the reason so the
3559
+ // record points at the work.
3560
+ //
3561
+ // The release also names its kind and the pull request as fields (never read
3562
+ // back out of the reason), so the rating leaves it out of both landed and
3563
+ // missed. Only the flight can say that: it opened the pull request itself, so it
3564
+ // records the release here, in its own process, through the task store it already
3565
+ // holds for leases. `atris task release` takes no kind, because any worker holding
3566
+ // a failed job could pass one to excuse its own miss. Nothing here reads the
3567
+ // environment, so a flight started from an agent session records it the same.
3568
+ //
3569
+ // It lets go of the exact row it claimed: rowId is the id the task command showed
3570
+ // before the claim, and the claim and the lease named that same row. The display id
3571
+ // a person reads is not a key, since the same folder name in another place gives the
3572
+ // same ids, so it is never looked up again. With no row id there is nothing exact to
3573
+ // release, and nothing is.
3574
+ //
3575
+ // Tried once: if the release fails too, the row says so.
3576
+ function releaseClaimOfUnsavedPullRequest({ taskDb, db, root, row, rowId, actor, log = () => {} }) {
3577
+ const reason = releaseReason(`PR ${row.pull_request.url} is open but its proof could not be saved`, row.detail);
3578
+ let released = false;
3579
+ let failure = 'unknown';
3580
+ try {
3581
+ if (!rowId) {
3582
+ failure = 'the flight has no row id for it';
3583
+ } else {
3584
+ const result = taskDb.releaseTask(db, {
3585
+ id: rowId,
3586
+ actor,
3587
+ reason,
3588
+ kind: PROOF_NOT_SAVED,
3589
+ pullRequest: row.pull_request,
3590
+ });
3591
+ if (!result.released) {
3592
+ failure = `release failed: ${result.reason}${result.claimed_by ? ` (held by ${result.claimed_by})` : ''}`;
3593
+ } else {
3594
+ released = true;
3595
+ // The two files people read are drawn again from the saved state, the way the
3596
+ // task command does after every release: the task list file, and atris/TODO.md
3597
+ // of the flight's own workspace. The release is already saved, so a file that
3598
+ // cannot be written does not undo it, and one failing does not stop the other.
3599
+ // The task command is loaded only here: this file is read on every start, and
3600
+ // only this rare path needs it.
3601
+ for (const [what, refresh] of [
3602
+ ['task list file', () => require('../commands/task').writeDefaultProjection(taskDb, db, { root })],
3603
+ ['TODO.md', () => require('../commands/task').autoRenderTodoFromDb(taskDb.workspaceRoot(root))],
3604
+ ]) {
3605
+ try { refresh(); } catch (err) {
3606
+ if (process.env.ATRIS_DEBUG) process.stderr.write(`${what} refresh failed: ${String(err?.message ?? err).replace(/[\r\n]+/g, ' ')}\n`);
3607
+ }
3608
+ }
3609
+ }
3610
+ }
3611
+ } catch (err) {
3612
+ failure = String(err?.message ?? err).trim() || 'unknown';
3613
+ }
3614
+ row.claim_released = released;
3615
+ log(row.claim_released
3616
+ ? ` released the claim on ${row.task} with the pull request link as the reason`
3617
+ : ` could not release the claim on ${row.task}: ${failure.replace(/\s+/g, ' ').trim().slice(0, 80)}. it stays until its lease runs out, unless the task record is repaired first.`);
3618
+ return row.claim_released;
3619
+ }
3620
+
3621
+ // True only when the paused task's worktree is level with its base and holds
3622
+ // nothing but conductor litter. Any doubt counts as work, so a claim is never
3623
+ // let go as "no work" while real changes may exist.
3624
+ function pausedWorktreeHasNoWork(row, worktreePath, baseRef) {
3625
+ if (worktreePath && fs.existsSync(worktreePath)) {
3626
+ const resolvedBase = baseRef || defaultMainlineBase(worktreePath);
3627
+ if (!baseRef && resolvedBase === 'HEAD') return false; // no known start point
3628
+ const ahead = spawnSync('git', ['-C', worktreePath, 'rev-list', '--count', `${resolvedBase}..HEAD`], { encoding: 'utf8' });
3629
+ if (ahead.status !== 0 || parseInt(String(ahead.stdout).trim(), 10) > 0) return false;
3630
+ // -uall lists untracked files one by one; the default collapses a fully
3631
+ // untracked .atris/ into "?? .atris/", which the allowlist cannot judge.
3632
+ const status = spawnSync('git', ['-C', worktreePath, 'status', '--porcelain', '-uall'], { encoding: 'utf8' });
3633
+ if (status.status !== 0) return false;
3634
+ const dirt = String(status.stdout || '').split('\n').filter(Boolean).filter((line) => !isConductorStatusLine(line));
3635
+ return dirt.length === 0;
3447
3636
  }
3637
+ // Worktree gone: only the early stages are known to hold nothing.
3638
+ return row.stage === 'worktree_start' || row.stage === 'remote_quarantine' || row.stage === 'prepare';
3448
3639
  }
3449
3640
 
3450
3641
  // The manual version of this loop took 6 Bash calls per task the night this
@@ -3521,6 +3712,9 @@ async function runDispatchFlight({
3521
3712
  const cli = ownCli || defaultOwnCli(root);
3522
3713
  const leaseDb = require('./task-db');
3523
3714
  const leaseConnection = leaseDb.open();
3715
+ // The workspace this flight works in, spelled the way the store spells it. It is read once, before
3716
+ // the flight writes anything into its folder.
3717
+ const workspace = leaseDb.workspaceRoot(root);
3524
3718
  try { leaseDb.expireLeases(leaseConnection); } catch (err) { if (process.env.ATRIS_DEBUG) process.stderr.write(`lease sweep failed: ${String(err?.message ?? err).replace(/[\r\n]+/g, ' ')}\n`); }
3525
3719
  // Cut worktrees from, and check self-landing against, the workspace's real
3526
3720
  // protected branch, not a hard-coded origin/master (CLI-1298).
@@ -3547,6 +3741,7 @@ async function runDispatchFlight({
3547
3741
  results: [],
3548
3742
  landed: [],
3549
3743
  ready: [],
3744
+ held_for_review: [],
3550
3745
  paused: [],
3551
3746
  };
3552
3747
  if (yolo) flight.yolo = true;
@@ -3566,6 +3761,9 @@ async function runDispatchFlight({
3566
3761
  const startBaseArgs = landTarget ? ['--base', landTarget] : [];
3567
3762
  const prepared = [];
3568
3763
  const leasedIds = new Set();
3764
+ // What each claimed job is called to the task command, by the id the caller gave. Only jobs this
3765
+ // flight claimed are in it.
3766
+ const claimedRefs = new Map();
3569
3767
  const leaseTimer = setInterval(() => {
3570
3768
  for (const id of leasedIds) leaseDb.renewLease(leaseConnection, { id, holder: taskActor, pid: process.pid });
3571
3769
  }, 5 * 60000);
@@ -3578,15 +3776,29 @@ async function runDispatchFlight({
3578
3776
  log(` ✗ ${taskId} not found`);
3579
3777
  continue;
3580
3778
  }
3581
- const claimed = cli(['task', 'claim', taskId, '--as', taskActor, '--lease-minutes', '60', '--lease-pid', String(process.pid)]);
3779
+ // Only this workspace's own jobs. What the task command showed can be a job in another
3780
+ // workspace (the list it read named it under this display id), and the store takes its row id
3781
+ // as readily as one of ours, so the row is asked before anything is claimed.
3782
+ if (jobOutsideWorkspace(leaseDb, task, workspace)) {
3783
+ const detail = namesAnotherWorkspace(taskId);
3784
+ flight.paused.push({ task: taskId, stage: 'other_workspace', detail });
3785
+ log(` ✗ ${detail}`);
3786
+ continue;
3787
+ }
3788
+ // The row the task command just showed is the row this claims, leases, proves and lets go of:
3789
+ // named once, by its row id, at every step (see jobRef). Log lines and the receipt keep saying
3790
+ // the display id.
3791
+ const rowRef = jobRef(task, taskId);
3792
+ const claimed = cli(['task', 'claim', rowRef, '--as', taskActor, '--lease-minutes', '60', '--lease-pid', String(process.pid)]);
3582
3793
  if (!claimed || claimed.status !== 0) {
3583
3794
  const detail = String(claimed && (claimed.stderr || claimed.stdout) || 'claim failed').trim().slice(0, 300);
3584
3795
  flight.paused.push({ task: taskId, stage: 'claim', detail });
3585
3796
  log(` x ${taskId} claim failed`);
3586
3797
  continue;
3587
3798
  }
3799
+ claimedRefs.set(String(taskId), rowRef);
3588
3800
  try {
3589
- const leaseId = task.id || taskId;
3801
+ const leaseId = rowRef;
3590
3802
  const leased = leaseDb.setLease(leaseConnection, { id: leaseId, holder: taskActor });
3591
3803
  if (!leased && (!ownCli || leaseDb.getTask(leaseConnection, leaseId))) throw new Error('could not set claim lease');
3592
3804
  if (leased) leasedIds.add(leaseId);
@@ -3604,7 +3816,9 @@ async function runDispatchFlight({
3604
3816
  if (!wt) {
3605
3817
  const released = releaseUnstartedDispatchClaim(cli, {
3606
3818
  taskId,
3819
+ ref: rowRef,
3607
3820
  actor: taskActor,
3821
+ stage: 'worktree_start',
3608
3822
  detail: String(started.stderr || '').slice(0, 200),
3609
3823
  });
3610
3824
  flight.paused.push({ task: taskId, stage: 'worktree_start', detail: released.detail, claim_released: released.released });
@@ -3616,7 +3830,9 @@ async function runDispatchFlight({
3616
3830
  if (enforceRemoteBoundary && (!remoteBoundary || remoteBoundary.ok !== true)) {
3617
3831
  const released = releaseUnstartedDispatchClaim(cli, {
3618
3832
  taskId,
3833
+ ref: rowRef,
3619
3834
  actor: taskActor,
3835
+ stage: 'remote_quarantine',
3620
3836
  detail: headTail(String(remoteBoundary && remoteBoundary.detail || 'could not prepare a sealed review sandbox')),
3621
3837
  });
3622
3838
  flight.paused.push({
@@ -3659,7 +3875,9 @@ async function runDispatchFlight({
3659
3875
  } catch (err) {
3660
3876
  const released = releaseUnstartedDispatchClaim(cli, {
3661
3877
  taskId,
3878
+ ref: rowRef,
3662
3879
  actor: taskActor,
3880
+ stage: 'prepare',
3663
3881
  detail: String(err && err.message || err).slice(0, 200),
3664
3882
  });
3665
3883
  flight.paused.push({ task: taskId, stage: 'prepare', detail: released.detail, claim_released: released.released });
@@ -3813,10 +4031,35 @@ async function runDispatchFlight({
3813
4031
  change,
3814
4032
  };
3815
4033
  }
3816
- const shipped = shipWithRetry(cli, fleetShipArgs({ task: entry.task, engine: entry.engine || engine }, check, landTarget), entry.worktreePath);
4034
+ // A flight never merges protected-lane work. It ships the branch without
4035
+ // --merge, so a pull request opens and a person lands it.
4036
+ const lane = protectedLaneOfTask(entry.task);
4037
+ const shipped = shipWithRetry(cli, fleetShipArgs({ task: entry.task, engine: entry.engine || engine }, check, landTarget, { merge: !lane }), entry.worktreePath);
3817
4038
  if (shipped.status !== 0 || !/done: worktree shipped/.test(shipped.stdout)) {
3818
4039
  return { ok: false, stage: 'ship', detail: shipFailureDetail(shipped) };
3819
4040
  }
4041
+ if (lane) {
4042
+ const pullRequest = shipPullRequest(shipped.stdout);
4043
+ if (!pullRequest) {
4044
+ const nothingToReview = /^pr: skipped \(no commits ahead of base\)/m.test(shipped.stdout);
4045
+ return {
4046
+ ok: false,
4047
+ stage: 'ship',
4048
+ detail: nothingToReview
4049
+ ? 'no commits ahead of base, so there was no pull request to open'
4050
+ : `the branch was pushed, but no pull request link came back for the protected ${lane} lane`,
4051
+ };
4052
+ }
4053
+ return {
4054
+ ok: true,
4055
+ stage: 'held_for_review',
4056
+ lane,
4057
+ pull_request: pullRequest,
4058
+ check,
4059
+ verifyOutput: `${verified.stdout}${verified.stderr}`,
4060
+ verifier_result: verifierResult,
4061
+ };
4062
+ }
3820
4063
  return {
3821
4064
  ok: true,
3822
4065
  stage: 'shipped',
@@ -4176,7 +4419,7 @@ async function runDispatchFlight({
4176
4419
  ? ` Independent validator ${validatorResult.engine} signed off: ${validatorResult.reason}.`
4177
4420
  : '';
4178
4421
  const readyResult = cli([
4179
- 'task', 'ready', entry.taskId,
4422
+ 'task', 'ready', jobRef(entry.task, entry.taskId),
4180
4423
  '--proof', `Built by ${activeEngine} engine via one-lap dispatch.${restaffProof} Preserved in isolated worktree ${reviewWorktreePath}.${verificationCitation}${validatorCitation} Verify output: ${verifyTail || '(command produced no output, exit 0)'}. Receipt saved at ${path.relative(root, receiptPath)}.`,
4181
4424
  '--result', 'Operators can review a completed, verified change before it lands, reducing the risk of unreviewed changes reaching users.',
4182
4425
  '--landing', 'Operators can review the verified change before it reaches users, reducing the risk of an unreviewed release.',
@@ -4210,13 +4453,49 @@ async function runDispatchFlight({
4210
4453
  }
4211
4454
  continue;
4212
4455
  }
4456
+ if (landed.stage === 'held_for_review') {
4457
+ // Protected-lane work: the pull request is open, a person lands it.
4458
+ // The claim stays with the builder and the job waits in Review, so the
4459
+ // rating sees good work waiting, not a drop.
4460
+ const prUrl = landed.pull_request.url;
4461
+ const heldRow = {
4462
+ task: entry.taskId,
4463
+ engine: activeEngine,
4464
+ check: landed.check,
4465
+ lane: landed.lane,
4466
+ pull_request: landed.pull_request,
4467
+ verifier_result: landed.verifier_result,
4468
+ };
4469
+ if (result.brief_id) heldRow.brief_id = result.brief_id;
4470
+ if (result.restaffed) heldRow.restaffed = result.restaffed;
4471
+ flight.held_for_review.push(heldRow);
4472
+ const heldRecorded = recordLandedTaskReview(cli, [
4473
+ 'task', 'ready', jobRef(entry.task, entry.taskId),
4474
+ '--proof', `Built by ${activeEngine} engine via atris engine dispatch.${restaffProof} This task sits in the protected ${landed.lane} lane, so it was not merged: pull request ${prUrl} waits for a person to land it. The declared verifier passed (exit 0). Verify output: ${verifyTail || '(command produced no output, exit 0)'}. Receipt saved at ${path.relative(root, receiptPath)}.`,
4475
+ '--result', 'A person can review and land this fix from its pull request, because dispatch reran the verifier before opening it.',
4476
+ '--checked', `${landed.check} passed before the pull request was opened`,
4477
+ '--tested', 'The requested behavior passed its declared verifier.',
4478
+ ...openPullRequestFlags(landed.pull_request),
4479
+ '--as', readyActor,
4480
+ ], { flight, landedRow: heldRow, worktree: entry.worktreePath, heldForReview: true, save: () => writeDispatchReceipt(flight, receiptPath, { ids, reviewOnly: proofOnly, enforceRemoteBoundary }) });
4481
+ if (!heldRecorded) {
4482
+ stampDispatchBrief(root, result.brief_id, 'partial', `opened a pull request for ${entry.taskId}, but task ready failed`);
4483
+ log(' the pull request is open, but saving its proof failed. repair the task record before starting more work.');
4484
+ const unsaved = flight.paused.find((row) => row.task === entry.taskId && row.stage === 'task_ready' && row.held_for_review === true);
4485
+ if (unsaved) releaseClaimOfUnsavedPullRequest({ taskDb: leaseDb, db: leaseConnection, root, row: unsaved, rowId: entry.task && entry.task.id, actor: taskActor, log });
4486
+ continue;
4487
+ }
4488
+ stampDispatchBrief(root, result.brief_id, 'pass', `held ${entry.taskId} for a person to land`);
4489
+ log(` ⏸ ${entry.taskId} waits for a person to land it: ${prUrl}`);
4490
+ continue;
4491
+ }
4213
4492
  const landedRow = { task: entry.taskId, engine: activeEngine, check: landed.check };
4214
4493
  landedRow.verifier_result = landed.verifier_result;
4215
4494
  if (result.brief_id) landedRow.brief_id = result.brief_id;
4216
4495
  if (result.restaffed) landedRow.restaffed = result.restaffed;
4217
4496
  flight.landed.push(landedRow);
4218
4497
  const reviewRecorded = recordLandedTaskReview(cli, [
4219
- 'task', 'ready', entry.taskId,
4498
+ 'task', 'ready', jobRef(entry.task, entry.taskId),
4220
4499
  '--proof', `Built by ${activeEngine} engine via atris engine dispatch.${restaffProof} Landed via worktree ship gate (rebase-before-ship, verify re-run). The declared verifier passed (exit 0). Verify output: ${verifyTail || '(command produced no output, exit 0)'}. Receipt saved at ${path.relative(root, receiptPath)}.`,
4221
4500
  '--result', 'Operators can now review engine-built work faster because dispatch reran the verifier before landing.',
4222
4501
  '--checked', `${landed.check} passed before landing`,
@@ -4248,6 +4527,7 @@ async function runDispatchFlight({
4248
4527
  paused: flight.paused,
4249
4528
  worktreeByTask: new Map(prepared.map((entry) => [String(entry.taskId), entry.worktreePath])),
4250
4529
  actorByTask: new Map(ids.map((taskId) => [String(taskId), taskActor])),
4530
+ refByTask: claimedRefs,
4251
4531
  baseRef: landTarget,
4252
4532
  log,
4253
4533
  keepClaimsWithWork: proofOnly,
@@ -4269,7 +4549,8 @@ async function runDispatchFlight({
4269
4549
  log(` fable handoff failed: ${cause}. receipt: ${path.relative(root, receiptPath)}`);
4270
4550
  }
4271
4551
  const completedLabel = proofOnly ? `${flight.ready.length} proof ready` : `${flight.landed.length} landed`;
4272
- log(` dispatch over: ${completedLabel}, ${flight.paused.length} paused - receipt: ${path.relative(root, flight.receipt)}`);
4552
+ const heldLabel = flight.held_for_review.length ? `, ${flight.held_for_review.length} waiting for a person to land` : '';
4553
+ log(` dispatch over: ${completedLabel}${heldLabel}, ${flight.paused.length} paused - receipt: ${path.relative(root, flight.receipt)}`);
4273
4554
  log('');
4274
4555
  return flight;
4275
4556
  } finally {