@bivy/bivy 0.10.1-staging.239 → 0.10.1-staging.241

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.
@@ -288,6 +288,35 @@ export async function ensureTaskLabels(cfg) {
288
288
  export async function commentIssue(cfg, issueNumber, body) {
289
289
  await gh(cfg, "POST", `/issues/${issueNumber}/comments`, { body });
290
290
  }
291
+ /** A hidden HTML-comment marker (invisible in rendered Markdown) that makes a
292
+ * Bivy issue comment idempotent across process restarts and lease reclaims: the
293
+ * same logical comment carries the same marker, so a duplicate delivery or a
294
+ * reclaim on a fresh node can detect that it was already posted. */
295
+ export function bivyCommentMarker(key) {
296
+ return `<!-- bivy:comment:${key} -->`;
297
+ }
298
+ /** Whether the issue already carries a Bivy comment with the given key. Scans a
299
+ * single bounded page (newest issues have few comments); an API failure returns
300
+ * false so we prefer re-posting the signal over silently dropping it. */
301
+ export async function issueHasCommentMarker(cfg, issueNumber, key) {
302
+ const marker = bivyCommentMarker(key);
303
+ const res = await gh(cfg, "GET", `/issues/${issueNumber}/comments?per_page=100`);
304
+ if (!res.ok)
305
+ return false;
306
+ const raw = (await res.json().catch(() => []));
307
+ return Array.isArray(raw) && raw.some((c) => typeof c?.body === "string" && c.body.includes(marker));
308
+ }
309
+ /** Post an issue comment at most once per `(issue, key)`. Returns true when it
310
+ * posted, false when an identically-keyed comment already existed. This is the
311
+ * external-effect idempotency guard: a retry or reclaim of the same Run does not
312
+ * duplicate a pickup or outcome comment. The marker is appended to the body so
313
+ * the check above can see it. */
314
+ export async function commentIssueOnce(cfg, issueNumber, body, key) {
315
+ if (await issueHasCommentMarker(cfg, issueNumber, key))
316
+ return false;
317
+ await commentIssue(cfg, issueNumber, `${body}\n\n${bivyCommentMarker(key)}`);
318
+ return true;
319
+ }
291
320
  /**
292
321
  * The comment posted on an issue the moment Bivy picks it up — the "visibly
293
322
  * signal pickup" half of the on-issue lifecycle (the other half is the label
@@ -318,7 +347,9 @@ export async function announcePickup(cfg, issueNumber, nodeName) {
318
347
  if (cfg.label && cfg.label !== cfg.claimLabel) {
319
348
  await removeLabel(cfg, issueNumber, cfg.label).catch((error) => warn(`remove routing label "${cfg.label}"`, error));
320
349
  }
321
- await commentIssue(cfg, issueNumber, pickupMessage(nodeName)).catch((error) => warn("post pickup comment", error));
350
+ // Idempotent across reclaims: a Machine that reclaims this issue after another
351
+ // lost its lease must not post a second pickup comment.
352
+ await commentIssueOnce(cfg, issueNumber, pickupMessage(nodeName), "pickup").catch((error) => warn("post pickup comment", error));
322
353
  }
323
354
  export async function defaultBranch(cfg) {
324
355
  const res = await gh(cfg, "GET", "");
package/dist/server.js CHANGED
@@ -78,7 +78,7 @@ import { setConfiguredAutoAttachToolImages } from "./harness/tool-image-attachme
78
78
  import { injectMcpProxyForSession, injectBivyToolsForSession } from "./harness/mcp-inject.js";
79
79
  import { parseRepo, isGitHubSlugPart, inferGitHubRepoFromWorkspace, isSharedCloneRoot, resolveGitHubToken, ghCliInstalled, cloneOrUpdateRepo, resolveDefaultBaseRef, resolveBranchBaseRef, resolveAdoptBaseRef, fetchOrigin } from "./repo-workspace.js";
80
80
  import { configureGitAuth, writeGitCredentialEndpoint } from "./git-auth.js";
81
- import { GitHubTaskPoller, resolveGitHubTaskConfig, buildTaskPrompt, buildResumePrompt, buildInteractiveResumePrompt, DEFAULT_ISSUE_INSTRUCTIONS, parseBivyDirectives, commitAll, pushBranch, mergeBaseIntoBranch, completeMerge, abortMerge, findOpenPullRequestForBranch, findPullRequestsForBranch, findMergedPullRequestForBranch, issueBranchName, getPullRequest, commentIssue, listOpenLabelledIssues, selectActionableIssues, getIssue, getIssueCommentBody, addLabel, removeLabel, announcePickup, } from "./github-tasks.js";
81
+ import { GitHubTaskPoller, resolveGitHubTaskConfig, buildTaskPrompt, buildResumePrompt, buildInteractiveResumePrompt, DEFAULT_ISSUE_INSTRUCTIONS, parseBivyDirectives, commitAll, pushBranch, mergeBaseIntoBranch, completeMerge, abortMerge, findOpenPullRequestForBranch, findPullRequestsForBranch, findMergedPullRequestForBranch, issueBranchName, getPullRequest, commentIssueOnce, listOpenLabelledIssues, selectActionableIssues, getIssue, getIssueCommentBody, addLabel, removeLabel, announcePickup, } from "./github-tasks.js";
82
82
  import { buildLinearTaskPrompt, getLinearIssue, linearBranchName } from "./linear-tasks.js";
83
83
  import { PairingStore } from "./device-registry.js";
84
84
  import { IntegrationManager } from "./integrations/index.js";
@@ -4185,7 +4185,9 @@ async function reportIssueOutcome(cfg, issue, record, emit, opts) {
4185
4185
  await prDetection.maybeDetectPullRequest(record);
4186
4186
  if (record.prUrl) {
4187
4187
  emit(record, "pr_opened", `Pull request ready for issue #${issue.number}.`, { prUrl: record.prUrl });
4188
- await commentIssue(cfg, issue.number, `🤖 ${record.prUrl}`).catch(() => { });
4188
+ // Keyed by the PR URL so a reclaim/retry that lands on the same PR does not
4189
+ // post a second link, while a genuinely new PR still gets its own comment.
4190
+ await commentIssueOnce(cfg, issue.number, `🤖 ${record.prUrl}`, `pr:${record.prUrl}`).catch(() => { });
4189
4191
  // Clean up the claim label now that the PR itself is the live "in progress"
4190
4192
  // signal on the issue (linked in the timeline + the comment above) — keeping
4191
4193
  // `bivy:in-progress` around after a PR exists is stale and, per the issue's
@@ -4199,7 +4201,9 @@ async function reportIssueOutcome(cfg, issue, record, emit, opts) {
4199
4201
  ? "Bivy handled the follow-up but produced no file changes."
4200
4202
  : "Bivy ran on this issue but produced no file changes.";
4201
4203
  emit(record, "no_changes", message);
4202
- await commentIssue(cfg, issue.number, message).catch(() => { });
4204
+ // Keyed by the deterministic issue branch so a reclaim doesn't repeat the
4205
+ // no-changes note for the same attempt cycle.
4206
+ await commentIssueOnce(cfg, issue.number, message, `no-changes:${wt.branch}`).catch(() => { });
4203
4207
  // The run is finished with nothing in progress — drop the claim label so it
4204
4208
  // doesn't linger on the issue. A stale `bivy/<node>:in-progress` label was
4205
4209
  // also mis-routing follow-up mentions before pickRoutingLabel was hardened;
@@ -4208,7 +4212,7 @@ async function reportIssueOutcome(cfg, issue, record, emit, opts) {
4208
4212
  return;
4209
4213
  }
4210
4214
  emit(record, "pushed", `Pushed ${wt.branch} for issue #${issue.number}; no pull request yet.`);
4211
- await commentIssue(cfg, issue.number, `🤖 Pushed \`${wt.branch}\` but didn't open a pull request. Comment \`@bivy\` again to continue, or open one from the session's chat (\`/pr\`).`).catch(() => { });
4215
+ await commentIssueOnce(cfg, issue.number, `🤖 Pushed \`${wt.branch}\` but didn't open a pull request. Comment \`@bivy\` again to continue, or open one from the session's chat (\`/pr\`).`, `pushed:${wt.branch}`).catch(() => { });
4212
4216
  }
4213
4217
  /** The live open session for an issue (by source tag), if we still have one. */
4214
4218
  function findIssueSession(source) {
@@ -6257,6 +6261,12 @@ function evictSessionRecord(record, reason) {
6257
6261
  function detachSessionRecord(record, reason) {
6258
6262
  persistSessionMetadata(record, "idle");
6259
6263
  eventLog.flush(record.id);
6264
+ // Evict the in-memory overlay/maps for the detached session (the child stays
6265
+ // alive on the service, and a re-attach lazily reloads the log from disk).
6266
+ // Keeps a churn of detach/re-attach from leaking like close did.
6267
+ eventLog.drop(record.id);
6268
+ mcpInventoryBySession.delete(record.id);
6269
+ eventLogIssues.delete(record.id);
6260
6270
  questionManager.cancelForSession(record.id);
6261
6271
  approvals.cancelForSession(record.id);
6262
6272
  record.unsubscribe?.();
@@ -6282,6 +6292,12 @@ function closeSessionRecord(record, reason = "closed") {
6282
6292
  void sessionTerminals.forget(record.id).catch(() => { });
6283
6293
  persistSessionMetadata(record, "idle");
6284
6294
  eventLog.flush(record.id);
6295
+ // Evict the flushed session's in-memory overlay so a long-lived daemon doesn't
6296
+ // retain every session it ever opened. drop() only clears the in-memory maps
6297
+ // and cancels the pending timer — the on-disk JSONL stays, and a reopen lazily
6298
+ // reloads it via load(). Without this the EventLog.disk cache grew monotonically
6299
+ // (only deleteSessionFile dropped it), the standout non-recovering leak.
6300
+ eventLog.drop(record.id);
6285
6301
  // Cancel any question still awaiting an answer so its card closes and the
6286
6302
  // guardian promise (and the tool call behind it) settles rather than hanging
6287
6303
  // until timeout. Belt-and-suspenders alongside the tool-call abort signal.
@@ -6308,6 +6324,10 @@ function closeSessionRecord(record, reason = "closed") {
6308
6324
  if (record.sessionFile)
6309
6325
  openSessions.delete(path.resolve(record.sessionFile));
6310
6326
  lastRecordedCostUsd.delete(record.id);
6327
+ // Per-session maps that were only ever populated, never pruned — evict on close
6328
+ // so they don't accumulate for the daemon's lifetime.
6329
+ mcpInventoryBySession.delete(record.id);
6330
+ eventLogIssues.delete(record.id);
6311
6331
  if (active?.id === record.id)
6312
6332
  active = undefined;
6313
6333
  broadcast({ type: "session.closed", sessionId: record.id, sessionFile: record.sessionFile, reason });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.10.1-staging.239",
3
+ "version": "0.10.1-staging.241",
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.",