@ai-dossier/sched 0.23.5 → 0.25.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 +103 -11
- package/dist/batch-dispatch.d.ts.map +1 -1
- package/dist/batch-dispatch.js +154 -9
- 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 +128 -56
- package/dist/engine.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 +4 -1
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +30 -1
- 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 +47 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +4 -1
- 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
|
|
@@ -261,6 +288,55 @@ This applies to `issue:<n>` unit dispatch (`dispatchAssignments`). `batch:<id>`
|
|
|
261
288
|
through a separate pass with its own claim/reconcile logic — see
|
|
262
289
|
[Batch dispatch (#523)](#batch-dispatch-523) below.
|
|
263
290
|
|
|
291
|
+
### Supervised deployment (#679)
|
|
292
|
+
|
|
293
|
+
Two supported shapes, one rule: **dispatched agents must outlive the tick or engine that
|
|
294
|
+
spawned them.** Agents are spawned detached and unref'd precisely so they survive a sched
|
|
295
|
+
crash or restart (restart reconciles by pid) — any supervisor that tears down the process
|
|
296
|
+
tree on exit defeats that by construction. The symptom when it happens is cruelly
|
|
297
|
+
misleading: agents die with zero usage events, get classified `unverified-exit`, and walk
|
|
298
|
+
up the escalation ladder to `unverified-exit-at-strongest-tier` — a failure reason that
|
|
299
|
+
reads as a model-capability verdict when the strongest tier was never given a chance to
|
|
300
|
+
run.
|
|
301
|
+
|
|
302
|
+
**Long-running engine** — `Type=simple` user service:
|
|
303
|
+
|
|
304
|
+
```ini
|
|
305
|
+
# ~/.config/systemd/user/dossier-sched-<project>.service
|
|
306
|
+
[Unit]
|
|
307
|
+
Description=dossier sched engine (<project>)
|
|
308
|
+
|
|
309
|
+
[Service]
|
|
310
|
+
Type=simple
|
|
311
|
+
ExecStart=%h/.local/bin/ai-dossier sched start --project <project>
|
|
312
|
+
Restart=on-failure
|
|
313
|
+
# Kill ONLY the engine process on stop/restart. The default (control-group)
|
|
314
|
+
# kills every dispatched agent with it — the engine's own restart would then
|
|
315
|
+
# read those deaths as failed runs and escalate the units (see above).
|
|
316
|
+
KillMode=process
|
|
317
|
+
|
|
318
|
+
[Install]
|
|
319
|
+
WantedBy=default.target
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
**One-shot tick** — cron or a `Type=oneshot` unit running `sched start --once` needs the
|
|
323
|
+
same rule: the tick exits immediately after dispatching, so under the default
|
|
324
|
+
`KillMode=control-group` the control group is torn down while every freshly spawned agent
|
|
325
|
+
is still starting — EVERY tick kills EVERY agent it dispatched. `KillMode=process` on the
|
|
326
|
+
unit (or spawning each agent into its own transient scope,
|
|
327
|
+
`systemd-run --user --scope --collect …`) is required, not cosmetic.
|
|
328
|
+
|
|
329
|
+
Verify a deployment survives its own ticks: dispatch one unit, let the tick or engine
|
|
330
|
+
process that spawned it exit, and confirm the agent process is still alive 60 seconds
|
|
331
|
+
later (`ps -p <agent-pid>`), with `run-log-no-usage` absent from the project's
|
|
332
|
+
`events.jsonl` for that unit.
|
|
333
|
+
|
|
334
|
+
Note the engine's own log (redirected stdout) carries one `✓ [ts] …` line per tick even
|
|
335
|
+
when a tick does nothing (`nothing to do`) — a log that stops growing while
|
|
336
|
+
`systemctl status` still says `active (running)` means the loop died silently, which is
|
|
337
|
+
the failure mode #679 fixed: the engine exited cleanly after its first tick because its
|
|
338
|
+
inter-tick sleep handles were unref'd.
|
|
339
|
+
|
|
264
340
|
### Zombie-run fencing (#504)
|
|
265
341
|
|
|
266
342
|
The ladder redispatches the SAME run, so a takeover inherits the run id and its milestone
|
|
@@ -616,6 +692,14 @@ with `touchUpdatedAt: false` — `QueueEntry.updated_at` is load-bearing for
|
|
|
616
692
|
`isStaleFailedPark`'s 7-day window, `status.ts`'s "parked since" and `readiness.ts`'s
|
|
617
693
|
tiebreak, and a silent dedup tick must not reset them. `patchBatch` takes the same flag
|
|
618
694
|
for the same reason on the batch rail.
|
|
695
|
+
Schema 1.13.0 (#629): `SchedState` gains `consecutive_dispatch_api_errors` (number) and
|
|
696
|
+
`dispatch_pause_reset_at` (string or null) — the confirmed-dispatch-failure streak and
|
|
697
|
+
the provider's own reset time. Unlike the #505 suspect-dispatch pair these are NOT a
|
|
698
|
+
single fact: a provider that reports no reset time leaves `dispatch_pause_reset_at`
|
|
699
|
+
null while the counter is nonzero (`validateState` only enforces the reverse — a reset
|
|
700
|
+
time can never outlive a streak that has already cleared to zero). 1.12.0 states
|
|
701
|
+
migrate on load, backfilling `0`/`null` — no confirmed dispatch failures were ever
|
|
702
|
+
tracked under them, so those values are exact, not a guess.
|
|
619
703
|
|
|
620
704
|
New journal events: `batch-setup-done`, `batch-setup-failed`, `member-advanced`,
|
|
621
705
|
`batch-warmup-done`, `batch-warmup-failed` (#561 — the cold-path warm step only; a pool
|
|
@@ -825,9 +909,10 @@ ladder is exhausted, the terminal `unit-failed` (`agent-exited-unverified` /
|
|
|
825
909
|
the dead-pid detection rail and the stall kill RECORD a fresh log slice (a `runs.jsonl`
|
|
826
910
|
entry) in the same tick — `recordDispatchRunLog` is once-per-dispatch and refuses to
|
|
827
911
|
append a second entry over the same slice. Since #620 a slot already `exited`/`verifying`
|
|
828
|
-
when reconciled again still ATTRIBUTES one: `
|
|
829
|
-
static slice for the tool
|
|
830
|
-
|
|
912
|
+
when reconciled again still ATTRIBUTES one: `readDispatchSignalsForSlot` re-parses the
|
|
913
|
+
same static slice (since #629, for the last-tool-call AND API-error-classification
|
|
914
|
+
signals together — see the dispatch-health section above), writing nothing, so it
|
|
915
|
+
carries no exactly-once constraint. That matters because the verify decision lands on a LATER tick than the
|
|
831
916
|
dead-pid detection whenever ground truth was unreachable in between — previously the
|
|
832
917
|
tool name was simply lost on exactly the runs hardest to diagnose. The read is deferred
|
|
833
918
|
behind a thunk so only the tick that reaches the unverified-exit decision pays for it,
|
|
@@ -902,6 +987,7 @@ after-the-fact recovery, not a missing-data bug.
|
|
|
902
987
|
- **Schema**: state/config files from #460 (schema 1.0.0), #464 (1.1.0), #468 (1.2.0),
|
|
903
988
|
#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)
|
|
904
989
|
load and migrate to 1.14.0 automatically (slot `branch`/`last_head`/`pid_start`, slot `role` (inferred from the
|
|
990
|
+
load and migrate to 1.13.0 automatically (slot `branch`/`last_head`/`pid_start`, slot `role` (inferred from the
|
|
905
991
|
unit's queue entry, with the persisted `phase` as a fallback — #500), entry
|
|
906
992
|
`pr`/`cleanup`/`failure_evidence`, batch `anchor`/`branch`/`run_id`/`eviction_groups`/
|
|
907
993
|
`evictions`/`fix_attempts`/`rebase_attempts`, state-level `last_pr_poll_at` backfill to
|
|
@@ -909,16 +995,22 @@ after-the-fact recovery, not a missing-data bug.
|
|
|
909
995
|
backfill to `0`/`null` — #505, slot `gen`/`fenced_at` backfill to `0`/`null` — #504, and
|
|
910
996
|
slot `spawned_at`/`log_offset_at_spawn` backfill to `null`/`null` — #524,
|
|
911
997
|
state-level `last_label_poll_at` backfill to `null` — #544, entry `priority` and batch
|
|
912
|
-
`member_gates`/`blocked_reason` — #565/#583,
|
|
913
|
-
`stale_milestone_ignored_for` backfill to `null` — #610
|
|
998
|
+
`member_gates`/`blocked_reason` — #565/#583, slot
|
|
999
|
+
`stale_milestone_ignored_for` backfill to `null` — #610, and state-level
|
|
1000
|
+
`consecutive_dispatch_api_errors`/`dispatch_pause_reset_at` backfill to `0`/`null` —
|
|
1001
|
+
#629).
|
|
914
1002
|
- **`max_slots`** bounds live units (`assigned | running | recovering`); dependency
|
|
915
1003
|
edges gate readiness — an issue with an unmerged dependency, and a batch behind an
|
|
916
1004
|
unmerged batch, are never runnable.
|
|
917
|
-
- **Pause** stops new assignments only (including report-agent dispatch — #505)
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
(
|
|
921
|
-
|
|
1005
|
+
- **Pause** stops new assignments only (including report-agent dispatch — #505) and,
|
|
1006
|
+
since #629, every batch respawn wedge in `runBatchTick` — tail, member continuation,
|
|
1007
|
+
fix, report, and the `ready` → `claimAndSetup` claim, which previously ignored `paused`
|
|
1008
|
+
entirely (`runValidate` is excepted: a local suite run, not a dispatch) — and an
|
|
1009
|
+
unescalated per-issue redispatch (`enterRecovery`/`reconcileRecovering`, held in
|
|
1010
|
+
`recovering` until resumed). Abandon routes through the typed failure rails
|
|
1011
|
+
(`evicted → requeued{full}` for batch members — nothing green is discarded). A pause
|
|
1012
|
+
can be manual (`sched pause`) or automatic (dispatch-health, #505/#629 above); `sched
|
|
1013
|
+
resume` clears the flag and both dispatch-health streaks.
|
|
922
1014
|
|
|
923
1015
|
## Hard-block labels are re-read every tick (#544)
|
|
924
1016
|
|
|
@@ -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));
|
|
@@ -1188,7 +1256,13 @@ function completeMemberGate(deps, config, dispatch, batchId, batch, memberIssue,
|
|
|
1188
1256
|
// invariant exists to prevent).
|
|
1189
1257
|
const ranges = (0, attribution_1.memberRanges)(boundaryCommits(deps, batch));
|
|
1190
1258
|
deps.store.withLock((s) => {
|
|
1191
|
-
|
|
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));
|
|
1192
1266
|
n = (0, state_1.patchBatch)(n, batchId, { ranges }, now);
|
|
1193
1267
|
n = advanceMemberToValidated(n, memberIssue, now);
|
|
1194
1268
|
return { state: n, result: undefined };
|
|
@@ -1324,7 +1398,15 @@ function recordMemberRunLog(deps, dispatch, state, batchId, batch, memberIssue,
|
|
|
1324
1398
|
const tier = (0, state_1.findEntry)(state, memberIssue)?.tier ?? 'mid';
|
|
1325
1399
|
const { cmd, model } = (0, dispatch_1.resolveTierSpawn)(dispatch, tier, memberIssue);
|
|
1326
1400
|
const logFile = (0, dispatch_1.batchMemberLogPath)(deps.store.runsDir, batchId, batch.executing_member, memberIssue);
|
|
1327
|
-
|
|
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);
|
|
1328
1410
|
const runEntry = (0, run_log_1.buildSchedRunLogEntry)({
|
|
1329
1411
|
unit: `issue:${memberIssue}`,
|
|
1330
1412
|
role: 'batch-member',
|
|
@@ -1469,6 +1551,16 @@ function reconcileMemberSlot(deps, config, dispatch, batchId, slot, now, result)
|
|
|
1469
1551
|
return;
|
|
1470
1552
|
}
|
|
1471
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
|
+
}
|
|
1472
1564
|
if (blockedNow || dead) {
|
|
1473
1565
|
// #605: only a milestone THIS dispatch posted may name the reason. A dead
|
|
1474
1566
|
// agent whose issue carries only a stale `blocked` milestone is an
|
|
@@ -1638,6 +1730,19 @@ function reconcileFixSlot(deps, config, batchId, slot, now, result) {
|
|
|
1638
1730
|
const offenderRecord = [...batch.fix_attempts].reverse().find((a) => a.outcome === 'dispatched');
|
|
1639
1731
|
if (!offenderRecord)
|
|
1640
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 });
|
|
1641
1746
|
deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
|
|
1642
1747
|
const suite = safeSuite(deps, batchId, batch.worktree);
|
|
1643
1748
|
const rDeps = recoveryDeps(deps, config, batch, now);
|
|
@@ -1680,7 +1785,10 @@ function reconcileTailSlot(deps, batchId, slot, now, result) {
|
|
|
1680
1785
|
const pr = (0, groundtruth_1.prOfMilestone)(milestone);
|
|
1681
1786
|
journalEvent(deps, 'pr-parked', unit(batchId), { pr: pr ?? undefined });
|
|
1682
1787
|
deps.store.withLock((s) => {
|
|
1683
|
-
|
|
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));
|
|
1684
1792
|
const b = (0, state_1.findBatch)(n, batchId);
|
|
1685
1793
|
if (!b)
|
|
1686
1794
|
return { state: n, result: undefined };
|
|
@@ -1692,6 +1800,13 @@ function reconcileTailSlot(deps, batchId, slot, now, result) {
|
|
|
1692
1800
|
return;
|
|
1693
1801
|
}
|
|
1694
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
|
+
}
|
|
1695
1810
|
deps.journal.append((0, journal_1.unitEvent)('unit-failed', unit(batchId), { reason: 'tail-agent-exited-unverified' }), now);
|
|
1696
1811
|
deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
|
|
1697
1812
|
result.failed.push(unit(batchId));
|
|
@@ -1709,7 +1824,10 @@ function reconcileReportSlot(deps, batchId, slot, now, result) {
|
|
|
1709
1824
|
if ((0, groundtruth_1.isBatchPhaseDone)(milestone, 'batch-report', slot.spawned_at)) {
|
|
1710
1825
|
deps.journal.append((0, journal_1.unitEvent)('external-advance', unit(batchId), { detail: 'batch report done' }), now);
|
|
1711
1826
|
deps.store.withLock((s) => {
|
|
1712
|
-
|
|
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));
|
|
1713
1831
|
const b = (0, state_1.findBatch)(n, batchId);
|
|
1714
1832
|
if (!b || b.status !== 'deployed')
|
|
1715
1833
|
return { state: n, result: undefined };
|
|
@@ -1722,6 +1840,13 @@ function reconcileReportSlot(deps, batchId, slot, now, result) {
|
|
|
1722
1840
|
return;
|
|
1723
1841
|
}
|
|
1724
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
|
+
}
|
|
1725
1850
|
deps.journal.append((0, journal_1.unitEvent)('report-failed', unit(batchId), { detail: 'unverified exit' }), now);
|
|
1726
1851
|
deps.store.withLock((s) => ({ state: releaseSlot(s, batchId, now), result: undefined }));
|
|
1727
1852
|
}
|
|
@@ -1894,10 +2019,23 @@ function runBatchTick(deps, config, dispatch) {
|
|
|
1894
2019
|
// only the per-batch `slotFor`/`status` re-check inside the loop reads
|
|
1895
2020
|
// fresh state (one `store.load()` per iteration, in case an earlier claim
|
|
1896
2021
|
// in this same pass changed things).
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
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);
|
|
1901
2039
|
for (const batchId of readyOrder) {
|
|
1902
2040
|
const state = deps.store.load();
|
|
1903
2041
|
const batch = (0, state_1.findBatch)(state, batchId);
|
|
@@ -1946,9 +2084,16 @@ function runBatchTick(deps, config, dispatch) {
|
|
|
1946
2084
|
if (slot)
|
|
1947
2085
|
continue; // live but neither running/assigned (e.g. mid-verify) — next tick
|
|
1948
2086
|
if (batch.status === 'validating') {
|
|
2087
|
+
// A local suite run, not a provider dispatch — unaffected by `paused`.
|
|
1949
2088
|
runValidate(deps, config, dispatch, batch.id, now, result);
|
|
2089
|
+
continue;
|
|
1950
2090
|
}
|
|
1951
|
-
|
|
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') {
|
|
1952
2097
|
spawnReportAgent(deps, config, dispatch, batch.id, now, result);
|
|
1953
2098
|
}
|
|
1954
2099
|
else if (batch.status === 'executing') {
|