atris 3.74.26 → 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 +116 -10
- package/commands/worktree.js +526 -19
- package/lib/auto-accept-certified.js +56 -0
- package/lib/campaign-keep.js +5 -1
- package/lib/fleet.js +357 -49
- package/lib/gm-season.js +45 -9
- 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 +113 -3
- 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,
|
|
@@ -57,6 +58,16 @@ const { recordEngineResource } = require('./engine-resource');
|
|
|
57
58
|
// calls. Mirrors the autoland denied lanes.
|
|
58
59
|
const DENIED_TAGS = ['billing', 'money', 'payments', 'deploy', 'security', 'customer', 'external', 'feedback'];
|
|
59
60
|
|
|
61
|
+
// The protected lane a task sits in (a denied tag on the task or its todo
|
|
62
|
+
// tags), or null. The ship gate and the dispatch flight both ask this, so they
|
|
63
|
+
// cannot disagree about which work a person must land.
|
|
64
|
+
function protectedLaneOfTask(task) {
|
|
65
|
+
if (!task || typeof task !== 'object') return null;
|
|
66
|
+
const todoTags = task.metadata && Array.isArray(task.metadata.todo_tags) ? task.metadata.todo_tags : [];
|
|
67
|
+
const tags = [task.tag, ...todoTags].filter(Boolean).map((tag) => String(tag).toLowerCase());
|
|
68
|
+
return tags.find((tag) => DENIED_TAGS.includes(tag)) || null;
|
|
69
|
+
}
|
|
70
|
+
|
|
60
71
|
// ---------------------------------------------------------------------------
|
|
61
72
|
// T1: dispatch primitive
|
|
62
73
|
|
|
@@ -844,9 +855,11 @@ function rosterAttemptRecord(engineName, pin, startedMs, endedMs, result, prep =
|
|
|
844
855
|
};
|
|
845
856
|
}
|
|
846
857
|
|
|
847
|
-
// After landing: the last attempt landed only when its task is in the landed
|
|
848
|
-
//
|
|
849
|
-
//
|
|
858
|
+
// After landing: the last attempt landed only when its task is in the landed or
|
|
859
|
+
// proof-ready rows. A task whose pull request waits for a person is held: not a
|
|
860
|
+
// landing until someone lands it, and not a failure either. Otherwise it failed
|
|
861
|
+
// at the stage that paused it. Every earlier attempt keeps its own outcome and
|
|
862
|
+
// names who took over.
|
|
850
863
|
function recordFlightRosterRuns(root, flight, results, { job = 'build', member = '', source = '' } = {}) {
|
|
851
864
|
for (const { entry, result } of results || []) {
|
|
852
865
|
const attempts = (result && Array.isArray(result.attempts) ? result.attempts : []).map((attempt) => ({ ...attempt }));
|
|
@@ -855,9 +868,15 @@ function recordFlightRosterRuns(root, flight, results, { job = 'build', member =
|
|
|
855
868
|
const sameTask = (row) => row && String(row.task) === taskId;
|
|
856
869
|
const last = attempts[attempts.length - 1];
|
|
857
870
|
if (last.outcome === 'landed') {
|
|
871
|
+
const held = (flight.held_for_review || []).find(sameTask);
|
|
858
872
|
const landed = (flight.landed || []).some(sameTask) || (flight.ready || []).some(sameTask);
|
|
859
873
|
const paused = (flight.paused || []).find(sameTask);
|
|
860
|
-
if (
|
|
874
|
+
if (held) {
|
|
875
|
+
last.outcome = 'held';
|
|
876
|
+
last.detail = held.review_recorded === false
|
|
877
|
+
? `pull request ${held.pull_request.url} is open but its proof could not be saved`
|
|
878
|
+
: `waiting for a person to land ${held.pull_request.url}`;
|
|
879
|
+
} else if (!landed) {
|
|
861
880
|
last.outcome = 'failed';
|
|
862
881
|
last.detail = paused
|
|
863
882
|
? `paused at ${String(paused.stage || 'landing').replace(/_/g, ' ')}${paused.detail ? `: ${String(paused.detail).split('\n')[0]}` : ''}`
|
|
@@ -1483,16 +1502,59 @@ function shipFailureDetail(shipped, { head = 400, tail = 500 } = {}) {
|
|
|
1483
1502
|
return `${cause}${headTail(combined.trim(), head, tail)}`;
|
|
1484
1503
|
}
|
|
1485
1504
|
|
|
1486
|
-
|
|
1505
|
+
// The `merge: record {...}` line worktree ship prints after a confirmed merge.
|
|
1506
|
+
function shipMergeRecord(stdout) {
|
|
1507
|
+
const line = String(stdout || '').match(/^merge: record (\{.*\})\s*$/m);
|
|
1508
|
+
if (!line) return null;
|
|
1509
|
+
try {
|
|
1510
|
+
const record = JSON.parse(line[1]);
|
|
1511
|
+
return record && typeof record === 'object' && typeof record.merge_commit === 'string' ? record : null;
|
|
1512
|
+
} catch {
|
|
1513
|
+
return null;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// task ready flags that name the pull request a flight merged by itself.
|
|
1518
|
+
function landedMergeFlags(merge) {
|
|
1519
|
+
if (!merge || !merge.merge_commit) return [];
|
|
1520
|
+
const flags = ['--merge-commit', merge.merge_commit, '--self-landed'];
|
|
1521
|
+
if (merge.number) flags.push('--pr-number', String(merge.number));
|
|
1522
|
+
if (merge.url) flags.push('--pr-url', merge.url);
|
|
1523
|
+
if (merge.merged_at) flags.push('--merged-at', merge.merged_at);
|
|
1524
|
+
return flags;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
// task ready flags that name the pull request left open for a person to land.
|
|
1528
|
+
// No merge commit, so the job record shows it as waiting, not landed.
|
|
1529
|
+
function openPullRequestFlags(pullRequest) {
|
|
1530
|
+
if (!pullRequest) return [];
|
|
1531
|
+
const flags = [];
|
|
1532
|
+
if (pullRequest.number) flags.push('--pr-number', String(pullRequest.number));
|
|
1533
|
+
if (pullRequest.url) flags.push('--pr-url', pullRequest.url);
|
|
1534
|
+
return flags;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// merge=false pushes the branch and opens the pull request without merging it.
|
|
1538
|
+
function fleetShipArgs(entry, check, targetRef = resolveDispatchLandTarget(entry.worktreePath || process.cwd()), { merge = true } = {}) {
|
|
1487
1539
|
return [
|
|
1488
1540
|
'worktree', 'ship',
|
|
1489
1541
|
'--message', `${String(entry.task.title || '').split(/[.:]/)[0].slice(0, 90)} (${entry.task.display_id}, built by ${entry.engine})`,
|
|
1490
1542
|
'--verify', check,
|
|
1491
1543
|
'--target', targetRef,
|
|
1492
|
-
'--merge',
|
|
1544
|
+
...(merge ? ['--merge'] : []),
|
|
1493
1545
|
];
|
|
1494
1546
|
}
|
|
1495
1547
|
|
|
1548
|
+
// The open pull request worktree ship printed when it was not asked to merge:
|
|
1549
|
+
// `pr: <url>` for a new one, `pr: <number> <url>` for one that was already open.
|
|
1550
|
+
function shipPullRequest(stdout) {
|
|
1551
|
+
const line = String(stdout || '').match(/^pr: (?:(\d+) )?(https?:\/\/\S+)\s*$/m);
|
|
1552
|
+
if (!line) return null;
|
|
1553
|
+
const url = line[2];
|
|
1554
|
+
const number = Number(line[1] || (url.match(/\/pull\/(\d+)/) || [])[1]);
|
|
1555
|
+
return { url, ...(Number.isInteger(number) && number > 0 ? { number } : {}) };
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1496
1558
|
// A fleet build worktree is ephemeral. If any process inside one runs
|
|
1497
1559
|
// `npm link` (which repoints the global `atris` package symlink at that
|
|
1498
1560
|
// worktree), then removing the worktree at teardown leaves the global `atris`
|
|
@@ -1623,12 +1685,17 @@ function landArrival({ worktreePath, targetRef = resolveDispatchLandTarget(workt
|
|
|
1623
1685
|
|
|
1624
1686
|
module.exports = {
|
|
1625
1687
|
DENIED_TAGS,
|
|
1688
|
+
protectedLaneOfTask,
|
|
1689
|
+
shipPullRequest,
|
|
1626
1690
|
DEAD_ENGINE_OUTPUT_PATTERNS,
|
|
1627
1691
|
releasePausedClaimsWithoutWork,
|
|
1628
1692
|
reviewOnlyEngineEnvironment,
|
|
1629
1693
|
runInReapedProcessGroup,
|
|
1630
1694
|
shipWithRetry,
|
|
1631
1695
|
shipFailureDetail,
|
|
1696
|
+
shipMergeRecord,
|
|
1697
|
+
landedMergeFlags,
|
|
1698
|
+
openPullRequestFlags,
|
|
1632
1699
|
get FLEET_CAPABLE() { return FLEET_CAPABLE; },
|
|
1633
1700
|
get DISPATCH_CAPABLE() { return DISPATCH_CAPABLE; },
|
|
1634
1701
|
get runFleetFlight() { return runFleetFlight; },
|
|
@@ -2796,7 +2863,9 @@ async function validateWithAttempts({
|
|
|
2796
2863
|
|
|
2797
2864
|
function dispatchReceiptResult(flight, { ids, reviewOnly, enforceRemoteBoundary }) {
|
|
2798
2865
|
const validatorResult = flight.validator_result || flight.result?.validator_result;
|
|
2799
|
-
|
|
2866
|
+
// Work on a pull request waiting for a person is neither landed nor failed.
|
|
2867
|
+
const held = flight.held_for_review || [];
|
|
2868
|
+
const successfulRows = reviewOnly ? flight.ready : [...flight.landed, ...held];
|
|
2800
2869
|
const successfulVerifierRows = successfulRows
|
|
2801
2870
|
.map((row) => row.verifier_result)
|
|
2802
2871
|
.filter((row) => row && typeof row === 'object');
|
|
@@ -2818,12 +2887,13 @@ function dispatchReceiptResult(flight, { ids, reviewOnly, enforceRemoteBoundary
|
|
|
2818
2887
|
&& validatorResult.engine !== validatorResult.executor_engine;
|
|
2819
2888
|
const passed = basePassed && (!oneLapReview || validatorPassed === true);
|
|
2820
2889
|
const result = {
|
|
2821
|
-
kind: reviewOnly ? 'dispatch_review_ready' : 'dispatch_landed',
|
|
2890
|
+
kind: reviewOnly ? 'dispatch_review_ready' : (held.length && !flight.landed.length ? 'dispatch_held_for_review' : 'dispatch_landed'),
|
|
2822
2891
|
passed,
|
|
2823
2892
|
verifier_result: verifierRows.length === 1
|
|
2824
2893
|
? verifierRows[0]
|
|
2825
2894
|
: { passed, checks: verifierRows },
|
|
2826
2895
|
};
|
|
2896
|
+
if (!reviewOnly && held.length) result.held_for_review = held.length;
|
|
2827
2897
|
if (reviewOnly) {
|
|
2828
2898
|
result.master_boundary_enforced = enforceRemoteBoundary;
|
|
2829
2899
|
result.master_unchanged = enforceRemoteBoundary
|
|
@@ -2890,7 +2960,10 @@ function defaultOwnCli(root) {
|
|
|
2890
2960
|
|
|
2891
2961
|
// A merge and its task handoff are separate writes. Keep the merge on disk
|
|
2892
2962
|
// before attempting the handoff, and never report success if that write fails.
|
|
2893
|
-
|
|
2963
|
+
// heldForReview is the same handoff for a pull request left open for a person:
|
|
2964
|
+
// if it fails, the paused row carries the pull request so the caller can let the
|
|
2965
|
+
// claim go with its link as the reason.
|
|
2966
|
+
function recordLandedTaskReview(cli, args, { flight, landedRow, worktree, save, heldForReview = false }) {
|
|
2894
2967
|
landedRow.review_recorded = false;
|
|
2895
2968
|
save();
|
|
2896
2969
|
let result;
|
|
@@ -2902,10 +2975,12 @@ function recordLandedTaskReview(cli, args, { flight, landedRow, worktree, save }
|
|
|
2902
2975
|
task: landedRow.task,
|
|
2903
2976
|
engine: landedRow.engine,
|
|
2904
2977
|
stage: 'task_ready',
|
|
2905
|
-
landed: true,
|
|
2978
|
+
...(heldForReview ? { held_for_review: true, pull_request: landedRow.pull_request } : { landed: true }),
|
|
2906
2979
|
worktree,
|
|
2907
2980
|
detail: headTail(String(result && (result.stderr || result.stdout) || 'task ready failed')),
|
|
2908
|
-
next_action:
|
|
2981
|
+
next_action: heldForReview
|
|
2982
|
+
? 'record fresh task proof with task ready; do not rebuild, and leave the pull request open'
|
|
2983
|
+
: 'record fresh task proof with task ready; do not rebuild or merge again',
|
|
2909
2984
|
});
|
|
2910
2985
|
save();
|
|
2911
2986
|
return false;
|
|
@@ -3210,22 +3285,40 @@ async function runFleetFlight({
|
|
|
3210
3285
|
const prepared = [];
|
|
3211
3286
|
const leaseDb = require('./task-db');
|
|
3212
3287
|
const leaseConnection = leaseDb.open();
|
|
3288
|
+
const workspace = leaseDb.workspaceRoot(root);
|
|
3213
3289
|
const leased = new Map();
|
|
3290
|
+
// What each claimed job is called to the task command, and who holds it, by display id. Only
|
|
3291
|
+
// jobs this flight claimed are in them, so a listed job it refused cannot be mistaken for one.
|
|
3292
|
+
const claimedRefs = new Map();
|
|
3293
|
+
const claimedActors = new Map();
|
|
3214
3294
|
const leaseTimer = setInterval(() => {
|
|
3215
3295
|
for (const [id, holder] of leased) leaseDb.renewLease(leaseConnection, { id, holder, pid: process.pid });
|
|
3216
3296
|
}, 5 * 60000);
|
|
3217
3297
|
leaseTimer.unref();
|
|
3218
3298
|
try {
|
|
3219
3299
|
for (const entry of staffed) {
|
|
3220
|
-
|
|
3300
|
+
// Only this workspace's own jobs. The list can name a job in another workspace, and the store
|
|
3301
|
+
// takes its row id as readily as one of ours, so the entry's row is asked before anything is claimed.
|
|
3302
|
+
if (jobOutsideWorkspace(leaseDb, entry.task, workspace)) {
|
|
3303
|
+
const detail = namesAnotherWorkspace(entry.task.display_id);
|
|
3304
|
+
flight.paused.push({ task: entry.task.display_id, engine: entry.engine, stage: 'other_workspace', detail });
|
|
3305
|
+
log(` ${entry.engine.padEnd(8)} ✗ ${detail}`);
|
|
3306
|
+
continue;
|
|
3307
|
+
}
|
|
3308
|
+
// The row the list named is the row this claims, leases, proves and lets go of: named once,
|
|
3309
|
+
// by its row id, at every step (see jobRef).
|
|
3310
|
+
const rowRef = jobRef(entry.task, entry.task.display_id);
|
|
3311
|
+
const claimed = cli(['task', 'claim', rowRef, '--as', `fleet-${entry.engine}`, '--lease-minutes', '60', '--lease-pid', String(process.pid)]);
|
|
3221
3312
|
if (!claimed || claimed.status !== 0) {
|
|
3222
3313
|
const detail = String(claimed && (claimed.stderr || claimed.stdout) || 'claim failed').trim().slice(0, 300);
|
|
3223
3314
|
flight.paused.push({ task: entry.task.display_id, engine: entry.engine, stage: 'claim', detail });
|
|
3224
3315
|
log(` ${entry.engine.padEnd(8)} ⏸ paused ${entry.task.display_id} at claim${detail ? `, ${detail}` : ''}`);
|
|
3225
3316
|
continue;
|
|
3226
3317
|
}
|
|
3227
|
-
const leaseId =
|
|
3318
|
+
const leaseId = rowRef;
|
|
3228
3319
|
const holder = `fleet-${entry.engine}`;
|
|
3320
|
+
claimedRefs.set(String(entry.task.display_id), rowRef);
|
|
3321
|
+
claimedActors.set(String(entry.task.display_id), holder);
|
|
3229
3322
|
const leaseSet = leaseDb.setLease(leaseConnection, { id: leaseId, holder });
|
|
3230
3323
|
if (!leaseSet && (!ownCli || leaseDb.getTask(leaseConnection, leaseId))) throw new Error('could not set claim lease');
|
|
3231
3324
|
if (leaseSet) leased.set(leaseId, holder);
|
|
@@ -3272,7 +3365,7 @@ async function runFleetFlight({
|
|
|
3272
3365
|
if (shipped.status !== 0 || !/done: worktree shipped/.test(shipped.stdout)) {
|
|
3273
3366
|
return { ok: false, stage: 'ship', detail: shipFailureDetail(shipped) };
|
|
3274
3367
|
}
|
|
3275
|
-
return { ok: true, stage: 'shipped' };
|
|
3368
|
+
return { ok: true, stage: 'shipped', merge: shipMergeRecord(shipped.stdout) };
|
|
3276
3369
|
});
|
|
3277
3370
|
|
|
3278
3371
|
for (const { entry, result } of results) {
|
|
@@ -3306,9 +3399,10 @@ async function runFleetFlight({
|
|
|
3306
3399
|
flight.landed.push(landedRow);
|
|
3307
3400
|
const restaffProof = result.restaffed ? ` Restaffed from ${result.restaffed.from} to ${result.restaffed.to} (${result.restaffed.reason}).` : '';
|
|
3308
3401
|
const reviewRecorded = recordLandedTaskReview(cli, [
|
|
3309
|
-
'task', 'ready',
|
|
3402
|
+
'task', 'ready', jobRef(entry.task, entry.task.display_id),
|
|
3310
3403
|
'--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, ' '))}`,
|
|
3311
3404
|
'--result', 'Operators can now review fleet-shipped work faster because the worktree was verified before landing.',
|
|
3405
|
+
...landedMergeFlags(landed.merge),
|
|
3312
3406
|
'--as', `fleet-${activeEngine}`,
|
|
3313
3407
|
], { flight, landedRow, worktree: entry.worktreePath, save: () => atomicWriteFlightReceipt(flight, receiptPath) });
|
|
3314
3408
|
if (!reviewRecorded) {
|
|
@@ -3340,7 +3434,8 @@ async function runFleetFlight({
|
|
|
3340
3434
|
releasePausedClaimsWithoutWork(cli, {
|
|
3341
3435
|
paused: flight.paused,
|
|
3342
3436
|
worktreeByTask: new Map(prepared.map((entry) => [String(entry.task.display_id), entry.worktreePath])),
|
|
3343
|
-
actorByTask:
|
|
3437
|
+
actorByTask: claimedActors,
|
|
3438
|
+
refByTask: claimedRefs,
|
|
3344
3439
|
baseRef: landTarget,
|
|
3345
3440
|
log,
|
|
3346
3441
|
});
|
|
@@ -3365,10 +3460,47 @@ async function runFleetFlight({
|
|
|
3365
3460
|
// ---------------------------------------------------------------------------
|
|
3366
3461
|
// T5, one-command dispatch: `atris engine dispatch <task-id> --engine <name>`
|
|
3367
3462
|
|
|
3463
|
+
// Why the flight let a claim go, in one plain line. It is saved on the release
|
|
3464
|
+
// so the rating can tell good work that stalled from a plain drop.
|
|
3465
|
+
function pausedStageLabel(stage) {
|
|
3466
|
+
return String(stage || 'an unknown step').replace(/_failed$/, '').replace(/_/g, ' ');
|
|
3467
|
+
}
|
|
3468
|
+
|
|
3469
|
+
function releaseReason(lead, detail) {
|
|
3470
|
+
const firstLine = String(detail || '').split(/\r?\n/).map((line) => line.trim()).find(Boolean) || '';
|
|
3471
|
+
return (firstLine ? `${lead}: ${firstLine}` : lead).slice(0, 300);
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3474
|
+
// Pauses where the engine never got to finish its turn.
|
|
3475
|
+
const ENGINE_DID_NOT_FINISH = new Set(['worktree_start', 'remote_quarantine', 'prepare', 'build']);
|
|
3476
|
+
|
|
3477
|
+
// The word a flight uses to name a job to the task command at every step: its row id when the
|
|
3478
|
+
// task command (or the task list) gave one, else the display id the flight has. The display id a
|
|
3479
|
+
// person reads is not a key. The same folder name in another place gives the same ids, and the
|
|
3480
|
+
// list that gives an id its meaning can be written again between two commands, so a command that
|
|
3481
|
+
// finds a job by it again can land on another job. It names a job only when no row id came back,
|
|
3482
|
+
// as with a stand-in task plane.
|
|
3483
|
+
function jobRef(task, displayId) {
|
|
3484
|
+
return task && task.id ? String(task.id) : String(displayId);
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3487
|
+
// A flight works only on jobs in its own workspace, and the store takes a row id from any
|
|
3488
|
+
// workspace, so the flight asks whose workspace the row is in before it claims anything. That is
|
|
3489
|
+
// the store's own rule for what a workspace holds (rowInWorkspace, the one its workspace listing
|
|
3490
|
+
// uses): the workspace, spelled the way the store spells it, and the git worktrees linked to it.
|
|
3491
|
+
// A row with no workspace is in no workspace's listing, so it is not inside this one. A job shown
|
|
3492
|
+
// with no row id has no row to ask about; it is named by its display id at every step.
|
|
3493
|
+
function jobOutsideWorkspace(taskDb, task, workspace) {
|
|
3494
|
+
return Boolean(task && task.id) && !taskDb.rowInWorkspace(task, workspace);
|
|
3495
|
+
}
|
|
3496
|
+
|
|
3497
|
+
const namesAnotherWorkspace = (displayId) => `${displayId} names a job in another workspace`;
|
|
3498
|
+
|
|
3368
3499
|
// A claim taken before the engine starts must not stay held if the flight
|
|
3369
|
-
// refuses or errors first. Release through the same task plane the claim used
|
|
3370
|
-
|
|
3371
|
-
|
|
3500
|
+
// refuses or errors first. Release through the same task plane the claim used,
|
|
3501
|
+
// naming the job by ref: the row id the claim named.
|
|
3502
|
+
function releaseUnstartedDispatchClaim(cli, { taskId, ref = taskId, actor, detail, stage }) {
|
|
3503
|
+
const released = cli(['task', 'release', ref, '--as', actor, '--reason', releaseReason(`paused at ${pausedStageLabel(stage)}`, detail)]);
|
|
3372
3504
|
const releasedOk = Boolean(released && released.status === 0);
|
|
3373
3505
|
const suffix = releasedOk
|
|
3374
3506
|
? 'claim released, safe to retry'
|
|
@@ -3388,38 +3520,122 @@ function releaseUnstartedDispatchClaim(cli, { taskId, actor, detail }) {
|
|
|
3388
3520
|
// nothing is working the task any more, and a kept claim made the retry
|
|
3389
3521
|
// fail at "claim failed". The work stays on its branch and the retry's
|
|
3390
3522
|
// worktree start resumes from it.
|
|
3391
|
-
|
|
3523
|
+
//
|
|
3524
|
+
// refByTask names each claimed job by the row id the flight claimed it with, so the release
|
|
3525
|
+
// lands on that row even if the display id a person reads now means another job. A job with
|
|
3526
|
+
// no entry there is named by the display id in its pause row.
|
|
3527
|
+
function releasePausedClaimsWithoutWork(cli, { paused, worktreeByTask, actorByTask, refByTask = new Map(), baseRef = '', log = () => {}, keepClaimsWithWork = true }) {
|
|
3392
3528
|
for (const row of paused || []) {
|
|
3393
|
-
|
|
3529
|
+
// No claim (the job was never found, was refused as another workspace's, or the claim
|
|
3530
|
+
// failed), work already landed, or work waiting on a pull request for a person (the flight
|
|
3531
|
+
// let that claim go itself when saving its proof failed).
|
|
3532
|
+
if (row.stage === 'claim' || row.stage === 'task_lookup' || row.stage === 'other_workspace' || row.landed === true || row.held_for_review === true) continue;
|
|
3394
3533
|
if (row.claim_released !== undefined) continue;
|
|
3395
3534
|
const taskId = String(row.task || '');
|
|
3396
3535
|
const actor = actorByTask.get(taskId);
|
|
3397
3536
|
if (!taskId || !actor) continue;
|
|
3398
3537
|
const worktreePath = row.worktree || worktreeByTask.get(taskId) || '';
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
const ahead = spawnSync('git', ['-C', worktreePath, 'rev-list', '--count', `${resolvedBase}..HEAD`], { encoding: 'utf8' });
|
|
3409
|
-
if (ahead.status !== 0 || parseInt(String(ahead.stdout).trim(), 10) > 0) continue;
|
|
3410
|
-
// -uall lists untracked files one by one; the default collapses a fully
|
|
3411
|
-
// untracked .atris/ into "?? .atris/", which the allowlist cannot judge.
|
|
3412
|
-
const status = spawnSync('git', ['-C', worktreePath, 'status', '--porcelain', '-uall'], { encoding: 'utf8' });
|
|
3413
|
-
if (status.status !== 0) continue;
|
|
3414
|
-
const dirt = String(status.stdout || '').split('\n').filter(Boolean).filter((line) => !isConductorStatusLine(line));
|
|
3415
|
-
if (dirt.length > 0) continue;
|
|
3416
|
-
} else if (row.stage !== 'worktree_start' && row.stage !== 'remote_quarantine' && row.stage !== 'prepare') {
|
|
3417
|
-
continue; // worktree gone and stage ambiguous: leave the claim for a human
|
|
3418
|
-
}
|
|
3419
|
-
const released = cli(['task', 'release', taskId, '--as', actor]);
|
|
3538
|
+
const noWork = pausedWorktreeHasNoWork(row, worktreePath, baseRef);
|
|
3539
|
+
if (keepClaimsWithWork && !noWork) continue;
|
|
3540
|
+
// "No work" is for an engine that finished its turn and changed nothing. A
|
|
3541
|
+
// pause before that, or a dead engine, keeps its own reason: it is not a
|
|
3542
|
+
// worker saying the job was already done.
|
|
3543
|
+
const reason = noWork && !ENGINE_DID_NOT_FINISH.has(row.stage)
|
|
3544
|
+
? 'engine produced no work'
|
|
3545
|
+
: releaseReason(`paused at ${pausedStageLabel(row.stage)}`, row.stage === 'build' ? row.reason || row.detail : row.detail || row.reason);
|
|
3546
|
+
const released = cli(['task', 'release', refByTask.get(taskId) || taskId, '--as', actor, '--reason', reason]);
|
|
3420
3547
|
row.claim_released = Boolean(released && released.status === 0);
|
|
3421
|
-
if (row.claim_released)
|
|
3548
|
+
if (!row.claim_released) continue;
|
|
3549
|
+
log(keepClaimsWithWork
|
|
3550
|
+
? ` released claim on ${taskId} (engine produced no work)`
|
|
3551
|
+
: ` released claim on ${taskId} so a retry can pick it up${worktreePath ? `; any work stays in ${path.basename(worktreePath)}` : ''}`);
|
|
3552
|
+
}
|
|
3553
|
+
}
|
|
3554
|
+
|
|
3555
|
+
// A pull request is open for a person, but the proof that says so could not be
|
|
3556
|
+
// saved. Left alone, the claim would keep its 60 minute lease and then a sweep
|
|
3557
|
+
// would expire it as a plain drop, with the pull request still open. Let it go
|
|
3558
|
+
// now, under the name that holds it, and put the link in the reason so the
|
|
3559
|
+
// record points at the work.
|
|
3560
|
+
//
|
|
3561
|
+
// The release also names its kind and the pull request as fields (never read
|
|
3562
|
+
// back out of the reason), so the rating leaves it out of both landed and
|
|
3563
|
+
// missed. Only the flight can say that: it opened the pull request itself, so it
|
|
3564
|
+
// records the release here, in its own process, through the task store it already
|
|
3565
|
+
// holds for leases. `atris task release` takes no kind, because any worker holding
|
|
3566
|
+
// a failed job could pass one to excuse its own miss. Nothing here reads the
|
|
3567
|
+
// environment, so a flight started from an agent session records it the same.
|
|
3568
|
+
//
|
|
3569
|
+
// It lets go of the exact row it claimed: rowId is the id the task command showed
|
|
3570
|
+
// before the claim, and the claim and the lease named that same row. The display id
|
|
3571
|
+
// a person reads is not a key, since the same folder name in another place gives the
|
|
3572
|
+
// same ids, so it is never looked up again. With no row id there is nothing exact to
|
|
3573
|
+
// release, and nothing is.
|
|
3574
|
+
//
|
|
3575
|
+
// Tried once: if the release fails too, the row says so.
|
|
3576
|
+
function releaseClaimOfUnsavedPullRequest({ taskDb, db, root, row, rowId, actor, log = () => {} }) {
|
|
3577
|
+
const reason = releaseReason(`PR ${row.pull_request.url} is open but its proof could not be saved`, row.detail);
|
|
3578
|
+
let released = false;
|
|
3579
|
+
let failure = 'unknown';
|
|
3580
|
+
try {
|
|
3581
|
+
if (!rowId) {
|
|
3582
|
+
failure = 'the flight has no row id for it';
|
|
3583
|
+
} else {
|
|
3584
|
+
const result = taskDb.releaseTask(db, {
|
|
3585
|
+
id: rowId,
|
|
3586
|
+
actor,
|
|
3587
|
+
reason,
|
|
3588
|
+
kind: PROOF_NOT_SAVED,
|
|
3589
|
+
pullRequest: row.pull_request,
|
|
3590
|
+
});
|
|
3591
|
+
if (!result.released) {
|
|
3592
|
+
failure = `release failed: ${result.reason}${result.claimed_by ? ` (held by ${result.claimed_by})` : ''}`;
|
|
3593
|
+
} else {
|
|
3594
|
+
released = true;
|
|
3595
|
+
// The two files people read are drawn again from the saved state, the way the
|
|
3596
|
+
// task command does after every release: the task list file, and atris/TODO.md
|
|
3597
|
+
// of the flight's own workspace. The release is already saved, so a file that
|
|
3598
|
+
// cannot be written does not undo it, and one failing does not stop the other.
|
|
3599
|
+
// The task command is loaded only here: this file is read on every start, and
|
|
3600
|
+
// only this rare path needs it.
|
|
3601
|
+
for (const [what, refresh] of [
|
|
3602
|
+
['task list file', () => require('../commands/task').writeDefaultProjection(taskDb, db, { root })],
|
|
3603
|
+
['TODO.md', () => require('../commands/task').autoRenderTodoFromDb(taskDb.workspaceRoot(root))],
|
|
3604
|
+
]) {
|
|
3605
|
+
try { refresh(); } catch (err) {
|
|
3606
|
+
if (process.env.ATRIS_DEBUG) process.stderr.write(`${what} refresh failed: ${String(err?.message ?? err).replace(/[\r\n]+/g, ' ')}\n`);
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
}
|
|
3610
|
+
}
|
|
3611
|
+
} catch (err) {
|
|
3612
|
+
failure = String(err?.message ?? err).trim() || 'unknown';
|
|
3422
3613
|
}
|
|
3614
|
+
row.claim_released = released;
|
|
3615
|
+
log(row.claim_released
|
|
3616
|
+
? ` released the claim on ${row.task} with the pull request link as the reason`
|
|
3617
|
+
: ` 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.`);
|
|
3618
|
+
return row.claim_released;
|
|
3619
|
+
}
|
|
3620
|
+
|
|
3621
|
+
// True only when the paused task's worktree is level with its base and holds
|
|
3622
|
+
// nothing but conductor litter. Any doubt counts as work, so a claim is never
|
|
3623
|
+
// let go as "no work" while real changes may exist.
|
|
3624
|
+
function pausedWorktreeHasNoWork(row, worktreePath, baseRef) {
|
|
3625
|
+
if (worktreePath && fs.existsSync(worktreePath)) {
|
|
3626
|
+
const resolvedBase = baseRef || defaultMainlineBase(worktreePath);
|
|
3627
|
+
if (!baseRef && resolvedBase === 'HEAD') return false; // no known start point
|
|
3628
|
+
const ahead = spawnSync('git', ['-C', worktreePath, 'rev-list', '--count', `${resolvedBase}..HEAD`], { encoding: 'utf8' });
|
|
3629
|
+
if (ahead.status !== 0 || parseInt(String(ahead.stdout).trim(), 10) > 0) return false;
|
|
3630
|
+
// -uall lists untracked files one by one; the default collapses a fully
|
|
3631
|
+
// untracked .atris/ into "?? .atris/", which the allowlist cannot judge.
|
|
3632
|
+
const status = spawnSync('git', ['-C', worktreePath, 'status', '--porcelain', '-uall'], { encoding: 'utf8' });
|
|
3633
|
+
if (status.status !== 0) return false;
|
|
3634
|
+
const dirt = String(status.stdout || '').split('\n').filter(Boolean).filter((line) => !isConductorStatusLine(line));
|
|
3635
|
+
return dirt.length === 0;
|
|
3636
|
+
}
|
|
3637
|
+
// Worktree gone: only the early stages are known to hold nothing.
|
|
3638
|
+
return row.stage === 'worktree_start' || row.stage === 'remote_quarantine' || row.stage === 'prepare';
|
|
3423
3639
|
}
|
|
3424
3640
|
|
|
3425
3641
|
// The manual version of this loop took 6 Bash calls per task the night this
|
|
@@ -3496,6 +3712,9 @@ async function runDispatchFlight({
|
|
|
3496
3712
|
const cli = ownCli || defaultOwnCli(root);
|
|
3497
3713
|
const leaseDb = require('./task-db');
|
|
3498
3714
|
const leaseConnection = leaseDb.open();
|
|
3715
|
+
// The workspace this flight works in, spelled the way the store spells it. It is read once, before
|
|
3716
|
+
// the flight writes anything into its folder.
|
|
3717
|
+
const workspace = leaseDb.workspaceRoot(root);
|
|
3499
3718
|
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`); }
|
|
3500
3719
|
// Cut worktrees from, and check self-landing against, the workspace's real
|
|
3501
3720
|
// protected branch, not a hard-coded origin/master (CLI-1298).
|
|
@@ -3522,6 +3741,7 @@ async function runDispatchFlight({
|
|
|
3522
3741
|
results: [],
|
|
3523
3742
|
landed: [],
|
|
3524
3743
|
ready: [],
|
|
3744
|
+
held_for_review: [],
|
|
3525
3745
|
paused: [],
|
|
3526
3746
|
};
|
|
3527
3747
|
if (yolo) flight.yolo = true;
|
|
@@ -3541,6 +3761,9 @@ async function runDispatchFlight({
|
|
|
3541
3761
|
const startBaseArgs = landTarget ? ['--base', landTarget] : [];
|
|
3542
3762
|
const prepared = [];
|
|
3543
3763
|
const leasedIds = new Set();
|
|
3764
|
+
// What each claimed job is called to the task command, by the id the caller gave. Only jobs this
|
|
3765
|
+
// flight claimed are in it.
|
|
3766
|
+
const claimedRefs = new Map();
|
|
3544
3767
|
const leaseTimer = setInterval(() => {
|
|
3545
3768
|
for (const id of leasedIds) leaseDb.renewLease(leaseConnection, { id, holder: taskActor, pid: process.pid });
|
|
3546
3769
|
}, 5 * 60000);
|
|
@@ -3553,15 +3776,29 @@ async function runDispatchFlight({
|
|
|
3553
3776
|
log(` ✗ ${taskId} not found`);
|
|
3554
3777
|
continue;
|
|
3555
3778
|
}
|
|
3556
|
-
|
|
3779
|
+
// Only this workspace's own jobs. What the task command showed can be a job in another
|
|
3780
|
+
// workspace (the list it read named it under this display id), and the store takes its row id
|
|
3781
|
+
// as readily as one of ours, so the row is asked before anything is claimed.
|
|
3782
|
+
if (jobOutsideWorkspace(leaseDb, task, workspace)) {
|
|
3783
|
+
const detail = namesAnotherWorkspace(taskId);
|
|
3784
|
+
flight.paused.push({ task: taskId, stage: 'other_workspace', detail });
|
|
3785
|
+
log(` ✗ ${detail}`);
|
|
3786
|
+
continue;
|
|
3787
|
+
}
|
|
3788
|
+
// The row the task command just showed is the row this claims, leases, proves and lets go of:
|
|
3789
|
+
// named once, by its row id, at every step (see jobRef). Log lines and the receipt keep saying
|
|
3790
|
+
// the display id.
|
|
3791
|
+
const rowRef = jobRef(task, taskId);
|
|
3792
|
+
const claimed = cli(['task', 'claim', rowRef, '--as', taskActor, '--lease-minutes', '60', '--lease-pid', String(process.pid)]);
|
|
3557
3793
|
if (!claimed || claimed.status !== 0) {
|
|
3558
3794
|
const detail = String(claimed && (claimed.stderr || claimed.stdout) || 'claim failed').trim().slice(0, 300);
|
|
3559
3795
|
flight.paused.push({ task: taskId, stage: 'claim', detail });
|
|
3560
3796
|
log(` x ${taskId} claim failed`);
|
|
3561
3797
|
continue;
|
|
3562
3798
|
}
|
|
3799
|
+
claimedRefs.set(String(taskId), rowRef);
|
|
3563
3800
|
try {
|
|
3564
|
-
const leaseId =
|
|
3801
|
+
const leaseId = rowRef;
|
|
3565
3802
|
const leased = leaseDb.setLease(leaseConnection, { id: leaseId, holder: taskActor });
|
|
3566
3803
|
if (!leased && (!ownCli || leaseDb.getTask(leaseConnection, leaseId))) throw new Error('could not set claim lease');
|
|
3567
3804
|
if (leased) leasedIds.add(leaseId);
|
|
@@ -3579,7 +3816,9 @@ async function runDispatchFlight({
|
|
|
3579
3816
|
if (!wt) {
|
|
3580
3817
|
const released = releaseUnstartedDispatchClaim(cli, {
|
|
3581
3818
|
taskId,
|
|
3819
|
+
ref: rowRef,
|
|
3582
3820
|
actor: taskActor,
|
|
3821
|
+
stage: 'worktree_start',
|
|
3583
3822
|
detail: String(started.stderr || '').slice(0, 200),
|
|
3584
3823
|
});
|
|
3585
3824
|
flight.paused.push({ task: taskId, stage: 'worktree_start', detail: released.detail, claim_released: released.released });
|
|
@@ -3591,7 +3830,9 @@ async function runDispatchFlight({
|
|
|
3591
3830
|
if (enforceRemoteBoundary && (!remoteBoundary || remoteBoundary.ok !== true)) {
|
|
3592
3831
|
const released = releaseUnstartedDispatchClaim(cli, {
|
|
3593
3832
|
taskId,
|
|
3833
|
+
ref: rowRef,
|
|
3594
3834
|
actor: taskActor,
|
|
3835
|
+
stage: 'remote_quarantine',
|
|
3595
3836
|
detail: headTail(String(remoteBoundary && remoteBoundary.detail || 'could not prepare a sealed review sandbox')),
|
|
3596
3837
|
});
|
|
3597
3838
|
flight.paused.push({
|
|
@@ -3634,7 +3875,9 @@ async function runDispatchFlight({
|
|
|
3634
3875
|
} catch (err) {
|
|
3635
3876
|
const released = releaseUnstartedDispatchClaim(cli, {
|
|
3636
3877
|
taskId,
|
|
3878
|
+
ref: rowRef,
|
|
3637
3879
|
actor: taskActor,
|
|
3880
|
+
stage: 'prepare',
|
|
3638
3881
|
detail: String(err && err.message || err).slice(0, 200),
|
|
3639
3882
|
});
|
|
3640
3883
|
flight.paused.push({ task: taskId, stage: 'prepare', detail: released.detail, claim_released: released.released });
|
|
@@ -3788,13 +4031,39 @@ async function runDispatchFlight({
|
|
|
3788
4031
|
change,
|
|
3789
4032
|
};
|
|
3790
4033
|
}
|
|
3791
|
-
|
|
4034
|
+
// A flight never merges protected-lane work. It ships the branch without
|
|
4035
|
+
// --merge, so a pull request opens and a person lands it.
|
|
4036
|
+
const lane = protectedLaneOfTask(entry.task);
|
|
4037
|
+
const shipped = shipWithRetry(cli, fleetShipArgs({ task: entry.task, engine: entry.engine || engine }, check, landTarget, { merge: !lane }), entry.worktreePath);
|
|
3792
4038
|
if (shipped.status !== 0 || !/done: worktree shipped/.test(shipped.stdout)) {
|
|
3793
4039
|
return { ok: false, stage: 'ship', detail: shipFailureDetail(shipped) };
|
|
3794
4040
|
}
|
|
4041
|
+
if (lane) {
|
|
4042
|
+
const pullRequest = shipPullRequest(shipped.stdout);
|
|
4043
|
+
if (!pullRequest) {
|
|
4044
|
+
const nothingToReview = /^pr: skipped \(no commits ahead of base\)/m.test(shipped.stdout);
|
|
4045
|
+
return {
|
|
4046
|
+
ok: false,
|
|
4047
|
+
stage: 'ship',
|
|
4048
|
+
detail: nothingToReview
|
|
4049
|
+
? 'no commits ahead of base, so there was no pull request to open'
|
|
4050
|
+
: `the branch was pushed, but no pull request link came back for the protected ${lane} lane`,
|
|
4051
|
+
};
|
|
4052
|
+
}
|
|
4053
|
+
return {
|
|
4054
|
+
ok: true,
|
|
4055
|
+
stage: 'held_for_review',
|
|
4056
|
+
lane,
|
|
4057
|
+
pull_request: pullRequest,
|
|
4058
|
+
check,
|
|
4059
|
+
verifyOutput: `${verified.stdout}${verified.stderr}`,
|
|
4060
|
+
verifier_result: verifierResult,
|
|
4061
|
+
};
|
|
4062
|
+
}
|
|
3795
4063
|
return {
|
|
3796
4064
|
ok: true,
|
|
3797
4065
|
stage: 'shipped',
|
|
4066
|
+
merge: shipMergeRecord(shipped.stdout),
|
|
3798
4067
|
check,
|
|
3799
4068
|
verifyOutput: `${verified.stdout}${verified.stderr}`,
|
|
3800
4069
|
verifier_result: verifierResult,
|
|
@@ -4150,7 +4419,7 @@ async function runDispatchFlight({
|
|
|
4150
4419
|
? ` Independent validator ${validatorResult.engine} signed off: ${validatorResult.reason}.`
|
|
4151
4420
|
: '';
|
|
4152
4421
|
const readyResult = cli([
|
|
4153
|
-
'task', 'ready', entry.taskId,
|
|
4422
|
+
'task', 'ready', jobRef(entry.task, entry.taskId),
|
|
4154
4423
|
'--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)}.`,
|
|
4155
4424
|
'--result', 'Operators can review a completed, verified change before it lands, reducing the risk of unreviewed changes reaching users.',
|
|
4156
4425
|
'--landing', 'Operators can review the verified change before it reaches users, reducing the risk of an unreviewed release.',
|
|
@@ -4184,17 +4453,54 @@ async function runDispatchFlight({
|
|
|
4184
4453
|
}
|
|
4185
4454
|
continue;
|
|
4186
4455
|
}
|
|
4456
|
+
if (landed.stage === 'held_for_review') {
|
|
4457
|
+
// Protected-lane work: the pull request is open, a person lands it.
|
|
4458
|
+
// The claim stays with the builder and the job waits in Review, so the
|
|
4459
|
+
// rating sees good work waiting, not a drop.
|
|
4460
|
+
const prUrl = landed.pull_request.url;
|
|
4461
|
+
const heldRow = {
|
|
4462
|
+
task: entry.taskId,
|
|
4463
|
+
engine: activeEngine,
|
|
4464
|
+
check: landed.check,
|
|
4465
|
+
lane: landed.lane,
|
|
4466
|
+
pull_request: landed.pull_request,
|
|
4467
|
+
verifier_result: landed.verifier_result,
|
|
4468
|
+
};
|
|
4469
|
+
if (result.brief_id) heldRow.brief_id = result.brief_id;
|
|
4470
|
+
if (result.restaffed) heldRow.restaffed = result.restaffed;
|
|
4471
|
+
flight.held_for_review.push(heldRow);
|
|
4472
|
+
const heldRecorded = recordLandedTaskReview(cli, [
|
|
4473
|
+
'task', 'ready', jobRef(entry.task, entry.taskId),
|
|
4474
|
+
'--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)}.`,
|
|
4475
|
+
'--result', 'A person can review and land this fix from its pull request, because dispatch reran the verifier before opening it.',
|
|
4476
|
+
'--checked', `${landed.check} passed before the pull request was opened`,
|
|
4477
|
+
'--tested', 'The requested behavior passed its declared verifier.',
|
|
4478
|
+
...openPullRequestFlags(landed.pull_request),
|
|
4479
|
+
'--as', readyActor,
|
|
4480
|
+
], { flight, landedRow: heldRow, worktree: entry.worktreePath, heldForReview: true, save: () => writeDispatchReceipt(flight, receiptPath, { ids, reviewOnly: proofOnly, enforceRemoteBoundary }) });
|
|
4481
|
+
if (!heldRecorded) {
|
|
4482
|
+
stampDispatchBrief(root, result.brief_id, 'partial', `opened a pull request for ${entry.taskId}, but task ready failed`);
|
|
4483
|
+
log(' the pull request is open, but saving its proof failed. repair the task record before starting more work.');
|
|
4484
|
+
const unsaved = flight.paused.find((row) => row.task === entry.taskId && row.stage === 'task_ready' && row.held_for_review === true);
|
|
4485
|
+
if (unsaved) releaseClaimOfUnsavedPullRequest({ taskDb: leaseDb, db: leaseConnection, root, row: unsaved, rowId: entry.task && entry.task.id, actor: taskActor, log });
|
|
4486
|
+
continue;
|
|
4487
|
+
}
|
|
4488
|
+
stampDispatchBrief(root, result.brief_id, 'pass', `held ${entry.taskId} for a person to land`);
|
|
4489
|
+
log(` ⏸ ${entry.taskId} waits for a person to land it: ${prUrl}`);
|
|
4490
|
+
continue;
|
|
4491
|
+
}
|
|
4187
4492
|
const landedRow = { task: entry.taskId, engine: activeEngine, check: landed.check };
|
|
4188
4493
|
landedRow.verifier_result = landed.verifier_result;
|
|
4189
4494
|
if (result.brief_id) landedRow.brief_id = result.brief_id;
|
|
4190
4495
|
if (result.restaffed) landedRow.restaffed = result.restaffed;
|
|
4191
4496
|
flight.landed.push(landedRow);
|
|
4192
4497
|
const reviewRecorded = recordLandedTaskReview(cli, [
|
|
4193
|
-
'task', 'ready', entry.taskId,
|
|
4498
|
+
'task', 'ready', jobRef(entry.task, entry.taskId),
|
|
4194
4499
|
'--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)}.`,
|
|
4195
4500
|
'--result', 'Operators can now review engine-built work faster because dispatch reran the verifier before landing.',
|
|
4196
4501
|
'--checked', `${landed.check} passed before landing`,
|
|
4197
4502
|
'--tested', 'The requested behavior passed its declared verifier.',
|
|
4503
|
+
...landedMergeFlags(landed.merge),
|
|
4198
4504
|
'--as', readyActor,
|
|
4199
4505
|
], { flight, landedRow, worktree: entry.worktreePath, save: () => writeDispatchReceipt(flight, receiptPath, { ids, reviewOnly: proofOnly, enforceRemoteBoundary }) });
|
|
4200
4506
|
if (!reviewRecorded) {
|
|
@@ -4221,6 +4527,7 @@ async function runDispatchFlight({
|
|
|
4221
4527
|
paused: flight.paused,
|
|
4222
4528
|
worktreeByTask: new Map(prepared.map((entry) => [String(entry.taskId), entry.worktreePath])),
|
|
4223
4529
|
actorByTask: new Map(ids.map((taskId) => [String(taskId), taskActor])),
|
|
4530
|
+
refByTask: claimedRefs,
|
|
4224
4531
|
baseRef: landTarget,
|
|
4225
4532
|
log,
|
|
4226
4533
|
keepClaimsWithWork: proofOnly,
|
|
@@ -4242,7 +4549,8 @@ async function runDispatchFlight({
|
|
|
4242
4549
|
log(` fable handoff failed: ${cause}. receipt: ${path.relative(root, receiptPath)}`);
|
|
4243
4550
|
}
|
|
4244
4551
|
const completedLabel = proofOnly ? `${flight.ready.length} proof ready` : `${flight.landed.length} landed`;
|
|
4245
|
-
|
|
4552
|
+
const heldLabel = flight.held_for_review.length ? `, ${flight.held_for_review.length} waiting for a person to land` : '';
|
|
4553
|
+
log(` dispatch over: ${completedLabel}${heldLabel}, ${flight.paused.length} paused - receipt: ${path.relative(root, flight.receipt)}`);
|
|
4246
4554
|
log('');
|
|
4247
4555
|
return flight;
|
|
4248
4556
|
} finally {
|