@ai-dossier/sched 0.23.2 → 0.24.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 +97 -14
- package/dist/batch-dispatch.d.ts.map +1 -1
- package/dist/batch-dispatch.js +228 -14
- package/dist/batch-dispatch.js.map +1 -1
- package/dist/dispatch-health.d.ts +84 -0
- package/dist/dispatch-health.d.ts.map +1 -0
- package/dist/dispatch-health.js +127 -0
- package/dist/dispatch-health.js.map +1 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +285 -71
- package/dist/engine.js.map +1 -1
- package/dist/enqueue.d.ts.map +1 -1
- package/dist/enqueue.js +1 -0
- package/dist/enqueue.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/scheduler.d.ts.map +1 -1
- package/dist/scheduler.js +11 -1
- package/dist/scheduler.js.map +1 -1
- package/dist/state.d.ts +49 -2
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +166 -12
- package/dist/state.js.map +1 -1
- package/dist/status.d.ts +9 -1
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +2 -0
- package/dist/status.js.map +1 -1
- package/dist/types.d.ts +153 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +27 -3
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -146,7 +146,7 @@ where every mechanical supervision decision is code, not remembered prose:
|
|
|
146
146
|
itself — see `slot-released` below (#525).
|
|
147
147
|
6. **Journal (AC6)** — every event (assigned, spawned, exit-detected, external-advance,
|
|
148
148
|
progress, stalled, redispatched, fence-written, fence-failed, unit-failed,
|
|
149
|
-
dependents-blocked, slot-released, suspect-dispatch, dispatch-unhealthy,
|
|
149
|
+
dependents-blocked, slot-released, suspect-dispatch, dispatch-unhealthy, dispatch-failure,
|
|
150
150
|
run-log-recorded, run-log-no-usage, run-log-skipped, run-log-failed, engine-stale,
|
|
151
151
|
engine-auto-upgrade-attempted, engine-auto-upgrade-failed, stale-milestone-ignored, …) is
|
|
152
152
|
appended to `events.jsonl`; `sched status` shows the live phase per unit, plus each
|
|
@@ -182,6 +182,33 @@ where every mechanical supervision decision is code, not remembered prose:
|
|
|
182
182
|
a wall that hasn't actually cleared), which also clears the streak so `sched status`'s
|
|
183
183
|
warning doesn't linger against a wall the operator already acted on.
|
|
184
184
|
|
|
185
|
+
**Confirmed dispatch failures (#629)** are a SEPARATE, deterministic signal alongside
|
|
186
|
+
the timing heuristic above: a dispatch result carrying `api_error_status` or
|
|
187
|
+
`terminal_reason: "api_error"` (`parseDispatchApiError`, `@ai-dossier/core`) is a
|
|
188
|
+
confirmed provider wall — a 429 spend/rate limit, an auth failure the provider itself
|
|
189
|
+
rejected — never an agent that ran, journaled **`dispatch-failure`** with the
|
|
190
|
+
provider's own status/reason/message and reset time (when supplied). Unlike
|
|
191
|
+
`suspect-dispatch`, a repeat from the SAME unit COUNTS toward its own
|
|
192
|
+
`consecutive_dispatch_api_errors` counter — no cross-unit correlation is needed when
|
|
193
|
+
the classification is a parsed field rather than a timing inference, and the incident
|
|
194
|
+
that motivated this (a batch tail respawning nine times in 33 minutes) was the SAME
|
|
195
|
+
unit throughout. At the same `DISPATCH_UNHEALTHY_THRESHOLD` it reuses `setPaused`/
|
|
196
|
+
`dispatch-unhealthy`. The redispatch itself does NOT escalate — same tier,
|
|
197
|
+
`recoveries` unchanged, `ESCALATION_CAP` never consumed, since a spend wall is not the
|
|
198
|
+
issue's fault — so once paused, BOTH the per-issue rail (`enterRecovery`/
|
|
199
|
+
`reconcileRecovering`, held in `recovering` until `sched resume`) and every batch
|
|
200
|
+
respawn wedge in `runBatchTick` (tail, member continuation, fix, report, and the
|
|
201
|
+
`ready` → `claimAndSetup` claim — `runValidate`'s local suite run is unaffected) stop
|
|
202
|
+
respawning into the wall, which previously ignored `paused` entirely. `dispatch-
|
|
203
|
+
health.ts` is its own module (not `engine.ts`) specifically so it is shared by BOTH
|
|
204
|
+
dispatch paths without `engine.ts` and `batch-dispatch.ts` importing each other.
|
|
205
|
+
Batch dispatch logs (per-role, append-mode) are fenced to `log_offset_at_spawn`,
|
|
206
|
+
stamped by each batch spawn function — a later dispatch that dies with NO result event
|
|
207
|
+
at all is never misclassified against a stale `api_error` result left in the same log
|
|
208
|
+
file by an earlier attempt. A verified completion/park resets the streak on the
|
|
209
|
+
per-issue path; a successful member/tail/report resets it on the batch path; `sched
|
|
210
|
+
resume` resets it on both.
|
|
211
|
+
|
|
185
212
|
Config schema moves to 1.4.0 (#527): `dispatch` gains `tiers` — a per-tier
|
|
186
213
|
`{ command?, model?, prompt? }` spawn spec. `command`/`tier_models`/`prompt` remain valid
|
|
187
214
|
as the shorthand and are the fallback for any field a `tiers` entry leaves unset, so a
|
|
@@ -232,7 +259,12 @@ Two engine-safety policies were explicit product decisions on #464:
|
|
|
232
259
|
network down), stall and verify-fail decisions pause for that unit — an outage can
|
|
233
260
|
never kill a healthy agent or fail a unit as "unverified". An agent that exits during
|
|
234
261
|
an outage holds in `verifying` until truth returns. Each pause is journaled as
|
|
235
|
-
`ground-truth-unreachable
|
|
262
|
+
`ground-truth-unreachable` — since #632 once per unbroken streak (carrying `since` and
|
|
263
|
+
`ticks_persisted`) rather than once per tick, re-announced every 20 ticks and re-armed
|
|
264
|
+
when truth recovers and fails anew, so an outage across `max_slots` units no longer
|
|
265
|
+
emits one line per unit per reconcile interval. Two sites are deliberately undeduped:
|
|
266
|
+
`failOrAdoptOpenPr`, which is terminal and so fires at most once per dispatch, and
|
|
267
|
+
`runTeardownFor`, which is audited but not yet fixed (#636).
|
|
236
268
|
- **A completion milestone is fenced to its own dispatch (#575).** `isVerifiedComplete`
|
|
237
269
|
(issues) and `isMemberComplete` (batch members) both accept the current dispatch's
|
|
238
270
|
`SlotEntry.spawned_at` and reject a `report done` / `review done mode=slot` milestone
|
|
@@ -587,6 +619,39 @@ states migrate on load, backfilling `null` — nothing was ever recorded per-dis
|
|
|
587
619
|
under the old once-per-tick behavior, so null is exact, not a guess. Resets with the
|
|
588
620
|
slot on release (`CLEARED_SLOT_FIELDS`).
|
|
589
621
|
|
|
622
|
+
Schema 1.13.0 (#630): `BatchEntry` gains `pr_watch_failed_reason` (the reason already
|
|
623
|
+
journalled for the batch's current `pr-watch-failed` streak, null when the watch is
|
|
624
|
+
healthy), `pr_watch_failed_since` (ISO, when the streak began) and
|
|
625
|
+
`pr_watch_failed_ticks` (ticks the streak has persisted, silent ones included) — so
|
|
626
|
+
`pr-watch-failed` fires once per distinct condition rather than once per reconcile tick,
|
|
627
|
+
re-announcing every `JOURNAL_DEDUP_REANNOUNCE_TICKS` (20) ticks. 1.12.0 states migrate on
|
|
628
|
+
load, backfilling `null`/`null`/`0` — no dedup marker was ever recorded under the old
|
|
629
|
+
once-per-tick behavior, so it is exact, not a guess. Cleared whenever the batch leaves
|
|
630
|
+
`awaiting-merge` (`CLEARED_PR_WATCH_FIELDS`), since the marker is scoped to one
|
|
631
|
+
awaiting-merge stretch and a batch can return to that status on the same id via
|
|
632
|
+
rebase-and-reship.
|
|
633
|
+
|
|
634
|
+
Schema 1.14.0 (#632): `QueueEntry` gains `ground_truth_unreachable_since`/`_ticks` and
|
|
635
|
+
`pr_watch_waiting_since`/`_ticks` — the same dedup scoped to the issue rather than the
|
|
636
|
+
batch, because these sites span slot-held, parked and stale-failed units alike and
|
|
637
|
+
`QueueEntry` is the one record every unit has either way. 1.13.0 and earlier states
|
|
638
|
+
migrate on load, backfilling `null`/`0` — exact, not a guess. Reset on every requeue
|
|
639
|
+
(`CLEARED_ENTRY_DEDUP_MARKERS`, applied by `requeueMember`,
|
|
640
|
+
`requeueOrphanedDispatches` and the label-cleared path): a requeue is a fresh attempt,
|
|
641
|
+
and a streak from the previous run must not silence this one's first occurrence. Written
|
|
642
|
+
with `touchUpdatedAt: false` — `QueueEntry.updated_at` is load-bearing for
|
|
643
|
+
`isStaleFailedPark`'s 7-day window, `status.ts`'s "parked since" and `readiness.ts`'s
|
|
644
|
+
tiebreak, and a silent dedup tick must not reset them. `patchBatch` takes the same flag
|
|
645
|
+
for the same reason on the batch rail.
|
|
646
|
+
Schema 1.13.0 (#629): `SchedState` gains `consecutive_dispatch_api_errors` (number) and
|
|
647
|
+
`dispatch_pause_reset_at` (string or null) — the confirmed-dispatch-failure streak and
|
|
648
|
+
the provider's own reset time. Unlike the #505 suspect-dispatch pair these are NOT a
|
|
649
|
+
single fact: a provider that reports no reset time leaves `dispatch_pause_reset_at`
|
|
650
|
+
null while the counter is nonzero (`validateState` only enforces the reverse — a reset
|
|
651
|
+
time can never outlive a streak that has already cleared to zero). 1.12.0 states
|
|
652
|
+
migrate on load, backfilling `0`/`null` — no confirmed dispatch failures were ever
|
|
653
|
+
tracked under them, so those values are exact, not a guess.
|
|
654
|
+
|
|
590
655
|
New journal events: `batch-setup-done`, `batch-setup-failed`, `member-advanced`,
|
|
591
656
|
`batch-warmup-done`, `batch-warmup-failed` (#561 — the cold-path warm step only; a pool
|
|
592
657
|
claim emits neither). `gate-inconclusive` (#583 — the incremental gate came back
|
|
@@ -707,7 +772,7 @@ import {
|
|
|
707
772
|
// so batch-dispatch.ts can share it.
|
|
708
773
|
TRANSITIONS, // the transition tables themselves (for previews)
|
|
709
774
|
buildStatusReport, // machine-readable status incl. blocked/failed sets
|
|
710
|
-
validateState, // strict persisted-state validation (1.0.0-1.
|
|
775
|
+
validateState, // strict persisted-state validation (1.0.0-1.13.0 files migrate)
|
|
711
776
|
DEFAULT_ISSUE_PRIORITY, DEFAULT_BATCH_PRIORITY, // priority defaults (0 / 10, #565)
|
|
712
777
|
IllegalTransitionError, EnqueueError, CorruptStateError, LockTimeoutError,
|
|
713
778
|
SchedNotFoundError,
|
|
@@ -795,9 +860,10 @@ ladder is exhausted, the terminal `unit-failed` (`agent-exited-unverified` /
|
|
|
795
860
|
the dead-pid detection rail and the stall kill RECORD a fresh log slice (a `runs.jsonl`
|
|
796
861
|
entry) in the same tick — `recordDispatchRunLog` is once-per-dispatch and refuses to
|
|
797
862
|
append a second entry over the same slice. Since #620 a slot already `exited`/`verifying`
|
|
798
|
-
when reconciled again still ATTRIBUTES one: `
|
|
799
|
-
static slice for the tool
|
|
800
|
-
|
|
863
|
+
when reconciled again still ATTRIBUTES one: `readDispatchSignalsForSlot` re-parses the
|
|
864
|
+
same static slice (since #629, for the last-tool-call AND API-error-classification
|
|
865
|
+
signals together — see the dispatch-health section above), writing nothing, so it
|
|
866
|
+
carries no exactly-once constraint. That matters because the verify decision lands on a LATER tick than the
|
|
801
867
|
dead-pid detection whenever ground truth was unreachable in between — previously the
|
|
802
868
|
tool name was simply lost on exactly the runs hardest to diagnose. The read is deferred
|
|
803
869
|
behind a thunk so only the tick that reaches the unverified-exit decision pays for it,
|
|
@@ -871,7 +937,8 @@ after-the-fact recovery, not a missing-data bug.
|
|
|
871
937
|
queue data.
|
|
872
938
|
- **Schema**: state/config files from #460 (schema 1.0.0), #464 (1.1.0), #468 (1.2.0),
|
|
873
939
|
#472 (1.3.0), #500 (1.4.0), #505 (1.5.0), #504 (1.6.0), #523 (1.7.0) and #524 (1.8.0)
|
|
874
|
-
load and migrate to 1.
|
|
940
|
+
load and migrate to 1.14.0 automatically (slot `branch`/`last_head`/`pid_start`, slot `role` (inferred from the
|
|
941
|
+
load and migrate to 1.13.0 automatically (slot `branch`/`last_head`/`pid_start`, slot `role` (inferred from the
|
|
875
942
|
unit's queue entry, with the persisted `phase` as a fallback — #500), entry
|
|
876
943
|
`pr`/`cleanup`/`failure_evidence`, batch `anchor`/`branch`/`run_id`/`eviction_groups`/
|
|
877
944
|
`evictions`/`fix_attempts`/`rebase_attempts`, state-level `last_pr_poll_at` backfill to
|
|
@@ -879,16 +946,22 @@ after-the-fact recovery, not a missing-data bug.
|
|
|
879
946
|
backfill to `0`/`null` — #505, slot `gen`/`fenced_at` backfill to `0`/`null` — #504, and
|
|
880
947
|
slot `spawned_at`/`log_offset_at_spawn` backfill to `null`/`null` — #524,
|
|
881
948
|
state-level `last_label_poll_at` backfill to `null` — #544, entry `priority` and batch
|
|
882
|
-
`member_gates`/`blocked_reason` — #565/#583,
|
|
883
|
-
`stale_milestone_ignored_for` backfill to `null` — #610
|
|
949
|
+
`member_gates`/`blocked_reason` — #565/#583, slot
|
|
950
|
+
`stale_milestone_ignored_for` backfill to `null` — #610, and state-level
|
|
951
|
+
`consecutive_dispatch_api_errors`/`dispatch_pause_reset_at` backfill to `0`/`null` —
|
|
952
|
+
#629).
|
|
884
953
|
- **`max_slots`** bounds live units (`assigned | running | recovering`); dependency
|
|
885
954
|
edges gate readiness — an issue with an unmerged dependency, and a batch behind an
|
|
886
955
|
unmerged batch, are never runnable.
|
|
887
|
-
- **Pause** stops new assignments only (including report-agent dispatch — #505)
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
(
|
|
891
|
-
|
|
956
|
+
- **Pause** stops new assignments only (including report-agent dispatch — #505) and,
|
|
957
|
+
since #629, every batch respawn wedge in `runBatchTick` — tail, member continuation,
|
|
958
|
+
fix, report, and the `ready` → `claimAndSetup` claim, which previously ignored `paused`
|
|
959
|
+
entirely (`runValidate` is excepted: a local suite run, not a dispatch) — and an
|
|
960
|
+
unescalated per-issue redispatch (`enterRecovery`/`reconcileRecovering`, held in
|
|
961
|
+
`recovering` until resumed). Abandon routes through the typed failure rails
|
|
962
|
+
(`evicted → requeued{full}` for batch members — nothing green is discarded). A pause
|
|
963
|
+
can be manual (`sched pause`) or automatic (dispatch-health, #505/#629 above); `sched
|
|
964
|
+
resume` clears the flag and both dispatch-health streaks.
|
|
892
965
|
|
|
893
966
|
## Hard-block labels are re-read every tick (#544)
|
|
894
967
|
|
|
@@ -1018,6 +1091,16 @@ in `events.jsonl` (`pr-parked` — two paths, see 1 and 1b above,
|
|
|
1018
1091
|
`pr-watch-waiting`, `teardown-done`/`teardown-failed`, `report-dispatched`,
|
|
1019
1092
|
`report-failed`, `ground-truth-unreachable`, `stale-failure-reconciled`).
|
|
1020
1093
|
|
|
1094
|
+
Since #610/#630/#632, `pr-watch-failed`, `pr-watch-waiting` and
|
|
1095
|
+
`ground-truth-unreachable` are emitted **once per unbroken streak** of the condition, not
|
|
1096
|
+
once per tick, re-announcing every `JOURNAL_DEDUP_REANNOUNCE_TICKS` (20) ticks. Each
|
|
1097
|
+
entry carries `at` (the engine's decision clock), `since` (the streak's onset) and
|
|
1098
|
+
`ticks_persisted` (ticks the streak has run, silent ones included). Read `since`, not the
|
|
1099
|
+
tick count, for duration: 20 ticks is ~20 min on the per-reconcile sites
|
|
1100
|
+
(`reconcile_interval_ms`, default 60 s) and ~50 min on the sites gated on the parked-PR
|
|
1101
|
+
poll (`pr_poll_interval_ms`, default 150 s), and both are operator-tunable. A count of
|
|
1102
|
+
these events is therefore a count of streaks and re-announcements, not of ticks.
|
|
1103
|
+
|
|
1021
1104
|
## Development
|
|
1022
1105
|
|
|
1023
1106
|
```bash
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"batch-dispatch.d.ts","sourceRoot":"","sources":["../src/batch-dispatch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6DG;AAmBH,OAAO,
|
|
1
|
+
{"version":3,"file":"batch-dispatch.d.ts","sourceRoot":"","sources":["../src/batch-dispatch.ts"],"names":[],"mappings":"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"}
|
package/dist/batch-dispatch.js
CHANGED
|
@@ -104,6 +104,7 @@ const core_1 = require("@ai-dossier/core");
|
|
|
104
104
|
const worktree_pool_1 = require("@ai-dossier/worktree-pool");
|
|
105
105
|
const attribution_1 = require("./attribution");
|
|
106
106
|
const dispatch_1 = require("./dispatch");
|
|
107
|
+
const dispatch_health_1 = require("./dispatch-health");
|
|
107
108
|
const groundtruth_1 = require("./groundtruth");
|
|
108
109
|
const journal_1 = require("./journal");
|
|
109
110
|
const readiness_1 = require("./readiness");
|
|
@@ -143,6 +144,55 @@ function sanitizeUntrustedText(value) {
|
|
|
143
144
|
function journalEvent(deps, event, unitId, extra = {}) {
|
|
144
145
|
deps.journal.append((0, journal_1.unitEvent)(event, unitId, extra), deps.now());
|
|
145
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Read a dead batch dispatch's own log and classify it (#629 AC3) — tail,
|
|
149
|
+
* member, fix and report all reach a `dead` agent the same way (`pid` no
|
|
150
|
+
* longer alive), and every one of them previously treated that exit as a
|
|
151
|
+
* real failure (`tail-agent-exited-unverified`, member eviction,
|
|
152
|
+
* `report-failed`, a red fix-attempt resolution) with no way to tell a
|
|
153
|
+
* confirmed provider API error apart from an agent that actually ran.
|
|
154
|
+
*
|
|
155
|
+
* `offset` fences this read to THIS dispatch's slice, mirroring the
|
|
156
|
+
* per-issue path's `dispatchLogSlice`/`log_offset_at_spawn` (#524): these
|
|
157
|
+
* batch log paths are per-ROLE, not per-dispatch, and opened in append mode
|
|
158
|
+
* (`dispatch.ts`'s `openLogForAppend`), so a byte-0 read would keep
|
|
159
|
+
* classifying a LATER dead exit — one that itself wrote no `result` event at
|
|
160
|
+
* all (killed, OOM, an operator killing the tick — the #629 incident itself)
|
|
161
|
+
* — against a STALE `result` line from an earlier dispatch, permanently
|
|
162
|
+
* suppressing genuine failures and re-arming the pause on every reconcile
|
|
163
|
+
* (#629 review). Each batch spawn function stamps `log_offset_at_spawn` on
|
|
164
|
+
* its slot patch, exactly like the per-issue spawn path already does.
|
|
165
|
+
*/
|
|
166
|
+
function readDispatchApiError(logFile, offset) {
|
|
167
|
+
return (0, core_1.parseDispatchApiError)((0, run_log_1.readDispatchLog)(logFile, offset));
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Classify a dead batch dispatch and, if it is a confirmed provider API
|
|
171
|
+
* error, record it against the shared #505/#629 pause (the same mechanism
|
|
172
|
+
* `engine.ts`'s `completeUnitOrRecover` uses — see `dispatch-health.ts`) and
|
|
173
|
+
* release the slot, all inside ONE lock (a crash between recording and
|
|
174
|
+
* releasing would otherwise leave the counter incremented with the slot
|
|
175
|
+
* still held by a dead pid — #629 review). Returns whether it classified as
|
|
176
|
+
* an API error — the caller's cue to skip its normal failure handling
|
|
177
|
+
* (eviction, `tail-agent-exited-unverified`, `report-failed`, a red
|
|
178
|
+
* fix-attempt resolution) entirely.
|
|
179
|
+
*
|
|
180
|
+
* `release: false` (the fix-agent caller) records without releasing — that
|
|
181
|
+
* caller has its own release a few lines later, after also deciding whether
|
|
182
|
+
* to resolve the fix attempt.
|
|
183
|
+
*/
|
|
184
|
+
function handleDeadDispatchApiError(deps, batchId, logFile, offset, now, options = {}) {
|
|
185
|
+
const apiError = readDispatchApiError(logFile, offset);
|
|
186
|
+
if (!apiError)
|
|
187
|
+
return false;
|
|
188
|
+
deps.store.withLock((s) => {
|
|
189
|
+
let n = (0, dispatch_health_1.recordDispatchApiError)((event, unitId, extra) => journalEvent(deps, event, unitId, extra), s, unit(batchId), apiError);
|
|
190
|
+
if (options.release ?? true)
|
|
191
|
+
n = releaseSlot(n, batchId, now);
|
|
192
|
+
return { state: n, result: undefined };
|
|
193
|
+
});
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
146
196
|
/**
|
|
147
197
|
* Last ~500 bytes of a gate's output tail, falling back to the envelope's
|
|
148
198
|
* `reason` when no subprocess ran (#583 AC1/AC3 review: `capability-unavailable`
|
|
@@ -527,6 +577,11 @@ function spawnMember(deps, dispatch, state, slot, batchId, now, result) {
|
|
|
527
577
|
const cmd = spawnSpec.cmd;
|
|
528
578
|
const prompt = (0, dispatch_1.buildMemberPrompt)(dispatch.memberPrompt, memberIssue, batchId, batch.worktree);
|
|
529
579
|
const logFile = (0, dispatch_1.batchMemberLogPath)(deps.store.runsDir, batchId, batch.executing_member, memberIssue);
|
|
580
|
+
// #629: captured BEFORE spawning, mirroring `engine.ts`'s own
|
|
581
|
+
// `spawnAndRecord` — the log is per-role and append-mode, so the size at
|
|
582
|
+
// this instant is exactly where THIS dispatch's own output starts. Fences
|
|
583
|
+
// `handleDeadDispatchApiError`'s classification to this dispatch's slice.
|
|
584
|
+
const logOffset = (0, dispatch_1.fileSizeOrZero)(logFile);
|
|
530
585
|
let pid;
|
|
531
586
|
try {
|
|
532
587
|
pid = deps.spawnDeps.spawn(cmd, prompt, logFile);
|
|
@@ -549,6 +604,7 @@ function spawnMember(deps, dispatch, state, slot, batchId, now, result) {
|
|
|
549
604
|
// (line ~745) — `spawnMember` never did, so `recordMemberRunLog`'s
|
|
550
605
|
// `slot.spawned_at === null` guard silently skipped every member.
|
|
551
606
|
spawned_at: now.toISOString(),
|
|
607
|
+
log_offset_at_spawn: logOffset,
|
|
552
608
|
};
|
|
553
609
|
const next = slot.status === 'assigned' || slot.status === 'recovering'
|
|
554
610
|
? (0, state_1.transitionSlot)(withStatus, slot.id, 'running', patch, now)
|
|
@@ -709,6 +765,9 @@ function spawnTailAgent(deps, config, dispatch, batchId, now, result) {
|
|
|
709
765
|
const cmd = spawnSpec.cmd;
|
|
710
766
|
const prompt = (0, dispatch_1.buildBatchTailPrompt)(dispatch.batchTailPrompt, batchId, batch.anchor, batch.members, batch.worktree);
|
|
711
767
|
const logFile = (0, dispatch_1.batchTailLogPath)(deps.store.runsDir, batchId);
|
|
768
|
+
// #629: fences `handleDeadDispatchApiError`'s classification to this
|
|
769
|
+
// dispatch's slice — see `spawnMember`'s identical comment.
|
|
770
|
+
const logOffset = (0, dispatch_1.fileSizeOrZero)(logFile);
|
|
712
771
|
let pid;
|
|
713
772
|
try {
|
|
714
773
|
pid = deps.spawnDeps.spawn(cmd, prompt, logFile);
|
|
@@ -726,6 +785,7 @@ function spawnTailAgent(deps, config, dispatch, batchId, now, result) {
|
|
|
726
785
|
pid_start: deps.spawnDeps.processStart(pid),
|
|
727
786
|
phase: 'reviewing',
|
|
728
787
|
last_progress_at: now.toISOString(),
|
|
788
|
+
log_offset_at_spawn: logOffset,
|
|
729
789
|
};
|
|
730
790
|
const next = slot.status === 'assigned' ? (0, state_1.transitionSlot)(state, slot.id, 'running', patch, now) : state;
|
|
731
791
|
deps.journal.append((0, journal_1.unitEvent)('spawned', unit(batchId), {
|
|
@@ -759,6 +819,9 @@ function spawnReportAgent(deps, config, dispatch, batchId, now, result) {
|
|
|
759
819
|
const cmd = spawnSpec.cmd;
|
|
760
820
|
const prompt = (0, dispatch_1.buildBatchReportPrompt)(dispatch.batchReportPrompt, batchId, batch.anchor, prNumber);
|
|
761
821
|
const logFile = (0, dispatch_1.batchReportLogPath)(deps.store.runsDir, batchId);
|
|
822
|
+
// #629: fences `handleDeadDispatchApiError`'s classification to this
|
|
823
|
+
// dispatch's slice — see `spawnMember`'s identical comment.
|
|
824
|
+
const logOffset = (0, dispatch_1.fileSizeOrZero)(logFile);
|
|
762
825
|
let pid;
|
|
763
826
|
try {
|
|
764
827
|
pid = deps.spawnDeps.spawn(cmd, prompt, logFile);
|
|
@@ -772,6 +835,7 @@ function spawnReportAgent(deps, config, dispatch, batchId, now, result) {
|
|
|
772
835
|
pid_start: deps.spawnDeps.processStart(pid),
|
|
773
836
|
phase: 'report',
|
|
774
837
|
last_progress_at: now.toISOString(),
|
|
838
|
+
log_offset_at_spawn: logOffset,
|
|
775
839
|
};
|
|
776
840
|
const next = slot.status === 'assigned'
|
|
777
841
|
? (0, state_1.transitionSlot)(state, slot.id, 'running', patchState, now)
|
|
@@ -891,6 +955,9 @@ function runValidate(deps, config, dispatch, batchId, now, result) {
|
|
|
891
955
|
}
|
|
892
956
|
claimAndSpawn(deps, config, batchId, 'fixing', now, (s, slot) => {
|
|
893
957
|
const logFile = (0, dispatch_1.batchFixLogPath)(deps.store.runsDir, batchId, offender);
|
|
958
|
+
// #629: fences `handleDeadDispatchApiError`'s classification to this
|
|
959
|
+
// dispatch's slice — see `spawnMember`'s identical comment.
|
|
960
|
+
const logOffset = (0, dispatch_1.fileSizeOrZero)(logFile);
|
|
894
961
|
let pid;
|
|
895
962
|
try {
|
|
896
963
|
pid = deps.spawnDeps.spawn(fixDispatch.command, fixDispatch.prompt, logFile);
|
|
@@ -913,6 +980,7 @@ function runValidate(deps, config, dispatch, batchId, now, result) {
|
|
|
913
980
|
pid_start: deps.spawnDeps.processStart(pid),
|
|
914
981
|
phase: 'fixing',
|
|
915
982
|
last_progress_at: now.toISOString(),
|
|
983
|
+
log_offset_at_spawn: logOffset,
|
|
916
984
|
};
|
|
917
985
|
const next = slot.status === 'assigned' ? (0, state_1.transitionSlot)(s, slot.id, 'running', patch, now) : s;
|
|
918
986
|
result.spawned.push(unit(batchId));
|
|
@@ -1103,6 +1171,14 @@ function runIncrementalGate(deps, config, dispatch, batchId, batch, memberIssue,
|
|
|
1103
1171
|
// Journalled per member: a gate that silently does not run is its own
|
|
1104
1172
|
// trap (#594's shape — absence reading as a verdict). Silence must never
|
|
1105
1173
|
// be mistaken for a pass.
|
|
1174
|
+
//
|
|
1175
|
+
// #632: confirmed this fires once per member, not once per tick.
|
|
1176
|
+
// `runIncrementalGate` is called only from the `isMemberComplete` branch
|
|
1177
|
+
// of `reconcileMemberSlot`, and both of that branch's callees
|
|
1178
|
+
// (`completeMemberGate`, `evictMemberAndContinue`) advance
|
|
1179
|
+
// `batch.executing_member` before returning — so the very next tick
|
|
1180
|
+
// reads a DIFFERENT `memberIssue` from `batch.members[executing_member -
|
|
1181
|
+
// 1]`, and this gate never runs twice against the same member.
|
|
1106
1182
|
journalEvent(deps, 'gate-skipped', unit(batchId), {
|
|
1107
1183
|
issue: memberIssue,
|
|
1108
1184
|
reason: `gate-skipped:${skipped.id}`,
|
|
@@ -1180,7 +1256,13 @@ function completeMemberGate(deps, config, dispatch, batchId, batch, memberIssue,
|
|
|
1180
1256
|
// invariant exists to prevent).
|
|
1181
1257
|
const ranges = (0, attribution_1.memberRanges)(boundaryCommits(deps, batch));
|
|
1182
1258
|
deps.store.withLock((s) => {
|
|
1183
|
-
|
|
1259
|
+
// #629: a verified member completion is proof dispatch is healthy —
|
|
1260
|
+
// reset the confirmed-failure streak, mirroring the per-issue path's
|
|
1261
|
+
// `resetDispatchApiErrorStreak` on its own verified-complete branch, or a
|
|
1262
|
+
// healthy member sandwiched between two unrelated api-error hits (this
|
|
1263
|
+
// one and a later one on the tail/report agent) would be invisible to
|
|
1264
|
+
// the streak and could still tip it into a false-positive pause.
|
|
1265
|
+
let n = (0, dispatch_health_1.resetDispatchApiErrorStreak)(releaseSlot(s, batchId, now));
|
|
1184
1266
|
n = (0, state_1.patchBatch)(n, batchId, { ranges }, now);
|
|
1185
1267
|
n = advanceMemberToValidated(n, memberIssue, now);
|
|
1186
1268
|
return { state: n, result: undefined };
|
|
@@ -1316,7 +1398,15 @@ function recordMemberRunLog(deps, dispatch, state, batchId, batch, memberIssue,
|
|
|
1316
1398
|
const tier = (0, state_1.findEntry)(state, memberIssue)?.tier ?? 'mid';
|
|
1317
1399
|
const { cmd, model } = (0, dispatch_1.resolveTierSpawn)(dispatch, tier, memberIssue);
|
|
1318
1400
|
const logFile = (0, dispatch_1.batchMemberLogPath)(deps.store.runsDir, batchId, batch.executing_member, memberIssue);
|
|
1319
|
-
|
|
1401
|
+
// #629: fenced to THIS dispatch's slice (`log_offset_at_spawn`, stamped by
|
|
1402
|
+
// `spawnMember`/`spawnMemberContinuation` at spawn time) rather than a
|
|
1403
|
+
// fixed byte-0 read. Before #629 a member log never outlived one attempt
|
|
1404
|
+
// (eviction was the only exit besides completion), so byte-0 was safe; a
|
|
1405
|
+
// confirmed-API-error hold now retries the SAME member in place, appending
|
|
1406
|
+
// a SECOND attempt to the same file — an unfenced read would double-count
|
|
1407
|
+
// the first attempt's tokens into the second's `runs.jsonl` entry, exactly
|
|
1408
|
+
// the #524 divergence this telemetry system exists to prevent.
|
|
1409
|
+
const logContent = (0, run_log_1.readDispatchLog)(logFile, slot.log_offset_at_spawn ?? 0);
|
|
1320
1410
|
const runEntry = (0, run_log_1.buildSchedRunLogEntry)({
|
|
1321
1411
|
unit: `issue:${memberIssue}`,
|
|
1322
1412
|
role: 'batch-member',
|
|
@@ -1461,6 +1551,16 @@ function reconcileMemberSlot(deps, config, dispatch, batchId, slot, now, result)
|
|
|
1461
1551
|
return;
|
|
1462
1552
|
}
|
|
1463
1553
|
const blockedNow = (0, groundtruth_1.isMemberBlocked)(milestone, slot.spawned_at);
|
|
1554
|
+
if (dead &&
|
|
1555
|
+
!blockedNow &&
|
|
1556
|
+
handleDeadDispatchApiError(deps, batchId, (0, dispatch_1.batchMemberLogPath)(deps.store.runsDir, batchId, batch.executing_member, memberIssue), slot.log_offset_at_spawn ?? 0, now)) {
|
|
1557
|
+
// #629: a confirmed provider API error is not a real member failure —
|
|
1558
|
+
// evicting the member for an account-wide spend wall would silently
|
|
1559
|
+
// throw away real, correct work. `runBatchTick`'s "same wedge" retry
|
|
1560
|
+
// (`spawnMemberContinuation`, now pause-gated) retries the SAME member in
|
|
1561
|
+
// place, keeping its position.
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1464
1564
|
if (blockedNow || dead) {
|
|
1465
1565
|
// #605: only a milestone THIS dispatch posted may name the reason. A dead
|
|
1466
1566
|
// agent whose issue carries only a stale `blocked` milestone is an
|
|
@@ -1630,6 +1730,19 @@ function reconcileFixSlot(deps, config, batchId, slot, now, result) {
|
|
|
1630
1730
|
const offenderRecord = [...batch.fix_attempts].reverse().find((a) => a.outcome === 'dispatched');
|
|
1631
1731
|
if (!offenderRecord)
|
|
1632
1732
|
return;
|
|
1733
|
+
// #629: a confirmed provider API error means the fix agent never got a
|
|
1734
|
+
// real chance to fix anything — record it against the shared pause (AC1/
|
|
1735
|
+
// AC2/AC3) so an operator reads "spend limit", not a fix that failed on
|
|
1736
|
+
// its merits. `release: false`: this function releases the slot itself a
|
|
1737
|
+
// few lines below, after also deciding whether to resolve the fix attempt
|
|
1738
|
+
// — one release, not two. The one-shot fix-attempt model has no existing
|
|
1739
|
+
// "retry the same attempt" primitive (unlike tail/member/report, which the
|
|
1740
|
+
// tick loop itself retries), so the suite still runs and resolves the
|
|
1741
|
+
// attempt below exactly as today: the member's one fix attempt is consumed
|
|
1742
|
+
// and resolves per the suite's real result. An operator who wants it back
|
|
1743
|
+
// must re-trigger the fix after `sched resume` — see
|
|
1744
|
+
// packages/sched/README.md's dispatch-health section.
|
|
1745
|
+
handleDeadDispatchApiError(deps, batchId, (0, dispatch_1.batchFixLogPath)(deps.store.runsDir, batchId, offenderRecord.issue), slot.log_offset_at_spawn ?? 0, now, { release: false });
|
|
1633
1746
|
deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
|
|
1634
1747
|
const suite = safeSuite(deps, batchId, batch.worktree);
|
|
1635
1748
|
const rDeps = recoveryDeps(deps, config, batch, now);
|
|
@@ -1672,7 +1785,10 @@ function reconcileTailSlot(deps, batchId, slot, now, result) {
|
|
|
1672
1785
|
const pr = (0, groundtruth_1.prOfMilestone)(milestone);
|
|
1673
1786
|
journalEvent(deps, 'pr-parked', unit(batchId), { pr: pr ?? undefined });
|
|
1674
1787
|
deps.store.withLock((s) => {
|
|
1675
|
-
|
|
1788
|
+
// #629: a verified park is proof dispatch is healthy — reset the
|
|
1789
|
+
// confirmed-failure streak, mirroring the per-issue path's own parked
|
|
1790
|
+
// branch.
|
|
1791
|
+
let n = (0, dispatch_health_1.resetDispatchApiErrorStreak)(releaseSlot(s, batchId, now));
|
|
1676
1792
|
const b = (0, state_1.findBatch)(n, batchId);
|
|
1677
1793
|
if (!b)
|
|
1678
1794
|
return { state: n, result: undefined };
|
|
@@ -1684,6 +1800,13 @@ function reconcileTailSlot(deps, batchId, slot, now, result) {
|
|
|
1684
1800
|
return;
|
|
1685
1801
|
}
|
|
1686
1802
|
if (dead) {
|
|
1803
|
+
// #629: a confirmed provider API error is not a real tail-agent failure —
|
|
1804
|
+
// record it against the shared pause and let `runBatchTick`'s "same
|
|
1805
|
+
// wedge" retry (`spawnTailAgent`, now pause-gated) respawn it, instead of
|
|
1806
|
+
// journaling a failure that will never stop respawning on its own.
|
|
1807
|
+
if (handleDeadDispatchApiError(deps, batchId, (0, dispatch_1.batchTailLogPath)(deps.store.runsDir, batchId), slot.log_offset_at_spawn ?? 0, now)) {
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1687
1810
|
deps.journal.append((0, journal_1.unitEvent)('unit-failed', unit(batchId), { reason: 'tail-agent-exited-unverified' }), now);
|
|
1688
1811
|
deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
|
|
1689
1812
|
result.failed.push(unit(batchId));
|
|
@@ -1701,7 +1824,10 @@ function reconcileReportSlot(deps, batchId, slot, now, result) {
|
|
|
1701
1824
|
if ((0, groundtruth_1.isBatchPhaseDone)(milestone, 'batch-report', slot.spawned_at)) {
|
|
1702
1825
|
deps.journal.append((0, journal_1.unitEvent)('external-advance', unit(batchId), { detail: 'batch report done' }), now);
|
|
1703
1826
|
deps.store.withLock((s) => {
|
|
1704
|
-
|
|
1827
|
+
// #629: a verified report completion is proof dispatch is healthy —
|
|
1828
|
+
// reset the confirmed-failure streak, mirroring the per-issue path's
|
|
1829
|
+
// own verified-complete branch.
|
|
1830
|
+
let n = (0, dispatch_health_1.resetDispatchApiErrorStreak)(releaseSlot(s, batchId, now));
|
|
1705
1831
|
const b = (0, state_1.findBatch)(n, batchId);
|
|
1706
1832
|
if (!b || b.status !== 'deployed')
|
|
1707
1833
|
return { state: n, result: undefined };
|
|
@@ -1714,16 +1840,46 @@ function reconcileReportSlot(deps, batchId, slot, now, result) {
|
|
|
1714
1840
|
return;
|
|
1715
1841
|
}
|
|
1716
1842
|
if (dead) {
|
|
1843
|
+
// #629: same reasoning as `reconcileTailSlot` — a confirmed API error is
|
|
1844
|
+
// not a real report-agent failure; record it and let the `deployed`
|
|
1845
|
+
// branch's `spawnReportAgent` retry (now pause-gated) instead of
|
|
1846
|
+
// journaling `report-failed` for a wall that will keep respawning anyway.
|
|
1847
|
+
if (handleDeadDispatchApiError(deps, batchId, (0, dispatch_1.batchReportLogPath)(deps.store.runsDir, batchId), slot.log_offset_at_spawn ?? 0, now)) {
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1717
1850
|
deps.journal.append((0, journal_1.unitEvent)('report-failed', unit(batchId), { detail: 'unverified exit' }), now);
|
|
1718
1851
|
deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
|
|
1719
1852
|
}
|
|
1720
1853
|
}
|
|
1721
1854
|
// --- PR watch for `awaiting-merge` batches (mirrors `pollParkedPrs`/`reconcileParked`) ---
|
|
1855
|
+
/**
|
|
1856
|
+
* Reset a batch's `pr-watch-failed` dedup marker (#630/#633). A no-op when no
|
|
1857
|
+
* streak is recorded, so it is safe to call on every non-`awaiting-merge`
|
|
1858
|
+
* batch every tick.
|
|
1859
|
+
*/
|
|
1860
|
+
function clearPrWatchFailed(deps, batch, now) {
|
|
1861
|
+
if (batch.pr_watch_failed_reason === null)
|
|
1862
|
+
return;
|
|
1863
|
+
deps.store.withLock((s) => ({
|
|
1864
|
+
state: (0, state_1.patchBatch)(s, batch.id, state_1.CLEARED_PR_WATCH_FIELDS, now, false),
|
|
1865
|
+
result: undefined,
|
|
1866
|
+
}));
|
|
1867
|
+
}
|
|
1722
1868
|
function reconcilePrWatch(deps, now, result) {
|
|
1723
1869
|
const state = deps.store.load();
|
|
1724
1870
|
for (const batch of state.batches) {
|
|
1725
|
-
if (batch.status !== 'awaiting-merge')
|
|
1871
|
+
if (batch.status !== 'awaiting-merge') {
|
|
1872
|
+
// #633: the marker is scoped to ONE awaiting-merge stretch. Merged,
|
|
1873
|
+
// blocked and dissolved batches all leave through this guard, and
|
|
1874
|
+
// `BATCH_TRANSITIONS` lets a batch come back to `awaiting-merge` on the
|
|
1875
|
+
// same id (`awaiting-merge → rebasing → re-validating → shipping →
|
|
1876
|
+
// awaiting-merge`, `recovery.ts`'s `handlePrConflict`). Leaving a live
|
|
1877
|
+
// reason behind would make the SAME reason recurring after a real
|
|
1878
|
+
// rebase-and-reship read as an unchanged streak and stay silent —
|
|
1879
|
+
// contradicting `BatchEntry.pr_watch_failed_reason`'s own contract.
|
|
1880
|
+
clearPrWatchFailed(deps, batch, now);
|
|
1726
1881
|
continue;
|
|
1882
|
+
}
|
|
1727
1883
|
const pr = batch.pr;
|
|
1728
1884
|
if (pr === null)
|
|
1729
1885
|
continue;
|
|
@@ -1756,14 +1912,52 @@ function reconcilePrWatch(deps, now, result) {
|
|
|
1756
1912
|
continue;
|
|
1757
1913
|
}
|
|
1758
1914
|
if (truth.blocked || truth.mergeable === 'CONFLICTING') {
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1915
|
+
const reason = truth.blocked ? 'auto-merge-blocked' : 'pr-conflicting';
|
|
1916
|
+
const isNewStreak = batch.pr_watch_failed_reason !== reason;
|
|
1917
|
+
const ticks = isNewStreak ? 1 : batch.pr_watch_failed_ticks + 1;
|
|
1918
|
+
const since = isNewStreak ? now.toISOString() : batch.pr_watch_failed_since;
|
|
1919
|
+
// #630: journal on the streak's first tick, then again only every
|
|
1920
|
+
// `JOURNAL_DEDUP_REANNOUNCE_TICKS` — never every tick — mirrors #610's
|
|
1921
|
+
// `stale_milestone_ignored_for` dedup for the "distinct condition"
|
|
1922
|
+
// half, while still giving a still-blocked streak a later line an
|
|
1923
|
+
// operator can read "still blocked, Nth check" off (a single onset
|
|
1924
|
+
// entry never updates, so "after 40 minutes" would otherwise never be
|
|
1925
|
+
// legible from any journal line at all). `at` is the engine's OWN
|
|
1926
|
+
// decision time, never copied from `truth` (which carries no timestamp
|
|
1927
|
+
// of its own); `since` is the streak's onset, and is what makes the
|
|
1928
|
+
// duration legible — `ticks_persisted` is a tick count against an
|
|
1929
|
+
// operator-tunable `reconcile_interval_ms`, not a fixed wall-clock.
|
|
1930
|
+
if (isNewStreak || ticks % types_1.JOURNAL_DEDUP_REANNOUNCE_TICKS === 0) {
|
|
1931
|
+
journalEvent(deps, 'pr-watch-failed', unit(batch.id), {
|
|
1932
|
+
reason,
|
|
1933
|
+
pr,
|
|
1934
|
+
at: now.toISOString(),
|
|
1935
|
+
since,
|
|
1936
|
+
ticks_persisted: ticks,
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
deps.store.withLock((s) => ({
|
|
1940
|
+
// `touchUpdatedAt: false` — a silent tick that only advances the
|
|
1941
|
+
// streak counter is bookkeeping, not activity (same rule as
|
|
1942
|
+
// `patchEntry`'s dedup writes).
|
|
1943
|
+
state: (0, state_1.patchBatch)(s, batch.id, {
|
|
1944
|
+
pr_watch_failed_reason: reason,
|
|
1945
|
+
pr_watch_failed_since: since,
|
|
1946
|
+
pr_watch_failed_ticks: ticks,
|
|
1947
|
+
}, now, false),
|
|
1948
|
+
result: undefined,
|
|
1949
|
+
}));
|
|
1763
1950
|
// #472's own rebase-and-reship path (RFC F.9) is a documented follow-up
|
|
1764
1951
|
// for the batch PR-conflict rail; for now the batch stays parked and
|
|
1765
1952
|
// the block is visible via the journal + `sched status`.
|
|
1766
1953
|
}
|
|
1954
|
+
else {
|
|
1955
|
+
// #630: the condition cleared — reset the marker so a future
|
|
1956
|
+
// re-occurrence (even the SAME reason) journals its own fresh entry.
|
|
1957
|
+
// This is de-duplication, not suppression: only an UNCHANGED streak
|
|
1958
|
+
// stays silent.
|
|
1959
|
+
clearPrWatchFailed(deps, batch, now);
|
|
1960
|
+
}
|
|
1767
1961
|
}
|
|
1768
1962
|
}
|
|
1769
1963
|
/**
|
|
@@ -1825,10 +2019,23 @@ function runBatchTick(deps, config, dispatch) {
|
|
|
1825
2019
|
// only the per-batch `slotFor`/`status` re-check inside the loop reads
|
|
1826
2020
|
// fresh state (one `store.load()` per iteration, in case an earlier claim
|
|
1827
2021
|
// in this same pass changed things).
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
2022
|
+
// #629: dispatch-health pause (#505, and now the confirmed-api-error
|
|
2023
|
+
// streak) stops NEW per-issue assignments via `computeAssignments` — but
|
|
2024
|
+
// nothing in this function ever read `paused` before #629, so claiming a
|
|
2025
|
+
// `ready` batch here (a provider dispatch, `batch-setup`, the most literal
|
|
2026
|
+
// "new assignment" there is) or a respawn wedge in the second loop below
|
|
2027
|
+
// would keep happening every tick regardless, making the pause cosmetic
|
|
2028
|
+
// for the exact incident (a batch tail respawning against a spend wall)
|
|
2029
|
+
// that motivated it. Read once — every use below in this same tick must
|
|
2030
|
+
// agree, and re-reading per iteration only risks a pause landing mid-loop
|
|
2031
|
+
// and gating some batches but not others in one pass.
|
|
2032
|
+
const paused = deps.store.load().paused;
|
|
2033
|
+
const readyOrder = paused
|
|
2034
|
+
? []
|
|
2035
|
+
: [...deps.store.load().batches]
|
|
2036
|
+
.filter((b) => b.status === 'ready')
|
|
2037
|
+
.sort((a, b) => (0, readiness_1.compareByPriority)((0, readiness_1.batchRank)(a), (0, readiness_1.batchRank)(b)))
|
|
2038
|
+
.map((b) => b.id);
|
|
1832
2039
|
for (const batchId of readyOrder) {
|
|
1833
2040
|
const state = deps.store.load();
|
|
1834
2041
|
const batch = (0, state_1.findBatch)(state, batchId);
|
|
@@ -1877,9 +2084,16 @@ function runBatchTick(deps, config, dispatch) {
|
|
|
1877
2084
|
if (slot)
|
|
1878
2085
|
continue; // live but neither running/assigned (e.g. mid-verify) — next tick
|
|
1879
2086
|
if (batch.status === 'validating') {
|
|
2087
|
+
// A local suite run, not a provider dispatch — unaffected by `paused`.
|
|
1880
2088
|
runValidate(deps, config, dispatch, batch.id, now, result);
|
|
2089
|
+
continue;
|
|
1881
2090
|
}
|
|
1882
|
-
|
|
2091
|
+
// #629: every branch below spawns a provider agent — hold the wedge
|
|
2092
|
+
// until `sched resume` (see the top of this function for why `paused`
|
|
2093
|
+
// is read once, up front, rather than per iteration).
|
|
2094
|
+
if (paused)
|
|
2095
|
+
continue;
|
|
2096
|
+
if (batch.status === 'deployed') {
|
|
1883
2097
|
spawnReportAgent(deps, config, dispatch, batch.id, now, result);
|
|
1884
2098
|
}
|
|
1885
2099
|
else if (batch.status === 'executing') {
|