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.
- package/commands/rating.js +17 -3
- package/commands/task.js +72 -5
- package/commands/worktree.js +27 -6
- package/lib/fleet.js +58 -4
- package/lib/gm-season.js +16 -1
- package/lib/held-pull-requests.js +589 -0
- package/lib/pull-request-link.js +25 -0
- package/lib/review-integrity.js +52 -5
- package/lib/task-db.js +519 -135
- package/package.json +1 -1
package/commands/rating.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
404
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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');
|
package/commands/worktree.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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',
|
|
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',
|
|
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',
|
|
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)
|