atris 3.74.27 → 3.74.29
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 +364 -73
- 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/fleet.js
CHANGED
|
@@ -44,6 +44,7 @@ const { buildVerifiedScoutPack, appendVerifiedScoutPack } = require('./dispatch-
|
|
|
44
44
|
const { runPrepPass, withPrepBrief, prepRecordFields } = require('./roster-prep');
|
|
45
45
|
const { mapNotesPath } = require('./map-refs');
|
|
46
46
|
const { SHARED_BRIEF_RULES } = require('./brief-rules');
|
|
47
|
+
const { PROOF_NOT_SAVED } = require('./release-kind');
|
|
47
48
|
const {
|
|
48
49
|
applySecretGrantEnvironment,
|
|
49
50
|
buildGatewaySupervisorScript,
|
|
@@ -52,11 +53,30 @@ const {
|
|
|
52
53
|
} = require('./secret-gateway');
|
|
53
54
|
const { appendEngineLiveLogChunk, createEngineLiveLog, engineTerminalReason } = require('./engine-job-lifecycle');
|
|
54
55
|
const { recordEngineResource } = require('./engine-resource');
|
|
56
|
+
const {
|
|
57
|
+
taskTagTokens,
|
|
58
|
+
isDecisionHoldTag,
|
|
59
|
+
isDecisionTask,
|
|
60
|
+
DECISION_REFUSE_REASON,
|
|
61
|
+
} = require('./task-decision');
|
|
55
62
|
|
|
56
63
|
// Lanes a fleet may never staff on its own: the human keeps irreversible
|
|
57
64
|
// calls. Mirrors the autoland denied lanes.
|
|
58
65
|
const DENIED_TAGS = ['billing', 'money', 'payments', 'deploy', 'security', 'customer', 'external', 'feedback'];
|
|
59
66
|
|
|
67
|
+
// The protected lane a task sits in, or null. This is the one place that
|
|
68
|
+
// decides protection: staffing (isSafeLane), the ship gate, the dispatch flight
|
|
69
|
+
// and the builder prompt all ask it, so a task one of them holds back as
|
|
70
|
+
// protected is never one that another would merge. A task's tags are its tag,
|
|
71
|
+
// its tags list, the tags `atris task tag` adds later and hashtags in its
|
|
72
|
+
// title (all through taskTagTokens), plus the todo tags it was imported with.
|
|
73
|
+
function protectedLaneOfTask(task) {
|
|
74
|
+
if (!task || typeof task !== 'object') return null;
|
|
75
|
+
const todoTags = task.metadata && Array.isArray(task.metadata.todo_tags) ? task.metadata.todo_tags : [];
|
|
76
|
+
const tags = [...taskTagTokens(task), ...todoTags.filter(Boolean).map((tag) => String(tag).trim().toLowerCase())];
|
|
77
|
+
return tags.find((tag) => DENIED_TAGS.includes(tag)) || null;
|
|
78
|
+
}
|
|
79
|
+
|
|
60
80
|
// ---------------------------------------------------------------------------
|
|
61
81
|
// T1: dispatch primitive
|
|
62
82
|
|
|
@@ -115,7 +135,8 @@ function shellQuote(value) {
|
|
|
115
135
|
|
|
116
136
|
// The bounded prompt every engine gets: isolated worktree, MAP first, the
|
|
117
137
|
// task's own check, one landing order per mode. Yolo: the engine lands it
|
|
118
|
-
// itself with worktree ship
|
|
138
|
+
// itself with worktree ship, unless the task is in a protected lane. Otherwise:
|
|
139
|
+
// commit only, the conductor lands it.
|
|
119
140
|
function buildFleetPrompt(task, { worktreePath, yolo = false } = {}) {
|
|
120
141
|
const ref = task.display_id || task.id || 'TASK';
|
|
121
142
|
const title = String(task.title || '').trim();
|
|
@@ -123,11 +144,27 @@ function buildFleetPrompt(task, { worktreePath, yolo = false } = {}) {
|
|
|
123
144
|
const done = titleDone || String((task.metadata && task.metadata.done_looks_like) || '').trim();
|
|
124
145
|
const check = taskDispatchCheck(task, worktreePath || process.cwd());
|
|
125
146
|
const shipMessage = title.split(/[.:](?:\s|$)/)[0].replace(/"/g, "'").slice(0, 90) || ref;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
147
|
+
// A protected-lane task is never shipped by its builder, yolo or not. The
|
|
148
|
+
// builder commits and stops, and the dispatch flight ships that commit once,
|
|
149
|
+
// without --merge, so a pull request opens and a person lands it. A builder
|
|
150
|
+
// that shipped too would open the pull request before the flight's own
|
|
151
|
+
// check, and if the flight's check or ship then failed, the claim would be
|
|
152
|
+
// released with that pull request open and unrecorded. Yolo still gives the
|
|
153
|
+
// builder its network for running checks.
|
|
154
|
+
const lane = yolo ? protectedLaneOfTask(task) : null;
|
|
155
|
+
let commitRule;
|
|
156
|
+
if (lane) {
|
|
157
|
+
commitRule = `This task is in the protected ${lane} lane, so a person lands it, not you. Commit on the current branch with a plain-English message. Do not push, do not run \`atris worktree ship\`, and do not create branches; the conductor ships your commit for a person to review.`;
|
|
158
|
+
} else if (yolo) {
|
|
159
|
+
commitRule = `Land it yourself. Commit on the current branch with a plain-English message, then run: atris worktree ship --message "${shipMessage} (${ref})" --verify ${shellQuote(check)} --merge, and report the PR URL. That ship command is the only push allowed. If it reports a conflict or a failed check, stop and report; never resolve conflicts yourself.`;
|
|
160
|
+
} else {
|
|
161
|
+
commitRule = 'Commit on the current branch with a clear message. Do not push and do not create branches; the conductor lands your commit.';
|
|
162
|
+
}
|
|
163
|
+
// The guard refuses a checkout that has edits in it, so it can only pass at
|
|
164
|
+
// the start. A builder that re-ran it in its review step, after its edits,
|
|
165
|
+
// took the failure as a stop order and left finished work uncommitted.
|
|
129
166
|
const lines = [
|
|
130
|
-
'
|
|
167
|
+
'Before your first edit, run `atris worktree guard` once; if it fails then, stop immediately, report back, and do not edit anything. It checks for a clean start, so it fails once edits exist: never run it again after editing.',
|
|
131
168
|
'',
|
|
132
169
|
ATRIS_BUILD_PROCESS_PREAMBLE,
|
|
133
170
|
'',
|
|
@@ -151,7 +188,7 @@ function buildFleetPrompt(task, { worktreePath, yolo = false } = {}) {
|
|
|
151
188
|
`- ${commitRule}`,
|
|
152
189
|
...METHOD_KERNEL.map((rule) => `- ${rule}`),
|
|
153
190
|
'',
|
|
154
|
-
`Final report (plain text): files changed, check command + result, commit sha${yolo ? ', PR URL' : ''} (or say what failed and why).`
|
|
191
|
+
`Final report (plain text): files changed, check command + result, commit sha${yolo && !lane ? ', PR URL' : ''} (or say what failed and why).`
|
|
155
192
|
);
|
|
156
193
|
const lessonFiles = [
|
|
157
194
|
...(Array.isArray(task.files) ? task.files : []),
|
|
@@ -844,9 +881,11 @@ function rosterAttemptRecord(engineName, pin, startedMs, endedMs, result, prep =
|
|
|
844
881
|
};
|
|
845
882
|
}
|
|
846
883
|
|
|
847
|
-
// After landing: the last attempt landed only when its task is in the landed
|
|
848
|
-
//
|
|
849
|
-
//
|
|
884
|
+
// After landing: the last attempt landed only when its task is in the landed or
|
|
885
|
+
// proof-ready rows. A task whose pull request waits for a person is held: not a
|
|
886
|
+
// landing until someone lands it, and not a failure either. Otherwise it failed
|
|
887
|
+
// at the stage that paused it. Every earlier attempt keeps its own outcome and
|
|
888
|
+
// names who took over.
|
|
850
889
|
function recordFlightRosterRuns(root, flight, results, { job = 'build', member = '', source = '' } = {}) {
|
|
851
890
|
for (const { entry, result } of results || []) {
|
|
852
891
|
const attempts = (result && Array.isArray(result.attempts) ? result.attempts : []).map((attempt) => ({ ...attempt }));
|
|
@@ -855,9 +894,15 @@ function recordFlightRosterRuns(root, flight, results, { job = 'build', member =
|
|
|
855
894
|
const sameTask = (row) => row && String(row.task) === taskId;
|
|
856
895
|
const last = attempts[attempts.length - 1];
|
|
857
896
|
if (last.outcome === 'landed') {
|
|
897
|
+
const held = (flight.held_for_review || []).find(sameTask);
|
|
858
898
|
const landed = (flight.landed || []).some(sameTask) || (flight.ready || []).some(sameTask);
|
|
859
899
|
const paused = (flight.paused || []).find(sameTask);
|
|
860
|
-
if (
|
|
900
|
+
if (held) {
|
|
901
|
+
last.outcome = 'held';
|
|
902
|
+
last.detail = held.review_recorded === false
|
|
903
|
+
? `pull request ${held.pull_request.url} is open but its proof could not be saved`
|
|
904
|
+
: `waiting for a person to land ${held.pull_request.url}`;
|
|
905
|
+
} else if (!landed) {
|
|
861
906
|
last.outcome = 'failed';
|
|
862
907
|
last.detail = paused
|
|
863
908
|
? `paused at ${String(paused.stage || 'landing').replace(/_/g, ' ')}${paused.detail ? `: ${String(paused.detail).split('\n')[0]}` : ''}`
|
|
@@ -1339,20 +1384,6 @@ function dispatchToEngine({ task, engine, worktreePath, root = process.cwd(), ti
|
|
|
1339
1384
|
// ---------------------------------------------------------------------------
|
|
1340
1385
|
// T2: staffing
|
|
1341
1386
|
|
|
1342
|
-
const {
|
|
1343
|
-
taskTagTokens,
|
|
1344
|
-
isDecisionHoldTag,
|
|
1345
|
-
isDecisionTask,
|
|
1346
|
-
DECISION_REFUSE_REASON,
|
|
1347
|
-
} = require('./task-decision');
|
|
1348
|
-
|
|
1349
|
-
function taskTags(task) {
|
|
1350
|
-
// Tags added after creation live in metadata.tags (`atris task tag`); a
|
|
1351
|
-
// fleet that only read task.tags/title hashtags would ignore an owner-hold
|
|
1352
|
-
// flag stamped on a live task and keep restaffing it (CLI-879).
|
|
1353
|
-
return taskTagTokens(task);
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
1387
|
// A task flagged for a human decision is never fleet-staffable, whatever its
|
|
1357
1388
|
// lane. Mirrors the sweep's needs-human hold so both loops agree. Also honors
|
|
1358
1389
|
// the clearer `decision` tag so policy questions stay off autonomous lanes.
|
|
@@ -1360,10 +1391,10 @@ function isHumanHoldTag(tag) {
|
|
|
1360
1391
|
return isDecisionHoldTag(tag);
|
|
1361
1392
|
}
|
|
1362
1393
|
|
|
1394
|
+
// The fleet may staff a task only when no person has to decide it and it sits
|
|
1395
|
+
// in no protected lane. Protection is protectedLaneOfTask's call alone.
|
|
1363
1396
|
function isSafeLane(task) {
|
|
1364
|
-
|
|
1365
|
-
const tags = taskTags(task);
|
|
1366
|
-
return !tags.some((t) => DENIED_TAGS.includes(t));
|
|
1397
|
+
return !isDecisionTask(task) && !protectedLaneOfTask(task);
|
|
1367
1398
|
}
|
|
1368
1399
|
|
|
1369
1400
|
// Rough file-surface guess from a task's text: paths and bare `commands/x.js`
|
|
@@ -1505,16 +1536,37 @@ function landedMergeFlags(merge) {
|
|
|
1505
1536
|
return flags;
|
|
1506
1537
|
}
|
|
1507
1538
|
|
|
1508
|
-
|
|
1539
|
+
// task ready flags that name the pull request left open for a person to land.
|
|
1540
|
+
// No merge commit, so the job record shows it as waiting, not landed.
|
|
1541
|
+
function openPullRequestFlags(pullRequest) {
|
|
1542
|
+
if (!pullRequest) return [];
|
|
1543
|
+
const flags = [];
|
|
1544
|
+
if (pullRequest.number) flags.push('--pr-number', String(pullRequest.number));
|
|
1545
|
+
if (pullRequest.url) flags.push('--pr-url', pullRequest.url);
|
|
1546
|
+
return flags;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// merge=false pushes the branch and opens the pull request without merging it.
|
|
1550
|
+
function fleetShipArgs(entry, check, targetRef = resolveDispatchLandTarget(entry.worktreePath || process.cwd()), { merge = true } = {}) {
|
|
1509
1551
|
return [
|
|
1510
1552
|
'worktree', 'ship',
|
|
1511
1553
|
'--message', `${String(entry.task.title || '').split(/[.:]/)[0].slice(0, 90)} (${entry.task.display_id}, built by ${entry.engine})`,
|
|
1512
1554
|
'--verify', check,
|
|
1513
1555
|
'--target', targetRef,
|
|
1514
|
-
'--merge',
|
|
1556
|
+
...(merge ? ['--merge'] : []),
|
|
1515
1557
|
];
|
|
1516
1558
|
}
|
|
1517
1559
|
|
|
1560
|
+
// The open pull request worktree ship printed when it was not asked to merge:
|
|
1561
|
+
// `pr: <url>` for a new one, `pr: <number> <url>` for one that was already open.
|
|
1562
|
+
function shipPullRequest(stdout) {
|
|
1563
|
+
const line = String(stdout || '').match(/^pr: (?:(\d+) )?(https?:\/\/\S+)\s*$/m);
|
|
1564
|
+
if (!line) return null;
|
|
1565
|
+
const url = line[2];
|
|
1566
|
+
const number = Number(line[1] || (url.match(/\/pull\/(\d+)/) || [])[1]);
|
|
1567
|
+
return { url, ...(Number.isInteger(number) && number > 0 ? { number } : {}) };
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1518
1570
|
// A fleet build worktree is ephemeral. If any process inside one runs
|
|
1519
1571
|
// `npm link` (which repoints the global `atris` package symlink at that
|
|
1520
1572
|
// worktree), then removing the worktree at teardown leaves the global `atris`
|
|
@@ -1645,6 +1697,8 @@ function landArrival({ worktreePath, targetRef = resolveDispatchLandTarget(workt
|
|
|
1645
1697
|
|
|
1646
1698
|
module.exports = {
|
|
1647
1699
|
DENIED_TAGS,
|
|
1700
|
+
protectedLaneOfTask,
|
|
1701
|
+
shipPullRequest,
|
|
1648
1702
|
DEAD_ENGINE_OUTPUT_PATTERNS,
|
|
1649
1703
|
releasePausedClaimsWithoutWork,
|
|
1650
1704
|
reviewOnlyEngineEnvironment,
|
|
@@ -1653,6 +1707,7 @@ module.exports = {
|
|
|
1653
1707
|
shipFailureDetail,
|
|
1654
1708
|
shipMergeRecord,
|
|
1655
1709
|
landedMergeFlags,
|
|
1710
|
+
openPullRequestFlags,
|
|
1656
1711
|
get FLEET_CAPABLE() { return FLEET_CAPABLE; },
|
|
1657
1712
|
get DISPATCH_CAPABLE() { return DISPATCH_CAPABLE; },
|
|
1658
1713
|
get runFleetFlight() { return runFleetFlight; },
|
|
@@ -1670,12 +1725,10 @@ module.exports = {
|
|
|
1670
1725
|
assertIsolatedWorktree,
|
|
1671
1726
|
buildEngineCommand,
|
|
1672
1727
|
isSafeLane,
|
|
1673
|
-
taskTags,
|
|
1674
1728
|
nextInstalledFleetEngine,
|
|
1675
1729
|
rankFleetEnginesDetailed,
|
|
1676
1730
|
dispatchToEngine,
|
|
1677
1731
|
recordDispatchOutcome,
|
|
1678
|
-
taskTags,
|
|
1679
1732
|
isHumanHoldTag,
|
|
1680
1733
|
isDecisionTask,
|
|
1681
1734
|
DECISION_REFUSE_REASON,
|
|
@@ -2820,7 +2873,9 @@ async function validateWithAttempts({
|
|
|
2820
2873
|
|
|
2821
2874
|
function dispatchReceiptResult(flight, { ids, reviewOnly, enforceRemoteBoundary }) {
|
|
2822
2875
|
const validatorResult = flight.validator_result || flight.result?.validator_result;
|
|
2823
|
-
|
|
2876
|
+
// Work on a pull request waiting for a person is neither landed nor failed.
|
|
2877
|
+
const held = flight.held_for_review || [];
|
|
2878
|
+
const successfulRows = reviewOnly ? flight.ready : [...flight.landed, ...held];
|
|
2824
2879
|
const successfulVerifierRows = successfulRows
|
|
2825
2880
|
.map((row) => row.verifier_result)
|
|
2826
2881
|
.filter((row) => row && typeof row === 'object');
|
|
@@ -2842,12 +2897,13 @@ function dispatchReceiptResult(flight, { ids, reviewOnly, enforceRemoteBoundary
|
|
|
2842
2897
|
&& validatorResult.engine !== validatorResult.executor_engine;
|
|
2843
2898
|
const passed = basePassed && (!oneLapReview || validatorPassed === true);
|
|
2844
2899
|
const result = {
|
|
2845
|
-
kind: reviewOnly ? 'dispatch_review_ready' : 'dispatch_landed',
|
|
2900
|
+
kind: reviewOnly ? 'dispatch_review_ready' : (held.length && !flight.landed.length ? 'dispatch_held_for_review' : 'dispatch_landed'),
|
|
2846
2901
|
passed,
|
|
2847
2902
|
verifier_result: verifierRows.length === 1
|
|
2848
2903
|
? verifierRows[0]
|
|
2849
2904
|
: { passed, checks: verifierRows },
|
|
2850
2905
|
};
|
|
2906
|
+
if (!reviewOnly && held.length) result.held_for_review = held.length;
|
|
2851
2907
|
if (reviewOnly) {
|
|
2852
2908
|
result.master_boundary_enforced = enforceRemoteBoundary;
|
|
2853
2909
|
result.master_unchanged = enforceRemoteBoundary
|
|
@@ -2914,7 +2970,10 @@ function defaultOwnCli(root) {
|
|
|
2914
2970
|
|
|
2915
2971
|
// A merge and its task handoff are separate writes. Keep the merge on disk
|
|
2916
2972
|
// before attempting the handoff, and never report success if that write fails.
|
|
2917
|
-
|
|
2973
|
+
// heldForReview is the same handoff for a pull request left open for a person:
|
|
2974
|
+
// if it fails, the paused row carries the pull request so the caller can let the
|
|
2975
|
+
// claim go with its link as the reason.
|
|
2976
|
+
function recordLandedTaskReview(cli, args, { flight, landedRow, worktree, save, heldForReview = false }) {
|
|
2918
2977
|
landedRow.review_recorded = false;
|
|
2919
2978
|
save();
|
|
2920
2979
|
let result;
|
|
@@ -2926,10 +2985,12 @@ function recordLandedTaskReview(cli, args, { flight, landedRow, worktree, save }
|
|
|
2926
2985
|
task: landedRow.task,
|
|
2927
2986
|
engine: landedRow.engine,
|
|
2928
2987
|
stage: 'task_ready',
|
|
2929
|
-
landed: true,
|
|
2988
|
+
...(heldForReview ? { held_for_review: true, pull_request: landedRow.pull_request } : { landed: true }),
|
|
2930
2989
|
worktree,
|
|
2931
2990
|
detail: headTail(String(result && (result.stderr || result.stdout) || 'task ready failed')),
|
|
2932
|
-
next_action:
|
|
2991
|
+
next_action: heldForReview
|
|
2992
|
+
? 'record fresh task proof with task ready; do not rebuild, and leave the pull request open'
|
|
2993
|
+
: 'record fresh task proof with task ready; do not rebuild or merge again',
|
|
2933
2994
|
});
|
|
2934
2995
|
save();
|
|
2935
2996
|
return false;
|
|
@@ -3234,22 +3295,40 @@ async function runFleetFlight({
|
|
|
3234
3295
|
const prepared = [];
|
|
3235
3296
|
const leaseDb = require('./task-db');
|
|
3236
3297
|
const leaseConnection = leaseDb.open();
|
|
3298
|
+
const workspace = leaseDb.workspaceRoot(root);
|
|
3237
3299
|
const leased = new Map();
|
|
3300
|
+
// What each claimed job is called to the task command, and who holds it, by display id. Only
|
|
3301
|
+
// jobs this flight claimed are in them, so a listed job it refused cannot be mistaken for one.
|
|
3302
|
+
const claimedRefs = new Map();
|
|
3303
|
+
const claimedActors = new Map();
|
|
3238
3304
|
const leaseTimer = setInterval(() => {
|
|
3239
3305
|
for (const [id, holder] of leased) leaseDb.renewLease(leaseConnection, { id, holder, pid: process.pid });
|
|
3240
3306
|
}, 5 * 60000);
|
|
3241
3307
|
leaseTimer.unref();
|
|
3242
3308
|
try {
|
|
3243
3309
|
for (const entry of staffed) {
|
|
3244
|
-
|
|
3310
|
+
// Only this workspace's own jobs. The list can name a job in another workspace, and the store
|
|
3311
|
+
// takes its row id as readily as one of ours, so the entry's row is asked before anything is claimed.
|
|
3312
|
+
if (jobOutsideWorkspace(leaseDb, entry.task, workspace)) {
|
|
3313
|
+
const detail = namesAnotherWorkspace(entry.task.display_id);
|
|
3314
|
+
flight.paused.push({ task: entry.task.display_id, engine: entry.engine, stage: 'other_workspace', detail });
|
|
3315
|
+
log(` ${entry.engine.padEnd(8)} ✗ ${detail}`);
|
|
3316
|
+
continue;
|
|
3317
|
+
}
|
|
3318
|
+
// The row the list named is the row this claims, leases, proves and lets go of: named once,
|
|
3319
|
+
// by its row id, at every step (see jobRef).
|
|
3320
|
+
const rowRef = jobRef(entry.task, entry.task.display_id);
|
|
3321
|
+
const claimed = cli(['task', 'claim', rowRef, '--as', `fleet-${entry.engine}`, '--lease-minutes', '60', '--lease-pid', String(process.pid)]);
|
|
3245
3322
|
if (!claimed || claimed.status !== 0) {
|
|
3246
3323
|
const detail = String(claimed && (claimed.stderr || claimed.stdout) || 'claim failed').trim().slice(0, 300);
|
|
3247
3324
|
flight.paused.push({ task: entry.task.display_id, engine: entry.engine, stage: 'claim', detail });
|
|
3248
3325
|
log(` ${entry.engine.padEnd(8)} ⏸ paused ${entry.task.display_id} at claim${detail ? `, ${detail}` : ''}`);
|
|
3249
3326
|
continue;
|
|
3250
3327
|
}
|
|
3251
|
-
const leaseId =
|
|
3328
|
+
const leaseId = rowRef;
|
|
3252
3329
|
const holder = `fleet-${entry.engine}`;
|
|
3330
|
+
claimedRefs.set(String(entry.task.display_id), rowRef);
|
|
3331
|
+
claimedActors.set(String(entry.task.display_id), holder);
|
|
3253
3332
|
const leaseSet = leaseDb.setLease(leaseConnection, { id: leaseId, holder });
|
|
3254
3333
|
if (!leaseSet && (!ownCli || leaseDb.getTask(leaseConnection, leaseId))) throw new Error('could not set claim lease');
|
|
3255
3334
|
if (leaseSet) leased.set(leaseId, holder);
|
|
@@ -3330,7 +3409,7 @@ async function runFleetFlight({
|
|
|
3330
3409
|
flight.landed.push(landedRow);
|
|
3331
3410
|
const restaffProof = result.restaffed ? ` Restaffed from ${result.restaffed.from} to ${result.restaffed.to} (${result.restaffed.reason}).` : '';
|
|
3332
3411
|
const reviewRecorded = recordLandedTaskReview(cli, [
|
|
3333
|
-
'task', 'ready',
|
|
3412
|
+
'task', 'ready', jobRef(entry.task, entry.task.display_id),
|
|
3334
3413
|
'--proof', `Built by ${activeEngine} engine in fleet flight.${restaffProof} Landed via worktree ship gate (rebase-before-ship, verify re-run). Receipt saved at ${path.relative(root, receiptPath)}. Report tail: ${headTail(String(result.report || '').replace(/\n/g, ' '))}`,
|
|
3335
3414
|
'--result', 'Operators can now review fleet-shipped work faster because the worktree was verified before landing.',
|
|
3336
3415
|
...landedMergeFlags(landed.merge),
|
|
@@ -3365,7 +3444,8 @@ async function runFleetFlight({
|
|
|
3365
3444
|
releasePausedClaimsWithoutWork(cli, {
|
|
3366
3445
|
paused: flight.paused,
|
|
3367
3446
|
worktreeByTask: new Map(prepared.map((entry) => [String(entry.task.display_id), entry.worktreePath])),
|
|
3368
|
-
actorByTask:
|
|
3447
|
+
actorByTask: claimedActors,
|
|
3448
|
+
refByTask: claimedRefs,
|
|
3369
3449
|
baseRef: landTarget,
|
|
3370
3450
|
log,
|
|
3371
3451
|
});
|
|
@@ -3390,10 +3470,47 @@ async function runFleetFlight({
|
|
|
3390
3470
|
// ---------------------------------------------------------------------------
|
|
3391
3471
|
// T5, one-command dispatch: `atris engine dispatch <task-id> --engine <name>`
|
|
3392
3472
|
|
|
3473
|
+
// Why the flight let a claim go, in one plain line. It is saved on the release
|
|
3474
|
+
// so the rating can tell good work that stalled from a plain drop.
|
|
3475
|
+
function pausedStageLabel(stage) {
|
|
3476
|
+
return String(stage || 'an unknown step').replace(/_failed$/, '').replace(/_/g, ' ');
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3479
|
+
function releaseReason(lead, detail) {
|
|
3480
|
+
const firstLine = String(detail || '').split(/\r?\n/).map((line) => line.trim()).find(Boolean) || '';
|
|
3481
|
+
return (firstLine ? `${lead}: ${firstLine}` : lead).slice(0, 300);
|
|
3482
|
+
}
|
|
3483
|
+
|
|
3484
|
+
// Pauses where the engine never got to finish its turn.
|
|
3485
|
+
const ENGINE_DID_NOT_FINISH = new Set(['worktree_start', 'remote_quarantine', 'prepare', 'build']);
|
|
3486
|
+
|
|
3487
|
+
// The word a flight uses to name a job to the task command at every step: its row id when the
|
|
3488
|
+
// task command (or the task list) gave one, else the display id the flight has. The display id a
|
|
3489
|
+
// person reads is not a key. The same folder name in another place gives the same ids, and the
|
|
3490
|
+
// list that gives an id its meaning can be written again between two commands, so a command that
|
|
3491
|
+
// finds a job by it again can land on another job. It names a job only when no row id came back,
|
|
3492
|
+
// as with a stand-in task plane.
|
|
3493
|
+
function jobRef(task, displayId) {
|
|
3494
|
+
return task && task.id ? String(task.id) : String(displayId);
|
|
3495
|
+
}
|
|
3496
|
+
|
|
3497
|
+
// A flight works only on jobs in its own workspace, and the store takes a row id from any
|
|
3498
|
+
// workspace, so the flight asks whose workspace the row is in before it claims anything. That is
|
|
3499
|
+
// the store's own rule for what a workspace holds (rowInWorkspace, the one its workspace listing
|
|
3500
|
+
// uses): the workspace, spelled the way the store spells it, and the git worktrees linked to it.
|
|
3501
|
+
// A row with no workspace is in no workspace's listing, so it is not inside this one. A job shown
|
|
3502
|
+
// with no row id has no row to ask about; it is named by its display id at every step.
|
|
3503
|
+
function jobOutsideWorkspace(taskDb, task, workspace) {
|
|
3504
|
+
return Boolean(task && task.id) && !taskDb.rowInWorkspace(task, workspace);
|
|
3505
|
+
}
|
|
3506
|
+
|
|
3507
|
+
const namesAnotherWorkspace = (displayId) => `${displayId} names a job in another workspace`;
|
|
3508
|
+
|
|
3393
3509
|
// A claim taken before the engine starts must not stay held if the flight
|
|
3394
|
-
// refuses or errors first. Release through the same task plane the claim used
|
|
3395
|
-
|
|
3396
|
-
|
|
3510
|
+
// refuses or errors first. Release through the same task plane the claim used,
|
|
3511
|
+
// naming the job by ref: the row id the claim named.
|
|
3512
|
+
function releaseUnstartedDispatchClaim(cli, { taskId, ref = taskId, actor, detail, stage }) {
|
|
3513
|
+
const released = cli(['task', 'release', ref, '--as', actor, '--reason', releaseReason(`paused at ${pausedStageLabel(stage)}`, detail)]);
|
|
3397
3514
|
const releasedOk = Boolean(released && released.status === 0);
|
|
3398
3515
|
const suffix = releasedOk
|
|
3399
3516
|
? 'claim released, safe to retry'
|
|
@@ -3413,40 +3530,124 @@ function releaseUnstartedDispatchClaim(cli, { taskId, actor, detail }) {
|
|
|
3413
3530
|
// nothing is working the task any more, and a kept claim made the retry
|
|
3414
3531
|
// fail at "claim failed". The work stays on its branch and the retry's
|
|
3415
3532
|
// worktree start resumes from it.
|
|
3416
|
-
|
|
3533
|
+
//
|
|
3534
|
+
// refByTask names each claimed job by the row id the flight claimed it with, so the release
|
|
3535
|
+
// lands on that row even if the display id a person reads now means another job. A job with
|
|
3536
|
+
// no entry there is named by the display id in its pause row.
|
|
3537
|
+
function releasePausedClaimsWithoutWork(cli, { paused, worktreeByTask, actorByTask, refByTask = new Map(), baseRef = '', log = () => {}, keepClaimsWithWork = true }) {
|
|
3417
3538
|
for (const row of paused || []) {
|
|
3418
|
-
|
|
3539
|
+
// No claim (the job was never found, was refused as another workspace's, or the claim
|
|
3540
|
+
// failed), work already landed, or work waiting on a pull request for a person (the flight
|
|
3541
|
+
// let that claim go itself when saving its proof failed).
|
|
3542
|
+
if (row.stage === 'claim' || row.stage === 'task_lookup' || row.stage === 'other_workspace' || row.landed === true || row.held_for_review === true) continue;
|
|
3419
3543
|
if (row.claim_released !== undefined) continue;
|
|
3420
3544
|
const taskId = String(row.task || '');
|
|
3421
3545
|
const actor = actorByTask.get(taskId);
|
|
3422
3546
|
if (!taskId || !actor) continue;
|
|
3423
3547
|
const worktreePath = row.worktree || worktreeByTask.get(taskId) || '';
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
const ahead = spawnSync('git', ['-C', worktreePath, 'rev-list', '--count', `${resolvedBase}..HEAD`], { encoding: 'utf8' });
|
|
3434
|
-
if (ahead.status !== 0 || parseInt(String(ahead.stdout).trim(), 10) > 0) continue;
|
|
3435
|
-
// -uall lists untracked files one by one; the default collapses a fully
|
|
3436
|
-
// untracked .atris/ into "?? .atris/", which the allowlist cannot judge.
|
|
3437
|
-
const status = spawnSync('git', ['-C', worktreePath, 'status', '--porcelain', '-uall'], { encoding: 'utf8' });
|
|
3438
|
-
if (status.status !== 0) continue;
|
|
3439
|
-
const dirt = String(status.stdout || '').split('\n').filter(Boolean).filter((line) => !isConductorStatusLine(line));
|
|
3440
|
-
if (dirt.length > 0) continue;
|
|
3441
|
-
} else if (row.stage !== 'worktree_start' && row.stage !== 'remote_quarantine' && row.stage !== 'prepare') {
|
|
3442
|
-
continue; // worktree gone and stage ambiguous: leave the claim for a human
|
|
3443
|
-
}
|
|
3444
|
-
const released = cli(['task', 'release', taskId, '--as', actor]);
|
|
3548
|
+
const noWork = pausedWorktreeHasNoWork(row, worktreePath, baseRef);
|
|
3549
|
+
if (keepClaimsWithWork && !noWork) continue;
|
|
3550
|
+
// "No work" is for an engine that finished its turn and changed nothing. A
|
|
3551
|
+
// pause before that, or a dead engine, keeps its own reason: it is not a
|
|
3552
|
+
// worker saying the job was already done.
|
|
3553
|
+
const reason = noWork && !ENGINE_DID_NOT_FINISH.has(row.stage)
|
|
3554
|
+
? 'engine produced no work'
|
|
3555
|
+
: releaseReason(`paused at ${pausedStageLabel(row.stage)}`, row.stage === 'build' ? row.reason || row.detail : row.detail || row.reason);
|
|
3556
|
+
const released = cli(['task', 'release', refByTask.get(taskId) || taskId, '--as', actor, '--reason', reason]);
|
|
3445
3557
|
row.claim_released = Boolean(released && released.status === 0);
|
|
3446
|
-
if (row.claim_released)
|
|
3558
|
+
if (!row.claim_released) continue;
|
|
3559
|
+
log(keepClaimsWithWork
|
|
3560
|
+
? ` released claim on ${taskId} (engine produced no work)`
|
|
3561
|
+
: ` released claim on ${taskId} so a retry can pick it up${worktreePath ? `; any work stays in ${path.basename(worktreePath)}` : ''}`);
|
|
3447
3562
|
}
|
|
3448
3563
|
}
|
|
3449
3564
|
|
|
3565
|
+
// A pull request is open for a person, but the proof that says so could not be
|
|
3566
|
+
// saved. Left alone, the claim would keep its 60 minute lease and then a sweep
|
|
3567
|
+
// would expire it as a plain drop, with the pull request still open. Let it go
|
|
3568
|
+
// now, under the name that holds it, and put the link in the reason so the
|
|
3569
|
+
// record points at the work.
|
|
3570
|
+
//
|
|
3571
|
+
// The release also names its kind and the pull request as fields (never read
|
|
3572
|
+
// back out of the reason), so the rating leaves it out of both landed and
|
|
3573
|
+
// missed. Only the flight can say that: it opened the pull request itself, so it
|
|
3574
|
+
// records the release here, in its own process, through the task store it already
|
|
3575
|
+
// holds for leases. `atris task release` takes no kind, because any worker holding
|
|
3576
|
+
// a failed job could pass one to excuse its own miss. Nothing here reads the
|
|
3577
|
+
// environment, so a flight started from an agent session records it the same.
|
|
3578
|
+
//
|
|
3579
|
+
// It lets go of the exact row it claimed: rowId is the id the task command showed
|
|
3580
|
+
// before the claim, and the claim and the lease named that same row. The display id
|
|
3581
|
+
// a person reads is not a key, since the same folder name in another place gives the
|
|
3582
|
+
// same ids, so it is never looked up again. With no row id there is nothing exact to
|
|
3583
|
+
// release, and nothing is.
|
|
3584
|
+
//
|
|
3585
|
+
// Tried once: if the release fails too, the row says so.
|
|
3586
|
+
function releaseClaimOfUnsavedPullRequest({ taskDb, db, root, row, rowId, actor, log = () => {} }) {
|
|
3587
|
+
const reason = releaseReason(`PR ${row.pull_request.url} is open but its proof could not be saved`, row.detail);
|
|
3588
|
+
let released = false;
|
|
3589
|
+
let failure = 'unknown';
|
|
3590
|
+
try {
|
|
3591
|
+
if (!rowId) {
|
|
3592
|
+
failure = 'the flight has no row id for it';
|
|
3593
|
+
} else {
|
|
3594
|
+
const result = taskDb.releaseTask(db, {
|
|
3595
|
+
id: rowId,
|
|
3596
|
+
actor,
|
|
3597
|
+
reason,
|
|
3598
|
+
kind: PROOF_NOT_SAVED,
|
|
3599
|
+
pullRequest: row.pull_request,
|
|
3600
|
+
});
|
|
3601
|
+
if (!result.released) {
|
|
3602
|
+
failure = `release failed: ${result.reason}${result.claimed_by ? ` (held by ${result.claimed_by})` : ''}`;
|
|
3603
|
+
} else {
|
|
3604
|
+
released = true;
|
|
3605
|
+
// The two files people read are drawn again from the saved state, the way the
|
|
3606
|
+
// task command does after every release: the task list file, and atris/TODO.md
|
|
3607
|
+
// of the flight's own workspace. The release is already saved, so a file that
|
|
3608
|
+
// cannot be written does not undo it, and one failing does not stop the other.
|
|
3609
|
+
// The task command is loaded only here: this file is read on every start, and
|
|
3610
|
+
// only this rare path needs it.
|
|
3611
|
+
for (const [what, refresh] of [
|
|
3612
|
+
['task list file', () => require('../commands/task').writeDefaultProjection(taskDb, db, { root })],
|
|
3613
|
+
['TODO.md', () => require('../commands/task').autoRenderTodoFromDb(taskDb.workspaceRoot(root))],
|
|
3614
|
+
]) {
|
|
3615
|
+
try { refresh(); } catch (err) {
|
|
3616
|
+
if (process.env.ATRIS_DEBUG) process.stderr.write(`${what} refresh failed: ${String(err?.message ?? err).replace(/[\r\n]+/g, ' ')}\n`);
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
}
|
|
3620
|
+
}
|
|
3621
|
+
} catch (err) {
|
|
3622
|
+
failure = String(err?.message ?? err).trim() || 'unknown';
|
|
3623
|
+
}
|
|
3624
|
+
row.claim_released = released;
|
|
3625
|
+
log(row.claim_released
|
|
3626
|
+
? ` released the claim on ${row.task} with the pull request link as the reason`
|
|
3627
|
+
: ` could not release the claim on ${row.task}: ${failure.replace(/\s+/g, ' ').trim().slice(0, 80)}. it stays until its lease runs out, unless the task record is repaired first.`);
|
|
3628
|
+
return row.claim_released;
|
|
3629
|
+
}
|
|
3630
|
+
|
|
3631
|
+
// True only when the paused task's worktree is level with its base and holds
|
|
3632
|
+
// nothing but conductor litter. Any doubt counts as work, so a claim is never
|
|
3633
|
+
// let go as "no work" while real changes may exist.
|
|
3634
|
+
function pausedWorktreeHasNoWork(row, worktreePath, baseRef) {
|
|
3635
|
+
if (worktreePath && fs.existsSync(worktreePath)) {
|
|
3636
|
+
const resolvedBase = baseRef || defaultMainlineBase(worktreePath);
|
|
3637
|
+
if (!baseRef && resolvedBase === 'HEAD') return false; // no known start point
|
|
3638
|
+
const ahead = spawnSync('git', ['-C', worktreePath, 'rev-list', '--count', `${resolvedBase}..HEAD`], { encoding: 'utf8' });
|
|
3639
|
+
if (ahead.status !== 0 || parseInt(String(ahead.stdout).trim(), 10) > 0) return false;
|
|
3640
|
+
// -uall lists untracked files one by one; the default collapses a fully
|
|
3641
|
+
// untracked .atris/ into "?? .atris/", which the allowlist cannot judge.
|
|
3642
|
+
const status = spawnSync('git', ['-C', worktreePath, 'status', '--porcelain', '-uall'], { encoding: 'utf8' });
|
|
3643
|
+
if (status.status !== 0) return false;
|
|
3644
|
+
const dirt = String(status.stdout || '').split('\n').filter(Boolean).filter((line) => !isConductorStatusLine(line));
|
|
3645
|
+
return dirt.length === 0;
|
|
3646
|
+
}
|
|
3647
|
+
// Worktree gone: only the early stages are known to hold nothing.
|
|
3648
|
+
return row.stage === 'worktree_start' || row.stage === 'remote_quarantine' || row.stage === 'prepare';
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3450
3651
|
// The manual version of this loop took 6 Bash calls per task the night this
|
|
3451
3652
|
// was written: claim, worktree start, prompt file, engine -p, verify, ship.
|
|
3452
3653
|
// One or more explicit task ids build in parallel isolated worktrees on ONE
|
|
@@ -3521,6 +3722,9 @@ async function runDispatchFlight({
|
|
|
3521
3722
|
const cli = ownCli || defaultOwnCli(root);
|
|
3522
3723
|
const leaseDb = require('./task-db');
|
|
3523
3724
|
const leaseConnection = leaseDb.open();
|
|
3725
|
+
// The workspace this flight works in, spelled the way the store spells it. It is read once, before
|
|
3726
|
+
// the flight writes anything into its folder.
|
|
3727
|
+
const workspace = leaseDb.workspaceRoot(root);
|
|
3524
3728
|
try { leaseDb.expireLeases(leaseConnection); } catch (err) { if (process.env.ATRIS_DEBUG) process.stderr.write(`lease sweep failed: ${String(err?.message ?? err).replace(/[\r\n]+/g, ' ')}\n`); }
|
|
3525
3729
|
// Cut worktrees from, and check self-landing against, the workspace's real
|
|
3526
3730
|
// protected branch, not a hard-coded origin/master (CLI-1298).
|
|
@@ -3547,6 +3751,7 @@ async function runDispatchFlight({
|
|
|
3547
3751
|
results: [],
|
|
3548
3752
|
landed: [],
|
|
3549
3753
|
ready: [],
|
|
3754
|
+
held_for_review: [],
|
|
3550
3755
|
paused: [],
|
|
3551
3756
|
};
|
|
3552
3757
|
if (yolo) flight.yolo = true;
|
|
@@ -3566,6 +3771,9 @@ async function runDispatchFlight({
|
|
|
3566
3771
|
const startBaseArgs = landTarget ? ['--base', landTarget] : [];
|
|
3567
3772
|
const prepared = [];
|
|
3568
3773
|
const leasedIds = new Set();
|
|
3774
|
+
// What each claimed job is called to the task command, by the id the caller gave. Only jobs this
|
|
3775
|
+
// flight claimed are in it.
|
|
3776
|
+
const claimedRefs = new Map();
|
|
3569
3777
|
const leaseTimer = setInterval(() => {
|
|
3570
3778
|
for (const id of leasedIds) leaseDb.renewLease(leaseConnection, { id, holder: taskActor, pid: process.pid });
|
|
3571
3779
|
}, 5 * 60000);
|
|
@@ -3578,15 +3786,29 @@ async function runDispatchFlight({
|
|
|
3578
3786
|
log(` ✗ ${taskId} not found`);
|
|
3579
3787
|
continue;
|
|
3580
3788
|
}
|
|
3581
|
-
|
|
3789
|
+
// Only this workspace's own jobs. What the task command showed can be a job in another
|
|
3790
|
+
// workspace (the list it read named it under this display id), and the store takes its row id
|
|
3791
|
+
// as readily as one of ours, so the row is asked before anything is claimed.
|
|
3792
|
+
if (jobOutsideWorkspace(leaseDb, task, workspace)) {
|
|
3793
|
+
const detail = namesAnotherWorkspace(taskId);
|
|
3794
|
+
flight.paused.push({ task: taskId, stage: 'other_workspace', detail });
|
|
3795
|
+
log(` ✗ ${detail}`);
|
|
3796
|
+
continue;
|
|
3797
|
+
}
|
|
3798
|
+
// The row the task command just showed is the row this claims, leases, proves and lets go of:
|
|
3799
|
+
// named once, by its row id, at every step (see jobRef). Log lines and the receipt keep saying
|
|
3800
|
+
// the display id.
|
|
3801
|
+
const rowRef = jobRef(task, taskId);
|
|
3802
|
+
const claimed = cli(['task', 'claim', rowRef, '--as', taskActor, '--lease-minutes', '60', '--lease-pid', String(process.pid)]);
|
|
3582
3803
|
if (!claimed || claimed.status !== 0) {
|
|
3583
3804
|
const detail = String(claimed && (claimed.stderr || claimed.stdout) || 'claim failed').trim().slice(0, 300);
|
|
3584
3805
|
flight.paused.push({ task: taskId, stage: 'claim', detail });
|
|
3585
3806
|
log(` x ${taskId} claim failed`);
|
|
3586
3807
|
continue;
|
|
3587
3808
|
}
|
|
3809
|
+
claimedRefs.set(String(taskId), rowRef);
|
|
3588
3810
|
try {
|
|
3589
|
-
const leaseId =
|
|
3811
|
+
const leaseId = rowRef;
|
|
3590
3812
|
const leased = leaseDb.setLease(leaseConnection, { id: leaseId, holder: taskActor });
|
|
3591
3813
|
if (!leased && (!ownCli || leaseDb.getTask(leaseConnection, leaseId))) throw new Error('could not set claim lease');
|
|
3592
3814
|
if (leased) leasedIds.add(leaseId);
|
|
@@ -3604,7 +3826,9 @@ async function runDispatchFlight({
|
|
|
3604
3826
|
if (!wt) {
|
|
3605
3827
|
const released = releaseUnstartedDispatchClaim(cli, {
|
|
3606
3828
|
taskId,
|
|
3829
|
+
ref: rowRef,
|
|
3607
3830
|
actor: taskActor,
|
|
3831
|
+
stage: 'worktree_start',
|
|
3608
3832
|
detail: String(started.stderr || '').slice(0, 200),
|
|
3609
3833
|
});
|
|
3610
3834
|
flight.paused.push({ task: taskId, stage: 'worktree_start', detail: released.detail, claim_released: released.released });
|
|
@@ -3616,7 +3840,9 @@ async function runDispatchFlight({
|
|
|
3616
3840
|
if (enforceRemoteBoundary && (!remoteBoundary || remoteBoundary.ok !== true)) {
|
|
3617
3841
|
const released = releaseUnstartedDispatchClaim(cli, {
|
|
3618
3842
|
taskId,
|
|
3843
|
+
ref: rowRef,
|
|
3619
3844
|
actor: taskActor,
|
|
3845
|
+
stage: 'remote_quarantine',
|
|
3620
3846
|
detail: headTail(String(remoteBoundary && remoteBoundary.detail || 'could not prepare a sealed review sandbox')),
|
|
3621
3847
|
});
|
|
3622
3848
|
flight.paused.push({
|
|
@@ -3659,7 +3885,9 @@ async function runDispatchFlight({
|
|
|
3659
3885
|
} catch (err) {
|
|
3660
3886
|
const released = releaseUnstartedDispatchClaim(cli, {
|
|
3661
3887
|
taskId,
|
|
3888
|
+
ref: rowRef,
|
|
3662
3889
|
actor: taskActor,
|
|
3890
|
+
stage: 'prepare',
|
|
3663
3891
|
detail: String(err && err.message || err).slice(0, 200),
|
|
3664
3892
|
});
|
|
3665
3893
|
flight.paused.push({ task: taskId, stage: 'prepare', detail: released.detail, claim_released: released.released });
|
|
@@ -3813,10 +4041,35 @@ async function runDispatchFlight({
|
|
|
3813
4041
|
change,
|
|
3814
4042
|
};
|
|
3815
4043
|
}
|
|
3816
|
-
|
|
4044
|
+
// A flight never merges protected-lane work. It ships the branch without
|
|
4045
|
+
// --merge, so a pull request opens and a person lands it.
|
|
4046
|
+
const lane = protectedLaneOfTask(entry.task);
|
|
4047
|
+
const shipped = shipWithRetry(cli, fleetShipArgs({ task: entry.task, engine: entry.engine || engine }, check, landTarget, { merge: !lane }), entry.worktreePath);
|
|
3817
4048
|
if (shipped.status !== 0 || !/done: worktree shipped/.test(shipped.stdout)) {
|
|
3818
4049
|
return { ok: false, stage: 'ship', detail: shipFailureDetail(shipped) };
|
|
3819
4050
|
}
|
|
4051
|
+
if (lane) {
|
|
4052
|
+
const pullRequest = shipPullRequest(shipped.stdout);
|
|
4053
|
+
if (!pullRequest) {
|
|
4054
|
+
const nothingToReview = /^pr: skipped \(no commits ahead of base\)/m.test(shipped.stdout);
|
|
4055
|
+
return {
|
|
4056
|
+
ok: false,
|
|
4057
|
+
stage: 'ship',
|
|
4058
|
+
detail: nothingToReview
|
|
4059
|
+
? 'no commits ahead of base, so there was no pull request to open'
|
|
4060
|
+
: `the branch was pushed, but no pull request link came back for the protected ${lane} lane`,
|
|
4061
|
+
};
|
|
4062
|
+
}
|
|
4063
|
+
return {
|
|
4064
|
+
ok: true,
|
|
4065
|
+
stage: 'held_for_review',
|
|
4066
|
+
lane,
|
|
4067
|
+
pull_request: pullRequest,
|
|
4068
|
+
check,
|
|
4069
|
+
verifyOutput: `${verified.stdout}${verified.stderr}`,
|
|
4070
|
+
verifier_result: verifierResult,
|
|
4071
|
+
};
|
|
4072
|
+
}
|
|
3820
4073
|
return {
|
|
3821
4074
|
ok: true,
|
|
3822
4075
|
stage: 'shipped',
|
|
@@ -4176,7 +4429,7 @@ async function runDispatchFlight({
|
|
|
4176
4429
|
? ` Independent validator ${validatorResult.engine} signed off: ${validatorResult.reason}.`
|
|
4177
4430
|
: '';
|
|
4178
4431
|
const readyResult = cli([
|
|
4179
|
-
'task', 'ready', entry.taskId,
|
|
4432
|
+
'task', 'ready', jobRef(entry.task, entry.taskId),
|
|
4180
4433
|
'--proof', `Built by ${activeEngine} engine via one-lap dispatch.${restaffProof} Preserved in isolated worktree ${reviewWorktreePath}.${verificationCitation}${validatorCitation} Verify output: ${verifyTail || '(command produced no output, exit 0)'}. Receipt saved at ${path.relative(root, receiptPath)}.`,
|
|
4181
4434
|
'--result', 'Operators can review a completed, verified change before it lands, reducing the risk of unreviewed changes reaching users.',
|
|
4182
4435
|
'--landing', 'Operators can review the verified change before it reaches users, reducing the risk of an unreviewed release.',
|
|
@@ -4210,13 +4463,49 @@ async function runDispatchFlight({
|
|
|
4210
4463
|
}
|
|
4211
4464
|
continue;
|
|
4212
4465
|
}
|
|
4466
|
+
if (landed.stage === 'held_for_review') {
|
|
4467
|
+
// Protected-lane work: the pull request is open, a person lands it.
|
|
4468
|
+
// The claim stays with the builder and the job waits in Review, so the
|
|
4469
|
+
// rating sees good work waiting, not a drop.
|
|
4470
|
+
const prUrl = landed.pull_request.url;
|
|
4471
|
+
const heldRow = {
|
|
4472
|
+
task: entry.taskId,
|
|
4473
|
+
engine: activeEngine,
|
|
4474
|
+
check: landed.check,
|
|
4475
|
+
lane: landed.lane,
|
|
4476
|
+
pull_request: landed.pull_request,
|
|
4477
|
+
verifier_result: landed.verifier_result,
|
|
4478
|
+
};
|
|
4479
|
+
if (result.brief_id) heldRow.brief_id = result.brief_id;
|
|
4480
|
+
if (result.restaffed) heldRow.restaffed = result.restaffed;
|
|
4481
|
+
flight.held_for_review.push(heldRow);
|
|
4482
|
+
const heldRecorded = recordLandedTaskReview(cli, [
|
|
4483
|
+
'task', 'ready', jobRef(entry.task, entry.taskId),
|
|
4484
|
+
'--proof', `Built by ${activeEngine} engine via atris engine dispatch.${restaffProof} This task sits in the protected ${landed.lane} lane, so it was not merged: pull request ${prUrl} waits for a person to land it. The declared verifier passed (exit 0). Verify output: ${verifyTail || '(command produced no output, exit 0)'}. Receipt saved at ${path.relative(root, receiptPath)}.`,
|
|
4485
|
+
'--result', 'A person can review and land this fix from its pull request, because dispatch reran the verifier before opening it.',
|
|
4486
|
+
'--checked', `${landed.check} passed before the pull request was opened`,
|
|
4487
|
+
'--tested', 'The requested behavior passed its declared verifier.',
|
|
4488
|
+
...openPullRequestFlags(landed.pull_request),
|
|
4489
|
+
'--as', readyActor,
|
|
4490
|
+
], { flight, landedRow: heldRow, worktree: entry.worktreePath, heldForReview: true, save: () => writeDispatchReceipt(flight, receiptPath, { ids, reviewOnly: proofOnly, enforceRemoteBoundary }) });
|
|
4491
|
+
if (!heldRecorded) {
|
|
4492
|
+
stampDispatchBrief(root, result.brief_id, 'partial', `opened a pull request for ${entry.taskId}, but task ready failed`);
|
|
4493
|
+
log(' the pull request is open, but saving its proof failed. repair the task record before starting more work.');
|
|
4494
|
+
const unsaved = flight.paused.find((row) => row.task === entry.taskId && row.stage === 'task_ready' && row.held_for_review === true);
|
|
4495
|
+
if (unsaved) releaseClaimOfUnsavedPullRequest({ taskDb: leaseDb, db: leaseConnection, root, row: unsaved, rowId: entry.task && entry.task.id, actor: taskActor, log });
|
|
4496
|
+
continue;
|
|
4497
|
+
}
|
|
4498
|
+
stampDispatchBrief(root, result.brief_id, 'pass', `held ${entry.taskId} for a person to land`);
|
|
4499
|
+
log(` ⏸ ${entry.taskId} waits for a person to land it: ${prUrl}`);
|
|
4500
|
+
continue;
|
|
4501
|
+
}
|
|
4213
4502
|
const landedRow = { task: entry.taskId, engine: activeEngine, check: landed.check };
|
|
4214
4503
|
landedRow.verifier_result = landed.verifier_result;
|
|
4215
4504
|
if (result.brief_id) landedRow.brief_id = result.brief_id;
|
|
4216
4505
|
if (result.restaffed) landedRow.restaffed = result.restaffed;
|
|
4217
4506
|
flight.landed.push(landedRow);
|
|
4218
4507
|
const reviewRecorded = recordLandedTaskReview(cli, [
|
|
4219
|
-
'task', 'ready', entry.taskId,
|
|
4508
|
+
'task', 'ready', jobRef(entry.task, entry.taskId),
|
|
4220
4509
|
'--proof', `Built by ${activeEngine} engine via atris engine dispatch.${restaffProof} Landed via worktree ship gate (rebase-before-ship, verify re-run). The declared verifier passed (exit 0). Verify output: ${verifyTail || '(command produced no output, exit 0)'}. Receipt saved at ${path.relative(root, receiptPath)}.`,
|
|
4221
4510
|
'--result', 'Operators can now review engine-built work faster because dispatch reran the verifier before landing.',
|
|
4222
4511
|
'--checked', `${landed.check} passed before landing`,
|
|
@@ -4248,6 +4537,7 @@ async function runDispatchFlight({
|
|
|
4248
4537
|
paused: flight.paused,
|
|
4249
4538
|
worktreeByTask: new Map(prepared.map((entry) => [String(entry.taskId), entry.worktreePath])),
|
|
4250
4539
|
actorByTask: new Map(ids.map((taskId) => [String(taskId), taskActor])),
|
|
4540
|
+
refByTask: claimedRefs,
|
|
4251
4541
|
baseRef: landTarget,
|
|
4252
4542
|
log,
|
|
4253
4543
|
keepClaimsWithWork: proofOnly,
|
|
@@ -4269,7 +4559,8 @@ async function runDispatchFlight({
|
|
|
4269
4559
|
log(` fable handoff failed: ${cause}. receipt: ${path.relative(root, receiptPath)}`);
|
|
4270
4560
|
}
|
|
4271
4561
|
const completedLabel = proofOnly ? `${flight.ready.length} proof ready` : `${flight.landed.length} landed`;
|
|
4272
|
-
|
|
4562
|
+
const heldLabel = flight.held_for_review.length ? `, ${flight.held_for_review.length} waiting for a person to land` : '';
|
|
4563
|
+
log(` dispatch over: ${completedLabel}${heldLabel}, ${flight.paused.length} paused - receipt: ${path.relative(root, flight.receipt)}`);
|
|
4273
4564
|
log('');
|
|
4274
4565
|
return flight;
|
|
4275
4566
|
} finally {
|