@botbuddy/cli 1.31.0 → 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.mjs +6 -2
|
@@ -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.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";
|
|
@@ -1505,7 +1505,11 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
|
|
|
1505
1505
|
: new WaitCheckpointError("wait_checkpoint_write_failed", String(error?.message ?? error)));
|
|
1506
1506
|
}
|
|
1507
1507
|
|
|
1508
|
-
|
|
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() });
|
|
1509
1513
|
if (needsRelay) {
|
|
1510
1514
|
try {
|
|
1511
1515
|
const credential = await resolveAgentSessionCredential({ argv, env: process.env, cwd: process.cwd() });
|