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/lib/task-db.js
CHANGED
|
@@ -27,6 +27,7 @@ const path = require('path');
|
|
|
27
27
|
const crypto = require('crypto');
|
|
28
28
|
const { DatabaseSync } = require('node:sqlite');
|
|
29
29
|
const reviewIntegrity = require('./review-integrity');
|
|
30
|
+
const { pullRequestOfLink } = require('./pull-request-link');
|
|
30
31
|
const { PROOF_NOT_SAVED, COULD_NOT_START, BUILD_STARTED } = require('./release-kind');
|
|
31
32
|
const dailyLog = require('./daily-log');
|
|
32
33
|
const { isGenericScratchRoot } = require('./scratch-root');
|
|
@@ -593,6 +594,19 @@ function workspaceHead(workspaceRoot) {
|
|
|
593
594
|
}
|
|
594
595
|
}
|
|
595
596
|
|
|
597
|
+
// What belongs to one attempt at a job and goes when the next attempt starts: the pull request link
|
|
598
|
+
// and any merge evidence. A new attempt starts when a job is claimed from open (claimTask, and the
|
|
599
|
+
// claim a `do` stage makes), or when work is handed in on a job nobody holds (readyTask on an open
|
|
600
|
+
// job), or when a `do` stage takes over a job nobody owns. A job sent back for changes that the
|
|
601
|
+
// same builder still holds and fixes is the same attempt and keeps them, and so is a review pass.
|
|
602
|
+
// The sent-back note and the approval mark stay: the next builder needs to read them. (The review
|
|
603
|
+
// record, ATTEMPT_RECORD_KEYS, is a separate matter: a let-go claim and a reopen clear that.)
|
|
604
|
+
const NEW_ATTEMPT_KEYS = ['pull_request', 'merge_commit'];
|
|
605
|
+
// The same removal as one SQL expression over the metadata column, for a write that must not read the
|
|
606
|
+
// row first. Metadata that is not JSON, or has neither key, comes through exactly as it was.
|
|
607
|
+
const NEW_ATTEMPT_PATHS = NEW_ATTEMPT_KEYS.map((key) => `'$.${key}'`);
|
|
608
|
+
const NEW_ATTEMPT_METADATA_SQL = `CASE WHEN json_valid(metadata) THEN CASE WHEN ${NEW_ATTEMPT_PATHS.map((jsonPath) => `json_type(metadata, ${jsonPath}) IS NOT NULL`).join(' OR ')} THEN json_remove(metadata, ${NEW_ATTEMPT_PATHS.join(', ')}) ELSE metadata END ELSE metadata END`;
|
|
609
|
+
|
|
596
610
|
// One row change and its history entry are one transaction: both are in the store or neither is, so nobody can see,
|
|
597
611
|
// or compare against, a job whose row says one thing and whose history says another, and a history write that fails
|
|
598
612
|
// takes the row change back with it. BEGIN IMMEDIATE takes the write lock first, so what the writer read is still
|
|
@@ -641,13 +655,18 @@ function claimTask(db, { id, claimedBy, leaseMinutes, leasePid }) {
|
|
|
641
655
|
if (!id) throw new Error('id required');
|
|
642
656
|
if (!claimedBy) throw new Error('claimedBy required');
|
|
643
657
|
const now = Date.now();
|
|
658
|
+
// A claim from open starts a new attempt, and a new attempt starts with no pull request link and
|
|
659
|
+
// no merge commit: they belong to the attempt that wrote them, and a hand-in that names no pull
|
|
660
|
+
// request must not carry the last attempt's forward (see NEW_ATTEMPT_KEYS). The same single
|
|
661
|
+
// write that takes the claim drops them, so no reader sees a claimed row with the old link.
|
|
644
662
|
const stmt = db.prepare(`
|
|
645
663
|
UPDATE tasks
|
|
646
664
|
SET status = 'claimed',
|
|
647
665
|
claimed_by = ?,
|
|
648
666
|
claimed_at = ?,
|
|
649
667
|
updated_at = ?,
|
|
650
|
-
metadata = CASE WHEN ? IS NULL THEN
|
|
668
|
+
metadata = CASE WHEN ? IS NULL THEN ${NEW_ATTEMPT_METADATA_SQL}
|
|
669
|
+
ELSE json_set(COALESCE(${NEW_ATTEMPT_METADATA_SQL}, '{}'), '$.lease', json(?)) END
|
|
651
670
|
WHERE id = ?
|
|
652
671
|
AND status = 'open'
|
|
653
672
|
`);
|
|
@@ -768,6 +787,7 @@ function expireLeases(db, { now = Date.now(), staleDays, claimer, dryRun = false
|
|
|
768
787
|
try {
|
|
769
788
|
const nextMetadata = metadata && typeof metadata === 'object' ? { ...metadata } : {};
|
|
770
789
|
delete nextMetadata.lease;
|
|
790
|
+
for (const key of LET_GO_RESET_KEYS) delete nextMetadata[key];
|
|
771
791
|
const changed = db.prepare(`UPDATE tasks SET status = 'open', claimed_by = NULL, claimed_at = NULL, updated_at = ?, metadata = ? WHERE id = ? AND status = 'claimed' AND claimed_by = ? AND updated_at = ? AND metadata IS ? AND (SELECT MAX(created_at) FROM task_events WHERE task_id = tasks.id) IS ?`)
|
|
772
792
|
.run(Date.now(), JSON.stringify(nextMetadata), row.id, row.claimed_by, row.updated_at, row.metadata, row.last_seen_at);
|
|
773
793
|
if (changed.changes === 1) {
|
|
@@ -916,20 +936,22 @@ function releaseTaskInTransaction(db, { id, actor, expectedUpdatedAt, reason, ki
|
|
|
916
936
|
const releaseReason = compactEpisodeText(reason, RELEASE_REASON_MAX);
|
|
917
937
|
const now = Date.now();
|
|
918
938
|
const named = claimEventId ? String(claimEventId) : null;
|
|
939
|
+
// The lease, and the last attempt's record (see LET_GO_RESET_KEYS), are removed in the one write.
|
|
940
|
+
const dropped = ['$.lease', ...LET_GO_RESET_KEYS.map((key) => `$.${key}`)];
|
|
919
941
|
const result = withBusyRetry(() => db.prepare(`
|
|
920
942
|
UPDATE tasks
|
|
921
943
|
SET status = 'open',
|
|
922
944
|
claimed_by = NULL,
|
|
923
945
|
claimed_at = NULL,
|
|
924
946
|
updated_at = ?,
|
|
925
|
-
metadata = CASE WHEN json_valid(metadata) THEN json_remove(metadata, '
|
|
947
|
+
metadata = CASE WHEN json_valid(metadata) THEN json_remove(metadata, ${dropped.map(() => '?').join(', ')}) ELSE metadata END
|
|
926
948
|
WHERE id = ?
|
|
927
949
|
AND status = 'claimed'
|
|
928
950
|
AND claimed_by = ?
|
|
929
951
|
AND claimed_at IS ?
|
|
930
952
|
AND (? IS NULL OR updated_at = ?)
|
|
931
953
|
${NAMED_CLAIM_SQL}
|
|
932
|
-
`).run(now, id, actor, row.claimed_at ?? null, expectedUpdatedAt ?? null, expectedUpdatedAt ?? null, named, named));
|
|
954
|
+
`).run(now, ...dropped, id, actor, row.claimed_at ?? null, expectedUpdatedAt ?? null, expectedUpdatedAt ?? null, named, named));
|
|
933
955
|
if (result.changes !== 1) {
|
|
934
956
|
// The job changed between the look and the write. A caller that named its claim is told what the job is now.
|
|
935
957
|
if (named) {
|
|
@@ -957,6 +979,40 @@ function releaseTaskInTransaction(db, { id, actor, expectedUpdatedAt, reason, ki
|
|
|
957
979
|
return { released: true, row: updated, event };
|
|
958
980
|
}
|
|
959
981
|
|
|
982
|
+
// What a finished attempt leaves on a row: its builder, its review record, its certification, its
|
|
983
|
+
// accepted and archived marks. A job sent back to open starts its next attempt without them, so a
|
|
984
|
+
// pass the last builder earned never counts toward the next builder's certification, and the last
|
|
985
|
+
// builder's name is not the one the next builder's passes are judged against (a pass by anyone but
|
|
986
|
+
// the builder on file certifies, so a stale name lets the next builder certify its own work).
|
|
987
|
+
const ATTEMPT_RECORD_KEYS = [
|
|
988
|
+
'accepted_at',
|
|
989
|
+
'accepted_by',
|
|
990
|
+
'agent_certification_policy',
|
|
991
|
+
'agent_certified',
|
|
992
|
+
'agent_certified_at',
|
|
993
|
+
'agent_certified_by',
|
|
994
|
+
'agent_review_actors',
|
|
995
|
+
'agent_review_pass_count',
|
|
996
|
+
'agent_reviewed_at',
|
|
997
|
+
'agent_reviewed_by',
|
|
998
|
+
'approval_status',
|
|
999
|
+
'archived_at',
|
|
1000
|
+
'archived_by',
|
|
1001
|
+
'archived_from',
|
|
1002
|
+
'archived_reason',
|
|
1003
|
+
'built_by',
|
|
1004
|
+
'independent_review_by',
|
|
1005
|
+
'latest_agent_lesson',
|
|
1006
|
+
'latest_agent_next_task',
|
|
1007
|
+
'latest_agent_proof',
|
|
1008
|
+
];
|
|
1009
|
+
|
|
1010
|
+
// A claim let go, by its holder or because its lease ran out, leaves the job open for the next
|
|
1011
|
+
// builder, so the attempt's record goes too. The one mark kept is that a person sent the job back
|
|
1012
|
+
// for changes, which the next builder needs to see; the sent-back job keeps its builder and review
|
|
1013
|
+
// record only while the same builder holds it and fixes it.
|
|
1014
|
+
const LET_GO_RESET_KEYS = ATTEMPT_RECORD_KEYS.filter((key) => key !== 'approval_status');
|
|
1015
|
+
|
|
960
1016
|
function reopenTask(db, { id, actor, reason, metadata: metadataPatch, fromReview = false } = {}) {
|
|
961
1017
|
if (!id) throw new Error('id required');
|
|
962
1018
|
const row = getTask(db, id);
|
|
@@ -979,28 +1035,7 @@ function reopenTask(db, { id, actor, reason, metadata: metadataPatch, fromReview
|
|
|
979
1035
|
reopened_from: row.status,
|
|
980
1036
|
reopen_reason: reasonText,
|
|
981
1037
|
};
|
|
982
|
-
for (const key of [
|
|
983
|
-
'accepted_at',
|
|
984
|
-
'accepted_by',
|
|
985
|
-
'agent_certification_policy',
|
|
986
|
-
'agent_certified',
|
|
987
|
-
'agent_certified_at',
|
|
988
|
-
'agent_certified_by',
|
|
989
|
-
'agent_review_actors',
|
|
990
|
-
'agent_review_pass_count',
|
|
991
|
-
'agent_reviewed_at',
|
|
992
|
-
'agent_reviewed_by',
|
|
993
|
-
'approval_status',
|
|
994
|
-
'archived_at',
|
|
995
|
-
'archived_by',
|
|
996
|
-
'archived_from',
|
|
997
|
-
'archived_reason',
|
|
998
|
-
'independent_review_by',
|
|
999
|
-
'latest_agent_lesson',
|
|
1000
|
-
'latest_agent_next_task',
|
|
1001
|
-
'latest_agent_proof',
|
|
1002
|
-
'pull_request',
|
|
1003
|
-
]) delete metadata[key];
|
|
1038
|
+
for (const key of [...ATTEMPT_RECORD_KEYS, 'pull_request']) delete metadata[key];
|
|
1004
1039
|
|
|
1005
1040
|
const result = withBusyRetry(() => db.prepare(`
|
|
1006
1041
|
UPDATE tasks
|
|
@@ -1247,22 +1282,56 @@ function cleanLandingText(value) {
|
|
|
1247
1282
|
return text || null;
|
|
1248
1283
|
}
|
|
1249
1284
|
|
|
1250
|
-
// Record who delivered this
|
|
1251
|
-
//
|
|
1252
|
-
//
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1285
|
+
// Record who delivered this pass and answer whether certification is allowed: at least one
|
|
1286
|
+
// recorded pass actor must be someone who never built the job. `authors` is everyone on file as
|
|
1287
|
+
// having built it (authorsOfTask), and it is cumulative: sending the job back, letting the claim go
|
|
1288
|
+
// or reopening it never takes a past builder out of it. `handIn` says the call is a builder's own
|
|
1289
|
+
// hand-in (the job was open or claimed) and not a review pass (the job was in review); only the
|
|
1290
|
+
// store, which knows the job's status at the moment of the call, can tell them apart.
|
|
1291
|
+
//
|
|
1292
|
+
// built_by names a builder and nobody else: whoever handed the work in, else whoever holds the
|
|
1293
|
+
// claim, else the first author on file. A review pass never stamps its own actor, because the
|
|
1294
|
+
// reviewer did not build the job, and a machine's name (reserved) is never stamped by a hand-in
|
|
1295
|
+
// either, because it records passes and checks, not builds.
|
|
1296
|
+
function recordReviewPassActor(metadata, claimedBy, rawActor, { authors = [], handIn = false } = {}) {
|
|
1297
|
+
const normalize = reviewIntegrity.normalizeActor;
|
|
1298
|
+
const actorNorm = normalize(rawActor || process.env.ATRIS_AGENT_ID || process.env.USER || '');
|
|
1299
|
+
const claimant = normalize(claimedBy || '');
|
|
1300
|
+
const handedInBy = handIn && actorNorm && !reviewIntegrity.isReservedActor(actorNorm) ? actorNorm : '';
|
|
1301
|
+
const built = new Set();
|
|
1302
|
+
for (const author of authors) {
|
|
1303
|
+
const name = normalize(author);
|
|
1304
|
+
if (name) built.add(name);
|
|
1305
|
+
}
|
|
1306
|
+
if (claimant) built.add(claimant);
|
|
1307
|
+
if (handedInBy) built.add(handedInBy);
|
|
1308
|
+
if (!metadata.built_by) {
|
|
1309
|
+
const stamp = handedInBy || claimant || [...built][0] || '';
|
|
1310
|
+
if (stamp) metadata.built_by = stamp;
|
|
1311
|
+
}
|
|
1312
|
+
const stamped = normalize(metadata.built_by);
|
|
1313
|
+
if (stamped) built.add(stamped);
|
|
1258
1314
|
const actors = Array.isArray(metadata.agent_review_actors) ? [...metadata.agent_review_actors] : [];
|
|
1259
1315
|
if (actorNorm && !actors.includes(actorNorm)) actors.push(actorNorm);
|
|
1260
1316
|
metadata.agent_review_actors = actors;
|
|
1261
|
-
|
|
1262
|
-
const independent =
|
|
1263
|
-
? (actors.find((a) => a && a
|
|
1317
|
+
// With nobody on record as a builder, independence cannot be proven from one name: it takes two.
|
|
1318
|
+
const independent = built.size
|
|
1319
|
+
? (actors.find((a) => a && !built.has(a)) || null)
|
|
1264
1320
|
: (actors.length >= 2 ? actors[1] : null);
|
|
1265
|
-
return { builder:
|
|
1321
|
+
return { builder: stamped || claimant || null, independent };
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// Everyone who ever built the job, from the whole history the store holds: the stamp, the claimant,
|
|
1325
|
+
// every claim on file and every hand-in (see reviewIntegrity.taskAuthors). A Set of normalized
|
|
1326
|
+
// names, empty for a row that is not there.
|
|
1327
|
+
function authorsOfTask(db, row) {
|
|
1328
|
+
if (!row || typeof row !== 'object') return new Set();
|
|
1329
|
+
const events = row.id
|
|
1330
|
+
? db.prepare("SELECT event_type, actor, payload FROM task_events WHERE task_id = ? AND event_type IN ('claimed', 'proof_ready') ORDER BY version")
|
|
1331
|
+
.all(row.id)
|
|
1332
|
+
.map((event) => ({ event_type: event.event_type, actor: event.actor, payload: safeJSON(event.payload) }))
|
|
1333
|
+
: [];
|
|
1334
|
+
return reviewIntegrity.taskAuthors({ metadata: row.metadata, claimed_by: row.claimed_by, events });
|
|
1266
1335
|
}
|
|
1267
1336
|
|
|
1268
1337
|
// A merge the caller confirmed. The commit id is required; the other fields
|
|
@@ -1278,6 +1347,9 @@ function cleanMergeRecord(merge) {
|
|
|
1278
1347
|
if (/^https?:\/\//i.test(url)) record.url = url;
|
|
1279
1348
|
const mergedAt = String(merge.merged_at || '').trim();
|
|
1280
1349
|
if (mergedAt && Number.isFinite(Date.parse(mergedAt))) record.merged_at = mergedAt;
|
|
1350
|
+
// Who merged it, as GitHub names them: a login or a bot name, one word.
|
|
1351
|
+
const mergedBy = String(merge.merged_by || '').trim();
|
|
1352
|
+
if (/^[A-Za-z0-9][A-Za-z0-9_.\-\[\]/]{0,63}$/.test(mergedBy)) record.merged_by = mergedBy;
|
|
1281
1353
|
return record;
|
|
1282
1354
|
}
|
|
1283
1355
|
|
|
@@ -1293,107 +1365,395 @@ function cleanOpenPullRequest(pr) {
|
|
|
1293
1365
|
return record.number || record.url ? record : null;
|
|
1294
1366
|
}
|
|
1295
1367
|
|
|
1296
|
-
|
|
1368
|
+
// The names a hand-in gives for the others who built the work with its actor: a list of names, each
|
|
1369
|
+
// written the one way names are compared, with the actor's own name, a machine's name (they record
|
|
1370
|
+
// passes and checks, never builds), blanks, repeats and anything that is not a name left out. Not a
|
|
1371
|
+
// list means no names: a string is not split.
|
|
1372
|
+
function cleanBuiltWith(builtWith, handedInBy) {
|
|
1373
|
+
const names = [];
|
|
1374
|
+
for (const value of Array.isArray(builtWith) ? builtWith : []) {
|
|
1375
|
+
if (typeof value !== 'string') continue;
|
|
1376
|
+
const name = reviewIntegrity.normalizeActor(value);
|
|
1377
|
+
if (name && name !== handedInBy && !reviewIntegrity.isReservedActor(name) && !names.includes(name)) names.push(name);
|
|
1378
|
+
}
|
|
1379
|
+
return names;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// Why a job cannot be handed in or given a pass: it is not there, or it has left the board. Null when it can.
|
|
1383
|
+
function readyRefusal(row) {
|
|
1384
|
+
if (!row) return { ready: false, reason: 'not_found' };
|
|
1385
|
+
if (!['open', 'claimed', 'review'].includes(row.status)) return { ready: false, reason: `already_${row.status}` };
|
|
1386
|
+
return null;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// How many times a person has sent the job back. A job sent back and handed in again is in the same state with the
|
|
1390
|
+
// same holder, so this is what tells it from the job a caller looked at before the send-back.
|
|
1391
|
+
function sentBackCount(row) {
|
|
1392
|
+
const count = Number(row && row.metadata && row.metadata.human_revision_count || 0);
|
|
1393
|
+
return Number.isFinite(count) ? count : 0;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// A hand-in (the job was open or claimed) or a review pass (it was in review), written as one transaction with its
|
|
1397
|
+
// history entry. A call that has to wait for the write lock waits while the world goes on: a person sends the job
|
|
1398
|
+
// back, its builder hands it in again, another pass lands. So the look taken before the lock only turns away a job
|
|
1399
|
+
// that is plainly not ready-able without a wait, and notes the claim the caller saw. It is never what is written.
|
|
1400
|
+
// Once the lock is held the job and everyone on record as having built it are read again, and every decision is made
|
|
1401
|
+
// from those reads: hand-in or pass, a new attempt or not, the pass count, who is independent, and which pull request
|
|
1402
|
+
// and commit stay on the job. A job that is not the one the caller saw is refused: its status, its holder, the time
|
|
1403
|
+
// of its claim and the number of times a person sent it back must all be the same. So a call made as a pass is never
|
|
1404
|
+
// turned into a hand-in by the wait, nor a hand-in into a pass, and a pass that looked at work a person has since
|
|
1405
|
+
// sent back for changes is not counted on the work that came after. Only the git head is read before the lock,
|
|
1406
|
+
// because a slow git must not hold it.
|
|
1407
|
+
function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, merge = null, resultTrace, result: resultSentence, reason, workRoot, builtWith }) {
|
|
1297
1408
|
if (!id) throw new Error('id required');
|
|
1298
1409
|
const text = String(proof || '').trim();
|
|
1299
1410
|
if (!text) throw new Error('proof required');
|
|
1300
|
-
const
|
|
1301
|
-
|
|
1302
|
-
if (
|
|
1303
|
-
return { ready: false, reason: `already_${row.status}` };
|
|
1304
|
-
}
|
|
1305
|
-
const now = Date.now();
|
|
1306
|
-
const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
|
|
1307
|
-
delete metadata.lease;
|
|
1308
|
-
const reviewPassCount = Number(metadata.agent_review_pass_count || 0) + 1;
|
|
1309
|
-
metadata.approval_status = 'pending';
|
|
1310
|
-
metadata.agent_review_pass_count = reviewPassCount;
|
|
1311
|
-
metadata.agent_reviewed_at = new Date(now).toISOString();
|
|
1312
|
-
metadata.agent_reviewed_by = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
|
|
1313
|
-
metadata.latest_agent_proof = text;
|
|
1314
|
-
metadata.latest_agent_lesson = String(lesson || '').trim() || null;
|
|
1315
|
-
metadata.latest_agent_next_task = String(nextTask || '').trim() || null;
|
|
1316
|
-
const resultText = cleanLandingText(resultSentence);
|
|
1317
|
-
if (resultText) metadata.result = resultText;
|
|
1318
|
-
const reasonText = cleanLandingText(reason);
|
|
1319
|
-
if (reasonText) metadata.result_reason = reasonText;
|
|
1320
|
-
const landingInput = landing && typeof landing === 'object' ? landing : {};
|
|
1321
|
-
for (const key of ['happened', 'checked', 'tested', 'decision']) {
|
|
1322
|
-
const cleaned = cleanLandingText(landingInput[key]);
|
|
1323
|
-
if (cleaned) metadata[`landing_${key}`] = cleaned;
|
|
1324
|
-
}
|
|
1325
|
-
const mergeRecord = cleanMergeRecord(merge);
|
|
1326
|
-
if (mergeRecord) metadata.merge_commit = mergeRecord.merge_commit;
|
|
1327
|
-
const openPullRequest = mergeRecord ? null : cleanOpenPullRequest(merge);
|
|
1328
|
-
// The row keeps the newest word on its pull request, so a reader that sees only
|
|
1329
|
-
// the row (auto-accept, a compact list) can tell a job waiting on a person from
|
|
1330
|
-
// one that landed. A review pass that names no pull request leaves it as it was.
|
|
1331
|
-
if (mergeRecord) metadata.pull_request = { state: 'merged', ...mergeRecord };
|
|
1332
|
-
else if (openPullRequest) metadata.pull_request = openPullRequest;
|
|
1411
|
+
const seen = getTask(db, id);
|
|
1412
|
+
const turnedAway = readyRefusal(seen);
|
|
1413
|
+
if (turnedAway) return turnedAway;
|
|
1333
1414
|
// Only the builder's own ready names the work's commit. A reviewer or the
|
|
1334
1415
|
// autoland verifier re-runs the check from its own checkout; stamping that
|
|
1335
1416
|
// checkout's HEAD would name a commit the builder never made. No actor, or
|
|
1336
1417
|
// no builder on record, means nobody can vouch for HEAD, so nothing is stamped.
|
|
1337
|
-
// A builder working in its own copy passes that copy as workRoot.
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
approval_status
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1418
|
+
// A builder working in its own copy passes that copy as workRoot. The head is
|
|
1419
|
+
// asked for here, and whether it is stamped is settled below, once the lock is held.
|
|
1420
|
+
const head = actor && seen.claimed_by && actor === seen.claimed_by && !reviewIntegrity.isReservedActor(actor)
|
|
1421
|
+
? workspaceHead(workRoot || seen.workspace_root)
|
|
1422
|
+
: null;
|
|
1423
|
+
return inWriteTransaction(db, () => {
|
|
1424
|
+
const row = getTask(db, id);
|
|
1425
|
+
const gone = readyRefusal(row);
|
|
1426
|
+
if (gone) return gone;
|
|
1427
|
+
if (row.status !== seen.status || row.claimed_by !== seen.claimed_by || row.claimed_at !== seen.claimed_at || sentBackCount(row) !== sentBackCount(seen)) {
|
|
1428
|
+
return { ready: false, reason: 'claim changed hands' };
|
|
1429
|
+
}
|
|
1430
|
+
const now = Date.now();
|
|
1431
|
+
const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
|
|
1432
|
+
delete metadata.lease;
|
|
1433
|
+
// A job in review is getting a review pass; an open or claimed one is getting its builder's hand-in.
|
|
1434
|
+
const handIn = row.status !== 'review';
|
|
1435
|
+
// A hand-in may also name the others who built the work with its actor: a flight that ran for a
|
|
1436
|
+
// member hands in under the member's name, and the engines that actually built it are named here.
|
|
1437
|
+
// Only a hand-in names builders, a review pass names none.
|
|
1438
|
+
const handedInBy = handIn ? reviewIntegrity.normalizeActor(actor || process.env.ATRIS_AGENT_ID || process.env.USER || '') : '';
|
|
1439
|
+
const builtWithNames = handIn ? cleanBuiltWith(builtWith, handedInBy) : [];
|
|
1440
|
+
// Work handed in on a job nobody holds is a new attempt: the last attempt's pull request link and
|
|
1441
|
+
// merge commit are not this one's, so a hand-in that names no pull request carries none forward.
|
|
1442
|
+
if (row.status === 'open' || (row.status === 'claimed' && !row.claimed_by)) {
|
|
1443
|
+
for (const key of NEW_ATTEMPT_KEYS) delete metadata[key];
|
|
1444
|
+
}
|
|
1445
|
+
const reviewPassCount = Number(metadata.agent_review_pass_count || 0) + 1;
|
|
1446
|
+
metadata.approval_status = 'pending';
|
|
1447
|
+
metadata.agent_review_pass_count = reviewPassCount;
|
|
1448
|
+
metadata.agent_reviewed_at = new Date(now).toISOString();
|
|
1449
|
+
metadata.agent_reviewed_by = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
|
|
1450
|
+
metadata.latest_agent_proof = text;
|
|
1451
|
+
metadata.latest_agent_lesson = String(lesson || '').trim() || null;
|
|
1452
|
+
metadata.latest_agent_next_task = String(nextTask || '').trim() || null;
|
|
1453
|
+
const resultText = cleanLandingText(resultSentence);
|
|
1454
|
+
if (resultText) metadata.result = resultText;
|
|
1455
|
+
const reasonText = cleanLandingText(reason);
|
|
1456
|
+
if (reasonText) metadata.result_reason = reasonText;
|
|
1457
|
+
const landingInput = landing && typeof landing === 'object' ? landing : {};
|
|
1458
|
+
for (const key of ['happened', 'checked', 'tested', 'decision']) {
|
|
1459
|
+
const cleaned = cleanLandingText(landingInput[key]);
|
|
1460
|
+
if (cleaned) metadata[`landing_${key}`] = cleaned;
|
|
1461
|
+
}
|
|
1462
|
+
const mergeRecord = cleanMergeRecord(merge);
|
|
1463
|
+
if (mergeRecord) metadata.merge_commit = mergeRecord.merge_commit;
|
|
1464
|
+
const openPullRequest = mergeRecord ? null : cleanOpenPullRequest(merge);
|
|
1465
|
+
// The row keeps the newest word on its pull request, so a reader that sees only
|
|
1466
|
+
// the row (auto-accept, a compact list) can tell a job waiting on a person from
|
|
1467
|
+
// one that landed. A review pass that names no pull request leaves it as it was.
|
|
1468
|
+
if (mergeRecord) metadata.pull_request = { state: 'merged', ...mergeRecord };
|
|
1469
|
+
else if (openPullRequest) metadata.pull_request = openPullRequest;
|
|
1470
|
+
if (head && actor && row.claimed_by && actor === row.claimed_by && !reviewIntegrity.isReservedActor(actor)) {
|
|
1471
|
+
metadata.work_commit = head;
|
|
1472
|
+
}
|
|
1473
|
+
const passRecord = recordReviewPassActor(metadata, row.claimed_by, actor, { authors: new Set([...authorsOfTask(db, row), ...builtWithNames]), handIn });
|
|
1474
|
+
if (reviewPassCount >= AGENT_CERTIFICATION_REVIEW_PASSES && passRecord.independent) {
|
|
1475
|
+
metadata.agent_certified = true;
|
|
1476
|
+
metadata.agent_certified_at = new Date(now).toISOString();
|
|
1477
|
+
metadata.agent_certified_by = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
|
|
1478
|
+
metadata.agent_certification_policy = `${AGENT_CERTIFICATION_REVIEW_PASSES}_agent_review_passes`;
|
|
1479
|
+
metadata.independent_review_by = passRecord.independent;
|
|
1480
|
+
}
|
|
1481
|
+
const payload = {
|
|
1482
|
+
proof: text,
|
|
1483
|
+
lesson: metadata.latest_agent_lesson,
|
|
1484
|
+
next_task: metadata.latest_agent_next_task,
|
|
1485
|
+
approval_status: 'pending',
|
|
1486
|
+
review_pass_count: reviewPassCount,
|
|
1487
|
+
agent_certified: metadata.agent_certified === true,
|
|
1488
|
+
agent_certification_policy: metadata.agent_certification_policy || null,
|
|
1489
|
+
result: metadata.result || null,
|
|
1490
|
+
};
|
|
1491
|
+
if (resultTrace && typeof resultTrace === 'object') {
|
|
1492
|
+
payload.result_trace = resultTrace;
|
|
1493
|
+
payload.result_packet = `TASK_RESULT_TRACE ${JSON.stringify(resultTrace)}`;
|
|
1494
|
+
}
|
|
1495
|
+
payload.landing = {
|
|
1496
|
+
happened: metadata.landing_happened || null,
|
|
1497
|
+
checked: metadata.landing_checked || null,
|
|
1498
|
+
tested: metadata.landing_tested || null,
|
|
1499
|
+
decision: metadata.landing_decision || null,
|
|
1500
|
+
};
|
|
1501
|
+
if (mergeRecord) {
|
|
1502
|
+
payload.landing.merge_commit = mergeRecord.merge_commit;
|
|
1503
|
+
payload.pull_request = { state: 'merged', ...mergeRecord };
|
|
1504
|
+
// Landed by the flight with no person in the loop.
|
|
1505
|
+
if (merge.self_landed === true) payload.self_landed = true;
|
|
1506
|
+
} else if (openPullRequest) {
|
|
1507
|
+
payload.pull_request = openPullRequest;
|
|
1508
|
+
}
|
|
1509
|
+
// The hand-in is written down as one, so the job's history says who built it for good, whatever
|
|
1510
|
+
// happens to the row after (a send-back, a let-go claim, a reopen). A review pass leaves no such
|
|
1511
|
+
// mark, and neither does a machine's name: it records passes and checks, never builds.
|
|
1512
|
+
if (handIn) {
|
|
1513
|
+
if (handedInBy && !reviewIntegrity.isReservedActor(handedInBy)) payload.handed_in_by = handedInBy;
|
|
1514
|
+
if (builtWithNames.length) payload.built_with = builtWithNames;
|
|
1515
|
+
}
|
|
1516
|
+
// The row and its entry are one write, so the entry is in the store the moment the job shows as handed in, with
|
|
1517
|
+
// the engines that built it. Saved one after the other, a stop between the two lost those names for good, and a
|
|
1518
|
+
// look landing in the gap could count one of the engines as an independent check. The guard in the statement
|
|
1519
|
+
// holds the write to the row that was read: a row that changed since is refused.
|
|
1520
|
+
const result = withBusyRetry(() => db.prepare(`
|
|
1521
|
+
UPDATE tasks
|
|
1522
|
+
SET status = 'review',
|
|
1523
|
+
done_at = NULL,
|
|
1524
|
+
updated_at = ?,
|
|
1525
|
+
metadata = ?
|
|
1526
|
+
WHERE id = ?
|
|
1527
|
+
AND status = ? AND claimed_by IS ? AND claimed_at IS ?
|
|
1528
|
+
`).run(now, JSON.stringify(metadata), id, row.status, row.claimed_by, row.claimed_at));
|
|
1529
|
+
if (result.changes !== 1) return { ready: false, reason: 'claim changed hands' };
|
|
1530
|
+
const updated = getTask(db, id);
|
|
1531
|
+
const event = appendTaskEvent(db, {
|
|
1532
|
+
taskId: id,
|
|
1533
|
+
workspaceRoot: updated.workspace_root,
|
|
1534
|
+
actor: actor || null,
|
|
1535
|
+
eventType: 'proof_ready',
|
|
1536
|
+
payload,
|
|
1537
|
+
});
|
|
1538
|
+
return { ready: true, event, row: updated };
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// A held job: done and handed in (status review), still under its builder's name, with the
|
|
1543
|
+
// pull request it opened still open and waiting for a person to land it. The row names that
|
|
1544
|
+
// pull request by number, by link, or both. Null for any other row.
|
|
1545
|
+
function heldPullRequestOf(row) {
|
|
1546
|
+
if (!row || row.status !== 'review' || !row.claimed_by) return null;
|
|
1547
|
+
const metadata = row.metadata && typeof row.metadata === 'object' ? row.metadata : null;
|
|
1548
|
+
const pullRequest = metadata && metadata.pull_request;
|
|
1549
|
+
if (!pullRequest || typeof pullRequest !== 'object' || pullRequest.merge_commit || pullRequest.merged_at) return null;
|
|
1550
|
+
if (String(pullRequest.state || '').toLowerCase() !== 'open') return null;
|
|
1551
|
+
const number = Number(pullRequest.number);
|
|
1552
|
+
const url = String(pullRequest.url || '').trim();
|
|
1553
|
+
const held = {
|
|
1554
|
+
number: Number.isInteger(number) && number > 0 ? number : null,
|
|
1555
|
+
url: /^https?:\/\//i.test(url) ? url : null,
|
|
1370
1556
|
};
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1557
|
+
return held.number || held.url ? held : null;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// Which pull request a caller means: the repository as owner/name (any case) and the number (a
|
|
1561
|
+
// whole number, or digits). Anything else is a caller's mistake, not a pull request.
|
|
1562
|
+
function pullRequestIdentity(pullRequest) {
|
|
1563
|
+
const repo = String((pullRequest && pullRequest.repo) || '').trim().toLowerCase();
|
|
1564
|
+
const raw = pullRequest && pullRequest.number;
|
|
1565
|
+
const number = typeof raw === 'number' || (typeof raw === 'string' && /^\d+$/.test(raw.trim())) ? Number(raw) : NaN;
|
|
1566
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(repo) || !Number.isSafeInteger(number) || number <= 0) {
|
|
1567
|
+
throw new Error('pullRequest required: the repository as owner/name and the pull request number');
|
|
1568
|
+
}
|
|
1569
|
+
return { repo, number };
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// One pull request, one landing. Whether some other job has this pull request, as { id, how }, or
|
|
1573
|
+
// null when none does:
|
|
1574
|
+
// landed a job already landed with it: its row says the pull request merged (in any status, so a
|
|
1575
|
+
// job accepted or archived since still counts), or its history does (a hand-in or a landing
|
|
1576
|
+
// that recorded the merge, which a reopen no longer shows on the row). This counts for every
|
|
1577
|
+
// job, `exceptId` included: a job cannot land with one pull request twice.
|
|
1578
|
+
// live a job still on the board (open, claimed or in review) names it on its row, however the
|
|
1579
|
+
// link is decorated, in whatever state the link says. `exceptId` is the job asking.
|
|
1580
|
+
// The search spans every workspace, because a pull request is one thing on GitHub whichever folder
|
|
1581
|
+
// the job came from. A row that names only a number names no repository, so it is not matched.
|
|
1582
|
+
// What is compared is what each link names (repository and number, read the one way every link is
|
|
1583
|
+
// read), never how its text is spelled: the candidates are every job and every hand-in or landing
|
|
1584
|
+
// mark that records a pull request at all, and each link is parsed before it is compared. Looking
|
|
1585
|
+
// for the text `/pull/4707` would miss the same pull request written `/pull/0004707`.
|
|
1586
|
+
function pullRequestTakenBy(db, { repo, number, exceptId = null } = {}) {
|
|
1587
|
+
const wanted = pullRequestIdentity({ repo, number });
|
|
1588
|
+
const isWanted = (link) => {
|
|
1589
|
+
const named = pullRequestOfLink(link);
|
|
1590
|
+
return Boolean(named) && named.repo === wanted.repo && named.number === wanted.number;
|
|
1380
1591
|
};
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1592
|
+
const merged = (record) => Boolean(record) && typeof record === 'object'
|
|
1593
|
+
&& (String(record.state || '').toLowerCase() === 'merged' || Boolean(record.merge_commit) || Boolean(record.merged_at));
|
|
1594
|
+
// Broad on purpose: whatever records a pull request, read by SQLite as JSON (so escapes and
|
|
1595
|
+
// spacing do not matter), then read exactly below. Rows that are not JSON record nothing.
|
|
1596
|
+
const recordsPullRequest = (column) => `CASE WHEN json_valid(${column}) THEN json_type(${column}, '$.pull_request') END = 'object'`;
|
|
1597
|
+
let live = null;
|
|
1598
|
+
const rows = db.prepare(`SELECT id, status, metadata FROM tasks WHERE ${recordsPullRequest('metadata')} ORDER BY created_at, id`).all();
|
|
1599
|
+
for (const row of rows) {
|
|
1600
|
+
const metadata = safeJSON(row.metadata);
|
|
1601
|
+
const record = metadata && typeof metadata === 'object' ? metadata.pull_request : null;
|
|
1602
|
+
if (!record || typeof record !== 'object' || !isWanted(record.url)) continue;
|
|
1603
|
+
if (merged(record)) return { id: row.id, how: 'landed' };
|
|
1604
|
+
if (!live && row.id !== exceptId && OPEN_TASK_STATUSES.has(row.status)) live = { id: row.id, how: 'live' };
|
|
1605
|
+
}
|
|
1606
|
+
const events = db.prepare(`SELECT task_id, payload FROM task_events WHERE event_type IN ('proof_ready', 'completed') AND ${recordsPullRequest('payload')} ORDER BY created_at, version`).all();
|
|
1607
|
+
for (const event of events) {
|
|
1608
|
+
const payload = safeJSON(event.payload);
|
|
1609
|
+
const record = payload && typeof payload === 'object' ? payload.pull_request : null;
|
|
1610
|
+
if (record && typeof record === 'object' && isWanted(record.url) && merged(record)) return { id: event.task_id, how: 'landed' };
|
|
1611
|
+
}
|
|
1612
|
+
return live;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// The two writers below settle a held job from what GitHub said. Each takes `expected`, what the
|
|
1616
|
+
// caller read before it asked GitHub (when the row last changed, who holds it, which pull request
|
|
1617
|
+
// it names), and writes only if the row is still exactly that: GitHub answers slowly, and a job
|
|
1618
|
+
// someone archived, accepted or re-sent with a new pull request in the meantime is not the one
|
|
1619
|
+
// that was asked about. The check and the write share one transaction, so nothing slips between.
|
|
1620
|
+
function heldRowToSettle(db, { id, ws, expected }) {
|
|
1621
|
+
const row = getTask(db, id);
|
|
1622
|
+
if (!row) return { reason: 'not_found' };
|
|
1623
|
+
if (!rowInWorkspace(row, ws)) return { reason: 'not_in_workspace' };
|
|
1624
|
+
if (row.status !== 'review') return { reason: `already_${row.status}` };
|
|
1625
|
+
const held = heldPullRequestOf(row);
|
|
1626
|
+
if (!held) return { reason: 'not_held' };
|
|
1627
|
+
const sameUpdate = Number(expected.updatedAt) === Number(row.updated_at);
|
|
1628
|
+
const sameHolder = (expected.claimedBy ?? null) === row.claimed_by;
|
|
1629
|
+
const samePullRequest = (expected.pullRequestUrl ?? null) === held.url;
|
|
1630
|
+
if (!sameUpdate || !sameHolder || !samePullRequest) return { reason: 'stale_task_state' };
|
|
1631
|
+
return { row, held };
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
function requireSettleArguments({ id, actor, ws, expected, pullRequest }) {
|
|
1635
|
+
if (!id) throw new Error('id required');
|
|
1636
|
+
if (!actor) throw new Error('actor required');
|
|
1637
|
+
if (!ws) throw new Error('workspaceRoot required');
|
|
1638
|
+
if (!expected || typeof expected !== 'object') {
|
|
1639
|
+
throw new Error('expected required: what the caller read about the job before it asked GitHub');
|
|
1388
1640
|
}
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1641
|
+
return pullRequestIdentity(pullRequest);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// What a settle that stopped because another job has the pull request tells its caller.
|
|
1645
|
+
const takenDetail = (taken) => ({ taken_by: taken.id, taken_how: taken.how });
|
|
1646
|
+
|
|
1647
|
+
// Inside one transaction: check the row, check that no other job has the pull request (the same
|
|
1648
|
+
// look the caller made before it asked GitHub, made again where nothing can slip in after it), then
|
|
1649
|
+
// let the caller write. Rolls back on any error.
|
|
1650
|
+
function settleHeldJob(db, { id, ws, expected, pullRequest }, write) {
|
|
1651
|
+
withBusyRetry(() => db.exec('BEGIN IMMEDIATE'));
|
|
1652
|
+
try {
|
|
1653
|
+
const found = heldRowToSettle(db, { id, ws, expected });
|
|
1654
|
+
let out;
|
|
1655
|
+
if (!found.row) {
|
|
1656
|
+
out = { refused: found.reason };
|
|
1657
|
+
} else {
|
|
1658
|
+
const taken = pullRequestTakenBy(db, { ...pullRequest, exceptId: id });
|
|
1659
|
+
out = taken ? { refused: 'pull_request_taken', taken } : write(found);
|
|
1660
|
+
}
|
|
1661
|
+
db.exec('COMMIT');
|
|
1662
|
+
return out;
|
|
1663
|
+
} catch (err) {
|
|
1664
|
+
try { db.exec('ROLLBACK'); } catch { /* the transaction is already gone */ }
|
|
1665
|
+
throw err;
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
// A person merged the held job's pull request. Record the merge the way any landed merge is
|
|
1670
|
+
// recorded (merge commit, pull request merged, merged at, who merged it) and close the job as
|
|
1671
|
+
// done with the action "merged", which the rating reads as landed by whoever held the claim.
|
|
1672
|
+
// A person's merge is the acceptance, so this is not a review pass: it adds no pass, certifies
|
|
1673
|
+
// nothing and stamps no accepted mark, and the review record stays exactly as the builder left it.
|
|
1674
|
+
// The event carries landed_at, the time of the merge, which the rating dates the landing by.
|
|
1675
|
+
// `pullRequest` ({ repo, number }) is the pull request GitHub answered for. One pull request, one
|
|
1676
|
+
// landing: if another job already landed with it, or another live job also points at it, nothing is
|
|
1677
|
+
// written and the answer is { landed: false, reason: 'pull_request_taken', taken_by, taken_how }.
|
|
1678
|
+
function landHeldMerge(db, { id, actor, workspaceRoot: ws, expected, pullRequest, merge, proof } = {}) {
|
|
1679
|
+
const identity = requireSettleArguments({ id, actor, ws, expected, pullRequest });
|
|
1680
|
+
const mergeRecord = cleanMergeRecord(merge);
|
|
1681
|
+
// The landing is dated when the merge happened, so a merge with no usable time cannot be recorded.
|
|
1682
|
+
if (!mergeRecord || !mergeRecord.merged_at) return { landed: false, reason: 'invalid_merge_record' };
|
|
1683
|
+
const out = settleHeldJob(db, { id, ws, expected, pullRequest: identity }, ({ row }) => {
|
|
1684
|
+
const now = Math.max(Date.now(), Number(row.updated_at || 0) + 1);
|
|
1685
|
+
const metadata = { ...row.metadata };
|
|
1686
|
+
delete metadata.lease;
|
|
1687
|
+
metadata.merge_commit = mergeRecord.merge_commit;
|
|
1688
|
+
metadata.pull_request = { state: 'merged', ...mergeRecord };
|
|
1689
|
+
const changed = db.prepare(`
|
|
1690
|
+
UPDATE tasks
|
|
1691
|
+
SET status = 'done', done_at = ?, updated_at = ?, metadata = ?
|
|
1692
|
+
WHERE id = ? AND status = 'review' AND claimed_by IS ? AND updated_at = ?
|
|
1693
|
+
`).run(now, now, JSON.stringify(metadata), id, row.claimed_by, row.updated_at);
|
|
1694
|
+
if (changed.changes !== 1) return { refused: 'stale_task_state' };
|
|
1695
|
+
const event = appendTaskEvent(db, {
|
|
1696
|
+
taskId: id,
|
|
1697
|
+
workspaceRoot: row.workspace_root,
|
|
1698
|
+
actor,
|
|
1699
|
+
eventType: 'completed',
|
|
1700
|
+
// landed_at is when the merge happened: the rating dates the landing by it, not by the day
|
|
1701
|
+
// this was written down, so sweeping an old merge in later does not make it count as new.
|
|
1702
|
+
payload: { status: 'done', action: 'merged', pull_request: metadata.pull_request, landed_at: mergeRecord.merged_at },
|
|
1703
|
+
});
|
|
1704
|
+
return { row: getTask(db, id), event };
|
|
1395
1705
|
});
|
|
1396
|
-
return {
|
|
1706
|
+
if (out.refused) return { landed: false, reason: out.refused, ...(out.taken ? takenDetail(out.taken) : {}) };
|
|
1707
|
+
// The landing is on file. The journal line is a courtesy, and a failure to write it must not
|
|
1708
|
+
// undo or hide a landing that already counts.
|
|
1709
|
+
let logs = {};
|
|
1710
|
+
try {
|
|
1711
|
+
logs = appendTaskCompletionLogs(db, out.row, {
|
|
1712
|
+
status: 'done',
|
|
1713
|
+
actor,
|
|
1714
|
+
action: 'merged',
|
|
1715
|
+
proof: proof || `pull request ${mergeRecord.number || ''} was merged${mergeRecord.merged_by ? ` by ${mergeRecord.merged_by}` : ''}`.replace(/\s+/g, ' '),
|
|
1716
|
+
});
|
|
1717
|
+
} catch { logs = {}; }
|
|
1718
|
+
return { landed: true, row: out.row, event: out.event, logs };
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
// The held job's pull request was closed without merging, so the work did not land. Record the
|
|
1722
|
+
// pull request as closed and let the claim go with the reason, which the rating counts as a
|
|
1723
|
+
// miss for the builder (and a person can still mark the job not needed). The closed pull request
|
|
1724
|
+
// stays on the row, so nothing mistakes the dead attempt's open one for the next attempt's. The
|
|
1725
|
+
// dead attempt's review record is cleared, as a reopen clears it, so a pass the last builder
|
|
1726
|
+
// earned never counts toward the next builder's certification. `pullRequest` ({ repo, number }) is
|
|
1727
|
+
// the pull request GitHub answered for; if another job has it (see pullRequestTakenBy), nothing is
|
|
1728
|
+
// written and the answer is { released: false, reason: 'pull_request_taken', taken_by, taken_how }.
|
|
1729
|
+
function releaseClosedPullRequest(db, { id, actor, workspaceRoot: ws, expected, pullRequest, reason } = {}) {
|
|
1730
|
+
const identity = requireSettleArguments({ id, actor, ws, expected, pullRequest });
|
|
1731
|
+
const releaseReason = compactEpisodeText(reason, RELEASE_REASON_MAX);
|
|
1732
|
+
if (!releaseReason) throw new Error('reason required');
|
|
1733
|
+
const out = settleHeldJob(db, { id, ws, expected, pullRequest: identity }, ({ row, held }) => {
|
|
1734
|
+
const now = Math.max(Date.now(), Number(row.updated_at || 0) + 1);
|
|
1735
|
+
const closed = { state: 'closed', ...(held.number ? { number: held.number } : {}), ...(held.url ? { url: held.url } : {}) };
|
|
1736
|
+
const metadata = { ...row.metadata };
|
|
1737
|
+
delete metadata.lease;
|
|
1738
|
+
for (const key of ATTEMPT_RECORD_KEYS) delete metadata[key];
|
|
1739
|
+
metadata.pull_request = closed;
|
|
1740
|
+
const changed = db.prepare(`
|
|
1741
|
+
UPDATE tasks
|
|
1742
|
+
SET status = 'open', claimed_by = NULL, claimed_at = NULL, done_at = NULL, updated_at = ?, metadata = ?
|
|
1743
|
+
WHERE id = ? AND status = 'review' AND claimed_by IS ? AND updated_at = ?
|
|
1744
|
+
`).run(now, JSON.stringify(metadata), id, row.claimed_by, row.updated_at);
|
|
1745
|
+
if (changed.changes !== 1) return { refused: 'stale_task_state' };
|
|
1746
|
+
const event = appendTaskEvent(db, {
|
|
1747
|
+
taskId: id,
|
|
1748
|
+
workspaceRoot: row.workspace_root,
|
|
1749
|
+
actor,
|
|
1750
|
+
eventType: 'claim_released',
|
|
1751
|
+
payload: { released_by: actor, previous_claimed_by: row.claimed_by, reason: releaseReason, pull_request: closed },
|
|
1752
|
+
});
|
|
1753
|
+
return { row: getTask(db, id), event };
|
|
1754
|
+
});
|
|
1755
|
+
if (out.refused) return { released: false, reason: out.refused, ...(out.taken ? takenDetail(out.taken) : {}) };
|
|
1756
|
+
return { released: true, row: out.row, event: out.event };
|
|
1397
1757
|
}
|
|
1398
1758
|
|
|
1399
1759
|
function setTaskResult(db, { id, actor, result }) {
|
|
@@ -1677,7 +2037,19 @@ function backlogPacketFromPayload(payload) {
|
|
|
1677
2037
|
return lines.filter(Boolean).join('\n');
|
|
1678
2038
|
}
|
|
1679
2039
|
|
|
1680
|
-
|
|
2040
|
+
// A `do` stage takes the claim (from open, or over a claimed job nobody holds), drops the last attempt's link and
|
|
2041
|
+
// merge commit, writes the stage, and writes the history entries for them: a claimed entry when this call took the
|
|
2042
|
+
// claim, then work_started. All of it is one transaction. Nobody sees the claim before its entry, a hand-in cannot
|
|
2043
|
+
// land between the two and leave a claim with no entry (the rating would tie the landing to the claim before it),
|
|
2044
|
+
// and an entry that cannot be saved takes the claim and the stage back with it. The job is read inside the
|
|
2045
|
+
// transaction too, so what `do` decides on is still true when it writes. The plan stage claims nothing and is not
|
|
2046
|
+
// wrapped.
|
|
2047
|
+
function stageTask(db, options) {
|
|
2048
|
+
if (cleanStageText(options && options.stage).toLowerCase() !== 'do') return stageTaskSteps(db, options);
|
|
2049
|
+
return inWriteTransaction(db, () => stageTaskSteps(db, options));
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
function stageTaskSteps(db, {
|
|
1681
2053
|
id,
|
|
1682
2054
|
actor,
|
|
1683
2055
|
stage,
|
|
@@ -1797,12 +2169,16 @@ function stageTask(db, {
|
|
|
1797
2169
|
if (targetStage === 'do' && workingRow.status === 'open') {
|
|
1798
2170
|
claimedWorker = stageOwner || cleanStageText(actor) || process.env.ATRIS_AGENT_ID || process.env.USER || 'unknown';
|
|
1799
2171
|
const claimTime = Math.max(Date.now(), Number(workingRow.updated_at || 0) + 1);
|
|
2172
|
+
// Starting work on an open job is a new attempt: the last one's pull request link and merge
|
|
2173
|
+
// commit are not this attempt's. The write that takes the claim drops them, so no hand-in can
|
|
2174
|
+
// land between the claim and the drop and keep them (see claimTask).
|
|
1800
2175
|
const claimed = withBusyRetry(() => db.prepare(`
|
|
1801
2176
|
UPDATE tasks
|
|
1802
2177
|
SET status = 'claimed',
|
|
1803
2178
|
claimed_by = ?,
|
|
1804
2179
|
claimed_at = ?,
|
|
1805
|
-
updated_at =
|
|
2180
|
+
updated_at = ?,
|
|
2181
|
+
metadata = ${NEW_ATTEMPT_METADATA_SQL}
|
|
1806
2182
|
WHERE id = ?
|
|
1807
2183
|
AND status = 'open'
|
|
1808
2184
|
AND updated_at = ?
|
|
@@ -1870,6 +2246,8 @@ function stageTask(db, {
|
|
|
1870
2246
|
} else {
|
|
1871
2247
|
const worker = workingRow && workingRow.claimed_by || stageOwner || cleanStageText(actor) || null;
|
|
1872
2248
|
if (workingRow && workingRow.status === 'claimed' && !workingRow.claimed_by && worker) {
|
|
2249
|
+
// Taking over a job nobody owns is a new attempt too.
|
|
2250
|
+
for (const key of NEW_ATTEMPT_KEYS) delete metadata[key];
|
|
1873
2251
|
result = withBusyRetry(() => db.prepare(`
|
|
1874
2252
|
UPDATE tasks
|
|
1875
2253
|
SET done_at = NULL,
|
|
@@ -2137,12 +2515,13 @@ function reviewTask(db, { id, actor, reward, lesson, nextTask, proof, verify, ca
|
|
|
2137
2515
|
const reviewer = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
|
|
2138
2516
|
const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
|
|
2139
2517
|
const reviewerNorm = reviewIntegrity.normalizeActor(reviewer);
|
|
2140
|
-
|
|
2141
|
-
//
|
|
2142
|
-
//
|
|
2518
|
+
// judge != worker applies pre-acceptance only: nobody who ever built a row can positive-reward
|
|
2519
|
+
// it while it still sits in review, whoever built it last (the stamped builder, the claimant, and
|
|
2520
|
+
// everyone who ever claimed it or handed work in on it: see authorsOfTask). Done rows are past
|
|
2143
2521
|
// the human gate; finish and post-acceptance XP bookkeeping stay open.
|
|
2144
|
-
|
|
2145
|
-
|
|
2522
|
+
const authors = numericReward > 0 && row.status === 'review' && reviewerNorm ? authorsOfTask(db, row) : null;
|
|
2523
|
+
if (authors && authors.has(reviewerNorm)) {
|
|
2524
|
+
return { reviewed: false, reason: 'judge_equals_worker', builder: reviewerNorm };
|
|
2146
2525
|
}
|
|
2147
2526
|
const proofText = String(proof || '').trim();
|
|
2148
2527
|
const verifyText = cleanStageText(verify);
|
|
@@ -2200,7 +2579,7 @@ function reviewTask(db, { id, actor, reward, lesson, nextTask, proof, verify, ca
|
|
|
2200
2579
|
if (reviewingPendingProof) {
|
|
2201
2580
|
reviewPassCount += 1;
|
|
2202
2581
|
metadata.agent_review_pass_count = reviewPassCount;
|
|
2203
|
-
const passRecord = recordReviewPassActor(metadata, row.claimed_by, reviewer);
|
|
2582
|
+
const passRecord = recordReviewPassActor(metadata, row.claimed_by, reviewer, { authors: authorsOfTask(db, row), handIn: false });
|
|
2204
2583
|
if (reviewPassCount >= AGENT_CERTIFICATION_REVIEW_PASSES && passRecord.independent) {
|
|
2205
2584
|
metadata.agent_certified = true;
|
|
2206
2585
|
metadata.agent_certified_at = new Date(now).toISOString();
|
|
@@ -2752,6 +3131,11 @@ module.exports = {
|
|
|
2752
3131
|
archiveNotNeeded,
|
|
2753
3132
|
relabelArchivedTasks,
|
|
2754
3133
|
readyTask,
|
|
3134
|
+
authorsOfTask,
|
|
3135
|
+
heldPullRequestOf,
|
|
3136
|
+
pullRequestTakenBy,
|
|
3137
|
+
landHeldMerge,
|
|
3138
|
+
releaseClosedPullRequest,
|
|
2755
3139
|
setTaskResult,
|
|
2756
3140
|
reviseTask,
|
|
2757
3141
|
stageTask,
|