@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.
Files changed (48) hide show
  1. package/README.md +102 -17
  2. package/dist/anchor-close.d.ts.map +1 -1
  3. package/dist/anchor-close.js +2 -1
  4. package/dist/anchor-close.js.map +1 -1
  5. package/dist/batch-dispatch.d.ts +23 -2
  6. package/dist/batch-dispatch.d.ts.map +1 -1
  7. package/dist/batch-dispatch.js +1057 -209
  8. package/dist/batch-dispatch.js.map +1 -1
  9. package/dist/dispatch.d.ts +17 -0
  10. package/dist/dispatch.d.ts.map +1 -1
  11. package/dist/dispatch.js +31 -0
  12. package/dist/dispatch.js.map +1 -1
  13. package/dist/engine.d.ts +3 -2
  14. package/dist/engine.d.ts.map +1 -1
  15. package/dist/engine.js +13 -4
  16. package/dist/engine.js.map +1 -1
  17. package/dist/index.d.ts +6 -6
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +17 -4
  20. package/dist/index.js.map +1 -1
  21. package/dist/journal.d.ts +4 -1
  22. package/dist/journal.d.ts.map +1 -1
  23. package/dist/journal.js +5 -2
  24. package/dist/journal.js.map +1 -1
  25. package/dist/persist.d.ts.map +1 -1
  26. package/dist/persist.js +10 -1
  27. package/dist/persist.js.map +1 -1
  28. package/dist/recovery.d.ts +39 -3
  29. package/dist/recovery.d.ts.map +1 -1
  30. package/dist/recovery.js +206 -46
  31. package/dist/recovery.js.map +1 -1
  32. package/dist/scheduler.d.ts +15 -1
  33. package/dist/scheduler.d.ts.map +1 -1
  34. package/dist/scheduler.js +31 -5
  35. package/dist/scheduler.js.map +1 -1
  36. package/dist/state.d.ts +94 -8
  37. package/dist/state.d.ts.map +1 -1
  38. package/dist/state.js +332 -26
  39. package/dist/state.js.map +1 -1
  40. package/dist/status.d.ts +29 -1
  41. package/dist/status.d.ts.map +1 -1
  42. package/dist/status.js +63 -3
  43. package/dist/status.js.map +1 -1
  44. package/dist/types.d.ts +133 -8
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/types.js +27 -2
  47. package/dist/types.js.map +1 -1
  48. 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 unshipped
67
- // member whatever state it reached, and an unmodelled edge there would throw
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
- // requeues members at ANY batch stage, not just mid-member (F.8).
74
- batched: ['waiting', 'evicted'],
75
- waiting: ['in-work', 'evicted'],
76
- 'in-work': ['committed', 'evicted'],
77
- committed: ['validated', 'evicted'],
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 for `abandonBatch` (operator dissolve), eviction and
1205
- * automatic dissolve (#472), so the "which edge applies from here?" question is
1206
- * answered once:
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, `evicted → requeued`
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 !== 'evicted') {
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
- let next = state;
1299
- let slot = slotForBatch(next, batchId);
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