omp-conductor 0.3.7 → 0.3.11
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 +71 -24
- package/package.json +1 -1
- package/src/briefs/orchestrator.md +16 -7
- package/src/briefs/worker.md +1 -1
- package/src/cli.ts +28 -14
- package/src/config.ts +16 -5
- package/src/daemon.ts +147 -48
- package/src/escalate.ts +26 -4
- package/src/lifecycle.ts +203 -12
- package/src/omp.ts +5 -0
- package/src/orchestrator-tick.ts +42 -2
- package/src/plugin.ts +32 -2
- package/src/setup.ts +3 -1
- package/src/types.ts +5 -2
- package/src/worker.ts +37 -5
- package/src/worktree.ts +109 -12
package/README.md
CHANGED
|
@@ -355,8 +355,11 @@ a yes over Telegram, which is the same protocol it uses for any other amendment.
|
|
|
355
355
|
omp-conductor daemon --once
|
|
356
356
|
```
|
|
357
357
|
|
|
358
|
-
The loop ticks every 5 minutes. `omp-conductor stop`
|
|
359
|
-
|
|
358
|
+
The loop ticks every 5 minutes. `omp-conductor stop` shuts the loop down after
|
|
359
|
+
the current tick rather than mid-run. When the live process is the MainPID of
|
|
360
|
+
`omp-conductor.service`, stop goes through `systemctl stop` so a unit with
|
|
361
|
+
`Restart=on-failure` cannot bring it straight back; otherwise it is a raw
|
|
362
|
+
`SIGTERM` (then `SIGKILL` after 10 seconds).
|
|
360
363
|
|
|
361
364
|
## How one tick works
|
|
362
365
|
|
|
@@ -454,19 +457,48 @@ A `claimed` or `running` row is a promise that a worker process exists, and a
|
|
|
454
457
|
daemon that just started knows that promise is broken: its workers died with the
|
|
455
458
|
previous process. At startup — unless another daemon is alive, so a foreground
|
|
456
459
|
`daemon --once` cannot orphan a running daemon's real workers — every such row is
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
clean orphan has its label released so the next
|
|
468
|
-
attempts still count toward `maxAttemptsPerIssue`,
|
|
469
|
-
instead of redispatching forever.
|
|
460
|
+
**salvaged first** (dirty tree → `wip(#N): attempt N killed by a daemon restart —
|
|
461
|
+
auto-salvaged` on the run's branch, same path as a turns-cap kill), then moved to
|
|
462
|
+
`orphaned`, with a log line naming the issue, the attempt and the worktree. That
|
|
463
|
+
frees the slots immediately; a fleet must never resume as deadlocked as it
|
|
464
|
+
crashed, and uncommitted edits must not wait for a human with `bun -e`.
|
|
465
|
+
|
|
466
|
+
Only the rows change after salvage. The issue keeps `agent:in-progress` — the
|
|
467
|
+
label is the crash guard against double-dispatch — and deciding what the dead
|
|
468
|
+
worker's remains are worth is the orchestrator's drain-duty judgement, spelled
|
|
469
|
+
out in its brief: an open green PR goes to the merge path, a salvaged sha is a
|
|
470
|
+
continuation hand-off, and a clean orphan has its label released so the next
|
|
471
|
+
tick re-claims it. Orphaned attempts still count toward `maxAttemptsPerIssue`,
|
|
472
|
+
so a crash loop escalates instead of redispatching forever.
|
|
473
|
+
|
|
474
|
+
### Deploying a new package onto a busy fleet
|
|
475
|
+
|
|
476
|
+
`systemctl restart` / `omp-conductor restart` is safe for **work product** once
|
|
477
|
+
this version is installed: startup salvage commits dirty trees before orphaning
|
|
478
|
+
rows, and salvage rewrites the mirror's managed `info/exclude` to the package's
|
|
479
|
+
current list before `git add` so a narrowed ignore cannot hide deliverables.
|
|
480
|
+
|
|
481
|
+
It is still disruptive for **in-flight sessions** (the worker process dies; the
|
|
482
|
+
attempt is spent). Prefer draining when you can wait:
|
|
483
|
+
|
|
484
|
+
1. `omp-conductor pause` — stop new claims; live workers finish.
|
|
485
|
+
2. Wait until `omp-conductor status` shows `workers 0 / N` (no `deploy` hint line).
|
|
486
|
+
3. Install the new package (`bun add -g omp-conductor@…`, `omp plugin install …`).
|
|
487
|
+
4. `systemctl restart omp-conductor` (or `omp-conductor restart`).
|
|
488
|
+
5. `omp-conductor resume` if you left it paused.
|
|
489
|
+
|
|
490
|
+
If you cannot wait:
|
|
491
|
+
|
|
492
|
+
1. `omp-conductor pause` (optional but keeps new claims off during the swap).
|
|
493
|
+
2. Install.
|
|
494
|
+
3. Restart — salvage runs on boot for every live worktree, then rows go `orphaned`.
|
|
495
|
+
4. Resume; the pane orchestrator triages `agent:in-progress` orphans (continuation
|
|
496
|
+
/ merge / release label). Status prints a `deploy` line while live workers > 0
|
|
497
|
+
so you can see the risk before you restart.
|
|
498
|
+
|
|
499
|
+
Do **not** edit files under the running install and expect the daemon to keep
|
|
500
|
+
dispatching — the integrity tripwire pauses and pages. Install, then restart, so
|
|
501
|
+
the new process records a fresh baseline.
|
|
470
502
|
|
|
471
503
|
### What settles a green PR
|
|
472
504
|
|
|
@@ -496,6 +528,17 @@ merged and whose issues were all closed, through two daemon restarts — and bec
|
|
|
496
528
|
the active set *is* the busy set, those three issues were permanently unclaimable.
|
|
497
529
|
A status page that has stopped being evidence is worse than no status page.
|
|
498
530
|
|
|
531
|
+
### Continuation runs
|
|
532
|
+
|
|
533
|
+
When a worktree is provisioned onto a branch that already exists in the mirror
|
|
534
|
+
(reattach after a prior attempt, orphan, or turns-cap auto-requeue), the worker
|
|
535
|
+
brief includes a **Continuation** section: read `git log` / `git diff` against
|
|
536
|
+
the default branch first, and do not recreate work already on the branch.
|
|
537
|
+
|
|
538
|
+
A **turns-cap kill with attempts remaining** salvages the tree, puts the queue
|
|
539
|
+
label back on, and skips the failed label so the next tick reclaims as a
|
|
540
|
+
continuation automatically.
|
|
541
|
+
|
|
499
542
|
### Branch names
|
|
500
543
|
|
|
501
544
|
`<type>/<slug>`, where the type is `fix` when any label's last segment (after `:`
|
|
@@ -539,14 +582,14 @@ rest. `0` is a real value (a hard stop), not "unset".
|
|
|
539
582
|
| Cap | Default | What it protects |
|
|
540
583
|
| --- | --- | --- |
|
|
541
584
|
| `maxConcurrentWorkers` | `2` | Parallel omp sessions. Two, because **CI runner slots, not model tokens, are the usual throughput ceiling** — a third worker would starve its own PR checks on a small self-hosted runner pool. Raise it only if you actually have the runners. |
|
|
542
|
-
| `dailySpendUsd` | `25` | Rolling-day spend ceiling
|
|
585
|
+
| `dailySpendUsd` | `25` | Rolling-day spend ceiling in USD, or `null` for no spend gate. `0` is a hard stop. Metered from assistant `usage.cost.total`. |
|
|
543
586
|
| `workerMaxTurns` | `120` | Turn ceiling for one worker. Catches a session looping without converging. |
|
|
544
587
|
| `workerWallClockMs` | `5400000` (90 minutes) | Wall-clock ceiling for one worker. A session that is merely stuck spends no turns, so turns alone cannot detect it. |
|
|
545
588
|
| `maxAttemptsPerIssue` | `2` | Retries per issue before it escalates. One clean retry recovers from flaky CI; a third attempt almost always means the issue itself is underspecified. |
|
|
546
589
|
|
|
547
590
|
Days are counted from **local midnight**, matching how a human reads "today".
|
|
548
591
|
|
|
549
|
-
Hitting `dailySpendUsd` is not the same as hitting the other caps. A concurrency
|
|
592
|
+
Set `dailySpendUsd` to `null` (wizard: blank) for no money gate — turns and wall-clock still apply. Hitting a numeric `dailySpendUsd` is not the same as hitting the other caps. A concurrency
|
|
550
593
|
limit simply defers work to a later tick. The spend cap **pauses the daemon and
|
|
551
594
|
pages at Tier 2**: a loop that is burning money has to halt itself, because
|
|
552
595
|
waiting for someone to notice tomorrow is how a runaway becomes expensive.
|
|
@@ -1065,9 +1108,9 @@ omp-conductor help
|
|
|
1065
1108
|
| Command | Behaviour |
|
|
1066
1109
|
| --- | --- |
|
|
1067
1110
|
| `start` | Spawn the loop in the background, detached, and wait until it answers `GET /healthz` on `:8787`. Refuses if one is already live, naming its pid. If the process dies or never serves, `start` cleans up after it and quotes the tail of `daemon.log`. |
|
|
1068
|
-
| `stop` | `SIGTERM`, then `SIGKILL` after a 10-second grace period. Prints `not running` when there is nothing to stop. |
|
|
1069
|
-
| `restart` | `stop` then `start`, inheriting the running daemon's port and project unless a flag overrides them
|
|
1070
|
-
| `status [--project NAME]` | Pause state, config and state paths, resolved caps, active runs and today's usage, plus a `daemon` block: pid, uptime, port, project, `/healthz` result and log path. A `.conductor-stalled` marker in the state directory adds an `orchestrator STALLED since …` line — see [the stall marker](#a-wedged-session-and-the-marker-that-notices). Reads while a daemon in another process writes. |
|
|
1111
|
+
| `stop` | Prefer `systemctl stop omp-conductor.service` when that unit's MainPID is the live daemon — systemd then owns the stop and will not schedule a restart for the exit it just requested. Otherwise `SIGTERM`, then `SIGKILL` after a 10-second grace period. Prints `not running` when there is nothing to stop, and tags the confirmation with `(via systemctl)` when the unit path was used. |
|
|
1112
|
+
| `restart` | Prefer `systemctl restart` when the unit owns the live pid so the replacement stays supervised; otherwise `stop` then `start`, inheriting the running daemon's port and project unless a flag overrides them. The new process **salvages dirty live worktrees before orphaning** those rows — see [Deploying a new package onto a busy fleet](#deploying-a-new-package-onto-a-busy-fleet). |
|
|
1113
|
+
| `status [--project NAME]` | Pause state, config and state paths, resolved caps, active runs and today's usage, plus a `daemon` block: pid, uptime, port, project, `/healthz` result and log path. While live workers > 0, prints a `deploy` line naming the count so a busy restart is visible before you take it. A `.conductor-stalled` marker in the state directory adds an `orchestrator STALLED since …` line — see [the stall marker](#a-wedged-session-and-the-marker-that-notices). Reads while a daemon in another process writes. |
|
|
1071
1114
|
| `tail <issue>` | Follow the newest run for that issue: the worker's assistant text as `assistant: …` and each tool it calls as `tool: <name>`, printed as they land. Workers are omp sessions inside the daemon rather than terminals, so this is the only way to watch one live — a herdr pane running it becomes an observation window. Starts from the top of the transcript, not the end, so attaching to a run that is already ten turns in shows those ten turns. Exits `1` with `no run recorded for #N` when the issue has never been dispatched, or `no transcript yet (state: …)` when the attempt has not opened one. Otherwise it runs until `Ctrl-C`, or until the run has finished and its transcript has been silent for five seconds, and prints `run ended: <state>`. |
|
|
1072
1115
|
| `unblock <issue>` | Remove that issue's `blocked` and `failed` state labels through the tracker, so the next tick can claim it again. This is the supported way back for an escalation you answered: eligibility disqualifies any issue carrying a state label, so an answered issue that keeps one is never re-claimed and the answer is inert. Removing a label the issue does not carry is a no-op, so both are always cleared and neither has to be looked up first. `agent:in-progress` is deliberately not touched — it means a worker process exists, which is not something an answer changes. The run history is left exactly as it is: an answered block still spent a worker, so it still counts toward `maxAttemptsPerIssue`, and the output says how many attempts remain — or warns that the next tick will escalate instead of dispatching, when none do. Exits `2` with `unblock needs an issue number` on a missing or malformed positional. |
|
|
1073
1116
|
| `daemon` | Run the loop in the **foreground**, ticking every 5 minutes and serving `/healthz`. This is what `start` launches, and what a systemd unit should call. Writes the pidfile itself, and refuses with `another daemon is alive (pid N); stop it first` rather than becoming a second dispatcher. |
|
|
@@ -1187,10 +1230,14 @@ Known and deliberate in this version:
|
|
|
1187
1230
|
retried rather than corrupted.
|
|
1188
1231
|
- **Mirrors grow one branch ref per run.** Unpushed work is never discarded, so
|
|
1189
1232
|
refs accumulate until you reap them.
|
|
1190
|
-
- **`stop` is a deadline, not a clean drain.**
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1233
|
+
- **`stop` is a deadline, not a clean drain.** Whether it goes through
|
|
1234
|
+
`systemctl` or a raw signal, the loop is asked to finish the tick it is on, and
|
|
1235
|
+
a tick with a worker in flight can run for that worker's whole wall clock; the
|
|
1236
|
+
signal path escalates to `SIGKILL` after 10 seconds. There is no "stop once the
|
|
1237
|
+
current worker lands". A unit that supervises the daemon should set
|
|
1238
|
+
`SuccessExitStatus=0 143` (belt-and-braces for a handled `SIGTERM` exit) and
|
|
1239
|
+
operators should prefer `omp-conductor stop` / `systemctl stop` over a raw
|
|
1240
|
+
`kill`, so `Restart=on-failure` cannot misread a deliberate stop as a crash.
|
|
1194
1241
|
- **A failed orchestrator degrades quietly.** The daemon logs a warning and keeps
|
|
1195
1242
|
running, but tier-1 escalations then land in issue comments — which is exactly the
|
|
1196
1243
|
"nobody reads it until morning" path the orchestrator exists to avoid. The warning
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
|
|
@@ -18,9 +18,11 @@ written out as the one you chose — ask an omp session to read
|
|
|
18
18
|
|
|
19
19
|
---
|
|
20
20
|
|
|
21
|
-
You are the orchestrator for **{{PROJECT}}**. You do not write product code
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
You are the orchestrator for **{{PROJECT}}**. You do not write product code —
|
|
22
|
+
**Hard boundaries** below names the exact acts that are out (checkout / commit /
|
|
23
|
+
push inside a worker's worktree; inventing a commit identity) and the one that
|
|
24
|
+
is in (`gh pr update-branch` for a green PR that fell behind). You keep the
|
|
25
|
+
queue moving, and you are the first responder when a worker gets stuck.
|
|
24
26
|
|
|
25
27
|
You are prompted on a timer. Each tick: do the three duties below, then stop.
|
|
26
28
|
|
|
@@ -100,8 +102,11 @@ checked in this order:
|
|
|
100
102
|
- **Commits — pushed or not — or a PR that is not green.** Safe either way:
|
|
101
103
|
pushed work lives on the remote, and unpushed commits live on the run's branch
|
|
102
104
|
in the mirror, which a re-claim deliberately reattaches so the next worker
|
|
103
|
-
starts from them
|
|
104
|
-
|
|
105
|
+
starts from them with a **continuation brief** (read the log/diff first; do
|
|
106
|
+
not recreate existing work). Note what exists and release the label; the
|
|
107
|
+
attempt counter still bounds a loop of deaths. A turns-cap kill with attempts
|
|
108
|
+
left is re-queued automatically by the daemon — you should still notice it on
|
|
109
|
+
drain, but you do not have to invent the continuation prompt.
|
|
105
110
|
- **Genuinely nothing** (clean tree, no commits, no PR). Release the label and let
|
|
106
111
|
the next tick re-claim it clean.
|
|
107
112
|
|
|
@@ -222,8 +227,12 @@ The protocol, in order:
|
|
|
222
227
|
big for that, send the one-sentence version of each change and say the
|
|
223
228
|
full text lands in the file on yes — the diff stays in your transcript for
|
|
224
229
|
anyone who wants it verbatim.
|
|
225
|
-
3. **On yes, apply it** by editing this file yourself. On no,
|
|
226
|
-
|
|
230
|
+
3. **On yes, apply it** by editing this file yourself. **On explicit no, drop
|
|
231
|
+
it** forever and do not re-ask that amendment. **On cancel, timeout, or no
|
|
232
|
+
answer**, park it — that means "not now", not "never": mention it once in the
|
|
233
|
+
next report as `pending amendment: <one-liner> — say 'apply it' or 'drop it'`,
|
|
234
|
+
never re-open the yes/no dialog, and drop it if still unanswered after 7 days.
|
|
235
|
+
A cancelled dialog is not a permanent rejection.
|
|
227
236
|
4. **Log it.** Append one line to **Amendments** at the bottom of this file: the
|
|
228
237
|
date, what triggered it, a one-sentence summary.
|
|
229
238
|
5. **Offer general fixes upstream.** Ask one question of the amendment you just
|
package/src/briefs/worker.md
CHANGED
|
@@ -28,7 +28,7 @@ Then read the repo's own guidance before writing anything: `AGENTS.md`,
|
|
|
28
28
|
`CLAUDE.md`, `CONTEXT.md`, and any `docs/adr/` entry the issue touches. Those
|
|
29
29
|
files are canonical; your priors are not.
|
|
30
30
|
|
|
31
|
-
## Acceptance criteria
|
|
31
|
+
{{CONTINUATION}}## Acceptance criteria
|
|
32
32
|
|
|
33
33
|
{{ACCEPTANCE_CRITERIA}}
|
|
34
34
|
|
package/src/cli.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
DEFAULT_PORT,
|
|
17
17
|
healthCheck,
|
|
18
18
|
livingDaemon,
|
|
19
|
+
restartDaemon,
|
|
19
20
|
startDaemon,
|
|
20
21
|
stopDaemon,
|
|
21
22
|
writeRecord,
|
|
@@ -46,9 +47,14 @@ usage:
|
|
|
46
47
|
start run the dispatch loop in the background and wait until it answers
|
|
47
48
|
GET /healthz on :8787 (override with --port). Refuses if one is
|
|
48
49
|
already running.
|
|
49
|
-
stop
|
|
50
|
+
stop stop the running daemon. Uses systemctl when the omp-conductor
|
|
51
|
+
unit owns the process (so Restart=on-failure cannot bring it back);
|
|
52
|
+
otherwise SIGTERM then SIGKILL.
|
|
50
53
|
restart stop then start, keeping the running daemon's port and project
|
|
51
|
-
unless a flag overrides them.
|
|
54
|
+
unless a flag overrides them. On boot the new process salvages any
|
|
55
|
+
dirty live worktrees before orphaning those rows — see README
|
|
56
|
+
"Deploying a new package onto a busy fleet". Goes through systemctl
|
|
57
|
+
when the unit owns the live pid.
|
|
52
58
|
status show pause state, caps, active runs, today's usage, and whether a
|
|
53
59
|
daemon is alive.
|
|
54
60
|
tail follow the newest run for <issue>: the worker's assistant text and
|
|
@@ -398,27 +404,35 @@ try {
|
|
|
398
404
|
}
|
|
399
405
|
|
|
400
406
|
case "stop": {
|
|
401
|
-
// Read the pid before it stops existing, so the confirmation can name it.
|
|
402
|
-
const pid = livingDaemon()?.pid;
|
|
403
407
|
const result = await stopDaemon();
|
|
404
|
-
|
|
408
|
+
if (result.kind === "not-running") {
|
|
409
|
+
process.stdout.write("not running\n");
|
|
410
|
+
} else {
|
|
411
|
+
process.stdout.write(
|
|
412
|
+
`stopped — pid ${result.pid}${result.via === "systemctl" ? " (via systemctl)" : ""}\n`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
405
415
|
break;
|
|
406
416
|
}
|
|
407
417
|
|
|
408
418
|
case "restart": {
|
|
409
419
|
// Inherit the running daemon's port and project: a restart that quietly
|
|
410
420
|
// moved to the default port would leave every existing health check
|
|
411
|
-
// pointing at nothing.
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
port: portFlag(argv) ?? previous?.port,
|
|
417
|
-
project: flag(argv, "project") ?? previous?.project,
|
|
421
|
+
// pointing at nothing. When the unit owns the live pid, restartDaemon
|
|
422
|
+
// goes through systemctl so the replacement stays supervised.
|
|
423
|
+
const { previous, record, via } = await restartDaemon({
|
|
424
|
+
port: portFlag(argv),
|
|
425
|
+
project: flag(argv, "project"),
|
|
418
426
|
});
|
|
427
|
+
if (previous !== undefined) {
|
|
428
|
+
process.stdout.write(
|
|
429
|
+
`stopped — pid ${previous.pid}${via === "systemctl" ? " (via systemctl)" : ""}\n`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
419
432
|
process.stdout.write(
|
|
420
|
-
`started — pid ${
|
|
421
|
-
`${
|
|
433
|
+
`started — pid ${record.pid}, /healthz on :${record.port}` +
|
|
434
|
+
`${record.project === undefined ? "" : `, project ${record.project}`}` +
|
|
435
|
+
`${via === "systemctl" ? " (via systemctl)" : ""}\nlog ${record.logFile}\n`,
|
|
422
436
|
);
|
|
423
437
|
break;
|
|
424
438
|
}
|
package/src/config.ts
CHANGED
|
@@ -139,15 +139,16 @@ export function saveConfig(c: ConductorConfig): void {
|
|
|
139
139
|
/**
|
|
140
140
|
* Layers a project's overrides on the global defaults, field by field, so a
|
|
141
141
|
* project that pins one cap still inherits the other five. `??` not `||`: a
|
|
142
|
-
* deliberate `dailySpendUsd: 0` is a hard stop, not "unset"
|
|
143
|
-
*
|
|
144
|
-
*
|
|
142
|
+
* deliberate `dailySpendUsd: 0` is a hard stop, not "unset", and `null` is a
|
|
143
|
+
* deliberate "no spend gate" that must not fall through to the default.
|
|
144
|
+
* Spelled out per field so adding a `Caps` member fails to compile here.
|
|
145
145
|
*/
|
|
146
146
|
export function resolveCaps(p: ProjectConfig, defaults: Caps): Caps {
|
|
147
147
|
const o: Partial<Caps> = p.caps ?? {};
|
|
148
148
|
return {
|
|
149
149
|
maxConcurrentWorkers: o.maxConcurrentWorkers ?? defaults.maxConcurrentWorkers,
|
|
150
|
-
|
|
150
|
+
// nullish only — `null` means off; do not coalesce it to the default.
|
|
151
|
+
dailySpendUsd: o.dailySpendUsd !== undefined ? o.dailySpendUsd : defaults.dailySpendUsd,
|
|
151
152
|
workerMaxTurns: o.workerMaxTurns ?? defaults.workerMaxTurns,
|
|
152
153
|
workerWallClockMs: o.workerWallClockMs ?? defaults.workerWallClockMs,
|
|
153
154
|
maxAttemptsPerIssue: o.maxAttemptsPerIssue ?? defaults.maxAttemptsPerIssue,
|
|
@@ -520,8 +521,18 @@ function coerceCaps(parsed: unknown, label: string, problems: string[], legacy:
|
|
|
520
521
|
for (const key of CAP_KEYS) {
|
|
521
522
|
const v = raw[key];
|
|
522
523
|
if (v === undefined) continue;
|
|
524
|
+
// Spend is the only cap that may be null (= no gate). Every other ceiling
|
|
525
|
+
// is a non-negative number; 0 remains a hard stop where it already was.
|
|
526
|
+
if (key === "dailySpendUsd" && v === null) {
|
|
527
|
+
out.dailySpendUsd = null;
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
523
530
|
if (typeof v !== "number" || !Number.isFinite(v) || v < 0) {
|
|
524
|
-
problems.push(
|
|
531
|
+
problems.push(
|
|
532
|
+
key === "dailySpendUsd"
|
|
533
|
+
? `${label}.${key} must be a non-negative finite number or null (no cap), found ${JSON.stringify(v)}`
|
|
534
|
+
: `${label}.${key} must be a non-negative finite number, found ${JSON.stringify(v)}`,
|
|
535
|
+
);
|
|
525
536
|
continue;
|
|
526
537
|
}
|
|
527
538
|
out[key] = v;
|