atris 3.74.27 → 3.74.28

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/gm-season.js CHANGED
@@ -11,6 +11,7 @@
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
13
  const { atrisRating } = require('./atris-rating');
14
+ const { isProofNotSaved } = require('./release-kind');
14
15
 
15
16
  const HOUR_MS = 60 * 60 * 1000;
16
17
  const DAY_MS = 24 * HOUR_MS;
@@ -21,14 +22,22 @@ const ACTIVE_MISSION_STATUSES = new Set(['running', 'ready']);
21
22
  const KEPT_ACTIONS = new Set(['accepted', 'merged']);
22
23
  const FINISHED = new Set(['landed', 'dropped', 'sent_back', 'abandoned']);
23
24
  const FORM_LETTER = { landed: 'k', dropped: 'd', sent_back: 'x', abandoned: 'a' };
24
- const PLAY_WORD = { took: 'took', landed: 'landed', dropped: 'dropped', sent_back: 'sent back', abandoned: 'abandoned' };
25
+ const PLAY_WORD = { took: 'took', landed: 'landed', dropped: 'dropped', sent_back: 'sent back', abandoned: 'abandoned', not_needed: 'not needed', held: 'held' };
26
+ // A person has this long after a drop to say the job did not need doing.
27
+ const NOT_NEEDED_WINDOW_MS = 7 * DAY_MS;
25
28
 
26
29
  // An outcome is one play:
27
30
  // { event_id, task_id, member, title, kind, at, claimed_at, landed_by }
28
- // kind is took | landed | dropped | sent_back | abandoned. abandoned is a
29
- // claim that ended archived without landing; a job still in review is
30
- // pending and not counted. at and claimed_at are epoch ms; claimed_at may
31
- // be null. landed_by is autoland | you, only on landed.
31
+ // kind is took | landed | dropped | sent_back | abandoned | not_needed | held.
32
+ // abandoned is a claim that ended archived without landing; a job still in
33
+ // review is pending and not counted. not_needed is a drop or abandon that
34
+ // someone other than the member later confirmed the job never needed; it is
35
+ // not finished, so it counts for and against nobody. held is a claim let go
36
+ // because the work is done and its pull request waits for a person, but the
37
+ // proof that says so could not be saved; the release says so in a field
38
+ // (lib/release-kind.js), never in its words. It is not finished either, so it
39
+ // is neither a landing nor a miss. at and claimed_at are epoch ms; claimed_at
40
+ // may be null. landed_by is autoland | you, only on landed.
32
41
 
33
42
  function byTimeAsc(a, b) {
34
43
  return Number(a.at || 0) - Number(b.at || 0);
@@ -41,7 +50,7 @@ function isFinished(outcome) {
41
50
  // jobs = landed + dropped + sent back + abandoned in the window, the same
42
51
  // count the rating divides by. Jobs still in review are pending.
43
52
  function tally(outcomes) {
44
- const out = { took: 0, landed: 0, dropped: 0, sent_back: 0, abandoned: 0 };
53
+ const out = { took: 0, landed: 0, dropped: 0, sent_back: 0, abandoned: 0, not_needed: 0, held: 0 };
45
54
  for (const o of outcomes || []) {
46
55
  if (Object.prototype.hasOwnProperty.call(out, o.kind)) out[o.kind] += 1;
47
56
  }
@@ -320,7 +329,9 @@ function outcomeFromEvent(row) {
320
329
  kind = 'took';
321
330
  member = row.actor || payload.claimed_by;
322
331
  } else if (row.event_type === 'claim_released') {
323
- kind = 'dropped';
332
+ // A release that carries the proof-not-saved fields is work waiting on a person,
333
+ // not a drop. Only the fields say so: the same words with no fields stay a drop.
334
+ kind = isProofNotSaved(payload) ? 'held' : 'dropped';
324
335
  member = payload.previous_claimed_by || payload.released_by || row.actor;
325
336
  } else if (row.event_type === 'claim_expired') {
326
337
  kind = 'dropped';
@@ -372,6 +383,30 @@ function hasStructuredMerge(payload) {
372
383
  });
373
384
  }
374
385
 
386
+ // A drop or abandon that someone other than the member later confirmed the job
387
+ // never needed (`task archive --not-needed`) is not the member's miss, so it
388
+ // stops counting for or against them. Only another actor can clear it, and
389
+ // only within a week of the miss: a member cannot wave off its own drop. A
390
+ // note from a fleet engine never counts, whatever name it was filed under: an
391
+ // engine cannot void another engine's miss, or its own.
392
+ function clearNotNeededMisses(plays, rows) {
393
+ const notices = rows.filter(row => row.event_type === 'not_needed');
394
+ if (!notices.length) return;
395
+ const { normalizeActor, isFleetEngineActor } = require('./review-integrity');
396
+ plays.forEach((play, i) => {
397
+ if (play.kind !== 'dropped' && play.kind !== 'abandoned') return;
398
+ const holder = normalizeActor(play.member);
399
+ const notice = notices.find(row => {
400
+ const by = normalizeActor(row.actor);
401
+ const lag = Number(row.created_at) - play.at;
402
+ return row.task_id === play.task_id && by && by !== holder && !isFleetEngineActor(by) && lag >= 0 && lag <= NOT_NEEDED_WINDOW_MS;
403
+ });
404
+ if (!notice) return;
405
+ const why = (parseJson(notice.payload) || {}).reason;
406
+ plays[i] = { ...play, kind: 'not_needed', reason: typeof why === 'string' && why.trim() || play.reason, confirmed_by: notice.actor };
407
+ });
408
+ }
409
+
375
410
  function outcomesFromEvents(rows, since = -Infinity) {
376
411
  const ordered = [...(rows || [])].sort((a, b) => String(a.task_id).localeCompare(String(b.task_id)) ||
377
412
  (a.version != null && b.version != null ? Number(a.version) - Number(b.version) : Number(a.created_at) - Number(b.created_at)));
@@ -403,6 +438,7 @@ function outcomesFromEvents(rows, since = -Infinity) {
403
438
  }
404
439
  if (ended) closed.add(key);
405
440
  }
441
+ clearNotNeededMisses(plays, ordered);
406
442
  return plays.filter(play => play.at >= since).sort(byTimeAsc);
407
443
  }
408
444
 
@@ -535,7 +571,7 @@ function playEventsSql(extraWhere = '') {
535
571
  WHERE p.task_id = e.task_id AND p.event_type = 'claimed' AND p.version < e.version
536
572
  ORDER BY p.version DESC LIMIT 1)
537
573
  WHERE EXISTS (SELECT 1 FROM task_events recent WHERE recent.task_id = e.task_id AND recent.created_at >= ?)
538
- AND e.event_type IN ('claimed', 'claim_released', 'claim_expired', 'proof_ready', 'landing', 'completed', 'revision_requested', 'reopened', 'archived')
574
+ AND e.event_type IN ('claimed', 'claim_released', 'claim_expired', 'proof_ready', 'landing', 'completed', 'revision_requested', 'reopened', 'archived', 'not_needed')
539
575
  ${extraWhere}
540
576
  ORDER BY e.created_at ASC`;
541
577
  }
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ // Why a claim was let go, as a field the rating can read without reading words.
4
+ //
5
+ // One kind exists. The work is done and its pull request is open for a person to
6
+ // land, but the proof that says so could not be saved. The builder did its part,
7
+ // so the release is neither a landing nor a drop. The kind and the pull request
8
+ // ride on the release event as fields; the reason text is only for people, and
9
+ // nothing ever parses it.
10
+ //
11
+ // A release says this by passing both fields. The task db refuses half of a
12
+ // marker, and the rating reads a release as this kind only when both are there.
13
+
14
+ const PROOF_NOT_SAVED = 'proof_not_saved';
15
+
16
+ // True for a release event payload that carries the kind and an open pull
17
+ // request it can point at. Anything less is an ordinary release.
18
+ function isProofNotSaved(payload) {
19
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false;
20
+ if (payload.kind !== PROOF_NOT_SAVED) return false;
21
+ const pr = payload.pull_request;
22
+ if (!pr || typeof pr !== 'object' || Array.isArray(pr)) return false;
23
+ if (String(pr.state || '').toLowerCase() !== 'open') return false;
24
+ return (Number.isInteger(pr.number) && pr.number > 0) || /^https?:\/\//i.test(String(pr.url || '').trim());
25
+ }
26
+
27
+ module.exports = { PROOF_NOT_SAVED, isProofNotSaved };
@@ -25,6 +25,12 @@ function normalizeActor(value) {
25
25
  .replace(/^-+|-+$/g, '');
26
26
  }
27
27
 
28
+ // The names the fleet claims and files proof under (fleet-codex, fleet-claude,
29
+ // ...). An engine, not a person: its word can never confirm a job was not needed.
30
+ function isFleetEngineActor(actor) {
31
+ return normalizeActor(actor).startsWith('fleet-');
32
+ }
33
+
28
34
  // Builder identity, oldest signal wins: explicit stamp, then who claimed it,
29
35
  // then whoever sent the first proof.
30
36
  function taskBuilder(task) {
@@ -130,6 +136,7 @@ function validateActor(actor, { root } = {}) {
130
136
 
131
137
  module.exports = {
132
138
  normalizeActor,
139
+ isFleetEngineActor,
133
140
  taskBuilder,
134
141
  reviewEventActors,
135
142
  independentReviewActors,
@@ -3,8 +3,9 @@
3
3
  // One line per run launched from a roster pick, so the owner can judge each
4
4
  // tool and model on real work instead of memory. A line says when, which job,
5
5
  // which member (if any), the engine, model, effort, time cap, seconds taken,
6
- // and how it ended: landed, failed, stalled, credit out, and who took over
7
- // when it was handed on. Tokens and cost appear only when the tool printed
6
+ // and how it ended: landed, failed, stalled, credit out, held (built and
7
+ // verified, with a pull request waiting for a person to land it), and who took
8
+ // over when it was handed on. Tokens and cost appear only when the tool printed
8
9
  // them in output we already capture; nothing is guessed.
9
10
  //
10
11
  // Writing never throws into the run that called it, and reading only looks
@@ -21,7 +22,7 @@ const RUNS_FILE = path.join('.atris', 'state', 'roster_runs.jsonl');
21
22
  const ROTATED_NAME = /^roster_runs\.\d.*\.jsonl$/;
22
23
  const ROTATED_KEEP = 3;
23
24
  const ROTATED_READ = 2;
24
- const OUTCOMES = Object.freeze(['landed', 'failed', 'stalled', 'credit out', 'review unavailable']);
25
+ const OUTCOMES = Object.freeze(['landed', 'failed', 'stalled', 'credit out', 'review unavailable', 'held']);
25
26
  const DEFAULT_DAYS = 7;
26
27
  const TAIL_BYTES = 512 * 1024;
27
28
  // Past this size the log is renamed aside before the append, never trimmed:
@@ -124,7 +125,7 @@ function parseRunUsage({ usage = null, cost_usd = null, stdout = '', stderr = ''
124
125
  // with --json. Writers that know the cause pass it as `detail`; this is the
125
126
  // fallback for a failed, stalled or credit-out run that has none. It is not
126
127
  // stored, so the log can still tell a real reason from the filler. A landed
127
- // run needs no reason.
128
+ // run needs no reason. A held run is not a miss: it says what it waits for.
128
129
  function runReason(run) {
129
130
  if (!run || run.outcome === 'landed') return '';
130
131
  const given = clean(run.detail);
@@ -132,6 +133,7 @@ function runReason(run) {
132
133
  if (run.outcome === 'stalled') return 'it did not finish in time';
133
134
  if (run.outcome === 'credit out') return 'the engine hit its usage limit';
134
135
  if (run.outcome === 'review unavailable') return 'the second review was unavailable';
136
+ if (run.outcome === 'held') return 'waiting for a person to land it';
135
137
  return 'it failed and the tool gave no reason';
136
138
  }
137
139
 
@@ -326,7 +328,8 @@ function workerRuns(runs, { job, engine, model = '' } = {}) {
326
328
  }
327
329
 
328
330
  // "last 7 days: 9 runs, 7 landed, 1 stalled, 1 failed, median 11 min", or
329
- // null when there are no runs.
331
+ // null when there are no runs. A held run counts as a run and as neither a
332
+ // landing nor a miss.
330
333
  function summarizeRuns(runs, { days = DEFAULT_DAYS } = {}) {
331
334
  const list = runs || [];
332
335
  if (!list.length) return null;
@@ -343,6 +346,8 @@ function summarizeRuns(runs, { days = DEFAULT_DAYS } = {}) {
343
346
  };
344
347
  const reviewUnavailable = count('review unavailable');
345
348
  if (reviewUnavailable) summary.review_unavailable = reviewUnavailable;
349
+ const held = count('held');
350
+ if (held) summary.held = held;
346
351
  const tokens = list.map((run) => run.tokens).filter((value) => Number.isFinite(value));
347
352
  if (tokens.length) summary.tokens = tokens.reduce((sum, value) => sum + value, 0);
348
353
  const parts = [`${summary.runs} run${summary.runs === 1 ? '' : 's'}`];
@@ -350,6 +355,7 @@ function summarizeRuns(runs, { days = DEFAULT_DAYS } = {}) {
350
355
  if (summary.stalled) parts.push(`${summary.stalled} stalled`);
351
356
  if (summary.failed) parts.push(`${summary.failed} failed`);
352
357
  if (summary.review_unavailable) parts.push(`${summary.review_unavailable} review unavailable`);
358
+ if (summary.held) parts.push(`${summary.held} waiting for a person to land`);
353
359
  if (summary.credit_out) parts.push(`${summary.credit_out} credit out`);
354
360
  if (summary.median_seconds !== null) parts.push(`median ${durationText(summary.median_seconds)}`);
355
361
  summary.text = `last ${days} day${days === 1 ? '' : 's'}: ${parts.join(', ')}`;
@@ -23,10 +23,11 @@ const SUGGEST_MIN_GAP_POINTS = 25;
23
23
  const BOOT_TAIL_BYTES = 64 * 1024;
24
24
 
25
25
  // A credit wall says nothing about how well a worker does the job, and
26
- // engine health already benches it, so credit out runs are left out.
26
+ // engine health already benches it, so credit out runs are left out. A held
27
+ // run has not landed or failed yet: its pull request waits for a person.
27
28
  function judgedRuns(runs) {
28
29
  return runs
29
- .filter((run) => run.outcome !== 'credit out' && run.outcome !== 'review unavailable')
30
+ .filter((run) => run.outcome !== 'credit out' && run.outcome !== 'review unavailable' && run.outcome !== 'held')
30
31
  .map((run, index) => ({ run, index }))
31
32
  .sort((a, b) => (Date.parse(b.run.at) - Date.parse(a.run.at)) || (b.index - a.index))
32
33
  .slice(0, SUGGEST_WINDOW_RUNS)
@@ -261,7 +261,9 @@ function swapMember(root, memberName, engineWords, modelWords, options = {}) {
261
261
  // model this machine offers that can do the member's work, grouped by tool.
262
262
  // A model's last 7 days in the owner's words: the phone's swap card shows it
263
263
  // under the model, so it says finished, never the team's own word for it.
264
- const recordWords = seen => `${seen.landed} of ${seen.runs - (seen.review_unavailable || 0)} finished this week${seen.review_unavailable ? `, ${seen.review_unavailable} awaiting review` : ''}`;
264
+ // A build whose pull request waits for the owner has not finished yet, so it is
265
+ // said apart instead of counting for or against the model.
266
+ const recordWords = seen => `${seen.landed} of ${seen.runs - (seen.review_unavailable || 0) - (seen.held || 0)} finished this week${seen.review_unavailable ? `, ${seen.review_unavailable} awaiting review` : ''}${seen.held ? `, ${seen.held} waiting for a person` : ''}`;
265
267
 
266
268
  // The current model is on top; models with a record in the last 7 days come
267
269
  // next, most landed first; the rest keep --available's order.
@@ -205,11 +205,12 @@ function readDispatchReceiptObservations(root) {
205
205
  // the router ranks because no roster line picks for them. Each run counts as
206
206
  // one receipt, the same weight and the same 30-day half-life as a task
207
207
  // receipt: landed is a pass; stalled and failed are a miss. Credit out is
208
- // left out, since engine health already benches that worker. The job name
209
- // maps to the role the router ranks by: build to executor, search to
210
- // navigator, review to validator, and an owner's job by its last word
211
- // ("small build" is build work). No record file means no observations, so
212
- // the ranking is exactly what it was without one.
208
+ // left out, since engine health already benches that worker, and so is held,
209
+ // a build whose pull request still waits for a person: not yet a pass or a
210
+ // miss. The job name maps to the role the router ranks by: build to executor,
211
+ // search to navigator, review to validator, and an owner's job by its last
212
+ // word ("small build" is build work). No record file means no observations,
213
+ // so the ranking is exactly what it was without one.
213
214
  const ROSTER_RUN_DAYS = 30;
214
215
  const ROSTER_JOB_ROLES = Object.freeze({ build: 'executor', search: 'navigator', review: 'validator' });
215
216
 
@@ -219,7 +220,7 @@ function readRosterRunObservations(root, now) {
219
220
  if (!fs.existsSync(file) && !rotatedRunsPaths(root).length) return [];
220
221
  const source = path.relative(root, file);
221
222
  return readRosterRuns(root, { days: ROSTER_RUN_DAYS, ...(now !== undefined ? { now } : {}) }).flatMap((run) => {
222
- if (run.outcome === 'credit out' || run.outcome === 'review unavailable') return [];
223
+ if (run.outcome === 'credit out' || run.outcome === 'review unavailable' || run.outcome === 'held') return [];
223
224
  const words = String(run.job || '').trim().toLowerCase().split(/\s+/);
224
225
  const role = ROSTER_JOB_ROLES[words[words.length - 1]];
225
226
  const atMs = Date.parse(run.at);
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 { PROOF_NOT_SAVED } = require('./release-kind');
30
31
  const dailyLog = require('./daily-log');
31
32
  const { isGenericScratchRoot } = require('./scratch-root');
32
33
  const { isDecisionTask } = require('./task-decision');
@@ -491,11 +492,26 @@ function linkedWorktreeRoots(ws) {
491
492
  return roots;
492
493
  }
493
494
 
495
+ // The workspaces a workspace's task listing covers: the workspace itself and, for a main
496
+ // checkout, the git worktrees linked to it.
497
+ function workspaceViewRoots(ws) {
498
+ return ws ? [ws, ...linkedWorktreeRoots(ws)] : [];
499
+ }
500
+
501
+ // Whether a task row sits inside a workspace, by the rule listTasks applies when it is given
502
+ // that workspace: the row's workspace_root is one of those roots, spelled exactly as stored
503
+ // (workspaceRoot(cwd) gives that spelling). A row with no workspace_root is in the listing of no
504
+ // workspace (only a listing of every workspace shows it), so it is inside none.
505
+ function rowInWorkspace(row, ws) {
506
+ const rowRoot = row && typeof row.workspace_root === 'string' ? row.workspace_root : '';
507
+ return Boolean(rowRoot) && workspaceViewRoots(ws).includes(rowRoot);
508
+ }
509
+
494
510
  function listTasks(db, { workspaceRoot: ws, status, claimedBy, limit }) {
495
511
  const where = [];
496
512
  const args = [];
497
513
  if (ws) {
498
- const roots = [ws, ...linkedWorktreeRoots(ws)];
514
+ const roots = workspaceViewRoots(ws);
499
515
  where.push(`workspace_root IN (${roots.map(() => '?').join(', ')})`);
500
516
  args.push(...roots);
501
517
  }
@@ -723,9 +739,28 @@ function sweepExpiredLeases() {
723
739
  }
724
740
  }
725
741
 
726
- function releaseTask(db, { id, actor, expectedUpdatedAt }) {
742
+ // Why a claim was let go. The reason is for people: it is kept short and on one
743
+ // line. A release that must not read as a drop (the work is done and its pull
744
+ // request waits for a person, only the proof could not be saved) also carries a
745
+ // 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`
747
+ // 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.
749
+ const RELEASE_REASON_MAX = 300;
750
+
751
+ function releaseMarker(kind, pullRequest) {
752
+ const named = kind !== undefined && kind !== null && String(kind).trim() !== '';
753
+ if (!named && !pullRequest) return { marker: null };
754
+ const open = cleanOpenPullRequest(pullRequest);
755
+ if (!named || String(kind).trim() !== PROOF_NOT_SAVED || !open) return { invalid: true };
756
+ return { marker: { kind: PROOF_NOT_SAVED, pull_request: open } };
757
+ }
758
+
759
+ function releaseTask(db, { id, actor, expectedUpdatedAt, reason, kind, pullRequest }) {
727
760
  if (!id) throw new Error('id required');
728
761
  if (!actor) throw new Error('actor required');
762
+ const { marker, invalid } = releaseMarker(kind, pullRequest);
763
+ if (invalid) return { released: false, reason: 'invalid_release_marker' };
729
764
  const row = getTask(db, id);
730
765
  if (!row) return { released: false, reason: 'not_found' };
731
766
  if (row.status !== 'claimed') {
@@ -734,6 +769,7 @@ function releaseTask(db, { id, actor, expectedUpdatedAt }) {
734
769
  if (row.claimed_by !== actor) {
735
770
  return { released: false, reason: 'held_by_other', claimed_by: row.claimed_by || null };
736
771
  }
772
+ const releaseReason = compactEpisodeText(reason, RELEASE_REASON_MAX);
737
773
  const now = Date.now();
738
774
  const result = withBusyRetry(() => db.prepare(`
739
775
  UPDATE tasks
@@ -757,6 +793,8 @@ function releaseTask(db, { id, actor, expectedUpdatedAt }) {
757
793
  payload: {
758
794
  released_by: actor,
759
795
  previous_claimed_by: row.claimed_by || null,
796
+ ...(releaseReason ? { reason: releaseReason } : {}),
797
+ ...(marker || {}),
760
798
  },
761
799
  });
762
800
  return { released: true, row: updated, event };
@@ -804,6 +842,7 @@ function reopenTask(db, { id, actor, reason, metadata: metadataPatch, fromReview
804
842
  'latest_agent_lesson',
805
843
  'latest_agent_next_task',
806
844
  'latest_agent_proof',
845
+ 'pull_request',
807
846
  ]) delete metadata[key];
808
847
 
809
848
  const result = withBusyRetry(() => db.prepare(`
@@ -967,6 +1006,31 @@ function archiveTask(db, { id, actor, reason, fromFailed = false, fromDone = fal
967
1006
  return { archived: true, event, row: updated, logs };
968
1007
  }
969
1008
 
1009
+ // A person confirms that a job nobody finished did not need doing. The job is
1010
+ // archived the way `task archive` does it, and the call is written down as its
1011
+ // own event so the rating can stop counting the drop against the member. A job
1012
+ // that is already archived keeps its status; only the note is added.
1013
+ function archiveNotNeeded(db, { id, actor, reason, fromFailed = false } = {}) {
1014
+ if (!id) throw new Error('id required');
1015
+ const reasonText = String(reason || '').trim();
1016
+ if (!reasonText) throw new Error('reason required');
1017
+ const row = getTask(db, id);
1018
+ if (!row) return { archived: false, recorded: false, reason: 'not_found' };
1019
+ let archived = null;
1020
+ if (row.status !== 'archived') {
1021
+ archived = archiveTask(db, { id, actor, reason: reasonText, fromFailed });
1022
+ if (!archived.archived) return { archived: false, recorded: false, reason: archived.reason };
1023
+ }
1024
+ const event = appendTaskEvent(db, {
1025
+ taskId: id,
1026
+ workspaceRoot: row.workspace_root,
1027
+ actor: actor || process.env.ATRIS_AGENT_ID || process.env.USER || null,
1028
+ eventType: 'not_needed',
1029
+ payload: { reason: reasonText, previous_status: row.status },
1030
+ });
1031
+ return { archived: Boolean(archived), recorded: true, event, row: archived ? archived.row : row };
1032
+ }
1033
+
970
1034
  // Marks metadata written by the 2026-06-10 "first-principles backlog reset"
971
1035
  // (see atris/logs/2026/2026-06-10.md), a bulk pass that closed off-roadmap
972
1036
  // tasks, including certified/proof-backed done work, by writing status
@@ -1060,6 +1124,18 @@ function cleanMergeRecord(merge) {
1060
1124
  return record;
1061
1125
  }
1062
1126
 
1127
+ // A pull request opened for a person to land. It names the PR and nothing
1128
+ // more: with no merge commit the rating still sees the job as waiting.
1129
+ function cleanOpenPullRequest(pr) {
1130
+ if (!pr || typeof pr !== 'object' || pr.merge_commit || pr.merged_at) return null;
1131
+ const record = { state: 'open' };
1132
+ const number = Number(pr.number);
1133
+ if (Number.isInteger(number) && number > 0) record.number = number;
1134
+ const url = String(pr.url || '').trim();
1135
+ if (/^https?:\/\//i.test(url)) record.url = url;
1136
+ return record.number || record.url ? record : null;
1137
+ }
1138
+
1063
1139
  function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, merge = null, resultTrace, result: resultSentence, reason, workRoot }) {
1064
1140
  if (!id) throw new Error('id required');
1065
1141
  const text = String(proof || '').trim();
@@ -1091,6 +1167,12 @@ function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, merge
1091
1167
  }
1092
1168
  const mergeRecord = cleanMergeRecord(merge);
1093
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;
1094
1176
  // Only the builder's own ready names the work's commit. A reviewer or the
1095
1177
  // autoland verifier re-runs the check from its own checkout; stamping that
1096
1178
  // checkout's HEAD would name a commit the builder never made. No actor, or
@@ -1144,6 +1226,8 @@ function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, merge
1144
1226
  payload.pull_request = { state: 'merged', ...mergeRecord };
1145
1227
  // Landed by the flight with no person in the loop.
1146
1228
  if (merge.self_landed === true) payload.self_landed = true;
1229
+ } else if (openPullRequest) {
1230
+ payload.pull_request = openPullRequest;
1147
1231
  }
1148
1232
  const event = appendTaskEvent(db, {
1149
1233
  taskId: id,
@@ -2491,6 +2575,7 @@ module.exports = {
2491
2575
  getTask,
2492
2576
  listTasks,
2493
2577
  listTasksCached,
2578
+ rowInWorkspace,
2494
2579
  claimTask,
2495
2580
  setLease,
2496
2581
  renewLease,
@@ -2505,6 +2590,7 @@ module.exports = {
2505
2590
  doneTask,
2506
2591
  reapMissionBlockerTasks,
2507
2592
  archiveTask,
2593
+ archiveNotNeeded,
2508
2594
  relabelArchivedTasks,
2509
2595
  readyTask,
2510
2596
  setTaskResult,
package/lib/voice-gate.js CHANGED
@@ -247,6 +247,7 @@ const LANDING_REASON_SENTENCES = {
247
247
  proof_not_executed: 'the proof was written but never actually run, execute the check and record what it showed',
248
248
  proof_required: 'no proof was given, say what was run and what it showed',
249
249
  proof_unmerged_or_draft_pr_boundary: 'its proof points at an unmerged draft, merge it or prove the work another way',
250
+ pull_request_open: 'its pull request is still open, so a person has to land it before the job can finish',
250
251
  receipt_verifier_failed: 'a saved receipt failed its re-check, fix what it names and re-certify',
251
252
  slop_gate: 'the touched prose breaks a deterministic taste rule, fix the named lines and re-certify',
252
253
  strict_verify_missing: 'no recorded check command to re-run, add one and re-certify',