toolroll 0.9.90 → 0.9.92
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/CHANGELOG.md +41 -0
- package/dist/approval-authority.d.ts +9 -1
- package/dist/approval-authority.js +15 -2
- package/dist/build-review.js +2 -0
- package/dist/chat-actions.d.ts +11 -3
- package/dist/chat-actions.js +47 -2
- package/dist/chat-decide.js +2 -0
- package/dist/chat-review.d.ts +2 -1
- package/dist/chat-review.js +2 -2
- package/dist/contracts/chat-actions.d.ts +10 -0
- package/dist/contracts/chat-actions.js +4 -0
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +3 -0
- package/dist/contracts/route-results.d.ts +121 -0
- package/dist/contracts/route-results.js +71 -0
- package/dist/contracts/route.d.ts +343 -5
- package/dist/contracts/route.js +36 -7
- package/dist/contracts/store-json.d.ts +2 -0
- package/dist/dispose.js +6 -0
- package/dist/flow-engine.js +1 -1
- package/dist/flow-pull-request.js +1 -1
- package/dist/lead-doors.js +4 -2
- package/dist/lead-tools.js +10 -2
- package/dist/local-owner.d.ts +1 -1
- package/dist/local-owner.js +11 -0
- package/dist/memory-cli.d.ts +3 -0
- package/dist/memory-cli.js +3 -3
- package/dist/memory-pass.js +5 -1
- package/dist/model-choices.d.ts +7 -3
- package/dist/model-choices.js +26 -11
- package/dist/models-cli.d.ts +205 -0
- package/dist/models-cli.js +143 -4
- package/dist/models-ui.d.ts +57 -0
- package/dist/models-ui.js +44 -1
- package/dist/operate-remote-arguments.js +4 -4
- package/dist/operate-remote.js +1 -1
- package/dist/operate.js +53 -5
- package/dist/phase-routing.d.ts +82 -2
- package/dist/phase-routing.js +132 -18
- package/dist/project-delete.d.ts +4 -0
- package/dist/project-delete.js +12 -4
- package/dist/project-knowledge.d.ts +6 -6
- package/dist/project-memory.d.ts +8 -0
- package/dist/project-memory.js +20 -1
- package/dist/pull-request-flow.js +1 -1
- package/dist/release-adapters.d.ts +2 -0
- package/dist/release-adapters.js +30 -1
- package/dist/release-auto.js +12 -3
- package/dist/result-actions.d.ts +4 -0
- package/dist/result-actions.js +3 -0
- package/dist/route-hooks.d.ts +54 -0
- package/dist/route-hooks.js +179 -0
- package/dist/route-results.d.ts +70 -0
- package/dist/route-results.js +340 -0
- package/dist/route-rules.d.ts +324 -0
- package/dist/route-rules.js +781 -0
- package/dist/route-savings.d.ts +136 -0
- package/dist/route-savings.js +193 -0
- package/dist/route-store.d.ts +300 -0
- package/dist/route-store.js +598 -0
- package/dist/scope.d.ts +3 -1
- package/dist/scope.js +4 -1
- package/dist/server/render-chat.js +2 -2
- package/dist/server/render-tasks.d.ts +7 -0
- package/dist/server/render-tasks.js +13 -0
- package/dist/server/route-table.js +12 -0
- package/dist/server/settings.d.ts +2 -1
- package/dist/server/settings.js +87 -1
- package/dist/server/task-views.js +6 -0
- package/dist/server/tasks.js +3 -0
- package/dist/settings-proofs.js +2 -0
- package/dist/setup-guide.d.ts +2 -2
- package/dist/spend-ui.d.ts +11 -0
- package/dist/spend-ui.js +49 -2
- package/dist/store.d.ts +38 -2
- package/dist/store.js +320 -35
- package/dist/surface.js +16 -4
- package/dist/task-outcome-cli.js +5 -2
- package/dist/toolroll-update.js +3 -2
- package/package.json +1 -1
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only writer of the results-routing rows (docs/plans/results-routing.md, Protection item 1): run_cohort,
|
|
3
|
+
* route_record, models_generation, route_rule, route_exposure*, route_trial_reservation, run.exec_class and
|
|
4
|
+
* task_ref.revision_origin. Release-auto's routing scan (route-hooks.ts) needs a person for any SQL that writes them
|
|
5
|
+
* from anywhere else (the migration and the Models row writers in store.ts aside).
|
|
6
|
+
*
|
|
7
|
+
* Each writer enforces its own invariants, so a new caller can't skip a guard that lives in its caller: a class or
|
|
8
|
+
* an origin outside its list refuses, a first write is never overwritten, a cohort is written only for a code-writing
|
|
9
|
+
* run that exists, and a record is saved only in a state/reason pair the contract names.
|
|
10
|
+
*
|
|
11
|
+
* A policy path (release-auto.ts EVAL_POLICY).
|
|
12
|
+
*/
|
|
13
|
+
import { isProtectedWork } from "./approval-policy.js";
|
|
14
|
+
import { trialSealStandsSql } from "./approval-authority.js";
|
|
15
|
+
import { currentLocalOwner, localOwnerInFlight, localOwnerStandsNow } from "./local-owner.js";
|
|
16
|
+
import { passwordlessOn } from "./passwordless.js";
|
|
17
|
+
import { machineActing } from "./scope.js";
|
|
18
|
+
import { NOT_COUNTED_REASONS, RECORD_STATES, REJECT_REASONS, REVISION_ORIGINS } from "./contracts/route-results.js";
|
|
19
|
+
import { BUILT_IN, nightlyTaskRef, releaseTaskRef } from "./store.js";
|
|
20
|
+
// ---- execution class --------------------------------------------------------------------------------------------
|
|
21
|
+
/** A failed run's raw reason, by whose account it failed. Anything not listed counts as the model's (conservative: a
|
|
22
|
+
* missing or unknown class counts against the cheaper model, never for it). */
|
|
23
|
+
export const EXEC_CLASS_OF_REASON = Object.freeze({
|
|
24
|
+
timeout: "model", agent: "model", "agent-reported": "model", "no-op": "model", "no-handoff": "model",
|
|
25
|
+
"moved-head": "model", "moved-branch": "model", "commit-failure": "model",
|
|
26
|
+
git: "infrastructure", setup: "infrastructure", "provider-init": "infrastructure", "provider-unattested": "infrastructure",
|
|
27
|
+
"provider-protocol": "infrastructure", stopped: "infrastructure",
|
|
28
|
+
});
|
|
29
|
+
/** The gateway's terminal classes that say the provider, not the model, ended the attempt. */
|
|
30
|
+
export const INFRASTRUCTURE_TERMINALS = new Set(["auth-expired", "usage-exhausted", "credits-depleted", "transient-throttle"]);
|
|
31
|
+
/** The class a failed run's raw reason (before the tick's normalization) and its terminal class give. */
|
|
32
|
+
export function execClassOf(reason, terminalClass) {
|
|
33
|
+
if (terminalClass !== null && INFRASTRUCTURE_TERMINALS.has(terminalClass))
|
|
34
|
+
return "infrastructure";
|
|
35
|
+
return Object.prototype.hasOwnProperty.call(EXEC_CLASS_OF_REASON, reason) ? EXEC_CLASS_OF_REASON[reason] : "model";
|
|
36
|
+
}
|
|
37
|
+
/** The sealed facts a code-writing run's eligibility is decided from, conservatively: anything unreadable or not
|
|
38
|
+
* sealed is not eligible. Pure reads of the store, in the admission transaction. */
|
|
39
|
+
export function cohortOf(store, input) {
|
|
40
|
+
const base = {
|
|
41
|
+
run: input.run, taskRef: input.taskRef, rootRef: input.taskRef, repo: null, role: input.role, size: null, risky: null, qualityMode: input.qualityMode,
|
|
42
|
+
evidenceDemands: false, publication: "none", release: false, protected: false, selection: { kind: "configured" },
|
|
43
|
+
routeDigest: input.routeDigest, provider: input.provider, model: input.model, cohortSize: null, ineligible: null, startedAt: input.startedAt,
|
|
44
|
+
};
|
|
45
|
+
const ineligible = (row, reason) => ({ ...row, cohortSize: null, ineligible: reason });
|
|
46
|
+
const ref = store.handle.prepare("SELECT backend, external_id, repo FROM task_ref WHERE id = ?").get(input.taskRef);
|
|
47
|
+
if (ref === undefined || ref["backend"] !== BUILT_IN)
|
|
48
|
+
return ineligible(base, "not-built-in");
|
|
49
|
+
const taskId = String(ref["external_id"]);
|
|
50
|
+
const repo = ref["repo"] === null ? null : String(ref["repo"]);
|
|
51
|
+
// The family's root, so the record pass finds the family without walking it: a lineage that can't be proved is
|
|
52
|
+
// its own root (and its record says so).
|
|
53
|
+
const family = store.taskFamilyOf(taskId, null, true);
|
|
54
|
+
const rootRef = family !== null && family.problem === null ? family.root.refId : input.taskRef;
|
|
55
|
+
const sealed = store.sealedRouteOf(taskId);
|
|
56
|
+
if (!sealed.ok)
|
|
57
|
+
return ineligible({ ...base, rootRef, repo }, "no-sealed-route");
|
|
58
|
+
const route = sealed.route;
|
|
59
|
+
const build = route.legs[1];
|
|
60
|
+
const selection = build.selection ?? { kind: build.chosen === "override" ? "override" : build.chosen === "pinned" ? "pin" : "configured" };
|
|
61
|
+
const scope = store.getScope(taskId);
|
|
62
|
+
const terms = store.handle.prepare("SELECT release_terms_json, release_check_json FROM task_scope WHERE task_id = ?").get(taskId);
|
|
63
|
+
const release = releaseTaskRef(store, input.taskRef) || nightlyTaskRef(store, input.taskRef)
|
|
64
|
+
|| (terms !== undefined && (terms["release_terms_json"] != null || terms["release_check_json"] != null));
|
|
65
|
+
const isProtected = repo === null ? false : isProtectedWork(store.approvalRules(repo), scope?.touches ?? []);
|
|
66
|
+
const row = {
|
|
67
|
+
...base, rootRef, repo, size: route.size?.size ?? null, risky: route.size?.risky ?? null,
|
|
68
|
+
qualityMode: route.qualityMode === "strict" || input.qualityMode === "strict" ? "strict" : "default",
|
|
69
|
+
evidenceDemands: route.evidence.some(kind => kind === "screenshot" || kind === "manual-review"),
|
|
70
|
+
publication: route.publication, release, protected: isProtected, selection,
|
|
71
|
+
};
|
|
72
|
+
// Every reason is a sealed fact or a project rule read now; the first that applies is kept.
|
|
73
|
+
if (row.release)
|
|
74
|
+
return ineligible(row, "release");
|
|
75
|
+
if (row.protected)
|
|
76
|
+
return ineligible(row, "protected");
|
|
77
|
+
if (row.qualityMode === "strict")
|
|
78
|
+
return ineligible(row, "strict");
|
|
79
|
+
if (row.evidenceDemands)
|
|
80
|
+
return ineligible(row, "evidence-demands");
|
|
81
|
+
if (row.publication === "automerge")
|
|
82
|
+
return ineligible(row, "automerge");
|
|
83
|
+
if (row.risky !== false || route.risk !== "routine")
|
|
84
|
+
return ineligible(row, row.risky === null ? "unsized" : "risky");
|
|
85
|
+
if (row.size === null)
|
|
86
|
+
return ineligible(row, "unsized");
|
|
87
|
+
if (row.size === "large")
|
|
88
|
+
return ineligible(row, "large");
|
|
89
|
+
return { ...row, cohortSize: row.size, ineligible: null };
|
|
90
|
+
}
|
|
91
|
+
/** Keep a failed run's execution class from its raw reason, before the tick normalizes it (dispose.ts). First write
|
|
92
|
+
* wins: a later ending of the same run never rewrites the class it was kept with. */
|
|
93
|
+
export function recordExecClass(store, runId, reason) {
|
|
94
|
+
if (!Number.isSafeInteger(runId) || runId < 1 || typeof reason !== "string")
|
|
95
|
+
throw new Error("an execution class names a run and its raw reason");
|
|
96
|
+
const terminal = store.handle.prepare("SELECT terminal_class FROM run WHERE id = ?").get(runId)?.["terminal_class"];
|
|
97
|
+
const kept = execClassOf(reason, terminal == null ? null : String(terminal));
|
|
98
|
+
store.handle.prepare("UPDATE run SET exec_class = ? WHERE id = ? AND exec_class IS NULL").run(kept, runId);
|
|
99
|
+
}
|
|
100
|
+
/** Who a revision came from, written once with the revision's seal; never on a task that revises nothing. */
|
|
101
|
+
export function recordRevisionOrigin(store, taskId, origin) {
|
|
102
|
+
if (!REVISION_ORIGINS.includes(origin))
|
|
103
|
+
throw new Error(`unknown revision origin: ${String(origin)}`);
|
|
104
|
+
store.handle.prepare("UPDATE task_ref SET revision_origin = ? WHERE backend = 'built-in' AND external_id = ? AND revision_of IS NOT NULL AND revision_origin IS NULL")
|
|
105
|
+
.run(origin, taskId);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* A code-writing run's cohort snapshot, in its admission transaction (store.ts admitRun), written once and never
|
|
109
|
+
* updated. A snapshot that can't be computed is never a refused admission: the run is recorded as not eligible, and a
|
|
110
|
+
* write that fails leaves no row (its record then reads "started before results were recorded"), so a cheaper model
|
|
111
|
+
* never gains from either.
|
|
112
|
+
*/
|
|
113
|
+
export function writeRunCohort(store, input) {
|
|
114
|
+
if (input.role !== "builder" && input.role !== "repair")
|
|
115
|
+
return;
|
|
116
|
+
let row;
|
|
117
|
+
try {
|
|
118
|
+
row = cohortOf(store, input);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
row = { ...fallbackCohort(input), ineligible: "no-sealed-route" };
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
store.handle.prepare(`INSERT INTO run_cohort (run, task_ref, root_ref, repo, role, size, risky, quality_mode, evidence_demands, publication, release, protected,
|
|
125
|
+
selection_json, route_digest, provider, model, cohort_size, ineligible, started_at)
|
|
126
|
+
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM run WHERE id = ? AND role IN ('builder','repair'))
|
|
127
|
+
ON CONFLICT (run) DO NOTHING`)
|
|
128
|
+
.run(row.run, row.taskRef, row.rootRef, row.repo, row.role, row.size, row.risky === null ? null : row.risky ? 1 : 0, row.qualityMode, row.evidenceDemands ? 1 : 0, row.publication, row.release ? 1 : 0, row.protected ? 1 : 0, JSON.stringify(row.selection), row.routeDigest, row.provider, row.model, row.cohortSize, row.ineligible, row.startedAt, row.run);
|
|
129
|
+
}
|
|
130
|
+
catch { /* no snapshot: not counted, never counted as good */ }
|
|
131
|
+
}
|
|
132
|
+
function fallbackCohort(input) {
|
|
133
|
+
return { run: input.run, taskRef: input.taskRef, rootRef: input.taskRef, repo: null, role: input.role, size: null, risky: null, qualityMode: input.qualityMode,
|
|
134
|
+
evidenceDemands: false, publication: "none", release: false, protected: false, selection: { kind: "configured" },
|
|
135
|
+
routeDigest: input.routeDigest, provider: input.provider, model: input.model, cohortSize: null, startedAt: input.startedAt };
|
|
136
|
+
}
|
|
137
|
+
/** Save a family's record as evaluated now. A decision time is kept while the state stays the same. Once a verdict
|
|
138
|
+
* is final (settled) it never moves — state, reason, result run, acceptance and decision time stay as decided — but
|
|
139
|
+
* the family's spending and counts keep being updated from later revision and repair runs until the record closes
|
|
140
|
+
* (route-results.ts ACCUMULATE_FOR_MS); a closed record is never written again. */
|
|
141
|
+
export function saveResultRecord(store, value, now) {
|
|
142
|
+
if (!RECORD_STATES.includes(value.state))
|
|
143
|
+
throw new Error(`unknown record state: ${value.state}`);
|
|
144
|
+
const reasons = value.state === "rejected" ? REJECT_REASONS : value.state === "not-counted" ? NOT_COUNTED_REASONS : [];
|
|
145
|
+
if ((value.reason === null) !== (reasons.length === 0) || (value.reason !== null && !reasons.includes(value.reason)))
|
|
146
|
+
throw new Error(`a ${value.state} record can't have the reason ${String(value.reason)}`);
|
|
147
|
+
store.handle.prepare(`INSERT INTO route_record (task_ref, repo, cohort_size, provider, model, state, reason, result_run, first_started_at, accepted_at, decided_at,
|
|
148
|
+
model_failures, infra_failures, api_microusd, subscription_runs, subscription_tokens, unknown_runs, remediation_runs, person_revisions,
|
|
149
|
+
first_check_passed, agent_turns, turns_unknown_runs, provenance_json, settled, closed, evaluated_at)
|
|
150
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
151
|
+
ON CONFLICT (task_ref) DO UPDATE SET
|
|
152
|
+
decided_at = CASE WHEN route_record.settled = 1 OR (route_record.state = excluded.state AND route_record.reason IS excluded.reason)
|
|
153
|
+
THEN COALESCE(route_record.decided_at, excluded.decided_at) ELSE excluded.decided_at END,
|
|
154
|
+
state = CASE WHEN route_record.settled = 1 THEN route_record.state ELSE excluded.state END,
|
|
155
|
+
reason = CASE WHEN route_record.settled = 1 THEN route_record.reason ELSE excluded.reason END,
|
|
156
|
+
result_run = CASE WHEN route_record.settled = 1 THEN route_record.result_run ELSE excluded.result_run END,
|
|
157
|
+
accepted_at = CASE WHEN route_record.settled = 1 THEN route_record.accepted_at ELSE excluded.accepted_at END,
|
|
158
|
+
repo = CASE WHEN route_record.settled = 1 THEN route_record.repo ELSE excluded.repo END,
|
|
159
|
+
cohort_size = CASE WHEN route_record.settled = 1 THEN route_record.cohort_size ELSE excluded.cohort_size END,
|
|
160
|
+
provider = CASE WHEN route_record.settled = 1 THEN route_record.provider ELSE excluded.provider END,
|
|
161
|
+
model = CASE WHEN route_record.settled = 1 THEN route_record.model ELSE excluded.model END,
|
|
162
|
+
first_started_at = excluded.first_started_at, model_failures = excluded.model_failures, infra_failures = excluded.infra_failures,
|
|
163
|
+
api_microusd = excluded.api_microusd, subscription_runs = excluded.subscription_runs, subscription_tokens = excluded.subscription_tokens,
|
|
164
|
+
unknown_runs = excluded.unknown_runs, remediation_runs = excluded.remediation_runs, person_revisions = excluded.person_revisions,
|
|
165
|
+
first_check_passed = excluded.first_check_passed, agent_turns = excluded.agent_turns, turns_unknown_runs = excluded.turns_unknown_runs,
|
|
166
|
+
provenance_json = excluded.provenance_json,
|
|
167
|
+
settled = MAX(route_record.settled, excluded.settled),
|
|
168
|
+
closed = CASE WHEN MAX(route_record.settled, excluded.settled) = 1 THEN excluded.closed ELSE 0 END,
|
|
169
|
+
evaluated_at = excluded.evaluated_at
|
|
170
|
+
WHERE route_record.closed = 0`)
|
|
171
|
+
.run(value.taskRef, value.repo, value.cohortSize, value.provider, value.model, value.state, value.reason, value.resultRun, value.firstStartedAt, value.acceptedAt, value.decidedAt, value.modelFailures, value.infraFailures, Math.max(0, Math.round(value.apiMicrousd)), value.subscriptionRuns, value.subscriptionTokens, value.unknownRuns, value.remediationRuns, value.personRevisions, value.firstCheckPassed === null ? null : value.firstCheckPassed ? 1 : 0, value.agentTurns, value.turnsUnknownRuns, JSON.stringify(value.selections), value.settled ? 1 : 0, value.settled && value.accumulationEnded ? 1 : 0, now.toISOString());
|
|
172
|
+
}
|
|
173
|
+
/** The generation of one (scope, phase) Models row set: 0 before anything changed it. */
|
|
174
|
+
export function modelsGenerationOf(store, scope, phase) {
|
|
175
|
+
return Number(store.handle.prepare("SELECT generation FROM models_generation WHERE scope = ? AND phase = ?").get(scope, phase)?.["generation"] ?? 0);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Move a (scope, phase) generation: called by every Models row writer (store.ts), inside the writer's own
|
|
179
|
+
* transaction, whenever a row actually changed. Rules that depend on a generation that moved are paused in the same
|
|
180
|
+
* transaction (pauseDependentRules), so a configuration change can never leave a rule running on terms it no longer
|
|
181
|
+
* has.
|
|
182
|
+
*/
|
|
183
|
+
export function bumpModelsGeneration(store, scope, phase, now) {
|
|
184
|
+
if (typeof scope !== "string" || scope === "" || typeof phase !== "string" || phase === "")
|
|
185
|
+
throw new Error("a generation names its scope and phase");
|
|
186
|
+
store.handle.prepare(`INSERT INTO models_generation (scope, phase, generation, changed_at) VALUES (?, ?, 1, ?)
|
|
187
|
+
ON CONFLICT (scope, phase) DO UPDATE SET generation = generation + 1, changed_at = excluded.changed_at`).run(scope, phase, now.toISOString());
|
|
188
|
+
pauseDependentRules(store, now);
|
|
189
|
+
return modelsGenerationOf(store, scope, phase);
|
|
190
|
+
}
|
|
191
|
+
/** Pause every running rule or trial whose recorded dependency generations no longer match: a compare-and-swap on
|
|
192
|
+
* the rule's own generation, so a rule another writer already moved is left to that writer. An unreadable
|
|
193
|
+
* dependency list pauses too. */
|
|
194
|
+
export function pauseDependentRules(store, now) {
|
|
195
|
+
let paused = 0;
|
|
196
|
+
for (const rule of store.handle.prepare("SELECT id, generation, depends_json FROM route_rule WHERE state IN ('active','trialling')").all()) {
|
|
197
|
+
let stale = true;
|
|
198
|
+
try {
|
|
199
|
+
const depends = JSON.parse(String(rule["depends_json"]));
|
|
200
|
+
stale = typeof depends !== "object" || depends === null || Array.isArray(depends) || Object.entries(depends).some(([key, generation]) => {
|
|
201
|
+
const at = key.lastIndexOf("|");
|
|
202
|
+
return at <= 0 || typeof generation !== "number" || modelsGenerationOf(store, key.slice(0, at), key.slice(at + 1)) !== generation;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
stale = true;
|
|
207
|
+
}
|
|
208
|
+
if (!stale)
|
|
209
|
+
continue;
|
|
210
|
+
paused += Number(store.handle.prepare(`UPDATE route_rule SET state = 'paused', generation = generation + 1, end_reason = 'config-changed', ended_at = ?
|
|
211
|
+
WHERE id = ? AND generation = ? AND state IN ('active','trialling')`).run(now.toISOString(), rule["id"], rule["generation"]).changes);
|
|
212
|
+
}
|
|
213
|
+
return paused;
|
|
214
|
+
}
|
|
215
|
+
/** A deleted project's results-routing rows (project-delete.ts, through store.ts forgetProjectModels): its rules,
|
|
216
|
+
* their exposures and trial places, its records and its runs' cohort snapshots. How many rows went. */
|
|
217
|
+
export function forgetProjectRoutes(store, repo) {
|
|
218
|
+
const db = store.handle;
|
|
219
|
+
const rules = "SELECT id FROM route_rule WHERE repo = ?";
|
|
220
|
+
const refs = "SELECT id FROM task_ref WHERE repo = ?";
|
|
221
|
+
let rows = 0;
|
|
222
|
+
rows += Number(db.prepare(`DELETE FROM route_trial_reservation WHERE rule IN (${rules}) OR task_ref IN (${refs})`).run(repo, repo).changes);
|
|
223
|
+
rows += Number(db.prepare(`DELETE FROM route_exposure_event WHERE exposure IN (SELECT id FROM route_exposure WHERE rule IN (${rules}) OR task_ref IN (${refs}))`).run(repo, repo).changes);
|
|
224
|
+
rows += Number(db.prepare(`DELETE FROM route_exposure WHERE rule IN (${rules}) OR task_ref IN (${refs})`).run(repo, repo).changes);
|
|
225
|
+
rows += Number(db.prepare("DELETE FROM route_rule WHERE repo = ?").run(repo).changes);
|
|
226
|
+
rows += Number(db.prepare(`DELETE FROM route_record WHERE repo = ? OR task_ref IN (${refs})`).run(repo, repo).changes);
|
|
227
|
+
rows += Number(db.prepare(`DELETE FROM run_cohort WHERE repo = ? OR task_ref IN (${refs}) OR root_ref IN (${refs})`).run(repo, repo, repo).changes);
|
|
228
|
+
return rows;
|
|
229
|
+
}
|
|
230
|
+
/** The acts and the door each takes (docs/plans/results-routing.md, Authority). Raising acts take a person with a
|
|
231
|
+
* step-up; lowering acts take a person, or a local owner whose in-flight act is exactly the one named here. */
|
|
232
|
+
export const RULE_ACTS = Object.freeze({
|
|
233
|
+
accept: { raising: true, localAct: null },
|
|
234
|
+
keep: { raising: true, localAct: null },
|
|
235
|
+
"suggest-on": { raising: true, localAct: null },
|
|
236
|
+
"turn-off": { raising: false, localAct: "routing-turn-off" },
|
|
237
|
+
dismiss: { raising: false, localAct: "dismiss-suggestion" },
|
|
238
|
+
"suggest-off": { raising: false, localAct: "dismiss-suggestion" },
|
|
239
|
+
// Slice 3: starting a trial raises (a person's step-up); stopping one only lowers.
|
|
240
|
+
"trial-start": { raising: true, localAct: null },
|
|
241
|
+
"trial-stop": { raising: false, localAct: "trial-stop" },
|
|
242
|
+
});
|
|
243
|
+
const provedDoors = new WeakSet();
|
|
244
|
+
/** Proofs a raising act accepts: a password or identity provider now, or (only while Passwordless is on) a session
|
|
245
|
+
* or remembered CLI login. A lowering act also accepts a signed-in session: it only takes authority away. */
|
|
246
|
+
const RAISING_PROOFS = new Set(["password-now", "sso-now", "fresh-password", "fresh-sso", "session", "cli-login"]);
|
|
247
|
+
/**
|
|
248
|
+
* The rule doors' one proof, run inside the write's transaction: a lead token or a remote API token never acts
|
|
249
|
+
* (`lead-refused`); a person is re-proved — the same account, still active, still an instance operator who can open
|
|
250
|
+
* the project, at the credential generation the proof was made under, by a proof that still stands (model-choices.ts
|
|
251
|
+
* applyModelChoices' re-proof); a local owner only for a lowering act, only while it still stands, and only when its
|
|
252
|
+
* in-flight act is exactly that act's.
|
|
253
|
+
*/
|
|
254
|
+
export function proveRuleDoor(store, door, act, repo) {
|
|
255
|
+
if (machineActing() || process.env["TOOLROLL_LEAD_TOKEN"] !== undefined)
|
|
256
|
+
return { ok: false, reason: "lead-refused" };
|
|
257
|
+
const spec = RULE_ACTS[act];
|
|
258
|
+
if (door.kind === "local-owner") {
|
|
259
|
+
const local = currentLocalOwner();
|
|
260
|
+
if (spec.localAct === null || local === null || !localOwnerInFlight(local) || local.act !== spec.localAct || local.account !== door.actor || !localOwnerStandsNow(store, local))
|
|
261
|
+
return { ok: false, reason: "authority" };
|
|
262
|
+
const proved = Object.freeze({ actor: door.actor, act, via: "local-owner" });
|
|
263
|
+
provedDoors.add(proved);
|
|
264
|
+
return { ok: true, door: proved };
|
|
265
|
+
}
|
|
266
|
+
const { person } = door;
|
|
267
|
+
if (person.method === "lead-token" || person.method === "remote-token")
|
|
268
|
+
return { ok: false, reason: "lead-refused" };
|
|
269
|
+
const account = store.accountOf(person.actor);
|
|
270
|
+
const proof = spec.raising
|
|
271
|
+
? RAISING_PROOFS.has(person.method) && ((person.method !== "session" && person.method !== "cli-login") || passwordlessOn(store))
|
|
272
|
+
: RAISING_PROOFS.has(person.method) || person.method === "local-owner";
|
|
273
|
+
if (account === null || account.revokedAt !== null || account.generation !== person.generation || !store.isInstanceOperator(person.actor)
|
|
274
|
+
|| !store.accountCanAccess(person.actor, repo) || !person.live() || !proof)
|
|
275
|
+
return { ok: false, reason: "authority" };
|
|
276
|
+
const proved = Object.freeze({ actor: person.actor, act, via: "person" });
|
|
277
|
+
provedDoors.add(proved);
|
|
278
|
+
return { ok: true, door: proved };
|
|
279
|
+
}
|
|
280
|
+
function spend(door, act) {
|
|
281
|
+
const acts = typeof act === "string" ? [act] : act;
|
|
282
|
+
if (!provedDoors.has(door) || !acts.includes(door.act))
|
|
283
|
+
throw new Error("a rule write needs a door proved for exactly this act");
|
|
284
|
+
provedDoors.delete(door);
|
|
285
|
+
}
|
|
286
|
+
/** Insert an accepted earned rule, live. The winner index makes a second live rule for the same project and size
|
|
287
|
+
* refuse (null); nothing else is written. */
|
|
288
|
+
export function insertEarnedRule(store, door, rule, now) {
|
|
289
|
+
spend(door, "accept");
|
|
290
|
+
if (rule.provider !== "claude" || (rule.cohortSize !== "small" && rule.cohortSize !== "medium"))
|
|
291
|
+
throw new Error("only a Claude cohort of small or routine tasks can earn a rule");
|
|
292
|
+
try {
|
|
293
|
+
const inserted = store.handle.prepare(`INSERT INTO route_rule (repo, cohort_size, kind, provider, model, configured_provider, configured_model, state, generation,
|
|
294
|
+
grant_json, prices_json, depends_json, trial_limit, accepted_by, accepted_at)
|
|
295
|
+
VALUES (?, ?, 'rule', ?, ?, ?, ?, 'active', 1, ?, ?, ?, NULL, ?, ?)`)
|
|
296
|
+
.run(rule.repo, rule.cohortSize, rule.provider, rule.model, rule.configuredProvider, rule.configuredModel, JSON.stringify(rule.grant), JSON.stringify(rule.prices), JSON.stringify(rule.depends), door.actor, now.toISOString());
|
|
297
|
+
return Number(inserted.lastInsertRowid);
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
if (/UNIQUE constraint failed/.test(String(error)))
|
|
301
|
+
return null;
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** A person's or local owner's end of a live rule — turned off, or a paused one dropped — by compare-and-swap on its
|
|
306
|
+
* generation. False when the rule moved since (or isn't live). */
|
|
307
|
+
export function endRuleByDoor(store, door, ruleId, generation, now) {
|
|
308
|
+
spend(door, "turn-off");
|
|
309
|
+
const row = store.handle.prepare("SELECT state FROM route_rule WHERE id = ?").get(ruleId);
|
|
310
|
+
const state = row?.["state"] === "paused" ? "dropped" : "turned-off";
|
|
311
|
+
return Number(store.handle.prepare(`UPDATE route_rule SET state = ?, generation = generation + 1, ended_at = ?, ended_by = ?, end_reason = ?
|
|
312
|
+
WHERE id = ? AND generation = ? AND state IN ('active','trialling','paused')`).run(state, now.toISOString(), door.actor, state === "dropped" ? "dropped" : "turned-off", ruleId, generation).changes) === 1;
|
|
313
|
+
}
|
|
314
|
+
/** Keep a paused rule: live again, on the configuration generations it depends on now. Its sealed grant (terms,
|
|
315
|
+
* cutoff, expiry) never changes. */
|
|
316
|
+
export function keepPausedRule(store, door, ruleId, generation, depends) {
|
|
317
|
+
spend(door, "keep");
|
|
318
|
+
return Number(store.handle.prepare(`UPDATE route_rule SET state = 'active', generation = generation + 1, depends_json = ?, ended_at = NULL, ended_by = NULL, end_reason = NULL
|
|
319
|
+
WHERE id = ? AND generation = ? AND state = 'paused'`).run(JSON.stringify(depends), ruleId, generation).changes) === 1;
|
|
320
|
+
}
|
|
321
|
+
/** "Not now" on one suggestion: kept as an ended offer (state dropped, reason not-now) so the same suggestion stays
|
|
322
|
+
* quiet for its 30 days; it never blocks a rule. */
|
|
323
|
+
export function recordNotNow(store, door, offer, now) {
|
|
324
|
+
spend(door, "dismiss");
|
|
325
|
+
store.handle.prepare(`INSERT INTO route_rule (repo, cohort_size, kind, provider, model, configured_provider, configured_model, state, generation,
|
|
326
|
+
grant_json, prices_json, depends_json, trial_limit, accepted_by, accepted_at, ended_at, ended_by, end_reason)
|
|
327
|
+
VALUES (?, ?, 'rule', ?, ?, ?, ?, 'dropped', 1, '{"notNow":true}', ?, '{}', NULL, ?, ?, ?, ?, 'not-now')`)
|
|
328
|
+
.run(offer.repo, offer.cohortSize, offer.provider, offer.model, offer.configuredProvider, offer.configuredModel, JSON.stringify(offer.prices), door.actor, now.toISOString(), now.toISOString(), door.actor);
|
|
329
|
+
}
|
|
330
|
+
/** A project's "Suggest cheaper models" switch. False when the project isn't saved here. */
|
|
331
|
+
export function writeSuggestModels(store, door, repo, on) {
|
|
332
|
+
spend(door, on ? "suggest-on" : "suggest-off");
|
|
333
|
+
return Number(store.handle.prepare("UPDATE project SET suggest_models = ? WHERE path = ?").run(on ? 1 : 0, repo).changes) === 1;
|
|
334
|
+
}
|
|
335
|
+
/** The plane's own end of a rule (no person): switched off by its sealed trigger, paused (a configuration or grant
|
|
336
|
+
* change), or expired. A compare-and-swap on the rule's generation and a live state: false when it moved since. */
|
|
337
|
+
export function endRuleByPlane(store, ruleId, generation, to, reason, now) {
|
|
338
|
+
const from = to === "expired" ? "('active','trialling','paused')" : "('active','trialling')";
|
|
339
|
+
return Number(store.handle.prepare(`UPDATE route_rule SET state = ?, generation = generation + 1, ended_at = ?, ended_by = NULL, end_reason = ?
|
|
340
|
+
WHERE id = ? AND generation = ? AND state IN ${from}`).run(to, now.toISOString(), reason.slice(0, 200), ruleId, generation).changes) === 1;
|
|
341
|
+
}
|
|
342
|
+
// ---- exposure: one row per task and rule, events append-only -----------------------------------------------------
|
|
343
|
+
/** The exposure rows a filing writes (store.ts saveScope): a build leg sealed under a rule opens the task's exposure
|
|
344
|
+
* to it (`proposed`) or, filed again, adds `refiled`; a filing that no longer selects a rule the task was exposed to
|
|
345
|
+
* adds `refiled` to that open exposure, which its approval then closes as approved with another model. Re-filing
|
|
346
|
+
* never adds a row. */
|
|
347
|
+
export function noteExposureFiled(store, taskRef, selection, now) {
|
|
348
|
+
const at = now.toISOString();
|
|
349
|
+
const open = store.handle.prepare("SELECT id, rule FROM route_exposure WHERE task_ref = ? AND closed_at IS NULL").all(taskRef);
|
|
350
|
+
const rule = selection?.kind === "rule" || selection?.kind === "trial" ? selection.rule : null;
|
|
351
|
+
for (const one of open)
|
|
352
|
+
appendExposureEvent(store, Number(one["id"]), "refiled", null, at);
|
|
353
|
+
if (rule === null || open.some(one => Number(one["rule"]) === rule))
|
|
354
|
+
return;
|
|
355
|
+
// Exposed once per task and rule: a closed exposure to the same rule is never reopened.
|
|
356
|
+
const made = store.handle.prepare("INSERT INTO route_exposure (task_ref, rule, created_at) VALUES (?, ?, ?) ON CONFLICT (task_ref, rule) DO NOTHING").run(taskRef, rule, at);
|
|
357
|
+
if (Number(made.changes) === 1)
|
|
358
|
+
appendExposureEvent(store, Number(made.lastInsertRowid), "proposed", null, at);
|
|
359
|
+
}
|
|
360
|
+
/** An approval closes the task's open exposures: as proposed when it sealed that very rule, otherwise approved with
|
|
361
|
+
* the model it sealed. */
|
|
362
|
+
export function noteExposureSealed(store, taskRef, sealed, now) {
|
|
363
|
+
const at = now.toISOString();
|
|
364
|
+
for (const one of store.handle.prepare("SELECT id, rule FROM route_exposure WHERE task_ref = ? AND closed_at IS NULL").all(taskRef)) {
|
|
365
|
+
const same = (sealed.selection?.kind === "rule" || sealed.selection?.kind === "trial") && sealed.selection.rule === Number(one["rule"]);
|
|
366
|
+
appendExposureEvent(store, Number(one["id"]), same ? "approved-as-proposed" : "approved-with-other", same ? null : sealed.model, at);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** A cancel closes the task's open exposures. */
|
|
370
|
+
export function noteExposureCancelled(store, taskRef, now) {
|
|
371
|
+
for (const one of store.handle.prepare("SELECT id FROM route_exposure WHERE task_ref = ? AND closed_at IS NULL").all(taskRef)) {
|
|
372
|
+
appendExposureEvent(store, Number(one["id"]), "cancelled", null, now.toISOString());
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
const EXPOSURE_CLOSERS = new Set(["approved-as-proposed", "approved-with-other", "cancelled"]);
|
|
376
|
+
/** Append one event, in order: `proposed` first and only once, nothing after a closing event, and a closing event
|
|
377
|
+
* closes the exposure in the same write. Anything out of order refuses. */
|
|
378
|
+
function appendExposureEvent(store, exposure, kind, model, at) {
|
|
379
|
+
const events = store.handle.prepare("SELECT kind FROM route_exposure_event WHERE exposure = ? ORDER BY id").all(exposure).map(one => String(one["kind"]));
|
|
380
|
+
if (kind === "proposed" ? events.length !== 0 : events[0] !== "proposed" || events.some(one => EXPOSURE_CLOSERS.has(one)))
|
|
381
|
+
throw new Error(`exposure event ${kind} out of order`);
|
|
382
|
+
if ((kind === "approved-with-other") !== (model !== null))
|
|
383
|
+
throw new Error("only an approval with another model names it");
|
|
384
|
+
store.handle.prepare("INSERT INTO route_exposure_event (exposure, kind, model, at) VALUES (?, ?, ?, ?)").run(exposure, kind, model, at);
|
|
385
|
+
if (EXPOSURE_CLOSERS.has(kind))
|
|
386
|
+
store.handle.prepare("UPDATE route_exposure SET closed_at = ? WHERE id = ? AND closed_at IS NULL").run(at, exposure);
|
|
387
|
+
}
|
|
388
|
+
// ---- reads routing makes at filing and admission ------------------------------------------------------------------
|
|
389
|
+
/** A project's live earned rules, as routing reads them (phase-routing.ts EarnedRule): kind rule, state active, a
|
|
390
|
+
* Claude model. A row that doesn't read is left out (it can only ever not apply). */
|
|
391
|
+
export function liveEarnedRules(store, repo) {
|
|
392
|
+
if (repo === null)
|
|
393
|
+
return [];
|
|
394
|
+
return store.handle.prepare("SELECT * FROM route_rule WHERE repo = ? AND kind = 'rule' AND state = 'active' ORDER BY id").all(repo).flatMap(row => {
|
|
395
|
+
const size = row["cohort_size"];
|
|
396
|
+
if ((size !== "small" && size !== "medium") || row["provider"] !== "claude")
|
|
397
|
+
return [];
|
|
398
|
+
return [{ id: Number(row["id"]), generation: Number(row["generation"]), cohortSize: size, provider: "claude", model: String(row["model"]),
|
|
399
|
+
configured: { provider: String(row["configured_provider"]), model: row["configured_model"] == null ? null : String(row["configured_model"]) } }];
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
/** Why a build of this task must not start under the rule its route sealed, or null: the rule switched itself off
|
|
403
|
+
* after the approval and the task hasn't started (docs/plans/results-routing.md, "When a rule ends"). A rule turned off
|
|
404
|
+
* by a person, or expired, keeps the sealed model; a started task's later attempts finish under it. */
|
|
405
|
+
export function ruleHoldOf(store, taskId) {
|
|
406
|
+
const sealed = store.sealedRouteOf(taskId);
|
|
407
|
+
if (!sealed.ok)
|
|
408
|
+
return null;
|
|
409
|
+
const selection = sealed.route.legs[1]?.selection;
|
|
410
|
+
if (selection?.kind !== "rule")
|
|
411
|
+
return null;
|
|
412
|
+
const rule = store.handle.prepare("SELECT state, model FROM route_rule WHERE id = ?").get(selection.rule);
|
|
413
|
+
if (rule === undefined || rule["state"] !== "switched-off")
|
|
414
|
+
return null;
|
|
415
|
+
const started = store.handle.prepare(`SELECT 1 FROM run JOIN task_ref ON task_ref.id = run.task_ref
|
|
416
|
+
WHERE task_ref.backend = ? AND task_ref.external_id = ? AND run.role IN ('builder','repair') LIMIT 1`).get(BUILT_IN, taskId);
|
|
417
|
+
return started === undefined ? { rule: selection.rule, model: String(rule["model"]) } : null;
|
|
418
|
+
}
|
|
419
|
+
/** Start a trial, live (state trialling) with its number of places. The winner index makes a second live rule or
|
|
420
|
+
* trial for the same project and size refuse (null); nothing else is written. */
|
|
421
|
+
export function insertTrial(store, door, trial, now) {
|
|
422
|
+
spend(door, "trial-start");
|
|
423
|
+
if ((trial.cohortSize !== "small" && trial.cohortSize !== "medium") || !Number.isSafeInteger(trial.limit) || trial.limit < 1 || trial.limit > 50)
|
|
424
|
+
throw new Error("a trial covers a Claude cohort of small or routine tasks, for 1 to 50 places");
|
|
425
|
+
try {
|
|
426
|
+
const inserted = store.handle.prepare(`INSERT INTO route_rule (repo, cohort_size, kind, provider, model, configured_provider, configured_model, state, generation,
|
|
427
|
+
grant_json, prices_json, depends_json, trial_limit, accepted_by, accepted_at)
|
|
428
|
+
VALUES (?, ?, 'trial', 'claude', ?, ?, ?, 'trialling', 1, ?, ?, ?, ?, ?, ?)`)
|
|
429
|
+
.run(trial.repo, trial.cohortSize, trial.model, trial.configuredProvider, trial.configuredModel, JSON.stringify(trial.grant), JSON.stringify(trial.prices), JSON.stringify(trial.depends), trial.limit, door.actor, now.toISOString());
|
|
430
|
+
return Number(inserted.lastInsertRowid);
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
if (/UNIQUE constraint failed/.test(String(error)))
|
|
434
|
+
return null;
|
|
435
|
+
throw error;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* A person's or local owner's stop of a live or paused trial, by compare-and-swap on its generation, in the same
|
|
440
|
+
* transaction as releasing the places of tasks not approved on them (those plans are re-filed by the rule pass). A
|
|
441
|
+
* place a task was approved on stays reserved: that task keeps the model a person approved (as a rule turned off
|
|
442
|
+
* does). False when the trial moved since.
|
|
443
|
+
*/
|
|
444
|
+
export function endTrialByDoor(store, door, trialId, generation, now) {
|
|
445
|
+
spend(door, "trial-stop");
|
|
446
|
+
const ended = Number(store.handle.prepare(`UPDATE route_rule SET state = 'turned-off', generation = generation + 1, ended_at = ?, ended_by = ?, end_reason = 'stopped'
|
|
447
|
+
WHERE id = ? AND kind = 'trial' AND generation = ? AND state IN ('trialling','paused')`).run(now.toISOString(), door.actor, trialId, generation).changes) === 1;
|
|
448
|
+
if (ended) {
|
|
449
|
+
store.handle.prepare(`UPDATE route_trial_reservation SET state = 'released', updated_at = ?
|
|
450
|
+
WHERE rule = ? AND state = 'reserved' AND NOT EXISTS (SELECT 1 FROM task_ref tr JOIN task_scope ts ON ts.task_id = tr.external_id AND tr.backend = ?
|
|
451
|
+
WHERE tr.id = route_trial_reservation.task_ref AND ts.approved_at IS NOT NULL AND ts.approved_digest = ts.digest AND ts.digest = route_trial_reservation.scope_digest)`)
|
|
452
|
+
.run(now.toISOString(), trialId, BUILT_IN);
|
|
453
|
+
}
|
|
454
|
+
return ended;
|
|
455
|
+
}
|
|
456
|
+
/** A project's live trial for one size, as routing reads it (without this filing's place): state trialling, a Claude
|
|
457
|
+
* model. Null when none, or when its row doesn't read (it can only ever not apply). */
|
|
458
|
+
export function liveTrialFor(store, repo, size) {
|
|
459
|
+
if (repo === null || (size !== "small" && size !== "medium"))
|
|
460
|
+
return null;
|
|
461
|
+
const row = store.handle.prepare("SELECT * FROM route_rule WHERE repo = ? AND cohort_size = ? AND kind = 'trial' AND state = 'trialling' ORDER BY id LIMIT 1").get(repo, size);
|
|
462
|
+
if (row === undefined || row["provider"] !== "claude" || !Number.isSafeInteger(Number(row["trial_limit"])))
|
|
463
|
+
return null;
|
|
464
|
+
return { id: Number(row["id"]), generation: Number(row["generation"]), cohortSize: size, provider: "claude", model: String(row["model"]), limit: Number(row["trial_limit"]),
|
|
465
|
+
configured: { provider: String(row["configured_provider"]), model: row["configured_model"] == null ? null : String(row["configured_model"]) } };
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* This task's place in a trial, in the filing's own write transaction (store.ts saveScope, serialized by
|
|
469
|
+
* beginWriteWithin): a place it already holds (reserved, or consumed by its own earlier attempt) is reused, so
|
|
470
|
+
* repeated saves never take a second one; a place it gave up is never taken back; otherwise a new one only while
|
|
471
|
+
* reserved + consumed is under the trial's limit and the trial is live. Its digest is bound by settleTrialPlaces in
|
|
472
|
+
* the same transaction. Null: no place.
|
|
473
|
+
*/
|
|
474
|
+
export function reserveTrialPlace(store, trialId, taskRef, now) {
|
|
475
|
+
const own = store.handle.prepare("SELECT id, state FROM route_trial_reservation WHERE rule = ? AND task_ref = ?").get(trialId, taskRef);
|
|
476
|
+
if (own !== undefined)
|
|
477
|
+
return own["state"] === "released" ? null : Number(own["id"]);
|
|
478
|
+
const inserted = store.handle.prepare(`INSERT INTO route_trial_reservation (rule, task_ref, scope_digest, state, created_at, updated_at)
|
|
479
|
+
SELECT ?, ?, '', 'reserved', ?, ? FROM route_rule r WHERE r.id = ? AND r.kind = 'trial' AND r.state = 'trialling'
|
|
480
|
+
AND (SELECT COUNT(*) FROM route_trial_reservation p WHERE p.rule = r.id AND p.state IN ('reserved','consumed')) < r.trial_limit`)
|
|
481
|
+
.run(trialId, taskRef, now.toISOString(), now.toISOString(), trialId);
|
|
482
|
+
return Number(inserted.changes) === 1 ? Number(inserted.lastInsertRowid) : null;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* After a filing, in its transaction: the place its route seals (if any) is bound to the scope digest it was filed
|
|
486
|
+
* under (a compare-and-swap on `reserved`; a place this task's own attempt already consumed stays as it is), and every
|
|
487
|
+
* other place this task holds unconsumed is released. A selection that names a place this task doesn't hold throws:
|
|
488
|
+
* nothing files on it.
|
|
489
|
+
*/
|
|
490
|
+
export function settleTrialPlaces(store, taskRef, selection, digest, now) {
|
|
491
|
+
let kept = -1;
|
|
492
|
+
if (selection?.kind === "trial") {
|
|
493
|
+
if (typeof digest !== "string" || digest === "")
|
|
494
|
+
throw new Error("a trial place is bound to the scope digest it was filed under");
|
|
495
|
+
const row = store.handle.prepare("SELECT rule, task_ref, state FROM route_trial_reservation WHERE id = ?").get(selection.reservation);
|
|
496
|
+
if (row === undefined || Number(row["rule"]) !== selection.rule || Number(row["task_ref"]) !== taskRef || (row["state"] !== "reserved" && row["state"] !== "consumed")) {
|
|
497
|
+
throw new Error("a trial route names a place this task doesn't hold");
|
|
498
|
+
}
|
|
499
|
+
if (row["state"] === "reserved")
|
|
500
|
+
store.handle.prepare("UPDATE route_trial_reservation SET scope_digest = ?, updated_at = ? WHERE id = ? AND state = 'reserved'").run(digest, now.toISOString(), selection.reservation);
|
|
501
|
+
kept = selection.reservation;
|
|
502
|
+
}
|
|
503
|
+
store.handle.prepare("UPDATE route_trial_reservation SET state = 'released', updated_at = ? WHERE task_ref = ? AND state = 'reserved' AND id != ?").run(now.toISOString(), taskRef, kept);
|
|
504
|
+
}
|
|
505
|
+
/** A cancel gives up every place the task holds unconsumed (store.ts applyCancellationLocked). A consumed place is
|
|
506
|
+
* never released. How many went. */
|
|
507
|
+
export function releaseTrialPlaces(store, taskRef, now) {
|
|
508
|
+
return Number(store.handle.prepare("UPDATE route_trial_reservation SET state = 'released', updated_at = ? WHERE task_ref = ? AND state = 'reserved'").run(now.toISOString(), taskRef).changes);
|
|
509
|
+
}
|
|
510
|
+
/** Whether this place still stands for this task's approval at `digest`: reserved and bound to exactly that digest,
|
|
511
|
+
* or consumed by this task's own earlier attempt. */
|
|
512
|
+
function placeStands(store, selection, taskRef, digest) {
|
|
513
|
+
const row = store.handle.prepare("SELECT rule, task_ref, state, scope_digest FROM route_trial_reservation WHERE id = ?").get(selection.reservation);
|
|
514
|
+
if (row === undefined || Number(row["rule"]) !== selection.rule || Number(row["task_ref"]) !== taskRef)
|
|
515
|
+
return false;
|
|
516
|
+
return row["state"] === "consumed" || (row["state"] === "reserved" && digest !== "" && row["scope_digest"] === digest);
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Why a seal of this route must not stand, or null (store.ts sealScopeApproval): a trial task is approved only by a
|
|
520
|
+
* person — their own ceremony (no basis; not a machine acting, not an AI approver's name) or their paired-chat tap —
|
|
521
|
+
* never a mode, a grant, a schedule, a carry, an automatic seal or the lead; and only while its place stands.
|
|
522
|
+
*/
|
|
523
|
+
export function trialSealProblem(store, input) {
|
|
524
|
+
if (input.selection?.kind !== "trial")
|
|
525
|
+
return null;
|
|
526
|
+
if (input.machine)
|
|
527
|
+
return "a trial task is approved by a person, never a lead or an API token";
|
|
528
|
+
if (input.basis !== null && input.basis !== "chat")
|
|
529
|
+
return `a trial task is approved by a person, not by a ${input.basis} approval`;
|
|
530
|
+
if (input.basis === null && input.by.endsWith(" (AI)"))
|
|
531
|
+
return "a trial task is approved by a person";
|
|
532
|
+
if (input.taskRef === null || !placeStands(store, input.selection, input.taskRef, input.digest))
|
|
533
|
+
return "this task's trial place is no longer held — re-file it";
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* A code-writing run's trial check, in its admission transaction after the run row exists (store.ts admitRun): for a
|
|
538
|
+
* route sealed on a trial, the approval must be a person's own (approval-authority.ts trialSealStandsSql), and the
|
|
539
|
+
* sealed place is consumed exactly once — a compare-and-swap on `reserved` and the sealed scope digest. A retry or
|
|
540
|
+
* repair of the same task whose place that task already consumed is admitted without consuming again. The words, or
|
|
541
|
+
* null to admit; the caller refuses (rolling the row back) on words.
|
|
542
|
+
*/
|
|
543
|
+
export function admitTrialRun(store, input) {
|
|
544
|
+
if (input.role !== "builder" && input.role !== "repair")
|
|
545
|
+
return null;
|
|
546
|
+
const sealed = store.sealedRouteOf(input.taskId);
|
|
547
|
+
if (!sealed.ok)
|
|
548
|
+
return null;
|
|
549
|
+
const selection = sealed.route.legs[1]?.selection;
|
|
550
|
+
if (selection?.kind !== "trial")
|
|
551
|
+
return null;
|
|
552
|
+
const scope = store.handle.prepare(`SELECT task_scope.approved_digest AS digest FROM task_scope WHERE task_scope.task_id = ? AND task_scope.approved_digest = task_scope.digest
|
|
553
|
+
AND ${trialSealStandsSql("task_scope")}`).get(input.taskId);
|
|
554
|
+
if (scope === undefined)
|
|
555
|
+
return "trial-person: a task trying a cheaper model runs only on a person's own approval — approve it yourself";
|
|
556
|
+
const row = store.handle.prepare("SELECT rule, task_ref, state, consumed_run FROM route_trial_reservation WHERE id = ?").get(selection.reservation);
|
|
557
|
+
if (row === undefined || Number(row["rule"]) !== selection.rule || Number(row["task_ref"]) !== input.taskRef)
|
|
558
|
+
return "trial-reservation: the trial place this task was approved on isn't its own";
|
|
559
|
+
if (row["state"] === "consumed")
|
|
560
|
+
return null;
|
|
561
|
+
if (row["state"] !== "reserved")
|
|
562
|
+
return "trial-reservation: the trial place this task was approved on was given up — re-file it";
|
|
563
|
+
if (!consumeTrialPlace(store, selection.reservation, String(scope["digest"]), input.run, input.now))
|
|
564
|
+
return "trial-reservation: the trial place doesn't match this approval — re-file it";
|
|
565
|
+
// Every place used: the trial is finished, and its cohort is free for a suggestion again.
|
|
566
|
+
store.handle.prepare(`UPDATE route_rule SET state = 'finished', generation = generation + 1, ended_at = ?, end_reason = 'all-places-used'
|
|
567
|
+
WHERE id = ? AND kind = 'trial' AND state IN ('trialling','paused')
|
|
568
|
+
AND (SELECT COUNT(*) FROM route_trial_reservation p WHERE p.rule = route_rule.id AND p.state = 'consumed') >= trial_limit`).run(input.now.toISOString(), selection.rule);
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
/** Consume one reserved place for this run: only with the sealed scope digest it was bound to. */
|
|
572
|
+
export function consumeTrialPlace(store, reservation, sealedDigest, run, now) {
|
|
573
|
+
if (typeof sealedDigest !== "string" || sealedDigest === "" || !Number.isSafeInteger(run) || run < 1)
|
|
574
|
+
throw new Error("a trial place is consumed only against the sealed scope digest, by a run");
|
|
575
|
+
return Number(store.handle.prepare(`UPDATE route_trial_reservation SET state = 'consumed', consumed_run = ?, updated_at = ?
|
|
576
|
+
WHERE id = ? AND state = 'reserved' AND scope_digest = ?`).run(run, now.toISOString(), reservation, sealedDigest).changes) === 1;
|
|
577
|
+
}
|
|
578
|
+
/** A trial's places: its limit, how many are reserved (waiting) and consumed (started). */
|
|
579
|
+
export function trialPlacesOf(store, trialId) {
|
|
580
|
+
const row = store.handle.prepare(`SELECT r.trial_limit AS lim,
|
|
581
|
+
(SELECT COUNT(*) FROM route_trial_reservation p WHERE p.rule = r.id AND p.state = 'reserved') AS reserved,
|
|
582
|
+
(SELECT COUNT(*) FROM route_trial_reservation p WHERE p.rule = r.id AND p.state = 'consumed') AS consumed
|
|
583
|
+
FROM route_rule r WHERE r.id = ? AND r.kind = 'trial'`).get(trialId);
|
|
584
|
+
return { limit: Number(row?.["lim"] ?? 0), reserved: Number(row?.["reserved"] ?? 0), consumed: Number(row?.["consumed"] ?? 0) };
|
|
585
|
+
}
|
|
586
|
+
/** Which place in its trial this one is (1-based, counting places held or used, in order), or null. */
|
|
587
|
+
export function trialOrdinalOf(store, reservation) {
|
|
588
|
+
const row = store.handle.prepare(`SELECT p.rule, p.state, (SELECT COUNT(*) FROM route_trial_reservation o WHERE o.rule = p.rule AND o.id <= p.id AND o.state IN ('reserved','consumed')) AS ordinal
|
|
589
|
+
FROM route_trial_reservation p WHERE p.id = ?`).get(reservation);
|
|
590
|
+
return row === undefined ? null : { trial: Number(row["rule"]), ordinal: Number(row["ordinal"]), state: String(row["state"]) };
|
|
591
|
+
}
|
|
592
|
+
/** Whether a proposal's trial place still stands for re-use by its next approval: the trial is live and the place is
|
|
593
|
+
* held reserved by that task (the rule pass re-files a proposal whose place doesn't). */
|
|
594
|
+
export function trialPlaceLive(store, selection, taskRef) {
|
|
595
|
+
const row = store.handle.prepare(`SELECT 1 FROM route_trial_reservation p JOIN route_rule r ON r.id = p.rule
|
|
596
|
+
WHERE p.id = ? AND p.rule = ? AND p.task_ref = ? AND p.state IN ('reserved','consumed') AND r.state = 'trialling'`).get(selection.reservation, selection.rule, taskRef);
|
|
597
|
+
return row !== undefined;
|
|
598
|
+
}
|
package/dist/scope.d.ts
CHANGED
|
@@ -880,7 +880,9 @@ export declare function acceptanceWords(criteria: readonly AcceptanceCriterion[]
|
|
|
880
880
|
* bytes the task page and chat render. Empty on a scope with no route. */
|
|
881
881
|
export declare function scopeRouteWords(scope: Pick<Scope, "proposedRouteJson">, readiness?: ReadinessLookup): string[];
|
|
882
882
|
/** The scope, in the words an operator has to be able to agree or disagree with. */
|
|
883
|
-
export declare function describeScope(scope: Scope, readiness?: ReadinessLookup
|
|
883
|
+
export declare function describeScope(scope: Scope, readiness?: ReadinessLookup,
|
|
884
|
+
/** Results routing: the evidence line when an earned rule chose the builder (route-rules.ts taskEvidenceWords). */
|
|
885
|
+
evidence?: string | null): string[];
|
|
884
886
|
/** The approval sheet's release lines (release-once.md): the release in one sentence, then what asks again. */
|
|
885
887
|
export declare function releaseTermsSentence(terms: ReleaseTerms): string;
|
|
886
888
|
/** The approval sheet's line for a release check purpose (pipeline-speed.md, P0): a release that can't carry. */
|
package/dist/scope.js
CHANGED
|
@@ -1333,7 +1333,9 @@ export function scopeRouteWords(scope, readiness = NO_READINESS) {
|
|
|
1333
1333
|
return route === null ? [] : routeWords(projectRoute(route, readiness));
|
|
1334
1334
|
}
|
|
1335
1335
|
/** The scope, in the words an operator has to be able to agree or disagree with. */
|
|
1336
|
-
export function describeScope(scope, readiness = NO_READINESS
|
|
1336
|
+
export function describeScope(scope, readiness = NO_READINESS,
|
|
1337
|
+
/** Results routing: the evidence line when an earned rule chose the builder (route-rules.ts taskEvidenceWords). */
|
|
1338
|
+
evidence = null) {
|
|
1337
1339
|
const approval = approvalOf(scope);
|
|
1338
1340
|
return [
|
|
1339
1341
|
` goal ${scope.goal}`,
|
|
@@ -1347,6 +1349,7 @@ export function describeScope(scope, readiness = NO_READINESS) {
|
|
|
1347
1349
|
// The ROUTE, in the words the yes agrees to (v47): posture and every
|
|
1348
1350
|
// leg with its reason and readiness — before anyone signs.
|
|
1349
1351
|
...scopeRouteWords(scope, readiness),
|
|
1352
|
+
...(evidence === null ? [] : [` why model ${evidence.replace(/^Why this model: /, "")}`]),
|
|
1350
1353
|
// What the build may reach (egress.ts), part of what the yes agrees to.
|
|
1351
1354
|
...(scope.profile === null || scope.profile === undefined ? [] : [` network ${networkWords(scope.profile.network ?? null).replace(/^Network: /, "")}`]),
|
|
1352
1355
|
...(scope.profile === null || scope.profile === undefined ? [] : [` writes ${writesWords(scope.profile.writes)}`]),
|