@hyperdrive.bot/paseo-cli 0.3.8 → 0.3.10

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.
@@ -57,7 +57,7 @@ export function createDaemonCommand() {
57
57
  .option("--check", "Report whether an update is available without installing it")
58
58
  .option("--force", "Reinstall even if current, or retry a version pinned after a failure")
59
59
  .option("--use-staged", "Activate a slot already present under <home>/versions instead of downloading it (still verified before activation)")
60
- .option("--ready-timeout <seconds>", "How long the new version has to come up before rollback (default: 20)")
60
+ .option("--ready-timeout <seconds>", "Backstop for a daemon that starts but never reports ready; a daemon that dies is detected without waiting (default: 180)")
61
61
  .action(withOutput(runUpdateCommand));
62
62
  addJsonOption(daemon
63
63
  .command("set-password")
@@ -8,6 +8,19 @@ interface UpdateResult {
8
8
  message: string;
9
9
  }
10
10
  export type UpdateCommandResult = SingleResult<UpdateResult>;
11
+ interface ReadyOutcome {
12
+ ok: boolean;
13
+ observed: string | null;
14
+ /** Why the wait ended, so the rollback can say something true about it. */
15
+ reason?: "exited" | "never-started" | "timeout";
16
+ }
17
+ /**
18
+ * What to record against the version that failed, in the words of whatever
19
+ * actually went wrong. A pin outlives this command and is read by a human
20
+ * months later deciding whether to `--force` past it, so "it was slow" and
21
+ * "it could not stay alive" must not arrive as the same sentence.
22
+ */
23
+ export declare function describeFailure(reason: ReadyOutcome["reason"], version: string, budgetMs: number): string;
11
24
  export declare function runUpdateCommand(options: CommandOptions, _command: Command): Promise<UpdateCommandResult>;
12
25
  export {};
13
26
  //# sourceMappingURL=update.d.ts.map
@@ -44,11 +44,58 @@ function parseChannel(raw) {
44
44
  function resolveRunningVersion(paseoHome) {
45
45
  return readSlotVersion(paseoHome, "current") ?? resolveCliVersion();
46
46
  }
47
+ /** How long the supervisor has to claim the pid lock before we call it a no-show. */
48
+ const SUPERVISOR_APPEAR_GRACE_MS = 15000;
49
+ /**
50
+ * Backstop for a daemon that is alive but never reports ready.
51
+ *
52
+ * Deliberately far above the 48.7s slowest real restart measured on a live
53
+ * daemon, because with the supervisor's exit now detected directly this bound
54
+ * is only reached by a genuine hang, where waiting is the right answer. It is
55
+ * not a guess at how long a healthy start takes.
56
+ */
57
+ const DEFAULT_READY_BUDGET_MS = 180000;
58
+ /**
59
+ * Wait for the restarted daemon to report the version we just activated.
60
+ *
61
+ * A deadline alone cannot do this job, because it cannot tell a slow start from
62
+ * a dead one. Both look like silence, and the two want opposite responses:
63
+ * waiting longer fixes the first and only delays the second. Measured on a live
64
+ * daemon, six real restarts took 1.2s to 48.7s to report ready - a 25x spread,
65
+ * with the slowest a perfectly healthy externally-requested restart. Any
66
+ * constant that is short enough to fail fast on a dead worker is short enough to
67
+ * roll back a good version for being slow.
68
+ *
69
+ * So the deadline is demoted to a backstop and the supervisor's own liveness
70
+ * carries the signal. Once the supervisor has claimed the pid lock, its
71
+ * disappearance means it gave up respawning the worker (the restart budget is
72
+ * exhausted), which is decisive and immediate. That is what makes it safe for
73
+ * the backstop to be generous.
74
+ */
47
75
  async function waitForDaemonVersion(paseoHome, expected, budgetMs) {
48
- const deadline = Date.now() + budgetMs;
76
+ const startedAt = Date.now();
77
+ const deadline = startedAt + budgetMs;
78
+ // Never spend more than half the budget waiting for the supervisor to merely
79
+ // appear, so a caller who passes a short timeout still gets the specific
80
+ // "it did not restart at all" answer rather than a generic expiry.
81
+ const appearGraceMs = Math.min(SUPERVISOR_APPEAR_GRACE_MS, Math.floor(budgetMs / 2));
49
82
  let observed = null;
83
+ let sawSupervisor = false;
50
84
  while (Date.now() < deadline) {
51
85
  const state = resolveLocalDaemonState({ home: paseoHome });
86
+ if (state.running) {
87
+ sawSupervisor = true;
88
+ }
89
+ else if (sawSupervisor) {
90
+ // It was up and now it is not. The supervisor only leaves after it stops
91
+ // respawning ("Restart budget exceeded"), so the new version cannot be
92
+ // kept alive. Nothing about the remaining budget can change that.
93
+ return { ok: false, observed, reason: "exited" };
94
+ }
95
+ else if (Date.now() - startedAt > appearGraceMs) {
96
+ // Never claimed the lock at all: the restart itself did not take.
97
+ return { ok: false, observed, reason: "never-started" };
98
+ }
52
99
  // `state.running` only means the SUPERVISOR is alive, which is true even while
53
100
  // it crash-loops a worker that dies on boot, and `current` is whatever we just
54
101
  // pointed it at. Neither says the daemon is serving.
@@ -65,7 +112,7 @@ async function waitForDaemonVersion(paseoHome, expected, budgetMs) {
65
112
  }
66
113
  await new Promise((resolve) => setTimeout(resolve, 500));
67
114
  }
68
- return { ok: false, observed };
115
+ return { ok: false, observed, reason: "timeout" };
69
116
  }
70
117
  function single(data) {
71
118
  return { type: "single", data, schema: updateResultSchema };
@@ -137,7 +184,23 @@ async function stageAndVerify(paseoHome, targetVersion, reuseExisting) {
137
184
  };
138
185
  throw error;
139
186
  }
140
- async function restartDaemon(home) {
187
+ /**
188
+ * Listen target the daemon is actually bound to, read from the pid lock before
189
+ * we stop it.
190
+ *
191
+ * Restarting without this silently moves the daemon: `paseo daemon start` does
192
+ * not read `daemon.listen` from config.json, so a child spawned with only
193
+ * `home` binds the built-in default (0.0.0.0:6767) no matter where the daemon
194
+ * it replaced was listening. On a box that already has something on 6767 the
195
+ * new worker dies with EADDRINUSE, burns the supervisor restart budget, and the
196
+ * update reports "the new version did not come up" - pinning a version that was
197
+ * never at fault.
198
+ */
199
+ function resolveBoundListen(paseoHome) {
200
+ const listen = resolveLocalDaemonState({ home: paseoHome }).pidInfo?.listen;
201
+ return typeof listen === "string" && listen.length > 0 ? listen : undefined;
202
+ }
203
+ async function restartDaemon(home, listen) {
141
204
  try {
142
205
  await stopLocalDaemon({ home, timeoutMs: 15000, force: false });
143
206
  }
@@ -146,7 +209,22 @@ async function restartDaemon(home) {
146
209
  // HTTP server didn't close in time" and exits a moment later. Force it.
147
210
  await stopLocalDaemon({ home, timeoutMs: 15000, force: true });
148
211
  }
149
- await startLocalDaemonDetached({ home });
212
+ await startLocalDaemonDetached({ home, ...(listen ? { listen } : {}) });
213
+ }
214
+ /**
215
+ * What to record against the version that failed, in the words of whatever
216
+ * actually went wrong. A pin outlives this command and is read by a human
217
+ * months later deciding whether to `--force` past it, so "it was slow" and
218
+ * "it could not stay alive" must not arrive as the same sentence.
219
+ */
220
+ export function describeFailure(reason, version, budgetMs) {
221
+ if (reason === "exited") {
222
+ return `the daemon could not keep ${version} running (the supervisor stopped respawning it)`;
223
+ }
224
+ if (reason === "never-started") {
225
+ return `the daemon did not restart at all after activating ${version}`;
226
+ }
227
+ return `${version} did not report ready within ${Math.round(budgetMs / 1000)}s`;
150
228
  }
151
229
  /**
152
230
  * Restart onto the freshly-swapped slot and hold it to a deadline. If it does
@@ -157,25 +235,29 @@ async function restartDaemon(home) {
157
235
  * did not.
158
236
  */
159
237
  async function restartWithRollback(paseoHome, home, targetVersion, fallbackVersion, channel, readyBudgetMs) {
160
- await restartDaemon(home);
238
+ // Read once, before the first stop: after that the pid lock is gone and the
239
+ // target the daemon was serving on is unrecoverable.
240
+ const listen = resolveBoundListen(paseoHome);
241
+ await restartDaemon(home, listen);
161
242
  const ready = await waitForDaemonVersion(paseoHome, targetVersion, readyBudgetMs);
162
243
  if (ready.ok)
163
244
  return null;
164
- const { rolledBackTo } = rollback(paseoHome, targetVersion, `daemon did not report ${targetVersion} within ${readyBudgetMs}ms`);
245
+ const failure = describeFailure(ready.reason, targetVersion, readyBudgetMs);
246
+ const { rolledBackTo } = rollback(paseoHome, targetVersion, failure);
165
247
  try {
166
248
  await stopLocalDaemon({ home, timeoutMs: 15000, force: true });
167
249
  }
168
250
  catch {
169
251
  /* already down */
170
252
  }
171
- await startLocalDaemonDetached({ home });
253
+ await startLocalDaemonDetached({ home, ...(listen ? { listen } : {}) });
172
254
  return single({
173
255
  action: "rolled-back",
174
256
  currentVersion: rolledBackTo ?? fallbackVersion,
175
257
  targetVersion,
176
258
  channel,
177
- message: `${targetVersion} did not come up within ${readyBudgetMs}ms. Rolled back to ` +
178
- `${rolledBackTo ?? "the bundled version"} and pinned ${targetVersion}.`,
259
+ message: `Rolled back: ${failure}. Restored ${rolledBackTo ?? "the bundled version"} ` +
260
+ `and pinned ${targetVersion}.`,
179
261
  });
180
262
  }
181
263
  export async function runUpdateCommand(options, _command) {
@@ -185,7 +267,7 @@ export async function runUpdateCommand(options, _command) {
185
267
  const force = options.force === true;
186
268
  const readyBudgetMs = typeof options.readyTimeout === "string" && options.readyTimeout.trim().length > 0
187
269
  ? Math.ceil(Number(options.readyTimeout) * 1000)
188
- : 20000;
270
+ : DEFAULT_READY_BUDGET_MS;
189
271
  const currentVersion = resolveRunningVersion(paseoHome);
190
272
  const state = readUpdateState(paseoHome);
191
273
  const targetVersion = await resolveTargetVersion(paseoHome, channel, options.to);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperdrive.bot/paseo-cli",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "description": "Paseo CLI - control your AI coding agents from the command line",
5
5
  "bin": {
6
6
  "paseo": "bin/paseo"
@@ -27,9 +27,9 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@clack/prompts": "^1.0.0",
30
- "@hyperdrive.bot/paseo-client": "0.3.8",
31
- "@hyperdrive.bot/paseo-protocol": "0.3.8",
32
- "@hyperdrive.bot/paseo-server": "0.3.8",
30
+ "@hyperdrive.bot/paseo-client": "0.3.10",
31
+ "@hyperdrive.bot/paseo-protocol": "0.3.10",
32
+ "@hyperdrive.bot/paseo-server": "0.3.10",
33
33
  "chalk": "^5.3.0",
34
34
  "commander": "^12.0.0",
35
35
  "mime-types": "^2.1.35",