@ai-dossier/sched 0.33.0 → 0.35.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
@@ -89,8 +89,8 @@ where every mechanical supervision decision is code, not remembered prose:
89
89
 
90
90
  (`strong` sets only `model`, so its command falls back to the default claude template.)
91
91
  What an engine is ACTUALLY running is always visible in two places, added in #680: the
92
- `Dispatch: …` line in `sched status` (and its `--json` `dispatch` field), and the
93
- `▶ sched dispatch: …` banner `sched start` prints once at startup — both show
92
+ `Dispatch (default): …` line in `sched status` (and its `--json` `dispatch` field), and the
93
+ `▶ sched dispatch (default): …` banner `sched start` prints once at startup — both show
94
94
  `tier=agent/model` resolved exactly as spawns resolve. Note the inverse trap: running
95
95
  the batch FROM opencode/GLM does not make it run ON opencode/GLM — without a
96
96
  `dispatch.tiers` override every tier dispatches the default claude template regardless
@@ -283,6 +283,30 @@ Config schema moves to 1.8.0 (#565): a new top-level `default_batch_priority` ke
283
283
  invalid value degrades the whole config file to built-in defaults, same contract as every
284
284
  other field.
285
285
 
286
+ Config schema moves to 1.9.0 (#707): `dispatch.dispatch_profiles` names complete
287
+ dispatch configurations for batch runs. A profile has the same `command`, `tier_models`,
288
+ `prompt`, and optional per-tier `tiers` overrides as `dispatch`; the batch records the
289
+ profile name at enqueue time, and every member, tail, report, and recovery agent resolves
290
+ through that profile. For example:
291
+
292
+ ```json
293
+ {
294
+ "dispatch": {
295
+ "dispatch_profiles": {
296
+ "claude": { "command": ["claude", "-p", "--model", "{model}"], "tier_models": { "mechanical": "haiku", "mid": "sonnet", "strong": "opus" } },
297
+ "glm": { "command": ["opencode", "run", "-m", "{model}", "--"], "tier_models": { "mechanical": "glm-flash", "mid": "glm", "strong": "glm-strong" } }
298
+ }
299
+ }
300
+ }
301
+ ```
302
+
303
+ Use `sched enqueue --mode slot --batch <id> --dispatch glm` to select a profile
304
+ explicitly. Without `--dispatch`, a new batch detects Claude Code from `CLAUDECODE` or a
305
+ configured agent binary in its parent process chain. Detection is convenience only: if
306
+ profiles exist and detection is inconclusive, enqueue fails and names the available
307
+ profiles rather than silently choosing the default. `--dispatch` is intentionally rejected
308
+ for full-cycle entries; no profiles means legacy dispatch behavior remains unchanged.
309
+
286
310
  Two engine-safety policies were explicit product decisions on #464:
287
311
 
288
312
  - **Pid identity is hybrid-verified (decision 1, option C).** Every spawn records the
@@ -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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"}
@@ -1374,7 +1374,10 @@ function runValidate(deps, config, dispatch, batchId, now, result) {
1374
1374
  return;
1375
1375
  }
1376
1376
  const offender = outcome.offenders[0];
1377
- const { state: fixing, dispatch: fixDispatch } = (0, recovery_1.beginFixAttempt)(attributed, batchId, offender, rDeps, { config, tests: outcome.attributed.get(offender) ?? [] });
1377
+ const { state: fixing, dispatch: fixDispatch } = (0, recovery_1.beginFixAttempt)(attributed, batchId, offender, rDeps,
1378
+ // #707: `dispatch` is the batch's own (profile-resolved) dispatch — the
1379
+ // fix agent rides the same family the members ran on.
1380
+ { config, tests: outcome.attributed.get(offender) ?? [], dispatch });
1378
1381
  deps.store.withLock((s) => ({
1379
1382
  state: applyBatchAndIssues(s, fixing, batchId, []),
1380
1383
  result: undefined,
@@ -1804,6 +1807,11 @@ function resumeBlockedGate(deps, config, dispatch, batchId, now) {
1804
1807
  const batch = (0, state_1.findBatch)(state, batchId);
1805
1808
  if (!batch)
1806
1809
  throw new types_1.SchedNotFoundError(`Batch not found: ${batchId}`);
1810
+ // A gate recheck can immediately advance to the next provider dispatch, so
1811
+ // it must use the batch's recorded profile rather than the engine default.
1812
+ const batchDispatch = batch.dispatch_profile === null
1813
+ ? dispatch
1814
+ : (0, dispatch_1.resolveProfiledDispatch)(config, batch.dispatch_profile);
1807
1815
  if (batch.status !== 'blocked' || !batch.blocked_reason?.startsWith('gate-inconclusive:')) {
1808
1816
  // #677 review: the landing-failure blocks have no resume verb (same as
1809
1817
  // `suite-unreadable`) — name the actual reason and the operator path
@@ -1852,7 +1860,7 @@ function resumeBlockedGate(deps, config, dispatch, batchId, now) {
1852
1860
  outputTail: recheck.outputTail,
1853
1861
  durationMs: recheck.durationMs,
1854
1862
  }, now);
1855
- completeMemberGate(deps, config, dispatch, batchId, batch, memberIssue, now, result);
1863
+ completeMemberGate(deps, config, batchDispatch, batchId, batch, memberIssue, now, result);
1856
1864
  return { outcome: 'skipped', capability: capabilityId, detail: excerpt };
1857
1865
  }
1858
1866
  recordMemberGate(deps, batchId, memberIssue, { id: capabilityId, ...recheck }, now);
@@ -1900,7 +1908,7 @@ function resumeBlockedGate(deps, config, dispatch, batchId, now) {
1900
1908
  if (recheck.outcome === 'task-failed') {
1901
1909
  const reason = `incremental-gate-failed:${capabilityId}`;
1902
1910
  writeGateLog(deps, batchId, capabilityId, memberIssue, recheck.outputTail);
1903
- evictMemberAndContinue(deps, config, dispatch, batchId, batch, memberIssue, {
1911
+ evictMemberAndContinue(deps, config, batchDispatch, batchId, batch, memberIssue, {
1904
1912
  reason,
1905
1913
  detail: withExcerpt(`sched resume --batch: cap run ${capabilityId} reported task-failed on recheck`, excerpt),
1906
1914
  }, now, result);
@@ -1910,7 +1918,7 @@ function resumeBlockedGate(deps, config, dispatch, batchId, now) {
1910
1918
  issue: memberIssue,
1911
1919
  detail: `sched resume --batch: cap run ${capabilityId} reported ok on recheck`,
1912
1920
  });
1913
- completeMemberGate(deps, config, dispatch, batchId, batch, memberIssue, now, result);
1921
+ completeMemberGate(deps, config, batchDispatch, batchId, batch, memberIssue, now, result);
1914
1922
  return { outcome: 'completed', capability: capabilityId };
1915
1923
  }
1916
1924
  /**
@@ -2791,6 +2799,81 @@ function teardownBatch(deps, batchId) {
2791
2799
  function runBatchTick(deps, config, dispatch) {
2792
2800
  const result = emptyResult();
2793
2801
  const now = deps.now();
2802
+ // #707: each batch dispatches through ITS OWN recorded profile, not the
2803
+ // engine's default. Successful resolution is cached per tick (per
2804
+ // runBatchTick call); missing profiles are handled per batch. A profile that
2805
+ // no longer resolves (config edited
2806
+ // mid-run) never silently falls back — the silent fallback is precisely
2807
+ // the #680 incident shape (a run that looked like one agent family,
2808
+ // executed as another). The reaction depends on how far the batch got:
2809
+ // - pre-merge (`dissolving` is a legal edge): DISSOLVE with reason
2810
+ // `dispatch-profile-missing:<name>` — loud, deterministic, one-shot, and
2811
+ // the members requeue as full-cycle units on the config default, which is
2812
+ // a coherent recovery rather than a mislabelled continuation.
2813
+ // - post-merge (PR parked/merged — the product already shipped): dissolving
2814
+ // would discard a landed PR's bookkeeping, so the remaining dispatch (the
2815
+ // report agent) runs on the default and journals
2816
+ // `dispatch-profile-missing` once while it holds the decision open — a
2817
+ // bounded phase (deployed → reported), with the journal acting as the
2818
+ // durable deduplication record across ticks and process restarts.
2819
+ const profileCache = new Map();
2820
+ const missingProfileBatches = new Set();
2821
+ const missingProfileNotices = new Set(deps.journal
2822
+ .read()
2823
+ .filter((event) => event.event === 'dispatch-profile-missing' && event.unit !== undefined)
2824
+ .map((event) => event.unit));
2825
+ const dispatchFor = (batch) => {
2826
+ if (batch.dispatch_profile === null)
2827
+ return dispatch;
2828
+ const cached = profileCache.get(batch.dispatch_profile);
2829
+ if (cached !== undefined)
2830
+ return cached;
2831
+ if (missingProfileBatches.has(batch.id))
2832
+ return dispatch;
2833
+ try {
2834
+ const resolved = (0, dispatch_1.resolveProfiledDispatch)(config, batch.dispatch_profile);
2835
+ profileCache.set(batch.dispatch_profile, resolved);
2836
+ return resolved;
2837
+ }
2838
+ catch (err) {
2839
+ if (!(err instanceof dispatch_1.DispatchProfileError))
2840
+ throw err;
2841
+ missingProfileBatches.add(batch.id);
2842
+ const current = deps.store.load();
2843
+ const fresh = (0, state_1.findBatch)(current, batch.id);
2844
+ const dissolvable = fresh !== undefined && (0, state_1.allowedBatchTransitions)(fresh.status).includes('dissolving');
2845
+ if (dissolvable) {
2846
+ const liveSlot = slotFor(current, batch.id);
2847
+ if (liveSlot !== undefined &&
2848
+ liveSlot.pid !== null &&
2849
+ deps.spawnDeps.isAlive(liveSlot.pid, liveSlot.pid_start ?? undefined)) {
2850
+ deps.spawnDeps.kill(liveSlot.pid, liveSlot.pid_start ?? undefined);
2851
+ }
2852
+ const outcome = (0, recovery_1.dissolveBatch)(current, batch.id, { strategy: 'full', reason: `dispatch-profile-missing:${batch.dispatch_profile}` }, recoveryDeps(deps, config, fresh, now));
2853
+ deps.store.withLock((s) => ({
2854
+ state: applyBatchAndIssues(s, outcome.state, batch.id, outcome.requeued),
2855
+ result: undefined,
2856
+ }));
2857
+ teardownBatch(deps, batch.id);
2858
+ result.failed.push(unit(batch.id));
2859
+ // Resolution failed: clean up any batch/member worktrees, then skip
2860
+ // this batch for the rest of the pass. No further arm can claim it.
2861
+ // Do not cache a missing profile: every batch must receive its own
2862
+ // dissolve/journal reaction rather than inheriting a cached default.
2863
+ return null;
2864
+ }
2865
+ else {
2866
+ const batchUnit = unit(batch.id);
2867
+ if (!missingProfileNotices.has(batchUnit)) {
2868
+ journalEvent(deps, 'dispatch-profile-missing', batchUnit, {
2869
+ detail: `profile '${batch.dispatch_profile}' is no longer configured; the post-merge tail runs on the default dispatch`,
2870
+ });
2871
+ missingProfileNotices.add(batchUnit);
2872
+ }
2873
+ return dispatch;
2874
+ }
2875
+ }
2876
+ };
2794
2877
  // #565: when more ready batches exist than free capacity, claim them in
2795
2878
  // the same priority order `runnableUnits` would (desc priority → asc
2796
2879
  // readiness age → anchor) — this loop never goes through
@@ -2822,7 +2905,12 @@ function runBatchTick(deps, config, dispatch) {
2822
2905
  const state = deps.store.load();
2823
2906
  const batch = (0, state_1.findBatch)(state, batchId);
2824
2907
  if (batch && batch.status === 'ready' && slotFor(state, batchId) === undefined) {
2825
- claimAndSetup(deps, config, dispatch, batchId, now, result);
2908
+ // #707: batch-setup dispatches through the batch's own profile too — it
2909
+ // is the batch's first agent, not an engine-internal step.
2910
+ const batchDispatch = dispatchFor(batch);
2911
+ if (batchDispatch !== null) {
2912
+ claimAndSetup(deps, config, batchDispatch, batchId, now, result);
2913
+ }
2826
2914
  }
2827
2915
  }
2828
2916
  for (const batch of deps.store.load().batches) {
@@ -2837,20 +2925,29 @@ function runBatchTick(deps, config, dispatch) {
2837
2925
  // `abandonBatch` that stops one from being created: it also REPAIRS state
2838
2926
  // files that already carry a leaked slot, which is the only way an
2839
2927
  // operator gets those three hours of held capacity back.
2840
- if (slot && types_1.TERMINAL_BATCH_STATUSES.has(batch.status)) {
2841
- deps.store.withLock((s) => ({
2842
- state: (0, state_1.releaseBatchSlot)(s, batch.id, now),
2843
- result: undefined,
2844
- }));
2845
- journalEvent(deps, 'slot-released', unit(batch.id), {
2846
- slot: slot.id,
2847
- detail: `terminal batch (${batch.status}) held slot ${slot.id}`,
2848
- });
2928
+ if (types_1.TERMINAL_BATCH_STATUSES.has(batch.status)) {
2929
+ if (slot) {
2930
+ deps.store.withLock((s) => ({
2931
+ state: (0, state_1.releaseBatchSlot)(s, batch.id, now),
2932
+ result: undefined,
2933
+ }));
2934
+ journalEvent(deps, 'slot-released', unit(batch.id), {
2935
+ slot: slot.id,
2936
+ detail: `terminal batch (${batch.status}) held slot ${slot.id}`,
2937
+ });
2938
+ }
2849
2939
  continue;
2850
2940
  }
2941
+ // #707: resolve this batch's profile once per pass — or dissolve/fall
2942
+ // back loudly when it no longer resolves (see `dispatchFor` above).
2943
+ // Terminal batches are skipped before this call so a removed profile does
2944
+ // not generate a missing-profile event on every later tick.
2945
+ const batchDispatch = dispatchFor(batch);
2946
+ if (batchDispatch === null)
2947
+ continue;
2851
2948
  if (slot && (slot.status === 'running' || slot.status === 'assigned')) {
2852
2949
  if (batch.status === 'executing') {
2853
- reconcileMemberSlot(deps, config, dispatch, batch.id, slot, now, result);
2950
+ reconcileMemberSlot(deps, config, batchDispatch, batch.id, slot, now, result);
2854
2951
  }
2855
2952
  else if (batch.status === 'fixing') {
2856
2953
  reconcileFixSlot(deps, config, batch.id, slot, now, result);
@@ -2867,7 +2964,7 @@ function runBatchTick(deps, config, dispatch) {
2867
2964
  continue; // live but neither running/assigned (e.g. mid-verify) — next tick
2868
2965
  if (batch.status === 'validating') {
2869
2966
  // A local suite run, not a provider dispatch — unaffected by `paused`.
2870
- runValidate(deps, config, dispatch, batch.id, now, result);
2967
+ runValidate(deps, config, batchDispatch, batch.id, now, result);
2871
2968
  continue;
2872
2969
  }
2873
2970
  // #629: every branch below spawns a provider agent — hold the wedge
@@ -2876,7 +2973,7 @@ function runBatchTick(deps, config, dispatch) {
2876
2973
  if (paused)
2877
2974
  continue;
2878
2975
  if (batch.status === 'deployed') {
2879
- spawnReportAgent(deps, config, dispatch, batch.id, now, result);
2976
+ spawnReportAgent(deps, config, batchDispatch, batch.id, now, result);
2880
2977
  }
2881
2978
  else if (batch.status === 'executing') {
2882
2979
  // A prior spawn threw, or `claimAndSpawn` found zero free capacity —
@@ -2887,11 +2984,11 @@ function runBatchTick(deps, config, dispatch) {
2887
2984
  // pre-checks BEFORE any pool/git work, so a full scheduler (or stale
2888
2985
  // member context) exits here without exec'ing, and `claimAndSpawn`
2889
2986
  // remains the authoritative capacity gate.
2890
- spawnMemberContinuation(deps, config, dispatch, batch.id, now, result);
2987
+ spawnMemberContinuation(deps, config, batchDispatch, batch.id, now, result);
2891
2988
  }
2892
2989
  else if (batch.status === 'reviewing' || batch.status === 'shipping') {
2893
2990
  // Same wedge, for a dead-or-never-claimed tail agent.
2894
- spawnTailAgent(deps, config, dispatch, batch.id, now, result);
2991
+ spawnTailAgent(deps, config, batchDispatch, batch.id, now, result);
2895
2992
  }
2896
2993
  else if (batch.status === 'fixing') {
2897
2994
  // `beginFixAttempt` already recorded this member's ONE attempt as