atris 3.75.0 → 3.77.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/brief.js +219 -36
- package/commands/rating.js +18 -11
- package/commands/task.js +72 -5
- package/commands/worktree.js +27 -6
- package/lib/fleet.js +58 -4
- package/lib/gm-season.js +93 -1
- package/lib/held-pull-requests.js +589 -0
- package/lib/owner-brief.js +1060 -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/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
|
@@ -89,6 +89,20 @@ function memberRatings(outcomes = []) {
|
|
|
89
89
|
return new Map([...groups].map(([name, list]) => [name, { ovr: ovr(list), finished: tally(list).finished }]));
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// The words a member's misses came with, most often given first: the reason on
|
|
93
|
+
// its dropped, sent back, and abandoned plays. atris rating and the owner brief
|
|
94
|
+
// both show these, so both read them here.
|
|
95
|
+
function missReasons(outcomes, member, limit = 3) {
|
|
96
|
+
const reasons = new Map();
|
|
97
|
+
for (const play of outcomes || []) {
|
|
98
|
+
if (play.member === member && ['dropped', 'sent_back', 'abandoned'].includes(play.kind) && play.reason) {
|
|
99
|
+
reasons.set(play.reason, (reasons.get(play.reason) || 0) + 1);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return [...reasons].map(([reason, count]) => ({ reason, count }))
|
|
103
|
+
.sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)).slice(0, limit);
|
|
104
|
+
}
|
|
105
|
+
|
|
92
106
|
// Last n finished jobs, oldest on the left, newest on the right.
|
|
93
107
|
function form(outcomes, n = 5) {
|
|
94
108
|
return [...(outcomes || [])]
|
|
@@ -316,6 +330,18 @@ function parseJson(text) {
|
|
|
316
330
|
}
|
|
317
331
|
}
|
|
318
332
|
|
|
333
|
+
// When a landing happened. A merge a person made on GitHub is written down later, when the sweep
|
|
334
|
+
// notices it, and its event says when the merge was (landed_at). The rating dates the landing by
|
|
335
|
+
// that, so a merge swept in weeks late does not count as news. It is never later than the event
|
|
336
|
+
// itself, so a time in the future cannot hold a landing inside the window; a time that cannot be
|
|
337
|
+
// read leaves the landing dated when it was written down.
|
|
338
|
+
function landedAt(row, payload) {
|
|
339
|
+
const written = Number(row.created_at);
|
|
340
|
+
const text = payload && typeof payload.landed_at === 'string' ? payload.landed_at.trim() : '';
|
|
341
|
+
const merged = text ? Date.parse(text) : NaN;
|
|
342
|
+
return Number.isFinite(merged) && Number.isFinite(written) ? Math.min(merged, written) : written;
|
|
343
|
+
}
|
|
344
|
+
|
|
319
345
|
function landedBy(row, payload) {
|
|
320
346
|
if (payload && (payload.auto_accepted === true || payload.self_landed === true)) return 'autoland';
|
|
321
347
|
return /auto/i.test(String(row.actor || '')) ? 'autoland' : 'you';
|
|
@@ -377,7 +403,7 @@ function outcomeFromEvent(row) {
|
|
|
377
403
|
member: String(member),
|
|
378
404
|
title: row.title || '',
|
|
379
405
|
kind,
|
|
380
|
-
at: row.event_type === 'claim_expired' && Number.isFinite(leaseUntil) ? leaseUntil : Number(row.created_at),
|
|
406
|
+
at: row.event_type === 'claim_expired' && Number.isFinite(leaseUntil) ? leaseUntil : kind === 'landed' ? landedAt(row, payload) : Number(row.created_at),
|
|
381
407
|
reason: kind !== 'took' && kind !== 'landed' ? (typeof payload.reason === 'string' && payload.reason.trim() || typeof payload.note === 'string' && payload.note.trim() || null) : null,
|
|
382
408
|
claimed_at: claimedAt,
|
|
383
409
|
landed_by: kind === 'landed' ? landedBy(row, payload) : null,
|
|
@@ -598,6 +624,65 @@ function playEventsSql(extraWhere = '') {
|
|
|
598
624
|
ORDER BY e.created_at ASC`;
|
|
599
625
|
}
|
|
600
626
|
|
|
627
|
+
// A workspace's plays as they stood at an earlier time: the same plays loadSeason
|
|
628
|
+
// would have found on that day, from the events that existed by then and nothing
|
|
629
|
+
// after. Working out all of the history and cutting the plays by date afterwards is
|
|
630
|
+
// not the same thing. A landing that came later replaces an earlier miss, and a
|
|
631
|
+
// later "not needed" note clears one, so the miss would be gone from a rating that
|
|
632
|
+
// was printed with it. Every other fact a play is built from (the claim before it,
|
|
633
|
+
// whether the engine had started) is an earlier event in the same job, so cutting
|
|
634
|
+
// the events is enough.
|
|
635
|
+
//
|
|
636
|
+
// The job's own row is the one thing that is not an earlier event. It says who holds
|
|
637
|
+
// the job now, and today's rating falls back on that when a play has no claim on
|
|
638
|
+
// record before it. For a day in the past that is the wrong answer: a job nobody held
|
|
639
|
+
// that day would be handed to whoever took it later, and sent back to them. So the
|
|
640
|
+
// holder and the claim time are cleared here. A play with no claim on record at or
|
|
641
|
+
// before the day has no member and is not counted, the way a job nobody held was not
|
|
642
|
+
// counted on that day. (Only the title is still read from the row, and it is only
|
|
643
|
+
// shown.) `db` is an open task store.
|
|
644
|
+
function outcomesAsOf(db, workspaceRoot, asOf, days = SEASON_DAYS) {
|
|
645
|
+
const since = asOf - days * DAY_MS;
|
|
646
|
+
const rows = db.prepare(playEventsSql('AND e.workspace_root = ? AND e.created_at <= ?')).all(since, workspaceRoot, asOf)
|
|
647
|
+
.map(row => ({ ...row, claimed_by: null, claimed_at: null }));
|
|
648
|
+
return outcomesFromEvents(rows, since).filter(play => play.at <= asOf);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// The jobs open right now whose latest release is one the rating reads as held (the
|
|
652
|
+
// work is done and its pull request waits for a person, but the proof could not be
|
|
653
|
+
// saved) or as could_not_start (the build machine never ran the job), and that nobody
|
|
654
|
+
// has claimed or landed since, and that has not been archived or reopened since: a
|
|
655
|
+
// release older than the job's latest archive or reopen is not what the job is waiting
|
|
656
|
+
// on, because it was closed and then opened again for new work. There is no age limit:
|
|
657
|
+
// loadSeason only looks back 30 days, and a job left open by a release older than that
|
|
658
|
+
// is still where it was left.
|
|
659
|
+
// Each release is read by the rating's own query and rules (the member, the kind, the
|
|
660
|
+
// engine_started mark), so this cannot disagree with atris rating about what a release
|
|
661
|
+
// was. Returns one play per job, in the shape outcomesFromEvents makes. `db` is an open
|
|
662
|
+
// task store.
|
|
663
|
+
function openReleasePlays(db, workspaceRoot) {
|
|
664
|
+
const found = db.prepare(`
|
|
665
|
+
SELECT r.event_id AS event_id, r.task_id AS task_id, r.version AS version
|
|
666
|
+
FROM task_events r
|
|
667
|
+
JOIN tasks t ON t.id = r.task_id
|
|
668
|
+
WHERE t.workspace_root = ? AND t.status = 'open' AND r.event_type = 'claim_released'
|
|
669
|
+
-- a first cut on the words, so only the few releases of these kinds are read in full below
|
|
670
|
+
AND (r.payload LIKE '%proof_not_saved%' OR r.payload LIKE '%could_not_start%')
|
|
671
|
+
AND r.version = (SELECT MAX(x.version) FROM task_events x WHERE x.task_id = r.task_id AND x.event_type = 'claim_released')
|
|
672
|
+
AND NOT EXISTS (SELECT 1 FROM task_events later WHERE later.task_id = r.task_id AND later.version > r.version AND later.event_type IN ('claimed', 'archived', 'reopened'))`)
|
|
673
|
+
.all(workspaceRoot);
|
|
674
|
+
const release = db.prepare(playEventsSql('AND e.event_id = ?'));
|
|
675
|
+
const after = db.prepare(playEventsSql('AND e.task_id = ? AND e.version > ?'));
|
|
676
|
+
const plays = [];
|
|
677
|
+
for (const { event_id: eventId, task_id: taskId, version } of found) {
|
|
678
|
+
const play = outcomesFromEvents(release.all(0, eventId)).find(made => made.event_id === eventId);
|
|
679
|
+
if (!play || (play.kind !== 'held' && play.kind !== 'could_not_start')) continue;
|
|
680
|
+
if (outcomesFromEvents(after.all(0, taskId, version)).some(made => made.kind === 'landed')) continue;
|
|
681
|
+
plays.push(play);
|
|
682
|
+
}
|
|
683
|
+
return plays;
|
|
684
|
+
}
|
|
685
|
+
|
|
601
686
|
// The one loader: last 30 days of plays for a workspace, plus open claims,
|
|
602
687
|
// who is asleep, and what is up next. Opens the db read-only.
|
|
603
688
|
// `lastSeen` pins the recap to a session's opening look; without it the
|
|
@@ -665,6 +750,9 @@ function loadSeason(workspaceRoot, { now = Date.now(), days = SEASON_DAYS, dbPat
|
|
|
665
750
|
title: t.title,
|
|
666
751
|
member: String(t.claimed_by),
|
|
667
752
|
since: Number(t.proof_at || t.last_event_at || t.updated_at || 0),
|
|
753
|
+
// Done, and its pull request still open for a person to land. The rating never asks
|
|
754
|
+
// GitHub; `atris task sweep --held` does, and credits the builder when it was merged.
|
|
755
|
+
...(taskDb.heldPullRequestOf(t) ? { waiting_on_pull_request: true } : {}),
|
|
668
756
|
}));
|
|
669
757
|
season.claims = live
|
|
670
758
|
.filter(t => t.status === 'claimed' && t.claimed_by)
|
|
@@ -1267,11 +1355,15 @@ module.exports = {
|
|
|
1267
1355
|
lastPlays,
|
|
1268
1356
|
loadSeason,
|
|
1269
1357
|
memberRatings,
|
|
1358
|
+
missReasons,
|
|
1359
|
+
openReleasePlays,
|
|
1360
|
+
outcomesAsOf,
|
|
1270
1361
|
outcomesFromEvents,
|
|
1271
1362
|
ovr,
|
|
1272
1363
|
ownerMood,
|
|
1273
1364
|
readLastSeen,
|
|
1274
1365
|
rosterRows,
|
|
1366
|
+
SEASON_DAYS,
|
|
1275
1367
|
seasonBoard,
|
|
1276
1368
|
sinceLastLook,
|
|
1277
1369
|
stillStuck,
|