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/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 metadata ELSE json_set(COALESCE(metadata, '{}'), '$.lease', json(?)) END
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, '$.lease') ELSE metadata END
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 review pass and answer whether certification is
1251
- // allowed: at least one recorded pass actor must differ from the builder.
1252
- // The first pass actor stamps built_by; judge != worker from then on.
1253
- function recordReviewPassActor(metadata, claimedBy, rawActor) {
1254
- const actorNorm = reviewIntegrity.normalizeActor(
1255
- rawActor || process.env.ATRIS_AGENT_ID || process.env.USER || ''
1256
- );
1257
- if (!metadata.built_by && actorNorm) metadata.built_by = actorNorm;
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
- const builder = reviewIntegrity.normalizeActor(metadata.built_by || claimedBy || '');
1262
- const independent = builder
1263
- ? (actors.find((a) => a && a !== builder) || null)
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: builder || null, independent };
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
- function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, merge = null, resultTrace, result: resultSentence, reason, workRoot }) {
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 row = getTask(db, id);
1301
- if (!row) return { ready: false, reason: 'not_found' };
1302
- if (!['open', 'claimed', 'review'].includes(row.status)) {
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
- if (actor && row.claimed_by && actor === row.claimed_by && !reviewIntegrity.isReservedActor(actor)) {
1339
- const head = workspaceHead(workRoot || row.workspace_root);
1340
- if (head) metadata.work_commit = head;
1341
- }
1342
- const passRecord = recordReviewPassActor(metadata, row.claimed_by, actor);
1343
- if (reviewPassCount >= AGENT_CERTIFICATION_REVIEW_PASSES && passRecord.independent) {
1344
- metadata.agent_certified = true;
1345
- metadata.agent_certified_at = new Date(now).toISOString();
1346
- metadata.agent_certified_by = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
1347
- metadata.agent_certification_policy = `${AGENT_CERTIFICATION_REVIEW_PASSES}_agent_review_passes`;
1348
- metadata.independent_review_by = passRecord.independent;
1349
- }
1350
- const result = withBusyRetry(() => db.prepare(`
1351
- UPDATE tasks
1352
- SET status = 'review',
1353
- done_at = NULL,
1354
- updated_at = ?,
1355
- metadata = ?
1356
- WHERE id = ?
1357
- AND status = ? AND claimed_by IS ? AND claimed_at IS ?
1358
- `).run(now, JSON.stringify(metadata), id, row.status, row.claimed_by, row.claimed_at));
1359
- if (result.changes !== 1) return { ready: false, reason: 'claim changed hands' };
1360
- const updated = getTask(db, id);
1361
- const payload = {
1362
- proof: text,
1363
- lesson: metadata.latest_agent_lesson,
1364
- next_task: metadata.latest_agent_next_task,
1365
- approval_status: 'pending',
1366
- review_pass_count: reviewPassCount,
1367
- agent_certified: metadata.agent_certified === true,
1368
- agent_certification_policy: metadata.agent_certification_policy || null,
1369
- result: metadata.result || null,
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
- if (resultTrace && typeof resultTrace === 'object') {
1372
- payload.result_trace = resultTrace;
1373
- payload.result_packet = `TASK_RESULT_TRACE ${JSON.stringify(resultTrace)}`;
1374
- }
1375
- payload.landing = {
1376
- happened: metadata.landing_happened || null,
1377
- checked: metadata.landing_checked || null,
1378
- tested: metadata.landing_tested || null,
1379
- decision: metadata.landing_decision || null,
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
- if (mergeRecord) {
1382
- payload.landing.merge_commit = mergeRecord.merge_commit;
1383
- payload.pull_request = { state: 'merged', ...mergeRecord };
1384
- // Landed by the flight with no person in the loop.
1385
- if (merge.self_landed === true) payload.self_landed = true;
1386
- } else if (openPullRequest) {
1387
- payload.pull_request = openPullRequest;
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
- const event = appendTaskEvent(db, {
1390
- taskId: id,
1391
- workspaceRoot: updated.workspace_root,
1392
- actor: actor || null,
1393
- eventType: 'proof_ready',
1394
- payload,
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 { ready: true, event, row: updated };
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
- function stageTask(db, {
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
- const builderNorm = reviewIntegrity.taskBuilder(row);
2141
- // judge != worker applies pre-acceptance only: a builder cannot positive-
2142
- // reward their own row while it still sits in review. Done rows are past
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
- if (numericReward > 0 && row.status === 'review' && builderNorm && reviewerNorm && reviewerNorm === builderNorm) {
2145
- return { reviewed: false, reason: 'judge_equals_worker', builder: builderNorm };
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,