bloby-bot 0.70.10 → 0.70.11

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": "bloby-bot",
3
- "version": "0.70.10",
3
+ "version": "0.70.11",
4
4
  "releaseNotes": [
5
5
  "1. Fix: agent self-update ",
6
6
  "1",
@@ -542,6 +542,16 @@ export class ChannelManager {
542
542
  eventData: any,
543
543
  botName: string,
544
544
  ): void {
545
+ // Synthetic turns (background-task continuation turns the harness injects
546
+ // with no matching user push — currently only the pi harness tags these)
547
+ // are INVISIBLE to channel routing: they enqueued no routing target, so
548
+ // consuming/flushing/draining here would steal a concurrently-queued
549
+ // channel message's route and cross-wire replies. They are dashboard-
550
+ // broadcast + DB-persist only. consumedThisTurn/chunkBuf are deliberately
551
+ // untouched — synthetic tokens never enter the chunk buffer, and the
552
+ // surrounding real turns' accounting stays intact.
553
+ if (eventData?.synthetic) return;
554
+
545
555
  const convId = eventData?.conversationId as string | undefined;
546
556
 
547
557
  if (type === 'bot:token' && eventData?.token) {
@@ -62,6 +62,15 @@ interface LiveConversation {
62
62
  * after the user hears "Done!", mirroring claude's usedTools capture of
63
63
  * sub-agent tool_use blocks. */
64
64
  taskUsedFileTools: boolean;
65
+ /** Origin of each queued message, FIFO-aligned with inputQueue: 'user' for
66
+ * pushMessage, 'synthetic' for task-completion injections. Shifted on
67
+ * turn_started so the turn's events can be tagged synthetic — the channel
68
+ * routing FIFO must skip turns that never enqueued a routing target
69
+ * (Phase B review PI-B-1: an untagged continuation turn would steal a
70
+ * concurrently-queued channel message's route). */
71
+ turnOrigins: ('user' | 'synthetic')[];
72
+ /** True while the in-flight turn originated from pushSyntheticMessage. */
73
+ currentTurnSynthetic: boolean;
65
74
  loopDone: Promise<void> | null;
66
75
  }
67
76
 
@@ -72,9 +81,18 @@ interface RunningTask {
72
81
  abortController: AbortController;
73
82
  /** True when stopped via user:stop-task or conversation teardown. */
74
83
  stopped: boolean;
84
+ /** True when the wall-clock task watchdog aborted it. */
85
+ timedOut: boolean;
75
86
  startedAt: number;
76
87
  }
77
88
 
89
+ /** Hard wall-clock cap per background task. Bounds the housekeeping wedge
90
+ * (idle:false defers recycling; anyConversationBusy defers restarts and
91
+ * self-updates) if a child ever hangs in a way the stream timeouts and the
92
+ * Bash exit-settle can't catch. Generous: a 50-round coder task fits well
93
+ * inside it. */
94
+ const TASK_WALL_CLOCK_MS = 30 * 60_000;
95
+
78
96
  const liveConversations = new Map<string, LiveConversation>();
79
97
 
80
98
  /**
@@ -253,15 +271,18 @@ function resolveAuth(): { ok: true; auth: PiSessionAuth } | { ok: false; error:
253
271
  // ── Background sub-agents (Phase B — audit D4-1) ───────────────────────────
254
272
 
255
273
  /** Inject a system-originated message into the parent's queue (task completion).
256
- * Mirrors the Claude SDK's self-prompted continuation turn: no routing target
257
- * is enqueued (channelManager only wraps USER pushes), so the continuation's
258
- * bot:response meets an empty routing FIFO and falls through to the dashboard
259
- * broadcastexactly claude's behavior. pendingCount/busy are maintained so
260
- * idle stays accurate and the recycler can't fire mid-continuation. No
261
- * bot:typing (claude's continuation turns emit none either). */
274
+ * Mirrors the Claude SDK's self-prompted continuation turn, with one
275
+ * improvement: the resulting turn's events are tagged `synthetic: true`
276
+ * (origin tracked via conv.turnOrigins) so the channel routing FIFO ignores
277
+ * them entirely a continuation enqueues no routing target, and an untagged
278
+ * bot:response would steal a concurrently-queued channel message's route
279
+ * (review PI-B-1). Synthetic turns are dashboard-broadcast + DB-persist only.
280
+ * pendingCount/busy are maintained so idle stays accurate and the recycler
281
+ * can't fire mid-continuation. No bot:typing (claude parity). */
262
282
  function pushSyntheticMessage(conv: LiveConversation, text: string): void {
263
283
  conv.busy = true;
264
284
  conv.pendingCount += 1;
285
+ conv.turnOrigins.push('synthetic');
265
286
  conv.inputQueue.push({ role: 'user', content: [{ type: 'text', text }] });
266
287
  }
267
288
 
@@ -310,10 +331,21 @@ function createTaskHost(conv: LiveConversation, getAuth: () => PiSessionAuth): P
310
331
  subagentType: req.subagentType,
311
332
  abortController,
312
333
  stopped: false,
334
+ timedOut: false,
313
335
  startedAt: Date.now(),
314
336
  };
315
337
  conv.tasks.set(taskId, task);
316
338
 
339
+ // Wall-clock backstop: a hung child would otherwise pin idle:false and
340
+ // anyConversationBusy forever, deferring recycling/restarts/self-updates
341
+ // indefinitely (review PI-B lifecycle finding). Cleared in the finally.
342
+ const watchdog = setTimeout(() => {
343
+ if (!conv.tasks.has(taskId)) return;
344
+ log.warn(`[pi/task] Task ${taskId} hit the ${TASK_WALL_CLOCK_MS / 60_000}-minute wall clock — aborting`);
345
+ task.timedOut = true;
346
+ abortController.abort();
347
+ }, TASK_WALL_CLOCK_MS);
348
+
317
349
  // Honor the agent config's tool restrictions (claude applies these via
318
350
  // the SDK's tools/disallowedTools options — e.g. a future researcher
319
351
  // agent with disallowedTools: ['Write','Edit']).
@@ -330,6 +362,13 @@ function createTaskHost(conv: LiveConversation, getAuth: () => PiSessionAuth): P
330
362
  let errorText = '';
331
363
  let usedFileTools = false;
332
364
  let toolUses = 0;
365
+ // Outcome from the session's turn_complete — the error EVENT is
366
+ // suppressed when partial text streamed (D6-2 precedence), so these are
367
+ // how the host learns a child that streamed text still failed
368
+ // (review PI-B-CHILD-1: such tasks must not be reported 'completed').
369
+ let childErrored = false;
370
+ let childErrorMsg = '';
371
+ let childCapHit = false;
333
372
  let lastUsage: { inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheCreationTokens?: number } | undefined;
334
373
 
335
374
  const session = createPiSession({
@@ -361,6 +400,11 @@ function createTaskHost(conv: LiveConversation, getAuth: () => PiSessionAuth): P
361
400
  case 'turn_complete':
362
401
  usedFileTools = usedFileTools || evt.usedFileTools;
363
402
  if (evt.usage) lastUsage = evt.usage;
403
+ if (evt.errored) {
404
+ childErrored = true;
405
+ childErrorMsg = evt.errorMsg || childErrorMsg;
406
+ }
407
+ if (evt.roundCapHit) childCapHit = true;
364
408
  break;
365
409
  }
366
410
  },
@@ -388,9 +432,21 @@ function createTaskHost(conv: LiveConversation, getAuth: () => PiSessionAuth): P
388
432
  } catch (err: any) {
389
433
  errorText = errorText || err?.message || String(err);
390
434
  } finally {
435
+ clearTimeout(watchdog);
391
436
  conv.tasks.delete(taskId);
392
- const status = task.stopped ? 'stopped' : (errorText && !summaryText ? 'failed' : 'completed');
393
- const summary = summaryText || errorText || '(the agent produced no output)';
437
+ // Honest status (review PI-B-CHILD-1): an error or round-cap exit is
438
+ // 'failed' even when partial text streamed the parent must not
439
+ // relay half-done work as success.
440
+ const failed = task.timedOut || childErrored || childCapHit || !!errorText || !summaryText;
441
+ const status = task.stopped ? 'stopped' : failed ? 'failed' : 'completed';
442
+ let summary = summaryText || errorText || '(the agent produced no output)';
443
+ if (task.timedOut) {
444
+ summary += `\n\n[The task was aborted after ${TASK_WALL_CLOCK_MS / 60_000} minutes — work is incomplete.]`;
445
+ } else if (childCapHit) {
446
+ summary += '\n\n[The task hit its tool-round limit before finishing — work may be incomplete.]';
447
+ } else if (childErrored && summaryText) {
448
+ summary += `\n\n[The task hit an error before finishing: ${childErrorMsg || errorText || 'unknown error'}]`;
449
+ }
394
450
  const u = lastUsage;
395
451
  const totalTokens = u
396
452
  ? (u.inputTokens || 0) + (u.outputTokens || 0) + (u.cacheReadTokens || 0) + (u.cacheCreationTokens || 0)
@@ -497,11 +553,20 @@ export async function startConversation(
497
553
  onMessage,
498
554
  busy: false,
499
555
  pendingCount: 0,
500
- batcher: createTokenBatcher((text) => onMessage('bot:token', { conversationId, token: text })),
556
+ batcher: null as unknown as TokenBatcher, // set right below (needs conv for the synthetic tag)
501
557
  tasks: new Map(),
502
558
  taskUsedFileTools: false,
559
+ turnOrigins: [],
560
+ currentTurnSynthetic: false,
503
561
  loopDone: null,
504
562
  };
563
+ conv.batcher = createTokenBatcher((text) =>
564
+ onMessage('bot:token', {
565
+ conversationId,
566
+ token: text,
567
+ ...(conv.currentTurnSynthetic ? { synthetic: true } : {}),
568
+ }),
569
+ );
505
570
  liveConversations.set(conversationId, conv);
506
571
 
507
572
  // Re-resolve auth on every provider round so a key/model fix in the wizard
@@ -560,19 +625,26 @@ function translateAndEmit(conv: LiveConversation, evt: PiSessionEvent) {
560
625
  // invariant both depend on it.
561
626
  conv.batcher.flush();
562
627
 
628
+ // Synthetic tag for every event of a continuation turn (origin shifted at
629
+ // turn_started): the channel routing FIFO must treat these turns as
630
+ // invisible — they enqueued no routing target, so consuming one would steal
631
+ // a queued channel message's route (review PI-B-1).
632
+ const syn = conv.currentTurnSynthetic ? { synthetic: true } : {};
633
+
563
634
  switch (evt.type) {
564
635
  case 'turn_started':
636
+ conv.currentTurnSynthetic = conv.turnOrigins.shift() === 'synthetic';
565
637
  // No bloby event for this — `bot:typing` is already emitted by pushMessage().
566
638
  break;
567
639
  case 'text_end':
568
- conv.onMessage('bot:response', { conversationId: conv.id, content: evt.text });
640
+ conv.onMessage('bot:response', { conversationId: conv.id, content: evt.text, ...syn });
569
641
  break;
570
642
  case 'tool_use': {
571
643
  // House vocabulary: claude's delegation tool is named Task; the pi
572
644
  // prompt's 'Agent' alias resolves to the same tool — normalize the
573
645
  // event so consumers see one name.
574
646
  const toolName = evt.name === 'Agent' || evt.name === 'agent' ? 'Task' : evt.name;
575
- conv.onMessage('bot:tool', { conversationId: conv.id, name: toolName, input: evt.input });
647
+ conv.onMessage('bot:tool', { conversationId: conv.id, name: toolName, input: evt.input, ...syn });
576
648
  break;
577
649
  }
578
650
  case 'tool_result':
@@ -605,6 +677,7 @@ function translateAndEmit(conv: LiveConversation, evt: PiSessionEvent) {
605
677
  contextTokens,
606
678
  contextWindow: evt.contextWindow || 0,
607
679
  idle,
680
+ ...syn,
608
681
  });
609
682
  log.info(`[pi/conversation] ──── TURN COMPLETE ──── busy=false ctx=${contextTokens}/${evt.contextWindow || 'n/a'} idle=${idle} tasks=${conv.tasks.size}`);
610
683
  break;
@@ -619,7 +692,7 @@ function translateAndEmit(conv: LiveConversation, evt: PiSessionEvent) {
619
692
  : evt.kind === 'auth'
620
693
  ? ' I\'ll reconnect with the new key as soon as it\'s saved.'
621
694
  : '';
622
- conv.onMessage('bot:error', { conversationId: conv.id, error: `${evt.error}${remedy}` });
695
+ conv.onMessage('bot:error', { conversationId: conv.id, error: `${evt.error}${remedy}`, ...syn });
623
696
  if (fatal) {
624
697
  // Unrecoverable for this session (audit D6-4): an over-window history
625
698
  // would re-fail on every future turn, and a dead key has no business
@@ -649,6 +722,7 @@ export function pushMessage(
649
722
  log.info(`[pi/conversation] ──── PUSH MESSAGE ──── busy=${conv.busy} pending=${conv.pendingCount + 1}`);
650
723
  conv.busy = true;
651
724
  conv.pendingCount += 1;
725
+ conv.turnOrigins.push('user');
652
726
  conv.inputQueue.push(buildUserMessage(content, attachments, savedFiles));
653
727
  conv.onMessage('bot:typing', { conversationId });
654
728
  return true;
@@ -46,7 +46,22 @@ export type PiSessionEvent =
46
46
  | { type: 'text_end'; text: string }
47
47
  | { type: 'tool_use'; id: string; name: string; input: any }
48
48
  | { type: 'tool_result'; toolUseId: string; name: string; isError?: boolean }
49
- | { type: 'turn_complete'; usedFileTools: boolean; usage?: PiUsage; contextWindow?: number }
49
+ | {
50
+ type: 'turn_complete';
51
+ usedFileTools: boolean;
52
+ usage?: PiUsage;
53
+ contextWindow?: number;
54
+ /** True when the turn ended on a provider error. NOTE: the `error` EVENT
55
+ * is suppressed when partial text streamed (D6-2 response-over-error
56
+ * precedence, designed for the watched parent stream) — these fields
57
+ * are how an UNwatched consumer (the task host) learns the turn failed,
58
+ * so a child that streamed text then died isn't reported 'completed'. */
59
+ errored?: boolean;
60
+ errorKind?: PiErrorKind;
61
+ errorMsg?: string;
62
+ /** True when the turn was cut off by the tool-round budget mid-task. */
63
+ roundCapHit?: boolean;
64
+ }
50
65
  | { type: 'error'; error: string; kind?: PiErrorKind };
51
66
 
52
67
  /** Everything the providers need that can change while a session is alive. */
@@ -230,8 +245,11 @@ export function createPiSession(init: PiSessionInit): PiSession {
230
245
  let turnErrorKind: PiErrorKind | undefined;
231
246
 
232
247
  const maxRounds = Math.max(1, init.maxToolRounds ?? MAX_TOOL_ROUNDS);
248
+ // True only when the for-loop runs out of rounds with the model still
249
+ // mid-task — every intentional exit (done, errored, aborted) clears it.
250
+ let roundCapHit = true;
233
251
  for (let round = 0; round < maxRounds; round++) {
234
- if (init.abortController.signal.aborted) break;
252
+ if (init.abortController.signal.aborted) { roundCapHit = false; break; }
235
253
  // The separator condition is decided BEFORE the round so the round can
236
254
  // emit it ahead of its first token (claude.ts ordering — see runOneRound).
237
255
  const needsSeparator = accumulatedText.length > 0 && !accumulatedText.endsWith('\n');
@@ -289,6 +307,7 @@ export function createPiSession(init: PiSessionInit): PiSession {
289
307
  turnErrored = true;
290
308
  turnErrorMsg = res.errorMsg;
291
309
  turnErrorKind = res.errorKind;
310
+ roundCapHit = false;
292
311
  break;
293
312
  }
294
313
 
@@ -313,7 +332,7 @@ export function createPiSession(init: PiSessionInit): PiSession {
313
332
  }
314
333
 
315
334
  // No tool calls ⇒ the model is done with this turn.
316
- if (toolUses.length === 0) break;
335
+ if (toolUses.length === 0) { roundCapHit = false; break; }
317
336
  }
318
337
 
319
338
  // Turn-end emission order (audit D6-2, mirrors claude.ts:394-401):
@@ -337,7 +356,16 @@ export function createPiSession(init: PiSessionInit): PiSession {
337
356
  init.onEvent({ type: 'error', error: turnErrorMsg || 'Provider turn failed', kind: turnErrorKind });
338
357
  }
339
358
  const usedFileTools = Array.from(usedTools).some((t) => FILE_TOOL_NAMES.has(t));
340
- init.onEvent({ type: 'turn_complete', usedFileTools, usage: lastUsage, contextWindow: lastContextWindow });
359
+ init.onEvent({
360
+ type: 'turn_complete',
361
+ usedFileTools,
362
+ usage: lastUsage,
363
+ contextWindow: lastContextWindow,
364
+ errored: turnErrored || undefined,
365
+ errorKind: turnErrorKind,
366
+ errorMsg: turnErrorMsg,
367
+ roundCapHit: roundCapHit || undefined,
368
+ });
341
369
  }
342
370
  }
343
371
 
@@ -354,7 +382,14 @@ export function createPiSession(init: PiSessionInit): PiSession {
354
382
  // and chat aren't wedged. Skip when aborting (teardown emits conversation-ended).
355
383
  // usedFileTools=false is the safe default (it only governs whether to auto-restart now).
356
384
  if (!init.abortController.signal.aborted) {
357
- init.onEvent({ type: 'turn_complete', usedFileTools: false, usage: lastUsage, contextWindow: lastContextWindow });
385
+ init.onEvent({
386
+ type: 'turn_complete',
387
+ usedFileTools: false,
388
+ usage: lastUsage,
389
+ contextWindow: lastContextWindow,
390
+ errored: true,
391
+ errorMsg: err?.message || String(err),
392
+ });
358
393
  }
359
394
  }
360
395
  }
@@ -38,12 +38,26 @@ export const bashTool: PiTool = {
38
38
  let timedOut = false;
39
39
  let settled = false;
40
40
 
41
+ // detached:true gives the child its own process group so kills reach
42
+ // grandchildren too — a SIGKILLed direct child can leave orphans holding
43
+ // the stdio pipes, and 'close' (which waits for the pipes) then never
44
+ // fires, hanging the tool promise and wedging the whole turn.
41
45
  const child = spawn('bash', ['-lc', command], {
42
46
  cwd: ctx.cwd,
43
47
  env: process.env,
44
48
  stdio: ['ignore', 'pipe', 'pipe'],
49
+ detached: true,
45
50
  });
46
51
 
52
+ const killTree = () => {
53
+ try {
54
+ if (child.pid) process.kill(-child.pid, 'SIGKILL');
55
+ else child.kill('SIGKILL');
56
+ } catch {
57
+ try { child.kill('SIGKILL'); } catch {}
58
+ }
59
+ };
60
+
47
61
  const append = (chunk: Buffer) => {
48
62
  if (truncated) return;
49
63
  const remaining = OUTPUT_CAP_BYTES - Buffer.byteLength(out, 'utf-8');
@@ -65,23 +79,15 @@ export const bashTool: PiTool = {
65
79
 
66
80
  const timer = setTimeout(() => {
67
81
  timedOut = true;
68
- try { child.kill('SIGKILL'); } catch {}
82
+ killTree();
69
83
  }, timeout);
70
84
 
71
85
  const onAbort = () => {
72
- try { child.kill('SIGKILL'); } catch {}
86
+ killTree();
73
87
  };
74
88
  ctx.signal?.addEventListener('abort', onAbort);
75
89
 
76
- child.on('error', (err) => {
77
- if (settled) return;
78
- settled = true;
79
- clearTimeout(timer);
80
- ctx.signal?.removeEventListener('abort', onAbort);
81
- resolve({ output: `Failed to spawn command: ${err.message}`, isError: true });
82
- });
83
-
84
- child.on('close', (code, signal) => {
90
+ const finish = (code: number | null, signal: NodeJS.Signals | null) => {
85
91
  if (settled) return;
86
92
  settled = true;
87
93
  clearTimeout(timer);
@@ -103,6 +109,23 @@ export const bashTool: PiTool = {
103
109
  isError: true,
104
110
  });
105
111
  }
112
+ };
113
+
114
+ child.on('error', (err) => {
115
+ if (settled) return;
116
+ settled = true;
117
+ clearTimeout(timer);
118
+ ctx.signal?.removeEventListener('abort', onAbort);
119
+ resolve({ output: `Failed to spawn command: ${err.message}`, isError: true });
120
+ });
121
+
122
+ // 'close' is the normal settle point (all output drained). But orphaned
123
+ // grandchildren can inherit the stdio pipes and keep them open after the
124
+ // direct child died — settle from 'exit' after a short drain grace so the
125
+ // tool promise can never hang the turn on a pipe that won't close.
126
+ child.on('close', (code, signal) => finish(code, signal));
127
+ child.on('exit', (code, signal) => {
128
+ setTimeout(() => finish(code, signal), 1500);
106
129
  });
107
130
  });
108
131
  },
@@ -3236,8 +3236,12 @@ ${alreadyLinked ? '' : `
3236
3236
  return async (type: string, eventData: any) => {
3237
3237
  // Capture surface BEFORE routeWaStreamEvent consumes the routing target on bot:response.
3238
3238
  // Used below to suppress chat-bubble broadcasts for non-dashboard turns.
3239
+ // Synthetic turns (background-task continuations — see routeWaStreamEvent's
3240
+ // guard) never own a routing target; the FIFO head, if any, belongs to a
3241
+ // QUEUED channel message, so peeking it would misclassify the turn.
3242
+ // They are always dashboard turns.
3239
3243
  const triggerSurface = channelManager.peekCurrentSurface(convId);
3240
- const isDashboardTurn = !triggerSurface || triggerSurface === 'workspace' || triggerSurface === 'chat';
3244
+ const isDashboardTurn = eventData?.synthetic === true || !triggerSurface || triggerSurface === 'workspace' || triggerSurface === 'chat';
3241
3245
 
3242
3246
  if (type === 'bot:typing') {
3243
3247
  currentStreamConvId = convId;
Binary file
Binary file