@bivy/bivy 0.10.1-staging.164 → 0.10.1-staging.166

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.
Files changed (2) hide show
  1. package/dist/server.js +89 -15
  2. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -5462,17 +5462,55 @@ function linearSessionSource(externalId) {
5462
5462
  * starting, and return false so the caller falls through. Returns true only when
5463
5463
  * it fully handled the item.
5464
5464
  */
5465
- async function continueCorrelatedSession(item, prompt, report) {
5465
+ async function continueCorrelatedSession(item, prompt, report, opts) {
5466
5466
  if (item.targetKind !== "existing_session" || !item.targetSessionId)
5467
5467
  return false;
5468
- const record = openSessions.get(item.targetSessionId);
5468
+ let record = openSessions.get(item.targetSessionId);
5469
+ if (!record && opts?.resumeOnMissing) {
5470
+ // Same-node resume: reopen a closed session from its durable metadata +
5471
+ // transcript — exactly how a prompt to an old session from the app is
5472
+ // handled (resolveOrResumeSession). Best-effort: an unresolvable session
5473
+ // falls through to the snapshot restore / fail handling below.
5474
+ record = await resolveOrResumeSession(item.targetSessionId).catch(() => undefined);
5475
+ }
5469
5476
  if (!record) {
5470
5477
  await restoreSessionFromSnapshot(item.targetSessionId).catch((e) => console.warn(`[case-b] snapshot restore for ${item.targetSessionId} failed:`, e.message));
5478
+ record = openSessions.get(item.targetSessionId);
5479
+ }
5480
+ if (!record) {
5481
+ // Strict mode (a scheduled message to a specific session): the session must
5482
+ // be resumed in place, so a genuinely-unavailable one is surfaced as a
5483
+ // failed run rather than silently starting a new session. Everything else
5484
+ // keeps the established best-effort fall-through to a fresh pickup.
5485
+ if (opts?.resumeOnMissing) {
5486
+ throw new Error(`Scheduled message could not be delivered: session ${item.targetSessionId} is not available on this node`);
5487
+ }
5471
5488
  return false;
5472
5489
  }
5473
5490
  const branch = record.worktree?.branch;
5491
+ if (opts?.resumeOnMissing) {
5492
+ // "Send when the turn ends": a scheduled message that fires mid-turn waits
5493
+ // for the session to go idle instead of interrupting it (bounded, so a
5494
+ // stuck session still surfaces as a failed run rather than hanging forever).
5495
+ await waitForSessionIdle(record);
5496
+ // Double-send guard: when the app is open and already delivered this exact
5497
+ // message as a follow-up (and deleted the pending schedule), the scheduled
5498
+ // run must not send it a second time. Matches the last user message, which
5499
+ // is all a text-only scheduled message can have produced.
5500
+ if (lastUserMessageText(record).trim() === prompt.trim()) {
5501
+ await report({
5502
+ output: { sessionId: record.id },
5503
+ events: [{
5504
+ at: new Date().toISOString(),
5505
+ kind: "completed",
5506
+ summary: "Already delivered to this session — skipped.",
5507
+ }],
5508
+ });
5509
+ return true;
5510
+ }
5511
+ }
5474
5512
  await runSessionTurn(record, prompt);
5475
- if (record.worktree) {
5513
+ if (record.worktree && !opts?.isMessage && !opts?.resumeOnMissing) {
5476
5514
  await maybePushWorktreeBranch(record);
5477
5515
  await maybeDetectPullRequest(record);
5478
5516
  }
@@ -5484,6 +5522,27 @@ async function continueCorrelatedSession(item, prompt, report) {
5484
5522
  });
5485
5523
  return true;
5486
5524
  }
5525
+ /** Poll a session until it's not working/streaming (scheduled-message idle wait). */
5526
+ async function waitForSessionIdle(record, capMs = 2 * 60 * 60 * 1000) {
5527
+ const deadline = Date.now() + capMs;
5528
+ while (sessionBusy(record)) {
5529
+ if (Date.now() > deadline) {
5530
+ throw new Error(`Timed out waiting for session ${record.id} to finish its current turn`);
5531
+ }
5532
+ await new Promise((resolve) => setTimeout(resolve, 2_000));
5533
+ }
5534
+ }
5535
+ /** Text of the most recent user message in a session's transcript, if any. */
5536
+ function lastUserMessageText(record) {
5537
+ const messages = record.session.getMessages();
5538
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
5539
+ const message = messages[i];
5540
+ if (message && typeof message === "object" && message.role === "user") {
5541
+ return runtimeContentText(message.content);
5542
+ }
5543
+ }
5544
+ return "";
5545
+ }
5487
5546
  async function runWorkItem(item, report) {
5488
5547
  // Scheduled, manual, and webhook-triggered automations carry the operator's
5489
5548
  // instructions as an E2E template (`bivy-room-v1:<node>:<ciphertext>`) only the
@@ -5617,12 +5676,25 @@ async function runWorkItem(item, report) {
5617
5676
  const parsedRepo = item.repo ? parseRepo(item.repo) : undefined;
5618
5677
  if (item.repo && !parsedRepo)
5619
5678
  throw new Error(`work item ${item.id} has an invalid repo "${item.repo}"`);
5620
- const request = item.body ? `${item.title}\n\n${item.body}` : item.title;
5679
+ // A scheduled run's body is the operator's (decrypted) template — the
5680
+ // definition name is metadata, not part of the instructions, so don't fold it
5681
+ // into the prompt. A scheduled chat message must arrive verbatim; the name is
5682
+ // only the run/session label.
5683
+ const request = item.source === "schedule"
5684
+ ? (item.body || item.title)
5685
+ : item.body ? `${item.title}\n\n${item.body}` : item.title;
5686
+ // Plain chat messages (scheduled "message me later" reminders) skip the
5687
+ // automation boilerplate, auto-push and required checks — even when a
5688
+ // workspace target happens to be a git checkout.
5689
+ const isMessage = item.message === true;
5621
5690
  // Case B (provider-agnostic): a follow-up the control plane correlated to an
5622
5691
  // existing session continues it as a normal chat. Reached by Slack the moment a
5623
5692
  // reply carries a thread identity the control plane can correlate; a one-shot
5624
5693
  // slash command has none, so it simply falls through to a fresh session.
5625
- if (await continueCorrelatedSession(item, request, report))
5694
+ // Scheduled runs targeting an existing session are STRICT: the message must
5695
+ // land in that session (resumed from disk if needed), never silently in a new
5696
+ // one — so a session that can't be resumed fails the run instead.
5697
+ if (await continueCorrelatedSession(item, request, report, { resumeOnMissing: item.source === "schedule", isMessage }))
5626
5698
  return;
5627
5699
  const sandbox = normalizeSandboxTier(item.sandbox);
5628
5700
  const sessionOpts = {
@@ -5645,16 +5717,18 @@ async function runWorkItem(item, report) {
5645
5717
  }
5646
5718
  catch { }
5647
5719
  }
5648
- const prompt = parsedRepo || record.worktree
5649
- ? [
5650
- request,
5651
- "",
5652
- "Work carefully in this repository and implement the request completely. Run the relevant tests, linter, and type-checker.",
5653
- "When finished, commit and push your changes, then open a pull request with a clear title and description. If you cannot open it, leave the changes committed on this branch.",
5654
- ].join("\n")
5655
- : request;
5720
+ const prompt = isMessage
5721
+ ? request
5722
+ : (parsedRepo || record.worktree)
5723
+ ? [
5724
+ request,
5725
+ "",
5726
+ "Work carefully in this repository and implement the request completely. Run the relevant tests, linter, and type-checker.",
5727
+ "When finished, commit and push your changes, then open a pull request with a clear title and description. If you cannot open it, leave the changes committed on this branch.",
5728
+ ].join("\n")
5729
+ : request;
5656
5730
  await runSessionTurn(record, prompt);
5657
- if (record.worktree) {
5731
+ if (!isMessage && record.worktree) {
5658
5732
  await maybePushWorktreeBranch(record);
5659
5733
  await maybeDetectPullRequest(record);
5660
5734
  }
@@ -5664,7 +5738,7 @@ async function runWorkItem(item, report) {
5664
5738
  // because the agent claimed success or already opened a PR. Only a run that was
5665
5739
  // allowed to modify the workspace is verified; a read-only run (an
5666
5740
  // investigation) changes nothing, so there is nothing to check.
5667
- const checks = record.worktree && sandbox !== "read-only"
5741
+ const checks = !isMessage && record.worktree && sandbox !== "read-only"
5668
5742
  ? runRequiredAutomationChecks(record.worktree.path)
5669
5743
  : [];
5670
5744
  const failedChecks = checks.filter((check) => check.status === "failed");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.10.1-staging.164",
3
+ "version": "0.10.1-staging.166",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",