@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.
- package/package.json +1 -1
- package/src/agent-cleanup.mjs +150 -0
- package/src/agent-key.mjs +1 -1
- package/src/agent-session.mjs +0 -0
- package/src/agent-state-fs.mjs +90 -0
- package/src/agent-state.mjs +78 -11
- package/src/codex-bridge.mjs +364 -15
- package/src/commands.mjs +153 -0
- package/src/session-bootstrap.mjs +375 -0
- package/src/wait-core.mjs +20 -7
- package/src/wait.mjs +13 -6
|
@@ -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
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
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
|
-
|
|
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/
|
|
116
|
-
Optional
|
|
117
|
-
|
|
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
|
-
|
|
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() });
|