@ai-dossier/sched 0.51.0 → 0.53.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.
@@ -96,9 +96,8 @@ var __importStar = (this && this.__importStar) || (function () {
96
96
  };
97
97
  })();
98
98
  Object.defineProperty(exports, "__esModule", { value: true });
99
- exports.MAX_BATCH_AGENT_RESPAWNS = void 0;
99
+ exports.MAX_BATCH_AGENT_RESPAWNS = exports.tailMembersRefusal = void 0;
100
100
  exports.memberBranchFor = memberBranchFor;
101
- exports.tailMembersRefusal = tailMembersRefusal;
102
101
  exports.evictMemberAndContinue = evictMemberAndContinue;
103
102
  exports.resumeBlockedGate = resumeBlockedGate;
104
103
  exports.reconcileStaleBlockedBatches = reconcileStaleBlockedBatches;
@@ -119,6 +118,7 @@ const recovery_1 = require("./recovery");
119
118
  const run_log_1 = require("./run-log");
120
119
  const scheduler_1 = require("./scheduler");
121
120
  const state_1 = require("./state");
121
+ Object.defineProperty(exports, "tailMembersRefusal", { enumerable: true, get: function () { return state_1.tailMembersRefusal; } });
122
122
  const teardown_1 = require("./teardown");
123
123
  const types_1 = require("./types");
124
124
  function emptyResult() {
@@ -162,6 +162,12 @@ function sanitizeUntrustedText(value) {
162
162
  function journalEvent(deps, event, unitId, extra = {}) {
163
163
  deps.journal.append((0, journal_1.unitEvent)(event, unitId, extra), deps.now());
164
164
  }
165
+ /** Stop the slot's agent if it is still alive (pid-start-safe: a reused pid is never signalled). */
166
+ function killLiveAgent(deps, slot) {
167
+ if (slot.pid !== null && deps.spawnDeps.isAlive(slot.pid, slot.pid_start ?? undefined)) {
168
+ deps.spawnDeps.kill(slot.pid, slot.pid_start ?? undefined);
169
+ }
170
+ }
165
171
  /**
166
172
  * Read a dead batch dispatch's own log and classify it (#629 AC3) — tail,
167
173
  * member, fix and report all reach a `dead` agent the same way (`pid` no
@@ -1016,7 +1022,11 @@ function spawnMemberAgent(deps, dispatch, state, slot, batchId, now, result, tar
1016
1022
  // #677: the prompt carries THIS member's own worktree and the integration
1017
1023
  // branch it was cut from — `member-cycle`'s `worktree`/`integration_branch`
1018
1024
  // inputs. The shared batch worktree is no longer a member surface at all.
1019
- const prompt = (0, dispatch_1.buildMemberPrompt)(dispatch.memberPrompt, memberIssue, batchId, target.member.worktree, batch.branch, review);
1025
+ const prompt = (0, dispatch_1.buildMemberPrompt)(dispatch.memberPrompt, memberIssue, batchId, target.member.worktree, batch.branch, review) +
1026
+ // #822: a member respawned after running the wrong procedure.
1027
+ (batch.reprompted_members.some((r) => r.issue === memberIssue)
1028
+ ? (0, dispatch_1.wrongProcedureDirective)(memberIssue, batchId)
1029
+ : '');
1020
1030
  const logFile = (0, dispatch_1.batchMemberLogPath)(deps.store.runsDir, batchId, target.index, memberIssue);
1021
1031
  // #629: captured BEFORE spawning, mirroring `engine.ts`'s own
1022
1032
  // `spawnAndRecord` — the log is per-role and append-mode, so the size at
@@ -1311,36 +1321,6 @@ function spawnMemberContinuation(deps, config, dispatch, batchId, now, result) {
1311
1321
  }
1312
1322
  claimAndSpawn(deps, config, batchId, 'member', now, (nextState, slot) => spawnMember(deps, dispatch, nextState, slot, batchId, now, result, member));
1313
1323
  }
1314
- /**
1315
- * #832: why the tail must NOT be dispatched over `landed`, or null when it may.
1316
- * The deterministic half of the check the tail agent itself makes before
1317
- * integrating (the members it is told about and the members it derives from
1318
- * the integration branch's boundary commits must agree) — made here for free
1319
- * instead of by a strong-tier agent that then exits `members-mismatch`.
1320
- * `batch.ranges` is the recorded boundary attribution (recomputed at every
1321
- * landing); when it is empty nothing was attributed, so there is nothing to
1322
- * cross-check. Invariant this relies on: `ranges` names only landed members —
1323
- * every exit path that reverts a member also drops its range
1324
- * (`recovery.ts`'s partial dissolve); a path that re-derived `ranges` from
1325
- * `git log` after a revert would make this refuse a healthy batch.
1326
- */
1327
- function tailMembersRefusal(batch, landed) {
1328
- if (landed.length === 0)
1329
- return 'no-landed-members';
1330
- if (batch.ranges.length === 0)
1331
- return null;
1332
- const attributed = new Set(batch.ranges.map((r) => r.issue));
1333
- const missing = landed.filter((issue) => !attributed.has(issue));
1334
- if (missing.length > 0)
1335
- return `members-mismatch:no-boundary-commit-${missing.join(',')}`;
1336
- // The tail derives members from the boundary commits too, and refuses when
1337
- // the two lists disagree in EITHER direction (review-issue Aggregate Step 1).
1338
- const landedSet = new Set(landed);
1339
- const unlanded = [...attributed].filter((issue) => !landedSet.has(issue));
1340
- return unlanded.length > 0
1341
- ? `members-mismatch:unlanded-boundary-commit-${unlanded.join(',')}`
1342
- : null;
1343
- }
1344
1324
  /**
1345
1325
  * #832: the one pre-dispatch verdict for a tail — checked before a slot is
1346
1326
  * claimed and again under the claim's lock. A tail whose respawns are spent
@@ -1353,7 +1333,7 @@ function tailDispatchRefusal(state, batch) {
1353
1333
  return null;
1354
1334
  if (agentRespawnsSpent(batch, 'tail'))
1355
1335
  return 'respawn-cap:tail';
1356
- return tailMembersRefusal(batch, (0, state_1.validatedMembersOf)(state, batch));
1336
+ return (0, state_1.tailMembersRefusal)(batch, (0, state_1.validatedMembersOf)(state, batch));
1357
1337
  }
1358
1338
  /**
1359
1339
  * Tail/report/fix dispatches (this function, `spawnReportAgent`,
@@ -2091,7 +2071,7 @@ function resumeBlockedGate(deps, config, dispatch, batchId, now) {
2091
2071
  // `suite-unreadable`) — name the actual reason and the operator path
2092
2072
  // instead of a bare illegal-transition error that reads like a bug.
2093
2073
  if (batch.status === 'blocked') {
2094
- throw new types_1.SchedNotFoundError(`Batch ${batchId} is blocked on '${batch.blocked_reason ?? '?'}', which has no gate recheck: inspect the batch worktree/state named in its journal, then \`sched abandon --batch ${batchId}\` if it is a dead end`);
2074
+ throw new types_1.SchedNotFoundError(`Batch ${batchId} is blocked on '${batch.blocked_reason ?? '?'}', which has no gate recheck (a batch blocked over landed work — dissolve-refused / tail-blocked / members-mismatch / respawn-cap:tail — resumes via \`sched resume --batch\`, #822): inspect the batch worktree/state named in its journal, then \`sched abandon --batch ${batchId}\` if it is a dead end`);
2095
2075
  }
2096
2076
  throw new types_1.IllegalTransitionError('batch', batch.status, 'executing');
2097
2077
  }
@@ -2367,8 +2347,113 @@ function readMemberDispatch(deps, batchId, memberIssue, slot, now) {
2367
2347
  milestone,
2368
2348
  dead,
2369
2349
  blockedNow: (0, groundtruth_1.isMemberBlocked)(milestone, slot.spawned_at),
2350
+ wrongProcedure: (0, groundtruth_1.isWrongProcedureMilestone)(milestone, slot.spawned_at),
2370
2351
  };
2371
2352
  }
2353
+ /**
2354
+ * #822: whether a wrong-procedure read counts against THIS dispatch of
2355
+ * `memberIssue`. After a re-prompt, only a milestone posted strictly after
2356
+ * the re-prompt does: the killed first dispatch's milestones still sit
2357
+ * inside the respawn's 60s fence tolerance and would otherwise evict the
2358
+ * member the moment it respawns. No tolerance here — the engine and the
2359
+ * member's CLI stamp times on the same host.
2360
+ */
2361
+ function wrongProcedureCounts(batch, memberIssue, read) {
2362
+ if (!read.wrongProcedure || read.milestone === null)
2363
+ return false;
2364
+ const prior = batch.reprompted_members.find((r) => r.issue === memberIssue);
2365
+ if (prior === undefined)
2366
+ return true;
2367
+ const at = Date.parse(read.milestone.at);
2368
+ const repromptedAt = Date.parse(prior.reprompted_at);
2369
+ return Number.isNaN(at) || Number.isNaN(repromptedAt) ? false : at > repromptedAt;
2370
+ }
2371
+ /**
2372
+ * #822 (#810 proposal 3): the member's dispatch ran the wrong procedure (a
2373
+ * full-cycle-shaped trail — imboard #4174 ran full-cycle as a batch member).
2374
+ * That is a dispatch mistake, not the member's work failing, so the first
2375
+ * time it is RE-PROMPTED: a live agent is stopped (a full-cycle run left going
2376
+ * would open its own PR), the slot is released, and the member is recorded in
2377
+ * `reprompted_members` — the wedge arm (serial) or `spawnParallelMembers`
2378
+ * (parallel) respawns it in place, in the same worktree, with
2379
+ * `wrongProcedureDirective` appended. Returns `'evict'` when the member was
2380
+ * already re-prompted once: the caller evicts it `wrong-procedure`.
2381
+ */
2382
+ function repromptWrongProcedure(deps, batchId, memberIssue, milestoneAt, slot, release, now) {
2383
+ // Never respawn into (or release) a worktree the old agent is still in: a
2384
+ // dying full-cycle run could still push, post, or open a PR. Signal it and
2385
+ // decide on a later tick, once it is gone — and so the re-prompt's
2386
+ // `reprompted_at` fence postdates everything it could still post.
2387
+ if (slot.pid !== null && deps.spawnDeps.isAlive(slot.pid, slot.pid_start ?? undefined)) {
2388
+ deps.spawnDeps.kill(slot.pid, slot.pid_start ?? undefined);
2389
+ return 'waiting';
2390
+ }
2391
+ const outcome = deps.store.withLock((s) => {
2392
+ const released = release(s);
2393
+ const b = (0, state_1.findBatch)(released, batchId);
2394
+ if (!b || b.reprompted_members.some((r) => r.issue === memberIssue)) {
2395
+ return { state: released, result: 'evict' };
2396
+ }
2397
+ return {
2398
+ state: (0, state_1.patchBatch)(released, batchId, {
2399
+ reprompted_members: [
2400
+ ...b.reprompted_members,
2401
+ { issue: memberIssue, milestone_at: milestoneAt, reprompted_at: now.toISOString() },
2402
+ ],
2403
+ }, now),
2404
+ result: 'reprompted',
2405
+ };
2406
+ });
2407
+ if (outcome === 'reprompted') {
2408
+ journalEvent(deps, 'member-reprompted', unit(batchId), {
2409
+ issue: memberIssue,
2410
+ reason: 'wrong-procedure',
2411
+ milestone_at: milestoneAt,
2412
+ detail: `${WRONG_PROCEDURE_SHAPE} — respawning it once in place with a member-cycle directive`,
2413
+ });
2414
+ }
2415
+ return outcome;
2416
+ }
2417
+ /** #822: the evidence line every wrong-procedure journal/eviction names. */
2418
+ const WRONG_PROCEDURE_SHAPE = 'member posted a full-cycle-shaped milestone (no batch=/mode=slot key)';
2419
+ /** #822: the eviction a member earns for running the wrong procedure after its one re-prompt. */
2420
+ function wrongProcedureFailure(lastTool, shippedPr = null) {
2421
+ return {
2422
+ reason: shippedPr === null ? 'wrong-procedure' : 'wrong-procedure-shipped',
2423
+ detail: shippedPr === null
2424
+ ? `${WRONG_PROCEDURE_SHAPE} again after its one re-prompt`
2425
+ : `${WRONG_PROCEDURE_SHAPE} and its full-cycle run already reached ship (PR ${shippedPr === 'unknown' ? 'not named' : `#${shippedPr}`}) — evicted without a re-prompt; close or reconcile that PR by hand`,
2426
+ kind: 'evicted',
2427
+ extraKv: {
2428
+ ...(lastTool ? { last_tool: lastTool } : {}),
2429
+ ...(typeof shippedPr === 'number' ? { stray_pr: String(shippedPr) } : {}),
2430
+ },
2431
+ };
2432
+ }
2433
+ /**
2434
+ * #822: the one wrong-procedure decision shared by both member rails —
2435
+ * `'waiting'` (the old agent is still dying), `'reprompted'`, or the
2436
+ * eviction failure to apply. A run that already shipped is evicted without
2437
+ * a re-prompt.
2438
+ */
2439
+ function decideWrongProcedure(deps, batchId, memberIssue, milestone, slot, release, lastTool, now) {
2440
+ const shipped = (0, groundtruth_1.wrongProcedureShippedPr)(milestone);
2441
+ if (shipped !== null) {
2442
+ if (slot.pid !== null && deps.spawnDeps.isAlive(slot.pid, slot.pid_start ?? undefined)) {
2443
+ deps.spawnDeps.kill(slot.pid, slot.pid_start ?? undefined);
2444
+ return 'waiting';
2445
+ }
2446
+ const tool = lastTool();
2447
+ deps.store.withLock((s) => ({ state: release(s), result: undefined }));
2448
+ return wrongProcedureFailure(tool, shipped);
2449
+ }
2450
+ const verdict = repromptWrongProcedure(deps, batchId, memberIssue, milestone.at, slot, release, now);
2451
+ if (verdict === 'waiting')
2452
+ return verdict;
2453
+ // The finished dispatch's run-log entry, exactly once (never while waiting).
2454
+ const tool = lastTool();
2455
+ return verdict === 'reprompted' ? verdict : wrongProcedureFailure(tool);
2456
+ }
2372
2457
  /**
2373
2458
  * The eviction a blocked-or-dead member dispatch earns (shared by both rails).
2374
2459
  * #605: only a milestone THIS dispatch posted may name the reason. A dead
@@ -2438,6 +2523,16 @@ function reconcileMemberSlot(deps, config, dispatch, batchId, slot, now, result)
2438
2523
  completeMemberGate(deps, config, dispatch, batchId, batch, memberIssue, now, result);
2439
2524
  return;
2440
2525
  }
2526
+ // #822: a wrong-procedure trail is re-prompted once, then evicted — decided
2527
+ // before the dead/blocked rail, which would otherwise evict it as an
2528
+ // unverified exit at the first sight.
2529
+ if (wrongProcedureCounts(batch, memberIssue, read) && read.milestone !== null) {
2530
+ const decision = decideWrongProcedure(deps, batchId, memberIssue, read.milestone, slot, (s) => releaseSlot(s, batchId, now), () => recordMemberRunLog(deps, dispatch, state0, batchId, batch.executing_member, memberIssue, slot, now), now);
2531
+ if (typeof decision === 'object') {
2532
+ evictMemberAndContinue(deps, config, dispatch, batchId, batch, memberIssue, decision, now, result);
2533
+ }
2534
+ return;
2535
+ }
2441
2536
  if (read.dead &&
2442
2537
  !read.blockedNow &&
2443
2538
  handleDeadDispatchApiError(deps, batchId, (0, dispatch_1.batchMemberLogPath)(deps.store.runsDir, batchId, batch.executing_member, memberIssue), slot.log_offset_at_spawn ?? 0, now)) {
@@ -2778,8 +2873,8 @@ function reconcileTailSlot(deps, config, batchId, slot, now, result) {
2778
2873
  ? null
2779
2874
  : (0, groundtruth_1.batchPhaseBlockedReason)(milestone, ['batch-review', 'batch-ship'], slot.spawned_at);
2780
2875
  if (handedBack !== null) {
2781
- if (slot.pid !== null && !dead)
2782
- deps.spawnDeps.kill(slot.pid, slot.pid_start ?? undefined);
2876
+ if (!dead)
2877
+ killLiveAgent(deps, slot);
2783
2878
  blockOnTailHandBack(deps, config, batch, `tail-blocked:${handedBack}`, now, result);
2784
2879
  return;
2785
2880
  }
@@ -3904,9 +3999,7 @@ function reconcileParallelMemberSlot(deps, config, dispatch, batchId, run, slot,
3904
3999
  // into that tree with its pid no longer tracked anywhere (so neither a
3905
4000
  // dissolve nor `sched stop --batch` could stop it). Its work is done —
3906
4001
  // stop it first.
3907
- if (slot.pid !== null && deps.spawnDeps.isAlive(slot.pid, slot.pid_start ?? undefined)) {
3908
- deps.spawnDeps.kill(slot.pid, slot.pid_start ?? undefined);
3909
- }
4002
+ killLiveAgent(deps, slot);
3910
4003
  journalEvent(deps, 'external-advance', unit(batchId), {
3911
4004
  issue: run.issue,
3912
4005
  detail: 'member review done',
@@ -3929,6 +4022,17 @@ function reconcileParallelMemberSlot(deps, config, dispatch, batchId, run, slot,
3929
4022
  result.completed.push(memberUnit);
3930
4023
  return;
3931
4024
  }
4025
+ // #822: re-prompt once, then evict — see the serial rail.
4026
+ const parallelBatch = (0, state_1.findBatch)(state0, batchId);
4027
+ if (parallelBatch &&
4028
+ wrongProcedureCounts(parallelBatch, run.issue, read) &&
4029
+ read.milestone !== null) {
4030
+ const decision = decideWrongProcedure(deps, batchId, run.issue, read.milestone, slot, release, () => recordMemberRunLog(deps, dispatch, state0, batchId, run.index, run.issue, slot, now), now);
4031
+ if (typeof decision === 'object') {
4032
+ evictParallelMember(deps, config, batchId, run, decision, now, result);
4033
+ }
4034
+ return;
4035
+ }
3932
4036
  if (read.dead &&
3933
4037
  !read.blockedNow &&
3934
4038
  handleDeadDispatchApiError(deps, batchId, (0, dispatch_1.batchMemberLogPath)(deps.store.runsDir, batchId, run.index, run.issue), slot.log_offset_at_spawn ?? 0, now, { releaseWith: release })) {
@@ -4239,9 +4343,7 @@ function stopMemberAgent(deps, batchId, issue, now) {
4239
4343
  const slot = (0, state_1.slotForBatchMember)(deps.store.load(), batchId, issue);
4240
4344
  if (slot === undefined)
4241
4345
  return;
4242
- if (slot.pid !== null && deps.spawnDeps.isAlive(slot.pid, slot.pid_start ?? undefined)) {
4243
- deps.spawnDeps.kill(slot.pid, slot.pid_start ?? undefined);
4244
- }
4346
+ killLiveAgent(deps, slot);
4245
4347
  deps.store.withLock((s) => ({
4246
4348
  state: (0, state_1.releaseBatchMemberSlot)(s, batchId, issue, now),
4247
4349
  result: undefined,