@schoolai/shipyard 3.24.0-rc.20260813.0 → 3.24.0

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.
@@ -71,7 +71,7 @@ async function main() {
71
71
  await loadAuthFromConfig(env);
72
72
  crumb("post-auth-load");
73
73
  crumb("pre-serve-import");
74
- const { serve } = await import("./serve-QHLWAVVY.js");
74
+ const { serve } = await import("./serve-RWFBSQIE.js");
75
75
  crumb("post-serve-import");
76
76
  const portQueue = [];
77
77
  let acceptor = null;
package/dist/index.js CHANGED
@@ -137,7 +137,7 @@ async function handleSubcommand() {
137
137
  return true;
138
138
  }
139
139
  if (subcommand === "start") {
140
- const { startCommand } = await import("./start-JJCGZ63I.js");
140
+ const { startCommand } = await import("./start-ZV22UPMC.js");
141
141
  await startCommand();
142
142
  return true;
143
143
  }
@@ -159,7 +159,7 @@ async function main() {
159
159
  const args = parseCliArgs();
160
160
  if (args.serve) {
161
161
  await loadAuthFromConfig(env);
162
- const { serve } = await import("./serve-QHLWAVVY.js");
162
+ const { serve } = await import("./serve-RWFBSQIE.js");
163
163
  return serve({ isDev: env.SHIPYARD_DEV });
164
164
  }
165
165
  logger.error("Use `shipyard start` to run the daemon. Use --help for usage.");
@@ -78822,6 +78822,13 @@ function handleSpawning(snapshot, event) {
78822
78822
  }
78823
78823
  return noop(snapshot);
78824
78824
  }
78825
+ function buildStallGiveUpMessage(recoveryAttempts) {
78826
+ if (recoveryAttempts <= 0) {
78827
+ return "The agent stopped responding mid-run. Send a message to try again.";
78828
+ }
78829
+ const plural = recoveryAttempts === 1 ? "time" : "times";
78830
+ return `The agent stopped responding mid-run. Automatic recovery already retried ${recoveryAttempts} ${plural} without success \u2014 send a message to resume.`;
78831
+ }
78825
78832
  function runningStallTimeout(sessionId, event) {
78826
78833
  const b2 = createEffectBuilder();
78827
78834
  b2.log("running", "resumable_idle", event.type);
@@ -78829,11 +78836,7 @@ function runningStallTimeout(sessionId, event) {
78829
78836
  b2.effects.push({ type: "clear_queue" });
78830
78837
  if (!event.recoveryArmed) {
78831
78838
  b2.taskStatus("input_required");
78832
- b2.emitError(
78833
- "The agent stopped responding mid-run. Send a message to try again.",
78834
- "sdk_error",
78835
- "run_stall_timeout"
78836
- );
78839
+ b2.emitError(buildStallGiveUpMessage(event.recoveryAttempts), "sdk_error", "run_stall_timeout");
78837
78840
  }
78838
78841
  b2.effects.push({ type: "kill_runner" });
78839
78842
  return {
@@ -79654,10 +79657,13 @@ var AgentSessionManager = class {
79654
79657
  return;
79655
79658
  }
79656
79659
  let recoveryArmed = false;
79660
+ let recoveryAttempts = 0;
79657
79661
  try {
79658
- recoveryArmed = this.#config.onStallTimeout?.() ?? false;
79662
+ const outcome = this.#config.onStallTimeout?.() ?? { armed: false, recoveryAttempts: 0 };
79663
+ recoveryArmed = outcome.armed;
79664
+ recoveryAttempts = outcome.recoveryAttempts;
79659
79665
  } finally {
79660
- this.#dispatch({ type: "run_stall_timeout", recoveryArmed });
79666
+ this.#dispatch({ type: "run_stall_timeout", recoveryArmed, recoveryAttempts });
79661
79667
  }
79662
79668
  }, timeoutMs);
79663
79669
  }
@@ -79685,48 +79691,98 @@ var AgentSessionManager = class {
79685
79691
 
79686
79692
  // src/services/conversation/cursor-recovery-controller.ts
79687
79693
  var MAX_SIGNAL_DEATH_RETRIES = 1;
79688
- var MAX_STALL_RESUME_RETRIES = 1;
79694
+ var MAX_STALL_RESUME_RETRIES = 3;
79695
+ var STALL_RESUME_BASE_DELAY_MS = 3e3;
79696
+ var STALL_RESUME_BACKOFF_MULTIPLIER = 3;
79697
+ function stallResumeDelayMs(attempt) {
79698
+ return STALL_RESUME_BASE_DELAY_MS * STALL_RESUME_BACKOFF_MULTIPLIER ** (attempt - 1);
79699
+ }
79689
79700
  var MAX_CRASH_DEATH_RETRIES = 1;
79690
79701
  var CursorRecoveryController = class {
79691
79702
  #signalDeathRetries = 0;
79692
79703
  #stallResumeRetries = 0;
79693
79704
  #stallResumeArmed = false;
79694
79705
  #crashDeathRetries = 0;
79706
+ /**
79707
+ * Pending timer for the settle-delay before an armed stall resume fires.
79708
+ * Cleared at every call site that supersedes the in-flight resume (new
79709
+ * user turn, stop, rewind, dispose) plus its own callback on fire — the
79710
+ * same shape as `RateLimitFlapController#timer`. A single handle suffices;
79711
+ * `consumeStallResumeOnDeath` cannot run twice without an intervening
79712
+ * `onStallTimeout` re-arming it.
79713
+ */
79714
+ #stallResumeTimer = null;
79695
79715
  #deps;
79696
79716
  constructor(deps) {
79697
79717
  this.#deps = deps;
79698
79718
  }
79719
+ /**
79720
+ * Cancel a pending delayed stall resume. Idempotent. Call from any path
79721
+ * that supersedes the armed resume (new user turn, stop, rewind, dispose)
79722
+ * — without this, a fresh intent would race the deferred
79723
+ * `respawnForResume()` fired by the settle-delay timer.
79724
+ */
79725
+ cancelPendingResume() {
79726
+ if (this.#stallResumeTimer !== null) {
79727
+ clearTimeout(this.#stallResumeTimer);
79728
+ this.#stallResumeTimer = null;
79729
+ }
79730
+ }
79699
79731
  /** Reset every budget. Call on each new user turn and on turn_complete. */
79700
79732
  reset() {
79733
+ this.cancelPendingResume();
79701
79734
  this.#signalDeathRetries = 0;
79702
79735
  this.#stallResumeRetries = 0;
79703
79736
  this.#stallResumeArmed = false;
79704
79737
  this.#crashDeathRetries = 0;
79705
79738
  }
79739
+ /**
79740
+ * Call on `turn_complete` instead of `reset()`. A successful auto-recovery
79741
+ * otherwise leaves no trace that the daemon self-healed rather than the
79742
+ * run simply never stalling — log the recovery before clearing the budget.
79743
+ */
79744
+ noteTurnCompleteRecovery() {
79745
+ if (this.#stallResumeRetries > 0) {
79746
+ this.#deps.log({
79747
+ event: "thread_cursor_stall_auto_resume_recovered",
79748
+ threadId: this.#deps.threadId,
79749
+ attempts: this.#stallResumeRetries
79750
+ });
79751
+ }
79752
+ this.reset();
79753
+ }
79706
79754
  /**
79707
79755
  * Fired by the manager when the stall watchdog expires, BEFORE the
79708
79756
  * `run_stall_timeout` event transitions the FSM. A mid-run Cursor stall
79709
79757
  * otherwise lands in `resumable_idle` with a "send a message to try again"
79710
- * banner and strands the orphaned turn until the user returns. Arm a one-shot
79711
- * resume so the imminent `kill_runner` death is resumed instead of surfaced.
79712
- * On repeat (cap exceeded) stay armed-off and let the existing banner run.
79758
+ * banner and strands the orphaned turn until the user returns. Arm a
79759
+ * resume so the imminent `kill_runner` death is resumed instead of
79760
+ * surfaced. On repeat, up to `MAX_STALL_RESUME_RETRIES` times, stay
79761
+ * armed-off and let the existing banner run.
79713
79762
  *
79714
- * Returns true when a recovery was armed — the manager passes this as
79715
- * `recoveryArmed` on the `run_stall_timeout` FSM event so the FSM suppresses
79716
- * the user-facing error banner during the 5-second self-heal (Fix B2).
79763
+ * Returns `armed: true` when a recovery was armed — the manager passes
79764
+ * this as `recoveryArmed` on the `run_stall_timeout` FSM event so the FSM
79765
+ * suppresses the user-facing error banner during the self-heal.
79766
+ * `recoveryAttempts` is only ever positive on the cap-exceeded give-up
79767
+ * (see `StallTimeoutOutcome`), letting the FSM distinguish "never
79768
+ * attempted" from "tried N times".
79717
79769
  */
79718
79770
  onStallTimeout() {
79719
- if (this.#deps.getState() !== "running") return false;
79720
- if (this.#deps.getAgentSystem() !== AGENT_SYSTEM_CURSOR) return false;
79771
+ if (this.#deps.getState() !== "running") return { armed: false, recoveryAttempts: 0 };
79772
+ if (this.#deps.getAgentSystem() !== AGENT_SYSTEM_CURSOR) {
79773
+ return { armed: false, recoveryAttempts: 0 };
79774
+ }
79721
79775
  this.#stallResumeRetries++;
79722
79776
  if (this.#stallResumeRetries > MAX_STALL_RESUME_RETRIES) {
79777
+ const recoveryAttempts = this.#stallResumeRetries - 1;
79723
79778
  this.#deps.log({
79724
79779
  event: "thread_cursor_stall_resume_cap_exceeded",
79725
79780
  threadId: this.#deps.threadId,
79726
- retries: this.#stallResumeRetries
79781
+ retries: this.#stallResumeRetries,
79782
+ maxRetries: MAX_STALL_RESUME_RETRIES
79727
79783
  });
79728
79784
  this.#stallResumeRetries = 0;
79729
- return false;
79785
+ return { armed: false, recoveryAttempts };
79730
79786
  }
79731
79787
  this.#deps.log({
79732
79788
  event: "thread_cursor_stall_resume_armed",
@@ -79734,19 +79790,41 @@ var CursorRecoveryController = class {
79734
79790
  retries: this.#stallResumeRetries
79735
79791
  });
79736
79792
  this.#stallResumeArmed = true;
79737
- return true;
79793
+ return { armed: true, recoveryAttempts: 0 };
79738
79794
  }
79739
79795
  /**
79740
- * Consume an armed stall resume on the `kill_runner` death. By now the FSM is
79741
- * in `resumable_idle` and the subprocess was already cleared by Thread's
79742
- * force-kill, so this only re-spawns. Race-free: we resume ON the death, so
79743
- * the hung runner is confirmed gone. Returns true when it handled the death.
79796
+ * Consume an armed stall resume on the `kill_runner` death. By now the FSM
79797
+ * is in `resumable_idle` and the subprocess was already cleared by
79798
+ * Thread's force-kill. Schedule the actual resume after
79799
+ * `stallResumeDelayMs(attempt)` rather than firing immediately the
79800
+ * settle window that prevents the busy collision (see
79801
+ * `MAX_STALL_RESUME_RETRIES`'s doc comment). Returns true as soon as it
79802
+ * commits to handling the death (the timer is in-flight); the caller must
79803
+ * not also surface it.
79744
79804
  */
79745
79805
  consumeStallResumeOnDeath() {
79746
79806
  if (!this.#stallResumeArmed) return false;
79747
79807
  this.#stallResumeArmed = false;
79748
- this.#deps.log({ event: "thread_cursor_stall_auto_resume", threadId: this.#deps.threadId });
79749
- this.#deps.respawnForResume();
79808
+ this.cancelPendingResume();
79809
+ const attempt = this.#stallResumeRetries;
79810
+ const delayMs = stallResumeDelayMs(attempt);
79811
+ this.#deps.log({
79812
+ event: "thread_cursor_stall_auto_resume_scheduled",
79813
+ threadId: this.#deps.threadId,
79814
+ attempt,
79815
+ delayMs
79816
+ });
79817
+ this.#stallResumeTimer = setTimeout(() => {
79818
+ this.#stallResumeTimer = null;
79819
+ if (this.#deps.isDisposed()) return;
79820
+ this.#deps.log({
79821
+ event: "thread_cursor_stall_auto_resume",
79822
+ threadId: this.#deps.threadId,
79823
+ attempt,
79824
+ delayMs
79825
+ });
79826
+ this.#deps.respawnForResume();
79827
+ }, delayMs);
79750
79828
  return true;
79751
79829
  }
79752
79830
  /**
@@ -80857,6 +80935,7 @@ var Thread = class {
80857
80935
  threadId: config.threadId,
80858
80936
  getState: () => this.#manager.state,
80859
80937
  getAgentSystem: () => this.#agentSystem,
80938
+ isDisposed: () => this.#disposed,
80860
80939
  log: config.log,
80861
80940
  clearSubprocess: () => {
80862
80941
  this.#subprocess?.setOnCwdChanged(null);
@@ -81017,8 +81096,16 @@ var Thread = class {
81017
81096
  * Unlike `handleUserMessage`, this does NOT call `#recovery.noteNewUserTurn()`
81018
81097
  * (which resets the request-aborted retry budget) because a synthetic wake is
81019
81098
  * not a real user message.
81099
+ *
81100
+ * DOES cancel a pending flap/stall-resume timer, same reasoning as `stop()`
81101
+ * and `rewind()`: `sendMessage` from `resumable_idle` walks the FSM to
81102
+ * `spawning` on its own, so a still-armed timer firing later would call
81103
+ * `respawnForResume()` against whatever run this wake started — a resume
81104
+ * with nothing to do with the death it was originally scheduled for.
81020
81105
  */
81021
81106
  pushSyntheticMessageAndWake(content) {
81107
+ this.#rateLimitFlap.cancelPendingRetry();
81108
+ this.#cursorRecovery.cancelPendingResume();
81022
81109
  this.#manager.sendMessage(content);
81023
81110
  }
81024
81111
  /**
@@ -81059,6 +81146,7 @@ var Thread = class {
81059
81146
  }
81060
81147
  stop() {
81061
81148
  this.#rateLimitFlap.cancelPendingRetry();
81149
+ this.#cursorRecovery.cancelPendingResume();
81062
81150
  this.#manager.stop();
81063
81151
  }
81064
81152
  /**
@@ -81068,6 +81156,7 @@ var Thread = class {
81068
81156
  */
81069
81157
  rewind(resumeAtMessageId) {
81070
81158
  this.#rateLimitFlap.cancelPendingRetry();
81159
+ this.#cursorRecovery.cancelPendingResume();
81071
81160
  this.#manager.rewind(resumeAtMessageId);
81072
81161
  }
81073
81162
  notifySetupReady() {
@@ -81221,6 +81310,7 @@ var Thread = class {
81221
81310
  await this.#asyncQueue;
81222
81311
  this.#disposed = true;
81223
81312
  this.#rateLimitFlap.cancelPendingRetry();
81313
+ this.#cursorRecovery.cancelPendingResume();
81224
81314
  this.#permissionHandler.denyAllPending("Thread disposed");
81225
81315
  this.#permissionQueue.length = 0;
81226
81316
  this.#streamDelta.dispose();
@@ -81861,7 +81951,7 @@ var Thread = class {
81861
81951
  });
81862
81952
  this.#streamDelta.resetForTurn();
81863
81953
  this.#recovery.resetRetriesOnTurnComplete();
81864
- this.#cursorRecovery.reset();
81954
+ this.#cursorRecovery.noteTurnCompleteRecovery();
81865
81955
  this.#rateLimitFlap.noteRecoveredOnTurnComplete((event.result.outputTokens ?? 0) > 0);
81866
81956
  this.#emitThreadTurnStats(event.result);
81867
81957
  this.#manager.notifyTurnComplete();
@@ -103390,4 +103480,4 @@ export {
103390
103480
  decideWorkspaceScope,
103391
103481
  serve
103392
103482
  };
103393
- //# sourceMappingURL=serve-QHLWAVVY.js.map
103483
+ //# sourceMappingURL=serve-RWFBSQIE.js.map