@ai-dossier/sched 0.46.1 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -17
- package/dist/anchor-close.d.ts.map +1 -1
- package/dist/anchor-close.js +2 -1
- package/dist/anchor-close.js.map +1 -1
- package/dist/batch-dispatch.d.ts +23 -2
- package/dist/batch-dispatch.d.ts.map +1 -1
- package/dist/batch-dispatch.js +1057 -209
- package/dist/batch-dispatch.js.map +1 -1
- package/dist/dispatch.d.ts +17 -0
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +31 -0
- package/dist/dispatch.js.map +1 -1
- package/dist/engine.d.ts +3 -2
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +13 -4
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -4
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts +4 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +5 -2
- package/dist/journal.js.map +1 -1
- package/dist/persist.d.ts.map +1 -1
- package/dist/persist.js +10 -1
- package/dist/persist.js.map +1 -1
- package/dist/recovery.d.ts +39 -3
- package/dist/recovery.d.ts.map +1 -1
- package/dist/recovery.js +206 -46
- package/dist/recovery.js.map +1 -1
- package/dist/scheduler.d.ts +15 -1
- package/dist/scheduler.d.ts.map +1 -1
- package/dist/scheduler.js +31 -5
- package/dist/scheduler.js.map +1 -1
- package/dist/state.d.ts +94 -8
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +332 -26
- package/dist/state.js.map +1 -1
- package/dist/status.d.ts +29 -1
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +63 -3
- package/dist/status.js.map +1 -1
- package/dist/types.d.ts +133 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +27 -2
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/dist/state.js
CHANGED
|
@@ -9,8 +9,41 @@
|
|
|
9
9
|
* decision-pending | failed(escalation-cap)" — encoded generically for every
|
|
10
10
|
* non-terminal issue status rather than repeated per row.
|
|
11
11
|
*/
|
|
12
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
15
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
16
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
17
|
+
}
|
|
18
|
+
Object.defineProperty(o, k2, desc);
|
|
19
|
+
}) : (function(o, m, k, k2) {
|
|
20
|
+
if (k2 === undefined) k2 = k;
|
|
21
|
+
o[k2] = m[k];
|
|
22
|
+
}));
|
|
23
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
24
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
25
|
+
}) : function(o, v) {
|
|
26
|
+
o["default"] = v;
|
|
27
|
+
});
|
|
28
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
29
|
+
var ownKeys = function(o) {
|
|
30
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
31
|
+
var ar = [];
|
|
32
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
33
|
+
return ar;
|
|
34
|
+
};
|
|
35
|
+
return ownKeys(o);
|
|
36
|
+
};
|
|
37
|
+
return function (mod) {
|
|
38
|
+
if (mod && mod.__esModule) return mod;
|
|
39
|
+
var result = {};
|
|
40
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
41
|
+
__setModuleDefault(result, mod);
|
|
42
|
+
return result;
|
|
43
|
+
};
|
|
44
|
+
})();
|
|
12
45
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
-
exports.TRANSITIONS = exports.CLEARED_SLOT_FIELDS = exports.CLEARED_PR_WATCH_FIELDS = exports.CLEARED_ANCHOR_CLOSE_FAILED_FIELDS = exports.CLEARED_ENTRY_DEDUP_MARKERS = exports.ISSUE_UNIVERSAL_FAILURE_EDGES = void 0;
|
|
46
|
+
exports.TRANSITIONS = exports.PARKED_MEMBER_STATUSES = exports.CLEARED_SLOT_FIELDS = exports.CLEARED_PR_WATCH_FIELDS = exports.CLEARED_ANCHOR_CLOSE_FAILED_FIELDS = exports.CLEARED_ENTRY_DEDUP_MARKERS = exports.ISSUE_UNIVERSAL_FAILURE_EDGES = void 0;
|
|
14
47
|
exports.allowedBatchTransitions = allowedBatchTransitions;
|
|
15
48
|
exports.createEmptyState = createEmptyState;
|
|
16
49
|
exports.createBatch = createBatch;
|
|
@@ -27,11 +60,23 @@ exports.appendEvictions = appendEvictions;
|
|
|
27
60
|
exports.distinctEvictions = distinctEvictions;
|
|
28
61
|
exports.duplicateEvictionDetail = duplicateEvictionDetail;
|
|
29
62
|
exports.requeueMember = requeueMember;
|
|
63
|
+
exports.memberExitEvidence = memberExitEvidence;
|
|
64
|
+
exports.recordedMemberBranch = recordedMemberBranch;
|
|
65
|
+
exports.validatedMembersOf = validatedMembersOf;
|
|
66
|
+
exports.memberDispatchProfile = memberDispatchProfile;
|
|
67
|
+
exports.parkMember = parkMember;
|
|
30
68
|
exports.findEntry = findEntry;
|
|
31
69
|
exports.findBatch = findBatch;
|
|
32
70
|
exports.batchUnit = batchUnit;
|
|
33
71
|
exports.slotForBatch = slotForBatch;
|
|
72
|
+
exports.batchMemberUnit = batchMemberUnit;
|
|
73
|
+
exports.slotForBatchMember = slotForBatchMember;
|
|
74
|
+
exports.slotsForBatch = slotsForBatch;
|
|
75
|
+
exports.releaseBatchMemberSlot = releaseBatchMemberSlot;
|
|
76
|
+
exports.releaseAllBatchSlots = releaseAllBatchSlots;
|
|
34
77
|
exports.releaseBatchSlot = releaseBatchSlot;
|
|
78
|
+
const path = __importStar(require("node:path"));
|
|
79
|
+
const attribution_1 = require("./attribution");
|
|
35
80
|
const dispatch_1 = require("./dispatch");
|
|
36
81
|
const journal_1 = require("./journal");
|
|
37
82
|
const types_1 = require("./types");
|
|
@@ -63,21 +108,29 @@ const ISSUE_BASE_TRANSITIONS = {
|
|
|
63
108
|
// #468: dispatched → parked is the detached-ship exit — the agent parked
|
|
64
109
|
// its PR on auto-merge and exited; parked → shipped accepts the MERGE
|
|
65
110
|
// (never the park — RFC-0001 §E.4 "scheduler gates on merge").
|
|
66
|
-
// Both also carry `evicted`: a batch dissolve (#472) requeues every
|
|
67
|
-
// member whatever state it reached, and an unmodelled edge there
|
|
68
|
-
// mid-eviction, after the reverts already landed.
|
|
111
|
+
// Both also carry `evicted`: a batch dissolve (#472) parks or requeues every
|
|
112
|
+
// unshipped member whatever state it reached, and an unmodelled edge there
|
|
113
|
+
// would throw mid-eviction, after the reverts already landed.
|
|
69
114
|
dispatched: ['shipped', 'parked', 'evicted'],
|
|
70
115
|
parked: ['shipped', 'evicted'],
|
|
71
116
|
shipped: ['done'],
|
|
72
117
|
// batched/waiting/validated also carry `evicted`: RFC-0001 §D.2 dissolving
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
118
|
+
// removes members at ANY batch stage, not just mid-member (F.8).
|
|
119
|
+
// #810: `handed-back` is the member's own explicit hand-back (a `blocked` /
|
|
120
|
+
// `review partial` milestone it posted) — a pre-landing exit, so it exists
|
|
121
|
+
// on the rows a member can still be working in, never from `validated`.
|
|
122
|
+
batched: ['waiting', 'evicted', 'handed-back'],
|
|
123
|
+
waiting: ['in-work', 'evicted', 'handed-back'],
|
|
124
|
+
'in-work': ['committed', 'evicted', 'handed-back'],
|
|
125
|
+
committed: ['validated', 'evicted', 'handed-back'],
|
|
78
126
|
validated: ['shipped-in-batch', 'evicted'],
|
|
79
127
|
'shipped-in-batch': ['done'],
|
|
128
|
+
// #810: `evicted` and `handed-back` are PARKED — terminal for their batch,
|
|
129
|
+
// never auto-dispatched. The only way on is an operator's decision:
|
|
130
|
+
// `sched requeue` (→ `requeued`, full-cycle from the member branch with
|
|
131
|
+
// the batch's dispatch profile) or `sched abandon`/`stop` (universal edges).
|
|
80
132
|
evicted: ['requeued'],
|
|
133
|
+
'handed-back': ['requeued'],
|
|
81
134
|
// `batched` is the half-batch rail: a member requeued into a fresh batch by a
|
|
82
135
|
// halved dissolve (#472 AC4) re-enters §D.1's slot line, not the full-cycle
|
|
83
136
|
// one. Without it those members are stranded — `requeued` + `mode: 'slot'` is
|
|
@@ -273,12 +326,16 @@ function createBatch(id, members, now, opts = {}) {
|
|
|
273
326
|
pr_watch_failed_ticks: 0,
|
|
274
327
|
anchor_closed_at: null,
|
|
275
328
|
...exports.CLEARED_ANCHOR_CLOSE_FAILED_FIELDS,
|
|
329
|
+
member_dispatch: null,
|
|
330
|
+
member_runs: [],
|
|
276
331
|
created_at: timestamp,
|
|
277
332
|
updated_at: timestamp,
|
|
278
333
|
};
|
|
279
334
|
}
|
|
280
335
|
const ISSUE_STATUSES = new Set(Object.keys(ISSUE_BASE_TRANSITIONS));
|
|
281
336
|
const BATCH_STATUSES = new Set(Object.keys(BATCH_TRANSITIONS));
|
|
337
|
+
/** #809: the closed vocabulary `validateState` accepts for `MemberRun.status`. */
|
|
338
|
+
const MEMBER_RUN_STATUS_SET = new Set(types_1.MEMBER_RUN_STATUSES);
|
|
282
339
|
const SLOT_STATUSES = new Set(Object.keys(SLOT_BASE_TRANSITIONS));
|
|
283
340
|
const CYCLE_MODES = new Set(['full', 'slot']);
|
|
284
341
|
const MODEL_TIERS = new Set(['mechanical', 'mid', 'strong']);
|
|
@@ -387,6 +444,12 @@ function validateQueueEntry(data, where) {
|
|
|
387
444
|
ev.reverted_commits.some((c) => typeof c !== 'string')) {
|
|
388
445
|
throw new Error(`${label}: failure_evidence.reverted_commits must be an array of strings`);
|
|
389
446
|
}
|
|
447
|
+
// #810: `reason` and `branch` reach an agent's prompt and git argv on a
|
|
448
|
+
// requeue — a malformed value is refused at load, never interpolated.
|
|
449
|
+
if (typeof ev.reason !== 'string') {
|
|
450
|
+
throw new Error(`${label}: failure_evidence.reason must be a string`);
|
|
451
|
+
}
|
|
452
|
+
validateMemberBranch(ev.branch, `${label}: failure_evidence.branch`);
|
|
390
453
|
}
|
|
391
454
|
validateDedupMarker(entry, label, 'ground_truth_unreachable_since', 'ground_truth_unreachable_ticks');
|
|
392
455
|
validateDedupMarker(entry, label, 'pr_watch_waiting_since', 'pr_watch_waiting_ticks');
|
|
@@ -394,6 +457,14 @@ function validateQueueEntry(data, where) {
|
|
|
394
457
|
throw new Error(`${label}: enqueued_at/updated_at must be ISO date strings`);
|
|
395
458
|
}
|
|
396
459
|
}
|
|
460
|
+
/** #810: a recorded member branch is absent, null, or a plain git ref. */
|
|
461
|
+
function validateMemberBranch(branch, label) {
|
|
462
|
+
if (branch === undefined || branch === null)
|
|
463
|
+
return;
|
|
464
|
+
if (typeof branch !== 'string' || !attribution_1.SAFE_REF_RE.test(branch)) {
|
|
465
|
+
throw new Error(`${label} must be null or a plain git ref`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
397
468
|
/**
|
|
398
469
|
* The #472 recovery fields of one batch. Absent fields are legacy (pre-1.3.0)
|
|
399
470
|
* and backfilled by the migration below; present ones must be well-shaped —
|
|
@@ -462,6 +533,15 @@ function validateBatchRecovery(batch, id) {
|
|
|
462
533
|
issue <= 0) {
|
|
463
534
|
throw new Error(`Batch ${id}: each ${key} record must carry a positive issue number`);
|
|
464
535
|
}
|
|
536
|
+
// #810: an unrecognized `kind` would silently change what the dissolve
|
|
537
|
+
// threshold counts — refuse it rather than guess.
|
|
538
|
+
if (key === 'evictions') {
|
|
539
|
+
const { kind, branch } = record;
|
|
540
|
+
if (kind !== undefined && !types_1.MEMBER_EXIT_KINDS.includes(kind)) {
|
|
541
|
+
throw new Error(`Batch ${id}: evictions[].kind must be one of ${types_1.MEMBER_EXIT_KINDS.join('|')}`);
|
|
542
|
+
}
|
|
543
|
+
validateMemberBranch(branch, `Batch ${id}: evictions[].branch`);
|
|
544
|
+
}
|
|
465
545
|
}
|
|
466
546
|
}
|
|
467
547
|
if (batch.pr !== null &&
|
|
@@ -606,6 +686,49 @@ function validateState(data) {
|
|
|
606
686
|
!dispatch_1.DISPATCH_PROFILE_RE.test(batch.dispatch_profile))) {
|
|
607
687
|
throw new Error(`Batch ${batch.id}: dispatch_profile must match ${dispatch_1.DISPATCH_PROFILE_RE} or be null, got ${String(batch.dispatch_profile)}`);
|
|
608
688
|
}
|
|
689
|
+
// #809: absent (pre-1.23.0) and null are both "not decided yet".
|
|
690
|
+
if (batch.member_dispatch !== undefined &&
|
|
691
|
+
batch.member_dispatch !== null &&
|
|
692
|
+
batch.member_dispatch !== 'serial' &&
|
|
693
|
+
batch.member_dispatch !== 'parallel') {
|
|
694
|
+
throw new Error(`Batch ${batch.id}: member_dispatch must be "serial", "parallel", or null, got ${String(batch.member_dispatch)}`);
|
|
695
|
+
}
|
|
696
|
+
if (batch.member_runs !== undefined) {
|
|
697
|
+
if (!Array.isArray(batch.member_runs)) {
|
|
698
|
+
throw new Error(`Batch ${batch.id}: member_runs must be an array`);
|
|
699
|
+
}
|
|
700
|
+
for (const run of batch.member_runs) {
|
|
701
|
+
const r = run;
|
|
702
|
+
// These values become git argv, spawn cwds and teardown targets —
|
|
703
|
+
// hold persisted state to the same shape the code that wrote it
|
|
704
|
+
// enforced (SAFE_REF_RE branch, absolute resolved path, a member of
|
|
705
|
+
// this batch), so a corrupt or hand-edited state.json cannot point a
|
|
706
|
+
// rebase/force-push/teardown somewhere else.
|
|
707
|
+
const worktree = r.worktree;
|
|
708
|
+
if (typeof run !== 'object' ||
|
|
709
|
+
run === null ||
|
|
710
|
+
!Number.isInteger(r.issue) ||
|
|
711
|
+
r.issue <= 0 ||
|
|
712
|
+
!batch.members.includes(r.issue) ||
|
|
713
|
+
!Number.isInteger(r.index) ||
|
|
714
|
+
r.index <= 0 ||
|
|
715
|
+
typeof r.branch !== 'string' ||
|
|
716
|
+
!attribution_1.SAFE_REF_RE.test(r.branch) ||
|
|
717
|
+
typeof worktree !== 'string' ||
|
|
718
|
+
!path.isAbsolute(worktree) ||
|
|
719
|
+
path.resolve(worktree) !== worktree ||
|
|
720
|
+
worktree.includes('\0') ||
|
|
721
|
+
worktree.includes('\n') ||
|
|
722
|
+
(r.pool_claimed !== undefined && typeof r.pool_claimed !== 'boolean') ||
|
|
723
|
+
(r.torn_down !== undefined && typeof r.torn_down !== 'boolean') ||
|
|
724
|
+
(r.gate_inconclusive !== undefined &&
|
|
725
|
+
r.gate_inconclusive !== null &&
|
|
726
|
+
typeof r.gate_inconclusive !== 'string') ||
|
|
727
|
+
!MEMBER_RUN_STATUS_SET.has(String(r.status))) {
|
|
728
|
+
throw new Error(`Batch ${batch.id}: malformed member_runs entry ${JSON.stringify(run)}`);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
609
732
|
if (!Number.isInteger(batch.executing_member) || batch.executing_member < 0) {
|
|
610
733
|
throw new Error(`Batch ${batch.id}: executing_member must be a non-negative integer`);
|
|
611
734
|
}
|
|
@@ -920,6 +1043,21 @@ function validateState(data) {
|
|
|
920
1043
|
anchor_close_failed_reason: batch.anchor_close_failed_reason ?? null,
|
|
921
1044
|
anchor_close_failed_since: batch.anchor_close_failed_since ?? null,
|
|
922
1045
|
anchor_close_failed_ticks: batch.anchor_close_failed_ticks ?? 0,
|
|
1046
|
+
// 1.22.0 → 1.23.0 (#809): every batch written before parallel member
|
|
1047
|
+
// dispatch ran serially. `null` (not `'serial'`) keeps a still-`ready`
|
|
1048
|
+
// batch free to choose its mode at the claim; past `ready`, a null mode
|
|
1049
|
+
// reads as serial (`isParallelBatch`), so an in-flight batch never flips.
|
|
1050
|
+
member_dispatch: batch.member_dispatch ?? null,
|
|
1051
|
+
member_runs: (batch.member_runs ?? []).map((run) => ({
|
|
1052
|
+
...run,
|
|
1053
|
+
pool_claimed: run.pool_claimed ?? false,
|
|
1054
|
+
gate_inconclusive: run.gate_inconclusive ?? null,
|
|
1055
|
+
torn_down: run.torn_down ?? false,
|
|
1056
|
+
})),
|
|
1057
|
+
// 1.23.0 → 1.24.0 (#810): no backfill — `evictions[].kind`/`branch` and
|
|
1058
|
+
// `failure_evidence.branch` are optional (absent = a pre-#810 `evicted`
|
|
1059
|
+
// record with no recorded branch), and `handed-back` is a new status no
|
|
1060
|
+
// older state can contain.
|
|
923
1061
|
}));
|
|
924
1062
|
return {
|
|
925
1063
|
...data,
|
|
@@ -1201,15 +1339,19 @@ function partitionByIssue(seen, records) {
|
|
|
1201
1339
|
* (RFC-0001 §D.1 "in-work/committed → evicted(reason) → requeued", §F.8
|
|
1202
1340
|
* "nothing green is discarded").
|
|
1203
1341
|
*
|
|
1204
|
-
* The single requeue path
|
|
1205
|
-
*
|
|
1206
|
-
*
|
|
1342
|
+
* The single requeue path — `abandonBatch` (operator dissolve), an operator's
|
|
1343
|
+
* `sched requeue` of a parked member (`requeueParkedMember`, #810), the
|
|
1344
|
+
* aggregate-suite eviction (`evictMembers`), and a dissolve's unstarted
|
|
1345
|
+
* members / half-batch split (#472) — so the "which edge applies from here?"
|
|
1346
|
+
* question is answered once. (Per-member evictions and hand-backs on the
|
|
1347
|
+
* executing rail PARK through `parkMember` instead, #810.)
|
|
1207
1348
|
*
|
|
1208
1349
|
* - already terminal or shipped → left alone, `requeued: false`
|
|
1209
1350
|
* - never reached the batch rail (`queued`/`classified`), or already on the
|
|
1210
1351
|
* requeue rail (`requeued`) → metadata retag only; there is no status edge to
|
|
1211
1352
|
* take, and re-transitioning `requeued → requeued` would throw
|
|
1212
|
-
* - `evicted` → the one remaining edge,
|
|
1353
|
+
* - parked (`evicted` / `handed-back`) → the one remaining edge, `→ requeued`
|
|
1354
|
+
* — keeping the dispatch profile stamped at park time (#810)
|
|
1213
1355
|
* - anything else active → the full failure rail, `→ evicted → requeued`
|
|
1214
1356
|
*
|
|
1215
1357
|
* `target` is where the member goes next: full-cycle (`{ mode: 'full', batch:
|
|
@@ -1238,9 +1380,22 @@ extra = {}) {
|
|
|
1238
1380
|
const reviewPatch = target.mode === 'full' && entry.review === 'full'
|
|
1239
1381
|
? { tier: (0, dispatch_1.memberDispatchTier)(entry), review: 'light' }
|
|
1240
1382
|
: {};
|
|
1383
|
+
// #810 (the #713 class, on the requeue rail): a member leaving its batch for
|
|
1384
|
+
// a full cycle keeps the batch's dispatch profile. A slot member's own
|
|
1385
|
+
// `dispatch_profile` is normally null — the BATCH carries it — so without
|
|
1386
|
+
// this every requeue silently re-ran the member on the config default
|
|
1387
|
+
// (observed: an `openai` batch's members requeued `dispatch_profile: null`).
|
|
1388
|
+
// A PARKED entry already carries the profile stamped at park time — which
|
|
1389
|
+
// may be a deliberate `null` (a `dispatch-profile-missing` dissolve parks on
|
|
1390
|
+
// the config default); re-deriving it from the batch would bring the broken
|
|
1391
|
+
// profile back.
|
|
1392
|
+
const profilePatch = target.mode === 'full' && !exports.PARKED_MEMBER_STATUSES.has(entry.status)
|
|
1393
|
+
? { dispatch_profile: memberDispatchProfile(state, entry) }
|
|
1394
|
+
: {};
|
|
1241
1395
|
const patch = {
|
|
1242
1396
|
...target,
|
|
1243
1397
|
...reviewPatch,
|
|
1398
|
+
...profilePatch,
|
|
1244
1399
|
reason,
|
|
1245
1400
|
...exports.CLEARED_ENTRY_DEDUP_MARKERS,
|
|
1246
1401
|
...extra,
|
|
@@ -1249,12 +1404,118 @@ extra = {}) {
|
|
|
1249
1404
|
return { state: patchEntry(state, issue, patch, now), requeued: true };
|
|
1250
1405
|
}
|
|
1251
1406
|
let next = state;
|
|
1252
|
-
if (entry.status
|
|
1407
|
+
if (!exports.PARKED_MEMBER_STATUSES.has(entry.status)) {
|
|
1253
1408
|
next = transitionIssue(next, issue, 'evicted', { reason }, now);
|
|
1254
1409
|
}
|
|
1255
1410
|
next = transitionIssue(next, issue, 'requeued', patch, now);
|
|
1256
1411
|
return { state: next, requeued: true };
|
|
1257
1412
|
}
|
|
1413
|
+
/**
|
|
1414
|
+
* #810: the statuses a batch member is PARKED in — out of its batch, its work
|
|
1415
|
+
* (if any) on its member branch, and never auto-dispatched until an operator
|
|
1416
|
+
* decides (`sched requeue` / `sched abandon`).
|
|
1417
|
+
*/
|
|
1418
|
+
exports.PARKED_MEMBER_STATUSES = new Set(types_1.MEMBER_EXIT_KINDS);
|
|
1419
|
+
/**
|
|
1420
|
+
* The `failure_evidence` a pre-landing member exit records (#810): no failing
|
|
1421
|
+
* tests, no attribution, nothing reverted — just why, and where its work is.
|
|
1422
|
+
*/
|
|
1423
|
+
function memberExitEvidence(batchId, reason, branch, now) {
|
|
1424
|
+
return {
|
|
1425
|
+
batch: batchId,
|
|
1426
|
+
reason,
|
|
1427
|
+
failing_tests: [],
|
|
1428
|
+
attribution: 'none',
|
|
1429
|
+
reverted_commits: [],
|
|
1430
|
+
branch,
|
|
1431
|
+
at: now.toISOString(),
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* The member branch holding `issue`'s un-landed work in `batch` (#810): its
|
|
1436
|
+
* parallel run's branch, else the serial current-member branch when `issue`
|
|
1437
|
+
* is the member in work. `null` when the batch has none recorded. (Contrast
|
|
1438
|
+
* `memberBranchFor` in batch-dispatch.ts, which BUILDS the name for a spawn.)
|
|
1439
|
+
*/
|
|
1440
|
+
function recordedMemberBranch(batch, issue) {
|
|
1441
|
+
const run = batch.member_runs.find((r) => r.issue === issue);
|
|
1442
|
+
if (run !== undefined)
|
|
1443
|
+
return run.branch;
|
|
1444
|
+
if (batch.members[batch.executing_member - 1] === issue)
|
|
1445
|
+
return batch.member_branch;
|
|
1446
|
+
return null;
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* #810: the members of `batch` whose work is validated and landed on its
|
|
1450
|
+
* integration branch — the work a dissolve must never throw away. The one
|
|
1451
|
+
* definition shared by the executing rail's dissolve suppression, the
|
|
1452
|
+
* `full` dissolve's block, and `sched status`.
|
|
1453
|
+
*/
|
|
1454
|
+
function validatedMembersOf(state, batch) {
|
|
1455
|
+
const exited = new Set(batch.evictions.map((e) => e.issue));
|
|
1456
|
+
return batch.members.filter((issue) => {
|
|
1457
|
+
if (exited.has(issue))
|
|
1458
|
+
return false;
|
|
1459
|
+
const entry = findEntry(state, issue);
|
|
1460
|
+
return (entry !== undefined &&
|
|
1461
|
+
entry.mode === 'slot' &&
|
|
1462
|
+
entry.batch === batch.id &&
|
|
1463
|
+
entry.status === 'validated');
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* The dispatch profile a member runs on (#810): its own when it has one, else
|
|
1468
|
+
* its batch's. The one answer for "which provider does this member's work
|
|
1469
|
+
* belong to?" — a requeue and a park both carry it onto the entry.
|
|
1470
|
+
*/
|
|
1471
|
+
function memberDispatchProfile(state, entry) {
|
|
1472
|
+
if (entry.dispatch_profile !== null && entry.dispatch_profile !== undefined) {
|
|
1473
|
+
return entry.dispatch_profile;
|
|
1474
|
+
}
|
|
1475
|
+
if (entry.batch === null)
|
|
1476
|
+
return null;
|
|
1477
|
+
return findBatch(state, entry.batch)?.dispatch_profile ?? null;
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* Park one batch member (#810): move it to `evicted` (an engine-decided
|
|
1481
|
+
* failure) or `handed-back` (the member's own explicit hand-back), keeping
|
|
1482
|
+
* `mode: 'slot'` + `batch` so the ledger still says which batch it left, and
|
|
1483
|
+
* stamping the evidence (reason, member branch) and the batch's dispatch
|
|
1484
|
+
* profile onto the entry. Nothing is requeued: an operator's `sched requeue`
|
|
1485
|
+
* is the only way on, and it then continues from `failure_evidence.branch`
|
|
1486
|
+
* on the recorded profile.
|
|
1487
|
+
*
|
|
1488
|
+
* Replaces the pre-#810 automatic `requeueMember(..., { mode: 'full' })` on
|
|
1489
|
+
* the eviction rail, which restarted the member from the base branch on the
|
|
1490
|
+
* config-default provider — discarding its commits and its profile.
|
|
1491
|
+
*
|
|
1492
|
+
* Already terminal / shipped / parked → left alone (`parked: false`).
|
|
1493
|
+
*/
|
|
1494
|
+
function parkMember(state, issue, kind, reason, now = new Date(), extra = {}) {
|
|
1495
|
+
const entry = state.entries.find((e) => e.issue === issue);
|
|
1496
|
+
if (!entry || isPreservedMember(entry) || exports.PARKED_MEMBER_STATUSES.has(entry.status)) {
|
|
1497
|
+
return { state, parked: false };
|
|
1498
|
+
}
|
|
1499
|
+
const patch = {
|
|
1500
|
+
reason,
|
|
1501
|
+
dispatch_profile: memberDispatchProfile(state, entry),
|
|
1502
|
+
...exports.CLEARED_ENTRY_DEDUP_MARKERS,
|
|
1503
|
+
...extra,
|
|
1504
|
+
};
|
|
1505
|
+
// A `classified` member (evicted before its first spawn, e.g. a failed
|
|
1506
|
+
// worktree prep) walks the `batched` waypoint first — the same waypoint
|
|
1507
|
+
// `advanceMemberToInWork` walks. `queued`/`requeued` have no batch-rail
|
|
1508
|
+
// edge at all and are left where they are, reported as not parked.
|
|
1509
|
+
let next = state;
|
|
1510
|
+
if (entry.status === 'classified') {
|
|
1511
|
+
next = transitionIssue(next, issue, 'batched', {}, now);
|
|
1512
|
+
}
|
|
1513
|
+
const from = findEntry(next, issue)?.status;
|
|
1514
|
+
if (from === undefined || !allowedIssueTransitions(from).includes(kind)) {
|
|
1515
|
+
return { state, parked: false };
|
|
1516
|
+
}
|
|
1517
|
+
return { state: transitionIssue(next, issue, kind, patch, now), parked: true };
|
|
1518
|
+
}
|
|
1258
1519
|
// --- Lookups ---
|
|
1259
1520
|
function findEntry(state, issue) {
|
|
1260
1521
|
return state.entries.find((e) => e.issue === issue);
|
|
@@ -1270,6 +1531,57 @@ function batchUnit(batchId) {
|
|
|
1270
1531
|
function slotForBatch(state, batchId) {
|
|
1271
1532
|
return state.slots.find((s) => s.unit === batchUnit(batchId));
|
|
1272
1533
|
}
|
|
1534
|
+
/**
|
|
1535
|
+
* The `unit` string a PARALLEL batch member's own slot carries (#809):
|
|
1536
|
+
* `batch:<id>#<issue>`. Distinct from {@link batchUnit} so the batch-level
|
|
1537
|
+
* lookups (`slotForBatch`) keep meaning "the batch's setup/tail/report/fix
|
|
1538
|
+
* agent" and never pick up a member's slot. `#` can never occur in a batch id
|
|
1539
|
+
* (enqueue's `BATCH_ID_RE`), so the two shapes cannot collide.
|
|
1540
|
+
*/
|
|
1541
|
+
function batchMemberUnit(batchId, issue) {
|
|
1542
|
+
return `${batchUnit(batchId)}#${issue}`;
|
|
1543
|
+
}
|
|
1544
|
+
/** The slot currently holding a parallel member's unit, if any (#809). */
|
|
1545
|
+
function slotForBatchMember(state, batchId, issue) {
|
|
1546
|
+
return state.slots.find((s) => s.unit === batchMemberUnit(batchId, issue));
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Every non-idle slot a batch holds (#809) — its own `batch:<id>` slot plus
|
|
1550
|
+
* any parallel member slots `batch:<id>#<issue>`. For the whole-batch exits
|
|
1551
|
+
* (stop, abandon, a terminal batch's leak repair, reconcile-error
|
|
1552
|
+
* containment), which must release all of them, not only the batch slot.
|
|
1553
|
+
*/
|
|
1554
|
+
function slotsForBatch(state, batchId) {
|
|
1555
|
+
const own = batchUnit(batchId);
|
|
1556
|
+
return state.slots.filter((s) => s.status !== 'idle' && s.unit !== null && (s.unit === own || s.unit.startsWith(`${own}#`)));
|
|
1557
|
+
}
|
|
1558
|
+
/** Walk one slot to idle (see {@link NEXT_TOWARD_IDLE}). */
|
|
1559
|
+
function walkSlotIdToIdle(state, slotId, now) {
|
|
1560
|
+
let next = state;
|
|
1561
|
+
let slot = next.slots.find((s) => s.id === slotId);
|
|
1562
|
+
// Bounded: the longest real walk (recovering → failed → idle, or
|
|
1563
|
+
// running → exited → verifying → complete → idle) is 4 hops.
|
|
1564
|
+
for (let i = 0; i < 8 && slot && slot.status !== 'idle'; i++) {
|
|
1565
|
+
const to = NEXT_TOWARD_IDLE[slot.status];
|
|
1566
|
+
if (to === null)
|
|
1567
|
+
break;
|
|
1568
|
+
next = transitionSlot(next, slot.id, to, {}, now);
|
|
1569
|
+
slot = next.slots.find((s) => s.id === slotId);
|
|
1570
|
+
}
|
|
1571
|
+
return next;
|
|
1572
|
+
}
|
|
1573
|
+
/** Release one parallel member's slot to idle (#809); a no-op when it holds none. */
|
|
1574
|
+
function releaseBatchMemberSlot(state, batchId, issue, now) {
|
|
1575
|
+
const slot = slotForBatchMember(state, batchId, issue);
|
|
1576
|
+
return slot ? walkSlotIdToIdle(state, slot.id, now) : state;
|
|
1577
|
+
}
|
|
1578
|
+
/** Release EVERY slot a batch holds — batch slot and member slots (#809). */
|
|
1579
|
+
function releaseAllBatchSlots(state, batchId, now) {
|
|
1580
|
+
let next = state;
|
|
1581
|
+
for (const slot of slotsForBatch(state, batchId))
|
|
1582
|
+
next = walkSlotIdToIdle(next, slot.id, now);
|
|
1583
|
+
return next;
|
|
1584
|
+
}
|
|
1273
1585
|
/** Slot statuses, in the order a walk toward `idle` passes through them. */
|
|
1274
1586
|
const NEXT_TOWARD_IDLE = {
|
|
1275
1587
|
idle: null,
|
|
@@ -1293,20 +1605,14 @@ const NEXT_TOWARD_IDLE = {
|
|
|
1293
1605
|
* leaked for the life of the state file, with no CLI lever to recover it. One
|
|
1294
1606
|
* operator `abandon --batch` per leaked slot, against a `max_slots` typically
|
|
1295
1607
|
* of 3, and the scheduler starves showing nothing but a dead pid.
|
|
1608
|
+
*
|
|
1609
|
+
* #809: releases the batch's OWN slot only — never a parallel member's
|
|
1610
|
+
* (`batch:<id>#<issue>`); the whole-batch exits (`abandonBatch`, `stopBatch`)
|
|
1611
|
+
* now call {@link releaseAllBatchSlots}.
|
|
1296
1612
|
*/
|
|
1297
1613
|
function releaseBatchSlot(state, batchId, now) {
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
// Bounded: the longest real walk (recovering → failed → idle, or
|
|
1301
|
-
// running → exited → verifying → complete → idle) is 4 hops.
|
|
1302
|
-
for (let i = 0; i < 8 && slot && slot.status !== 'idle'; i++) {
|
|
1303
|
-
const to = NEXT_TOWARD_IDLE[slot.status];
|
|
1304
|
-
if (to === null)
|
|
1305
|
-
break;
|
|
1306
|
-
next = transitionSlot(next, slot.id, to, {}, now);
|
|
1307
|
-
slot = slotForBatch(next, batchId);
|
|
1308
|
-
}
|
|
1309
|
-
return next;
|
|
1614
|
+
const slot = slotForBatch(state, batchId);
|
|
1615
|
+
return slot ? walkSlotIdToIdle(state, slot.id, now) : state;
|
|
1310
1616
|
}
|
|
1311
1617
|
/**
|
|
1312
1618
|
* All transition tables, exposed as public API — future consumers (#464's
|