@bivy/bivy 0.10.1-staging.236 → 0.10.1-staging.237

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.
@@ -79,9 +79,15 @@ export async function claimWork(cfg, id) {
79
79
  const res = await cp(cfg, "POST", `/node/work/${encodeURIComponent(id)}/claim`);
80
80
  return res.ok;
81
81
  }
82
+ /** Renew ownership and retain the reason a renewal was rejected. Cancellation is
83
+ * intentionally distinct from a generic lost lease so an active agent can be
84
+ * stopped promptly when the account cancels its Run. */
82
85
  export async function renewWorkLease(cfg, id) {
83
86
  const res = await cp(cfg, "POST", `/node/work/${encodeURIComponent(id)}/heartbeat`);
84
- return res.ok;
87
+ if (res.ok)
88
+ return "renewed";
89
+ const data = (await res.json().catch(() => ({})));
90
+ return data.reason === "cancelled" ? "cancelled" : "lost";
85
91
  }
86
92
  export async function completeWork(cfg, id) {
87
93
  await transitionWork(cfg, id, "complete");
@@ -119,9 +125,12 @@ export class ControlPlaneTaskPoller {
119
125
  runItem;
120
126
  maxConcurrent;
121
127
  timer;
122
- inFlight = new Set();
128
+ /** Keep the controller alongside the reservation: relay pokes can arrive at
129
+ * any point from claim through policy retries and must address the same Run. */
130
+ inFlight = new Map();
123
131
  policy;
124
132
  sleep;
133
+ leaseHeartbeatMs;
125
134
  constructor(cfg, runItem,
126
135
  /** Node's cap on concurrently-running queue sessions (0/undefined = unlimited).
127
136
  * Read fresh each tick so the Settings → Nodes value takes effect live. */
@@ -131,6 +140,7 @@ export class ControlPlaneTaskPoller {
131
140
  this.maxConcurrent = maxConcurrent;
132
141
  this.policy = options.policy;
133
142
  this.sleep = options.sleep ?? defaultSleep;
143
+ this.leaseHeartbeatMs = options.leaseHeartbeatMs ?? 30_000;
134
144
  }
135
145
  start() {
136
146
  void this.tick();
@@ -138,8 +148,16 @@ export class ControlPlaneTaskPoller {
138
148
  this.timer.unref?.();
139
149
  console.log(`[control-plane-tasks] watching hosted queue for labels [${this.cfg.labels.join(", ")}] (relay push + ${Math.round(this.cfg.pollMs / 1000)}s fallback poll)`);
140
150
  }
141
- /** Trigger an immediate fetch after a relay push says work may be available. */
142
- poke() {
151
+ /** React to a relay work notification. If it names a Run already executing,
152
+ * check its lease immediately (the notification may be its cancellation);
153
+ * otherwise fetch the queue as before. The optional id keeps old callers
154
+ * that only use poke() fully compatible. */
155
+ poke(id) {
156
+ const run = id ? this.inFlight.get(id) : undefined;
157
+ if (id && run) {
158
+ void this.checkLease(id, run);
159
+ return;
160
+ }
143
161
  void this.tick();
144
162
  }
145
163
  /** Number of queue items currently running on this node. Lets an ephemeral
@@ -193,37 +211,76 @@ export class ControlPlaneTaskPoller {
193
211
  // within a single tick: items ran one at a time regardless of `max`, and
194
212
  // only overlapping `setInterval` ticks happened to run more than one
195
213
  // concurrently.
196
- this.inFlight.add(item.id);
197
- running.push(this.runOne(item));
214
+ const run = { controller: new AbortController(), state: "active" };
215
+ this.inFlight.set(item.id, run);
216
+ running.push(this.runOne(item, run));
198
217
  }
199
218
  await Promise.all(running);
200
219
  }
201
- async runOne(item) {
202
- let leaseHeartbeat;
220
+ async runOne(item, reserved) {
221
+ // runOne is exercised directly by a few callers/tests, so create a control
222
+ // when there was no tick reservation. Never replace an existing control.
223
+ const run = reserved ?? this.inFlight.get(item.id) ?? { controller: new AbortController(), state: "active" };
224
+ if (!this.inFlight.has(item.id))
225
+ this.inFlight.set(item.id, run);
203
226
  try {
204
227
  // Claim first so only one node runs it; skip if another node won (no
205
228
  // claim → not ours → don't run or complete it). A heartbeat keeps the
206
229
  // finite lease alive; process death stops it and makes the item reclaimable.
207
- if (!(await claimWork(this.cfg, item.id)))
230
+ if (!(await claimWork(this.cfg, item.id)) || run.state !== "active")
208
231
  return;
209
- leaseHeartbeat = setInterval(() => void renewWorkLease(this.cfg, item.id), 30_000);
210
- leaseHeartbeat.unref?.();
232
+ run.heartbeat = setInterval(() => void this.checkLease(item.id, run), this.leaseHeartbeatMs);
233
+ run.heartbeat.unref?.();
211
234
  const report = (patch) => reportEvidence(this.cfg, item.id, patch);
212
235
  await transitionWork(this.cfg, item.id, "running");
236
+ if (run.state !== "active")
237
+ return;
213
238
  console.log(`[control-plane-tasks] running ${item.source} item ${item.id}: ${item.title}`);
214
239
  // routingReason is a coarse baseline — a manual "Run…" override picked
215
240
  // this agent/model explicitly; otherwise it's whatever the queue label
216
241
  // routed to. runWorkItem/runIssueTask may layer a more specific reason
217
242
  // (e.g. a fallback after an error) on top via the same `report` hook.
218
243
  await report({ routingReason: item.runtimeId || item.model ? "manual override" : "queue label" });
219
- await this.runWithPolicy(item, report);
244
+ if (run.state !== "active")
245
+ return;
246
+ await this.runWithPolicy(item, report, run);
220
247
  }
221
248
  finally {
222
- if (leaseHeartbeat)
223
- clearInterval(leaseHeartbeat);
224
- this.inFlight.delete(item.id);
249
+ if (run.heartbeat)
250
+ clearInterval(run.heartbeat);
251
+ // A stale completion must not remove a newer reservation for the same id.
252
+ if (this.inFlight.get(item.id) === run)
253
+ this.inFlight.delete(item.id);
225
254
  }
226
255
  }
256
+ checkLease(id, run) {
257
+ if (run.state !== "active")
258
+ return Promise.resolve();
259
+ if (run.leaseCheck)
260
+ return run.leaseCheck;
261
+ run.leaseCheck = (async () => {
262
+ try {
263
+ const renewal = await renewWorkLease(this.cfg, id);
264
+ if (renewal === "renewed" || this.inFlight.get(id) !== run || run.state !== "active")
265
+ return;
266
+ run.state = renewal;
267
+ if (run.heartbeat) {
268
+ clearInterval(run.heartbeat);
269
+ run.heartbeat = undefined;
270
+ }
271
+ run.controller.abort(new Error(renewal === "cancelled" ? "Run cancelled" : "Run lease lost"));
272
+ }
273
+ catch (error) {
274
+ // A transient network error does not prove ownership was lost. Keep the
275
+ // Run alive and let the next heartbeat retry.
276
+ console.warn(`[control-plane-tasks] work ${id} heartbeat failed:`, error instanceof Error ? error.message : error);
277
+ }
278
+ finally {
279
+ run.leaseCheck = undefined;
280
+ }
281
+ })();
282
+ return run.leaseCheck;
283
+ }
227
284
  /**
228
285
  * Run one item under the run policy: on failure, classify → decide → retry /
229
286
  * reroute (rewrite routing for the next attempt) / park (needs_attention) /
@@ -233,17 +290,27 @@ export class ControlPlaneTaskPoller {
233
290
  * safe evidence event. With no policy injected this is the historical path:
234
291
  * one attempt, any throw fails the run.
235
292
  */
236
- async runWithPolicy(item, report) {
293
+ async runWithPolicy(item, report, run) {
237
294
  let current = item;
238
295
  let attempt = 1;
239
296
  let rerouteCount = 0;
240
297
  for (;;) {
298
+ if (run.state !== "active")
299
+ return;
241
300
  try {
242
- await this.runItem(current, report);
301
+ // Functions declared with the historical two arguments remain valid in
302
+ // TypeScript/JavaScript; cancellation-aware runners can use the third.
303
+ await this.runItem(current, report, run.controller.signal);
304
+ if (run.state !== "active")
305
+ return;
243
306
  await completeWork(this.cfg, item.id);
244
307
  return;
245
308
  }
246
309
  catch (error) {
310
+ // Abort errors are ordinary throws to the policy layer unless guarded.
311
+ // A cancelled/lost Run has no node-side terminal transition or retry.
312
+ if (run.state !== "active")
313
+ return;
247
314
  const policy = typeof this.policy === "function" ? this.policy(current) : this.policy;
248
315
  const decision = policy?.decide({
249
316
  routing: { runtimeId: current.runtimeId, model: current.model },
@@ -258,6 +325,8 @@ export class ControlPlaneTaskPoller {
258
325
  const summary = `Attempt limit reached (${maxAttempts}); automation parked for review.`;
259
326
  console.warn(`[control-plane-tasks] item ${item.id} needs attention: ${summary}`);
260
327
  await report({ events: [{ at: new Date().toISOString(), kind: "needs_attention", summary, attempt }] });
328
+ if (run.state !== "active")
329
+ return;
261
330
  await needsAttentionWork(this.cfg, item.id);
262
331
  return;
263
332
  }
@@ -283,15 +352,21 @@ export class ControlPlaneTaskPoller {
283
352
  }
284
353
  if (decision.delayMs > 0)
285
354
  await this.sleep(decision.delayMs);
355
+ if (run.state !== "active")
356
+ return;
286
357
  continue;
287
358
  }
288
359
  if (decision.action === "park") {
289
360
  console.warn(`[control-plane-tasks] item ${item.id} needs attention (${decision.condition}): ${decision.summary}`);
290
361
  await report({ events: [{ at: new Date().toISOString(), kind: "needs_attention", summary: decision.summary }] });
362
+ if (run.state !== "active")
363
+ return;
291
364
  await needsAttentionWork(this.cfg, item.id);
292
365
  return;
293
366
  }
294
367
  console.warn(`[control-plane-tasks] item ${item.id} failed:`, error);
368
+ if (run.state !== "active")
369
+ return;
295
370
  await failWork(this.cfg, item.id);
296
371
  return;
297
372
  }
package/dist/server.js CHANGED
@@ -3008,8 +3008,8 @@ function startRelayIfConfigured() {
3008
3008
  terminals.dropClient(RELAY_CLIENT_ID);
3009
3009
  relay = new RelayConnector(config, (msg) => void handleRelayMessage(msg), {
3010
3010
  pairing: pairingStore,
3011
- onWorkAvailable: () => {
3012
- controlPlanePoller?.poke();
3011
+ onWorkAvailable: (hint) => {
3012
+ controlPlanePoller?.poke(hint.id);
3013
3013
  // A relay wake also means "something changed for this account" — kick a
3014
3014
  // (debounced) model-auth sync so a peer node answers any pending vault-key
3015
3015
  // request from a freshly-launched ephemeral runner without waiting for its
@@ -4073,12 +4073,18 @@ async function runIssueTaskInner(cfg, issue, source, overrides = {}) {
4073
4073
  throw new Error("worktree was not created for the issue session");
4074
4074
  try {
4075
4075
  emit(record, "started", `Started work on ${cfg.owner}/${cfg.repo}#${issue.number}.`);
4076
- await runSessionTurn(record, buildTaskPrompt(issue, nodeGithubIssuePrompt()));
4076
+ await runSessionTurn(record, buildTaskPrompt(issue, nodeGithubIssuePrompt()), overrides.signal);
4077
+ if (overrides.signal?.aborted)
4078
+ throw overrides.signal.reason ?? new Error("Run cancelled");
4077
4079
  emit(record, "agent_done", `Agent finished issue #${issue.number}; running deterministic checks.`);
4078
4080
  await reportIssueOutcome(cfg, issue, record, emit, { followUp: false, onEvidence: overrides.onEvidence });
4079
4081
  }
4080
4082
  catch (error) {
4081
- emit(record, "failed", `GitHub issue #${issue.number} failed: ${error instanceof Error ? error.message : String(error)}`);
4083
+ // Cancellation has its own durable control-plane outcome. Do not append a
4084
+ // misleading execution-failed event after the operator stopped the Run.
4085
+ if (!overrides.signal?.aborted) {
4086
+ emit(record, "failed", `GitHub issue #${issue.number} failed: ${error instanceof Error ? error.message : String(error)}`);
4087
+ }
4082
4088
  throw error;
4083
4089
  }
4084
4090
  }
@@ -4103,7 +4109,9 @@ async function runIssueFollowUp(cfg, issue, record, emit, overrides = {}) {
4103
4109
  const merge = await mergeBaseIntoBranch(cfg, wt.path, base);
4104
4110
  if (merge.status === "conflicts") {
4105
4111
  emit(record, "resolving_conflicts", `Merge conflicts with ${base} in ${merge.conflicts.length} file(s); asking the agent to resolve them.`, { files: merge.conflicts });
4106
- await runSessionTurn(record, buildConflictPrompt(base, merge.conflicts));
4112
+ await runSessionTurn(record, buildConflictPrompt(base, merge.conflicts), overrides.signal);
4113
+ if (overrides.signal?.aborted)
4114
+ throw overrides.signal.reason ?? new Error("Run cancelled");
4107
4115
  const resolved = await completeMerge(wt.path, merge.conflicts);
4108
4116
  if (resolved) {
4109
4117
  emit(record, "conflicts_resolved", `Resolved merge conflicts with ${base}.`);
@@ -4116,12 +4124,16 @@ async function runIssueFollowUp(cfg, issue, record, emit, overrides = {}) {
4116
4124
  emit(record, "conflicts_unresolved", `Could not automatically resolve conflicts with ${base}; continuing without merging it in.`);
4117
4125
  }
4118
4126
  }
4119
- await runSessionTurn(record, buildFollowUpPrompt(issue));
4127
+ await runSessionTurn(record, buildFollowUpPrompt(issue), overrides.signal);
4128
+ if (overrides.signal?.aborted)
4129
+ throw overrides.signal.reason ?? new Error("Run cancelled");
4120
4130
  emit(record, "agent_done", `Agent handled the follow-up on issue #${issue.number}; running deterministic checks.`);
4121
4131
  await reportIssueOutcome(cfg, issue, record, emit, { followUp: true, onEvidence: overrides.onEvidence });
4122
4132
  }
4123
4133
  catch (error) {
4124
- emit(record, "failed", `GitHub issue #${issue.number} follow-up failed: ${error instanceof Error ? error.message : String(error)}`);
4134
+ if (!overrides.signal?.aborted) {
4135
+ emit(record, "failed", `GitHub issue #${issue.number} follow-up failed: ${error instanceof Error ? error.message : String(error)}`);
4136
+ }
4125
4137
  throw error;
4126
4138
  }
4127
4139
  }
@@ -4240,17 +4252,31 @@ function buildConflictPrompt(base, conflicts) {
4240
4252
  * Mirrors how the issue pickup awaited a turn inline; factored out so both the
4241
4253
  * implementation turn and the conflict-resolution turn share it.
4242
4254
  */
4243
- async function runSessionTurn(record, prompt) {
4255
+ async function runSessionTurn(record, prompt, signal) {
4256
+ if (signal?.aborted)
4257
+ throw signal.reason ?? new Error("Run cancelled");
4258
+ let unsubscribe = () => { };
4244
4259
  const finished = new Promise((resolve) => {
4245
- const off = record.session.subscribe((event) => {
4246
- if (event.type === "agent_end") {
4247
- off();
4260
+ unsubscribe = record.session.subscribe((event) => {
4261
+ if (event.type === "agent_end")
4248
4262
  resolve();
4249
- }
4250
4263
  });
4251
4264
  });
4252
- await turnWatchdog.promptWithWatchdog(record, prompt);
4253
- await finished;
4265
+ let rejectCancellation = () => { };
4266
+ const cancelled = new Promise((_resolve, reject) => { rejectCancellation = reject; });
4267
+ const onAbort = () => {
4268
+ abortSessionRecord(record);
4269
+ rejectCancellation(signal?.reason ?? new Error("Run cancelled"));
4270
+ };
4271
+ signal?.addEventListener("abort", onAbort, { once: true });
4272
+ try {
4273
+ await turnWatchdog.promptWithWatchdog(record, prompt);
4274
+ await (signal ? Promise.race([finished, cancelled]) : finished);
4275
+ }
4276
+ finally {
4277
+ signal?.removeEventListener("abort", onAbort);
4278
+ unsubscribe();
4279
+ }
4254
4280
  }
4255
4281
  async function anthropicHeadersFromNodeCredential() {
4256
4282
  const cred = await createCredentialStore(credsDir).getCredential("anthropic");
@@ -4621,7 +4647,9 @@ async function continueCorrelatedSession(item, prompt, report, opts) {
4621
4647
  throw new Error(`Repository policy requires ${safety.sandbox}; refusing to continue a session opened as ${currentSandbox}`);
4622
4648
  }
4623
4649
  record.approvalMode = safety.approval;
4624
- await runSessionTurn(record, prompt);
4650
+ await runSessionTurn(record, prompt, opts?.signal);
4651
+ if (opts?.signal?.aborted)
4652
+ throw opts.signal.reason ?? new Error("Run cancelled");
4625
4653
  if (record.worktree && !opts?.isMessage && !opts?.resumeOnMissing) {
4626
4654
  await branchPublish.maybePushWorktreeBranch(record);
4627
4655
  await prDetection.maybeDetectPullRequest(record);
@@ -4655,7 +4683,9 @@ function lastUserMessageText(record) {
4655
4683
  }
4656
4684
  return "";
4657
4685
  }
4658
- async function runWorkItem(item, report) {
4686
+ async function runWorkItem(item, report, signal) {
4687
+ if (signal.aborted)
4688
+ throw signal.reason ?? new Error("Run cancelled");
4659
4689
  // Scheduled, manual, and webhook-triggered automations carry the operator's
4660
4690
  // instructions as an E2E template (`bivy-room-v1:<node>:<ciphertext>`) only the
4661
4691
  // assigned node can read. The envelope prefix is Bivy's own and never appears
@@ -4739,6 +4769,7 @@ async function runWorkItem(item, report) {
4739
4769
  sandbox: normalizeSandboxTier(item.sandbox),
4740
4770
  approvalMode: approvalModeFrom(item.approvalMode),
4741
4771
  onEvidence: report,
4772
+ signal,
4742
4773
  });
4743
4774
  return;
4744
4775
  }
@@ -4757,7 +4788,7 @@ async function runWorkItem(item, report) {
4757
4788
  throw new Error(`Linear work item has an invalid repo "${repoSlug}"`);
4758
4789
  // Case B: a re-dispatch the control plane correlated to an existing session
4759
4790
  // continues it as a normal chat instead of starting cold (mirrors GitHub).
4760
- if (await continueCorrelatedSession(item, buildLinearTaskPrompt(issue), report))
4791
+ if (await continueCorrelatedSession(item, buildLinearTaskPrompt(issue), report, { signal }))
4761
4792
  return;
4762
4793
  const githubToken = await resolveGitHubToken();
4763
4794
  if (!githubToken)
@@ -4784,7 +4815,9 @@ async function runWorkItem(item, report) {
4784
4815
  catch { }
4785
4816
  }
4786
4817
  await report({ output: { sessionId: record.id, branch }, events: [{ at: new Date().toISOString(), kind: "branch", summary: "Linear issue working branch and session created.", ref: branch, url: issue.url }] });
4787
- await runSessionTurn(record, buildLinearTaskPrompt(issue));
4818
+ await runSessionTurn(record, buildLinearTaskPrompt(issue), signal);
4819
+ if (signal.aborted)
4820
+ throw signal.reason ?? new Error("Run cancelled");
4788
4821
  await branchPublish.maybePushWorktreeBranch(record);
4789
4822
  await prDetection.maybeDetectPullRequest(record);
4790
4823
  await report({ output: { sessionId: record.id, branch, prUrl: record.prUrl }, events: record.prUrl ? [{ at: new Date().toISOString(), kind: "pull_request", summary: "Pull request opened.", ref: branch, url: record.prUrl }] : undefined });
@@ -4815,7 +4848,7 @@ async function runWorkItem(item, report) {
4815
4848
  // Scheduled runs targeting an existing session are STRICT: the message must
4816
4849
  // land in that session (resumed from disk if needed), never silently in a new
4817
4850
  // one — so a session that can't be resumed fails the run instead.
4818
- if (await continueCorrelatedSession(item, request, report, { resumeOnMissing: item.source === "schedule", isMessage }))
4851
+ if (await continueCorrelatedSession(item, request, report, { resumeOnMissing: item.source === "schedule", isMessage, signal }))
4819
4852
  return;
4820
4853
  const requestedSandbox = normalizeSandboxTier(item.sandbox);
4821
4854
  // Prepare an explicit repository before resolving its policy. Otherwise a
@@ -4858,7 +4891,9 @@ async function runWorkItem(item, report) {
4858
4891
  "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.",
4859
4892
  ].join("\n")
4860
4893
  : request;
4861
- await runSessionTurn(record, prompt);
4894
+ await runSessionTurn(record, prompt, signal);
4895
+ if (signal.aborted)
4896
+ throw signal.reason ?? new Error("Run cancelled");
4862
4897
  if (!isMessage && record.worktree) {
4863
4898
  await branchPublish.maybePushWorktreeBranch(record);
4864
4899
  await prDetection.maybeDetectPullRequest(record);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.10.1-staging.236",
3
+ "version": "0.10.1-staging.237",
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.",