atris 3.74.29 → 3.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/task-db.js CHANGED
@@ -27,7 +27,8 @@ 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 { PROOF_NOT_SAVED } = require('./release-kind');
30
+ const { pullRequestOfLink } = require('./pull-request-link');
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');
33
34
  const { isDecisionTask } = require('./task-decision');
@@ -593,45 +594,119 @@ function workspaceHead(workspaceRoot) {
593
594
  }
594
595
  }
595
596
 
596
- // Atomic claim. Returns { claimed: true, row } only if THIS call won the row.
597
- // Race-safe via single UPDATE with WHERE status='open' guard. SQLite serializes
598
- // writes; busy_timeout absorbs contention. Caller must check `.claimed`.
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
+
610
+ // One row change and its history entry are one transaction: both are in the store or neither is, so nobody can see,
611
+ // or compare against, a job whose row says one thing and whose history says another, and a history write that fails
612
+ // takes the row change back with it. BEGIN IMMEDIATE takes the write lock first, so what the writer read is still
613
+ // true when it writes. A caller already inside a transaction of its own joins that one (db.isTransaction says so on
614
+ // the Node versions that have it; on the others SQLite refuses to begin inside one, and that refusal says the same).
615
+ function inWriteTransaction(db, write) {
616
+ if (db.isTransaction) return write();
617
+ try {
618
+ withBusyRetry(() => db.exec('BEGIN IMMEDIATE'));
619
+ } catch (err) {
620
+ if (/within a transaction/i.test(String(err && err.message))) return write();
621
+ throw err;
622
+ }
623
+ let result;
624
+ try {
625
+ result = write();
626
+ db.exec('COMMIT');
627
+ } catch (err) {
628
+ try { db.exec('ROLLBACK'); } catch { /* the transaction is already gone */ }
629
+ throw err;
630
+ }
631
+ return result;
632
+ }
633
+
634
+ // The part of a write that holds it to one claim. The caller names a claim by its claimed event (bound twice, once
635
+ // for the NULL test, which leaves a write that names none as it always was). The write goes through only when that
636
+ // event is the job's latest claimed event and is no older than the row's own claim time, which is how an older CLI,
637
+ // that saves the row first and its event a moment later, shows a claim that is not here yet. It sits inside the
638
+ // write itself, one statement, so a claim that lands after the caller's last look is refused too, and it reads the
639
+ // claim by its event, not by a time, which two claims can share.
640
+ const NAMED_CLAIM_SQL = `AND (? IS NULL OR EXISTS (
641
+ SELECT 1 FROM task_events e
642
+ WHERE e.task_id = tasks.id
643
+ AND e.event_type = 'claimed'
644
+ AND e.event_id = ?
645
+ AND (tasks.claimed_at IS NULL OR e.created_at >= tasks.claimed_at)
646
+ AND e.version = (SELECT MAX(version) FROM task_events WHERE task_id = tasks.id AND event_type = 'claimed')))`;
647
+
648
+ // Atomic claim. Returns { claimed: true, row, event } only if THIS call won the row; event is the claimed event
649
+ // this claim wrote, the one name for it (see latestClaim). Race-safe via single UPDATE with WHERE status='open'
650
+ // guard. SQLite serializes writes; busy_timeout absorbs contention. Caller must check `.claimed`.
651
+ //
652
+ // The row and its claimed event are written in one transaction. Before, the row was claimed first and the event
653
+ // followed a git call later, and in that gap a late release naming the claim before could pass for the new one.
599
654
  function claimTask(db, { id, claimedBy, leaseMinutes, leasePid }) {
600
655
  if (!id) throw new Error('id required');
601
656
  if (!claimedBy) throw new Error('claimedBy required');
602
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.
603
662
  const stmt = db.prepare(`
604
663
  UPDATE tasks
605
664
  SET status = 'claimed',
606
665
  claimed_by = ?,
607
666
  claimed_at = ?,
608
667
  updated_at = ?,
609
- 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
610
670
  WHERE id = ?
611
671
  AND status = 'open'
612
672
  `);
613
673
  const lease = leaseMinutes == null ? null : JSON.stringify({ until: new Date(now + leaseMinutes * 60000).toISOString(), minutes: leaseMinutes, holder: claimedBy, pid: leasePid });
614
- const result = withBusyRetry(() => stmt.run(claimedBy, now, now, lease, lease, id));
615
- if (result.changes === 1) {
674
+ const won = inWriteTransaction(db, () => {
675
+ const result = withBusyRetry(() => stmt.run(claimedBy, now, now, lease, lease, id));
676
+ if (result.changes !== 1) return null;
616
677
  let row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
617
- // First claim wins: a re-claim after a revision keeps the original start.
678
+ // The commit the workspace sits on is read here, with the write lock held, and only for a claim that won. A claim
679
+ // that has to wait for the lock is made when it gets it, and a commit that landed in the checkout while it waited
680
+ // is part of what was there before the job began: read earlier, the start would name a commit older than the claim,
681
+ // and a job that made no commit could be credited with the one that landed (lib/landing-proof.js). A git that is
682
+ // slow to answer holds the lock for no longer than workspaceHead lets it wait.
618
683
  const startCommit = workspaceHead(row.workspace_root);
684
+ // First claim wins: a re-claim after a revision keeps the original start.
619
685
  const claimedMetadata = row.metadata ? safeJSON(row.metadata) || {} : {};
620
686
  if (startCommit && !claimedMetadata.start_commit) {
621
687
  claimedMetadata.start_commit = startCommit;
622
688
  withBusyRetry(() => db.prepare('UPDATE tasks SET metadata = ? WHERE id = ?').run(JSON.stringify(claimedMetadata), id));
623
689
  row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
624
690
  }
625
- appendTaskEvent(db, {
691
+ const event = appendTaskEvent(db, {
626
692
  taskId: id,
627
693
  workspaceRoot: row.workspace_root,
628
694
  actor: claimedBy,
629
695
  eventType: 'claimed',
630
696
  payload: { claimed_by: claimedBy, ...(startCommit ? { start_commit: startCommit } : {}) },
631
697
  });
632
- const enriched = { ...row, metadata: row.metadata ? safeJSON(row.metadata) : null };
633
- appendTaskWorkLog(db, enriched, { actor: claimedBy, title: 'Task claimed' });
634
- return { claimed: true, row: enriched };
698
+ return { row, event };
699
+ });
700
+ if (won) {
701
+ const enriched = { ...won.row, metadata: won.row.metadata ? safeJSON(won.row.metadata) : null };
702
+ // The work log is a note for people, written once the claim is saved. A note that cannot be written must not
703
+ // turn a claim that landed into an error: the caller would not learn which claim is its own.
704
+ try {
705
+ appendTaskWorkLog(db, enriched, { actor: claimedBy, title: 'Task claimed' });
706
+ } catch (err) {
707
+ if (process.env.ATRIS_DEBUG) process.stderr.write(`claim log failed: ${String(err?.message ?? err).replace(/[\r\n]+/g, ' ')}\n`);
708
+ }
709
+ return { claimed: true, row: enriched, event: won.event };
635
710
  }
636
711
  // Either id doesn't exist or status != 'open'. Tell the caller which.
637
712
  const row = db.prepare('SELECT id, status, claimed_by FROM tasks WHERE id = ?').get(id);
@@ -639,14 +714,19 @@ function claimTask(db, { id, claimedBy, leaseMinutes, leasePid }) {
639
714
  return { claimed: false, reason: 'already_' + row.status, claimed_by: row.claimed_by };
640
715
  }
641
716
 
642
- // Lease updates are guarded by the holder and do not create activity events.
643
- function setLease(db, { id, holder, minutes = 60, pid = process.pid, now = Date.now() }) {
717
+ // Lease updates are guarded by the holder and do not create activity events. A flight also names the claim it
718
+ // made (claimEventId, see NAMED_CLAIM_SQL): the lease is written, renewed, or cleared only while the job is
719
+ // still held under that claim, in the same statement, so a flight that lost its job cannot replace or clear the
720
+ // lease of the claim that came after it, even under the same builder name. One statement is one transaction,
721
+ // and a lease has no history entry, so there is nothing more to keep together.
722
+ function setLease(db, { id, holder, minutes = 60, pid = process.pid, now = Date.now(), claimEventId }) {
644
723
  const raw = db.prepare('SELECT metadata FROM tasks WHERE id = ?').get(id);
645
724
  const row = getTask(db, id);
646
725
  if (!row || row.metadata_unreadable || row.status !== 'claimed' || row.claimed_by !== holder) return false;
726
+ const named = claimEventId ? String(claimEventId) : null;
647
727
  const metadata = { ...(row.metadata || {}), lease: { until: new Date(Number(now) + minutes * 60000).toISOString(), minutes, holder, pid } };
648
- return withBusyRetry(() => db.prepare("UPDATE tasks SET metadata = ? WHERE id = ? AND status = 'claimed' AND claimed_by = ? AND metadata IS ?")
649
- .run(JSON.stringify(metadata), id, holder, raw.metadata)).changes === 1;
728
+ return withBusyRetry(() => db.prepare(`UPDATE tasks SET metadata = ? WHERE id = ? AND status = 'claimed' AND claimed_by = ? AND metadata IS ? ${NAMED_CLAIM_SQL}`)
729
+ .run(JSON.stringify(metadata), id, holder, raw.metadata, named, named)).changes === 1;
650
730
  }
651
731
 
652
732
  function renewLease(db, options) {
@@ -654,21 +734,23 @@ function renewLease(db, options) {
654
734
  const metadata = row && row.metadata ? safeJSON(row.metadata) : null;
655
735
  const lease = metadata && metadata.lease;
656
736
  if (!row || row.status !== 'claimed' || row.claimed_by !== options.holder || !lease || lease.holder !== options.holder || (options.pid != null && lease.pid !== options.pid)) return false;
737
+ const named = options.claimEventId ? String(options.claimEventId) : null;
657
738
  const minutes = options.minutes ?? lease.minutes;
658
739
  const now = options.now ?? Date.now();
659
740
  const updated = { ...metadata, lease: { until: new Date(Number(now) + minutes * 60000).toISOString(), minutes, holder: options.holder, pid: options.pid ?? lease.pid } };
660
- return withBusyRetry(() => db.prepare("UPDATE tasks SET metadata = ? WHERE id = ? AND status = 'claimed' AND claimed_by = ? AND claimed_at IS ? AND metadata IS ?")
661
- .run(JSON.stringify(updated), row.id, row.claimed_by, row.claimed_at, row.metadata)).changes === 1;
741
+ return withBusyRetry(() => db.prepare(`UPDATE tasks SET metadata = ? WHERE id = ? AND status = 'claimed' AND claimed_by = ? AND claimed_at IS ? AND metadata IS ? ${NAMED_CLAIM_SQL}`)
742
+ .run(JSON.stringify(updated), row.id, row.claimed_by, row.claimed_at, row.metadata, named, named)).changes === 1;
662
743
  }
663
744
 
664
- function clearLease(db, { id, holder }) {
745
+ function clearLease(db, { id, holder, claimEventId }) {
665
746
  const raw = db.prepare('SELECT metadata FROM tasks WHERE id = ?').get(id);
666
747
  const row = getTask(db, id);
667
748
  if (!row || !row.metadata || !row.metadata.lease || row.metadata.lease.holder !== holder) return false;
749
+ const named = claimEventId ? String(claimEventId) : null;
668
750
  const metadata = { ...row.metadata };
669
751
  delete metadata.lease;
670
- return withBusyRetry(() => db.prepare('UPDATE tasks SET metadata = ? WHERE id = ? AND metadata IS ?')
671
- .run(JSON.stringify(metadata), id, raw.metadata)).changes === 1;
752
+ return withBusyRetry(() => db.prepare(`UPDATE tasks SET metadata = ? WHERE id = ? AND metadata IS ? ${NAMED_CLAIM_SQL}`)
753
+ .run(JSON.stringify(metadata), id, raw.metadata, named, named)).changes === 1;
672
754
  }
673
755
 
674
756
  function pidAlive(pid) {
@@ -705,6 +787,7 @@ function expireLeases(db, { now = Date.now(), staleDays, claimer, dryRun = false
705
787
  try {
706
788
  const nextMetadata = metadata && typeof metadata === 'object' ? { ...metadata } : {};
707
789
  delete nextMetadata.lease;
790
+ for (const key of LET_GO_RESET_KEYS) delete nextMetadata[key];
708
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 ?`)
709
792
  .run(Date.now(), JSON.stringify(nextMetadata), row.id, row.claimed_by, row.updated_at, row.metadata, row.last_seen_at);
710
793
  if (changed.changes === 1) {
@@ -743,23 +826,103 @@ function sweepExpiredLeases() {
743
826
  // line. A release that must not read as a drop (the work is done and its pull
744
827
  // request waits for a person, only the proof could not be saved) also carries a
745
828
  // kind and that pull request as fields, so the rating never has to read words.
746
- // Only the dispatch flight passes them, in its own process; `atris task release`
829
+ // Only the flights pass them, in their own process; `atris task release`
747
830
  // takes neither, so a worker cannot excuse its own miss with a flag. The two go
748
- // together or not at all; half a marker leaves the claim held.
831
+ // together or not at all; half a marker leaves the claim held. One kind goes
832
+ // alone: could_not_start, for a claim let go before the engine ran, takes no
833
+ // pull request and is refused with one.
749
834
  const RELEASE_REASON_MAX = 300;
750
835
 
751
836
  function releaseMarker(kind, pullRequest) {
752
837
  const named = kind !== undefined && kind !== null && String(kind).trim() !== '';
753
838
  if (!named && !pullRequest) return { marker: null };
839
+ if (named && String(kind).trim() === COULD_NOT_START && !pullRequest) return { marker: { kind: COULD_NOT_START } };
754
840
  const open = cleanOpenPullRequest(pullRequest);
755
841
  if (!named || String(kind).trim() !== PROOF_NOT_SAVED || !open) return { invalid: true };
756
842
  return { marker: { kind: PROOF_NOT_SAVED, pull_request: open } };
757
843
  }
758
844
 
759
- function releaseTask(db, { id, actor, expectedUpdatedAt, reason, kind, pullRequest }) {
845
+ // The claim a job is held under now: its latest claimed event. The event id names that one claim, so a
846
+ // flight that remembers it can tell later whether the job is still held under the claim it made, or was
847
+ // let go and claimed again, even under the same builder name.
848
+ function latestClaim(db, id) {
849
+ return db.prepare("SELECT event_id, version, actor, created_at FROM task_events WHERE task_id = ? AND event_type = 'claimed' ORDER BY version DESC LIMIT 1").get(id) || null;
850
+ }
851
+
852
+ // True when the claim event the caller names is the claim the job is held under now. claimTask writes the row and
853
+ // its claimed event in one transaction, so here the two always agree. A claim saved by an older CLI wrote the row
854
+ // first and its event a moment later, and in that gap the latest event is still the claim before it: an event
855
+ // written before the row's own claim time is that older claim, not the one the row holds.
856
+ function claimIsCurrent(row, claim, claimEventId) {
857
+ if (!claim || claim.event_id !== claimEventId) return false;
858
+ return row.claimed_at == null || Number(claim.created_at) >= Number(row.claimed_at);
859
+ }
860
+
861
+ // True when the engine was asked on the job's current claim: a build_started event sits after its latest
862
+ // claimed event (see markBuildStarted).
863
+ function claimMarkedStarted(db, id) {
864
+ const claim = latestClaim(db, id);
865
+ return Boolean(claim) && Boolean(db.prepare('SELECT 1 AS hit FROM task_events WHERE task_id = ? AND event_type = ? AND version > ? LIMIT 1').get(id, BUILD_STARTED, claim.version));
866
+ }
867
+
868
+ // A flight writes this right before it asks the assigned engine, on the claim it made. It is what lets the
869
+ // store and the rating tell a claim let go before the engine ran from one let go after it: a release that
870
+ // says could_not_start on a marked claim is an ordinary release (see releaseTask). Only the holder can mark,
871
+ // and claimEventId, when given, names the one claim the caller made, so a run that lost its claim cannot mark
872
+ // the next run's. Marking twice adds nothing. A refusal writes nothing.
873
+ function markBuildStarted(db, { id, actor, claimEventId }) {
760
874
  if (!id) throw new Error('id required');
761
875
  if (!actor) throw new Error('actor required');
762
- const { marker, invalid } = releaseMarker(kind, pullRequest);
876
+ withBusyRetry(() => db.exec('BEGIN IMMEDIATE'));
877
+ try {
878
+ const row = getTask(db, id);
879
+ let result;
880
+ if (!row) result = { marked: false, reason: 'not_found' };
881
+ else if (row.status !== 'claimed') result = { marked: false, reason: `already_${row.status}`, claimed_by: row.claimed_by || null };
882
+ else if (row.claimed_by !== actor) result = { marked: false, reason: 'held_by_other', claimed_by: row.claimed_by || null };
883
+ else {
884
+ const claim = latestClaim(db, id);
885
+ if (!claim) result = { marked: false, reason: 'no_claim_event' };
886
+ else if (claimEventId && !claimIsCurrent(row, claim, claimEventId)) result = { marked: false, reason: 'claim_changed', claimed_by: row.claimed_by || null };
887
+ else if (claimMarkedStarted(db, id)) result = { marked: true, already: true };
888
+ else {
889
+ result = {
890
+ marked: true,
891
+ event: appendTaskEvent(db, {
892
+ taskId: id,
893
+ workspaceRoot: row.workspace_root,
894
+ actor,
895
+ eventType: BUILD_STARTED,
896
+ payload: { claimed_by: actor, claim_event_id: claim.event_id },
897
+ }),
898
+ };
899
+ }
900
+ }
901
+ db.exec('COMMIT');
902
+ return result;
903
+ } catch (err) { db.exec('ROLLBACK'); throw err; }
904
+ }
905
+
906
+ // claimEventId, when given, names the one claim the caller made (see latestClaim). A job that was let go
907
+ // and claimed again since, by anyone, is not the caller's to release: nothing is written. The check is part of
908
+ // the write itself, one statement (see NAMED_CLAIM_SQL), so a claim that lands between the caller's last look and
909
+ // the release is refused too, and it reads the claim by its event, not by a time, which two claims can share.
910
+ //
911
+ // The look, the row write, and the claim_released entry are one transaction (see inWriteTransaction): a history
912
+ // entry that cannot be written takes the release back with it, so a job is never left open with no record of how,
913
+ // and the check cannot pass against a claim whose own entry is not in the store yet.
914
+ //
915
+ // The could-not-start kind checks itself. It is taken only for a claim whose engine was never asked. On a
916
+ // claim marked started (see markBuildStarted) the release goes through as an ordinary release with no kind,
917
+ // and is not refused, so the claim is never left held.
918
+ function releaseTask(db, release) {
919
+ return inWriteTransaction(db, () => releaseTaskInTransaction(db, release));
920
+ }
921
+
922
+ function releaseTaskInTransaction(db, { id, actor, expectedUpdatedAt, reason, kind, pullRequest, claimEventId }) {
923
+ if (!id) throw new Error('id required');
924
+ if (!actor) throw new Error('actor required');
925
+ const { marker: asked, invalid } = releaseMarker(kind, pullRequest);
763
926
  if (invalid) return { released: false, reason: 'invalid_release_marker' };
764
927
  const row = getTask(db, id);
765
928
  if (!row) return { released: false, reason: 'not_found' };
@@ -769,21 +932,37 @@ function releaseTask(db, { id, actor, expectedUpdatedAt, reason, kind, pullReque
769
932
  if (row.claimed_by !== actor) {
770
933
  return { released: false, reason: 'held_by_other', claimed_by: row.claimed_by || null };
771
934
  }
935
+ const marker = asked && asked.kind === COULD_NOT_START && claimMarkedStarted(db, id) ? null : asked;
772
936
  const releaseReason = compactEpisodeText(reason, RELEASE_REASON_MAX);
773
937
  const now = Date.now();
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}`)];
774
941
  const result = withBusyRetry(() => db.prepare(`
775
942
  UPDATE tasks
776
943
  SET status = 'open',
777
944
  claimed_by = NULL,
778
945
  claimed_at = NULL,
779
946
  updated_at = ?,
780
- 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
781
948
  WHERE id = ?
782
949
  AND status = 'claimed'
783
950
  AND claimed_by = ?
951
+ AND claimed_at IS ?
784
952
  AND (? IS NULL OR updated_at = ?)
785
- `).run(now, id, actor, expectedUpdatedAt ?? null, expectedUpdatedAt ?? null));
786
- if (result.changes !== 1) return { released: false, reason: 'stale_task_state', claimed_by: row.claimed_by || null };
953
+ ${NAMED_CLAIM_SQL}
954
+ `).run(now, ...dropped, id, actor, row.claimed_at ?? null, expectedUpdatedAt ?? null, expectedUpdatedAt ?? null, named, named));
955
+ if (result.changes !== 1) {
956
+ // The job changed between the look and the write. A caller that named its claim is told what the job is now.
957
+ if (named) {
958
+ const current = getTask(db, id);
959
+ if (!current) return { released: false, reason: 'not_found' };
960
+ if (current.status !== 'claimed') return { released: false, reason: `already_${current.status}`, claimed_by: current.claimed_by || null };
961
+ if (current.claimed_by !== actor) return { released: false, reason: 'held_by_other', claimed_by: current.claimed_by || null };
962
+ if (!claimIsCurrent(current, latestClaim(db, id), named)) return { released: false, reason: 'claim_changed', claimed_by: current.claimed_by || null };
963
+ }
964
+ return { released: false, reason: 'stale_task_state', claimed_by: row.claimed_by || null };
965
+ }
787
966
  const updated = getTask(db, id);
788
967
  const event = appendTaskEvent(db, {
789
968
  taskId: id,
@@ -800,6 +979,40 @@ function releaseTask(db, { id, actor, expectedUpdatedAt, reason, kind, pullReque
800
979
  return { released: true, row: updated, event };
801
980
  }
802
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
+
803
1016
  function reopenTask(db, { id, actor, reason, metadata: metadataPatch, fromReview = false } = {}) {
804
1017
  if (!id) throw new Error('id required');
805
1018
  const row = getTask(db, id);
@@ -822,28 +1035,7 @@ function reopenTask(db, { id, actor, reason, metadata: metadataPatch, fromReview
822
1035
  reopened_from: row.status,
823
1036
  reopen_reason: reasonText,
824
1037
  };
825
- for (const key of [
826
- 'accepted_at',
827
- 'accepted_by',
828
- 'agent_certification_policy',
829
- 'agent_certified',
830
- 'agent_certified_at',
831
- 'agent_certified_by',
832
- 'agent_review_actors',
833
- 'agent_review_pass_count',
834
- 'agent_reviewed_at',
835
- 'agent_reviewed_by',
836
- 'approval_status',
837
- 'archived_at',
838
- 'archived_by',
839
- 'archived_from',
840
- 'archived_reason',
841
- 'independent_review_by',
842
- 'latest_agent_lesson',
843
- 'latest_agent_next_task',
844
- 'latest_agent_proof',
845
- 'pull_request',
846
- ]) delete metadata[key];
1038
+ for (const key of [...ATTEMPT_RECORD_KEYS, 'pull_request']) delete metadata[key];
847
1039
 
848
1040
  const result = withBusyRetry(() => db.prepare(`
849
1041
  UPDATE tasks
@@ -1090,22 +1282,56 @@ function cleanLandingText(value) {
1090
1282
  return text || null;
1091
1283
  }
1092
1284
 
1093
- // Record who delivered this review pass and answer whether certification is
1094
- // allowed: at least one recorded pass actor must differ from the builder.
1095
- // The first pass actor stamps built_by; judge != worker from then on.
1096
- function recordReviewPassActor(metadata, claimedBy, rawActor) {
1097
- const actorNorm = reviewIntegrity.normalizeActor(
1098
- rawActor || process.env.ATRIS_AGENT_ID || process.env.USER || ''
1099
- );
1100
- 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);
1101
1314
  const actors = Array.isArray(metadata.agent_review_actors) ? [...metadata.agent_review_actors] : [];
1102
1315
  if (actorNorm && !actors.includes(actorNorm)) actors.push(actorNorm);
1103
1316
  metadata.agent_review_actors = actors;
1104
- const builder = reviewIntegrity.normalizeActor(metadata.built_by || claimedBy || '');
1105
- const independent = builder
1106
- ? (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)
1107
1320
  : (actors.length >= 2 ? actors[1] : null);
1108
- 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 });
1109
1335
  }
1110
1336
 
1111
1337
  // A merge the caller confirmed. The commit id is required; the other fields
@@ -1121,6 +1347,9 @@ function cleanMergeRecord(merge) {
1121
1347
  if (/^https?:\/\//i.test(url)) record.url = url;
1122
1348
  const mergedAt = String(merge.merged_at || '').trim();
1123
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;
1124
1353
  return record;
1125
1354
  }
1126
1355
 
@@ -1136,107 +1365,395 @@ function cleanOpenPullRequest(pr) {
1136
1365
  return record.number || record.url ? record : null;
1137
1366
  }
1138
1367
 
1139
- 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 }) {
1140
1408
  if (!id) throw new Error('id required');
1141
1409
  const text = String(proof || '').trim();
1142
1410
  if (!text) throw new Error('proof required');
1143
- const row = getTask(db, id);
1144
- if (!row) return { ready: false, reason: 'not_found' };
1145
- if (!['open', 'claimed', 'review'].includes(row.status)) {
1146
- return { ready: false, reason: `already_${row.status}` };
1147
- }
1148
- const now = Date.now();
1149
- const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
1150
- delete metadata.lease;
1151
- const reviewPassCount = Number(metadata.agent_review_pass_count || 0) + 1;
1152
- metadata.approval_status = 'pending';
1153
- metadata.agent_review_pass_count = reviewPassCount;
1154
- metadata.agent_reviewed_at = new Date(now).toISOString();
1155
- metadata.agent_reviewed_by = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
1156
- metadata.latest_agent_proof = text;
1157
- metadata.latest_agent_lesson = String(lesson || '').trim() || null;
1158
- metadata.latest_agent_next_task = String(nextTask || '').trim() || null;
1159
- const resultText = cleanLandingText(resultSentence);
1160
- if (resultText) metadata.result = resultText;
1161
- const reasonText = cleanLandingText(reason);
1162
- if (reasonText) metadata.result_reason = reasonText;
1163
- const landingInput = landing && typeof landing === 'object' ? landing : {};
1164
- for (const key of ['happened', 'checked', 'tested', 'decision']) {
1165
- const cleaned = cleanLandingText(landingInput[key]);
1166
- if (cleaned) metadata[`landing_${key}`] = cleaned;
1167
- }
1168
- const mergeRecord = cleanMergeRecord(merge);
1169
- if (mergeRecord) metadata.merge_commit = mergeRecord.merge_commit;
1170
- const openPullRequest = mergeRecord ? null : cleanOpenPullRequest(merge);
1171
- // The row keeps the newest word on its pull request, so a reader that sees only
1172
- // the row (auto-accept, a compact list) can tell a job waiting on a person from
1173
- // one that landed. A review pass that names no pull request leaves it as it was.
1174
- if (mergeRecord) metadata.pull_request = { state: 'merged', ...mergeRecord };
1175
- else if (openPullRequest) metadata.pull_request = openPullRequest;
1411
+ const seen = getTask(db, id);
1412
+ const turnedAway = readyRefusal(seen);
1413
+ if (turnedAway) return turnedAway;
1176
1414
  // Only the builder's own ready names the work's commit. A reviewer or the
1177
1415
  // autoland verifier re-runs the check from its own checkout; stamping that
1178
1416
  // checkout's HEAD would name a commit the builder never made. No actor, or
1179
1417
  // no builder on record, means nobody can vouch for HEAD, so nothing is stamped.
1180
- // A builder working in its own copy passes that copy as workRoot.
1181
- if (actor && row.claimed_by && actor === row.claimed_by && !reviewIntegrity.isReservedActor(actor)) {
1182
- const head = workspaceHead(workRoot || row.workspace_root);
1183
- if (head) metadata.work_commit = head;
1184
- }
1185
- const passRecord = recordReviewPassActor(metadata, row.claimed_by, actor);
1186
- if (reviewPassCount >= AGENT_CERTIFICATION_REVIEW_PASSES && passRecord.independent) {
1187
- metadata.agent_certified = true;
1188
- metadata.agent_certified_at = new Date(now).toISOString();
1189
- metadata.agent_certified_by = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
1190
- metadata.agent_certification_policy = `${AGENT_CERTIFICATION_REVIEW_PASSES}_agent_review_passes`;
1191
- metadata.independent_review_by = passRecord.independent;
1192
- }
1193
- const result = withBusyRetry(() => db.prepare(`
1194
- UPDATE tasks
1195
- SET status = 'review',
1196
- done_at = NULL,
1197
- updated_at = ?,
1198
- metadata = ?
1199
- WHERE id = ?
1200
- AND status = ? AND claimed_by IS ? AND claimed_at IS ?
1201
- `).run(now, JSON.stringify(metadata), id, row.status, row.claimed_by, row.claimed_at));
1202
- if (result.changes !== 1) return { ready: false, reason: 'claim changed hands' };
1203
- const updated = getTask(db, id);
1204
- const payload = {
1205
- proof: text,
1206
- lesson: metadata.latest_agent_lesson,
1207
- next_task: metadata.latest_agent_next_task,
1208
- approval_status: 'pending',
1209
- review_pass_count: reviewPassCount,
1210
- agent_certified: metadata.agent_certified === true,
1211
- agent_certification_policy: metadata.agent_certification_policy || null,
1212
- 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,
1213
1556
  };
1214
- if (resultTrace && typeof resultTrace === 'object') {
1215
- payload.result_trace = resultTrace;
1216
- payload.result_packet = `TASK_RESULT_TRACE ${JSON.stringify(resultTrace)}`;
1217
- }
1218
- payload.landing = {
1219
- happened: metadata.landing_happened || null,
1220
- checked: metadata.landing_checked || null,
1221
- tested: metadata.landing_tested || null,
1222
- 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;
1223
1591
  };
1224
- if (mergeRecord) {
1225
- payload.landing.merge_commit = mergeRecord.merge_commit;
1226
- payload.pull_request = { state: 'merged', ...mergeRecord };
1227
- // Landed by the flight with no person in the loop.
1228
- if (merge.self_landed === true) payload.self_landed = true;
1229
- } else if (openPullRequest) {
1230
- 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');
1231
1640
  }
1232
- const event = appendTaskEvent(db, {
1233
- taskId: id,
1234
- workspaceRoot: updated.workspace_root,
1235
- actor: actor || null,
1236
- eventType: 'proof_ready',
1237
- 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 };
1705
+ });
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 };
1238
1754
  });
1239
- return { ready: true, event, row: updated };
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 };
1240
1757
  }
1241
1758
 
1242
1759
  function setTaskResult(db, { id, actor, result }) {
@@ -1520,7 +2037,19 @@ function backlogPacketFromPayload(payload) {
1520
2037
  return lines.filter(Boolean).join('\n');
1521
2038
  }
1522
2039
 
1523
- 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, {
1524
2053
  id,
1525
2054
  actor,
1526
2055
  stage,
@@ -1640,12 +2169,16 @@ function stageTask(db, {
1640
2169
  if (targetStage === 'do' && workingRow.status === 'open') {
1641
2170
  claimedWorker = stageOwner || cleanStageText(actor) || process.env.ATRIS_AGENT_ID || process.env.USER || 'unknown';
1642
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).
1643
2175
  const claimed = withBusyRetry(() => db.prepare(`
1644
2176
  UPDATE tasks
1645
2177
  SET status = 'claimed',
1646
2178
  claimed_by = ?,
1647
2179
  claimed_at = ?,
1648
- updated_at = ?
2180
+ updated_at = ?,
2181
+ metadata = ${NEW_ATTEMPT_METADATA_SQL}
1649
2182
  WHERE id = ?
1650
2183
  AND status = 'open'
1651
2184
  AND updated_at = ?
@@ -1713,6 +2246,8 @@ function stageTask(db, {
1713
2246
  } else {
1714
2247
  const worker = workingRow && workingRow.claimed_by || stageOwner || cleanStageText(actor) || null;
1715
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];
1716
2251
  result = withBusyRetry(() => db.prepare(`
1717
2252
  UPDATE tasks
1718
2253
  SET done_at = NULL,
@@ -1980,12 +2515,13 @@ function reviewTask(db, { id, actor, reward, lesson, nextTask, proof, verify, ca
1980
2515
  const reviewer = actor || process.env.ATRIS_AGENT_ID || process.env.USER || null;
1981
2516
  const metadata = row.metadata && typeof row.metadata === 'object' ? { ...row.metadata } : {};
1982
2517
  const reviewerNorm = reviewIntegrity.normalizeActor(reviewer);
1983
- const builderNorm = reviewIntegrity.taskBuilder(row);
1984
- // judge != worker applies pre-acceptance only: a builder cannot positive-
1985
- // 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
1986
2521
  // the human gate; finish and post-acceptance XP bookkeeping stay open.
1987
- if (numericReward > 0 && row.status === 'review' && builderNorm && reviewerNorm && reviewerNorm === builderNorm) {
1988
- 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 };
1989
2525
  }
1990
2526
  const proofText = String(proof || '').trim();
1991
2527
  const verifyText = cleanStageText(verify);
@@ -2043,7 +2579,7 @@ function reviewTask(db, { id, actor, reward, lesson, nextTask, proof, verify, ca
2043
2579
  if (reviewingPendingProof) {
2044
2580
  reviewPassCount += 1;
2045
2581
  metadata.agent_review_pass_count = reviewPassCount;
2046
- const passRecord = recordReviewPassActor(metadata, row.claimed_by, reviewer);
2582
+ const passRecord = recordReviewPassActor(metadata, row.claimed_by, reviewer, { authors: authorsOfTask(db, row), handIn: false });
2047
2583
  if (reviewPassCount >= AGENT_CERTIFICATION_REVIEW_PASSES && passRecord.independent) {
2048
2584
  metadata.agent_certified = true;
2049
2585
  metadata.agent_certified_at = new Date(now).toISOString();
@@ -2584,6 +3120,8 @@ module.exports = {
2584
3120
  previewExpiredLeases,
2585
3121
  sweepExpiredLeases,
2586
3122
  releaseTask,
3123
+ latestClaim,
3124
+ markBuildStarted,
2587
3125
  reopenTask,
2588
3126
  backlogTask,
2589
3127
  clearPlanTasks,
@@ -2593,6 +3131,11 @@ module.exports = {
2593
3131
  archiveNotNeeded,
2594
3132
  relabelArchivedTasks,
2595
3133
  readyTask,
3134
+ authorsOfTask,
3135
+ heldPullRequestOf,
3136
+ pullRequestTakenBy,
3137
+ landHeldMerge,
3138
+ releaseClosedPullRequest,
2596
3139
  setTaskResult,
2597
3140
  reviseTask,
2598
3141
  stageTask,