@botbuddy/cli 1.30.2 → 1.31.1

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.
@@ -0,0 +1,375 @@
1
+ // BOT-1650 — the only CLI-side bridge between a harness and the tier-3 token.
2
+ // It never prints or exports a secret; the runtime store is consumed by `bb wait`.
3
+
4
+ import os from "node:os";
5
+ import { callToolJson } from "./api.mjs";
6
+ import { readAgentState, touchAgentStateExpiry, withStateLock, worktreeRoot, writeAgentState } from "./agent-state.mjs";
7
+ import { clearAgentSessionIfMatches, drainPendingCleanup, recordPendingCleanup } from "./agent-cleanup.mjs";
8
+ import { readAgentBinding } from "./wait-profile.mjs";
9
+ import { resolveAgentKey, resolveOwnerToken } from "./cli-credentials.mjs";
10
+ import { getConfig } from "./config.mjs";
11
+
12
+ // BOT-1650 → BOT-1649 reconciliation: the harness session lives in the ONE
13
+ // unified client cache (agent-state.mjs), written in main's `agent_session_token`
14
+ // schema. These adapters map between the bootstrap RPC response shape (which uses
15
+ // `session_token`) and the on-disk state, so `bb wait`/`run`/`test`/`pw` can
16
+ // authenticate from the same file via resolveAgentSessionCredential.
17
+
18
+ /** Read the per-worktree cache back in the RPC-shaped form the bridge expects. */
19
+ export async function loadHarnessSession({ cwd = process.cwd() } = {}) {
20
+ const state = await readAgentState(cwd);
21
+ if (!state) return null;
22
+ return {
23
+ ...state,
24
+ session_token: state.agent_session_token,
25
+ ...(state.session_token_generation ? { session_token_generation: state.session_token_generation } : {}),
26
+ };
27
+ }
28
+
29
+ /** Persist a bootstrap-issued session into the unified cache. The bootstrap RPC
30
+ * does not echo the tenant, so derive it from the committed binding exactly as
31
+ * selfHealAgentSession does; host/worktree/minted_at are derived locally. */
32
+ export async function saveHarnessSession(session, { cwd = process.cwd() } = {}) {
33
+ const root = await worktreeRoot(cwd);
34
+ let tenant;
35
+ try { tenant = (await readAgentBinding(root))?.tenant; } catch { tenant = undefined; }
36
+ const state = {
37
+ schema_version: 1,
38
+ agent_id: session.agent_id,
39
+ agent_session_token: session.session_token,
40
+ session_id: session.session_id ?? null,
41
+ ...(tenant ? { tenant } : {}),
42
+ host: os.hostname(),
43
+ worktree: root,
44
+ minted_at: new Date().toISOString(),
45
+ expires_at: session.expires_at,
46
+ // BOT-1650 (Codex round-28 P2): mark provenance so a TIMER wait only relays for
47
+ // a bootstrap-issued cache (the cross-shell receipt-of-principal contract). A
48
+ // bb setup / self-heal cache shares this schema but omits this marker, so a
49
+ // plain timer from such a shell stays fully offline.
50
+ harness_origin: "bootstrap_agent_session",
51
+ ...(session.session_token_generation ? { session_token_generation: session.session_token_generation } : {}),
52
+ };
53
+ await writeAgentState(root, state);
54
+ // Return the RPC-shaped session so callers reading `.session_token` are unchanged.
55
+ return session;
56
+ }
57
+
58
+ /** Guarded, lock-free clear used inside the bootstrap transaction (re-exported so
59
+ * the Codex bridge and `bb setup` close path share one implementation). */
60
+ export const clearHarnessSession = clearAgentSessionIfMatches;
61
+
62
+ /** Serialize the whole remote-bootstrap-plus-save on the unified per-worktree
63
+ * state lock — the SAME `<agent-state>.lock` selfHealAgentSession takes — so a
64
+ * bootstrap and a self-heal can never both write the cache. */
65
+ export async function withHarnessBootstrapLock({ cwd = process.cwd() } = {}, operation) {
66
+ const root = await worktreeRoot(cwd);
67
+ return withStateLock(root, {}, () => operation(root));
68
+ }
69
+
70
+ export async function durableCleanupOptions({
71
+ resolveAgentKeyFn = resolveAgentKey,
72
+ resolveOwnerTokenFn = resolveOwnerToken,
73
+ getConfigFn = getConfig,
74
+ } = {}) {
75
+ const owner = await resolveOwnerTokenFn({ getConfig: getConfigFn });
76
+ if (owner && !(owner.expiresAt && owner.expiresAt <= Date.now())) {
77
+ return { auth: { Authorization: `Bearer ${owner.token}` } };
78
+ }
79
+ const agentKey = await resolveAgentKeyFn();
80
+ if (agentKey) return { auth: { "x-agent-api-key": agentKey } };
81
+ return null;
82
+ }
83
+
84
+ function cleanupError(response) {
85
+ return response?.data?.error || response?.data?.message || response?.error || "remote session cleanup could not be confirmed";
86
+ }
87
+
88
+ async function closeWithDurableCredential(call, args, options) {
89
+ try {
90
+ const response = await call("close_session", args, options);
91
+ return { response, closed: !!response?.ok && !response.isError };
92
+ } catch (error) {
93
+ return { response: null, closed: false, error };
94
+ }
95
+ }
96
+
97
+ // BOT-1650 (Codex round-24/25): a session was admitted but could not be retired.
98
+ // Persist a cleanup marker so a later bootstrap retires it, then ALWAYS throw a
99
+ // failure that preserves the primary cause, the retirement failure, AND any
100
+ // persistence failure — never swallowing a marker-write failure (round-25 P2),
101
+ // which would silently revert to leaving an orphaned holder. Never returns.
102
+ async function recordOrphanAndFail({ session, cwd, retirementError, primaryError }) {
103
+ let persistError;
104
+ try {
105
+ await recordPendingCleanup(
106
+ { session_id: session.session_id, session_token_generation: session.session_token_generation },
107
+ { cwd },
108
+ );
109
+ } catch (error) {
110
+ persistError = error;
111
+ }
112
+ const parts = [];
113
+ if (primaryError) parts.push(primaryError.message);
114
+ parts.push(`the admitted session could not be retired: ${retirementError.message}`);
115
+ if (persistError) parts.push(`and its cleanup marker could not be persisted: ${persistError.message}`);
116
+ const err = new Error(parts.join("; "));
117
+ err.cause = primaryError ?? retirementError;
118
+ throw err;
119
+ }
120
+
121
+ // Keep a REST-bootstrapped harness alive without handing its durable setup
122
+ // credential to the bridge. The session bearer is scoped to this exact session;
123
+ // the server rejects a mismatched or ended session rather than refreshing an
124
+ // unrelated agent-global heartbeat.
125
+ export async function heartbeatHarnessSession({
126
+ session,
127
+ codexThreadId = null,
128
+ model = null,
129
+ call = callToolJson,
130
+ cwd = process.cwd(),
131
+ touchExpiry = touchAgentStateExpiry,
132
+ } = {}) {
133
+ if (!session?.session_id || !session?.session_token) {
134
+ throw new Error("bootstrap heartbeat requires a stored session credential");
135
+ }
136
+ const response = await call("heartbeat", {
137
+ session_id: session.session_id,
138
+ ...(codexThreadId ? { codex_thread_id: codexThreadId } : {}),
139
+ ...(model ? { model } : {}),
140
+ }, { auth: { "x-agent-api-key": session.session_token } });
141
+ if (!response?.ok || response.isError) {
142
+ throw new Error(response?.data?.message || response?.error || "bootstrap heartbeat failed");
143
+ }
144
+ // BOT-1650 (Codex round-30 P2): the server just rolled this session's expiry
145
+ // forward, but the runtime cache still holds the ORIGINAL deadline. Without
146
+ // mirroring the renewal, a later relay wait on a long-lived bridge judges the
147
+ // cache stale and self-heals into a DIFFERENT work session — losing the harness
148
+ // principal and creating a second live holder. touchAgentStateExpiry extends the
149
+ // cache only while it still holds THIS exact bearer, so it never revives a
150
+ // replaced credential. Best-effort: a heartbeat that cannot refresh the cache is
151
+ // not itself a failure.
152
+ await touchExpiry(cwd, session.session_token).catch(() => {});
153
+ return response.data;
154
+ }
155
+
156
+ export async function bootstrapHarnessSession({
157
+ name,
158
+ type = "codex",
159
+ harnessSessionId,
160
+ codexThreadId = null,
161
+ cwd = process.cwd(),
162
+ branch = null,
163
+ ticketId = null,
164
+ ticketUrl = null,
165
+ noTicketReason = null,
166
+ prId = null,
167
+ prNumber = null,
168
+ prUrl = null,
169
+ model = null,
170
+ effort = null,
171
+ harness = type,
172
+ call = callToolJson,
173
+ callOptions = undefined,
174
+ resolveCleanupOptions = durableCleanupOptions,
175
+ store = saveHarnessSession,
176
+ transaction = withHarnessBootstrapLock,
177
+ } = {}) {
178
+ if (!name || !harnessSessionId) throw new Error("bootstrap requires name and harnessSessionId");
179
+ const worktree = await worktreeRoot(cwd);
180
+ return transaction({ cwd: worktree }, async () => {
181
+ // BOT-1650 (Codex round-24): before admitting a new session, retry retiring
182
+ // any session a PRIOR bootstrap orphaned (its replacement retirement failed in
183
+ // a compound outage). The durable credential retires by id, so no bearer is
184
+ // needed. Best-effort — a missing durable credential just keeps the marker.
185
+ await drainPendingCleanup({
186
+ cwd: worktree,
187
+ retire: async (entry) => {
188
+ const opts = callOptions ?? await resolveCleanupOptions();
189
+ if (!opts) return false;
190
+ const closed = await closeWithDurableCredential(call, {
191
+ session_id: entry.session_id,
192
+ ...(entry.session_token_generation ? { session_token_generation: entry.session_token_generation } : {}),
193
+ }, opts);
194
+ return closed.closed;
195
+ },
196
+ }).catch(() => {});
197
+ const cached = await loadHarnessSession({ cwd: worktree });
198
+ // `expires_at` is a cache snapshot, not authority: an authenticated relay
199
+ // use can extend the server-side session after the file was written. Pass
200
+ // bootstrap straight through so the server reuses a still-live incarnation
201
+ // or rotates a truly expired one; never revoke an active wait from a stale
202
+ // local timestamp.
203
+ const response = await call("bootstrap_agent_session", {
204
+ name,
205
+ type,
206
+ harness_session_id: harnessSessionId,
207
+ host: os.hostname(),
208
+ worktree,
209
+ ...(branch ? { branch } : {}),
210
+ ...(ticketId ? { ticket_id: ticketId } : {}),
211
+ ...(ticketUrl ? { ticket_url: ticketUrl } : {}),
212
+ ...(noTicketReason ? { no_ticket_reason: noTicketReason } : {}),
213
+ ...(prId ? { pr_id: prId } : {}),
214
+ ...(prNumber != null ? { pr_number: prNumber } : {}),
215
+ ...(prUrl ? { pr_url: prUrl } : {}),
216
+ ...(codexThreadId ? { codex_thread_id: codexThreadId } : {}),
217
+ ...(model ? { model } : {}),
218
+ ...(effort ? { effort } : {}),
219
+ harness,
220
+ }, callOptions);
221
+ if (!response?.ok || response.isError) {
222
+ throw new Error(response?.data?.message || response?.error || "bootstrap_agent_session failed");
223
+ }
224
+ // Persist only the four session-secret fields. Ticket signals are intentionally
225
+ // returned to the launcher, never copied into the runtime credential file.
226
+ const { ticket_signals: ticketSignals, display_name: displayName, linkage, ...session } = response.data;
227
+ if (ticketSignals?.concurrency) throw new Error(ticketSignals.concurrency.warning || "Another live session holds this ticket.");
228
+ if (linkage?.status === "gap") throw new Error(linkage.warning || "Session bootstrap requires ticket attribution.");
229
+ // A new harness may be admitted only after an abandoned cache aged out of
230
+ // the server's liveness window. Before replacing that file, ask the server
231
+ // to retire the cached session *only if its authoritative holder-liveness
232
+ // window elapsed*. The file's timestamp is intentionally not authority:
233
+ // relay heartbeats can extend it after the snapshot was written.
234
+ if (cached && cached.session_id !== session.session_id) {
235
+ const cleanupOptions = callOptions ?? await resolveCleanupOptions();
236
+ if (!cleanupOptions) {
237
+ // BOT-1650 (Codex round-27): the new session was admitted but there is no
238
+ // durable credential to retire the OLD cached session, so the replacement
239
+ // cannot complete. Do not strand the NEW bearer (never persisted): self-close
240
+ // it with its own scoped token first, and if that cannot be confirmed record
241
+ // an orphan marker for a later bootstrap to drain — then fail closed. Matches
242
+ // the store()-failure path's no-durable-credential handling.
243
+ const primaryError = new Error("a replacement harness cache requires a durable credential to confirm remote session cleanup");
244
+ const direct = await closeWithDurableCredential(call, {
245
+ session_id: session.session_id,
246
+ ...(session.session_token_generation ? { session_token_generation: session.session_token_generation } : {}),
247
+ }, { auth: { "x-agent-api-key": session.session_token } });
248
+ if (!direct.closed) {
249
+ await recordOrphanAndFail({
250
+ session,
251
+ cwd: worktree,
252
+ retirementError: new Error("no durable credential is available to retire the cached session, and the new session's self-close was not confirmed"),
253
+ primaryError,
254
+ });
255
+ }
256
+ throw primaryError;
257
+ }
258
+ const discardNewSession = async () => {
259
+ // A successful self-close revokes this tier-3 token before its response
260
+ // reaches us. If that response is lost, retrying the bearer loops on
261
+ // 401 forever; the durable setup credential is the authority that can
262
+ // confirm the idempotent close (including already_closed).
263
+ const closeArgs = {
264
+ session_id: session.session_id,
265
+ ...(session.session_token_generation ? { session_token_generation: session.session_token_generation } : {}),
266
+ };
267
+ const direct = await closeWithDurableCredential(call, closeArgs, {
268
+ auth: { "x-agent-api-key": session.session_token },
269
+ });
270
+ if (direct.closed) return;
271
+ const confirmed = await closeWithDurableCredential(call, closeArgs, cleanupOptions);
272
+ if (confirmed.closed) return;
273
+ throw new Error(cleanupError(confirmed.response) || direct.error?.message || "new harness session cleanup could not be confirmed");
274
+ };
275
+ const cachedCloseArgs = {
276
+ session_id: cached.session_id,
277
+ if_stale: true,
278
+ ...(cached.session_token_generation ? { session_token_generation: cached.session_token_generation } : {}),
279
+ };
280
+ let retired;
281
+ try {
282
+ retired = await call("close_session", cachedCloseArgs, cleanupOptions);
283
+ } catch (error) {
284
+ // BOT-1650 (Codex round-22): a THROWN cached-close (e.g. its idempotent
285
+ // close committed but the response was lost) must not bypass cleanup and
286
+ // strand the newly-admitted session — whose bearer is neither returned nor
287
+ // persisted — while the active holder blocks a later bridge thread with
288
+ // agent_session_conflict. Retry once (if_stale is idempotent) to confirm
289
+ // the old close; if it still cannot be confirmed, retire the new session
290
+ // before propagating.
291
+ const confirm = await call("close_session", cachedCloseArgs, cleanupOptions).catch((retryError) => ({ ok: false, error: retryError?.message }));
292
+ if (!confirm?.ok || confirm.isError) {
293
+ // BOT-1650 (Codex round-23): if retiring the new session ALSO fails (e.g.
294
+ // the same outage hits its direct AND durable close), do NOT swallow that
295
+ // and claim cleanup succeeded — the replacement bearer would stay active
296
+ // and block the next bootstrap. Surface both failures; a fresh bootstrap
297
+ // retries retirement (the orphaned session ages out / is re-reconciled).
298
+ try {
299
+ await discardNewSession();
300
+ } catch (discardError) {
301
+ // BOT-1650 (Codex round-24/25): retirement failed, so the new session
302
+ // is admitted but unpersisted. Record a cleanup marker (id+generation,
303
+ // no bearer) for a later bootstrap to drain, and fail closed preserving
304
+ // the cached-close cause, the retirement failure, and any marker-write
305
+ // failure (never swallowed).
306
+ await recordOrphanAndFail({ session, cwd: worktree, retirementError: discardError, primaryError: error });
307
+ }
308
+ throw error;
309
+ }
310
+ retired = confirm;
311
+ }
312
+ if (!retired?.ok || retired.isError) {
313
+ try {
314
+ await discardNewSession();
315
+ } catch (discardError) {
316
+ // BOT-1650 (Codex round-24/25): retirement failed here too — record the
317
+ // orphan and fail closed (propagating any marker-write failure).
318
+ await recordOrphanAndFail({ session, cwd: worktree, retirementError: discardError });
319
+ }
320
+ throw new Error(retired?.data?.error || retired?.error || "cached harness session is still live or could not be retired");
321
+ }
322
+ await clearHarnessSession(cached.session_id, {
323
+ cwd: worktree,
324
+ sessionToken: cached.session_token,
325
+ lock: false, // already inside withHarnessBootstrapLock (the same state lock); re-locking would deadlock
326
+ });
327
+ }
328
+ let state;
329
+ try {
330
+ state = await store(session, { cwd: worktree });
331
+ } catch (error) {
332
+ // The server session is unusable if this harness cannot persist its only
333
+ // credential. The freshly issued session bearer is scoped to this exact
334
+ // session and can self-close, so try it first — the durable OAuth/MCP
335
+ // credential may have expired or been revoked between bootstrap and
336
+ // cleanup, and using only that would strand an active session whose token
337
+ // was never returned or saved. Fall back to the durable credential just
338
+ // to confirm an ambiguous close (a lost tier-3 response that already
339
+ // revoked its own bearer).
340
+ const closeArgs = {
341
+ session_id: session.session_id,
342
+ ...(session.session_token_generation ? { session_token_generation: session.session_token_generation } : {}),
343
+ };
344
+ const direct = await closeWithDurableCredential(call, closeArgs, {
345
+ auth: { "x-agent-api-key": session.session_token },
346
+ });
347
+ if (direct.closed) throw error;
348
+ const cleanupOptions = callOptions ?? await resolveCleanupOptions();
349
+ if (!cleanupOptions) {
350
+ // BOT-1650 (Codex round-26): the fresh bearer could not self-close AND no
351
+ // durable credential is available to retire the admitted session — so it
352
+ // cannot be closed at all. Record the orphan (so a later bootstrap with a
353
+ // credential drains it) and fail closed, instead of throwing with no marker.
354
+ await recordOrphanAndFail({
355
+ session,
356
+ cwd: worktree,
357
+ retirementError: new Error("no durable credential is available to confirm session cleanup"),
358
+ primaryError: error,
359
+ });
360
+ }
361
+ const confirmed = await closeWithDurableCredential(call, closeArgs, cleanupOptions);
362
+ if (!confirmed.closed) {
363
+ // BOT-1650 (Codex round-25): the session was admitted but its credential
364
+ // could not be persisted AND neither the fresh-bearer nor the durable
365
+ // close confirmed retirement — an orphaned live holder unrelated to cache
366
+ // replacement (e.g. a Windows ACL-hardening failure in store()). Record it
367
+ // so a later bootstrap drains it, and fail closed preserving both causes.
368
+ const retirementError = new Error(cleanupError(confirmed.response) || direct.error?.message || "bootstrap persistence cleanup could not be confirmed");
369
+ await recordOrphanAndFail({ session, cwd: worktree, retirementError, primaryError: error });
370
+ }
371
+ throw error;
372
+ }
373
+ return { ...state, ...(displayName ? { display_name: displayName } : {}), ...(ticketSignals ? { ticket_signals: ticketSignals } : {}), ...(linkage ? { linkage } : {}) };
374
+ });
375
+ }
package/src/wait-core.mjs CHANGED
@@ -196,16 +196,26 @@ const VALIDATORS = {
196
196
  // BOT-1066 — ci: wake when a PR's CI reaches a terminal conclusion. Exactly one
197
197
  // selector: scope=latest (follows the PR's latest run — needs pr=), run_id=<id>
198
198
  // (pinned to one run), or sha=<sha> (pinned to a commit). repo= is always required.
199
- // BOT-1507 — scope=next adds a fourth, LIVE-ONLY selector: it names no run, sha,
200
- // or PR (they must all already exist at arm time) and wakes on the first terminal
201
- // CI run for the repo delivered AFTER the wait was armed — the post-merge / cron
202
- // train case. Optional branch=/workflow= filter that first run; both are valid
203
- // ONLY with scope=next (a run_id/sha/latest wait already names its run).
199
+ // BOT-1507 — scope=next adds a fourth, LIVE-ONLY selector: it names no run or PR
200
+ // (they must already exist at arm time) and wakes on the first terminal CI run for
201
+ // the repo delivered AFTER the wait was armed — the post-merge / cron train case.
202
+ // Optional branch=/workflow= filter that first run; both are valid ONLY with
203
+ // scope=next (a run_id/sha/latest wait already names its run).
204
+ // BOT-1685 — scope=next also accepts an optional sha= FILTER (distinct from the
205
+ // level-triggered sha= selector): a wait armed BEFORE re-dispatching a run for a
206
+ // known commit then wakes on the NEXT terminal run for THAT commit, instead of
207
+ // replaying an earlier successful run for the same sha.
204
208
  ci(p) {
205
209
  const repo = p.repo != null ? String(p.repo).trim() : null;
206
210
  if (!repo || !repo.includes("/")) throw new Error("ci needs repo=<owner/repo>");
207
211
  const selectors = ["scope", "run_id", "sha"].filter((k) => p[k] !== undefined);
208
- if (selectors.length !== 1) {
212
+ // BOT-1685: scope=next accepts sha= as a live FILTER (like branch=/workflow=),
213
+ // not as a second selector — a wait armed BEFORE re-dispatching a run for a
214
+ // known commit then wakes on the NEXT terminal run for that commit rather than
215
+ // replaying an earlier terminal run (which the level-triggered `sha=` selector
216
+ // does by design). Exclude that filter sha from the single-selector count.
217
+ const shaIsNextFilter = p.scope === "next" && p.sha !== undefined;
218
+ if ((shaIsNextFilter ? selectors.length - 1 : selectors.length) !== 1) {
209
219
  throw new Error("ci needs exactly one of scope=latest|next | run_id=<id> | sha=<sha>");
210
220
  }
211
221
  const hasFilter = p.branch !== undefined || p.workflow !== undefined;
@@ -229,7 +239,7 @@ const VALIDATORS = {
229
239
  if (v === "") throw new Error(`ci ${key}= must be non-empty`);
230
240
  return v;
231
241
  };
232
- return { repo, scope: "next", branch: filter("branch"), workflow: filter("workflow"), pr: null };
242
+ return { repo, scope: "next", branch: filter("branch"), workflow: filter("workflow"), sha: filter("sha"), pr: null };
233
243
  }
234
244
  throw new Error("ci scope must be 'latest' or 'next' (or use run_id=/sha=)");
235
245
  }
@@ -804,6 +814,9 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
804
814
  // branch is exact, workflow is the GitHub display name (case/space-insensitive).
805
815
  const conclusion = signal.payload?.conclusion;
806
816
  if (typeof conclusion !== "string" || conclusion.trim() === "") return false;
817
+ // BOT-1685: an optional sha filter pins scope=next to a commit, so a
818
+ // re-dispatch wait wakes only on the terminal run for that commit.
819
+ if (params.sha != null && signal.payload?.head_sha !== params.sha) return false;
807
820
  if (params.branch != null && signal.payload?.head_branch !== params.branch) return false;
808
821
  if (params.workflow != null) {
809
822
  const wf = signal.payload?.workflow_name;
package/src/wait.mjs CHANGED
@@ -19,7 +19,7 @@ import { VERSION } from "./version.mjs";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { latestPublicCliCommand } from "./public-invocation.mjs";
21
21
  import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
22
- import { clearAgentState, isRejectedCachedSession, resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
22
+ import { clearAgentState, hasBootstrapAgentSession, isRejectedCachedSession, resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
23
23
  import { touchAgentStateExpiry } from "./agent-state.mjs";
24
24
  import { SETUP_BLOCK } from "./setup-block.mjs";
25
25
  import { createWaitCheckpointStore, WaitCheckpointError } from "./wait-checkpoint.mjs";
@@ -106,15 +106,18 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
106
106
  the others keep parking. Older servers broadcast without
107
107
  the ranking (first-come). Needs cli >= 1.20.0.
108
108
  ci:repo=<owner/repo>,{scope=latest,pr=<n> | run_id=<id> | sha=<sha>
109
- | scope=next[,branch=<name>][,workflow=<name>]}
109
+ | scope=next[,sha=<sha>][,branch=<name>][,workflow=<name>]}
110
110
  a PR's CI reaching a terminal conclusion (owner-scoped);
111
111
  GitHub Actions or an external check_suite provider.
112
112
  A repo with no CI feed is rejected (no_signal_source).
113
113
  scope=next is LIVE-ONLY: it wakes on the first terminal
114
114
  run delivered AFTER arm (post-merge / cron / scheduled),
115
- never replays a finished run, and needs no run_id/sha/pr.
116
- Optional branch= (exact) / workflow= (GitHub workflow
117
- DISPLAY name, case-insensitive) narrow that first run.
115
+ never replays a finished run, and needs no run_id/pr.
116
+ Optional sha= pins it to a commit (BOT-1685: arm before
117
+ re-dispatching a run for a known SHA so it wakes on the
118
+ NEW run, not an earlier terminal run for the same SHA);
119
+ branch= (exact) / workflow= (GitHub workflow DISPLAY
120
+ name, case-insensitive) narrow that first run too.
118
121
  staging-green:repo=<owner/repo> verified automatic recovery of an enabled staging gate.
119
122
  Wakes only after a complete non-vacuous workflow success;
120
123
  manual resolutions, overrides, skipped, and neutral runs do not match.
@@ -1502,7 +1505,11 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1502
1505
  : new WaitCheckpointError("wait_checkpoint_write_failed", String(error?.message ?? error)));
1503
1506
  }
1504
1507
 
1505
- const needsRelay = conditions.some((c) => c.type !== "timer");
1508
+ // BOT-1650: a plain timer stays offline, but a timer from a bootstrapped harness
1509
+ // registers so its terminal receipt proves the current session principal (the
1510
+ // cross-shell bootstrap contract). Detected without a network self-heal.
1511
+ const needsRelay = conditions.some((c) => c.type !== "timer")
1512
+ || await hasBootstrapAgentSession({ cwd: process.cwd() });
1506
1513
  if (needsRelay) {
1507
1514
  try {
1508
1515
  const credential = await resolveAgentSessionCredential({ argv, env: process.env, cwd: process.cwd() });