@edgehero/pi-dispatch 4.0.1 → 4.2.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.
@@ -6,7 +6,12 @@ import { resolveBackendName } from "./backend-registry.mjs";
6
6
  import { isForgeKind, targetSeparator } from "./forges.mjs";
7
7
  import { MODEL_REF_PATTERN as USAGE_ID_PATTERN } from "./model-ref.mjs";
8
8
  import { isProjectId } from "./project-id.mjs";
9
- import { recordedJobSize } from "./job-size.mjs";
9
+ import { HOST_CPUS_MAX, HOST_MEMORY_MAX_MIB, HOST_SLOTS_MAX, recordedJobSize } from "./job-size.mjs";
10
+ import { waitArmed } from "./wait-for.mjs";
11
+ import { scheduledForMillis } from "./repeat-slot.mjs";
12
+ import { EARLIER_MAX, recordedEarlier } from "./run-earlier.mjs";
13
+
14
+ export { EARLIER_MAX, recordedEarlier };
10
15
 
11
16
  /**
12
17
  * Durable per-run history.
@@ -159,8 +164,15 @@ export function parseExitTurns(text) {
159
164
  * it could not enforce before a call. The two refusals are live from the first image that knows the variables.
160
165
  * `cost-cap` is live from the image that declares `costCap`, whose runner carries the cost guard, and
161
166
  * `model-not-allowed` from the image that declares `modelPolicy`, whose runner carries the model guard.
167
+ *
168
+ * Then the runner's exit-2 refusals of a job's MCP servers (`MCP_RUNNER_REASONS`), each before the prompt. They read as
169
+ * `runner-policy` until they were named here, which hid an operator's fix (a server that answers 401, a command the
170
+ * image lacks) behind budget wording. `mcp-config-invalid` is also the pickup's FREE refusal of an overlay mcp.json it
171
+ * cannot read whole; that one returns `budgetReserved: false` and pages nobody, as the free `model-not-allowed` does.
172
+ * Live from the first image that declares `mcp`: an image built before the worker named them prints the same line.
162
173
  */
163
- export const RUNNER_POLICY_REASONS = new Set(["provider-auth-refused", "cost-cap", "model-not-allowed", "cost-cap-unenforceable", "model-policy-unenforceable"]);
174
+ export const MCP_RUNNER_REASONS = Object.freeze(["mcp-unavailable", "mcp-config-invalid", "mcp-config-mismatch", "mcp-secret-missing", "mcp-command-missing", "mcp-shadowed"]);
175
+ export const RUNNER_POLICY_REASONS = new Set(["provider-auth-refused", "cost-cap", "model-not-allowed", "cost-cap-unenforceable", "model-policy-unenforceable", ...MCP_RUNNER_REASONS]);
164
176
 
165
177
  /**
166
178
  * The reason off the decisive exit line (`decisiveExitLine`), or `null`: a member of `RUNNER_POLICY_REASONS`
@@ -569,7 +581,7 @@ function rebuildUsage(u) {
569
581
  * default to `null` when the outcome does not carry them, so the record shape is stable whether or not
570
582
  * the source reports those fields.
571
583
  */
572
- export function buildRecord({ job, result, error, startedAt, endedAt, host = null, defaultBackend = null, project = null, size = null }) {
584
+ export function buildRecord({ job, result, error, startedAt, endedAt, host = null, defaultBackend = null, project = null, size = null, capacity = null, earlier = null }) {
573
585
  const data = job.data ?? {};
574
586
  const kind = data.kind ?? job.name;
575
587
  const source = result ?? error ?? {};
@@ -728,7 +740,114 @@ export function buildRecord({ job, result, error, startedAt, endedAt, host = nul
728
740
  // refused: an off or unknown dimension never refuses by itself); `hostShare` an integer or null. Present only on
729
741
  // the `job-size-exceeds-host` and `-share` records; null on every other.
730
742
  hostBudget: recordedHostBudget(source.hostBudget),
743
+ // When the job became ELIGIBLE to run (issue #599, INT-RUN-HISTORY-FILE-CONTRACT), so `startedAt - queuedAt` is how
744
+ // long it waited for a slot. Additive, nullable, an explicit literal, TAIL position after `hostBudget` on the same
745
+ // contract. `job.timestamp + opts.delay`, not `job.timestamp` alone: BullMQ's job scheduler creates the next cron
746
+ // job when the current one runs, stamped with that moment and delayed until it is due (job-scheduler.js
747
+ // `getNextJobOpts`), so the timestamp alone made a daily trigger read as a day of waiting. `opts.delay` is the
748
+ // delay the job was ADDED with and survives a `moveToDelayed` (a pause, a deferral) and a retry, while `job.delay`
749
+ // is rewritten by both (measured against bullmq 5.80.4 on Valkey, worker/test/queued-at.integration.test.mjs), so
750
+ // a pause window and a deferral count as waiting, which they are. Null for a job held on `run.waitFor` (a wait its
751
+ // trigger asked for is not a wait for capacity) and for a retry (its wait would include the earlier attempt). An
752
+ // ISO string or null, a number's rendering, so PII-free.
753
+ queuedAt: queuedAtOf(job),
754
+ // What this host offered when the job took its slot (issue #599, INT-RUN-HISTORY-FILE-CONTRACT): `{ slots, memMiB,
755
+ // cpuCenti, cpus }`, the live PI_CONCURRENCY, the host budget as `hostBudget` above maps it, and the CPUs the OS
756
+ // reports. Additive, nullable, an explicit literal REBUILT here (`recordedCapacity`), TAIL position after `queuedAt`.
757
+ // Passed in by the processor only once the job is ADMITTED (index.mjs, where `startedAt` is set), so it is also
758
+ // the record's own answer to "did this run hold a slot": null on every refusal before one, which is what
759
+ // lets a capacity report tell a run from a refusal without guessing from the wall time (capacity.mjs).
760
+ capacity: recordedCapacity(capacity),
761
+ // The slot time of this job's EARLIER attempts (issue #599, INT-RUN-HISTORY-FILE-CONTRACT), which a retry's record
762
+ // would otherwise erase: it overwrites the same file and the same mirror key. `[{ host, startedAt, endedAt, memMiB,
763
+ // cpuCenti }]`, at most `EARLIER_MAX`, newest last, read by the processor from the previous record BEFORE this one
764
+ // replaces it (`earlierFrom`) and passed in. Additive, nullable, an explicit literal REBUILT here, TAIL position after
765
+ // `capacity`. A worker name, two ISO instants and two integers per entry: never the earlier attempt's dollars,
766
+ // tokens or usage, so every cost reader, which reads the top-level fields only, counts nothing twice. Null on a
767
+ // first attempt and when the previous record held no slot or could not be read.
768
+ earlier: recordedEarlier(earlier),
769
+ // Whether the FIRST attempt stalled and this pickup re-ran it (issue #599): BullMQ raised `stalledCounter`, not
770
+ // `attemptsMade`, so that pickup wrote no record and its slot time is in none. A stall after a failed attempt is a
771
+ // retry like any other (its earlier attempt's record is carried in `earlier`), so it is false there. A boolean,
772
+ // TAIL position after `earlier`, so the capacity report can say how many runs that is.
773
+ stalledRepick: Number.isInteger(job.stalledCounter) && job.stalledCounter > 0 && !(Number.isInteger(job.attemptsMade) && job.attemptsMade > 0),
774
+ };
775
+ }
776
+
777
+ /**
778
+ * Of two copies of one job's record (this host's file and the fleet's copy), the one a retry replaces: the HIGHER
779
+ * `attempt` (a retry on another host writes its own copy there, so either side can be the newer attempt), an exact tie
780
+ * broken by the later `endedAt` (run-mirror.mjs `mergeRuns`' rule), a remaining tie by the first given (the local file).
781
+ * Either may be null; a non-object is none.
782
+ */
783
+ export function newerRecord(a, b) {
784
+ const ok = (r) => r !== null && typeof r === "object" && !Array.isArray(r);
785
+ if (!ok(a)) return ok(b) ? b : null;
786
+ if (!ok(b)) return a;
787
+ const attempt = (r) => (Number.isInteger(r.attempt) ? r.attempt : 0);
788
+ if (attempt(a) !== attempt(b)) return attempt(a) > attempt(b) ? a : b;
789
+ const end = (r) => {
790
+ const t = Date.parse(r.endedAt ?? r.startedAt ?? "");
791
+ return Number.isFinite(t) ? t : -Infinity;
792
+ };
793
+ return end(b) > end(a) ? b : a;
794
+ }
795
+
796
+ /**
797
+ * What a retry's record carries forward from the record it is about to replace: that record's own `earlier`, then its
798
+ * own slot interval when it held a slot (`capacity` an object), each rebuilt; null when there is nothing.
799
+ */
800
+ export function earlierFrom(previous) {
801
+ if (previous === null || typeof previous !== "object" || Array.isArray(previous)) return null;
802
+ const carried = Array.isArray(previous.earlier) ? previous.earlier : [];
803
+ const own = previous.capacity !== null && typeof previous.capacity === "object" && !Array.isArray(previous.capacity) ? [{ host: previous.host, startedAt: previous.startedAt, endedAt: previous.endedAt, memMiB: previous.size?.memMiB, cpuCenti: previous.size?.cpuCenti }] : [];
804
+ return recordedEarlier([...carried, ...own]);
805
+ }
806
+
807
+ /**
808
+ * When a job became eligible to run, as an ISO string, or null: `job.timestamp` plus the delay it was added with
809
+ * (`opts.delay`, at least 0). Null for a job armed with `run.waitFor` (its wait is its trigger's own), for a retry
810
+ * (`attemptsMade` above 0) or a pickup after a stall (`stalledCounter` above 0), and for any input that is not a
811
+ * finite, representable instant.
812
+ */
813
+ export function queuedAtOf(job) {
814
+ if (waitArmed(job?.data)) return null;
815
+ // A RETRY's wait would include its earlier attempt and that attempt's run (the add's moment is kept across attempts),
816
+ // so only a first attempt says how long the job waited for a slot. `attemptsMade` counts attempts finished before.
817
+ if (Number.isInteger(job?.attemptsMade) && job.attemptsMade > 0) return null;
818
+ // A pickup after a STALL is the same: BullMQ raises `stalledCounter`, not `attemptsMade`, and the add's moment is kept.
819
+ if (Number.isInteger(job?.stalledCounter) && job.stalledCounter > 0) return null;
820
+ // A job scheduler's job carries its exact slot in its id. BullMQ stamps `timestamp` and computes `delay` from two
821
+ // separate clock reads, so their sum can land a few milliseconds past the slot (measured in CI).
822
+ const slot = scheduledForMillis(job?.id);
823
+ if (Number.isSafeInteger(slot) && slot >= 0 && slot <= 8.64e15) return new Date(slot).toISOString();
824
+ const timestamp = job?.timestamp;
825
+ const delay = job?.opts?.delay ?? 0;
826
+ if (!Number.isSafeInteger(timestamp) || timestamp < 0 || typeof delay !== "number" || !Number.isFinite(delay)) return null;
827
+ const at = timestamp + Math.max(0, Math.trunc(delay));
828
+ // 8.64e15 is the last instant a Date can hold; past it `toISOString` throws, and this function must not.
829
+ return at <= 8.64e15 ? new Date(at).toISOString() : null;
830
+ }
831
+
832
+ /** A record's non-negative safe integer, else null. */
833
+ const recordInt = (v) => (Number.isSafeInteger(v) && v >= 0 ? v : null);
834
+ /** A record's budget dimension: an integer, `"off"` (the budget's `Infinity`, or the word itself), else null. */
835
+ const recordBudget = (v) => (v === Infinity || v === "off" ? "off" : recordInt(v));
836
+
837
+ /**
838
+ * A run's capacity as a record carries it: `{ slots, memMiB, cpuCenti, cpus }`, else null. `slots` and `cpus` are
839
+ * positive integers or null (unknown); each budget dimension is `recordedHostBudget`'s mapping, so `"off"` and null
840
+ * keep meaning "not limited" and "not known".
841
+ */
842
+ export function recordedCapacity(value) {
843
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
844
+ // Each held to what a host can have (job-size.mjs `HOST_*_MAX`): past it a value is a fault, recorded as unknown.
845
+ const upTo = (max, read) => (v) => {
846
+ const n = read(v);
847
+ return typeof n === "number" && n > max ? null : n;
731
848
  };
849
+ const positive = (v) => (Number.isSafeInteger(v) && v >= 1 ? v : null);
850
+ return { slots: upTo(HOST_SLOTS_MAX, positive)(value.slots), memMiB: upTo(HOST_MEMORY_MAX_MIB, recordBudget)(value.memMiB), cpuCenti: upTo(HOST_CPUS_MAX * 100, recordBudget)(value.cpuCenti), cpus: upTo(HOST_CPUS_MAX, positive)(value.cpus) };
732
851
  }
733
852
 
734
853
  /**
@@ -739,9 +858,7 @@ export function buildRecord({ job, result, error, startedAt, endedAt, host = nul
739
858
  */
740
859
  export function recordedHostBudget(value) {
741
860
  if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
742
- const int = (v) => (Number.isSafeInteger(v) && v >= 0 ? v : null);
743
- const budget = (v) => (v === Infinity || v === "off" ? "off" : int(v));
744
- return { memMiB: budget(value.memMiB), cpuCenti: budget(value.cpuCenti), hostShare: int(value.hostShare) };
861
+ return { memMiB: recordBudget(value.memMiB), cpuCenti: recordBudget(value.cpuCenti), hostShare: recordInt(value.hostShare) };
745
862
  }
746
863
 
747
864
  /** What became of a collected plan. */
@@ -39,6 +39,158 @@ import { UNREADABLE_RECORD } from "./run-history.mjs";
39
39
  /** The index: sanitized jobId -> the run's end (or start) in millis. */
40
40
  export const RUNS_INDEX = "runs:index";
41
41
 
42
+ /**
43
+ * The fleet's HISTORY HORIZON (issue #599): a one-member ZSET (`trim`) whose score is the newest instant before which a
44
+ * writer has trimmed runs out of the index. Every writer trims the SHARED index by its OWN retention (and the shared
45
+ * count cap), so a host with a short `PI_LOG_RETENTION_DAYS` removes every host's older runs; a reader cannot know
46
+ * that from its own settings, and without this key it read the gap as idle time. Raised only, never lowered (`ZADD
47
+ * GT`), so concurrent writers agree on the latest cut without a lock.
48
+ */
49
+ export const RUNS_HORIZON = "runs:horizon";
50
+ /** The horizon's one member. */
51
+ export const RUNS_HORIZON_MEMBER = "trim";
52
+
53
+ /**
54
+ * WHEN THIS MIRROR STARTED (issue #599): a hash, `at` the instant in millis from which the index holds every run that
55
+ * ended, and `member` a run of the index that vouches for it. Nothing above says it: the reader's other bounds (the
56
+ * depth, the horizon, the cap, an expired body) all live in keys that a flushed or replaced Valkey, or an index that
57
+ * expired, loses together with the runs, and the next write then recreates an index that claimed the whole window, so a
58
+ * peer read every lost run as idle.
59
+ *
60
+ * A start is TRUSTED only while its `member` is in the index. The start can outlive its index (a worker of 4.0.1 sets
61
+ * the index's expiry to its own retention while the start keeps the deepest window; a pruning reader can remove every
62
+ * member), and an older worker can then recreate the index without knowing this key: a member that is still there is
63
+ * the one proof that the index the start describes is the index that exists. Rejected: also trusting it while
64
+ * `runs:horizon` is at or past it, since the horizon outlives a lost index just the same.
65
+ * - The write that finds no index starts it NOW (the write's time), vouched for by the run it adds.
66
+ * - A write that finds an index whose start it cannot trust (none, or its member is gone) starts it at the index's
67
+ * oldest run before this write, vouched for by that run: the most the index can say, so time before it reads as
68
+ * missing. A trusted start is kept.
69
+ * - Whatever removes the vouching run while the start is trusted (a trim, `PRUNE_SCRIPT`) hands it to the oldest run
70
+ * that remains, `at` unchanged, and deletes the start with the last run. So no trim moves `at`, earlier or later.
71
+ * It expires with the index (one `PEXPIRE` each, in the same script). A reader that finds no trusted start starts the
72
+ * mirror at the index's oldest run.
73
+ */
74
+ export const RUNS_SINCE = "runs:since";
75
+
76
+ /**
77
+ * Lua shared by the three scripts (KEYS[1] the index, KEYS[3] the start): whether the start is a hash, whether its member
78
+ * is held, and handing a trusted start on once its member is removed. A start that is not a hash (a string from a
79
+ * development build, or a hand SET) is read as absent, and the writing scripts delete it first (`sinceClean`), so a
80
+ * stray value can never make HGET fail with WRONGTYPE and stop the mirror.
81
+ */
82
+ const SINCE_LUA = `
83
+ local function sinceIsHash()
84
+ local t = redis.call("TYPE", KEYS[3])
85
+ return (t.ok or t) == "hash"
86
+ end
87
+ local function sinceClean()
88
+ if redis.call("EXISTS", KEYS[3]) == 1 and not sinceIsHash() then redis.call("DEL", KEYS[3]) end
89
+ end
90
+ local function sinceHeld()
91
+ if not sinceIsHash() then return false end
92
+ local member = redis.call("HGET", KEYS[3], "member")
93
+ return member and redis.call("ZSCORE", KEYS[1], member) and true or false
94
+ end
95
+ local function sinceHandOn()
96
+ local first = redis.call("ZRANGE", KEYS[1], 0, 0)
97
+ if first[1] then redis.call("HSET", KEYS[3], "member", first[1]) else redis.call("DEL", KEYS[3]) end
98
+ end
99
+ `;
100
+
101
+ /**
102
+ * The writer's index write and trim, in one script so the member, the start (`RUNS_SINCE`) and the horizon it raises
103
+ * are atomic (issue #599). KEYS: the index, the horizon and the start; ARGV: the age cutoff in millis, the count cap,
104
+ * the expiry all three keys get, the horizon's member, the run's score, its id, and the write's time in millis.
105
+ *
106
+ * - The start is checked and set as `RUNS_SINCE` says, from the index as it was before this write, and the member is
107
+ * added (`ZADD`).
108
+ * - By age (scores below the cutoff, exclusive) and by count (all but the newest `cap`). A trim that REMOVED something
109
+ * raises the horizon with `ZADD GT`: to the cutoff, or after a count trim to the oldest score that remains, which is
110
+ * conservative (a removed run ended at or before it). Nothing removed, nothing written: a fleet whose writers trim
111
+ * nothing keeps no horizon. A trim that removed the start's member hands the start on.
112
+ * - The keys expire after the DEEPEST window any reader asks for (`mirrorWindowMs(0)`), never the writer's own. The
113
+ * index's members are trimmed by score anyway; a short-retention writer that set the shared index's TTL to its own
114
+ * window made the whole index expire on a quiet day, every peer's history with it and no horizon to say so. The
115
+ * expiry still rolls with traffic: a fleet that runs nothing for that long has nothing to show.
116
+ */
117
+ export const TRIM_SCRIPT = `${SINCE_LUA}
118
+ sinceClean()
119
+ local oldest = redis.call("ZRANGE", KEYS[1], 0, 0, "WITHSCORES")
120
+ if not oldest[2] then
121
+ redis.call("DEL", KEYS[3])
122
+ redis.call("HSET", KEYS[3], "at", ARGV[7], "member", ARGV[6])
123
+ elseif not (redis.call("HGET", KEYS[3], "at") and sinceHeld()) then
124
+ redis.call("DEL", KEYS[3])
125
+ redis.call("HSET", KEYS[3], "at", string.format("%d", tonumber(oldest[2])), "member", oldest[1])
126
+ end
127
+ redis.call("ZADD", KEYS[1], ARGV[5], ARGV[6])
128
+ redis.call("PEXPIRE", KEYS[3], ARGV[3])
129
+ local removed = redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", "(" .. ARGV[1])
130
+ local horizon = nil
131
+ if removed > 0 then horizon = tonumber(ARGV[1]) end
132
+ if redis.call("ZREMRANGEBYRANK", KEYS[1], 0, -tonumber(ARGV[2]) - 1) > 0 then
133
+ local kept = redis.call("ZRANGE", KEYS[1], 0, 0, "WITHSCORES")
134
+ if kept[2] then
135
+ local score = tonumber(kept[2])
136
+ if horizon == nil or score > horizon then horizon = score end
137
+ end
138
+ end
139
+ if horizon then
140
+ redis.call("ZADD", KEYS[2], "GT", string.format("%d", horizon), ARGV[4])
141
+ redis.call("PEXPIRE", KEYS[2], ARGV[3])
142
+ if not sinceHeld() then sinceHandOn() end
143
+ end
144
+ redis.call("PEXPIRE", KEYS[1], ARGV[3])
145
+ return horizon and string.format("%d", horizon) or false
146
+ `;
147
+
148
+ /**
149
+ * A pruning reader's removal of members whose bodies are gone (`readMirroredRuns`), in one script so the cut is
150
+ * recorded with it (issue #599). The run a removed member stood for is no longer shown, so the time up to it is no
151
+ * longer whole: the horizon is raised (`ZADD GT`) to the highest score removed, as `TRIM_SCRIPT` raises it for a trim,
152
+ * and a trusted start whose member is removed is handed on. Without it, the reader's "expired body" bound was erased by
153
+ * the very read that saw it, and the time before it read as idle. KEYS: the index, the horizon and the start; ARGV: the
154
+ * horizon's expiry, its member, then the ids. Returns the highest score removed, or false.
155
+ */
156
+ export const PRUNE_SCRIPT = `${SINCE_LUA}
157
+ sinceClean()
158
+ -- Handed on only when the start was trusted BEFORE this removal: a start whose run was already gone (an older worker
159
+ -- recreated the index under it) must not be revalidated by handing it to a run of the new index.
160
+ local held = sinceHeld()
161
+ local top = nil
162
+ for i = 3, #ARGV do
163
+ local score = redis.call("ZSCORE", KEYS[1], ARGV[i])
164
+ if score then
165
+ redis.call("ZREM", KEYS[1], ARGV[i])
166
+ score = tonumber(score)
167
+ if top == nil or score > top then top = score end
168
+ end
169
+ end
170
+ if top then
171
+ redis.call("ZADD", KEYS[2], "GT", string.format("%d", top), ARGV[2])
172
+ redis.call("PEXPIRE", KEYS[2], ARGV[1])
173
+ if held and not sinceHeld() then sinceHandOn() end
174
+ end
175
+ return top and string.format("%d", top) or false
176
+ `;
177
+
178
+ /**
179
+ * The capacity reader's one snapshot of the index (issue #599): its size, its oldest score, the horizon, the start's
180
+ * `at` and whether its member is held, and the members that ended after ARGV[2] (exclusive) with their scores, newest
181
+ * first. One script, so a flush or a write between these reads cannot mix two states into one report. KEYS: the index,
182
+ * the horizon and the start; ARGV: the horizon's member and the window's start.
183
+ */
184
+ export const READ_SCRIPT = `${SINCE_LUA}
185
+ local size = redis.call("ZCARD", KEYS[1])
186
+ if size == 0 then return {0} end
187
+ local oldest = redis.call("ZRANGE", KEYS[1], 0, 0, "WITHSCORES")
188
+ local horizon = redis.call("ZSCORE", KEYS[2], ARGV[1])
189
+ local at = sinceIsHash() and redis.call("HGET", KEYS[3], "at") or false
190
+ local range = redis.call("ZREVRANGEBYSCORE", KEYS[1], "+inf", "(" .. ARGV[2], "WITHSCORES")
191
+ return {size, oldest[2] or false, horizon, at, sinceHeld() and 1 or 0, range}
192
+ `;
193
+
42
194
  /** One run's own bytes. */
43
195
  export const runRecordKey = (sanitizedJobId) => `runs:rec:${sanitizedJobId}`;
44
196
 
@@ -106,17 +258,13 @@ export function makeRunMirror({ redis, retentionDays, now = () => Date.now(), lo
106
258
  const score = Number.isFinite(at) ? at : now();
107
259
  const body = JSON.stringify(record);
108
260
  await bounded(redis.set(runRecordKey(sanitizedJobId), body, "PX", windowMs), timeoutMs);
109
- await bounded(redis.zadd(RUNS_INDEX, score, sanitizedJobId), timeoutMs);
110
- // Trimmed by the WRITER, twice: by age, and by count. Two `ZREMRANGE`s against a run that
111
- // took minutes is free, and it means no reader has to pay for a backlog it did not create.
112
- await bounded(redis.zremrangebyscore(RUNS_INDEX, "-inf", `(${now() - windowMs}`), timeoutMs);
113
- await bounded(redis.zremrangebyrank(RUNS_INDEX, 0, -indexMax - 1), timeoutMs);
114
- // ROLLING expiry, deliberately unlike `budget.mjs`'s set-once rule and deliberately like
115
- // `pi-dispatch:sched-stalls:<schedulerId>`. A budget window must not be pushed forward by traffic or a busy
116
- // day never resets; an ACTIVITY index should roll with traffic, because that is what it
117
- // describes. A fleet that stops running jobs loses its index one window later, which is
118
- // correct: there is nothing left to show.
119
- await bounded(redis.pexpire(RUNS_INDEX, windowMs), timeoutMs);
261
+ // Indexed, and trimmed by the WRITER, twice: by age, and by count, with the mirror's start (`RUNS_SINCE`) and
262
+ // the fleet's horizon written by the same script (`TRIM_SCRIPT`), so no crash or timeout can land between a
263
+ // member and the record of where the index starts, or between a cut and the record of it. A script, not a
264
+ // MULTI, because both depend on what the index held: the start on whether it existed, the horizon on what a
265
+ // trim REMOVED and what remains.
266
+ const writtenMs = now();
267
+ await bounded(redis.eval(TRIM_SCRIPT, 3, RUNS_INDEX, RUNS_HORIZON, RUNS_SINCE, writtenMs - windowMs, indexMax, mirrorWindowMs(0), RUNS_HORIZON_MEMBER, score, sanitizedJobId, writtenMs), timeoutMs);
120
268
  warned = false;
121
269
  return true;
122
270
  } catch (err) {
@@ -180,7 +328,8 @@ export async function readMirroredRuns(redis, { limit = 50, sinceMs = 0, now = (
180
328
  }
181
329
  if (stale.length > 0) {
182
330
  try {
183
- await bounded(redis.zrem(RUNS_INDEX, ...stale), timeoutMs);
331
+ // Removed with the cut recorded (`PRUNE_SCRIPT`): the time up to a pruned run is no longer whole.
332
+ await bounded(redis.eval(PRUNE_SCRIPT, 3, RUNS_INDEX, RUNS_HORIZON, RUNS_SINCE, mirrorWindowMs(0), RUNS_HORIZON_MEMBER, ...stale), timeoutMs);
184
333
  } catch {
185
334
  // best-effort: a straggler in the index costs one skipped row next time, never a wrong one
186
335
  }
package/src/schedules.mjs CHANGED
@@ -182,7 +182,7 @@ function normalizeCronSchedule({ on, run }, path, existsSync, fleet) {
182
182
  // `portfolio` (issue #505) is conditional like `command`, so an unflagged schedule grows no key, and it rides at JOB
183
183
  // level, never inside `trigger`: `trigger` is copied verbatim into `/job/event.json`, and whether a job may send a
184
184
  // budget plan is the worker's decision to re-check against the live file, not a fact for the agent to read.
185
- const data = { kind: "local", folder: run.folder, flow: run.flow, task: run.task, ...(run.command !== undefined && { command: run.command }), provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages: run.packages, image: run.image, ...(run.backend !== undefined && { backend: run.backend }), ...(run.excludeTools !== undefined && { excludeTools: run.excludeTools }), ...(run.models !== undefined && { models: run.models }), ...(run.maxCostUsd !== undefined && { maxCostUsd: run.maxCostUsd }), ...(run.skillsDir !== undefined && { skillsDir: run.skillsDir }), ...(run.secrets !== undefined && { secrets: run.secrets }), ...(run.secretsProfile !== undefined && { secretsProfile: run.secretsProfile }), resume: run.resume, ...(run.portfolio !== undefined && { portfolio: run.portfolio }), trigger: { id: on.id, pattern: on.pattern } };
185
+ const data = { kind: "local", folder: run.folder, flow: run.flow, task: run.task, ...(run.command !== undefined && { command: run.command }), provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages: run.packages, image: run.image, ...(run.backend !== undefined && { backend: run.backend }), ...(run.excludeTools !== undefined && { excludeTools: run.excludeTools }), ...(run.mcp !== undefined && { mcp: run.mcp }), ...(run.models !== undefined && { models: run.models }), ...(run.maxCostUsd !== undefined && { maxCostUsd: run.maxCostUsd }), ...(run.skillsDir !== undefined && { skillsDir: run.skillsDir }), ...(run.secrets !== undefined && { secrets: run.secrets }), ...(run.secretsProfile !== undefined && { secretsProfile: run.secretsProfile }), resume: run.resume, ...(run.portfolio !== undefined && { portfolio: run.portfolio }), trigger: { id: on.id, pattern: on.pattern } };
186
186
  // Retention only; the deterministic repeat:<id>:<millis> jobId supplies dedup, so no jobId here, and
187
187
  // scheduler jobs are not retried (DES-CRON-VIA-BULLMQ-SCHEDULER) so no attempts/backoff.
188
188
  const opts = { removeOnComplete: { age: 24 * 3600 }, removeOnFail: { age: 7 * 24 * 3600 } };
@@ -42,7 +42,8 @@
42
42
  * A size is rounded UP to a step: 256m up to 2g, 512m up to 8g, then 1g.
43
43
  * CPUs: fewer than `SUGGEST_MIN_SAMPLES` runs is not enough runs; the p95 cores used (CPU time over wall time) below
44
44
  * 0.4x the current cpus lowers to 1.25x that p95, never below 1.25x the window's largest cores used, nor 0.25;
45
- * otherwise it fits. A FACT (no call) rides along when the median run was throttled more than 25% of its wall time.
45
+ * otherwise it fits. A FACT (no call) rides along when the median run was throttled more than 25% of its wall time,
46
+ * read over the runs that report throttled time and only when at least `SUGGEST_MIN_SAMPLES` of them do.
46
47
  * The wall time is the record's pickup-to-end span, which includes the clone, so cores used read slightly LOW.
47
48
  *
48
49
  * THE CAP. A raise never goes past `cap` (this host's budget per dimension, or where that is off or unknown the host's
@@ -119,9 +120,10 @@ const isCount = (v) => Number.isSafeInteger(v) && v >= 0;
119
120
  /**
120
121
  * One record as a suggestion reads it, or null when it is not evidence: `{ at, size, memPeak, oom, wallUsec, cpuUsec,
121
122
  * throttledUsec, memFullUsec }`. `memPeak` (bytes, clamped to the record's own limit) is null when absent or not a
122
- * count; `wallUsec` is null unless the span is readable; `cpuUsec` and `throttledUsec` are null unless both can be read
123
- * with a wall time (CPU time clamped to the CPU ceiling over the wall, throttled time to the wall); `memFullUsec` is
124
- * null unless it can be read with a wall time.
123
+ * count; `wallUsec` is null unless the span is readable; `cpuUsec` is null unless it can be read with a wall time (CPU
124
+ * time clamped to the CPU ceiling over the wall), and `throttledUsec` likewise on its own (clamped to the wall): cgroup v2
125
+ * writes `throttled_usec` in `cpu.stat` only where the cpu controller is enabled, so a run without it is still a CPU
126
+ * sample, and only the throttle fact leaves it out. `memFullUsec` is null unless it can be read with a wall time.
125
127
  */
126
128
  function evidenceOf(record, project, nowMs) {
127
129
  if (record === null || typeof record !== "object" || Array.isArray(record)) return null;
@@ -136,11 +138,10 @@ function evidenceOf(record, project, nowMs) {
136
138
  const start = typeof record.startedAt === "string" ? Date.parse(record.startedAt) : NaN;
137
139
  const wallMs = Number.isFinite(start) ? at - start : NaN;
138
140
  const wallUsec = Number.isSafeInteger(wallMs) && wallMs > 0 && wallMs <= SUGGEST_MAX_WALL_MS ? wallMs * 1000 : null;
139
- let cpu = { cpuUsec: null, throttledUsec: null };
140
- if (wallUsec !== null && isCount(r.cpuUsec) && isCount(r.throttledUsec)) {
141
- const cpuCap = (wallUsec * JOB_CPUS_CEILING_CENTI) / 100;
142
- cpu = { cpuUsec: Math.min(r.cpuUsec, cpuCap), throttledUsec: Math.min(r.throttledUsec, wallUsec) };
143
- }
141
+ const cpu = {
142
+ cpuUsec: wallUsec !== null && isCount(r.cpuUsec) ? Math.min(r.cpuUsec, (wallUsec * JOB_CPUS_CEILING_CENTI) / 100) : null,
143
+ throttledUsec: wallUsec !== null && isCount(r.cpuUsec) && isCount(r.throttledUsec) ? Math.min(r.throttledUsec, wallUsec) : null,
144
+ };
144
145
  // unclamped: it is only ever compared (exactly, in BigInt) with 1% of the wall, and a stall past the wall is above it
145
146
  const memFullUsec = wallUsec !== null && isCount(r.memFullUsec) ? r.memFullUsec : null;
146
147
  if (memPeak === null && cpu.cpuUsec === null) return null;
@@ -174,7 +175,8 @@ function suggestMemory(runs, current, cap) {
174
175
  const largestOom = oomSizes.length > 0 ? Math.max(...oomSizes) : null;
175
176
  // at the limit (90% of it or more: peak x 10 >= limit x 9) AND stalled for memory more than 1% of the wall.
176
177
  const pressured = relevant.filter((e) => !productAbove(e.size.memMiB * MIB, 9, e.memPeak, 10) && e.memFullUsec !== null && productAbove(e.memFullUsec, 100, e.wallUsec, 1)).length;
177
- const evidence = { samples, p95MiB: p95 === null ? null : Math.ceil(p95 / MIB), maxPeakMiB: maxPeak === null ? null : Math.ceil(maxPeak / MIB), ooms, largestOomMiB: largestOom, pressured };
178
+ // `smaller`: the measured runs below the current size, which a chart draws and this verdict does not count.
179
+ const evidence = { samples, smaller: measured.length - samples, p95MiB: p95 === null ? null : Math.ceil(p95 / MIB), maxPeakMiB: maxPeak === null ? null : Math.ceil(maxPeak / MIB), ooms, largestOomMiB: largestOom, pressured };
178
180
  const fact = pressured > 0 ? "pressure" : null;
179
181
  const out = (reason, suggested, more = {}) => ({ current, suggested: suggested === null || suggested === current ? null : suggested, reason, evidence, fact, held: null, wanted: null, ...more });
180
182
  if (ooms > 0) {
@@ -198,12 +200,17 @@ function suggestCpus(runs, current) {
198
200
  const samples = relevant.length;
199
201
  // ordered by the exact fraction, never a float: a/b < c/d <=> a x d < c x b.
200
202
  const byFraction = (num) => (x, y) => (productAbove(num(y), x.wallUsec, num(x), y.wallUsec) ? -1 : productAbove(num(x), y.wallUsec, num(y), x.wallUsec) ? 1 : 0);
201
- const throttle = samples > 0 ? rank([...relevant].sort(byFraction((e) => e.throttledUsec)), 50) : null;
203
+ // The throttle fact reads only the runs whose cgroup reported throttled time (see `evidenceOf`), and needs as many of
204
+ // them as a suggestion needs runs: one throttled run among twenty is not "the median run".
205
+ const throttled = relevant.filter((e) => e.throttledUsec !== null);
206
+ const throttle = throttled.length >= SUGGEST_MIN_SAMPLES ? rank([...throttled].sort(byFraction((e) => e.throttledUsec)), 50) : null;
202
207
  const busy = samples > 0 ? rank([...relevant].sort(byFraction((e) => e.cpuUsec)), 95) : null;
203
208
  const busiest = measured.length > 0 ? [...measured].sort(byFraction((e) => e.cpuUsec)).at(-1) : null;
204
209
  const coresOf = (e) => (e === null ? null : Math.ceil((e.cpuUsec * 100) / e.wallUsec));
205
210
  const evidence = {
206
211
  samples,
212
+ smaller: measured.length - samples,
213
+ throttledSamples: throttled.length,
207
214
  p95CoresCenti: coresOf(busy),
208
215
  maxCoresCenti: coresOf(busiest),
209
216
  throttledPct: throttle === null ? null : Math.round((throttle.throttledUsec * 100) / throttle.wallUsec),
@@ -211,7 +218,7 @@ function suggestCpus(runs, current) {
211
218
  const out = (reason, suggested, fact = null) => ({ current, suggested: suggested === null || suggested === current ? null : suggested, reason, evidence, fact, held: null, wanted: null });
212
219
  if (samples < SUGGEST_MIN_SAMPLES) return out("not-enough-runs", null);
213
220
  // throttled more than 25% of the wall time (throttled x 4 > wall): a fact about the host's ceiling, never a call.
214
- const fact = productAbove(throttle.throttledUsec, 4, throttle.wallUsec, 1) ? "ceiling" : null;
221
+ const fact = throttle !== null && productAbove(throttle.throttledUsec, 4, throttle.wallUsec, 1) ? "ceiling" : null;
215
222
  // p95 cores below 0.4 x cpus: cpuUsec / wall < 0.4 x current / 100, that is cpuUsec x 1000 < 4 x current x wall.
216
223
  if (productAbove(4 * current, busy.wallUsec, busy.cpuUsec, 1000)) {
217
224
  const lower = Math.max(roundCpusUp(quarterMoreCenti(busy)), roundCpusUp(quarterMoreCenti(busiest)));
@@ -399,6 +406,19 @@ export const coresText = (centi) => `${formatCpus(centi)} core${centi === 100 ?
399
406
  /** CPUs in words: `1 CPU`, `0.5 CPUs`, `2 CPUs`. */
400
407
  export const cpusText = (centi) => `${formatCpus(centi)} CPU${centi === 100 ? "" : "s"}`;
401
408
 
409
+ /**
410
+ * A "not enough runs" verdict in words for the panel's PROJECTS view and the dispatch_limit_edit preview (the insights
411
+ * page restates it; doctor says the same counts through `suggestionEvidence`): how many runs at the current size or
412
+ * larger it counted, and how many smaller ones it did not, which for memory the chart beside it draws:
413
+ * `not enough runs at 4g (0; 20 at smaller sizes)`. `sizeText` formats the dimension's size.
414
+ */
415
+ export function notEnoughRunsText(dim, sizeText) {
416
+ const e = dim?.evidence ?? {};
417
+ const samples = Number.isSafeInteger(e.samples) ? e.samples : 0;
418
+ const smaller = Number.isSafeInteger(e.smaller) && e.smaller > 0 ? `; ${e.smaller} at smaller sizes` : "";
419
+ return `not enough runs at ${sizeText(dim.current)} (${samples}${smaller})`;
420
+ }
421
+
402
422
  /**
403
423
  * The evidence of a suggestion in words, for doctor, the panel and the insights page alike: `{ memory, cpu, memoryHeld,
404
424
  * memoryFact, cpuFact }`, each a short clause (empty when it does not apply), such as `2 runs ended oom-killed (the
@@ -415,12 +435,12 @@ export function suggestionEvidence(suggestion) {
415
435
  const cores = `p95 ${coresText(ce.p95CoresCenti ?? 0)} used, largest ${coresText(ce.maxCoresCenti ?? 0)}, over ${plural(ce.samples, "run")}`;
416
436
  const memWords = {
417
437
  "oom-killed": `${plural(me.ooms, "run")} ended oom-killed (the largest size killed ${formatMemory(me.largestOomMiB ?? 0)})`,
418
- "not-enough-runs": `${me.samples} of the ${SUGGEST_MIN_SAMPLES} runs with measurements it needs`,
438
+ "not-enough-runs": `${me.samples} of the ${SUGGEST_MIN_SAMPLES} runs with measurements at ${formatMemory(m.current ?? 0)} or larger it needs${me.smaller > 0 ? `, ${me.smaller} more at smaller sizes` : ""}`,
419
439
  oversized: peaks,
420
440
  fits: peaks,
421
441
  };
422
442
  const cpuWords = {
423
- "not-enough-runs": `${ce.samples} of the ${SUGGEST_MIN_SAMPLES} runs with measurements it needs`,
443
+ "not-enough-runs": `${ce.samples} of the ${SUGGEST_MIN_SAMPLES} runs with measurements at ${cpusText(c.current ?? 0)} or more it needs${ce.smaller > 0 ? `, ${ce.smaller} more at smaller sizes` : ""}`,
424
444
  underused: cores,
425
445
  fits: cores,
426
446
  };