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/commands/rating.js +7 -2
- package/commands/task.js +105 -15
- package/commands/worktree.js +510 -18
- package/lib/auto-accept-certified.js +56 -0
- package/lib/campaign-keep.js +5 -1
- package/lib/fleet.js +329 -48
- package/lib/gm-season.js +44 -8
- package/lib/release-kind.js +27 -0
- package/lib/review-integrity.js +7 -0
- package/lib/roster-runs.js +11 -5
- package/lib/roster-suggest.js +3 -2
- package/lib/roster-swap.js +3 -1
- package/lib/router-brain.js +7 -6
- package/lib/task-db.js +88 -2
- package/lib/voice-gate.js +1 -0
- package/lib/worktree-space.js +357 -0
- package/package.json +1 -1
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
|
|
29
|
-
// claim that ended archived without landing; a job still in
|
|
30
|
-
// pending and not counted.
|
|
31
|
-
//
|
|
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
|
-
|
|
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 };
|
package/lib/review-integrity.js
CHANGED
|
@@ -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,
|
package/lib/roster-runs.js
CHANGED
|
@@ -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,
|
|
7
|
-
//
|
|
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(', ')}`;
|
package/lib/roster-suggest.js
CHANGED
|
@@ -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)
|
package/lib/roster-swap.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/lib/router-brain.js
CHANGED
|
@@ -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
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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',
|