atris 3.75.0 → 3.76.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.
@@ -44,11 +44,21 @@ function readHere(deps = {}) {
44
44
  return null;
45
45
  }
46
46
  if (!played || played.error || played.no_history) return null;
47
- const { rosterRows } = require('../lib/gm-season');
47
+ const { rosterRows, tally } = require('../lib/gm-season');
48
+ // Jobs still in review whose pull request is open, per builder. These do not depend on the
49
+ // last 30 days of plays: a job can wait longer than that, and its builder still has it.
50
+ const waiting = new Map();
51
+ for (const review of played.reviews || []) {
52
+ if (review && review.waiting_on_pull_request && review.member) waiting.set(review.member, (waiting.get(review.member) || 0) + 1);
53
+ }
54
+ const roster = rosterRows({ outcomes: played.outcomes || [], limit: Number.MAX_SAFE_INTEGER });
48
55
  // A member whose only misses were later confirmed not needed, or whose only jobs
49
56
  // wait on a pull request or could not start, has no finished job, but still shows,
50
57
  // so those jobs have somewhere to be counted.
51
- const rows = rosterRows({ outcomes: played.outcomes || [], limit: Number.MAX_SAFE_INTEGER }).filter((row) => row.finished > 0 || row.not_needed > 0 || row.held > 0 || row.could_not_start > 0);
58
+ const rows = roster.filter((row) => row.finished > 0 || row.not_needed > 0 || row.held > 0 || row.could_not_start > 0 || waiting.has(row.name));
59
+ for (const name of waiting.keys()) {
60
+ if (!roster.some((row) => row.name === name)) rows.push({ name, ...tally([]), ovr: null });
61
+ }
52
62
  if (!rows.length) return null;
53
63
  let lineup = deps.lineup;
54
64
  if (lineup === undefined) lineup = require('../lib/team-lineup').readLineupSafe(root);
@@ -63,7 +73,7 @@ function readHere(deps = {}) {
63
73
  }
64
74
  const miss_reasons = [...reasons].map(([reason, count]) => ({ reason, count }))
65
75
  .sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)).slice(0, 3);
66
- return { member: row.name, ovr: row.ovr, landed: row.landed, finished: row.finished, ...(row.not_needed ? { not_needed: row.not_needed } : {}), ...(row.held ? { held: row.held } : {}), ...(row.could_not_start ? { could_not_start: row.could_not_start } : {}), job: seat.job || null, model: seat.model || null, ...(miss_reasons.length ? { miss_reasons } : {}) };
76
+ return { member: row.name, ovr: row.ovr, landed: row.landed, finished: row.finished, ...(row.not_needed ? { not_needed: row.not_needed } : {}), ...(row.held ? { held: row.held } : {}), ...(row.could_not_start ? { could_not_start: row.could_not_start } : {}), ...(waiting.has(row.name) ? { waiting_on_pull_request: waiting.get(row.name) } : {}), job: seat.job || null, model: seat.model || null, ...(miss_reasons.length ? { miss_reasons } : {}) };
67
77
  });
68
78
  const buildJob = ((lineup && lineup.ok && lineup.jobs) || []).find((row) => row.job === 'build' && !row.key);
69
79
  const buildModel = buildJob && buildJob.now_runs ? buildJob.now_runs.model : null;
@@ -126,6 +136,10 @@ function renderMember(m, card) {
126
136
  if (m.not_needed) lines.push(` not needed: ${m.not_needed} (confirmed by someone else, so ${m.not_needed === 1 ? 'it does' : 'they do'} not count either way)`);
127
137
  if (m.held) lines.push(` waiting on a pull request: ${m.held} (the work is done and a person has to land it, so ${m.held === 1 ? 'it does' : 'they do'} not count either way)`);
128
138
  if (m.could_not_start) lines.push(` ${couldNotStartLine(m.could_not_start)}`);
139
+ if (m.waiting_on_pull_request) {
140
+ const one = m.waiting_on_pull_request === 1;
141
+ lines.push(` ${m.waiting_on_pull_request} ${one ? 'job waits' : 'jobs wait'} on an open pull request. Check GitHub and credit ${one ? 'it' : 'them'}: atris task sweep --held`);
142
+ }
129
143
  if (m.ovr == null) lines.push(' rated once it has 3 finished jobs');
130
144
  if (m.miss_reasons && m.miss_reasons.length) {
131
145
  lines.push(' top miss reasons:');
package/commands/task.js CHANGED
@@ -210,6 +210,7 @@ atris task - durable local task state (SQLite, gitignored)
210
210
  Preview certified Review rows; live accept needs --confirm-human-accept --as <human>
211
211
  atris task sweep --auto-accept [--json] Auto-accept verified Review rows; protected lanes wait for human
212
212
  atris task sweep [--json] [--stale-days N] [--claimer NAME] [--dry-run] Release expired claims
213
+ atris task sweep --held [--everywhere] [--dry-run] [--json] Check held jobs against GitHub: credit a merge, release a closed pull request
213
214
  atris task audit [--limit <n>] [--revise] re-run stored verifies for newest accepted tasks; report-only unless --revise
214
215
  atris task revise <id> --note "..." Send reviewed work back to Do
215
216
 
@@ -400,8 +401,10 @@ function failAgentProofOnly(label, detail) {
400
401
  );
401
402
  }
402
403
 
403
- function printJson(value) {
404
- const buffer = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
404
+ // Write straight to stdout and wait until it is all out, so a command that exits right after
405
+ // printing (process.exit drops whatever a pipe still holds) never loses the end of its output.
406
+ function writeStdoutSync(text) {
407
+ const buffer = Buffer.from(text, 'utf8');
405
408
  const retryWait = new Int32Array(new SharedArrayBuffer(4));
406
409
  let offset = 0;
407
410
  while (offset < buffer.length) {
@@ -417,6 +420,10 @@ function printJson(value) {
417
420
  }
418
421
  }
419
422
 
423
+ function printJson(value) {
424
+ writeStdoutSync(`${JSON.stringify(value, null, 2)}\n`);
425
+ }
426
+
420
427
  function refreshCareerXpProjection(workspaceRoot) {
421
428
  if (!workspaceRoot) return null;
422
429
  try {
@@ -9743,6 +9750,9 @@ function cmdReady(args) {
9743
9750
  const merge = readyMergeFlags(args);
9744
9751
  guardExplicitActor('atris task ready', flag(args, '--as'));
9745
9752
  const actor = String(flag(args, '--as') || DEFAULT_OWNER);
9753
+ // The others who built the work with --as, by name, comma apart: a flight that ran for a member
9754
+ // hands in under the member's name and names the engines that ran here. Only a hand-in uses it.
9755
+ const builtWith = textFlag(args, ['--built-with']).split(',').map((name) => name.trim()).filter(Boolean);
9746
9756
  const resultFields = {
9747
9757
  changed: textFlag(args, ['--changed', '--done']),
9748
9758
  checked: textFlag(args, ['--checked', '--check', '--verified']),
@@ -9790,6 +9800,7 @@ function cmdReady(args) {
9790
9800
  landing,
9791
9801
  merge,
9792
9802
  result: resultSentence,
9803
+ builtWith,
9793
9804
  });
9794
9805
  if (!result.ready) {
9795
9806
  console.error(`ready failed: ${result.reason}`);
@@ -10419,10 +10430,12 @@ function cmdCertifyVerified(args, options = {}) {
10419
10430
  results.push({ ref, action: 'skipped', reason: evaluation.reason });
10420
10431
  continue;
10421
10432
  }
10422
- // judge != worker: the re-run only counts as an independent check when
10423
- // its actor is not the builder of the row it is judging.
10433
+ // judge != worker: the re-run only counts as an independent check when its actor never built
10434
+ // the row it is judging: not its builder now, and not anyone who built it before (the store
10435
+ // reads the whole history, so a job sent back, let go or reopened does not forget its builders).
10424
10436
  const builder = reviewIntegrity.taskBuilder(task);
10425
- if (builder && reviewIntegrity.normalizeActor(actor) === builder) {
10437
+ const verifier = reviewIntegrity.normalizeActor(actor);
10438
+ if (verifier && ((builder && verifier === builder) || taskDb.authorsOfTask(db, task).has(verifier))) {
10426
10439
  results.push({ ref, action: 'skipped', reason: 'verifier_is_builder' });
10427
10440
  continue;
10428
10441
  }
@@ -10906,7 +10919,61 @@ function evaluateSweepAutoAccept(task, root, includeProtected = false) {
10906
10919
  return { eligible: false, ref, reason: verifier.reason, evidence: verifier.evidence };
10907
10920
  }
10908
10921
 
10922
+ // atris task sweep --held: ask GitHub about each job waiting on an open pull request and write
10923
+ // down what happened (lib/held-pull-requests.js). Anyone may run it, agents included: GitHub is
10924
+ // the only source and the run grants nothing, so the agent block that guards --auto-accept does
10925
+ // not apply here. It takes none of the expired-claims flags.
10926
+ function cmdSweepHeld(args) {
10927
+ for (const name of ['--auto-accept', '--stale-days', '--claimer']) {
10928
+ if (hasFlag(args, name)) {
10929
+ failTask('atris task sweep', 'held_flags_conflict', `atris task sweep: --held cannot be combined with ${name}`);
10930
+ }
10931
+ }
10932
+ const named = flag(args, '--as');
10933
+ if (named === true) failTask('atris task sweep', 'missing_actor', 'atris task sweep: --as needs a name');
10934
+ guardExplicitActor('atris task sweep', named);
10935
+ const actor = String(named || DEFAULT_OWNER);
10936
+ const dryRun = hasFlag(args, '--dry-run');
10937
+ const json = wantsJson(args);
10938
+ const taskDb = getTaskDb();
10939
+ const workspaceRoot = scopedWorkspaceRoot(taskDb, args);
10940
+ const { sweepHeldJobs, describeHeldSweep } = require('../lib/held-pull-requests');
10941
+
10942
+ // A dry run only reads: it opens the task list read only and never creates one.
10943
+ let db = null;
10944
+ let reader = null;
10945
+ if (!dryRun) {
10946
+ db = taskDb.open();
10947
+ } else if (fs.existsSync(taskDb.getDbPath())) {
10948
+ const { DatabaseSync } = require('node:sqlite');
10949
+ reader = new DatabaseSync(taskDb.getDbPath(), { readOnly: true });
10950
+ db = reader;
10951
+ }
10952
+ let report;
10953
+ try {
10954
+ report = sweepHeldJobs(taskDb, db, { workspaceRoot, actor, dryRun });
10955
+ } finally {
10956
+ if (reader) reader.close();
10957
+ }
10958
+
10959
+ const changed = report.summary.landed + report.summary.released > 0;
10960
+ const projectionPath = changed ? writeDefaultProjection(taskDb, db).outPath : null;
10961
+ if (json) printJson({ ...report, ...(projectionPath ? { projection_path: projectionPath } : {}) });
10962
+ else writeStdoutSync(`${describeHeldSweep(report).join('\n')}\n`);
10963
+ if (report.ok) return;
10964
+
10965
+ // GitHub could not be reached, so the rest were not asked and nothing more changed. Whatever
10966
+ // was settled before that is still refreshed on the task list.
10967
+ if (changed) autoRenderTodoFromDb();
10968
+ if (!json) {
10969
+ const left = report.summary.not_asked;
10970
+ console.error(`stopped: ${report.outage.detail}${left ? `. ${left} held ${left === 1 ? 'job was' : 'jobs were'} not asked` : ''}`);
10971
+ }
10972
+ process.exit(1);
10973
+ }
10974
+
10909
10975
  function cmdSweep(args) {
10976
+ if (hasFlag(args, '--held')) return cmdSweepHeld(args);
10910
10977
  if (!hasFlag(args, '--auto-accept')) {
10911
10978
  const taskDb = getTaskDb();
10912
10979
  const staleRaw = flag(args, '--stale-days');
@@ -581,7 +581,7 @@ function printStatus() {
581
581
  // finished copies across the workspace and then refuses (ATRIS_LOW_DISK),
582
582
  // and a matching install is cloned in as node_modules. deps: false skips
583
583
  // the clone; an object passes options through to seedNodeModules.
584
- function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task, branch: branchOverride, path: pathOverride, base: baseOverride, now = new Date(), env = process.env, freeSpace = space.freeBytes, spaceGuard, deps } = {}) {
584
+ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task, formerTasks = [], branch: branchOverride, path: pathOverride, base: baseOverride, now = new Date(), env = process.env, freeSpace = space.freeBytes, spaceGuard, deps } = {}) {
585
585
  const owner = member || agent;
586
586
  if (!owner || !task) throw new Error('createAgentWorktree: owner (member/agent) and task required');
587
587
  // Pin the caller's flight base, or resolve it once for this new flight.
@@ -634,6 +634,18 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
634
634
  resumedFrom = null;
635
635
  console.error(`note: the last run's branch ${skippedResume} no longer merges cleanly with ${checkoutBase}, so this run starts fresh; that branch keeps its commits`);
636
636
  }
637
+ // A display name is not an identity: it repeats across workspaces and changes when the task list
638
+ // changes, so a branch under a job's older name is never a starting point and never blocks a
639
+ // flight. It can still be this job's own unfinished work, so say where it is and leave the choice
640
+ // to a person; this run goes on from the job's own name.
641
+ const own = slugify(task, 'task', 36);
642
+ for (const former of Array.isArray(formerTasks) ? formerTasks : [formerTasks]) {
643
+ if (!former || !String(former).trim() || slugify(former, 'task', 36) === own) continue;
644
+ const olderRun = lastRunBranch(root, owner, former, checkoutBase, branch);
645
+ if (!olderRun) continue;
646
+ console.error(`note: older unfinished work may be on ${olderRun}; ${resumedFrom ? `continuing from ${resumedFrom} instead` : 'starting fresh'}`);
647
+ break;
648
+ }
637
649
  const startPoint = resumedFrom || checkoutBase;
638
650
  const ref = `refs/heads/${branch}`;
639
651
  const existed = runGit(['show-ref', '--verify', '--quiet', ref], { cwd: root, check: false }).status !== 1;
@@ -792,9 +804,12 @@ function startWorktree(args) {
792
804
  const owner = member || agent;
793
805
  const task = readFlag(args, '--task');
794
806
  if (!owner || !task) {
795
- console.error('Usage: atris worktree start --member <member>|--agent <name> --task "<short task>" [--claim] [--force] [--no-deps]');
807
+ console.error('Usage: atris worktree start --member <member>|--agent <name> --task "<short task>" [--former-task "<older name of the task>"] [--claim] [--force] [--no-deps]');
796
808
  return 2;
797
809
  }
810
+ // The task's older name, when it has gone by one. It is only mentioned: an unfinished branch under
811
+ // it is named in one line, never resumed and never a collision (see createAgentWorktree).
812
+ const formerTask = readFlag(args, '--former-task');
798
813
  const memberFile = member ? path.join(root, 'atris', 'team', member, 'MEMBER.md') : '';
799
814
  if (memberFile && !fs.existsSync(memberFile)) {
800
815
  console.error(`warning: no member persona at ${path.relative(root, memberFile)}`);
@@ -826,6 +841,7 @@ function startWorktree(args) {
826
841
  member,
827
842
  agent,
828
843
  task,
844
+ formerTasks: formerTask ? [formerTask] : [],
829
845
  branch: readFlag(args, '--branch'),
830
846
  path: readFlag(args, '--path'),
831
847
  base: readFlag(args, '--base') || readFlag(args, '--target'),
@@ -944,15 +960,19 @@ function deniedLaneForShip(root, branch, message) {
944
960
  for (const m of source.toUpperCase().matchAll(/([A-Z]{2,6}-\d+)/g)) refs.add(m[1]);
945
961
  for (const m of source.toLowerCase().matchAll(/([a-z]{2,6})-(\d+)/g)) refs.add(`${m[1].toUpperCase()}-${m[2]}`);
946
962
  }
947
- if (!refs.size) return null;
963
+ // A flight's branch names its job by row id, lowercased, not by the display id a person reads
964
+ // (flightTaskName in lib/fleet.js), so a branch also finds a job by its row id as a whole word.
965
+ const words = new Set(String(branch || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
948
966
  const projectionPath = path.join(findPrimaryRoot(root), '.atris', 'state', 'tasks.projection.json');
949
967
  if (!fs.existsSync(projectionPath)) return null;
950
968
  const projection = JSON.parse(fs.readFileSync(projectionPath, 'utf8'));
951
969
  for (const task of projection.tasks || []) {
952
970
  const ids = [task.display_id, task.legacy_ref].filter(Boolean).map(v => String(v).toUpperCase());
953
- if (!ids.some(id => refs.has(id))) continue;
971
+ // Only a real row id counts: a short or odd one could match any word in a branch name.
972
+ const rowId = typeof task.id === 'string' && /^[A-Za-z0-9]{16,}$/.test(task.id) ? task.id.toLowerCase() : '';
973
+ if (!ids.some(id => refs.has(id)) && !(rowId && words.has(rowId))) continue;
954
974
  const hit = protectedLaneOfTask(task);
955
- if (hit) return { taskId: ids[0], tag: hit };
975
+ if (hit) return { taskId: ids[0] || task.id, tag: hit };
956
976
  }
957
977
  return null;
958
978
  } catch {
@@ -2127,8 +2147,9 @@ function help() {
2127
2147
  console.log('Usage: atris worktree <guide|start|ship|status|guard|prune|cleanup|sweep>');
2128
2148
  console.log('');
2129
2149
  console.log(' atris worktree guide');
2130
- console.log(' atris worktree start --member <member>|--agent <name> --task "<task>" [--claim] [--force] [--no-deps]');
2150
+ console.log(' atris worktree start --member <member>|--agent <name> --task "<task>" [--former-task "<older name>"] [--claim] [--force] [--no-deps]');
2131
2151
  console.log(' --force start even when another recent flight shares a task word');
2152
+ console.log(' --former-task the task\'s older name; a branch left unfinished under it is named, never resumed');
2132
2153
  console.log(' --no-deps do not clone node_modules from a matching install');
2133
2154
  console.log(' atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>] [--local]');
2134
2155
  console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
package/lib/fleet.js CHANGED
@@ -1769,6 +1769,9 @@ module.exports = {
1769
1769
  METHOD_KERNEL,
1770
1770
  ATRIS_BUILD_PROCESS_PREAMBLE,
1771
1771
  buildFleetPrompt,
1772
+ flightTaskName,
1773
+ formerTaskArgs,
1774
+ builtWithArgs,
1772
1775
  assertIsolatedWorktree,
1773
1776
  buildEngineCommand,
1774
1777
  isSafeLane,
@@ -2552,7 +2555,7 @@ function bindOneLapProof(worktreePath, proofRef, sourceCommit) {
2552
2555
  return sourceTree;
2553
2556
  }
2554
2557
 
2555
- function importReviewCommit(boundary, { cli, taskId, engine, expectedCommit = '', expectedTree = '', startBaseArgs = [] } = {}) {
2558
+ function importReviewCommit(boundary, { cli, taskId, ref = taskId, engine, expectedCommit = '', expectedTree = '', startBaseArgs = [] } = {}) {
2556
2559
  const fail = (stage, detail, extra = {}) => ({ ok: false, stage, detail: headTail(String(detail || '').trim()), ...extra });
2557
2560
  const runSandbox = (args) => spawnSync('git', args, {
2558
2561
  cwd: boundary.worktreePath,
@@ -2613,7 +2616,7 @@ function importReviewCommit(boundary, { cli, taskId, engine, expectedCommit = ''
2613
2616
  .map((relative) => `${baseByPath.has(relative) ? (sourceByPath.has(relative) ? 'M' : 'D') : 'A'} ${relative}`);
2614
2617
  if (!changedEntries.length) return fail('no_change', 'the verified commit has no tree change');
2615
2618
 
2616
- const started = cli(['worktree', 'start', '--agent', engine, '--task', `dispatch-${String(taskId).toLowerCase()}`, ...startBaseArgs]);
2619
+ const started = cli(['worktree', 'start', '--agent', engine, '--task', flightTaskName('dispatch', ref), ...formerTaskArgs('dispatch', ref, taskId), ...startBaseArgs]);
2617
2620
  const landingPath = (String(started && started.stdout || '').match(/next: cd (.+)/) || [])[1];
2618
2621
  if (!landingPath) return fail('worktree_start', String(started && (started.stderr || started.stdout) || 'could not create review worktree'));
2619
2622
  const worktreePath = landingPath.trim();
@@ -3413,7 +3416,7 @@ async function runFleetFlight({
3413
3416
  const leaseSet = leaseDb.setLease(leaseConnection, { id: leaseId, holder, claimEventId: claims.eventId(leaseId) });
3414
3417
  if (!leaseSet && (!ownCli || leaseDb.getTask(leaseConnection, leaseId))) throw new Error('could not set claim lease');
3415
3418
  if (leaseSet) leased.set(leaseId, holder);
3416
- const started = cli(['worktree', 'start', '--agent', entry.engine, '--task', `fleet-${String(entry.task.display_id).toLowerCase()}`, ...startBaseArgs]);
3419
+ const started = cli(['worktree', 'start', '--agent', entry.engine, '--task', flightTaskName('fleet', rowRef), ...formerTaskArgs('fleet', rowRef, entry.task.display_id), ...startBaseArgs]);
3417
3420
  const wt = (started.stdout.match(/next: cd (.+)/) || [])[1];
3418
3421
  if (!wt) {
3419
3422
  cannotStart('worktree_start', started.stderr.slice(0, 200));
@@ -3621,6 +3624,50 @@ function jobRef(task, displayId) {
3621
3624
  return task && task.id ? String(task.id) : String(displayId);
3622
3625
  }
3623
3626
 
3627
+ // The task name a flight gives `worktree start`, which builds the build branch from it. The branch
3628
+ // carries the job's row id, lowercased, as one word, because that is what the held-pull-request
3629
+ // sweep (`atris task sweep --held`) reads to know a merged pull request is this job's own build
3630
+ // and not one a builder named on a row. A display id says nothing like that: the same folder name
3631
+ // in another place gives the same ids, and the list can be written again. `ref` is what jobRef
3632
+ // gave, so a job shown with no row id keeps its display id, and the sweep will not credit that
3633
+ // branch. Both kinds fit the 36 characters `worktree start` keeps of a name with a 26 character id.
3634
+ function flightTaskName(kind, ref) {
3635
+ return `${kind}-${String(ref).toLowerCase()}`;
3636
+ }
3637
+
3638
+ // Before the branch carried the row id it carried the display id a person reads. The flight hands
3639
+ // `worktree start` that older name as well, as --former-task, for one purpose: so start can say that
3640
+ // unfinished work may be on a branch under it. A display name is not an identity (the same folder
3641
+ // name in another place gives the same name, and the list can be written again), so start never
3642
+ // resumes a branch under the older name and is never blocked by one: it starts fresh and prints one
3643
+ // line naming the branch. Nothing is handed over when there is no display id, or when it makes the
3644
+ // same name (a job shown with no row id is named by its display id at every step). The sweep never
3645
+ // credits a branch with the older name either.
3646
+ function formerTaskArgs(kind, ref, displayId) {
3647
+ if (displayId === undefined || displayId === null || !String(displayId).trim()) return [];
3648
+ const former = flightTaskName(kind, String(displayId).trim());
3649
+ return former === flightTaskName(kind, ref) ? [] : ['--former-task', former];
3650
+ }
3651
+
3652
+ // A flight that runs for a member claims the job and hands it in under the member's name, so the
3653
+ // engines that did the building would never reach the job's history, and either one could later
3654
+ // review the work as an independent check. The hand-in also names every engine that ran a build
3655
+ // attempt, each by the name it has on a flight with no member (fleet-<engine>). The names the claim
3656
+ // and the hand-in already put on the record (`claimedAs`, `handedInAs`) are left out, so a flight
3657
+ // with no member and one or two engines names nobody extra. `engines` is the engines in the order
3658
+ // they ran. The answer is the flag with its value, or nothing to add.
3659
+ function builtWithArgs(engines, { claimedAs = '', handedInAs = '' } = {}) {
3660
+ const { normalizeActor } = require('./review-integrity');
3661
+ const onRecord = new Set([normalizeActor(claimedAs), normalizeActor(handedInAs)]);
3662
+ const names = [];
3663
+ for (const engine of Array.isArray(engines) ? engines : []) {
3664
+ const id = normalizeActor(engine);
3665
+ const name = id ? `fleet-${id}` : '';
3666
+ if (name && !onRecord.has(name) && !names.includes(name)) names.push(name);
3667
+ }
3668
+ return names.length ? ['--built-with', names.join(',')] : [];
3669
+ }
3670
+
3624
3671
  // A flight works only on jobs in its own workspace, and the store takes a row id from any
3625
3672
  // workspace, so the flight asks whose workspace the row is in before it claims anything. That is
3626
3673
  // the store's own rule for what a workspace holds (rowInWorkspace, the one its workspace listing
@@ -4263,7 +4310,7 @@ async function runDispatchFlight({
4263
4310
  // earlier finished run's branch must not refuse the retry. Worktree
4264
4311
  // start resumes that branch's commits, or starts fresh when they no
4265
4312
  // longer merge cleanly (the member-path retry fix).
4266
- const started = cli(['worktree', 'start', '--agent', engine, '--task', `dispatch-${taskId.toLowerCase()}`, '--force', ...startBaseArgs]);
4313
+ const started = cli(['worktree', 'start', '--agent', engine, '--task', flightTaskName('dispatch', rowRef), ...formerTaskArgs('dispatch', rowRef, taskId), '--force', ...startBaseArgs]);
4267
4314
  const wt = (started.stdout.match(/next: cd (.+)/) || [])[1];
4268
4315
  if (!wt) {
4269
4316
  const released = releaseUnstartedDispatchClaim(cli, {
@@ -4571,6 +4618,9 @@ async function runDispatchFlight({
4571
4618
  for (const { entry, result, candidate, startedAtMs } of results) {
4572
4619
  const activeEngine = result.engine || entry.engine || engine;
4573
4620
  const readyActor = explicitActor || `fleet-${activeEngine}`;
4621
+ // Every engine that ran a build attempt on this job, for the hand-in to name (see builtWithArgs).
4622
+ const ranEngines = (Array.isArray(result.attempts) ? result.attempts : []).map((attempt) => attempt && attempt.engine);
4623
+ const builtWith = builtWithArgs(ranEngines, { claimedAs: taskActor, handedInAs: readyActor });
4574
4624
  const landingEntry = { ...entry, engine: activeEngine, candidate };
4575
4625
  if (result.restaffed) log(` restaffed ${entry.taskId}: ${result.restaffed.from} -> ${result.restaffed.to} (${result.restaffed.detail || result.restaffed.reason})`);
4576
4626
  if (result.handover) log(` handed over ${entry.taskId}: ${result.handover_reason}`);
@@ -4814,6 +4864,7 @@ async function runDispatchFlight({
4814
4864
  const imported = importReviewCommit(entry.remoteBoundary, {
4815
4865
  cli,
4816
4866
  taskId: entry.taskId,
4867
+ ref: jobRef(entry.task, entry.taskId),
4817
4868
  engine: activeEngine,
4818
4869
  expectedCommit: candidate.commit,
4819
4870
  expectedTree: candidate.tree,
@@ -4923,6 +4974,7 @@ async function runDispatchFlight({
4923
4974
  '--landing', 'Operators can review the verified change before it reaches users, reducing the risk of an unreviewed release.',
4924
4975
  '--checked', `${landed.check} passed in the isolated worktree`,
4925
4976
  '--tested', 'The requested behavior passed its declared verifier.',
4977
+ ...builtWith,
4926
4978
  '--as', readyActor,
4927
4979
  ]);
4928
4980
  if (!readyResult || readyResult.status !== 0) {
@@ -4974,6 +5026,7 @@ async function runDispatchFlight({
4974
5026
  '--checked', `${landed.check} passed before the pull request was opened`,
4975
5027
  '--tested', 'The requested behavior passed its declared verifier.',
4976
5028
  ...openPullRequestFlags(landed.pull_request),
5029
+ ...builtWith,
4977
5030
  '--as', readyActor,
4978
5031
  ], { flight, landedRow: heldRow, worktree: entry.worktreePath, heldForReview: true, save: () => writeDispatchReceipt(flight, receiptPath, { ids, reviewOnly: proofOnly, enforceRemoteBoundary }) });
4979
5032
  if (!heldRecorded) {
@@ -5002,6 +5055,7 @@ async function runDispatchFlight({
5002
5055
  '--checked', `${landed.check} passed before landing`,
5003
5056
  '--tested', 'The requested behavior passed its declared verifier.',
5004
5057
  ...landedMergeFlags(landed.merge),
5058
+ ...builtWith,
5005
5059
  '--as', readyActor,
5006
5060
  ], { flight, landedRow, worktree: entry.worktreePath, save: () => writeDispatchReceipt(flight, receiptPath, { ids, reviewOnly: proofOnly, enforceRemoteBoundary }) });
5007
5061
  if (!reviewRecorded) {
package/lib/gm-season.js CHANGED
@@ -316,6 +316,18 @@ function parseJson(text) {
316
316
  }
317
317
  }
318
318
 
319
+ // When a landing happened. A merge a person made on GitHub is written down later, when the sweep
320
+ // notices it, and its event says when the merge was (landed_at). The rating dates the landing by
321
+ // that, so a merge swept in weeks late does not count as news. It is never later than the event
322
+ // itself, so a time in the future cannot hold a landing inside the window; a time that cannot be
323
+ // read leaves the landing dated when it was written down.
324
+ function landedAt(row, payload) {
325
+ const written = Number(row.created_at);
326
+ const text = payload && typeof payload.landed_at === 'string' ? payload.landed_at.trim() : '';
327
+ const merged = text ? Date.parse(text) : NaN;
328
+ return Number.isFinite(merged) && Number.isFinite(written) ? Math.min(merged, written) : written;
329
+ }
330
+
319
331
  function landedBy(row, payload) {
320
332
  if (payload && (payload.auto_accepted === true || payload.self_landed === true)) return 'autoland';
321
333
  return /auto/i.test(String(row.actor || '')) ? 'autoland' : 'you';
@@ -377,7 +389,7 @@ function outcomeFromEvent(row) {
377
389
  member: String(member),
378
390
  title: row.title || '',
379
391
  kind,
380
- at: row.event_type === 'claim_expired' && Number.isFinite(leaseUntil) ? leaseUntil : Number(row.created_at),
392
+ at: row.event_type === 'claim_expired' && Number.isFinite(leaseUntil) ? leaseUntil : kind === 'landed' ? landedAt(row, payload) : Number(row.created_at),
381
393
  reason: kind !== 'took' && kind !== 'landed' ? (typeof payload.reason === 'string' && payload.reason.trim() || typeof payload.note === 'string' && payload.note.trim() || null) : null,
382
394
  claimed_at: claimedAt,
383
395
  landed_by: kind === 'landed' ? landedBy(row, payload) : null,
@@ -665,6 +677,9 @@ function loadSeason(workspaceRoot, { now = Date.now(), days = SEASON_DAYS, dbPat
665
677
  title: t.title,
666
678
  member: String(t.claimed_by),
667
679
  since: Number(t.proof_at || t.last_event_at || t.updated_at || 0),
680
+ // Done, and its pull request still open for a person to land. The rating never asks
681
+ // GitHub; `atris task sweep --held` does, and credits the builder when it was merged.
682
+ ...(taskDb.heldPullRequestOf(t) ? { waiting_on_pull_request: true } : {}),
668
683
  }));
669
684
  season.claims = live
670
685
  .filter(t => t.status === 'claimed' && t.claimed_by)