@bivy/bivy 0.7.0-staging.102 → 0.7.0-staging.103

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/dist/metadata.js CHANGED
@@ -182,6 +182,20 @@ export class MetadataStore {
182
182
  this.data.sessions[id] = { ...prev, resumeAt: next, updatedAt: nowIso() };
183
183
  this.save();
184
184
  }
185
+ /** Set the durable consecutive auto-resume counter (the restart-safe backstop
186
+ * for the in-memory reroute budget). Pass 0 to clear. No-op when the row is
187
+ * missing or already in the requested state, so a normal turn (counter already
188
+ * 0) never churns the file. */
189
+ setResumeAttempts(id, attempts) {
190
+ const prev = this.data.sessions[id];
191
+ if (!prev)
192
+ return;
193
+ const next = attempts > 0 ? attempts : undefined;
194
+ if ((prev.resumeAttempts ?? undefined) === next)
195
+ return;
196
+ this.data.sessions[id] = { ...prev, resumeAttempts: next, updatedAt: nowIso() };
197
+ this.save();
198
+ }
185
199
  /** Sessions with a durable auto-resume time set — the resume sweep re-arms
186
200
  * these after a restart. */
187
201
  sessionsWithResumeAt() {
@@ -92,8 +92,22 @@ export class SessionRerouteController {
92
92
  // long enough that it's clearly a limit rather than routine backoff.
93
93
  if (decision.resetsAt === undefined && decision.delayMs < MIN_RESUME_DELAY_MS)
94
94
  return null;
95
- const resumeAt = decision.resetsAt ?? new Date(now + decision.delayMs).toISOString();
96
- return { condition: decision.condition, summary: decision.summary, delayMs: Math.max(0, decision.delayMs), resumeAt };
95
+ // Resolve the due time (provider reset when known, else backoff) and floor it
96
+ // to at least MIN_RESUME_DELAY_MS in the FUTURE. A reset time can be in the
97
+ // past or ~now — a stale/elapsed reset, clock skew, or (most often) a window
98
+ // that already lapsed while the daemon was down — and using it verbatim yields
99
+ // a 0ms delay. The caller arms a timer at that delay, so a 0ms resume re-sends
100
+ // instantly, re-hits the still-standing limit, and re-schedules 0ms again: a
101
+ // tight loop that pins a CPU core and never settles. Flooring turns a
102
+ // not-yet-cleared limit into a slow retry the attempt budget can still park.
103
+ const rawDueMs = decision.resetsAt ? Date.parse(decision.resetsAt) : now + decision.delayMs;
104
+ const dueMs = Math.max(Number.isFinite(rawDueMs) ? rawDueMs : now, now + MIN_RESUME_DELAY_MS);
105
+ return {
106
+ condition: decision.condition,
107
+ summary: decision.summary,
108
+ delayMs: dueMs - now,
109
+ resumeAt: new Date(dueMs).toISOString(),
110
+ };
97
111
  }
98
112
  /** Advance the attempt budget once the caller has committed to a resume, so a
99
113
  * limit that re-fires after the reset counts toward `maxAttempts` and can
package/dist/server.js CHANGED
@@ -6886,6 +6886,14 @@ const SESSION_RESUME_SWEEP_MS = 60_000;
6886
6886
  /** Slack around "due": a capped timer may fire a touch early — drive only when
6887
6887
  * within this of the target, else re-arm. */
6888
6888
  const SESSION_RESUME_TICK_MS = 15_000;
6889
+ /** Hard ceiling on consecutive auto-resumes for one session before we give up and
6890
+ * surface the limit. The reroute controller already caps per turn, but its budget
6891
+ * is in-memory: a session re-resolved after its child exits on the limit (or a
6892
+ * daemon restart) gets a fresh controller, so without a durable count a limit that
6893
+ * never actually clears would re-send every MIN_RESUME_DELAY_MS indefinitely.
6894
+ * Generous enough to ride out a mis-parsed multi-day window (each wait is ≥1 min,
6895
+ * usually far longer), low enough to bound a genuinely stuck limit. */
6896
+ const MAX_DURABLE_RESUME_ATTEMPTS = 10;
6889
6897
  const sessionResumeTimers = new Map();
6890
6898
  // Fire due auto-resumes (a usage/rate limit that has since reset) and re-arm the
6891
6899
  // tail of long waits whose in-process timer was capped or lost to a restart.
@@ -7192,10 +7200,20 @@ function armSessionResumeTimer(id, dueMs) {
7192
7200
  timer.unref?.();
7193
7201
  sessionResumeTimers.set(id, timer);
7194
7202
  }
7195
- /** Persist + arm an auto-resume decided by the session policy. Synchronous so
7196
- * the caller can atomically suppress the turn's error toast. */
7203
+ /** Persist + arm an auto-resume decided by the session policy. Synchronous so the
7204
+ * caller can atomically suppress the turn's error toast. Returns false when the
7205
+ * session has already exhausted its durable auto-resume budget (a limit that never
7206
+ * clears) — the caller then lets the error surface instead of looping. */
7197
7207
  function scheduleSessionResume(record, plan) {
7208
+ const attempts = metadata.getSession(record.id)?.resumeAttempts ?? 0;
7209
+ if (attempts >= MAX_DURABLE_RESUME_ATTEMPTS) {
7210
+ console.warn(`[resume] session ${record.id} hit the durable auto-resume cap (${MAX_DURABLE_RESUME_ATTEMPTS}) without the limit clearing — giving up`);
7211
+ clearSessionResume(record.id);
7212
+ metadata.setResumeAttempts(record.id, 0);
7213
+ return false;
7214
+ }
7198
7215
  metadata.setResumeAt(record.id, plan.resumeAt);
7216
+ metadata.setResumeAttempts(record.id, attempts + 1);
7199
7217
  const when = Date.parse(plan.resumeAt);
7200
7218
  const cond = plan.condition.replace(/_/g, " ");
7201
7219
  broadcast({
@@ -7205,6 +7223,7 @@ function scheduleSessionResume(record, plan) {
7205
7223
  message: `Hit a ${cond} limit — I'll resume this automatically when it resets (${plan.resumeAt}).`,
7206
7224
  });
7207
7225
  armSessionResumeTimer(record.id, Number.isFinite(when) ? when : Date.now());
7226
+ return true;
7208
7227
  }
7209
7228
  /** Fire a due auto-resume: re-open the session if needed and re-send the turn's
7210
7229
  * last prompt. Clears the durable marker BEFORE driving so a crash mid-resume
@@ -7484,6 +7503,10 @@ function attachSessionListeners(record) {
7484
7503
  resetsAtHint: limitResetHint(record, Date.now()),
7485
7504
  }) ?? null
7486
7505
  : null;
7506
+ // Did this turn end by scheduling another auto-resume? If not, the session
7507
+ // made forward progress (a user turn, a resume that cleared the limit, a
7508
+ // reroute, or a surfaced error), so its durable resume streak resets below.
7509
+ let scheduledResume = false;
7487
7510
  if (reroutePlan) {
7488
7511
  void record.reroute.applyReroute(reroutePlan, {
7489
7512
  getCurrentModelName: () => record.session.getCurrentModel()?.name,
@@ -7493,12 +7516,14 @@ function attachSessionListeners(record) {
7493
7516
  },
7494
7517
  });
7495
7518
  }
7496
- else if (resumePlan) {
7519
+ else if (resumePlan && scheduleSessionResume(record, resumePlan)) {
7497
7520
  // Charge the attempt budget so a limit that re-fires after the reset can
7498
7521
  // eventually exhaust (→ surface) instead of looping, then park the turn
7499
- // as a scheduled resume rather than a dead error.
7522
+ // as a scheduled resume rather than a dead error. scheduleSessionResume
7523
+ // returns false once the durable cap is hit, so this falls through to
7524
+ // surface the limit instead of resuming forever.
7500
7525
  record.reroute.noteResumeApplied();
7501
- scheduleSessionResume(record, resumePlan);
7526
+ scheduledResume = true;
7502
7527
  }
7503
7528
  else if (messageError) {
7504
7529
  // Only the server-owned (pi-ai) path surfaces here; a Claude Code error
@@ -7527,6 +7552,11 @@ function attachSessionListeners(record) {
7527
7552
  body: `${sessionNotifyLabel(record)} finished — tap to review the result.`,
7528
7553
  });
7529
7554
  }
7555
+ // Any turn that didn't schedule another resume broke the limit streak —
7556
+ // clear the durable counter so a future limit starts with a full budget
7557
+ // (no-op when it's already 0, so a normal turn never touches the file).
7558
+ if (!scheduledResume)
7559
+ metadata.setResumeAttempts(record.id, 0);
7530
7560
  // First real commit on a repo-backed worktree → publish the branch to the
7531
7561
  // remote (sets upstream), so the work is visible on GitHub. No-op until
7532
7562
  // there's a commit, and only pushes once. Then adopt a PR the agent opened
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.7.0-staging.102",
3
+ "version": "0.7.0-staging.103",
4
4
  "type": "module",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "description": "Run coding agents on machines you own. Source-available, self-hostable agent workspace.",