@parall/daemon 1.36.0 → 1.37.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.
Files changed (59) hide show
  1. package/bundle/manifest.json +11 -11
  2. package/bundle/parall-browser-pod.js +384 -32
  3. package/bundle/parall-claude-agent.js +426 -51
  4. package/bundle/parall-codex-agent.js +425 -49
  5. package/bundle/parall-daemon.js +33336 -31520
  6. package/dist/browser-pod.d.ts +7 -0
  7. package/dist/browser-pod.d.ts.map +1 -1
  8. package/dist/browser-pod.js +73 -3
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +2 -2
  11. package/dist/clip-runtime/browser-daemon-env.d.ts +29 -0
  12. package/dist/clip-runtime/browser-daemon-env.d.ts.map +1 -0
  13. package/dist/clip-runtime/browser-daemon-env.js +70 -0
  14. package/dist/clip-runtime/browser-profile-manager.d.ts +34 -5
  15. package/dist/clip-runtime/browser-profile-manager.d.ts.map +1 -1
  16. package/dist/clip-runtime/browser-profile-manager.js +166 -24
  17. package/dist/clip-runtime/browser-profile-pool.d.ts +250 -0
  18. package/dist/clip-runtime/browser-profile-pool.d.ts.map +1 -0
  19. package/dist/clip-runtime/browser-profile-pool.js +581 -0
  20. package/dist/clip-runtime/index.d.ts +2 -1
  21. package/dist/clip-runtime/index.d.ts.map +1 -1
  22. package/dist/clip-runtime/index.js +2 -1
  23. package/dist/clip-runtime/process-manager.d.ts +5 -3
  24. package/dist/clip-runtime/process-manager.d.ts.map +1 -1
  25. package/dist/clip-runtime/subprocess.d.ts +14 -0
  26. package/dist/clip-runtime/subprocess.d.ts.map +1 -1
  27. package/dist/clip-runtime/subprocess.js +49 -0
  28. package/dist/config.d.ts +14 -15
  29. package/dist/config.d.ts.map +1 -1
  30. package/dist/config.js +8 -26
  31. package/dist/daemon-main.d.ts +8 -0
  32. package/dist/daemon-main.d.ts.map +1 -0
  33. package/dist/daemon-main.js +165 -0
  34. package/dist/daemon-paths.d.ts +14 -0
  35. package/dist/daemon-paths.d.ts.map +1 -0
  36. package/dist/daemon-paths.js +26 -0
  37. package/dist/daemon-update-mode.d.ts +8 -0
  38. package/dist/daemon-update-mode.d.ts.map +1 -0
  39. package/dist/daemon-update-mode.js +18 -0
  40. package/dist/index.js +41 -167
  41. package/dist/runtime-bin-resolver.d.ts +4 -0
  42. package/dist/runtime-bin-resolver.d.ts.map +1 -1
  43. package/dist/runtime-bin-resolver.js +40 -7
  44. package/dist/runtime-detector.d.ts +30 -0
  45. package/dist/runtime-detector.d.ts.map +1 -0
  46. package/dist/runtime-detector.js +100 -0
  47. package/dist/supervisor.d.ts +64 -2
  48. package/dist/supervisor.d.ts.map +1 -1
  49. package/dist/supervisor.js +629 -67
  50. package/dist/update-health-gate.d.ts +66 -0
  51. package/dist/update-health-gate.d.ts.map +1 -0
  52. package/dist/update-health-gate.js +93 -0
  53. package/dist/updater-manifest.d.ts +2 -1
  54. package/dist/updater-manifest.d.ts.map +1 -1
  55. package/dist/updater-manifest.js +38 -7
  56. package/dist/updater.d.ts +13 -2
  57. package/dist/updater.d.ts.map +1 -1
  58. package/dist/updater.js +126 -17
  59. package/package.json +6 -6
@@ -0,0 +1,66 @@
1
+ import type { GatewayLogger } from '@parall/agent-core';
2
+ import type { DaemonUpdater } from './updater.js';
3
+ type TimerHandle = {
4
+ unref?: () => void;
5
+ };
6
+ type TimerApi = {
7
+ setTimeout(callback: () => void, ms: number): TimerHandle;
8
+ clearTimeout(handle: TimerHandle): void;
9
+ };
10
+ /**
11
+ * Canary gate for a freshly self-updated daemon. Centralizes the "is the new
12
+ * version healthy enough to confirm?" policy in ONE place so it isn't smeared
13
+ * across the supervisor's WS handlers.
14
+ *
15
+ * A pending update is confirmed only when BOTH hold:
16
+ * - the control-plane handshake (machine.hello) is seen at least once, AND
17
+ * - the process + supervisor then stay up for `stabilityWindowMs` without the
18
+ * supervisor soft-crashing or the process exiting.
19
+ *
20
+ * Lifecycle:
21
+ * - `onMachineHello()` — the first hello with a pending update arms the
22
+ * stability timer. The timer is process-level (lives in this gate, which the
23
+ * process owns), so it survives WS reconnects; a disconnect does NOT cancel
24
+ * it.
25
+ * - timer elapses with the process still alive → `confirmVersion()`. (A
26
+ * process exit/crash before the timer means it simply never fires.)
27
+ * - `onSupervisorEnded()` — the supervisor instance stopped (clean shutdown OR
28
+ * a runForever-caught soft crash). Cancel the timer so a stale timer can't
29
+ * confirm across supervisor instances; the next supervisor's machine.hello
30
+ * re-arms it. This RESETS the health window but NEVER records a rollback
31
+ * failure — soft crashes are not rollback signals. Rollback `boot_count` is
32
+ * incremented only by process-level unclean exits (crash / kill / power loss
33
+ * / uncaught fatal exit), tracked via the `daemon-running` marker in
34
+ * `index.ts`, not here. A pure in-process soft-crash loop is therefore left
35
+ * to runForever backoff + observability, by design.
36
+ *
37
+ * The updater is unaware of all this: it only exposes `hasPendingActivation()` +
38
+ * `confirmVersion()` and never learns what a "supervisor soft crash" is. Agent
39
+ * child / clip / browser workload failures never reach this gate and never count
40
+ * as rollback failures.
41
+ */
42
+ export declare class PendingUpdateHealthGate {
43
+ private readonly updater;
44
+ private readonly stabilityWindowMs;
45
+ private readonly log;
46
+ private readonly timers;
47
+ private timer;
48
+ private confirmed;
49
+ constructor(updater: DaemonUpdater, stabilityWindowMs: number, log: GatewayLogger, timers?: TimerApi);
50
+ /**
51
+ * Control-plane handshake. Arms the stability timer on the first hello that
52
+ * sees a pending (unconfirmed) update. No-op once confirmed, while a timer is
53
+ * already running, or when there is nothing pending to confirm.
54
+ */
55
+ onMachineHello(): void;
56
+ /**
57
+ * The current supervisor instance ended — a clean shutdown or a
58
+ * runForever-caught soft crash. Reset the health window (cancel the pending
59
+ * confirm timer) without recording any rollback failure; the next supervisor
60
+ * re-arms via `onMachineHello`.
61
+ */
62
+ onSupervisorEnded(): void;
63
+ private confirm;
64
+ }
65
+ export {};
66
+ //# sourceMappingURL=update-health-gate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"update-health-gate.d.ts","sourceRoot":"","sources":["../src/update-health-gate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACxD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAElD,KAAK,WAAW,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,IAAI,CAAA;CAAE,CAAC;AAE1C,KAAK,QAAQ,GAAG;IACd,UAAU,CAAC,QAAQ,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,WAAW,CAAC;IAC1D,YAAY,CAAC,MAAM,EAAE,WAAW,GAAG,IAAI,CAAC;CACzC,CAAC;AAOF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,qBAAa,uBAAuB;IAKhC,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,iBAAiB;IAClC,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,MAAM;IAPzB,OAAO,CAAC,KAAK,CAA4B;IACzC,OAAO,CAAC,SAAS,CAAS;gBAGP,OAAO,EAAE,aAAa,EACtB,iBAAiB,EAAE,MAAM,EACzB,GAAG,EAAE,aAAa,EAClB,MAAM,GAAE,QAAqB;IAGhD;;;;OAIG;IACH,cAAc,IAAI,IAAI;IActB;;;;;OAKG;IACH,iBAAiB,IAAI,IAAI;IAOzB,OAAO,CAAC,OAAO;CAShB"}
@@ -0,0 +1,93 @@
1
+ const realTimers = {
2
+ setTimeout: (callback, ms) => setTimeout(callback, ms),
3
+ clearTimeout: (handle) => clearTimeout(handle),
4
+ };
5
+ /**
6
+ * Canary gate for a freshly self-updated daemon. Centralizes the "is the new
7
+ * version healthy enough to confirm?" policy in ONE place so it isn't smeared
8
+ * across the supervisor's WS handlers.
9
+ *
10
+ * A pending update is confirmed only when BOTH hold:
11
+ * - the control-plane handshake (machine.hello) is seen at least once, AND
12
+ * - the process + supervisor then stay up for `stabilityWindowMs` without the
13
+ * supervisor soft-crashing or the process exiting.
14
+ *
15
+ * Lifecycle:
16
+ * - `onMachineHello()` — the first hello with a pending update arms the
17
+ * stability timer. The timer is process-level (lives in this gate, which the
18
+ * process owns), so it survives WS reconnects; a disconnect does NOT cancel
19
+ * it.
20
+ * - timer elapses with the process still alive → `confirmVersion()`. (A
21
+ * process exit/crash before the timer means it simply never fires.)
22
+ * - `onSupervisorEnded()` — the supervisor instance stopped (clean shutdown OR
23
+ * a runForever-caught soft crash). Cancel the timer so a stale timer can't
24
+ * confirm across supervisor instances; the next supervisor's machine.hello
25
+ * re-arms it. This RESETS the health window but NEVER records a rollback
26
+ * failure — soft crashes are not rollback signals. Rollback `boot_count` is
27
+ * incremented only by process-level unclean exits (crash / kill / power loss
28
+ * / uncaught fatal exit), tracked via the `daemon-running` marker in
29
+ * `index.ts`, not here. A pure in-process soft-crash loop is therefore left
30
+ * to runForever backoff + observability, by design.
31
+ *
32
+ * The updater is unaware of all this: it only exposes `hasPendingActivation()` +
33
+ * `confirmVersion()` and never learns what a "supervisor soft crash" is. Agent
34
+ * child / clip / browser workload failures never reach this gate and never count
35
+ * as rollback failures.
36
+ */
37
+ export class PendingUpdateHealthGate {
38
+ updater;
39
+ stabilityWindowMs;
40
+ log;
41
+ timers;
42
+ timer = null;
43
+ confirmed = false;
44
+ constructor(updater, stabilityWindowMs, log, timers = realTimers) {
45
+ this.updater = updater;
46
+ this.stabilityWindowMs = stabilityWindowMs;
47
+ this.log = log;
48
+ this.timers = timers;
49
+ }
50
+ /**
51
+ * Control-plane handshake. Arms the stability timer on the first hello that
52
+ * sees a pending (unconfirmed) update. No-op once confirmed, while a timer is
53
+ * already running, or when there is nothing pending to confirm.
54
+ */
55
+ onMachineHello() {
56
+ if (this.confirmed || this.timer)
57
+ return;
58
+ if (!this.updater.hasPendingActivation())
59
+ return;
60
+ if (this.stabilityWindowMs <= 0) {
61
+ this.confirm();
62
+ return;
63
+ }
64
+ this.timer = this.timers.setTimeout(() => {
65
+ this.timer = null;
66
+ this.confirm();
67
+ }, this.stabilityWindowMs);
68
+ this.timer.unref?.();
69
+ }
70
+ /**
71
+ * The current supervisor instance ended — a clean shutdown or a
72
+ * runForever-caught soft crash. Reset the health window (cancel the pending
73
+ * confirm timer) without recording any rollback failure; the next supervisor
74
+ * re-arms via `onMachineHello`.
75
+ */
76
+ onSupervisorEnded() {
77
+ if (this.timer) {
78
+ this.timers.clearTimeout(this.timer);
79
+ this.timer = null;
80
+ }
81
+ }
82
+ confirm() {
83
+ if (this.confirmed)
84
+ return;
85
+ try {
86
+ this.updater.confirmVersion();
87
+ this.confirmed = true;
88
+ }
89
+ catch (err) {
90
+ this.log.warn(`update: confirmVersion failed: ${String(err)}`);
91
+ }
92
+ }
93
+ }
@@ -35,7 +35,8 @@ export declare function verifyManifestSignature(manifest: RemoteManifest, public
35
35
  *
36
36
  * Prerelease ordering follows SemVer 2.0: a version with prerelease has
37
37
  * lower precedence than the same version without prerelease. Prerelease
38
- * identifiers are compared lexicographically when both present.
38
+ * identifiers are compared per SemVer §11.4 when both are present: dot
39
+ * separated, numeric identifiers numerically, non-numeric lexically.
39
40
  */
40
41
  export declare function semverCompare(a: string, b: string): number | null;
41
42
  //# sourceMappingURL=updater-manifest.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"updater-manifest.d.ts","sourceRoot":"","sources":["../src/updater-manifest.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IACpC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;CACrC;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAUlD;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAQ5F;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAiBjE"}
1
+ {"version":3,"file":"updater-manifest.d.ts","sourceRoot":"","sources":["../src/updater-manifest.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IACpC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;CACrC;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAUlD;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAQ5F;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAejE"}
@@ -38,7 +38,8 @@ export function verifyManifestSignature(manifest, publicKey) {
38
38
  *
39
39
  * Prerelease ordering follows SemVer 2.0: a version with prerelease has
40
40
  * lower precedence than the same version without prerelease. Prerelease
41
- * identifiers are compared lexicographically when both present.
41
+ * identifiers are compared per SemVer §11.4 when both are present: dot
42
+ * separated, numeric identifiers numerically, non-numeric lexically.
42
43
  */
43
44
  export function semverCompare(a, b) {
44
45
  const pa = parseSemver(a);
@@ -59,12 +60,8 @@ export function semverCompare(a, b) {
59
60
  return 1;
60
61
  if (!pb.pre)
61
62
  return -1;
62
- // Both have prerelease: lexicographic comparison
63
- if (pa.pre < pb.pre)
64
- return -1;
65
- if (pa.pre > pb.pre)
66
- return 1;
67
- return 0;
63
+ // Both have prerelease: compare dot-separated identifiers per SemVer §11.4.
64
+ return comparePrerelease(pa.pre, pb.pre);
68
65
  }
69
66
  function parseSemver(v) {
70
67
  // Split off prerelease at the first hyphen in the patch component.
@@ -92,3 +89,37 @@ function parseSemver(v) {
92
89
  return null;
93
90
  return { nums: [major, minor, patch], pre };
94
91
  }
92
+ /**
93
+ * Compare two prerelease strings per SemVer 2.0 §11.4: dot-separated identifiers
94
+ * left-to-right — numeric identifiers compared numerically, alphanumeric ones by
95
+ * ASCII order, a numeric identifier always lower precedence than an alphanumeric
96
+ * one, and (all prior identifiers equal) the longer set outranks the shorter.
97
+ */
98
+ function comparePrerelease(a, b) {
99
+ const aIds = a.split('.');
100
+ const bIds = b.split('.');
101
+ const len = Math.min(aIds.length, bIds.length);
102
+ for (let i = 0; i < len; i++) {
103
+ const ai = aIds[i];
104
+ const bi = bIds[i];
105
+ const aNum = /^\d+$/.test(ai);
106
+ const bNum = /^\d+$/.test(bi);
107
+ if (aNum && bNum) {
108
+ const diff = Number(ai) - Number(bi);
109
+ if (diff !== 0)
110
+ return diff < 0 ? -1 : 1;
111
+ }
112
+ else if (aNum) {
113
+ return -1; // numeric identifiers have lower precedence than alphanumeric
114
+ }
115
+ else if (bNum) {
116
+ return 1;
117
+ }
118
+ else if (ai !== bi) {
119
+ return ai < bi ? -1 : 1;
120
+ }
121
+ }
122
+ if (aIds.length === bIds.length)
123
+ return 0;
124
+ return aIds.length < bIds.length ? -1 : 1;
125
+ }
package/dist/updater.d.ts CHANGED
@@ -24,10 +24,12 @@ export declare class DaemonUpdater {
24
24
  triggerUpdate(targetVersion: string, mandatory: boolean): Promise<boolean>;
25
25
  /**
26
26
  * Check if we need to roll back from a failed update.
27
- * Call on every daemon startup, before bootstrap.
27
+ * Call on every daemon startup, before bootstrap. `uncleanPrevExit` is true
28
+ * when the previous run did not exit cleanly (crash / kill / power loss) — the
29
+ * only signal that counts toward the rollback threshold.
28
30
  * Returns true if rollback was performed (caller should exit immediately).
29
31
  */
30
- checkRollback(): boolean;
32
+ checkRollback(uncleanPrevExit: boolean): boolean;
31
33
  /**
32
34
  * Confirm the current version after successful health check.
33
35
  * Clears pending state so rollback won't trigger.
@@ -37,6 +39,12 @@ export declare class DaemonUpdater {
37
39
  * Get the current local manifest version (for heartbeat reporting).
38
40
  */
39
41
  getLocalVersion(): string | undefined;
42
+ /**
43
+ * Whether an update has been applied but not yet confirmed healthy. The health
44
+ * gate only arms its stability timer when this is true — a daemon already
45
+ * running a confirmed version needs no canary.
46
+ */
47
+ hasPendingActivation(): boolean;
40
48
  /**
41
49
  * Check CDN for available update without downloading.
42
50
  * Returns remote version info for CLI --check display.
@@ -53,6 +61,9 @@ export declare class DaemonUpdater {
53
61
  loadLocalManifest(): LocalManifest | null;
54
62
  private loadUpdateState;
55
63
  private saveUpdateState;
64
+ private fsyncDir;
65
+ private isRedirect;
66
+ private resolveRedirectUrl;
56
67
  private httpGet;
57
68
  private downloadFile;
58
69
  private cleanDir;
@@ -1 +1 @@
1
- {"version":3,"file":"updater.d.ts","sourceRoot":"","sources":["../src/updater.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACxD,OAAO,EAEL,KAAK,aAAa,EAGnB,MAAM,uBAAuB,CAAC;AAsB/B,qBAAa,aAAa;IAKtB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,cAAc;IAPjC,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,aAAa,CAA+B;gBAGjC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,aAAa,EAClB,cAAc,EAAE,OAAO;IAK1C;;OAEG;IACH,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAU5C,iBAAiB,IAAI,IAAI;IAOzB;;;OAGG;IACG,aAAa,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAa7D;;OAEG;IACG,aAAa,CAAC,aAAa,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IAQhF;;;;OAIG;IACH,aAAa,IAAI,OAAO;IA6CxB;;;OAGG;IACH,cAAc,IAAI,IAAI;IAYtB;;OAEG;IACH,eAAe,IAAI,MAAM,GAAG,SAAS;IAIrC;;;OAGG;IACG,cAAc,IAAI,OAAO,CAAC;QAC9B,SAAS,EAAE,OAAO,CAAC;QACnB,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,aAAa,CAAC,EAAE,MAAM,CAAC;KACxB,CAAC;YAuBY,QAAQ;IA4HtB,OAAO,CAAC,UAAU;IAmClB,OAAO,CAAC,WAAW;IAUnB,OAAO,CAAC,gBAAgB;IAmBxB,iBAAiB,IAAI,aAAa,GAAG,IAAI;IAUzC,OAAO,CAAC,eAAe;IASvB,OAAO,CAAC,eAAe;IAQvB,OAAO,CAAC,OAAO;IA8Bf,OAAO,CAAC,YAAY;IAoCpB,OAAO,CAAC,QAAQ;CAKjB"}
1
+ {"version":3,"file":"updater.d.ts","sourceRoot":"","sources":["../src/updater.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACxD,OAAO,EAEL,KAAK,aAAa,EAGnB,MAAM,uBAAuB,CAAC;AA2B/B,qBAAa,aAAa;IAKtB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,cAAc;IAPjC,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,aAAa,CAA+B;gBAGjC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,aAAa,EAClB,cAAc,EAAE,OAAO;IAK1C;;OAEG;IACH,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAU5C,iBAAiB,IAAI,IAAI;IAOzB;;;OAGG;IACG,aAAa,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAa7D;;OAEG;IACG,aAAa,CAAC,aAAa,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IAQhF;;;;;;OAMG;IACH,aAAa,CAAC,eAAe,EAAE,OAAO,GAAG,OAAO;IAqDhD;;;OAGG;IACH,cAAc,IAAI,IAAI;IAYtB;;OAEG;IACH,eAAe,IAAI,MAAM,GAAG,SAAS;IAIrC;;;;OAIG;IACH,oBAAoB,IAAI,OAAO;IAM/B;;;OAGG;IACG,cAAc,IAAI,OAAO,CAAC;QAC9B,SAAS,EAAE,OAAO,CAAC;QACnB,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,aAAa,CAAC,EAAE,MAAM,CAAC;KACxB,CAAC;YAuBY,QAAQ;IAmJtB,OAAO,CAAC,UAAU;IAmClB,OAAO,CAAC,WAAW;IAUnB,OAAO,CAAC,gBAAgB;IAmBxB,iBAAiB,IAAI,aAAa,GAAG,IAAI;IAUzC,OAAO,CAAC,eAAe;IASvB,OAAO,CAAC,eAAe;IA+BvB,OAAO,CAAC,QAAQ;IAYhB,OAAO,CAAC,UAAU;IAIlB,OAAO,CAAC,kBAAkB;IAI1B,OAAO,CAAC,OAAO;IAuCf,OAAO,CAAC,YAAY;IA6CpB,OAAO,CAAC,QAAQ;CAKjB"}
package/dist/updater.js CHANGED
@@ -5,6 +5,10 @@ import * as http from 'node:http';
5
5
  import { createHash } from 'node:crypto';
6
6
  import { verifyManifestSignature, semverCompare, } from './updater-manifest.js';
7
7
  const UPDATE_EXIT_CODE = 42;
8
+ // Number of UNCLEAN boots (crash / kill / power loss) a pending update may
9
+ // accumulate before the daemon reverts to the previous version. Clean restarts
10
+ // and offline-but-healthy restarts don't count — see checkRollback().
11
+ const ROLLBACK_BOOT_THRESHOLD = 3;
8
12
  // Ed25519 public key for verifying daemon bundle manifests.
9
13
  // The corresponding private key lives in GitHub Actions secrets (DAEMON_BUNDLE_SIGNING_KEY).
10
14
  const DEFAULT_SIGNING_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
@@ -75,19 +79,29 @@ export class DaemonUpdater {
75
79
  }
76
80
  /**
77
81
  * Check if we need to roll back from a failed update.
78
- * Call on every daemon startup, before bootstrap.
82
+ * Call on every daemon startup, before bootstrap. `uncleanPrevExit` is true
83
+ * when the previous run did not exit cleanly (crash / kill / power loss) — the
84
+ * only signal that counts toward the rollback threshold.
79
85
  * Returns true if rollback was performed (caller should exit immediately).
80
86
  */
81
- checkRollback() {
87
+ checkRollback(uncleanPrevExit) {
82
88
  const state = this.loadUpdateState();
83
89
  const current = this.loadLocalManifest();
84
90
  if (!state || !current)
85
91
  return false;
86
92
  if (!state.pending_version || state.pending_version !== current.version)
87
93
  return false;
88
- state.boot_count = (state.boot_count ?? 0) + 1;
89
- this.saveUpdateState(state);
90
- if (state.boot_count < 3)
94
+ // Count this boot toward the rollback threshold ONLY if the previous run
95
+ // ended abnormally (crash / kill / power loss — index.ts left the running
96
+ // marker behind). A clean shutdown or an offline-but-healthy restart must
97
+ // not push a working version toward rollback: a healthy version that simply
98
+ // can't reach the server (so confirmVersion never fires) would otherwise be
99
+ // reverted after a few normal reboots (the offline-churn bug).
100
+ if (uncleanPrevExit) {
101
+ state.boot_count = (state.boot_count ?? 0) + 1;
102
+ this.saveUpdateState(state);
103
+ }
104
+ if ((state.boot_count ?? 0) < ROLLBACK_BOOT_THRESHOLD)
91
105
  return false;
92
106
  if (!state.previous_version)
93
107
  return false;
@@ -141,6 +155,16 @@ export class DaemonUpdater {
141
155
  getLocalVersion() {
142
156
  return this.loadLocalManifest()?.version;
143
157
  }
158
+ /**
159
+ * Whether an update has been applied but not yet confirmed healthy. The health
160
+ * gate only arms its stability timer when this is true — a daemon already
161
+ * running a confirmed version needs no canary.
162
+ */
163
+ hasPendingActivation() {
164
+ const state = this.loadUpdateState();
165
+ const current = this.loadLocalManifest();
166
+ return !!state?.pending_version && !!current && state.pending_version === current.version;
167
+ }
144
168
  /**
145
169
  * Check CDN for available update without downloading.
146
170
  * Returns remote version info for CLI --check display.
@@ -169,6 +193,15 @@ export class DaemonUpdater {
169
193
  // --- Internal ---
170
194
  async doUpdate(targetVersion) {
171
195
  const local = this.loadLocalManifest();
196
+ const state = this.loadUpdateState();
197
+ // Keep the canary state machine single-flight: while the currently running
198
+ // bundle is still pending health confirmation, do not stack another update
199
+ // on top of it. Rollback must always target the last confirmed version, not
200
+ // an unconfirmed intermediate bundle.
201
+ if (state?.pending_version && local?.version === state.pending_version) {
202
+ this.log.info(`update: ${local.version} is still pending activation — skipping new update until confirmed`);
203
+ return false;
204
+ }
172
205
  const manifestUrl = targetVersion
173
206
  ? `${this.cdnBaseUrl}/${targetVersion}/manifest.json`
174
207
  : `${this.cdnBaseUrl}/latest/manifest.json`;
@@ -195,7 +228,6 @@ export class DaemonUpdater {
195
228
  return false;
196
229
  }
197
230
  // Skip versions we already rolled back from
198
- const state = this.loadUpdateState();
199
231
  if (state?.rollback_from === remote.version) {
200
232
  this.log.info(`update: skipping ${remote.version} (previously rolled back)`);
201
233
  return false;
@@ -255,17 +287,29 @@ export class DaemonUpdater {
255
287
  }
256
288
  // Write manifest to staging
257
289
  fs.writeFileSync(path.join(stagingDir, 'manifest.json'), JSON.stringify(remote, null, 2));
258
- // Atomic swap
259
- this.atomicSwap(stagingDir, remote.version);
260
- // Record pending update state
290
+ // Record the pending update state BEFORE swapping `current`. The state
291
+ // write and the swap are two separate filesystem operations, not one atomic
292
+ // unit, so a crash can land between them — order them so either landing is
293
+ // safe (crash-consistency):
294
+ // - crash after saveUpdateState, before swap → `current` still points at
295
+ // the OLD version while state names the new one as pending. On next boot
296
+ // checkRollback() sees pending_version !== current.version and does
297
+ // nothing; a later check simply re-attempts the update.
298
+ // - crash after swap → `current` is the NEW version AND state already has
299
+ // the matching pending_version, so rollback protection is intact.
300
+ // The reverse order (swap first) could leave `current` on the new version
301
+ // with no matching pending_version, silently voiding rollback.
302
+ const rollbackVersion = state?.confirmed_version ?? local?.version;
261
303
  const newState = {
262
- confirmed_version: local?.version ?? state?.confirmed_version,
263
- previous_version: local?.version,
304
+ confirmed_version: rollbackVersion,
305
+ previous_version: rollbackVersion,
264
306
  pending_version: remote.version,
265
307
  pending_at: new Date().toISOString(),
266
308
  boot_count: 0,
267
309
  };
268
310
  this.saveUpdateState(newState);
311
+ // Atomic swap — only after the pending state is durably recorded.
312
+ this.atomicSwap(stagingDir, remote.version);
269
313
  this.log.info(`update: ${local?.version ?? 'unknown'} → ${remote.version} applied, restarting`);
270
314
  return true;
271
315
  }
@@ -350,17 +394,72 @@ export class DaemonUpdater {
350
394
  }
351
395
  saveUpdateState(state) {
352
396
  const statePath = path.join(this.bundleDir, 'update-state.json');
353
- fs.mkdirSync(path.dirname(statePath), { recursive: true });
354
- fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
397
+ const stateDir = path.dirname(statePath);
398
+ fs.mkdirSync(stateDir, { recursive: true });
399
+ // Atomic write: stage to a unique temp file in the same directory, then
400
+ // rename(2) over the target (atomic on POSIX). A crash mid-write can never
401
+ // leave a truncated update-state.json — a reader sees either the old or the
402
+ // new content, never a half-written mix.
403
+ const tmpPath = `${statePath}.tmp-${process.pid}-${Date.now()}`;
404
+ let fd;
405
+ try {
406
+ fd = fs.openSync(tmpPath, 'w');
407
+ fs.writeFileSync(fd, JSON.stringify(state, null, 2));
408
+ fs.fsyncSync(fd);
409
+ fs.closeSync(fd);
410
+ fd = undefined;
411
+ fs.renameSync(tmpPath, statePath);
412
+ this.fsyncDir(stateDir);
413
+ }
414
+ catch (err) {
415
+ if (fd !== undefined) {
416
+ try {
417
+ fs.closeSync(fd);
418
+ }
419
+ catch { }
420
+ }
421
+ try {
422
+ fs.unlinkSync(tmpPath);
423
+ }
424
+ catch { }
425
+ throw err;
426
+ }
427
+ }
428
+ fsyncDir(dir) {
429
+ if (process.platform === 'win32')
430
+ return;
431
+ const fd = fs.openSync(dir, 'r');
432
+ try {
433
+ fs.fsyncSync(fd);
434
+ }
435
+ finally {
436
+ fs.closeSync(fd);
437
+ }
355
438
  }
356
439
  // --- HTTP helpers ---
440
+ isRedirect(statusCode) {
441
+ return statusCode === 301 || statusCode === 302 || statusCode === 307 || statusCode === 308;
442
+ }
443
+ resolveRedirectUrl(location, fromUrl) {
444
+ return new URL(location, fromUrl).toString();
445
+ }
357
446
  httpGet(url, maxRedirects = 5) {
358
447
  return new Promise((resolve, reject) => {
359
448
  const mod = url.startsWith('https') ? https : http;
360
449
  const req = mod.get(url, (res) => {
361
- if (res.statusCode === 301 || res.statusCode === 302) {
450
+ if (this.isRedirect(res.statusCode)) {
362
451
  if (res.headers.location && maxRedirects > 0) {
363
- this.httpGet(res.headers.location, maxRedirects - 1).then(resolve, reject);
452
+ let redirectUrl;
453
+ try {
454
+ redirectUrl = this.resolveRedirectUrl(res.headers.location, url);
455
+ }
456
+ catch (err) {
457
+ res.resume();
458
+ reject(new Error(`invalid redirect location for ${url}: ${String(err)}`));
459
+ return;
460
+ }
461
+ res.resume();
462
+ this.httpGet(redirectUrl, maxRedirects - 1).then(resolve, reject);
364
463
  return;
365
464
  }
366
465
  res.resume();
@@ -387,9 +486,19 @@ export class DaemonUpdater {
387
486
  return new Promise((resolve, reject) => {
388
487
  const mod = url.startsWith('https') ? https : http;
389
488
  const req = mod.get(url, (res) => {
390
- if (res.statusCode === 301 || res.statusCode === 302) {
489
+ if (this.isRedirect(res.statusCode)) {
391
490
  if (res.headers.location && maxRedirects > 0) {
392
- this.downloadFile(res.headers.location, dest, maxRedirects - 1).then(resolve, reject);
491
+ let redirectUrl;
492
+ try {
493
+ redirectUrl = this.resolveRedirectUrl(res.headers.location, url);
494
+ }
495
+ catch (err) {
496
+ res.resume();
497
+ reject(new Error(`invalid redirect location for ${url}: ${String(err)}`));
498
+ return;
499
+ }
500
+ res.resume();
501
+ this.downloadFile(redirectUrl, dest, maxRedirects - 1).then(resolve, reject);
393
502
  return;
394
503
  }
395
504
  res.resume();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/daemon",
3
- "version": "1.36.0",
3
+ "version": "1.37.0",
4
4
  "description": "Parall local agent runtime — daemon supervisor + bridge runtimes, bundled as standalone JS files",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,11 +32,11 @@
32
32
  "dependencies": {
33
33
  "@aws-sdk/client-s3": "3.984.0",
34
34
  "@pinixai/bb-browser-pro": "0.15.0",
35
- "@parall/sdk": "1.36.0",
36
- "@parall/claude-agent": "1.36.0",
37
- "@parall/openclaw-agent": "1.36.0",
38
- "@parall/agent-core": "1.36.0",
39
- "@parall/codex-agent": "1.36.0"
35
+ "@parall/agent-core": "1.37.0",
36
+ "@parall/sdk": "1.37.0",
37
+ "@parall/claude-agent": "1.37.0",
38
+ "@parall/codex-agent": "1.37.0",
39
+ "@parall/openclaw-agent": "1.37.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/node": "^22.0.0",