@ai-dossier/sched 0.39.0 → 0.41.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 CHANGED
@@ -29,8 +29,10 @@ ai-dossier sched enqueue --from-manifest batch-prep.json # batc
29
29
  ai-dossier sched start # the dispatch engine: spawn, verify, escalate, watch parked PRs (Ctrl-C stops it)
30
30
  ai-dossier sched start --once # a single reconcile+refill tick (cron-style)
31
31
  ai-dossier sched status # queue (+pr/cleanup), parked PRs, slots, batches, blocked/failed
32
- ai-dossier sched pause # stop NEW assignments; live units keep running
32
+ ai-dossier sched pause # prevent every new agent process; live units keep running
33
33
  ai-dossier sched resume
34
+ ai-dossier sched stop --issue 42 # terminate one full-cycle agent and record it stopped (no recovery)
35
+ ai-dossier sched stop --batch b1 # terminate the batch process and stop unfinished members
34
36
  ai-dossier sched abandon --issue 42 --reason "operator abort"
35
37
  ai-dossier sched abandon --batch b1 # dissolve; members requeue as full-cycle
36
38
  ai-dossier sched stats --issues 4..9 # per-issue tokens/cost from ~/.dossier/runs.jsonl (#524)
@@ -47,6 +49,14 @@ every other subcommand and reads that project's `~/.dossier/sched/<project>/runs
47
49
  directory directly, reconstructing costs from the raw dispatch logs rather than
48
50
  `runs.jsonl` (#564) — see "Batch members (#564)" below.
49
51
 
52
+ `pause` prevents every new agent process, including escalation and same-tier recovery
53
+ takeovers; it does not terminate agents that are already running. `stop --issue <n>` is the
54
+ single-command stop path: it PID-start-safely terminates that issue's live agent, releases its
55
+ slot, and records a terminal `stopped` outcome that will not recover or escalate. An active
56
+ slot-mode member must instead be stopped through `stop --batch <id>`, which terminates the batch
57
+ process and atomically stops the batch and its unfinished members. `abandon` only records failure
58
+ and releases the slot; it intentionally does not terminate its process.
59
+
50
60
  Since #507, `enqueue` additionally reads each candidate issue's live GitHub labels (one
51
61
  `gh issue view --json labels` call per issue, resolved against the current directory's repo
52
62
  unless `--repo <owner/name>` is passed) and lands an issue carrying `decision-pending` /
@@ -125,6 +125,8 @@ export interface BatchTickResult {
125
125
  parked: string[];
126
126
  mergeAccepted: string[];
127
127
  failed: string[];
128
+ /** Per-batch exceptions contained so unrelated units can continue this tick (#635). */
129
+ reconciliation_errors: string[];
128
130
  /** Issue numbers requeued full-cycle by a dissolve — matches `TickResult.blocked`'s shape. */
129
131
  blocked: number[];
130
132
  }
@@ -1 +1 @@
1
- {"version":3,"file":"batch-dispatch.d.ts","sourceRoot":"","sources":["../src/batch-dispatch.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"batch-dispatch.d.ts","sourceRoot":"","sources":["../src/batch-dispatch.ts"],"names":[],"mappings":"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"}
@@ -116,7 +116,15 @@ const state_1 = require("./state");
116
116
  const teardown_1 = require("./teardown");
117
117
  const types_1 = require("./types");
118
118
  function emptyResult() {
119
- return { spawned: [], completed: [], parked: [], mergeAccepted: [], failed: [], blocked: [] };
119
+ return {
120
+ spawned: [],
121
+ completed: [],
122
+ parked: [],
123
+ mergeAccepted: [],
124
+ failed: [],
125
+ reconciliation_errors: [],
126
+ blocked: [],
127
+ };
120
128
  }
121
129
  function unit(batchId) {
122
130
  return `batch:${batchId}`;
@@ -2902,6 +2910,22 @@ function runBatchTick(deps, config, dispatch) {
2902
2910
  }
2903
2911
  }
2904
2912
  };
2913
+ const containFailure = (batchId, err) => {
2914
+ const detail = `${err.name}: ${err.message}`;
2915
+ // A corrupt unit must not turn into a project-wide scheduler outage.
2916
+ // Keep the failed batch inspectable, release its slot, and continue.
2917
+ deps.store.withLock((state) => {
2918
+ const fresh = (0, state_1.findBatch)(state, batchId);
2919
+ if (!fresh || types_1.TERMINAL_BATCH_STATUSES.has(fresh.status) || fresh.status === 'blocked') {
2920
+ return { state, result: undefined };
2921
+ }
2922
+ const blocked = (0, state_1.transitionBatch)(state, batchId, 'blocked', { blocked_reason: 'reconcile-error' }, now);
2923
+ return { state: (0, state_1.releaseBatchSlot)(blocked, batchId, now), result: undefined };
2924
+ });
2925
+ journalEvent(deps, 'unit-failed', unit(batchId), { reason: 'reconcile-error', detail });
2926
+ result.failed.push(unit(batchId));
2927
+ result.reconciliation_errors.push(`${unit(batchId)}: ${detail}`);
2928
+ };
2905
2929
  // #565: when more ready batches exist than free capacity, claim them in
2906
2930
  // the same priority order `runnableUnits` would (desc priority → asc
2907
2931
  // readiness age → anchor) — this loop never goes through
@@ -2933,11 +2957,16 @@ function runBatchTick(deps, config, dispatch) {
2933
2957
  const state = deps.store.load();
2934
2958
  const batch = (0, state_1.findBatch)(state, batchId);
2935
2959
  if (batch && batch.status === 'ready' && slotFor(state, batchId) === undefined) {
2936
- // #707: batch-setup dispatches through the batch's own profile too — it
2937
- // is the batch's first agent, not an engine-internal step.
2938
- const batchDispatch = dispatchFor(batch);
2939
- if (batchDispatch !== null) {
2940
- claimAndSetup(deps, config, batchDispatch, batchId, now, result);
2960
+ try {
2961
+ // #707: batch-setup dispatches through the batch's own profile too it
2962
+ // is the batch's first agent, not an engine-internal step.
2963
+ const batchDispatch = dispatchFor(batch);
2964
+ if (batchDispatch !== null) {
2965
+ claimAndSetup(deps, config, batchDispatch, batchId, now, result);
2966
+ }
2967
+ }
2968
+ catch (err) {
2969
+ containFailure(batchId, err);
2941
2970
  }
2942
2971
  }
2943
2972
  }
@@ -2966,78 +2995,83 @@ function runBatchTick(deps, config, dispatch) {
2966
2995
  }
2967
2996
  continue;
2968
2997
  }
2969
- // #707: resolve this batch's profile once per pass — or dissolve/fall
2970
- // back loudly when it no longer resolves (see `dispatchFor` above).
2971
- // Terminal batches are skipped before this call so a removed profile does
2972
- // not generate a missing-profile event on every later tick.
2973
- const batchDispatch = dispatchFor(batch);
2974
- if (batchDispatch === null)
2975
- continue;
2976
- if (slot && (slot.status === 'running' || slot.status === 'assigned')) {
2977
- if (batch.status === 'executing') {
2978
- reconcileMemberSlot(deps, config, batchDispatch, batch.id, slot, now, result);
2998
+ try {
2999
+ // #707: resolve this batch's profile once per pass or dissolve/fall
3000
+ // back loudly when it no longer resolves (see `dispatchFor` above).
3001
+ // Terminal batches are skipped before this call so a removed profile does
3002
+ // not generate a missing-profile event on every later tick.
3003
+ const batchDispatch = dispatchFor(batch);
3004
+ if (batchDispatch === null)
3005
+ continue;
3006
+ if (slot && (slot.status === 'running' || slot.status === 'assigned')) {
3007
+ if (batch.status === 'executing') {
3008
+ reconcileMemberSlot(deps, config, batchDispatch, batch.id, slot, now, result);
3009
+ }
3010
+ else if (batch.status === 'fixing') {
3011
+ reconcileFixSlot(deps, config, batch.id, slot, now, result);
3012
+ }
3013
+ else if (batch.status === 'reviewing' || batch.status === 'shipping') {
3014
+ reconcileTailSlot(deps, batch.id, slot, now, result);
3015
+ }
3016
+ else if (batch.status === 'deployed') {
3017
+ reconcileReportSlot(deps, batch.id, slot, now, result);
3018
+ }
3019
+ continue;
2979
3020
  }
2980
- else if (batch.status === 'fixing') {
2981
- reconcileFixSlot(deps, config, batch.id, slot, now, result);
3021
+ if (slot)
3022
+ continue; // live but neither running/assigned (e.g. mid-verify) next tick
3023
+ if (batch.status === 'validating') {
3024
+ // A local suite run, not a provider dispatch — unaffected by `paused`.
3025
+ runValidate(deps, config, batchDispatch, batch.id, now, result);
3026
+ continue;
3027
+ }
3028
+ // #629: every branch below spawns a provider agent — hold the wedge
3029
+ // until `sched resume` (see the top of this function for why `paused`
3030
+ // is read once, up front, rather than per iteration).
3031
+ if (paused)
3032
+ continue;
3033
+ if (batch.status === 'deployed') {
3034
+ spawnReportAgent(deps, config, batchDispatch, batch.id, now, result);
3035
+ }
3036
+ else if (batch.status === 'executing') {
3037
+ // A prior spawn threw, or `claimAndSpawn` found zero free capacity —
3038
+ // either way the batch is stuck mid-member with no slot and nothing
3039
+ // else will ever retry it (Conformance review AC5 caveat; Supportability
3040
+ // review #12). Retrying every tick is cheap-by-contract (#677 review):
3041
+ // `spawnMemberContinuation` runs the free-capacity and member-identity
3042
+ // pre-checks BEFORE any pool/git work, so a full scheduler (or stale
3043
+ // member context) exits here without exec'ing, and `claimAndSpawn`
3044
+ // remains the authoritative capacity gate.
3045
+ spawnMemberContinuation(deps, config, batchDispatch, batch.id, now, result);
2982
3046
  }
2983
3047
  else if (batch.status === 'reviewing' || batch.status === 'shipping') {
2984
- reconcileTailSlot(deps, batch.id, slot, now, result);
3048
+ // Same wedge, for a dead-or-never-claimed tail agent.
3049
+ spawnTailAgent(deps, config, batchDispatch, batch.id, now, result);
2985
3050
  }
2986
- else if (batch.status === 'deployed') {
2987
- reconcileReportSlot(deps, batch.id, slot, now, result);
3051
+ else if (batch.status === 'fixing') {
3052
+ // `beginFixAttempt` already recorded this member's ONE attempt as
3053
+ // `dispatched` before `claimAndSpawn` could find capacity — retrying the
3054
+ // exact same dispatch isn't reconstructible from persisted state (only
3055
+ // the outcome is persisted, not the command/prompt), so resolve it
3056
+ // `red` (conservatively: the member loses its one attempt and evicts on
3057
+ // the next validate pass, which is safe — never a permanent wedge).
3058
+ const state = deps.store.load();
3059
+ const b = (0, state_1.findBatch)(state, batch.id);
3060
+ const offenderRecord = b
3061
+ ? [...b.fix_attempts].reverse().find((a) => a.outcome === 'dispatched')
3062
+ : undefined;
3063
+ if (b && offenderRecord) {
3064
+ const rDeps = recoveryDeps(deps, config, b, now);
3065
+ const { state: resolved } = (0, recovery_1.resolveFixAttempt)(state, batch.id, offenderRecord.issue, 'red', rDeps);
3066
+ deps.store.withLock((s) => ({
3067
+ state: applyBatchAndIssues(s, resolved, batch.id, []),
3068
+ result: undefined,
3069
+ }));
3070
+ }
2988
3071
  }
2989
- continue;
2990
3072
  }
2991
- if (slot)
2992
- continue; // live but neither running/assigned (e.g. mid-verify) — next tick
2993
- if (batch.status === 'validating') {
2994
- // A local suite run, not a provider dispatch — unaffected by `paused`.
2995
- runValidate(deps, config, batchDispatch, batch.id, now, result);
2996
- continue;
2997
- }
2998
- // #629: every branch below spawns a provider agent — hold the wedge
2999
- // until `sched resume` (see the top of this function for why `paused`
3000
- // is read once, up front, rather than per iteration).
3001
- if (paused)
3002
- continue;
3003
- if (batch.status === 'deployed') {
3004
- spawnReportAgent(deps, config, batchDispatch, batch.id, now, result);
3005
- }
3006
- else if (batch.status === 'executing') {
3007
- // A prior spawn threw, or `claimAndSpawn` found zero free capacity —
3008
- // either way the batch is stuck mid-member with no slot and nothing
3009
- // else will ever retry it (Conformance review AC5 caveat; Supportability
3010
- // review #12). Retrying every tick is cheap-by-contract (#677 review):
3011
- // `spawnMemberContinuation` runs the free-capacity and member-identity
3012
- // pre-checks BEFORE any pool/git work, so a full scheduler (or stale
3013
- // member context) exits here without exec'ing, and `claimAndSpawn`
3014
- // remains the authoritative capacity gate.
3015
- spawnMemberContinuation(deps, config, batchDispatch, batch.id, now, result);
3016
- }
3017
- else if (batch.status === 'reviewing' || batch.status === 'shipping') {
3018
- // Same wedge, for a dead-or-never-claimed tail agent.
3019
- spawnTailAgent(deps, config, batchDispatch, batch.id, now, result);
3020
- }
3021
- else if (batch.status === 'fixing') {
3022
- // `beginFixAttempt` already recorded this member's ONE attempt as
3023
- // `dispatched` before `claimAndSpawn` could find capacity — retrying the
3024
- // exact same dispatch isn't reconstructible from persisted state (only
3025
- // the outcome is persisted, not the command/prompt), so resolve it
3026
- // `red` (conservatively: the member loses its one attempt and evicts on
3027
- // the next validate pass, which is safe — never a permanent wedge).
3028
- const state = deps.store.load();
3029
- const b = (0, state_1.findBatch)(state, batch.id);
3030
- const offenderRecord = b
3031
- ? [...b.fix_attempts].reverse().find((a) => a.outcome === 'dispatched')
3032
- : undefined;
3033
- if (b && offenderRecord) {
3034
- const rDeps = recoveryDeps(deps, config, b, now);
3035
- const { state: resolved } = (0, recovery_1.resolveFixAttempt)(state, batch.id, offenderRecord.issue, 'red', rDeps);
3036
- deps.store.withLock((s) => ({
3037
- state: applyBatchAndIssues(s, resolved, batch.id, []),
3038
- result: undefined,
3039
- }));
3040
- }
3073
+ catch (err) {
3074
+ containFailure(batch.id, err);
3041
3075
  }
3042
3076
  }
3043
3077
  reconcilePrWatch(deps, now, result);