@botbuddy/cli 1.34.2 → 1.34.4
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-session.mjs +0 -0
- package/src/commands.mjs +9 -4
- package/src/session-bootstrap.mjs +77 -2
- package/src/stack.mjs +26 -3
package/package.json
CHANGED
package/src/agent-session.mjs
CHANGED
|
Binary file
|
package/src/commands.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { callTool, callToolJson, readResource } from "./api.mjs";
|
|
6
6
|
import { doLogin } from "./auth.mjs";
|
|
7
7
|
import { runBridge } from "./codex-bridge.mjs";
|
|
8
|
-
import { bootstrapHarnessSession, clearHarnessSession, loadHarnessSession, withHarnessBootstrapLock } from "./session-bootstrap.mjs";
|
|
8
|
+
import { bootstrapHarnessSession, clearHarnessSession, doctorBootstrapState, formatBootstrapLine, loadHarnessSession, withHarnessBootstrapLock } from "./session-bootstrap.mjs";
|
|
9
9
|
import { loadConfig, getConfig, clearConfig, getConfigPath, SERVER_URL } from "./config.mjs";
|
|
10
10
|
import { resolveOwnerToken, resolveAgentKey, clearOwnerToken } from "./cli-credentials.mjs";
|
|
11
11
|
import { agentProfileKeyLabel } from "./credential-kinds.mjs";
|
|
@@ -236,7 +236,7 @@ ${SETUP_BLOCK}`);
|
|
|
236
236
|
|
|
237
237
|
export async function cmdDoctor(args, {
|
|
238
238
|
log = (line) => console.log(line), errorLog = (line) => console.error(line),
|
|
239
|
-
doctor = doctorAgentAuth, usageReport = doctorUsageSync,
|
|
239
|
+
doctor = doctorAgentAuth, usageReport = doctorUsageSync, bootstrapReport = doctorBootstrapState,
|
|
240
240
|
} = {}) {
|
|
241
241
|
const parsed = parseDoctorArgs(args);
|
|
242
242
|
if (parsed.help) { log(doctorHelp()); return 0; }
|
|
@@ -244,8 +244,10 @@ export async function cmdDoctor(args, {
|
|
|
244
244
|
const result = await doctor({ fix: parsed.fix, printExports: parsed.printExports });
|
|
245
245
|
// BOT-1021: informational only — never changes doctor's exit code.
|
|
246
246
|
const usage = await usageReport().catch(() => null);
|
|
247
|
+
// BOT-1945 (AC-3): informational, like usage sync — never changes the exit code.
|
|
248
|
+
const bootstrap = await bootstrapReport().catch(() => null);
|
|
247
249
|
if (parsed.json) {
|
|
248
|
-
log(JSON.stringify(
|
|
250
|
+
log(JSON.stringify({ ...result.report, ...(usage ? { usage_sync: usage } : {}), ...(bootstrap ? { bootstrap } : {}) }));
|
|
249
251
|
} else {
|
|
250
252
|
log(`binding: ${result.report.binding.status}${result.report.binding.tenant ? ` (${result.report.binding.tenant})` : ""}`);
|
|
251
253
|
log(`client key: ${result.report.client_key.status}`);
|
|
@@ -256,6 +258,7 @@ export async function cmdDoctor(args, {
|
|
|
256
258
|
log(`stack lease: ${sl.capability} (host ${sl.host}, machine id ${sl.machine_id_readable ? "readable" : "unavailable"})${sl.reason ? ` — ${sl.reason}` : ""}`);
|
|
257
259
|
}
|
|
258
260
|
if (result.report.recovery) log(`recovery: ${result.report.recovery}`);
|
|
261
|
+
if (bootstrap) log(formatBootstrapLine(bootstrap));
|
|
259
262
|
if (usage) log(formatUsageSyncLine(usage));
|
|
260
263
|
}
|
|
261
264
|
if (result.exports) log(result.exports);
|
|
@@ -784,7 +787,9 @@ async function cmdBootstrap(args) {
|
|
|
784
787
|
harness: `botbuddy-${type}-launcher`,
|
|
785
788
|
});
|
|
786
789
|
if (state.linkage?.status === "gap") die(state.linkage.warning || "bb bootstrap requires ticket attribution or --no-ticket-reason.");
|
|
787
|
-
|
|
790
|
+
// BOT-1945: name (never the bearer of) an unowned cached session this bootstrap
|
|
791
|
+
// replaced, so a leftover live holder is visible instead of silent.
|
|
792
|
+
console.log(JSON.stringify({ agent_id: state.agent_id, display_name: state.display_name, session_id: state.session_id, expires_at: state.expires_at, ...(state.replaced_cached_session ? { replaced_cached_session: state.replaced_cached_session } : {}) }));
|
|
788
793
|
}
|
|
789
794
|
|
|
790
795
|
// BOT-1566 A4: after the local-metadata lines, ask the SERVER whether the stored
|
|
@@ -153,6 +153,55 @@ export async function heartbeatHarnessSession({
|
|
|
153
153
|
return response.data;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
// BOT-1945: a worktree cache that this bootstrap path did not create — written by
|
|
157
|
+
// the self-heal / `bb setup` / MCP registration path (no `harness_origin` marker),
|
|
158
|
+
// or a legacy bootstrap cache with no stored generation — is not a harness
|
|
159
|
+
// principal this bootstrap owns and cannot be retired through the fenced durable
|
|
160
|
+
// close (the server refuses a generated row without its generation:
|
|
161
|
+
// `session_generation_required`). Retire it best-effort and NEVER fail the
|
|
162
|
+
// bootstrap because of it:
|
|
163
|
+
// 1. its own scoped bearer self-closes with if_stale (the server derives the
|
|
164
|
+
// generation from the token, and still refuses a live holder);
|
|
165
|
+
// 2. the durable credential confirms (with the generation when cached): an
|
|
166
|
+
// ended row answers already_closed, an active generated row stays refused;
|
|
167
|
+
// 3. otherwise leave it: a session that is never closed simply ages out of the
|
|
168
|
+
// server's holder-liveness window and its short-lived token expires.
|
|
169
|
+
// Returns a non-secret receipt `{ session_id, retired }`.
|
|
170
|
+
export function cacheOwnedByBootstrap(cached) {
|
|
171
|
+
return cached?.harness_origin === "bootstrap_agent_session" && !!cached.session_token_generation;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function retireUnownedCachedSession({ cached, call, callOptions, resolveCleanupOptions }) {
|
|
175
|
+
const receipt = { session_id: cached.session_id, retired: false };
|
|
176
|
+
const ok = (response) => !!response?.ok && !response.isError;
|
|
177
|
+
try {
|
|
178
|
+
if (cached.session_token) {
|
|
179
|
+
const self = await call("close_session", { session_id: cached.session_id, if_stale: true }, {
|
|
180
|
+
auth: { "x-agent-api-key": cached.session_token },
|
|
181
|
+
}).catch(() => null);
|
|
182
|
+
if (ok(self)) return { ...receipt, retired: true };
|
|
183
|
+
// A live holder is refused by if_stale; the durable credential would be
|
|
184
|
+
// refused the same way, so do not widen to it.
|
|
185
|
+
if ((self?.data?.error || self?.error) === "session_still_active") return receipt;
|
|
186
|
+
}
|
|
187
|
+
// Codex #986 P2: confirm with the durable credential even without a cached
|
|
188
|
+
// generation. A bearer that fails auth often means the row was already ended
|
|
189
|
+
// (e.g. superseded by registration reconciliation) — the durable close then
|
|
190
|
+
// answers idempotent `already_closed`; an ACTIVE generated row is still
|
|
191
|
+
// refused (`session_generation_required`) and `if_stale` protects a live one.
|
|
192
|
+
const durable = callOptions ?? await resolveCleanupOptions().catch(() => null);
|
|
193
|
+
if (!durable) return receipt;
|
|
194
|
+
const confirmed = await call("close_session", {
|
|
195
|
+
session_id: cached.session_id,
|
|
196
|
+
...(cached.session_token_generation ? { session_token_generation: cached.session_token_generation } : {}),
|
|
197
|
+
if_stale: true,
|
|
198
|
+
}, durable).catch(() => null);
|
|
199
|
+
return { ...receipt, retired: ok(confirmed) };
|
|
200
|
+
} catch {
|
|
201
|
+
return receipt;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
156
205
|
export async function bootstrapHarnessSession({
|
|
157
206
|
name,
|
|
158
207
|
type = "codex",
|
|
@@ -231,7 +280,10 @@ export async function bootstrapHarnessSession({
|
|
|
231
280
|
// to retire the cached session *only if its authoritative holder-liveness
|
|
232
281
|
// window elapsed*. The file's timestamp is intentionally not authority:
|
|
233
282
|
// relay heartbeats can extend it after the snapshot was written.
|
|
234
|
-
|
|
283
|
+
let replacedCachedSession = null;
|
|
284
|
+
if (cached && cached.session_id !== session.session_id && !cacheOwnedByBootstrap(cached)) {
|
|
285
|
+
replacedCachedSession = await retireUnownedCachedSession({ cached, call, callOptions, resolveCleanupOptions });
|
|
286
|
+
} else if (cached && cached.session_id !== session.session_id) {
|
|
235
287
|
const cleanupOptions = callOptions ?? await resolveCleanupOptions();
|
|
236
288
|
if (!cleanupOptions) {
|
|
237
289
|
// BOT-1650 (Codex round-27): the new session was admitted but there is no
|
|
@@ -370,6 +422,29 @@ export async function bootstrapHarnessSession({
|
|
|
370
422
|
}
|
|
371
423
|
throw error;
|
|
372
424
|
}
|
|
373
|
-
return { ...state, ...(displayName ? { display_name: displayName } : {}), ...(ticketSignals ? { ticket_signals: ticketSignals } : {}), ...(linkage ? { linkage } : {}) };
|
|
425
|
+
return { ...state, ...(replacedCachedSession ? { replaced_cached_session: replacedCachedSession } : {}), ...(displayName ? { display_name: displayName } : {}), ...(ticketSignals ? { ticket_signals: ticketSignals } : {}), ...(linkage ? { linkage } : {}) };
|
|
374
426
|
});
|
|
375
427
|
}
|
|
428
|
+
|
|
429
|
+
/** BOT-1945 (AC-3): non-secret `bb doctor` view of whether this worktree's cache
|
|
430
|
+
* is a harness session linked by `bb bootstrap` (the SessionStart hook's job).
|
|
431
|
+
* Only ids and presence flags — never the bearer. */
|
|
432
|
+
export async function doctorBootstrapState({ cwd = process.cwd(), readState = readAgentState } = {}) {
|
|
433
|
+
let root;
|
|
434
|
+
try { root = await worktreeRoot(cwd); } catch { root = cwd; }
|
|
435
|
+
const state = await readState(root).catch(() => null);
|
|
436
|
+
if (!state) return { status: "not_linked", cache: "none" };
|
|
437
|
+
const bootstrap = state.harness_origin === "bootstrap_agent_session";
|
|
438
|
+
return {
|
|
439
|
+
status: bootstrap ? "linked" : "not_linked",
|
|
440
|
+
cache: bootstrap ? "bootstrap" : "self_heal",
|
|
441
|
+
session_id: state.session_id ?? null,
|
|
442
|
+
generation: state.session_token_generation ? "cached" : "missing",
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function formatBootstrapLine(report) {
|
|
447
|
+
if (report?.status === "linked") return `bootstrap: linked (session ${report.session_id}, generation ${report.generation})`;
|
|
448
|
+
if (!report || report.cache === "none") return "bootstrap: not linked — no session cache in this worktree (the SessionStart hook runs bb bootstrap)";
|
|
449
|
+
return `bootstrap: not linked — cache is a self-heal session ${report.session_id} (generation ${report.generation}); the SessionStart hook's bb bootstrap replaces it`;
|
|
450
|
+
}
|
package/src/stack.mjs
CHANGED
|
@@ -699,7 +699,8 @@ export function leaseRefusalExit(code) {
|
|
|
699
699
|
// BOT-1896: an auth-class refusal (scope_denied, a tenant mismatch, …) is the
|
|
700
700
|
// server refusing the credential — never BACKEND, which a gate reads as "down".
|
|
701
701
|
if (CALL_AUTH_ERROR_CODES.has(code)) return EXIT.AUTH;
|
|
702
|
-
|
|
702
|
+
// BOT-1926: a reused lease requested in the other execution mode is fixable by the caller.
|
|
703
|
+
return code === STACK_PATH_ROOT_REFUSED_CODE || code === "STACK_EXECUTION_MODE_MISMATCH" ? EXIT.INVALID : EXIT.BACKEND;
|
|
703
704
|
}
|
|
704
705
|
|
|
705
706
|
/** The refusal text for a receipt: the server's message, else its code. */
|
|
@@ -1372,6 +1373,10 @@ export async function cmdUp(opts, {
|
|
|
1372
1373
|
stack_path: execution.stackPath,
|
|
1373
1374
|
worktree_root: execution.worktreeRoot,
|
|
1374
1375
|
machine_uuid: hardwareUuid,
|
|
1376
|
+
// BOT-1926: fence the provision job from the Helper server-side. Without it a
|
|
1377
|
+
// polling Helper wins the request→reserve race, provisions the stack itself and
|
|
1378
|
+
// records a targetless connection that `done --local-exec` cannot release.
|
|
1379
|
+
...(opts.localExec ? { local_exec: true } : {}),
|
|
1375
1380
|
};
|
|
1376
1381
|
let req = await call("request_stack_lease", leaseArgs);
|
|
1377
1382
|
if (!req.ok && req.auth) {
|
|
@@ -1627,6 +1632,10 @@ export async function cmdDone(leaseId, opts, {
|
|
|
1627
1632
|
let legacyTargetProof = null;
|
|
1628
1633
|
let teardownDir = process.cwd();
|
|
1629
1634
|
let leaseBefore = null;
|
|
1635
|
+
// BOT-1926: a lease the Helper provisioned (it won the provision-job race, or the
|
|
1636
|
+
// server predates the local-exec fence) is torn down BY the Helper from its own
|
|
1637
|
+
// manifest. Locally it is not ours to stop: release it and let the reap job run.
|
|
1638
|
+
let helperTeardown = false;
|
|
1630
1639
|
if (opts.localExec) {
|
|
1631
1640
|
// BOT-1887: the disposition picks the local teardown (destroy deletes the project's
|
|
1632
1641
|
// volumes); refuse an unknown one BEFORE the lease is released into `reaping`.
|
|
@@ -1640,10 +1649,23 @@ export async function cmdDone(leaseId, opts, {
|
|
|
1640
1649
|
error: current.error || current.data?.code || "could not verify the lease Docker target" }), opts,
|
|
1641
1650
|
current.auth ? EXIT.AUTH : EXIT.BACKEND);
|
|
1642
1651
|
}
|
|
1652
|
+
leaseBefore = current.data;
|
|
1653
|
+
if (current.data.provisioner === "helper_provisioning") {
|
|
1654
|
+
// Codex #984 P1: the Helper has claimed but not finished provisioning; its manifest
|
|
1655
|
+
// is stored only by its receipt. Releasing now would strand that stack — stay fenced.
|
|
1656
|
+
return emitResult(buildReceipt({ command: "done", outcome: "refused", lease_id: leaseId,
|
|
1657
|
+
error: "the Helper is still provisioning this lease; retry `stack done` once it is active. Lease and slot remain fenced." }), opts, EXIT.LEASE_FAILED);
|
|
1658
|
+
}
|
|
1659
|
+
helperTeardown = current.data.provisioner === "helper";
|
|
1660
|
+
}
|
|
1661
|
+
// BOT-1926: an already-reaped lease has nothing left to tear down locally (only a
|
|
1662
|
+
// `reaping` release runs the local stop), so its target guards cannot protect anything.
|
|
1663
|
+
// Refusing it only kept the hermetic orphan sweep retrying a finished lease forever.
|
|
1664
|
+
if (opts.localExec && !helperTeardown && leaseBefore.state !== "reaped") {
|
|
1665
|
+
const current = { data: leaseBefore };
|
|
1643
1666
|
// BOT-1711: tear down in the directory the stack was PROVISIONED in (the lease's
|
|
1644
1667
|
// recorded worktree_root/stack_path), not the invoking cwd — otherwise `supabase
|
|
1645
1668
|
// stop` at the worktree root would stop the shared canonical stack.
|
|
1646
|
-
leaseBefore = current.data;
|
|
1647
1669
|
teardownDir = leaseStackDir(current.data);
|
|
1648
1670
|
// BOT-1711 (Codex P1, R4): refuse local-exec teardown of ANY lease whose target is the
|
|
1649
1671
|
// worktree root / default project — BEFORE branching on the persisted Docker target.
|
|
@@ -1765,7 +1787,7 @@ export async function cmdDone(leaseId, opts, {
|
|
|
1765
1787
|
error: `the lease's established reap disposition ${JSON.stringify(established)} is unknown — NOT tearing down; the slot stays fenced` }), opts, EXIT.BACKEND);
|
|
1766
1788
|
}
|
|
1767
1789
|
const disposition = alreadyReaped ? established : (established ?? opts.disposition);
|
|
1768
|
-
if (opts.localExec && state === "reaping") {
|
|
1790
|
+
if (opts.localExec && !helperTeardown && state === "reaping") {
|
|
1769
1791
|
// Only finalize once the stack is PROVABLY down. A failed `supabase stop` (Docker
|
|
1770
1792
|
// unavailable, etc.) must NOT finalize — finalize frees the slot lock and promotes
|
|
1771
1793
|
// the next queued lease, which would collide with containers still running on this
|
|
@@ -1782,6 +1804,7 @@ export async function cmdDone(leaseId, opts, {
|
|
|
1782
1804
|
else process.stderr.write(`${yellow("⚠")} stack: finalize failed (${fin.error || fin.data?.code}); the reaper will reconcile.\n`);
|
|
1783
1805
|
}
|
|
1784
1806
|
return emitResult(buildReceipt({ command: "done", outcome: "released", lease_id: leaseId, state, disposition,
|
|
1807
|
+
...(helperTeardown ? { teardown: "helper" } : {}),
|
|
1785
1808
|
...(legacyTargetProof ? { legacy_target_proof: legacyTargetProof } : {}) }), opts, EXIT.OK);
|
|
1786
1809
|
}
|
|
1787
1810
|
|