@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.
- package/.env.example +2 -1
- package/package.json +6 -1
- package/src/capacity-cli.mjs +375 -0
- package/src/capacity-records.mjs +209 -0
- package/src/capacity.mjs +654 -0
- package/src/cli.mjs +9 -0
- package/src/config.mjs +4 -20
- package/src/connection.mjs +4 -2
- package/src/doctor.mjs +399 -49
- package/src/egress-allowlist.mjs +81 -0
- package/src/env-allowlist.mjs +5 -1
- package/src/host-budget.mjs +6 -0
- package/src/host-registry.mjs +20 -2
- package/src/image-preflight.mjs +25 -1
- package/src/import-pi.mjs +113 -8
- package/src/index.mjs +97 -6
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +135 -0
- package/src/mcp-config.mjs +466 -0
- package/src/mcp-preflight.mjs +262 -0
- package/src/model-endpoints.mjs +8 -3
- package/src/outbox.mjs +7 -1
- package/src/prepare.mjs +13 -13
- package/src/processor.mjs +36 -2
- package/src/queue.mjs +4 -2
- package/src/repeat-slot.mjs +13 -0
- package/src/reserved-env.mjs +1 -0
- package/src/run-container.mjs +2 -0
- package/src/run-earlier.mjs +29 -0
- package/src/run-history.mjs +123 -6
- package/src/run-mirror.mjs +161 -12
- package/src/schedules.mjs +1 -1
- package/src/size-suggest.mjs +34 -14
- package/src/start.mjs +79 -5
- package/src/triggers-file.mjs +3 -0
- package/src/triggers.mjs +123 -7
- package/src/wait-for.mjs +7 -0
- package/src/worker-name.mjs +25 -0
package/src/run-history.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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. */
|
package/src/run-mirror.mjs
CHANGED
|
@@ -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
|
-
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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 } };
|
package/src/size-suggest.mjs
CHANGED
|
@@ -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`
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|