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

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) {
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.240",
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.",