@botbuddy/cli 1.40.1 → 1.40.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.40.1",
3
+ "version": "1.40.3",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -99,6 +99,23 @@ export function applyTurnModelOverride(bootstrappedSessions, threadId, model, re
99
99
  return true;
100
100
  }
101
101
 
102
+ // BOT-2058: a bridge-created thread does not have a visible command-result
103
+ // surface before its first turn. Put an advisory live-holder warning in that
104
+ // turn's input, so the agent sees it before it can edit or claim a resource.
105
+ export function prependTicketSessionWarning(prompt, concurrency) {
106
+ const warning = concurrency?.warning;
107
+ if (typeof warning !== "string" || !warning.trim()) return prompt;
108
+ return `BotBuddy ticket-session warning: ${warning}\n\n${prompt}`;
109
+ }
110
+
111
+ export function consumeTicketSessionWarning(bootstrappedSessions, threadId, result) {
112
+ if (result?.error) return false;
113
+ const stored = bootstrappedSessions?.get?.(threadId);
114
+ if (!stored?.ticket_session_warning) return false;
115
+ stored.ticket_session_warning = null;
116
+ return true;
117
+ }
118
+
102
119
  // BOT-1650: retire a thread's admission only after its terminal (archived)
103
120
  // lifecycle update has persisted. relayPost returns the relay's error body (no
104
121
  // throw) on a transient 4xx/5xx, so a missing thread_id means the archived
@@ -677,8 +694,11 @@ export async function runBridge(args) {
677
694
  call: callToolJson,
678
695
  callOptions: { auth: await authHeaders() },
679
696
  });
680
- if (bootstrap.ticket_signals?.concurrency) {
681
- throw new Error(bootstrap.ticket_signals.concurrency.warning);
697
+ if (bootstrap.ticket_signals?.policy) {
698
+ throw new Error(bootstrap.ticket_signals.concurrency?.warning || "Ticket-session exclusivity could not be verified.");
699
+ }
700
+ if (bootstrap.ticket_signals?.concurrency?.holders?.length) {
701
+ console.warn(` ${yellow("!")} Ticket-session warning: ${bootstrap.ticket_signals.concurrency.warning}`);
682
702
  }
683
703
  if (bootstrap.linkage?.status === "gap") {
684
704
  throw new Error(bootstrap.linkage.warning || "Session bootstrap requires ticket attribution.");
@@ -699,6 +719,7 @@ export async function runBridge(args) {
699
719
  // overwritten to the default on the first 15s heartbeat.
700
720
  model: cmd.params.model || model,
701
721
  cwd: requestedWorktree,
722
+ ticket_session_warning: bootstrap.ticket_signals?.concurrency || null,
702
723
  });
703
724
  const linkPayload = {
704
725
  session_id: sessionId,
@@ -725,6 +746,7 @@ export async function runBridge(args) {
725
746
  display_name: bootstrap.display_name,
726
747
  session_id: bootstrap.session_id,
727
748
  expires_at: bootstrap.expires_at,
749
+ ...(bootstrap.ticket_signals?.concurrency ? { ticket_session_warning: bootstrap.ticket_signals.concurrency } : {}),
728
750
  };
729
751
  }
730
752
  break;
@@ -740,9 +762,10 @@ export async function runBridge(args) {
740
762
  result = { error: "Thread session bootstrap did not complete; start a new thread after resolving its authentication or ticket conflict." };
741
763
  break;
742
764
  }
765
+ const bootstrap = bootstrappedSessions.get(codexThreadId);
743
766
  result = await sendWs("turn/start", {
744
767
  threadId: codexThreadId,
745
- input: [{ type: "text", text: cmd.params.prompt || cmd.params.text || "" }],
768
+ input: [{ type: "text", text: prependTicketSessionWarning(cmd.params.prompt || cmd.params.text || "", bootstrap?.ticket_session_warning) }],
746
769
  ...(cmd.params.model && { model: cmd.params.model }),
747
770
  });
748
771
  // BOT-1650 (Codex round-35 P2): a per-turn model override must be written
@@ -750,6 +773,7 @@ export async function runBridge(args) {
750
773
  // creation-time model and overwrites agents.model — misattributing the
751
774
  // overridden turn on the dashboard and in effectiveness reporting.
752
775
  applyTurnModelOverride(bootstrappedSessions, codexThreadId, cmd.params.model, result);
776
+ consumeTicketSessionWarning(bootstrappedSessions, codexThreadId, result);
753
777
  break;
754
778
  }
755
779
 
package/src/commands.mjs CHANGED
@@ -551,12 +551,11 @@ export async function cmdSetup(args, {
551
551
  // resolving so it cannot merely report an old cache as ready.
552
552
  await clearState(cwd);
553
553
  const session = await selfHeal({ cwd, env });
554
- // BOT-1649 (Codex round-6 P1): register_agent reports any OTHER live session
555
- // already holding this ticket (heartbeat < 15m) in `ticket_signals`. One
556
- // ticket = one live session is a hard stop (AGENTS.md), so refuse to report
557
- // the worktree ready — otherwise setup silently green-lights a second coding
558
- // session on the ticket. The mint above re-adopts this worktree's own agent
559
- // and is not itself a second holder.
554
+ // BOT-2058: register_agent reports any OTHER live session already holding
555
+ // this ticket (heartbeat < 15m) in `ticket_signals`. This is advisory: a
556
+ // previous harness can remain visible while a human intentionally resumes
557
+ // the ticket in a new session. Shared resources stay protected by typed
558
+ // locks, which remain exclusive.
560
559
  const holders = session.ticketSignals?.concurrency?.holders;
561
560
  if (Array.isArray(holders) && holders.length > 0) {
562
561
  const warning = session.ticketSignals.concurrency.warning
@@ -564,8 +563,7 @@ export async function cmdSetup(args, {
564
563
  const who = holders
565
564
  .map((h) => `${h.agent ?? h.agent_id}${h.branch ? ` (${h.branch})` : ""}`)
566
565
  .join(", ");
567
- errorLog(`bb setup: ${warning} — held by ${who}. Do not start a second coding session on this ticket; hand off to the live holder or wait for it to finish.`);
568
- return 5;
566
+ errorLog(`bb setup: warning: ${warning} — held by ${who}. Confirm the active work before editing shared resources; typed locks remain required.`);
569
567
  }
570
568
  log(`bb setup: ${tenant} is ready (${session.source} worktree session). Run bb wait '<condition>'.`);
571
569
  await reportUsageAgent(usageAgent, { cwd, log });
@@ -828,7 +826,10 @@ async function cmdBootstrap(args) {
828
826
  if (state.linkage?.status === "gap") die(state.linkage.warning || "bb bootstrap requires ticket attribution or --no-ticket-reason.");
829
827
  // BOT-1945: name (never the bearer of) an unowned cached session this bootstrap
830
828
  // replaced, so a leftover live holder is visible instead of silent.
831
- 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 } : {}) }));
829
+ // BOT-2058: the launcher must see a known-holder warning before it starts a
830
+ // new harness. Keep it out of the credential cache, but preserve the
831
+ // non-secret concurrency payload in this command receipt.
832
+ 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.ticket_signals?.concurrency ? { ticket_session_warning: state.ticket_signals.concurrency } : {}), ...(state.replaced_cached_session ? { replaced_cached_session: state.replaced_cached_session } : {}) }));
832
833
  }
833
834
 
834
835
  // BOT-1566 A4: after the local-metadata lines, ask the SERVER whether the stored
@@ -273,7 +273,11 @@ export async function bootstrapHarnessSession({
273
273
  // Persist only the four session-secret fields. Ticket signals are intentionally
274
274
  // returned to the launcher, never copied into the runtime credential file.
275
275
  const { ticket_signals: ticketSignals, display_name: displayName, linkage, ...session } = response.data;
276
- if (ticketSignals?.concurrency) throw new Error(ticketSignals.concurrency.warning || "Another live session holds this ticket.");
276
+ // A live-holder report is advisory: agents frequently resume a ticket in a
277
+ // new harness session after a previous session's shutdown was not observed.
278
+ // The signal remains on the returned result so callers can show the holder;
279
+ // resource locks, not ticket presence, continue to arbitrate shared state.
280
+ if (ticketSignals?.policy) throw new Error(ticketSignals.concurrency?.warning || "Ticket-session exclusivity could not be verified.");
277
281
  if (linkage?.status === "gap") throw new Error(linkage.warning || "Session bootstrap requires ticket attribution.");
278
282
  // A new harness may be admitted only after an abandoned cache aged out of
279
283
  // the server's liveness window. Before replacing that file, ask the server
@@ -107,6 +107,20 @@ async function processStartedAt(pid) {
107
107
  }
108
108
  }
109
109
 
110
+ // The real process-instance probe behind the ownership fence. Tests inject a
111
+ // deterministic one instead of spawning `ps` (BOT-2031).
112
+ const systemProcessProbe = Object.freeze({
113
+ async isAlive(pid) {
114
+ try { process.kill(pid, 0); }
115
+ catch (error) {
116
+ if (error?.code === "ESRCH") return false;
117
+ return true; // EPERM is deliberately conservative.
118
+ }
119
+ return true;
120
+ },
121
+ startedAt: processStartedAt,
122
+ });
123
+
110
124
  function installFenceExitHook() {
111
125
  if (installedFenceExitHook) return;
112
126
  installedFenceExitHook = true;
@@ -120,7 +134,11 @@ function installFenceExitHook() {
120
134
  });
121
135
  }
122
136
 
123
- export function createWaitCheckpointStore({ root = process.cwd(), now = () => new Date() } = {}) {
137
+ export function createWaitCheckpointStore({
138
+ root = process.cwd(),
139
+ now = () => new Date(),
140
+ processProbe = systemProcessProbe,
141
+ } = {}) {
124
142
  const worktree = resolve(root);
125
143
  const directory = join(worktree, ".botbuddy", "waits");
126
144
  const pathFor = (id) => {
@@ -184,12 +202,8 @@ export function createWaitCheckpointStore({ root = process.cwd(), now = () => ne
184
202
  return lock;
185
203
  };
186
204
  const lockIsLive = async (lock) => {
187
- try { process.kill(lock.pid, 0); }
188
- catch (error) {
189
- if (error?.code === "ESRCH") return false;
190
- return true; // EPERM is deliberately conservative.
191
- }
192
- const startedAt = await processStartedAt(lock.pid);
205
+ if (!await processProbe.isAlive(lock.pid)) return false;
206
+ const startedAt = await processProbe.startedAt(lock.pid);
193
207
  // A live PID without a matching process-instance identity is never ownership proof.
194
208
  return startedAt != null && startedAt === lock.process_started_at;
195
209
  };
@@ -310,7 +324,7 @@ export function createWaitCheckpointStore({ root = process.cwd(), now = () => ne
310
324
  const localWaitId = safeId(id);
311
325
  await ensureDirectory();
312
326
  const path = lockPathFor(localWaitId);
313
- const processStartedAtValue = await processStartedAt(process.pid);
327
+ const processStartedAtValue = await processProbe.startedAt(process.pid);
314
328
  if (!processStartedAtValue) {
315
329
  throw new WaitCheckpointError("wait_owner_unknown", "could not establish this process identity for wait recovery");
316
330
  }