toolroll 0.9.94 → 0.9.95

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.
Files changed (191) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/dist/agent-fence.d.ts +6 -0
  3. package/dist/agent-fence.js +2 -2
  4. package/dist/assignment-status.d.ts +1 -1
  5. package/dist/assignment-ui.js +2 -0
  6. package/dist/attest.d.ts +3 -0
  7. package/dist/attest.js +10 -0
  8. package/dist/browser/terminal.js +1 -1
  9. package/dist/browser/workspace.css +1 -1
  10. package/dist/browser/workspace.js +24 -24
  11. package/dist/browser-crew.js +2 -0
  12. package/dist/browser-workspace.d.ts +5 -0
  13. package/dist/build-review.js +2 -0
  14. package/dist/builder.d.ts +7 -2
  15. package/dist/builder.js +183 -79
  16. package/dist/card-catalog.d.ts +209 -0
  17. package/dist/card-catalog.js +133 -0
  18. package/dist/card-cli.d.ts +43 -0
  19. package/dist/card-cli.js +44 -0
  20. package/dist/card-render.d.ts +26 -0
  21. package/dist/card-render.js +223 -0
  22. package/dist/card-sources.d.ts +68 -0
  23. package/dist/card-sources.js +234 -0
  24. package/dist/chat-actions.d.ts +95 -8
  25. package/dist/chat-actions.js +449 -45
  26. package/dist/chat-approval.d.ts +13 -0
  27. package/dist/chat-approval.js +39 -9
  28. package/dist/chat-card-part.d.ts +11 -0
  29. package/dist/chat-card-part.js +18 -0
  30. package/dist/chat-card.d.ts +46 -0
  31. package/dist/chat-card.js +99 -0
  32. package/dist/chat-channel.d.ts +2 -0
  33. package/dist/chat-channel.js +26 -14
  34. package/dist/chat-controls.d.ts +4 -0
  35. package/dist/chat-controls.js +4 -0
  36. package/dist/chat-decide.d.ts +3 -14
  37. package/dist/chat-decide.js +43 -54
  38. package/dist/chat-delivery.js +13 -4
  39. package/dist/chat-flow.d.ts +0 -27
  40. package/dist/chat-flow.js +31 -2
  41. package/dist/chat-quiet.js +1 -1
  42. package/dist/chat-task-actions.d.ts +3 -2
  43. package/dist/chat-task-actions.js +19 -5
  44. package/dist/chat-terms.d.ts +14 -2
  45. package/dist/chat-terms.js +35 -0
  46. package/dist/check-failure.d.ts +2 -0
  47. package/dist/check-failure.js +34 -2
  48. package/dist/check-repair.d.ts +47 -0
  49. package/dist/check-repair.js +240 -0
  50. package/dist/claim.d.ts +6 -0
  51. package/dist/claim.js +13 -2
  52. package/dist/cli-help.d.ts +1 -1
  53. package/dist/cli-help.js +2 -0
  54. package/dist/cli.js +4 -0
  55. package/dist/coding-provider.d.ts +1 -1
  56. package/dist/coding-provider.js +9 -5
  57. package/dist/coding-workspace.d.ts +1 -1
  58. package/dist/coding-workspace.js +4 -4
  59. package/dist/contracts/chat-actions.d.ts +83 -0
  60. package/dist/contracts/chat-actions.js +18 -0
  61. package/dist/contracts/chat-card.d.ts +91 -0
  62. package/dist/contracts/chat-card.js +54 -0
  63. package/dist/contracts/chat-content.d.ts +191 -0
  64. package/dist/contracts/chat-content.js +4 -0
  65. package/dist/contracts/console-api.d.ts +2 -0
  66. package/dist/contracts/console-api.js +2 -0
  67. package/dist/contracts/flow-propose.d.ts +8 -1
  68. package/dist/contracts/flow-propose.js +3 -1
  69. package/dist/contracts/flow-send.d.ts +126 -0
  70. package/dist/contracts/flow-send.js +3 -0
  71. package/dist/contracts/lead-tools.d.ts +173 -0
  72. package/dist/contracts/lead-tools.js +15 -0
  73. package/dist/contracts/store-json.d.ts +16 -0
  74. package/dist/discord-chat.js +14 -0
  75. package/dist/dispatch.d.ts +1 -1
  76. package/dist/dispatch.js +7 -1
  77. package/dist/dispose.d.ts +19 -0
  78. package/dist/dispose.js +47 -7
  79. package/dist/eval-oracle.js +4 -1
  80. package/dist/evidence-pair.d.ts +53 -0
  81. package/dist/evidence-pair.js +128 -0
  82. package/dist/exhaustion.d.ts +17 -4
  83. package/dist/exhaustion.js +44 -4
  84. package/dist/export.js +1 -1
  85. package/dist/failing-first.d.ts +31 -0
  86. package/dist/failing-first.js +44 -0
  87. package/dist/fallback-schema.d.ts +8 -0
  88. package/dist/fallback-schema.js +40 -0
  89. package/dist/fallback.d.ts +138 -0
  90. package/dist/fallback.js +605 -0
  91. package/dist/findings.d.ts +224 -0
  92. package/dist/findings.js +449 -0
  93. package/dist/fleet-schema.d.ts +17 -0
  94. package/dist/fleet-schema.js +135 -0
  95. package/dist/flow-browser.js +2 -2
  96. package/dist/flow-code.d.ts +2 -2
  97. package/dist/flow-code.js +9 -5
  98. package/dist/flow-result-card.d.ts +22 -0
  99. package/dist/flow-result-card.js +55 -0
  100. package/dist/flow-send.js +12 -9
  101. package/dist/flow-steps.js +1 -1
  102. package/dist/flow-triggers.js +1 -1
  103. package/dist/flow-unattended.d.ts +26 -0
  104. package/dist/flow-unattended.js +12 -3
  105. package/dist/flows-ui.js +10 -2
  106. package/dist/follow-through-brief.d.ts +12 -0
  107. package/dist/follow-through-brief.js +31 -0
  108. package/dist/follow-through-cli.d.ts +23 -0
  109. package/dist/follow-through-cli.js +153 -0
  110. package/dist/git-incident.d.ts +16 -0
  111. package/dist/git-incident.js +69 -6
  112. package/dist/handover-note.d.ts +33 -0
  113. package/dist/handover-note.js +106 -0
  114. package/dist/invoke.d.ts +20 -1
  115. package/dist/invoke.js +108 -44
  116. package/dist/lead-cli.js +26 -24
  117. package/dist/lead-contract.js +1 -0
  118. package/dist/lead-doors.d.ts +4 -0
  119. package/dist/lead-doors.js +2 -2
  120. package/dist/lead-progress.js +2 -0
  121. package/dist/lead-tools.js +111 -32
  122. package/dist/lead.js +1 -1
  123. package/dist/local-owner.js +5 -0
  124. package/dist/modes.d.ts +6 -6
  125. package/dist/modes.js +3 -1
  126. package/dist/needs-you.d.ts +5 -2
  127. package/dist/needs-you.js +14 -2
  128. package/dist/operate-remote-arguments.js +1 -0
  129. package/dist/operate-remote.js +1 -1
  130. package/dist/operate.d.ts +1 -1
  131. package/dist/operate.js +165 -14
  132. package/dist/plan-login.d.ts +14 -0
  133. package/dist/plan-login.js +58 -0
  134. package/dist/planner.d.ts +1 -1
  135. package/dist/planner.js +2 -1
  136. package/dist/project-delete-ui.d.ts +12 -0
  137. package/dist/project-delete-ui.js +18 -1
  138. package/dist/project-delete.js +6 -0
  139. package/dist/project-knowledge.d.ts +6 -6
  140. package/dist/project-tools.d.ts +4 -4
  141. package/dist/project-tools.js +23 -7
  142. package/dist/protected-storage-look.d.ts +1 -0
  143. package/dist/protected-storage-look.js +13 -0
  144. package/dist/protected-storage.d.ts +70 -39
  145. package/dist/protected-storage.js +196 -147
  146. package/dist/provider-capture.d.ts +34 -0
  147. package/dist/provider-capture.js +53 -0
  148. package/dist/release-carry.js +10 -1
  149. package/dist/release.js +9 -1
  150. package/dist/scope.d.ts +22 -3
  151. package/dist/scope.js +71 -20
  152. package/dist/scout.d.ts +1 -1
  153. package/dist/scout.js +2 -1
  154. package/dist/serve.js +2 -0
  155. package/dist/server/chat.js +2 -2
  156. package/dist/server/console.css +50 -0
  157. package/dist/server/render-chat.js +14 -1
  158. package/dist/server/render-follow-through.d.ts +11 -0
  159. package/dist/server/render-follow-through.js +68 -0
  160. package/dist/server/render-results.js +1 -1
  161. package/dist/server/render-tasks.d.ts +3 -0
  162. package/dist/server/render-tasks.js +24 -5
  163. package/dist/server/route-table.js +3 -0
  164. package/dist/server/settings.js +37 -12
  165. package/dist/server/task-views.js +5 -0
  166. package/dist/server/tasks.js +76 -0
  167. package/dist/settings-groups-ui.d.ts +1 -0
  168. package/dist/settings-groups-ui.js +3 -1
  169. package/dist/settings-kit.js +3 -2
  170. package/dist/settings-proofs.js +3 -1
  171. package/dist/setup-guide.d.ts +2 -2
  172. package/dist/slack-chat.js +14 -1
  173. package/dist/steer-mailbox.d.ts +8 -1
  174. package/dist/steer-mailbox.js +164 -6
  175. package/dist/store.d.ts +11 -4
  176. package/dist/store.js +93 -30
  177. package/dist/surface.js +24 -4
  178. package/dist/task-control.d.ts +2 -1
  179. package/dist/task-status.js +5 -0
  180. package/dist/teardown-git.d.ts +2 -0
  181. package/dist/teardown-git.js +42 -16
  182. package/dist/telegram-lead.js +23 -6
  183. package/dist/telegram.js +12 -1
  184. package/dist/toolroll-update.js +2 -0
  185. package/dist/work-index.js +22 -7
  186. package/dist/work-summary.d.ts +1 -1
  187. package/dist/work-summary.js +5 -2
  188. package/dist/workspace-ui.d.ts +3 -1
  189. package/dist/workspace-ui.js +10 -1
  190. package/dist/worktree.js +23 -1
  191. package/package.json +2 -1
@@ -0,0 +1,605 @@
1
+ /**
2
+ * Fallback chains (docs/plans/fallback-chains.md): when an agent runs out of its plan mid-task, the work moves to the
3
+ * next agent a person approved.
4
+ *
5
+ * A person sets a project's chain and its paid grant. Filing snapshots the chain into the scope and the approval seals
6
+ * it. A walk is one pass down a sealed chain for one task; every builder run on a chain approval is bound to one, and
7
+ * a run with no binding spawns nothing. A move happens only in the transaction that seals a recognised exhausted run.
8
+ * The next agent runs through the ordinary claim, keyed to its pinned entry. Paid entries re-read the live grant at the
9
+ * move, the admission and the spawn.
10
+ *
11
+ * Tables may not exist on a database an older build still holds (update drain reads through here): every read treats
12
+ * that as "no chain" (`hasTables`), never an error.
13
+ */
14
+ import { createHash } from "node:crypto";
15
+ import { resolveScopeProfile } from "./agentconfig.js";
16
+ import { isAcpProvider } from "./acp-agents.js";
17
+ import { permissionPlainWords } from "./chat-terms.js";
18
+ import { limitRecognised } from "./exhaustion.js";
19
+ import { SUBSCRIPTION_CAPABLE } from "./keys.js";
20
+ import { routeDigestOf } from "./phase-routing.js";
21
+ import { isProviderId, validateSpec } from "./provider.js";
22
+ import { providerName } from "./provider-auth.js";
23
+ import { PLAN_LOGIN_PROVABLE } from "./plan-login.js";
24
+ import { canonicalChainJson, chainDigestOf, LIVE_CHAIN_VERSION, liveChainFromJson, machineActing, profileDigestOf } from "./scope.js";
25
+ export const MAX_FALLBACK_ENTRIES = 3;
26
+ const tablesSeen = new WeakSet();
27
+ function hasTables(store) {
28
+ if (tablesSeen.has(store.handle))
29
+ return true;
30
+ const row = store.handle.prepare("SELECT 1 AS hit FROM sqlite_master WHERE type = 'table' AND name = 'fallback_walk'").get();
31
+ if (row === undefined)
32
+ return false;
33
+ tablesSeen.add(store.handle);
34
+ return true;
35
+ }
36
+ export function entryDigestOf(entry) {
37
+ return createHash("sha256").update(`toolroll:fallback-entry:v1:${profileDigestOf(entry.profile)}:${entry.authMode}`, "utf8").digest("hex").slice(0, 32);
38
+ }
39
+ const planCapable = (provider) => SUBSCRIPTION_CAPABLE[provider] === true;
40
+ const billingWord = (authMode) => authMode === "api-key" ? "API key" : "plan";
41
+ /** An entry in the words every surface uses. */
42
+ export function entryWords(entry) {
43
+ return `${providerName(entry.provider)} ${entry.model} (${billingWord(entry.authMode)})`;
44
+ }
45
+ /** Whether Toolroll can see this entry run out, in plain words: an entry it can't recognise ends the chain there. */
46
+ export function entryMovesOnWords(entry) {
47
+ return limitRecognised(entry.provider, entry.authMode) ? "Moves on when its plan runs out" : "Won't move on: Toolroll can't yet recognise this agent running out";
48
+ }
49
+ /** Whether this entry spends money, by what it was pinned to: an API key, or a provider that only bills by use. A plan
50
+ * entry is never paid: where its login can't be proved to be that plan, it doesn't run (invoke.ts), it is never
51
+ * reinterpreted as a paid agent. */
52
+ export function entryPaid(_store, entry) {
53
+ return entry.authMode === "api-key" || !planCapable(entry.provider);
54
+ }
55
+ // ---- The project setting. Only a person changes it, and every change is logged.
56
+ function personOnly() {
57
+ return machineActing() ? "Only a person can change a project's fallback agents." : null;
58
+ }
59
+ /** Parse `provider:model:plan|api-key`. The model is everything between the first and last colon, so ":free" ids work. */
60
+ export function parseEntry(text) {
61
+ const first = text.indexOf(":"), last = text.lastIndexOf(":");
62
+ if (first <= 0 || last <= first)
63
+ return `"${text}" should read provider:model:plan or provider:model:api-key`;
64
+ const billing = text.slice(last + 1);
65
+ if (billing !== "plan" && billing !== "api-key")
66
+ return `"${text}" should end in :plan or :api-key`;
67
+ return { provider: text.slice(0, first), model: text.slice(first + 1, last), authMode: billing === "plan" ? "subscription" : "api-key" };
68
+ }
69
+ export function entriesProblem(entries) {
70
+ if (entries.length === 0 || entries.length > MAX_FALLBACK_ENTRIES)
71
+ return `A chain lists 1 to ${MAX_FALLBACK_ENTRIES} fallback agents.`;
72
+ const seen = new Set();
73
+ for (const entry of entries) {
74
+ if (!isProviderId(entry.provider) || isAcpProvider(entry.provider))
75
+ return `${entry.provider} can't be a fallback agent.`;
76
+ const valid = validateSpec({ provider: entry.provider, model: entry.model });
77
+ if (!valid.ok)
78
+ return valid.problem;
79
+ if (entry.authMode === "subscription" && !PLAN_LOGIN_PROVABLE.has(entry.provider))
80
+ return `Toolroll can't check that ${providerName(entry.provider)} is signed in to a plan, so it can't be a plan fallback. Pin it to an API key with paid fallback allowed instead.`;
81
+ if (entry.authMode === "subscription" && !planCapable(entry.provider))
82
+ return `${providerName(entry.provider)} has no plan to sign in with; use an API key.`;
83
+ const key = `${entry.provider}\n${entry.model}\n${entry.authMode}`;
84
+ if (seen.has(key))
85
+ return `${entryWords(entry)} is listed twice.`;
86
+ seen.add(key);
87
+ }
88
+ return null;
89
+ }
90
+ export function fallbackSettingOf(store, repo) {
91
+ if (!hasTables(store))
92
+ return null;
93
+ const row = store.handle.prepare("SELECT * FROM fallback_setting WHERE repo = ?").get(repo);
94
+ if (row === undefined)
95
+ return null;
96
+ let entries;
97
+ try {
98
+ entries = JSON.parse(String(row["entries_json"]));
99
+ }
100
+ catch {
101
+ entries = [];
102
+ }
103
+ return { repo, entries, paidGrant: row["paid_granted_by"] == null ? null : { by: String(row["paid_granted_by"]), at: String(row["paid_granted_at"]) }, updatedBy: String(row["updated_by"]), updatedAt: String(row["updated_at"]) };
104
+ }
105
+ function logSetting(store, repo, action, by, detail, now) {
106
+ store.recordAction({ at: now.toISOString(), actor: by, repo, taskId: null, runId: null, action, outcome: "changed", source: "request", detail });
107
+ }
108
+ export function setFallbackChain(store, repo, entries, by, now) {
109
+ const problem = personOnly() ?? entriesProblem(entries);
110
+ if (problem !== null)
111
+ return { ok: false, problem };
112
+ return store.transact(() => {
113
+ // A chain Toolroll couldn't file refuses here, not on the next task.
114
+ for (const entry of entries) {
115
+ const resolved = resolveEntry(store, repo, entry);
116
+ if (!resolved.ok)
117
+ return { ok: false, problem: `${entryWords(entry)}: ${resolved.problem}` };
118
+ }
119
+ store.handle.prepare(`INSERT INTO fallback_setting (repo, entries_json, updated_by, updated_at) VALUES (?, ?, ?, ?)
120
+ ON CONFLICT (repo) DO UPDATE SET entries_json = excluded.entries_json, updated_by = excluded.updated_by, updated_at = excluded.updated_at`)
121
+ .run(repo, JSON.stringify(entries), by, now.toISOString());
122
+ logSetting(store, repo, "fallback agents set", by, entries.map(entryWords).join(" → "), now);
123
+ return { ok: true };
124
+ });
125
+ }
126
+ export function clearFallbackChain(store, repo, by, now) {
127
+ const problem = personOnly();
128
+ if (problem !== null)
129
+ return { ok: false, problem };
130
+ store.transact(() => {
131
+ store.handle.prepare("DELETE FROM fallback_setting WHERE repo = ?").run(repo);
132
+ logSetting(store, repo, "fallback agents cleared", by, "New tasks in this project file with no fallback agents.", now);
133
+ });
134
+ return { ok: true };
135
+ }
136
+ /** The paid grant: the one explicit yes a paid entry spends under, read again at every money moment. */
137
+ export function setPaidGrant(store, repo, allowed, by, now) {
138
+ const problem = personOnly();
139
+ if (problem !== null)
140
+ return { ok: false, problem };
141
+ return store.transact(() => {
142
+ const changed = store.handle.prepare("UPDATE fallback_setting SET paid_granted_by = ?, paid_granted_at = ?, updated_by = ?, updated_at = ? WHERE repo = ?")
143
+ .run(allowed ? by : null, allowed ? now.toISOString() : null, by, now.toISOString(), repo);
144
+ if (Number(changed.changes) === 0)
145
+ return { ok: false, problem: "This project has no fallback agents to pay for." };
146
+ logSetting(store, repo, allowed ? "paid fallback allowed" : "paid fallback revoked", by, allowed ? "Paid fallback agents may spend." : "Paid fallback agents may not spend.", now);
147
+ return { ok: true };
148
+ });
149
+ }
150
+ function paidGrantLive(store, repo) {
151
+ return repo !== null && fallbackSettingOf(store, repo)?.paidGrant != null;
152
+ }
153
+ // ---- Filing and sealing.
154
+ function resolveEntry(store, repo, entry, permissionMode = "auto") {
155
+ // "inherit": a repair turn resumes its own agent's session, so its model is the entry's whatever the repair row says.
156
+ const resolved = resolveScopeProfile(store, repo, undefined, { provider: entry.provider, model: entry.model, repairModel: "inherit", permissionMode });
157
+ return resolved.ok ? { ok: true, profile: resolved.profile } : { ok: false, problem: resolved.problem };
158
+ }
159
+ /**
160
+ * The chain a new scope files with: its base agent, then the project's entries. Null means no chain. A setting that can't
161
+ * resolve is a stated problem: the scope files unresolved in those words, and nothing runs on half a chain.
162
+ */
163
+ export function chainForFiling(store, repo, base, baseAuth, lower) {
164
+ if (repo === null)
165
+ return { ok: true, chain: null };
166
+ const setting = fallbackSettingOf(store, repo);
167
+ if (setting === null || (baseAuth !== "subscription" && baseAuth !== "api-key"))
168
+ return { ok: true, chain: null };
169
+ const problem = entriesProblem(setting.entries);
170
+ if (problem !== null)
171
+ return { ok: false, problem: `This project's fallback agents can't be read: ${problem}` };
172
+ const chain = [{ profile: base, authMode: baseAuth }];
173
+ const seen = new Set([entryDigestOf(chain[0])]);
174
+ for (const entry of setting.entries) {
175
+ // An entry never reaches further than the base: full access only where the base already has it.
176
+ const resolved = resolveEntry(store, repo, entry, permissionPlainWords(base)?.startsWith("full access") === true ? "bypassPermissions" : "auto");
177
+ if (!resolved.ok)
178
+ return { ok: false, problem: `This project's fallback agent ${entryWords(entry)} can't be used: ${resolved.problem}` };
179
+ const one = { profile: lower(resolved.profile), authMode: entry.authMode };
180
+ if (seen.has(entryDigestOf(one)))
181
+ continue;
182
+ seen.add(entryDigestOf(one));
183
+ chain.push(one);
184
+ }
185
+ return { ok: true, chain: chain.length < 2 ? null : chain };
186
+ }
187
+ export const chainJsonOf = (chain) => canonicalChainJson(chain, LIVE_CHAIN_VERSION);
188
+ /**
189
+ * Why this chain can't seal on this yes, or null. A paid entry takes a person's own yes: the ceremony or their chat tap,
190
+ * never a mode, grant, schedule, lead or automatic seal, and no machine's credential. A chat tap is bound to the scope's
191
+ * digest, which covers the chain, and the card shows the chain in full (chainTermsLines).
192
+ */
193
+ export function chainSealProblem(store, scope, basis) {
194
+ const chain = liveChainFromJson(scope.proposedChainJson ?? null);
195
+ if (chain === null)
196
+ return scope.proposedChainJson == null ? null : "this scope's fallback agents can't be read";
197
+ if (!chain.slice(1).some(entry => entryPaid(store, { provider: entry.profile.provider, authMode: entry.authMode })))
198
+ return null;
199
+ if (machineActing() || (basis !== undefined && basis.kind !== "chat"))
200
+ return "a paid fallback agent needs a person's own yes";
201
+ return null;
202
+ }
203
+ function sealedChain(store, taskId) {
204
+ const scope = store.getScope(taskId);
205
+ if (scope === null || scope.approvalKind !== "chain")
206
+ return { kind: "none" };
207
+ if (scope.approvedAt === null || scope.approvedDigest !== scope.digest)
208
+ return { kind: "problem", problem: "the approval no longer stands" };
209
+ const route = store.sealedRouteOf(taskId);
210
+ if (!route.ok)
211
+ return { kind: "problem", problem: route.detail };
212
+ const chain = liveChainFromJson(scope.approvedChainJson ?? null);
213
+ // A chain sealed before v115 still verifies as history; it spends nothing now.
214
+ if (chain === null)
215
+ return { kind: "problem", problem: "this approval names fallback agents from an older version; save the scope again and approve it" };
216
+ return { kind: "chain", chain, digest: chainDigestOf(chain, LIVE_CHAIN_VERSION), scope, routeDigest: routeDigestOf(route.route) };
217
+ }
218
+ // ---- The walk.
219
+ function walkOf(row) {
220
+ if (row === undefined)
221
+ return null;
222
+ return { id: Number(row["id"]), taskRef: Number(row["task_ref"]), scopeDigest: String(row["scope_digest"]), chainDigest: String(row["chain_digest"]), cursor: Number(row["cursor"]),
223
+ tailRun: Number(row["tail_run"]), state: row["state"] === "open" ? "open" : "closed", closedReason: row["closed_reason"] == null ? null : String(row["closed_reason"]) };
224
+ }
225
+ const openWalk = (store, taskRef) => walkOf(store.handle.prepare("SELECT * FROM fallback_walk WHERE task_ref = ? AND state = 'open'").get(taskRef));
226
+ function bindingOf(store, runId) {
227
+ const row = store.handle.prepare("SELECT id, task_ref, role, provider, model, outcome, reason, parent_run, chain_cycle, chain_index, entry_digest, auth_mode, terminal_class, provider_started_at, finished_at FROM run WHERE id = ?").get(runId);
228
+ if (row === undefined)
229
+ return null;
230
+ const opt = (key) => row[key] == null ? null : String(row[key]);
231
+ const num = (key) => row[key] == null ? null : Number(row[key]);
232
+ return { id: Number(row["id"]), taskRef: Number(row["task_ref"]), role: String(row["role"]), provider: String(row["provider"]), model: opt("model"), outcome: opt("outcome"), reason: opt("reason"), parentRun: num("parent_run"),
233
+ chainCycle: num("chain_cycle"), chainIndex: num("chain_index"), entryDigest: opt("entry_digest"), authMode: opt("auth_mode"), terminalClass: opt("terminal_class"), providerStartedAt: opt("provider_started_at"), finishedAt: opt("finished_at") };
234
+ }
235
+ const pendingMove = (store, walk, index) => store.handle.prepare("SELECT 1 AS hit FROM fallback_move WHERE walk = ? AND to_index = ? AND to_run IS NULL").get(walk, index) !== undefined;
236
+ function closeWalk(store, id, reason, now) {
237
+ store.handle.prepare("UPDATE fallback_walk SET state = 'closed', closed_reason = ?, closed_at = ? WHERE id = ? AND state = 'open'").run(reason, now.toISOString(), id);
238
+ }
239
+ /**
240
+ * THE ONE RESOLVER: what a walk's tail says about the walk. An open tail or a move waiting to run leaves it; a parked
241
+ * tail keeps it, because the answer resumes that agent; a built tail closes it; anything else closes it, and the retry
242
+ * starts a fresh walk at the base. Whichever pass gets here, after any crash, settles it the same way.
243
+ */
244
+ function settleWalk(store, walk, now) {
245
+ const ref = store.refForId(walk.taskRef);
246
+ const scope = ref === null ? null : store.getScope(ref.externalId);
247
+ if (ref === null || scope === null || scope.approvedDigest !== walk.scopeDigest || scope.digest !== walk.scopeDigest) {
248
+ closeWalk(store, walk.id, "approval-changed", now);
249
+ return;
250
+ }
251
+ const pending = pendingMove(store, walk.id, walk.cursor);
252
+ const tail = bindingOf(store, walk.tailRun);
253
+ const finished = !pending && (tail?.outcome === "built" || tail?.outcome === "no-change");
254
+ const state = store.getTask(ref.externalId)?.state;
255
+ if (state === "cancelled" || state === "done") {
256
+ closeWalk(store, walk.id, finished ? "succeeded" : "task-ended", now);
257
+ return;
258
+ }
259
+ if (pending)
260
+ return;
261
+ if (tail === null) {
262
+ closeWalk(store, walk.id, "entry-ended", now);
263
+ return;
264
+ }
265
+ if (tail.outcome === null || tail.outcome === "parked")
266
+ return;
267
+ closeWalk(store, walk.id, finished ? "succeeded" : "entry-ended", now);
268
+ }
269
+ /** Each tick, before any dispatch: settle every open walk. */
270
+ export function reconcileWalks(store, now) {
271
+ if (!hasTables(store))
272
+ return;
273
+ store.transact(() => {
274
+ for (const row of store.handle.prepare("SELECT * FROM fallback_walk WHERE state = 'open' ORDER BY id").all())
275
+ settleWalk(store, walkOf(row), now);
276
+ });
277
+ }
278
+ /**
279
+ * Which entry the next build of this task runs as, read by the tick before anything is claimed: null is the ordinary
280
+ * road (no chain, or the base agent). A move whose paid grant has since gone closes here and says so: nothing runs on it.
281
+ */
282
+ export function dispatchEntryOf(store, taskRef, taskId, now) {
283
+ if (!hasTables(store))
284
+ return null;
285
+ const sealed = sealedChain(store, taskId);
286
+ if (sealed.kind === "none")
287
+ return null;
288
+ if (sealed.kind === "problem")
289
+ return { problem: sealed.problem };
290
+ return store.transact(() => {
291
+ const before = openWalk(store, taskRef);
292
+ if (before !== null)
293
+ settleWalk(store, before, now);
294
+ const walk = openWalk(store, taskRef);
295
+ if (walk === null || walk.cursor === 0)
296
+ return null;
297
+ if (walk.chainDigest !== sealed.digest) {
298
+ closeWalk(store, walk.id, "approval-changed", now);
299
+ return null;
300
+ }
301
+ const entry = sealed.chain[walk.cursor];
302
+ if (entry === undefined) {
303
+ closeWalk(store, walk.id, "chain-ended", now);
304
+ return null;
305
+ }
306
+ const paid = entryPaid(store, { provider: entry.profile.provider, authMode: entry.authMode });
307
+ if (paid && !paidGrantLive(store, store.refForId(taskRef)?.repo ?? null)) {
308
+ closeWalk(store, walk.id, "grant-withheld", now);
309
+ return null;
310
+ }
311
+ return { index: walk.cursor, entry, paid };
312
+ });
313
+ }
314
+ /** A route stamp for a fallback entry, checked against the sealed chain: the admission's proof of `fallback` provenance. */
315
+ export function fallbackStampProblem(store, taskRef, taskId, stamp, parentRun) {
316
+ if (!hasTables(store))
317
+ return "this database has no fallback records yet";
318
+ const sealed = sealedChain(store, taskId);
319
+ if (sealed.kind !== "chain")
320
+ return sealed.kind === "problem" ? sealed.problem : "this approval names no fallback agents";
321
+ if (stamp.routeDigest !== sealed.routeDigest)
322
+ return "the stamp names a route the approval doesn't";
323
+ const walk = openWalk(store, taskRef);
324
+ if (walk === null)
325
+ return "no walk is open for this task";
326
+ const entry = sealed.chain[walk.cursor];
327
+ if (entry === undefined || walk.cursor === 0)
328
+ return "the chain has no fallback entry here";
329
+ const model = stamp.phase === "repair" ? (entry.profile.repairModel === "inherit" ? entry.profile.model : entry.profile.repairModel) : entry.profile.model;
330
+ if (stamp.provider !== entry.profile.provider || stamp.model !== model)
331
+ return `the chain's entry is ${entry.profile.provider} · ${model}, not ${stamp.provider} · ${stamp.model ?? "(none)"}`;
332
+ if (stamp.phase === "repair") {
333
+ const parent = parentRun === null ? null : bindingOf(store, parentRun);
334
+ if (parent === null || parent.id !== walk.tailRun)
335
+ return "a fallback repair mends the walk's own attempt";
336
+ }
337
+ else if (stamp.phase !== "build")
338
+ return "only a build or its repair runs as a fallback";
339
+ return null;
340
+ }
341
+ function stampRun(store, runId, walk, index, entry) {
342
+ store.handle.prepare("UPDATE run SET chain_cycle = ?, chain_index = ?, entry_digest = ?, auth_mode = ? WHERE id = ?").run(walk, index, entryDigestOf(entry), entry.authMode, runId);
343
+ }
344
+ /**
345
+ * Inside the run's admission transaction: bind it to its walk, or say why not (the caller rolls the row back). A base run
346
+ * opens a walk; a pending entry consumes its edge exactly once, with the paid grant re-read; a parked tail hands custody to
347
+ * its resumption; a repair inherits its parent's binding verbatim. Nothing else opens on a chain approval.
348
+ */
349
+ export function bindRunToWalk(store, runId, now) {
350
+ if (!hasTables(store))
351
+ return null;
352
+ const run = bindingOf(store, runId);
353
+ if (run === null || (run.role !== "builder" && run.role !== "repair"))
354
+ return null;
355
+ const ref = store.refForId(run.taskRef);
356
+ if (ref === null)
357
+ return null;
358
+ const sealed = sealedChain(store, ref.externalId);
359
+ if (sealed.kind === "none")
360
+ return null;
361
+ if (sealed.kind === "problem")
362
+ return sealed.problem;
363
+ if (run.role === "repair") {
364
+ const parent = run.parentRun === null ? null : bindingOf(store, run.parentRun);
365
+ if (parent === null || parent.chainCycle === null || parent.chainIndex === null)
366
+ return "this repair's attempt holds no place in a walk";
367
+ const entry = sealed.chain[parent.chainIndex];
368
+ if (entry === undefined)
369
+ return "this repair's attempt holds no place in the chain";
370
+ stampRun(store, run.id, parent.chainCycle, parent.chainIndex, entry);
371
+ return null;
372
+ }
373
+ const open = openWalk(store, run.taskRef);
374
+ if (open !== null)
375
+ settleWalk(store, open, now);
376
+ const walk = openWalk(store, run.taskRef);
377
+ if (walk === null) {
378
+ const base = sealed.chain[0];
379
+ if (run.provider !== base.profile.provider || run.model !== base.profile.model)
380
+ return "a new walk starts at the chain's base agent";
381
+ const made = store.handle.prepare("INSERT INTO fallback_walk (task_ref, scope_digest, chain_digest, tail_run, opened_at) VALUES (?, ?, ?, ?, ?)")
382
+ .run(run.taskRef, sealed.scope.digest, sealed.digest, run.id, now.toISOString());
383
+ stampRun(store, run.id, Number(made.lastInsertRowid), 0, base);
384
+ return null;
385
+ }
386
+ if (walk.chainDigest !== sealed.digest || walk.scopeDigest !== sealed.scope.digest)
387
+ return "the walk was opened under another approval";
388
+ const entry = sealed.chain[walk.cursor];
389
+ if (entry === undefined)
390
+ return "the walk's place isn't in the chain";
391
+ if (run.provider !== entry.profile.provider || run.model !== entry.profile.model)
392
+ return `the walk is at ${entry.profile.provider} · ${entry.profile.model}, not ${run.provider} · ${run.model ?? "(none)"}`;
393
+ if (entryPaid(store, { provider: entry.profile.provider, authMode: entry.authMode }) && !paidGrantLive(store, ref.repo))
394
+ return "paid fallback isn't allowed for this project";
395
+ if (pendingMove(store, walk.id, walk.cursor)) {
396
+ const took = store.handle.prepare("UPDATE fallback_move SET to_run = ? WHERE walk = ? AND to_index = ? AND to_run IS NULL").run(run.id, walk.id, walk.cursor);
397
+ if (Number(took.changes) !== 1)
398
+ return "this move was already admitted";
399
+ }
400
+ else if (bindingOf(store, walk.tailRun)?.outcome !== "parked")
401
+ return "the walk's attempt is still running";
402
+ store.handle.prepare("UPDATE fallback_walk SET tail_run = ? WHERE id = ?").run(run.id, walk.id);
403
+ stampRun(store, run.id, walk.id, walk.cursor, entry);
404
+ return null;
405
+ }
406
+ /** The pinned entry a bound run spends as, or null for a run with no binding. */
407
+ export function pinnedEntryOf(store, runId) {
408
+ if (!hasTables(store))
409
+ return null;
410
+ const run = bindingOf(store, runId);
411
+ if (run === null || run.chainCycle === null || run.chainIndex === null)
412
+ return null;
413
+ const ref = store.refForId(run.taskRef);
414
+ const sealed = ref === null ? null : sealedChain(store, ref.externalId);
415
+ if (sealed === null || sealed.kind !== "chain")
416
+ return { problem: sealed !== null && sealed.kind === "problem" ? sealed.problem : "the approval no longer names this chain" };
417
+ const entry = sealed.chain[run.chainIndex];
418
+ if (entry === undefined || entryDigestOf(entry) !== run.entryDigest)
419
+ return { problem: "this run's fallback entry isn't in the approved chain" };
420
+ return { index: run.chainIndex, entry };
421
+ }
422
+ /**
423
+ * THE LAST LOOK AT THE SPAWN: one read of every authority a chain run spends under. A builder or repair on a chain
424
+ * approval with no binding spends nothing; a bound run must hold the walk's current place, under the live approval, as its
425
+ * exact entry; a paid entry must still hold the live grant. Null means go.
426
+ */
427
+ export function chainCustodyProblem(store, runId) {
428
+ if (!hasTables(store))
429
+ return null;
430
+ return store.transact(() => {
431
+ const run = bindingOf(store, runId);
432
+ if (run === null || (run.role !== "builder" && run.role !== "repair"))
433
+ return null;
434
+ const ref = store.refForId(run.taskRef);
435
+ if (ref === null)
436
+ return null;
437
+ const sealed = sealedChain(store, ref.externalId);
438
+ if (sealed.kind === "none")
439
+ return run.chainCycle === null ? null : "this run is bound to a chain the approval no longer names";
440
+ if (sealed.kind === "problem")
441
+ return sealed.problem;
442
+ if (run.chainCycle === null || run.chainIndex === null)
443
+ return "nothing spends on a chain approval outside its walk";
444
+ const walk = walkOf(store.handle.prepare("SELECT * FROM fallback_walk WHERE id = ?").get(run.chainCycle));
445
+ if (walk === null || walk.state !== "open")
446
+ return "this run's walk has closed";
447
+ if (walk.chainDigest !== sealed.digest || walk.scopeDigest !== sealed.scope.digest)
448
+ return "this run's walk was opened under another approval";
449
+ if (walk.cursor !== run.chainIndex)
450
+ return "this run no longer holds the walk's place";
451
+ if (walk.tailRun !== run.id && !(run.role === "repair" && run.parentRun === walk.tailRun))
452
+ return "this run no longer holds the walk's place";
453
+ const entry = sealed.chain[run.chainIndex];
454
+ if (entry === undefined || entryDigestOf(entry) !== run.entryDigest || entry.profile.provider !== run.provider || entry.authMode !== run.authMode)
455
+ return "this run isn't the entry that was approved";
456
+ if (entryPaid(store, { provider: entry.profile.provider, authMode: entry.authMode }) && !paidGrantLive(store, ref.repo))
457
+ return "paid fallback isn't allowed for this project";
458
+ return null;
459
+ });
460
+ }
461
+ /** A builder's newest repair turn, when it ran out of plan holding the builder's exact place in the walk: the evidence a
462
+ * repair turn gives its owning attempt. Any newer turn, a turn that never spawned or never finished, or one bound
463
+ * elsewhere, is none. */
464
+ function exhaustedRepairOf(store, run) {
465
+ const row = store.handle.prepare("SELECT id FROM run WHERE parent_run = ? AND role = 'repair' ORDER BY id DESC LIMIT 1").get(run.id);
466
+ const child = row === undefined ? null : bindingOf(store, Number(row["id"]));
467
+ // Only the agent's own failed exit: a turn the gateway refused (a session that didn't match, any protocol problem) is
468
+ // never evidence, whatever its stamp says.
469
+ if (child === null || child.terminalClass !== "usage-exhausted" || child.providerStartedAt === null || child.outcome !== "failed" || child.reason !== "agent")
470
+ return null;
471
+ if (child.taskRef !== run.taskRef || child.chainCycle !== run.chainCycle || child.chainIndex !== run.chainIndex || child.entryDigest !== run.entryDigest)
472
+ return null;
473
+ return child;
474
+ }
475
+ /** Whether this finished attempt is about to move on: it holds a walk's place and the gateway classed it exhausted. */
476
+ export function ranOutOnChain(store, runId) {
477
+ if (!hasTables(store))
478
+ return false;
479
+ const run = bindingOf(store, runId);
480
+ return run !== null && run.chainCycle !== null && run.terminalClass === "usage-exhausted" && (run.outcome === null || (run.outcome === "failed" && run.reason === "agent"));
481
+ }
482
+ /** The gateway refused this bound run: its plan login isn't proved. The walk closes, a person is told, no strike. */
483
+ export function closeWalkSignedOut(store, runId, now) {
484
+ if (!hasTables(store))
485
+ return;
486
+ store.transact(() => {
487
+ const run = bindingOf(store, runId);
488
+ if (run === null || run.chainCycle === null)
489
+ return;
490
+ const closed = store.handle.prepare("UPDATE fallback_walk SET state = 'closed', closed_reason = 'entry-signed-out', closed_at = ? WHERE id = ? AND state = 'open'").run(now.toISOString(), run.chainCycle);
491
+ if (Number(closed.changes) === 0)
492
+ return;
493
+ const ref = store.refForId(run.taskRef);
494
+ store.enqueueNotification({
495
+ source: { run: run.id }, dedupeKey: `fallback:signed-out:${run.id}`, kind: "fallback", pushClass: "attention", link: `/r/${run.id}`,
496
+ subject: `${ref?.externalId ?? "A task"}: ${providerName(run.provider)} isn't signed in to its plan`,
497
+ body: `The next approved agent runs on its plan, but ${providerName(run.provider)} here isn't signed in to one. Nothing ran and nothing was spent. Sign it in to its plan, then retry the task.`,
498
+ }, now);
499
+ });
500
+ }
501
+ /**
502
+ * After the dispatch road finished a refused run: when it was a fallback agent refused for not being signed in to its
503
+ * plan, the task stops and waits for a person (task state failed, the run's reason saying why), until they retry it.
504
+ * Nothing expires it. True when it did.
505
+ */
506
+ export function blockAfterSignedOut(store, runId, now) {
507
+ if (!hasTables(store))
508
+ return false;
509
+ return store.transact(() => {
510
+ const run = bindingOf(store, runId);
511
+ if (run === null || run.chainCycle === null)
512
+ return false;
513
+ const walk = walkOf(store.handle.prepare("SELECT * FROM fallback_walk WHERE id = ?").get(run.chainCycle));
514
+ if (walk === null || walk.closedReason !== "entry-signed-out")
515
+ return false;
516
+ store.handle.prepare("UPDATE run SET reason = 'fallback-signed-out' WHERE id = ? AND outcome = 'refused'").run(runId);
517
+ const ref = store.refForId(run.taskRef);
518
+ if (ref !== null)
519
+ store.handle.prepare("UPDATE task SET state = 'failed', updated_at = ? WHERE id = ? AND state IN ('queued','running')").run(now.toISOString(), ref.externalId);
520
+ return true;
521
+ });
522
+ }
523
+ /**
524
+ * THE MOVE, inside sealFailure's transaction (the claim is already released, the run still open): only the walk's own tail,
525
+ * started and classed exhausted by the gateway, on the exact entry it was pinned to. Null means the ordinary failure road
526
+ * runs, with the walk closed in words when it can't go on. The caller finishes the run either way; a walk that moved leaves
527
+ * no strike.
528
+ */
529
+ export function moveOnIfExhausted(store, runId, taskId, now) {
530
+ if (!hasTables(store))
531
+ return null;
532
+ const run = bindingOf(store, runId);
533
+ if (run === null || run.outcome !== null || run.providerStartedAt === null)
534
+ return null;
535
+ if (run.role !== "builder" || run.chainCycle === null || run.chainIndex === null)
536
+ return null;
537
+ // The evidence: this attempt's own terminal, or its last repair turn's, proved to hold the same place on the same entry.
538
+ const evidence = run.terminalClass === "usage-exhausted" ? run : exhaustedRepairOf(store, run);
539
+ if (evidence === null)
540
+ return null;
541
+ const walk = openWalk(store, run.taskRef);
542
+ if (walk === null || walk.id !== run.chainCycle || walk.tailRun !== run.id || walk.cursor !== run.chainIndex)
543
+ return null;
544
+ const sealed = sealedChain(store, taskId);
545
+ if (sealed.kind !== "chain" || sealed.digest !== walk.chainDigest || sealed.scope.digest !== walk.scopeDigest) {
546
+ closeWalk(store, walk.id, "approval-changed", now);
547
+ return null;
548
+ }
549
+ const from = sealed.chain[run.chainIndex];
550
+ if (from === undefined || entryDigestOf(from) !== run.entryDigest || from.profile.provider !== run.provider || from.authMode !== run.authMode || !limitRecognised(run.provider, from.authMode)
551
+ || evidence.provider !== run.provider || evidence.authMode !== run.authMode) {
552
+ closeWalk(store, walk.id, "entry-ended", now);
553
+ return null;
554
+ }
555
+ const next = sealed.chain[run.chainIndex + 1];
556
+ if (next === undefined) {
557
+ closeWalk(store, walk.id, "chain-ended", now);
558
+ tell(store, run, taskId, "chain-ended", null, now);
559
+ return null;
560
+ }
561
+ const to = { provider: next.profile.provider, model: next.profile.model, authMode: next.authMode };
562
+ const paid = entryPaid(store, to);
563
+ if (paid && !paidGrantLive(store, store.refForId(run.taskRef)?.repo ?? null)) {
564
+ closeWalk(store, walk.id, "grant-withheld", now);
565
+ tell(store, run, taskId, "grant-withheld", to, now);
566
+ return null;
567
+ }
568
+ store.handle.prepare("INSERT INTO fallback_move (walk, to_index, from_run, terminal_class, paid, moved_at) VALUES (?, ?, ?, ?, ?, ?)")
569
+ .run(walk.id, run.chainIndex + 1, run.id, evidence.terminalClass, paid ? 1 : 0, now.toISOString());
570
+ store.handle.prepare("UPDATE fallback_walk SET cursor = ? WHERE id = ?").run(run.chainIndex + 1, walk.id);
571
+ store.handle.prepare("UPDATE task SET state = 'queued', updated_at = ? WHERE id = ? AND state = 'running'").run(now.toISOString(), taskId);
572
+ store.recordAction({ at: now.toISOString(), actor: "toolroll", repo: store.refForId(run.taskRef)?.repo ?? null, taskId, runId: run.id, action: "work moved to fallback agent", outcome: paid ? "paid" : "plan", source: "work", detail: entryWords(to) });
573
+ tell(store, run, taskId, "moved", to, now);
574
+ store.bumpWake();
575
+ return { moved: true, to, paid };
576
+ }
577
+ function tell(store, run, taskId, what, to, now) {
578
+ const agent = providerName(run.provider);
579
+ store.enqueueNotification({
580
+ source: { run: run.id },
581
+ dedupeKey: `fallback:${what}:${run.id}`,
582
+ kind: "fallback",
583
+ ...(what === "moved" ? {} : { pushClass: "attention" }),
584
+ link: `/r/${run.id}`,
585
+ subject: what === "moved" ? `${taskId}: moved to ${entryWords(to)}` : `${taskId}: ${agent} ran out and the work can't move on`,
586
+ body: what === "moved" ? `${agent} ran out of its plan. Its work was kept and the next approved agent continues it.`
587
+ : what === "grant-withheld" ? `The next agent, ${entryWords(to)}, is paid and this project doesn't allow paid fallback. Allow it, or wait for the plan to reset and requeue.`
588
+ : "Every approved agent has run out. Wait for a plan to reset and requeue.",
589
+ }, now);
590
+ }
591
+ /**
592
+ * What the next agent is told about the move, for its brief. The one place the handover note plugs in: once it merges, a
593
+ * fallback run gets that note here. Null for any run that isn't continuing another agent's work.
594
+ */
595
+ export function fallbackBriefFor(store, runId) {
596
+ if (!hasTables(store))
597
+ return null;
598
+ const row = store.handle.prepare("SELECT m.from_run FROM fallback_move m WHERE m.to_run = ?").get(runId);
599
+ if (row === undefined)
600
+ return null;
601
+ const from = bindingOf(store, Number(row["from_run"]));
602
+ if (from === null)
603
+ return null;
604
+ return `Another agent (${providerName(from.provider)}) started this task and ran out of its plan before finishing. Whatever it wrote is already on this branch, as a work-in-progress commit if there were changes. Read the branch first and continue from it; don't start again.`;
605
+ }