@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>", "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
178
|
-
|
|
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
|
-
:
|
|
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.
|
|
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.
|
|
31
|
-
"@hyperdrive.bot/paseo-protocol": "0.3.
|
|
32
|
-
"@hyperdrive.bot/paseo-server": "0.3.
|
|
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",
|