paseo-bm-plugin 0.0.0-placeholder.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/client/agent-tree.ts +308 -0
  4. package/client/answer-state.ts +62 -0
  5. package/client/bead-chips.tsx +147 -0
  6. package/client/beads-header-button.ts +108 -0
  7. package/client/beads-model.ts +581 -0
  8. package/client/beads-screen.tsx +516 -0
  9. package/client/beads-tab.tsx +58 -0
  10. package/client/chat-card.tsx +636 -0
  11. package/client/chat-cards.ts +1038 -0
  12. package/client/dashboard-actions.tsx +255 -0
  13. package/client/dashboard-model.ts +947 -0
  14. package/client/dashboard-view.ts +215 -0
  15. package/client/dashboard.tsx +318 -0
  16. package/client/launch-manager.ts +323 -0
  17. package/client/launcher.tsx +516 -0
  18. package/client/markdown-view.tsx +112 -0
  19. package/client/markdown.ts +145 -0
  20. package/client/settings.tsx +104 -0
  21. package/client/setup-model.ts +552 -0
  22. package/client/setup-screen.tsx +913 -0
  23. package/client/slot.ts +47 -0
  24. package/client/tree.tsx +204 -0
  25. package/client/ui.tsx +262 -0
  26. package/client/waiting-pills-model.ts +156 -0
  27. package/client/waiting-pills.tsx +201 -0
  28. package/index.client.tsx +232 -0
  29. package/index.server.ts +168 -0
  30. package/package.json +35 -0
  31. package/paseo-plugin.json +6 -0
  32. package/roles/manager.md +181 -0
  33. package/roles/reviewer.md +160 -0
  34. package/roles/worker.md +407 -0
  35. package/server/agent-labels.ts +194 -0
  36. package/server/agent-role.ts +102 -0
  37. package/server/answer-marks.ts +120 -0
  38. package/server/bead-actions.ts +88 -0
  39. package/server/bead-work.ts +80 -0
  40. package/server/beads-store.ts +342 -0
  41. package/server/bm-report.ts +433 -0
  42. package/server/chat-peers.ts +65 -0
  43. package/server/chat-rpc.ts +122 -0
  44. package/server/chat-waiting.ts +182 -0
  45. package/server/collector.ts +629 -0
  46. package/server/config-writer.ts +222 -0
  47. package/server/cost.ts +88 -0
  48. package/server/dashboard-rpc.ts +662 -0
  49. package/server/fallback-detect.ts +183 -0
  50. package/server/fallback-handover.ts +365 -0
  51. package/server/fallback-manager.ts +170 -0
  52. package/server/fallback-reviewer.ts +198 -0
  53. package/server/fallback-rpc.ts +306 -0
  54. package/server/fallback-settings.ts +322 -0
  55. package/server/fallback-state.ts +518 -0
  56. package/server/fallback-switch.ts +191 -0
  57. package/server/fallback-wait.ts +188 -0
  58. package/server/format-check.ts +352 -0
  59. package/server/install-home.ts +187 -0
  60. package/server/live-timeline.ts +129 -0
  61. package/server/manager-instructions.ts +9 -0
  62. package/server/manager.ts +647 -0
  63. package/server/model-costs.ts +238 -0
  64. package/server/notice-queue.ts +315 -0
  65. package/server/notices.ts +81 -0
  66. package/server/paseo-cli.ts +115 -0
  67. package/server/provider-id.ts +12 -0
  68. package/server/review-budget.ts +208 -0
  69. package/server/reviewer-instructions.ts +9 -0
  70. package/server/role-choices.ts +161 -0
  71. package/server/role-extras.ts +270 -0
  72. package/server/role-hook.ts +347 -0
  73. package/server/role-mode.ts +397 -0
  74. package/server/role-settings-rpc.ts +325 -0
  75. package/server/roles.ts +96 -0
  76. package/server/settings-notices.ts +112 -0
  77. package/server/setup-rpc.ts +70 -0
  78. package/server/setup-skills.ts +121 -0
  79. package/server/setup-tools.ts +162 -0
  80. package/server/shell.ts +68 -0
  81. package/server/stop-propagation.ts +365 -0
  82. package/server/tools-check.ts +118 -0
  83. package/server/trace-store.ts +1137 -0
  84. package/server/traces.ts +1356 -0
  85. package/server/worker-instructions.ts +9 -0
  86. package/server/workflow-steps.ts +422 -0
  87. package/shared/bead-ids.ts +25 -0
  88. package/shared/bm-fallback.ts +91 -0
  89. package/shared/bm-format.ts +424 -0
  90. package/shared/bm-questions.ts +213 -0
  91. package/shared/bm-report.ts +433 -0
  92. package/shared/contracts.ts +1371 -0
  93. package/shared/fallback-patterns.ts +201 -0
  94. package/shared/fallback.ts +46 -0
  95. package/shared/new-request.ts +20 -0
  96. package/shared/order.ts +22 -0
  97. package/shared/prices.ts +65 -0
  98. package/shared/settings.ts +57 -0
  99. package/shared/sole-worker.ts +20 -0
  100. package/shared/version.ts +6 -0
  101. package/tsconfig.json +16 -0
@@ -0,0 +1,191 @@
1
+ /**
2
+ * "Switch to <candidate>" for a stopped Worker (delta 20260921 §4.4.7,
3
+ * REQ-065 d): the plugin creates the replacement Worker on the candidate's
4
+ * fallback alias, with a handover built in code.
5
+ *
6
+ * 1. One switch at a time (in-process mutex; `fallback.act` also runs every
7
+ * card action one at a time, so a concurrent Wait or Dismiss cannot decide
8
+ * the incident while a Switch is under way — review b6): the incident must
9
+ * still be `pending` with a candidate, and the old Worker not already
10
+ * replaced (`bm.replacedBy`). A second click waits, then finds it decided.
11
+ * 2. The candidate's base provider is still available.
12
+ * 3. The handover (`fallback-handover.ts`, 5 s budget).
13
+ * 4. `agents.create` on `bm-worker-fallback-<n>/<model>`, in the old Worker's
14
+ * directory, as a child of its Manager. The `agent.create` hook still runs,
15
+ * so the Worker gets its instructions and its start posture.
16
+ * 5. `bm.replacedBy` on the old Worker (Paseo CLI); a failure only logs — the
17
+ * incident's `replacementId` excludes the old Worker too (§4.4.8).
18
+ * 6. The old Worker's RUNNING Reviewers get the existing stop notice.
19
+ * 7. `switched`, `replacementId`, `decidedAt`; the caller sends BM-FALLBACK.
20
+ * 8. A creation that fails records `failed` with the error and returns
21
+ * `E_FALLBACK_CREATE_FAILED`: the incident never goes back to `pending`, so
22
+ * a repeated click can never create two Workers. A problem found BEFORE the
23
+ * creation (the old Worker cannot be read) creates nothing and leaves the
24
+ * incident pending.
25
+ */
26
+ import { createLocationResolver, resolveLocationFromPaseo } from "./collector";
27
+ import { decidePending, type FallbackAction, type FallbackRpcDeps } from "./fallback-rpc";
28
+ import { REPLACED_BY_LABEL } from "./fallback-detect";
29
+ import { workerHandover } from "./fallback-handover";
30
+ import { readIncidents } from "./fallback-state";
31
+ import { setAgentLabels, type CliResult } from "./paseo-cli";
32
+ import { asRecord, availableProviders, nonEmpty, reasonOf } from "./role-choices";
33
+ import { installHomeOf } from "./role-extras";
34
+ import { TIMED_OUT, capabilityOf, chooseModeId, featuresFor, modesFor, runPostureOf, withTimeout } from "./role-mode";
35
+ import { stopRunningReviewers, type StopPaseo } from "./stop-propagation";
36
+ import type { TraceStoreLocation } from "./trace-store";
37
+ import { DashboardError, type FallbackIncident } from "../shared/contracts";
38
+ import { PLUGIN_VERSION } from "../shared/version";
39
+
40
+ /** Title of every replacement Worker. */
41
+ export const FALLBACK_WORKER_TITLE = "Beads Worker (fallback)";
42
+
43
+ /** Label a replacement agent carries: the id of the agent it replaces. */
44
+ export const REPLACES_LABEL = "bm.replaces";
45
+
46
+ /** The SDK slice a switch uses; `PaseoApi` is structurally assignable. */
47
+ export interface SwitchPaseo {
48
+ agents: {
49
+ create(options: {
50
+ config: Record<string, unknown>;
51
+ cwd: string;
52
+ parent?: string;
53
+ title: string;
54
+ labels: Record<string, string>;
55
+ prompt: string;
56
+ }): Promise<{ id: string }>;
57
+ ref(agentId: string): { refresh(): Promise<{ agent?: unknown } | null> };
58
+ };
59
+ }
60
+
61
+ export interface SwitchDeps {
62
+ log?: (message: string) => void;
63
+ now?: () => Date;
64
+ /** `setAgentLabels` of `paseo-cli.ts` by default. */
65
+ setLabels?: (agentId: string, labels: Record<string, string>) => Promise<CliResult>;
66
+ /** `stopRunningReviewers` of `stop-propagation.ts` by default. */
67
+ stopReviewers?: (paseo: unknown, workerId: string, workspaceId: string | null) => Promise<unknown>;
68
+ /** `workerHandover` by default. */
69
+ handover?: (incident: FallbackIncident, deps: { paseo: unknown; location: TraceStoreLocation | null }) => Promise<string>;
70
+ /** The workspace trace store; the collector's resolver by default. */
71
+ location?: (paseo: unknown) => Promise<TraceStoreLocation | null>;
72
+ }
73
+
74
+ let queue: Promise<unknown> = Promise.resolve();
75
+
76
+ /** Runs `work` after every switch queued before it; a failure does not block the next one. */
77
+ function serialised<T>(work: () => Promise<T>): Promise<T> {
78
+ const run = queue.then(work, work);
79
+ queue = run.catch(() => undefined);
80
+ return run;
81
+ }
82
+
83
+ /** The `switch` action of `fallback.act`, for the Worker. */
84
+ export function createWorkerSwitch(deps: SwitchDeps = {}): FallbackAction {
85
+ const log = deps.log ?? ((message: string) => console.warn(message));
86
+ const setLabels = deps.setLabels ?? ((agentId: string, labels: Record<string, string>) => setAgentLabels(agentId, labels));
87
+ const stopReviewers =
88
+ deps.stopReviewers ?? ((paseo: unknown, workerId: string, workspaceId: string | null) => stopRunningReviewers(paseo as StopPaseo, workerId, workspaceId));
89
+ const handover = deps.handover ?? workerHandover;
90
+ const location = deps.location ?? createLocationResolver(resolveLocationFromPaseo);
91
+
92
+ return (incident, paseo, rpcDeps: FallbackRpcDeps) =>
93
+ serialised(async () => {
94
+ const now = rpcDeps.now ?? deps.now ?? (() => new Date());
95
+ const found = await withTimeout(rpcDeps.home !== undefined ? Promise.resolve(rpcDeps.home) : installHomeOf(paseo));
96
+ const home = found === TIMED_OUT ? null : found;
97
+ if (home === null) throw new DashboardError("E_FALLBACK_NOT_FOUND", "paseo-bm cannot find its install home");
98
+ if (incident.role !== "worker") throw new DashboardError("E_FALLBACK_NO_CANDIDATE", `switching a ${incident.role} arrives in a later release`);
99
+
100
+ // 1. Still pending (a second click finds it decided) and not replaced yet.
101
+ const current = readIncidents(home, log).incidents.find((entry) => entry.id === incident.id);
102
+ if (current === undefined) throw new DashboardError("E_FALLBACK_NOT_FOUND", `no fallback incident ${incident.id}`);
103
+ if (current.status !== "pending") throw new DashboardError("E_FALLBACK_NOT_PENDING", `incident ${current.id} is ${current.status}, not pending`);
104
+ const candidate = current.candidate;
105
+ if (candidate === null) throw new DashboardError("E_FALLBACK_NO_CANDIDATE", "the fallback chain has no entry left for this Worker");
106
+ const api = paseo as SwitchPaseo;
107
+ let snapshot: Record<string, unknown> | null;
108
+ try {
109
+ snapshot = asRecord((await api.agents.ref(current.agentId).refresh())?.agent);
110
+ } catch (error) {
111
+ // Nothing was created: the incident stays pending, so the user can try again.
112
+ throw new DashboardError("E_FALLBACK_CREATE_FAILED", `could not read Worker ${current.agentId}: ${reasonOf(error)}; try again`);
113
+ }
114
+ const labels = asRecord(snapshot?.["labels"]) ?? {};
115
+ const replacedBy = nonEmpty(labels[REPLACED_BY_LABEL]);
116
+ if (replacedBy !== null) throw new DashboardError("E_FALLBACK_NOT_PENDING", `Worker ${current.agentId} was already replaced by ${replacedBy}`);
117
+
118
+ // 2. The candidate's provider is still there (when Paseo can say).
119
+ const available = await availableProviders(paseo);
120
+ if (available !== null && !available.has(candidate.baseProvider)) {
121
+ throw new DashboardError("E_FALLBACK_NO_CANDIDATE", `${candidate.baseProvider} is not available in Paseo right now`);
122
+ }
123
+
124
+ const fail = async (detail: string): Promise<never> => {
125
+ await decidePending(home, current.id, (entry) => ({ ...entry, status: "failed", decidedAt: now().toISOString(), error: detail }), log);
126
+ throw new DashboardError("E_FALLBACK_CREATE_FAILED", detail);
127
+ };
128
+ const cwd = nonEmpty(snapshot?.["cwd"]);
129
+ if (cwd === null) throw new DashboardError("E_FALLBACK_CREATE_FAILED", `the directory of Worker ${current.agentId} cannot be read`);
130
+
131
+ // 3. The handover, within its own budget.
132
+ const prompt = await handover(current, { paseo, location: await location(paseo).catch(() => null) });
133
+
134
+ // 4. The replacement Worker; the agent.create hook adds its instructions.
135
+ const alias = candidate.alias;
136
+ const modes = await modesFor(paseo, alias, log, cwd);
137
+ const capability = capabilityOf(modes);
138
+ const features = capability === "untiered" ? await featuresFor(paseo, `${alias}/${candidate.model}`, cwd, log) : null;
139
+ const posture = runPostureOf("worker", capability, modes ?? [], features, candidate.modeId);
140
+ // Tiered (Claude, Codex): the Worker rule the hook applies, passed explicitly.
141
+ const modeId =
142
+ candidate.modeId ?? (capability === "tiered" && modes !== null ? chooseModeId("worker", modes, undefined, null) : (posture?.modeId ?? undefined));
143
+ const featureValues = posture?.featureValues ?? undefined;
144
+ let created: { id: string };
145
+ try {
146
+ created = await api.agents.create({
147
+ config: {
148
+ provider: `${alias}/${candidate.model}`,
149
+ ...(candidate.thinkingOptionId !== null ? { thinkingOptionId: candidate.thinkingOptionId } : {}),
150
+ ...(modeId !== undefined && capability !== "none" ? { modeId } : {}),
151
+ ...(featureValues !== undefined ? { featureValues } : {}),
152
+ },
153
+ cwd,
154
+ ...(current.managerId !== null ? { parent: current.managerId } : {}),
155
+ title: FALLBACK_WORKER_TITLE,
156
+ labels: {
157
+ "bm.role": "worker",
158
+ ...(current.requestId !== null ? { "bm.requestId": current.requestId } : {}),
159
+ "bm.version": PLUGIN_VERSION,
160
+ [REPLACES_LABEL]: current.agentId,
161
+ },
162
+ prompt,
163
+ });
164
+ } catch (error) {
165
+ return fail(`could not create the fallback Worker on ${alias}/${candidate.model}: ${reasonOf(error)}`);
166
+ }
167
+
168
+ // 5. Mark the old Worker; the incident's replacementId excludes it even if this fails.
169
+ try {
170
+ const labelled = await setLabels(current.agentId, { [REPLACED_BY_LABEL]: created.id });
171
+ if (!labelled.ok) log(`[paseo-bm] could not label Worker ${current.agentId} as replaced by ${created.id}: ${labelled.reason}`);
172
+ } catch (error) {
173
+ log(`[paseo-bm] could not label Worker ${current.agentId} as replaced by ${created.id}: ${reasonOf(error)}`);
174
+ }
175
+
176
+ // 6. The old Worker's running Reviewers stop.
177
+ try {
178
+ await stopReviewers(paseo, current.agentId, current.workspaceId);
179
+ } catch (error) {
180
+ log(`[paseo-bm] could not stop the Reviewers of Worker ${current.agentId}: ${reasonOf(error)}`);
181
+ }
182
+
183
+ // 7. Decided.
184
+ return decidePending(
185
+ home,
186
+ current.id,
187
+ (entry) => ({ ...entry, status: "switched", decidedAt: now().toISOString(), replacementId: created.id }),
188
+ log,
189
+ );
190
+ });
191
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * "Wait until the reset" (delta 20260921 §4.4.9, REQ-065 e): one timer per
3
+ * Wait click, for any role (phase 2a-16 offers the Worker's only).
4
+ *
5
+ * The owner accepted it as a narrow exception to the root design's "no
6
+ * background task" rule (Q6 a): each wait has exactly one timer, set by the
7
+ * user's click; it is written to the incidents file (`waiting`, `waitUntil`)
8
+ * and set again after a plugin reload; a status change of the incident ends
9
+ * it; nothing scans the disk or repeats.
10
+ *
11
+ * Paseo hands a plugin no SDK handle when it loads (no start-up event, and
12
+ * `PluginServerContext` carries no `paseo`), so the timers of `waiting`
13
+ * incidents are set again at the first hook or fallback RPC that brings one —
14
+ * the same once-per-run pattern as the label scan of delta 20260918g. A timer
15
+ * already past due fires at once.
16
+ *
17
+ * When it fires: the stopped agent archived, gone, running or already replaced
18
+ * (`bm.replacedBy`) → `expired`. Otherwise it is sent `RESUME_NOTICE`, the
19
+ * incident becomes `resumed`, and its Manager chat gets a `BM-FALLBACK` with
20
+ * `status: resumed`. Never throws into a timer: failures cost one log line.
21
+ */
22
+ import { decidePending, notifyFallback, type FallbackAction, type FallbackRpcDeps } from "./fallback-rpc";
23
+ import { readIncidents, updateIncidents } from "./fallback-state";
24
+ import { REPLACED_BY_LABEL } from "./fallback-detect";
25
+ import { asRecord, reasonOf } from "./role-choices";
26
+ import { installHomeOf } from "./role-extras";
27
+ import { TIMED_OUT, withTimeout } from "./role-mode";
28
+ import { DashboardError, FALLBACK_MAX_WAIT_MS, type FallbackIncident } from "../shared/contracts";
29
+
30
+ /** What the stopped agent is sent when its usage reset (agent-facing, so English). */
31
+ export const RESUME_NOTICE = "BM-RESUME The usage limit that stopped you has reset. Continue from where you stopped; do not redo finished work.";
32
+
33
+ /** The agent is resumed this long after `resetsAt`. */
34
+ export const WAIT_GRACE_MS = 60_000;
35
+
36
+ /** A reset further ahead than this is not waited for; the card offers Wait by the same limit. */
37
+ export const MAX_WAIT_MS = FALLBACK_MAX_WAIT_MS;
38
+
39
+ type Timer = ReturnType<typeof setTimeout>;
40
+
41
+ export interface WaiterDeps {
42
+ now?: () => Date;
43
+ log?: (message: string) => void;
44
+ setTimer?: (fire: () => void, ms: number) => Timer;
45
+ clearTimer?: (timer: Timer) => void;
46
+ /** The Manager chat notice; `notifyFallback` by default. */
47
+ notify?: (incident: FallbackIncident, paseo: unknown) => Promise<unknown>;
48
+ /** The install home when the caller knows it (tests); looked up otherwise. */
49
+ home?: string | null;
50
+ }
51
+
52
+ /** The part of the SDK a resume uses; `PaseoApi` is structurally assignable. */
53
+ interface ResumePaseo {
54
+ agents: { ref(agentId: string): { refresh(): Promise<{ agent?: unknown } | null>; send(text: string): Promise<void> } };
55
+ }
56
+
57
+ export interface FallbackWaiter {
58
+ /** The `wait` action of `fallback.act`. */
59
+ wait: FallbackAction;
60
+ /** Sets the timers of every `waiting` incident, once per plugin run. Never throws. */
61
+ ensureArmed(paseo: unknown): Promise<void>;
62
+ /** Ends the timer of one incident (its status changed). */
63
+ cancel(incidentId: string): void;
64
+ /** Ends every timer (plugin cleanup). */
65
+ clear(): void;
66
+ }
67
+
68
+ export function createFallbackWaiter(deps: WaiterDeps = {}): FallbackWaiter {
69
+ const log = deps.log ?? ((message: string) => console.warn(message));
70
+ const now = deps.now ?? (() => new Date());
71
+ const setTimer =
72
+ deps.setTimer ??
73
+ ((fire: () => void, ms: number) => {
74
+ const timer = setTimeout(fire, ms);
75
+ timer.unref?.();
76
+ return timer;
77
+ });
78
+ const clearTimer = deps.clearTimer ?? ((timer: Timer) => clearTimeout(timer));
79
+ const notify = deps.notify ?? ((incident: FallbackIncident, paseo: unknown) => notifyFallback(incident, paseo));
80
+ const timers = new Map<string, Timer>();
81
+ let armed = false;
82
+
83
+ const homeOf = async (paseo: unknown, known?: string | null): Promise<string | null> => {
84
+ if (known !== undefined) return known;
85
+ if (deps.home !== undefined) return deps.home;
86
+ const found = await withTimeout(installHomeOf(paseo));
87
+ return found === TIMED_OUT ? null : found;
88
+ };
89
+
90
+ const cancel = (incidentId: string): void => {
91
+ const timer = timers.get(incidentId);
92
+ if (timer !== undefined) clearTimer(timer);
93
+ timers.delete(incidentId);
94
+ };
95
+
96
+ /** Moves a `waiting` incident to `resumed` or `expired`; null when it is no longer waiting. */
97
+ const finish = async (home: string, incidentId: string, status: "resumed" | "expired"): Promise<FallbackIncident | null> => {
98
+ let finished: FallbackIncident | null = null;
99
+ await updateIncidents(
100
+ home,
101
+ (incidents) => {
102
+ const index = incidents.findIndex((incident) => incident.id === incidentId && incident.status === "waiting");
103
+ if (index === -1) return null;
104
+ finished = { ...incidents[index]!, status };
105
+ return incidents.map((incident, at) => (at === index ? finished! : incident));
106
+ },
107
+ log,
108
+ );
109
+ return finished;
110
+ };
111
+
112
+ const fire = async (incidentId: string, paseo: unknown, home: string): Promise<void> => {
113
+ timers.delete(incidentId);
114
+ try {
115
+ const current = readIncidents(home, log).incidents.find((incident) => incident.id === incidentId);
116
+ // Any other status means the wait was ended by a later decision.
117
+ if (current === undefined || current.status !== "waiting") return;
118
+ const ref = (paseo as ResumePaseo).agents.ref(current.agentId);
119
+ const snapshot = asRecord((await ref.refresh())?.agent);
120
+ const status = snapshot?.["status"];
121
+ const labels = asRecord(snapshot?.["labels"]) ?? {};
122
+ const gone = snapshot === null || snapshot["archivedAt"] != null || status === "closed";
123
+ const busy = status === "running" || status === "initializing";
124
+ if (gone || busy || Object.prototype.hasOwnProperty.call(labels, REPLACED_BY_LABEL)) {
125
+ await finish(home, incidentId, "expired");
126
+ return;
127
+ }
128
+ await ref.send(RESUME_NOTICE);
129
+ const resumed = await finish(home, incidentId, "resumed");
130
+ if (resumed !== null) await notify(resumed, paseo);
131
+ } catch (error) {
132
+ log(`[paseo-bm] resuming the agent of fallback incident ${incidentId} failed: ${reasonOf(error)}`);
133
+ }
134
+ };
135
+
136
+ const arm = (incident: FallbackIncident, paseo: unknown, home: string): void => {
137
+ cancel(incident.id);
138
+ const due = incident.waitUntil === null ? Number.NaN : Date.parse(incident.waitUntil);
139
+ const delay = Number.isNaN(due) ? 0 : Math.max(0, due - now().getTime());
140
+ timers.set(
141
+ incident.id,
142
+ setTimer(() => void fire(incident.id, paseo, home), delay),
143
+ );
144
+ };
145
+
146
+ const wait: FallbackAction = async (incident, paseo, rpcDeps: FallbackRpcDeps) => {
147
+ const home = await homeOf(paseo, rpcDeps.home);
148
+ if (home === null) throw new DashboardError("E_FALLBACK_NOT_FOUND", "paseo-bm cannot find its install home");
149
+ const resetsAt = incident.resetsAt === null ? Number.NaN : Date.parse(incident.resetsAt);
150
+ const at = (rpcDeps.now ?? now)();
151
+ if (Number.isNaN(resetsAt)) throw new DashboardError("E_FALLBACK_NO_RESET", "the reset time of this limit is not known");
152
+ if (resetsAt - at.getTime() > MAX_WAIT_MS) throw new DashboardError("E_FALLBACK_NO_RESET", "the limit resets more than 7 days from now");
153
+ const waiting = await decidePending(
154
+ home,
155
+ incident.id,
156
+ (current) => ({ ...current, status: "waiting", decidedAt: at.toISOString(), waitUntil: new Date(resetsAt + WAIT_GRACE_MS).toISOString() }),
157
+ log,
158
+ );
159
+ arm(waiting, paseo, home);
160
+ return waiting;
161
+ };
162
+
163
+ return {
164
+ wait,
165
+ cancel,
166
+ async ensureArmed(paseo: unknown): Promise<void> {
167
+ if (armed) return;
168
+ armed = true;
169
+ try {
170
+ const home = await homeOf(paseo);
171
+ if (home === null) {
172
+ armed = false;
173
+ return;
174
+ }
175
+ for (const incident of readIncidents(home, log).incidents) {
176
+ if (incident.status === "waiting" && !timers.has(incident.id)) arm(incident, paseo, home);
177
+ }
178
+ } catch (error) {
179
+ armed = false;
180
+ log(`[paseo-bm] setting the fallback wait timers failed: ${reasonOf(error)}`);
181
+ }
182
+ },
183
+ clear(): void {
184
+ for (const timer of timers.values()) clearTimer(timer);
185
+ timers.clear();
186
+ },
187
+ };
188
+ }