faberun 0.12.1 → 0.14.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/package.json +1 -1
- package/skills/faberun/references/contract.md +10 -10
- package/src/campaign/index.mjs +74 -2
- package/src/campaign/record.mjs +28 -0
- package/src/contract/index.mjs +57 -10
- package/src/contract/runtime.mjs +4 -1
- package/src/contract/snapshot.mjs +46 -3
- package/src/contract/task-packet.mjs +5 -1
- package/src/engine/backoff.mjs +2 -1
- package/src/engine/capacity.mjs +74 -0
- package/src/engine/dispatch.mjs +12 -186
- package/src/engine/lifecycle.mjs +7 -2
- package/src/engine/phase-session.mjs +217 -0
- package/src/engine/process.mjs +26 -108
- package/src/engine/scheduler.mjs +15 -3
- package/src/engine/settle.mjs +14 -2
- package/src/engine/state.mjs +1 -0
- package/src/engine/transcript.mjs +145 -0
- package/src/harnesses/claude/index.mjs +5 -0
- package/src/harnesses/dsh/runner.mjs +38 -3
- package/src/harnesses/exec-jsonl/index.mjs +0 -499
- package/src/harnesses/index.mjs +55 -8
- package/src/harnesses/protocol.mjs +22 -2
- package/src/harnesses/session-metrics.mjs +674 -0
- package/src/host/tool-policy-decisions.mjs +78 -21
- package/src/host/tool-policy-hook.mjs +12 -3
- package/src/plan/freeze.mjs +53 -6
- package/src/plan/pipeline.mjs +139 -48
- package/src/plan/sizing.mjs +0 -0
- package/src/plan/template.mjs +102 -8
- package/src/run/operations.mjs +1 -1
- package/src/run/usage.mjs +9 -3
- package/src/util.mjs +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faberun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Faberun is a development orchestration system that turns intent into verified software: harness- and model-agnostic, it keeps the intent, coordinates the work, verifies the result and decides what happens next.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -297,19 +297,19 @@ offered to a second node, so one session runs one turn.
|
|
|
297
297
|
|
|
298
298
|
## Gates
|
|
299
299
|
|
|
300
|
-
`gate: false` skips review
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
`
|
|
304
|
-
`
|
|
305
|
-
|
|
300
|
+
`gate: false` skips review; the node keeps `maxRevisions` (default 1) fresh
|
|
301
|
+
attempts after a red verification, and `{ enabled: false, maxRevisions: 0 }`
|
|
302
|
+
makes the first red one final. A gate object accepts `runtime` (the judge),
|
|
303
|
+
`review` (`none`/`advisory`/`blocking`, default `advisory`), `failOn`
|
|
304
|
+
(default `["critical"]`) and `maxRevisions`. `advisory` records the verdict
|
|
305
|
+
and still settles `done` on deterministic verification alone, never
|
|
306
|
+
re-dispatching; `blocking` re-dispatches within `maxRevisions` when findings
|
|
306
307
|
reach `failOn`. Validation requires `critical` whenever `major` is in
|
|
307
308
|
`failOn`, and `major` in `failOn` for a `blocking` gate: `["critical"]` alone
|
|
308
|
-
passes every major finding
|
|
309
|
+
passes every major finding.
|
|
309
310
|
|
|
310
|
-
The revision budget counts
|
|
311
|
-
crash-restart never consumes one
|
|
312
|
-
`revisions`). Deterministic `verification` commands run once by default
|
|
311
|
+
The revision budget counts rejections, not worker starts; a resume or a
|
|
312
|
+
crash-restart never consumes one. Deterministic `verification` commands run once by default
|
|
313
313
|
before any judge and the judge reviews the recorded results, never
|
|
314
314
|
re-running them (`repeat` opts into re-running a flaky check). A judge
|
|
315
315
|
output is `pass` only with empty `findings` and `maxSeverity: none`; for
|
package/src/campaign/index.mjs
CHANGED
|
@@ -21,7 +21,9 @@ import { handoffFromState, materializeHandoff } from "./handoff.mjs";
|
|
|
21
21
|
/** @typedef {{path: string, digest: string}} CampaignContract */
|
|
22
22
|
/** @typedef {{runId: string, contractPath?: string, branch: string, sha: string, previousSha: string|null, at: string}} PromotionRecord */
|
|
23
23
|
/** @typedef {{code: string, message: string, at: string, contractPath?: string, contractId?: string, runId?: string, node?: string|null, status?: string|null, resume?: string}} CampaignAttention */
|
|
24
|
-
/** @typedef {{
|
|
24
|
+
/** @typedef {{runId: string, node: string, passed: boolean|null, verdict: string|null}} RequirementNodeEvidence */
|
|
25
|
+
/** @typedef {{requirementId: string, status: "covered"|"open", nodes: RequirementNodeEvidence[]}} RequirementClosure */
|
|
26
|
+
/** @typedef {{id: string, goal: string, status: "active"|"closed", linkedRunIds: string[], contracts: CampaignContract[], landBranch: string, promotions: PromotionRecord[], attention?: CampaignAttention, requirements?: RequirementClosure[], createdAt: string, updatedAt: string, closedAt?: string}} Campaign */
|
|
25
27
|
/** @typedef {{type: string, eventId: string, at: string, sessionId?: string, text?: string, tool?: string, transcript?: string|null, transcriptUnavailable?: boolean, format?: string|null, cursor?: string|null, decisionId?: string, supersedes?: string, runId?: string, questionId?: string, campaignId?: string, nodeId?: string|null, phase?: string, checkpointsDone?: number, checkpointsTotal?: number, runtime?: string|null, state?: string, lastProgressAt?: string, attention?: string|null}} JournalEntry */
|
|
26
28
|
/** @typedef {{updatedAt: string|null, decisions: Record<string, JournalEntry>, questions: Record<string, JournalEntry>, constraints: JournalEntry[], intents: JournalEntry[], outcomes: JournalEntry[], sessions: JournalEntry[], next: JournalEntry|null, evicted: Record<string, number>}} Projection */
|
|
27
29
|
/** @typedef {{cursor: number, byte: number, size: number, projection: Projection}} ProjectionRecord */
|
|
@@ -135,12 +137,82 @@ export function closeCampaign(campaignPath, { at = new Date().toISOString(), eve
|
|
|
135
137
|
}
|
|
136
138
|
const repoRoot = campaignRepoRoot(campaignPath);
|
|
137
139
|
const ledgerFiles = preserveCampaignLedger(campaignPath, repoRoot);
|
|
138
|
-
|
|
140
|
+
// The closure travels on the record itself, computed in one deterministic
|
|
141
|
+
// pass over the linked runs' own files before the close is written.
|
|
142
|
+
const requirements = buildRequirementClosure(campaignPath, campaign);
|
|
143
|
+
/** @type {Campaign} */
|
|
144
|
+
const closed = { ...campaign, status: "closed", closedAt: at, updatedAt: at, requirements };
|
|
139
145
|
writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), closed);
|
|
140
146
|
appendJournal(campaignPath, { type: "campaign.closed", at, eventId });
|
|
141
147
|
return { path: campaignPath, campaign: closed, ledgerFiles };
|
|
142
148
|
}
|
|
143
149
|
|
|
150
|
+
/**
|
|
151
|
+
* The requirement closure a close records: one entry per requirement id the
|
|
152
|
+
* linked runs' contracts declared, correlated only by the identifiers the runs
|
|
153
|
+
* carried -- the contract node's declaration and the id the done node snapshot
|
|
154
|
+
* itself carries, stamped there by the engine -- never by requirement text. A
|
|
155
|
+
* requirement no done node carries is recorded as open rather than omitted.
|
|
156
|
+
* Deterministic and free of external calls: runs are visited in sorted id
|
|
157
|
+
* order, nodes in contract order, requirement ids sorted, and the only inputs
|
|
158
|
+
* are files already on disk. A run that never launched (or was pruned)
|
|
159
|
+
* contributes nothing.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} campaignPath
|
|
162
|
+
* @param {Campaign} campaign
|
|
163
|
+
* @returns {RequirementClosure[]}
|
|
164
|
+
*/
|
|
165
|
+
function buildRequirementClosure(campaignPath, campaign) {
|
|
166
|
+
const runsDir = resolve(campaignPath, "..", "..");
|
|
167
|
+
/** @type {Map<string, RequirementNodeEvidence[]>} */
|
|
168
|
+
const covered = new Map();
|
|
169
|
+
/** @type {Set<string>} */
|
|
170
|
+
const declared = new Set();
|
|
171
|
+
for (const runId of [...campaign.linkedRunIds].sort()) {
|
|
172
|
+
const contract = readRunJson(join(runsDir, runId, "contract.json"));
|
|
173
|
+
const nodes = contract !== null && Array.isArray(contract.nodes) ? /** @type {JsonObject[]} */ (contract.nodes) : [];
|
|
174
|
+
for (const node of nodes) {
|
|
175
|
+
const nodeId = typeof node.id === "string" ? node.id : "";
|
|
176
|
+
const requirementIds = Array.isArray(node.requirementIds) ? node.requirementIds : [];
|
|
177
|
+
if (!nodeId || requirementIds.length === 0) continue;
|
|
178
|
+
const snapshot = readRunJson(join(runsDir, runId, "nodes", `${nodeId}.json`));
|
|
179
|
+
const done = snapshot !== null && snapshot.status === "done";
|
|
180
|
+
const carried = snapshot !== null && Array.isArray(snapshot.requirementIds) ? /** @type {unknown[]} */ (snapshot.requirementIds) : [];
|
|
181
|
+
const verification = snapshot !== null ? /** @type {JsonObject|null|undefined} */ (snapshot.verification) : undefined;
|
|
182
|
+
const gate = snapshot !== null ? /** @type {JsonObject|null|undefined} */ (snapshot.gate) : undefined;
|
|
183
|
+
const passed = verification && typeof verification.passed === "boolean" ? verification.passed : null;
|
|
184
|
+
const verdict = gate && typeof gate.verdict === "string" ? gate.verdict : null;
|
|
185
|
+
for (const requirementId of requirementIds) {
|
|
186
|
+
if (typeof requirementId !== "string") continue;
|
|
187
|
+
declared.add(requirementId);
|
|
188
|
+
if (!done || !carried.includes(requirementId)) continue;
|
|
189
|
+
const evidence = covered.get(requirementId) ?? [];
|
|
190
|
+
evidence.push({ runId, node: nodeId, passed, verdict });
|
|
191
|
+
covered.set(requirementId, evidence);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return [...declared].sort().map((requirementId) => {
|
|
196
|
+
const nodes = covered.get(requirementId) ?? [];
|
|
197
|
+
return { requirementId, status: nodes.length > 0 ? "covered" : "open", nodes };
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {string} path
|
|
203
|
+
* @returns {JsonObject|null} null when the file is absent or not JSON: a run
|
|
204
|
+
* that never launched contributes nothing to the closure rather than failing
|
|
205
|
+
* the close, the same discipline `preserveCampaignLedger` applies to a run
|
|
206
|
+
* without a usage ledger.
|
|
207
|
+
*/
|
|
208
|
+
function readRunJson(path) {
|
|
209
|
+
try {
|
|
210
|
+
return /** @type {JsonObject} */ (JSON.parse(readFileSync(path, "utf8")));
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
144
216
|
/**
|
|
145
217
|
* The repository a campaign's ledger is preserved into. Under the home layout
|
|
146
218
|
* the campaign path carries the project id at the resolver's fixed position,
|
package/src/campaign/record.mjs
CHANGED
|
@@ -99,4 +99,32 @@ function validateCampaign(campaign) {
|
|
|
99
99
|
if (promotion.contractPath !== undefined) requireString(promotion.contractPath, `campaign.promotions[${index}].contractPath`);
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
|
+
// The requirement closure a close records: one entry per requirement id the
|
|
103
|
+
// linked runs' contracts declared, correlated by the identifiers the runs
|
|
104
|
+
// carried, with every uncovered requirement kept as open. Optional on read
|
|
105
|
+
// so a campaign closed before the field existed stays readable.
|
|
106
|
+
if (record.requirements !== undefined) {
|
|
107
|
+
if (!Array.isArray(record.requirements)) throw new TypeError("campaign.requirements must be an array");
|
|
108
|
+
for (const [index, entry] of record.requirements.entries()) {
|
|
109
|
+
assertObject(entry, `campaign.requirements[${index}]`);
|
|
110
|
+
const closure = /** @type {JsonObject} */ (entry);
|
|
111
|
+
requireId(closure.requirementId, `campaign.requirements[${index}].requirementId`);
|
|
112
|
+
if (closure.status !== "covered" && closure.status !== "open") {
|
|
113
|
+
throw new TypeError(`campaign.requirements[${index}].status must be covered or open`);
|
|
114
|
+
}
|
|
115
|
+
if (!Array.isArray(closure.nodes)) throw new TypeError(`campaign.requirements[${index}].nodes must be an array`);
|
|
116
|
+
for (const [position, evidence] of closure.nodes.entries()) {
|
|
117
|
+
assertObject(evidence, `campaign.requirements[${index}].nodes[${position}]`);
|
|
118
|
+
const node = /** @type {JsonObject} */ (evidence);
|
|
119
|
+
requireId(node.runId, `campaign.requirements[${index}].nodes[${position}].runId`);
|
|
120
|
+
requireId(node.node, `campaign.requirements[${index}].nodes[${position}].node`);
|
|
121
|
+
if (node.passed !== null && typeof node.passed !== "boolean") {
|
|
122
|
+
throw new TypeError(`campaign.requirements[${index}].nodes[${position}].passed must be a boolean or null`);
|
|
123
|
+
}
|
|
124
|
+
if (node.verdict !== null && typeof node.verdict !== "string") {
|
|
125
|
+
throw new TypeError(`campaign.requirements[${index}].nodes[${position}].verdict must be a string or null`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
102
130
|
}
|
package/src/contract/index.mjs
CHANGED
|
@@ -32,16 +32,25 @@ const WRITE_FILE_LINE_WARN_MARGIN = 100;
|
|
|
32
32
|
|
|
33
33
|
const CONTRACT_FIELDS = new Set([
|
|
34
34
|
"schemaVersion", "contractVersion", "id", "campaignId", "goal", "cwd", "sourceIdentity",
|
|
35
|
-
"maxParallel", "pollIntervalMs", "stallTimeoutSec", "timeoutSec",
|
|
35
|
+
"maxParallel", "pollIntervalMs", "stallTimeoutSec", "timeoutSec", "maxTurns", "phaseSessionReuse",
|
|
36
36
|
"runtimeDefaults", "runtimes", "nodes", "warnings", "finalVerification", "sharedVerification", "nodeAdvisory",
|
|
37
37
|
]);
|
|
38
38
|
const DEFAULTS_FIELDS = new Set(["worker", "judge"]);
|
|
39
39
|
const NODE_FIELDS = new Set([
|
|
40
|
-
"id", "type", "phase", "runtime", "dependsOn", "taskPacket", "taskPacketFile", "prompt", "promptFile",
|
|
41
|
-
"definitionOfDone", "gate", "timeoutSec",
|
|
40
|
+
"id", "type", "phase", "requirementIds", "runtime", "dependsOn", "taskPacket", "taskPacketFile", "prompt", "promptFile",
|
|
41
|
+
"definitionOfDone", "gate", "timeoutSec", "maxTurns",
|
|
42
42
|
"requiredCapabilities", "packetHash", "sourceIdentity", "replayPolicy",
|
|
43
43
|
]);
|
|
44
44
|
const REPLAY_POLICIES = new Set(["safe", "reconcile", "never"]);
|
|
45
|
+
/**
|
|
46
|
+
* Provider requests one attempt may make before the controller ends it, when
|
|
47
|
+
* neither the contract nor the node says otherwise. measured 2026-09-20 over
|
|
48
|
+
* 200 completed claude worker turns: p90 83, p95 96, p99 122, max 339; the 23
|
|
49
|
+
* turns that never produced a result held 25% of all context spend, and six
|
|
50
|
+
* of them ran past 150 (180 to 600 requests). One completed turn would have
|
|
51
|
+
* been cut and retried once.
|
|
52
|
+
*/
|
|
53
|
+
export const DEFAULT_MAX_TURNS = 150;
|
|
45
54
|
const GATE_FIELDS = new Set(["enabled", "runtime", "review", "failOn", "maxRevisions", "requiredCapabilities", "skipWhen"]);
|
|
46
55
|
const GATE_REVIEWS = new Set(["none", "advisory", "blocking"]);
|
|
47
56
|
|
|
@@ -55,13 +64,13 @@ const GATE_REVIEWS = new Set(["none", "advisory", "blocking"]);
|
|
|
55
64
|
|
|
56
65
|
/** @typedef {{mode: "execution"|"discovery"|"autonomous", objective: string, instructions: string[], readFiles: string[], writeFiles?: string[], writeRoots?: string[], symbols: string[], scopeAcknowledged?: string[], decisions: string[], nonGoals: string[], verification: VerificationCommand[]}} TaskPacket */
|
|
57
66
|
|
|
58
|
-
/** @typedef {{harness: "claude"|"codex"|"agy"|"dsh"|"zcode"|"exec-jsonl"|"replay", model: string, reasoning?: string, sandbox?: "read-only"|"workspace-write"|"danger-full-access", permissionMode?: string, config?: Record<string, unknown>, printTimeout?: string, tools?: string[], executable?: string, args?: string[], versionArgs?: string[], maxArgvPromptBytes?: number, requiredCapabilities?: CapabilityRequirements, costRank?: number, fallback?: string, vendor: string, tier?: number|string, stallTimeoutSec?: number}} ValidatedRuntime */
|
|
67
|
+
/** @typedef {{harness: "claude"|"codex"|"agy"|"dsh"|"zcode"|"exec-jsonl"|"replay", model: string, reasoning?: string, sandbox?: "read-only"|"workspace-write"|"danger-full-access", permissionMode?: string, config?: Record<string, unknown>, printTimeout?: string, tools?: string[], executable?: string, args?: string[], versionArgs?: string[], maxArgvPromptBytes?: number, requiredCapabilities?: CapabilityRequirements, costRank?: number, fallback?: string, vendor: string, tier?: number|string, stallTimeoutSec?: number, maxConcurrent?: number}} ValidatedRuntime */
|
|
59
68
|
|
|
60
69
|
/** @typedef {{enabled: boolean, review?: ("none"|"advisory"|"blocking"), runtime?: string, failOn?: ("minor"|"major"|"critical")[], maxRevisions?: number, requiredCapabilities?: CapabilityRequirements, skipWhen?: {verificationGreen: true, maxChangedPaths: number}}} ValidatedGate */
|
|
61
70
|
|
|
62
|
-
/** @typedef {{id: string, type: string, phase: string, runtime?: string, dependsOn: string[], taskPacket: TaskPacket, taskPacketFile?: string, prompt: string, definitionOfDone: import("./definition-of-done.mjs").DefinitionOfDoneItem[], gate: ValidatedGate, timeoutSec?: number, requiredCapabilities: CapabilityRequirements, packetHash: string, sourceIdentity: SourceIdentity, replayPolicy: "safe"|"reconcile"|"never"}} ValidatedNode */
|
|
71
|
+
/** @typedef {{id: string, type: string, phase: string, requirementIds?: string[], runtime?: string, dependsOn: string[], taskPacket: TaskPacket, taskPacketFile?: string, prompt: string, definitionOfDone: import("./definition-of-done.mjs").DefinitionOfDoneItem[], gate: ValidatedGate, timeoutSec?: number, maxTurns?: number, requiredCapabilities: CapabilityRequirements, packetHash: string, sourceIdentity: SourceIdentity, replayPolicy: "safe"|"reconcile"|"never"}} ValidatedNode */
|
|
63
72
|
|
|
64
|
-
/** @typedef {{schemaVersion: number, contractVersion: string, id: string, campaignId: string, goal: string, cwd: string, sourceIdentity: SourceIdentity, runtimes: Record<string, ValidatedRuntime>, runtimeDefaults: {worker?: string, judge?: string}, nodes: ValidatedNode[], maxParallel: number, pollIntervalMs: number, stallTimeoutSec: number, timeoutSec: number, finalVerification?: VerificationCommand[], sharedVerification?: VerificationCommand[], nodeAdvisory?: NodeAdvisoryPolicy, warnings: string[]}} ValidatedContract */
|
|
73
|
+
/** @typedef {{schemaVersion: number, contractVersion: string, id: string, campaignId: string, goal: string, cwd: string, sourceIdentity: SourceIdentity, runtimes: Record<string, ValidatedRuntime>, runtimeDefaults: {worker?: string, judge?: string}, nodes: ValidatedNode[], maxParallel: number, pollIntervalMs: number, stallTimeoutSec: number, timeoutSec: number, maxTurns: number, phaseSessionReuse: boolean, finalVerification?: VerificationCommand[], sharedVerification?: VerificationCommand[], nodeAdvisory?: NodeAdvisoryPolicy, warnings: string[]}} ValidatedContract */
|
|
65
74
|
/** @typedef {{costUsd?: number, durationSec?: number}} NodeAdvisoryPolicy */
|
|
66
75
|
|
|
67
76
|
/** @typedef {"pending"|"running"|"done"|"no-op"|"blocked"|"failed"|"exhausted"|"stalled"|"canceled"} NodeStatus */
|
|
@@ -89,11 +98,11 @@ const GATE_REVIEWS = new Set(["none", "advisory", "blocking"]);
|
|
|
89
98
|
/** @typedef {{history: RoutingHistoryEntry[], currentOverride: RoutingOverride|null, assignments?: RuntimeAssignments, availability?: Record<string, RuntimeAvailability>, tierExhaustion?: TierExhaustion, tierExhaustionCycle?: number}} RoutingState */
|
|
90
99
|
/** @typedef {{revision?: number, heartbeatCount: number, dryHeartbeatCount: number, progressSignature?: string|null, lastHeartbeatAt: string|null, lastProgressAt: string|null, nextCheckAt?: string|null}} ProgressState */
|
|
91
100
|
/** @typedef {{status: "unassigned"|"provisioning"|"ready"|"failed"|"removed", path: string|null, branch: string|null, commit: string|null, baseSha?: string|null, sealedSha?: string|null, sealError?: string|null, previousAttempt?: number|null}} WorktreeState */
|
|
92
|
-
/** @typedef {{schemaVersion: number, contractVersion: string, id: string, type: string, sourceIdentity: SourceIdentity, packetHash: string, status: NodeStatus, phase: NodePhase, attempt: number, revisions: number, judgeFailures?: number, review?: ("none"|"advisory"|"blocking"), runtime: RuntimeSnapshot|null, blockedBy: string[], startedAt: string|null, updatedAt: string, result: unknown, gate: GateResult|null, error: SnapshotError|null, usage?: Usage, costUsd?: number, routing?: RoutingState|null, progress?: ProgressState|null, worktree?: WorktreeState|null, integratedHead?: string|null, invocations?: Invocation[], executionOverrides?: ExecutionOverride[], verification?: VerificationState|null, scope?: BoundedScope|null, scopeFindings?: ScopeFindings|null, previousAttempt?: string, sessionPolicy?: {forceFresh?: boolean}|null, declaredReadBytes?: number|null}} NodeSnapshot */
|
|
101
|
+
/** @typedef {{schemaVersion: number, contractVersion: string, id: string, type: string, sourceIdentity: SourceIdentity, packetHash: string, requirementIds?: string[], status: NodeStatus, phase: NodePhase, attempt: number, revisions: number, judgeFailures?: number, review?: ("none"|"advisory"|"blocking"), runtime: RuntimeSnapshot|null, blockedBy: string[], startedAt: string|null, updatedAt: string, result: unknown, gate: GateResult|null, error: SnapshotError|null, usage?: Usage, costUsd?: number, routing?: RoutingState|null, progress?: ProgressState|null, worktree?: WorktreeState|null, integratedHead?: string|null, invocations?: Invocation[], executionOverrides?: ExecutionOverride[], verification?: VerificationState|null, scope?: BoundedScope|null, scopeFindings?: ScopeFindings|null, previousAttempt?: string, sessionPolicy?: {forceFresh?: boolean}|null, declaredReadBytes?: number|null}} NodeSnapshot */
|
|
93
102
|
/** @typedef {{path: string, sha: string}} ControllerIdentity */
|
|
94
103
|
/** @typedef {{schemaVersion: number, contractVersion: string, pid: number, processStartToken: string|null, startedAt: string, sourceIdentity: SourceIdentity, controllerIdentity?: ControllerIdentity, integrationRef?: string, identityWarnings?: string[], relaunchCount?: number, lastRelaunchProgressAt?: string|null, attention?: {code: string, message: string, at: string}|null, contractDigest?: string, scopeDecision?: ScopeDecision, autoRetries?: Record<string, {code: string, at: string}>}} RunMetadata */
|
|
95
104
|
/** @typedef {{at: string, base: string|null, dirtyTreeFingerprint: string|null}} ScopeDecision */
|
|
96
|
-
/** @typedef {{schemaVersion: number, contractVersion: string, at: string, node: string, from?: string, to: string, type?: string, phase?: string, attempt?: number, role?: "worker"|"judge", status?: NodeStatus, runtime?: string, currentRuntime?: string, errorCode?: string, error?: SnapshotError, verdict?: string, summary?: string, revisions?: number, sourceIdentity: SourceIdentity, packetHash: string, override?: unknown, recovery?: unknown, invocationId?: string, unexpectedPaths?: string[], unexpectedPathCount?: number}} EventRecord */
|
|
105
|
+
/** @typedef {{schemaVersion: number, contractVersion: string, at: string, node: string, from?: string, to: string, type?: string, phase?: string, attempt?: number, role?: "worker"|"judge", status?: NodeStatus, runtime?: string, currentRuntime?: string, errorCode?: string, error?: SnapshotError, verdict?: string, summary?: string, revisions?: number, requirementIds?: string[], sourceIdentity: SourceIdentity, packetHash: string, override?: unknown, recovery?: unknown, invocationId?: string, unexpectedPaths?: string[], unexpectedPathCount?: number}} EventRecord */
|
|
97
106
|
|
|
98
107
|
/**
|
|
99
108
|
* Validate and canonicalize the versioned contract. Runtime JSON remains
|
|
@@ -170,6 +179,17 @@ export function validateContract(raw, contractPath, options = {}) {
|
|
|
170
179
|
ids.add(node.id);
|
|
171
180
|
requireString(node.type, `nodes[${index}].type`);
|
|
172
181
|
boundedString(node.phase, `nodes[${index}].phase`, 128);
|
|
182
|
+
// A node's requirementIds are inherited from its phase at freeze time
|
|
183
|
+
// (src/plan/freeze.mjs stamps them) and are optional on read: a contract
|
|
184
|
+
// without the field loads unchanged, which is what keeps CONTRACT_VERSION
|
|
185
|
+
// at 0.3.0 while requirement ids reach the node.
|
|
186
|
+
const requirementIds = node.requirementIds;
|
|
187
|
+
if (requirementIds !== undefined) {
|
|
188
|
+
if (!Array.isArray(requirementIds) || requirementIds.length > 64) {
|
|
189
|
+
throw new TypeError(`nodes[${index}].requirementIds must be an array of at most 64 requirement ids`);
|
|
190
|
+
}
|
|
191
|
+
requirementIds.forEach((id, position) => boundedString(id, `nodes[${index}].requirementIds[${position}]`, 128));
|
|
192
|
+
}
|
|
173
193
|
if (node.runtime !== undefined) requireRuntime(runtimes, node.runtime, `nodes[${index}].runtime`);
|
|
174
194
|
const dependsOn = node.dependsOn ?? [];
|
|
175
195
|
if (!Array.isArray(dependsOn) || dependsOn.some((id) => typeof id !== "string")) {
|
|
@@ -220,6 +240,9 @@ export function validateContract(raw, contractPath, options = {}) {
|
|
|
220
240
|
const timeoutSec = node.timeoutSec === undefined
|
|
221
241
|
? undefined
|
|
222
242
|
: positiveNumber(node.timeoutSec, `nodes[${index}].timeoutSec`);
|
|
243
|
+
const maxTurns = node.maxTurns === undefined
|
|
244
|
+
? undefined
|
|
245
|
+
: positiveInteger(node.maxTurns, `nodes[${index}].maxTurns`);
|
|
223
246
|
const replayPolicy = validateReplayPolicy(node.replayPolicy, `nodes[${index}]`);
|
|
224
247
|
return /** @type {ValidatedNode} */ ({
|
|
225
248
|
...node,
|
|
@@ -232,6 +255,7 @@ export function validateContract(raw, contractPath, options = {}) {
|
|
|
232
255
|
prompt,
|
|
233
256
|
gate,
|
|
234
257
|
timeoutSec,
|
|
258
|
+
maxTurns,
|
|
235
259
|
replayPolicy,
|
|
236
260
|
});
|
|
237
261
|
});
|
|
@@ -362,6 +386,14 @@ export function validateContract(raw, contractPath, options = {}) {
|
|
|
362
386
|
pollIntervalMs: positiveInteger(raw.pollIntervalMs ?? 1_000, "contract.pollIntervalMs"),
|
|
363
387
|
stallTimeoutSec: positiveNumber(raw.stallTimeoutSec ?? 300, "contract.stallTimeoutSec"),
|
|
364
388
|
timeoutSec: positiveNumber(raw.timeoutSec ?? 2_400, "contract.timeoutSec"),
|
|
389
|
+
maxTurns: positiveInteger(raw.maxTurns ?? DEFAULT_MAX_TURNS, "contract.maxTurns"),
|
|
390
|
+
// Opt-in: a phase sibling's provider session is rotated (fresh session,
|
|
391
|
+
// structured summaries carried) unless the contract asks to reuse it.
|
|
392
|
+
// measured 2026-09-20 over 21 runs with both kinds of turn: a turn opened
|
|
393
|
+
// on a sibling's session cost 1.87x the fresh one at the same request
|
|
394
|
+
// count, because it began with 200k tokens of context instead of 45k and
|
|
395
|
+
// re-read them on every request.
|
|
396
|
+
phaseSessionReuse: booleanField(raw.phaseSessionReuse, false, "contract.phaseSessionReuse"),
|
|
365
397
|
finalVerification: validateFinalVerification(raw.finalVerification, "contract.finalVerification"),
|
|
366
398
|
sharedVerification: validateSharedVerification(raw.sharedVerification, "contract.sharedVerification"),
|
|
367
399
|
nodeAdvisory: validateNodeAdvisory(raw.nodeAdvisory),
|
|
@@ -447,8 +479,16 @@ function validateGate(gate, runtimes, index, nodeId) {
|
|
|
447
479
|
assertObject(gate, `nodes[${index}].gate`);
|
|
448
480
|
rejectUnknown(gate, GATE_FIELDS, `nodes[${index}].gate`);
|
|
449
481
|
if (gate.enabled === false) {
|
|
450
|
-
|
|
451
|
-
|
|
482
|
+
// A disabled gate reviews nothing, but the node keeps its revision budget
|
|
483
|
+
// for a red verification (default 1, as with a gate); that budget is the
|
|
484
|
+
// one field the disabled shape may carry.
|
|
485
|
+
if (Object.keys(gate).some((key) => key !== "enabled" && key !== "maxRevisions")) {
|
|
486
|
+
throw new TypeError(`nodes[${index}].gate disabled shape only allows enabled and maxRevisions`);
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
enabled: false,
|
|
490
|
+
...(gate.maxRevisions === undefined ? {} : { maxRevisions: nonNegativeInteger(gate.maxRevisions, `nodes[${index}].gate.maxRevisions`) }),
|
|
491
|
+
};
|
|
452
492
|
}
|
|
453
493
|
if (gate.enabled !== undefined && gate.enabled !== true) {
|
|
454
494
|
throw new TypeError(`nodes[${index}].gate.enabled must be true or false`);
|
|
@@ -686,3 +726,10 @@ function validateMaxParallel(value) {
|
|
|
686
726
|
// beyond being a sane positive integer.
|
|
687
727
|
return positiveInteger(value, "contract.maxParallel");
|
|
688
728
|
}
|
|
729
|
+
|
|
730
|
+
/** @param {unknown} value @param {boolean} fallback @param {string} label @returns {boolean} */
|
|
731
|
+
function booleanField(value, fallback, label) {
|
|
732
|
+
if (value === undefined) return fallback;
|
|
733
|
+
if (typeof value !== "boolean") throw new TypeError(`${label} must be a boolean`);
|
|
734
|
+
return value;
|
|
735
|
+
}
|
package/src/contract/runtime.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import { stableJson } from "../util.mjs";
|
|
|
22
22
|
const RUNTIME_FIELDS = new Set([
|
|
23
23
|
"harness", "model", "reasoning", "sandbox", "permissionMode", "config", "printTimeout", "tools",
|
|
24
24
|
"executable", "args", "versionArgs", "maxArgvPromptBytes", "requiredCapabilities", "costRank",
|
|
25
|
-
"fallback", "vendor", "tier", "pricing", "stallTimeoutSec",
|
|
25
|
+
"fallback", "vendor", "tier", "pricing", "stallTimeoutSec", "maxConcurrent",
|
|
26
26
|
]);
|
|
27
27
|
const RUNTIME_HARNESSES = new Set(["claude", "codex", "agy", "dsh", "zcode", "exec-jsonl", "replay"]);
|
|
28
28
|
|
|
@@ -98,6 +98,9 @@ function validateRuntimeValues(runtime, label, executableRequired) {
|
|
|
98
98
|
throw new TypeError(`${label}.sandbox is invalid`);
|
|
99
99
|
}
|
|
100
100
|
if (runtime.permissionMode !== undefined) requireString(runtime.permissionMode, `${label}.permissionMode`);
|
|
101
|
+
// How many attempts this runtime may run at once, below the run's
|
|
102
|
+
// maxParallel; absent leaves only maxParallel to bound it.
|
|
103
|
+
if (runtime.maxConcurrent !== undefined) positiveInteger(runtime.maxConcurrent, `${label}.maxConcurrent`);
|
|
101
104
|
if (runtime.config !== undefined && (!runtime.config || typeof runtime.config !== "object" || Array.isArray(runtime.config))) {
|
|
102
105
|
throw new TypeError(`${label}.config must be an object`);
|
|
103
106
|
}
|
|
@@ -127,7 +127,7 @@ export function validateNodeSnapshot(value, expectedNode = null) {
|
|
|
127
127
|
assertObject(value, "node snapshot");
|
|
128
128
|
rejectUnknown(value, new Set([
|
|
129
129
|
"schemaVersion", "contractVersion", "id", "type", "sourceIdentity", "packetHash", "status", "phase",
|
|
130
|
-
"attempt", "revisions", "judgeFailures", "runtime", "blockedBy", "startedAt", "updatedAt", "result", "gate", "error", "usage",
|
|
130
|
+
"attempt", "revisions", "judgeFailures", "requirementIds", "runtime", "blockedBy", "startedAt", "updatedAt", "result", "gate", "error", "usage",
|
|
131
131
|
"costUsd", "routing", "progress", "worktree", "invocations", "executionOverrides", "verification", "scope",
|
|
132
132
|
"scopeFindings", "review", "previousAttempt", "sessionPolicy", "integratedHead", "declaredReadBytes",
|
|
133
133
|
]), "node snapshot");
|
|
@@ -139,6 +139,10 @@ export function validateNodeSnapshot(value, expectedNode = null) {
|
|
|
139
139
|
nonNegativeInteger(value.attempt, "node snapshot.attempt");
|
|
140
140
|
nonNegativeInteger(value.revisions, "node snapshot.revisions");
|
|
141
141
|
if (value.judgeFailures !== undefined) nonNegativeInteger(value.judgeFailures, "node snapshot.judgeFailures");
|
|
142
|
+
// The phase requirement ids the engine stamped onto the node when its result
|
|
143
|
+
// was accepted; optional on read so snapshots written before the stamp load
|
|
144
|
+
// unchanged.
|
|
145
|
+
if (value.requirementIds !== undefined) validateRequirementIdList(value.requirementIds, "node snapshot.requirementIds");
|
|
142
146
|
// The review mode that governed the attempt's gate, recorded so a status
|
|
143
147
|
// surface can tell an advisory finding from a below-threshold blocking one.
|
|
144
148
|
if (value.review !== undefined && !REVIEW_MODES.has(/** @type {string} */ (value.review))) {
|
|
@@ -206,7 +210,7 @@ export function validateEvent(value) {
|
|
|
206
210
|
assertObject(value, "event");
|
|
207
211
|
rejectUnknown(value, new Set([
|
|
208
212
|
"schemaVersion", "contractVersion", "at", "node", "from", "to", "type", "phase", "attempt", "runtime",
|
|
209
|
-
"role", "status", "currentRuntime", "errorCode", "error", "verdict", "summary", "revisions", "sourceIdentity", "packetHash", "override", "recovery", "invocationId", "unexpectedPaths", "unexpectedPathCount",
|
|
213
|
+
"role", "status", "currentRuntime", "errorCode", "error", "verdict", "summary", "revisions", "requirementIds", "sourceIdentity", "packetHash", "override", "recovery", "invocationId", "unexpectedPaths", "unexpectedPathCount",
|
|
210
214
|
]), "event");
|
|
211
215
|
validateMetadata(value, "event");
|
|
212
216
|
requireString(value.at, "event.at");
|
|
@@ -230,8 +234,23 @@ export function validateEvent(value) {
|
|
|
230
234
|
}
|
|
231
235
|
}
|
|
232
236
|
if (value.unexpectedPathCount !== undefined) nonNegativeInteger(value.unexpectedPathCount, "event.unexpectedPathCount");
|
|
237
|
+
if (value.requirementIds !== undefined) validateRequirementIdList(value.requirementIds, "event.requirementIds");
|
|
233
238
|
return /** @type {EventRecord} */ (value);
|
|
234
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* The requirement ids a node inherited from its phase, stamped by the engine
|
|
242
|
+
* and carried beside the result on both the snapshot and the transition event.
|
|
243
|
+
* Bounded like every persisted list: at most 64 ids of at most 128 bytes.
|
|
244
|
+
*
|
|
245
|
+
* @param {unknown} value
|
|
246
|
+
* @param {string} label
|
|
247
|
+
*/
|
|
248
|
+
function validateRequirementIdList(value, label) {
|
|
249
|
+
if (!Array.isArray(value) || value.length > 64) {
|
|
250
|
+
throw new TypeError(`${label} must be an array of at most 64 requirement ids`);
|
|
251
|
+
}
|
|
252
|
+
for (const [index, id] of value.entries()) boundedString(id, `${label}[${index}]`, 128);
|
|
253
|
+
}
|
|
235
254
|
/**
|
|
236
255
|
* @param {unknown} value
|
|
237
256
|
* @param {string} label
|
|
@@ -246,7 +265,7 @@ function validateInvocations(value, label) {
|
|
|
246
265
|
"promptPath", "stdoutPath", "stderrPath", "startedAt", "updatedAt", "closedAt", "deadlineAt",
|
|
247
266
|
"exitCode", "signal", "status", "executable", "usage", "usageEstimated", "costUsd", "costProvenance", "snapshotPath", "revision", "cycle",
|
|
248
267
|
"runId", "campaignId", "planPhase", "role", "runtimeFingerprint", "model", "reasoning", "sandbox", "continuationId", "continuationMode",
|
|
249
|
-
"nodeId", "attempt", "workspace", "worktreeBranch", "worktreeBaseSha",
|
|
268
|
+
"nodeId", "attempt", "workspace", "worktreeBranch", "worktreeBaseSha", "session",
|
|
250
269
|
]);
|
|
251
270
|
rejectUnknown(invocation, allowed, `${label}[${index}]`);
|
|
252
271
|
requireString(invocation.id, `${label}[${index}].id`);
|
|
@@ -272,6 +291,7 @@ function validateInvocations(value, label) {
|
|
|
272
291
|
if (!['fresh', 'reuse', 'rotate'].includes(/** @type {string} */ (invocation.continuationMode))) {
|
|
273
292
|
throw new TypeError(`${label}[${index}].continuationMode is invalid`);
|
|
274
293
|
}
|
|
294
|
+
if (invocation.session !== undefined && invocation.session !== null) validateSessionLedger(invocation.session, `${label}[${index}].session`);
|
|
275
295
|
if (invocation.revision !== undefined) nonNegativeInteger(invocation.revision, `${label}[${index}].revision`);
|
|
276
296
|
if (invocation.cycle !== undefined) nonNegativeInteger(invocation.cycle, `${label}[${index}].cycle`);
|
|
277
297
|
for (const key of ["promptPath", "stdoutPath", "stderrPath", "executable"]) {
|
|
@@ -643,3 +663,26 @@ function validateScopeBoundarySnapshot(value, label) {
|
|
|
643
663
|
}
|
|
644
664
|
}
|
|
645
665
|
}
|
|
666
|
+
|
|
667
|
+
const SESSION_LEDGER_FIELDS = new Set(["turns", "toolCalls", "requests", "contextFirst", "contextMax", "contextLast", "contextSum", "completed"]);
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* The per-request ledger an invocation carries (`session-metrics.mjs`'s
|
|
671
|
+
* SessionLedger): turns and tool calls are non-negative integers; requests
|
|
672
|
+
* and the context fields are non-negative integers, or null when the stream
|
|
673
|
+
* carried no usage per request (codex reports per turn, zcode emits one
|
|
674
|
+
* document), which is unknown, not zero.
|
|
675
|
+
*
|
|
676
|
+
* @param {unknown} value
|
|
677
|
+
* @param {string} label
|
|
678
|
+
*/
|
|
679
|
+
function validateSessionLedger(value, label) {
|
|
680
|
+
assertObject(value, label);
|
|
681
|
+
const record = /** @type {Record<string, unknown>} */ (value);
|
|
682
|
+
rejectUnknown(record, SESSION_LEDGER_FIELDS, label);
|
|
683
|
+
for (const key of ["turns", "toolCalls"]) nonNegativeInteger(record[key], `${label}.${key}`);
|
|
684
|
+
for (const key of ["requests", "contextFirst", "contextMax", "contextLast", "contextSum"]) {
|
|
685
|
+
if (record[key] !== null) nonNegativeInteger(record[key], `${label}.${key}`);
|
|
686
|
+
}
|
|
687
|
+
if (typeof record.completed !== "boolean") throw new TypeError(`${label}.completed must be a boolean`);
|
|
688
|
+
}
|
|
@@ -25,7 +25,11 @@ const PROMPT_MAX_BYTES = 64 * 1024;
|
|
|
25
25
|
* process-free command, so a sandbox that cannot signal processes never hangs
|
|
26
26
|
* on the node's own verification.
|
|
27
27
|
*/
|
|
28
|
-
|
|
28
|
+
// measured 2026-09-20 over 228 stored claude worker turns: tool results are
|
|
29
|
+
// 73% of what enters the context after the packet (Read 38%, Bash 32%), edits
|
|
30
|
+
// and whole-file writes 24%, and each byte is re-read by a median of 49 later
|
|
31
|
+
// requests of the same turn. Hence the last sentence.
|
|
32
|
+
const VERIFICATION_PARAGRAPH = "The controller runs every command below after you report; its recorded results are the proof of this node. Running a command yourself is optional and only for one that finishes in seconds and spawns no long-lived process. Keep output bounded (pipe through `| tail -n 200`). Never wait on a background job, never run the whole test suite, and never run tests that start and terminate other processes. Prefer a targeted edit over rewriting a whole file, and read with an offset and limit rather than whole files: every byte you read or write is re-read by every later request of this turn.";
|
|
29
33
|
|
|
30
34
|
/**
|
|
31
35
|
* The `## Required output` schema every mode states: literally every key
|
package/src/engine/backoff.mjs
CHANGED
|
@@ -321,7 +321,8 @@ export function networkTransition(contract, node, state, role, envelope, exitCod
|
|
|
321
321
|
* @returns {boolean}
|
|
322
322
|
*/
|
|
323
323
|
export function isRepairable(node, state) {
|
|
324
|
-
|
|
324
|
+
// The budget is the node's, gate or not: see settle.mjs applyRejection.
|
|
325
|
+
return (state.revisions ?? 0) < (node.gate.maxRevisions ?? 1);
|
|
325
326
|
}
|
|
326
327
|
|
|
327
328
|
/**
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-runtime dispatch capacity: how many attempts one runtime may run at
|
|
3
|
+
* once (`maxConcurrent`; absent means only `maxParallel` bounds it) and which
|
|
4
|
+
* runtimes are on a quota hold -- a node of theirs is waiting out a provider
|
|
5
|
+
* exhaustion backoff on that same runtime, so dispatching a sibling to it
|
|
6
|
+
* would spend the next quota window on a refusal the run already knows
|
|
7
|
+
* about. Separate from the scheduler because it is pure over the running set
|
|
8
|
+
* and the persisted snapshots, and from failover.mjs because that decides one
|
|
9
|
+
* node's route while this decides whether the tick may start another.
|
|
10
|
+
*
|
|
11
|
+
* measured 2026-09-20: `maxParallel` was the one concurrency knob, global to
|
|
12
|
+
* the run; nothing reduced dispatch to a provider that had just refused a
|
|
13
|
+
* sibling on quota, and 14 of 58 stored contracts ran with maxParallel 2.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
|
|
17
|
+
/** @typedef {import("../contract/index.mjs").NodeSnapshot} NodeSnapshot */
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Live attempts per runtime id, from the jobs a tick is holding.
|
|
21
|
+
*
|
|
22
|
+
* @param {Iterable<{runtime: {id: string|null}}>} running
|
|
23
|
+
* @returns {Map<string, number>}
|
|
24
|
+
*/
|
|
25
|
+
export function runningPerRuntime(running) {
|
|
26
|
+
/** @type {Map<string, number>} */
|
|
27
|
+
const counts = new Map();
|
|
28
|
+
for (const job of running) {
|
|
29
|
+
const id = job.runtime.id;
|
|
30
|
+
if (typeof id !== "string") continue;
|
|
31
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
32
|
+
}
|
|
33
|
+
return counts;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Runtimes some node is waiting to use again after a provider exhaustion:
|
|
38
|
+
* the node's current routing override still has a `backoffUntil` in the
|
|
39
|
+
* future, it points back at the runtime that was exhausted (a reset wait, not
|
|
40
|
+
* a hop to another runtime), and the exhaustion was the last routing event.
|
|
41
|
+
* A network wait or an ordinary provider failure holds nothing.
|
|
42
|
+
*
|
|
43
|
+
* @param {Iterable<NodeSnapshot>} states
|
|
44
|
+
* @param {number} now epoch milliseconds
|
|
45
|
+
* @returns {Set<string>}
|
|
46
|
+
*/
|
|
47
|
+
export function quotaHeldRuntimes(states, now) {
|
|
48
|
+
/** @type {Set<string>} */
|
|
49
|
+
const held = new Set();
|
|
50
|
+
for (const state of states) {
|
|
51
|
+
const override = state.routing?.currentOverride;
|
|
52
|
+
const last = state.routing?.history?.at(-1);
|
|
53
|
+
if (!override?.backoffUntil || Date.parse(override.backoffUntil) <= now) continue;
|
|
54
|
+
if (!override.runtime || !last || last.runtime !== override.runtime) continue;
|
|
55
|
+
if (last.status !== "exhausted") continue;
|
|
56
|
+
held.add(override.runtime);
|
|
57
|
+
}
|
|
58
|
+
return held;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether one more attempt may start on `runtimeId` right now.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} runtimeId
|
|
65
|
+
* @param {ValidatedContract} contract
|
|
66
|
+
* @param {Map<string, number>} counts live attempts per runtime, `runningPerRuntime`'s shape
|
|
67
|
+
* @param {Set<string>} held `quotaHeldRuntimes`'s result
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
export function runtimeHasCapacity(runtimeId, contract, counts, held) {
|
|
71
|
+
if (held.has(runtimeId)) return false;
|
|
72
|
+
const limit = contract.runtimes[runtimeId]?.maxConcurrent;
|
|
73
|
+
return typeof limit !== "number" || (counts.get(runtimeId) ?? 0) < limit;
|
|
74
|
+
}
|