@cydm/happy-elves 0.1.0-beta.501 → 0.1.0-beta.502

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/README.md CHANGED
@@ -6,8 +6,8 @@ Public beta CLI for Happy Elves.
6
6
 
7
7
  Run once in the existing user account; it detects %USERPROFILE%\.happy-elves\hosted, keeps the current daemon and sessions running, installs the independent updater, and registers per-user startup.
8
8
 
9
- npm.cmd install --prefix $HOME\.happy-elves\management-entry --ignore-scripts --no-audit --no-fund --package-lock=false @cydm/happy-elves@0.1.0-beta.501
10
- npm.cmd exec --yes --package @cydm/happy-elves@0.1.0-beta.501 -- happy-elves-updater install --state-root $HOME\.happy-elves\hosted --autostart
9
+ npm.cmd install --prefix $HOME\.happy-elves\management-entry --ignore-scripts --no-audit --no-fund --package-lock=false @cydm/happy-elves@0.1.0-beta.502
10
+ npm.cmd exec --yes --package @cydm/happy-elves@0.1.0-beta.502 -- happy-elves-updater install --state-root $HOME\.happy-elves\hosted --autostart
11
11
 
12
12
  The same updater entrypoint works on macOS after selecting a permanent user prefix.
13
13
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-cli",
3
- "version": "0.1.0-beta.501",
3
+ "version": "0.1.0-beta.502",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -22,7 +22,6 @@ export declare function catchUpSubscriptionEvents(input: {
22
22
  config: DaemonConfig;
23
23
  subscription: OrchestrationSubscriptionRecord;
24
24
  }): Promise<SubscriptionCatchUpResult>;
25
- export declare function remoteEventCursorForSubscription(crewMachineId: string, subscription: Pick<OrchestrationSubscriptionRecord, "remoteEventCursor" | "remoteEventSeq">): string | undefined;
26
25
  export declare function attentionFromReconcile(result: TurnReconcileResult): {
27
26
  eventType: TaskAttentionEventType;
28
27
  reason: string;
@@ -6,11 +6,7 @@ import { activeSubscriptionsForTurn, appendAttention, closeSubscriptionForTurn,
6
6
  import { scheduleRemoteOrchestrationWork } from "./remote-work-budget.js";
7
7
  import { relayMachineRegistry } from "../state.js";
8
8
  const remoteCatchupsInFlight = new Map();
9
- const legacyRemoteCursorFallbackUntil = new Map();
10
- const remoteForwardCursorConfirmed = new Set();
11
- const legacyRemoteCursorFallbackMs = 5 * 60 * 1000;
12
9
  const initialRemoteEventCursor = "event_000000000000";
13
- const forwardEventCursorSinceBeta = 495;
14
10
  export async function observeSessionEvent(input) {
15
11
  const attention = attentionFromEvent(input.payload);
16
12
  if (!attention) {
@@ -134,8 +130,10 @@ async function catchUpSubscriptionEventsInternal(input) {
134
130
  const remoteCursor = crewMachineId !== input.config.machineId
135
131
  ? remoteEventCursorForSubscription(crewMachineId, input.subscription)
136
132
  : undefined;
137
- const remoteCursorEnabled = remoteCursor !== undefined;
138
133
  try {
134
+ if (crewMachineId !== input.config.machineId && remotePeerForwardCursorSupport(crewMachineId) !== true) {
135
+ throw new RemoteCommandError(`Remote Crew ${crewMachineId} does not advertise the required forward event cursor protocol`, "REMOTE_FORWARD_CURSOR_UNSUPPORTED", false);
136
+ }
139
137
  const result = crewMachineId === input.config.machineId
140
138
  ? await runtimeRead(input.config, {
141
139
  requestId: randomId("orch_catchup_events"),
@@ -158,8 +156,7 @@ async function catchUpSubscriptionEventsInternal(input) {
158
156
  action: "turn.eventsPage",
159
157
  sessionId: input.subscription.crewSessionId,
160
158
  turnId: input.subscription.turnId,
161
- ...(remoteCursor ? { after: remoteCursor } : {}),
162
- ...(input.subscription.remoteEventItemId && !remoteCursor ? { afterItemId: input.subscription.remoteEventItemId } : {}),
159
+ after: remoteCursor,
163
160
  ...(input.subscription.runtimeTurnId ?? input.subscription.providerTurnId
164
161
  ? { runtimeTurnId: input.subscription.runtimeTurnId ?? input.subscription.providerTurnId }
165
162
  : {}),
@@ -181,19 +178,8 @@ async function catchUpSubscriptionEventsInternal(input) {
181
178
  });
182
179
  return { status: "unreachable" };
183
180
  }
184
- // A rolling-upgrade peer may accept the old runtimeRead command but omit
185
- // the forward cursor in its response. Treat that as the legacy path for
186
- // this machine; in particular, never advance a durable cursor from a
187
- // page whose peer cannot prove append-only progress.
188
- if (crewMachineId !== input.config.machineId && result.cursorSupported === true) {
189
- remoteForwardCursorConfirmed.add(crewMachineId);
190
- }
191
- else if (crewMachineId !== input.config.machineId && remoteCursorEnabled && !result.nextAfter) {
192
- // A peer that accepted the forward request but cannot prove the cursor
193
- // contract is no longer trusted. Remove the positive handshake so the
194
- // next safe, legacy-compatible request can renegotiate it.
195
- remoteForwardCursorConfirmed.delete(crewMachineId);
196
- legacyRemoteCursorFallbackUntil.set(crewMachineId, Date.now() + legacyRemoteCursorFallbackMs);
181
+ if (crewMachineId !== input.config.machineId && result.cursorSupported !== true) {
182
+ throw new RemoteCommandError(`Remote Crew ${crewMachineId} returned a response without forward cursor support`, "REMOTE_FORWARD_CURSOR_UNSUPPORTED", false);
197
183
  }
198
184
  const observed = await observeSubscriptionRuntimeEvents({
199
185
  config: input.config,
@@ -203,7 +189,7 @@ async function catchUpSubscriptionEventsInternal(input) {
203
189
  skipThroughSeq: crewMachineId === input.config.machineId ? 0 : input.subscription.remoteEventSeq ?? 0,
204
190
  });
205
191
  if (crewMachineId !== input.config.machineId) {
206
- const canAdvance = Boolean(result.nextAfter);
192
+ const canAdvance = result.cursorSupported === true;
207
193
  await markSubscriptionRemoteScan(input.subscription.taskId, {
208
194
  crewMachineId,
209
195
  crewSessionId: input.subscription.crewSessionId,
@@ -221,86 +207,32 @@ async function catchUpSubscriptionEventsInternal(input) {
221
207
  };
222
208
  }
223
209
  catch (error) {
224
- if (crewMachineId !== input.config.machineId && remoteCursorEnabled && isRemoteEventCursorCompatibilityFailure(error)) {
225
- remoteForwardCursorConfirmed.delete(crewMachineId);
226
- legacyRemoteCursorFallbackUntil.set(crewMachineId, Date.now() + legacyRemoteCursorFallbackMs);
227
- }
228
210
  await appendOrchestrationAudit(input.config, "orchestration.subscription.catchup_failed", `Subscription catch-up failed for ${input.subscription.taskId}`, {
229
211
  taskId: input.subscription.taskId,
230
212
  crewMachineId,
231
213
  crewSessionId: input.subscription.crewSessionId,
232
214
  turnId: input.subscription.turnId,
215
+ errorCode: error instanceof RemoteCommandError ? error.code : undefined,
233
216
  error: error instanceof Error ? error.message : String(error),
234
217
  });
235
218
  return { status: "failed" };
236
219
  }
237
220
  }
238
- function canUseRemoteEventCursor(crewMachineId) {
239
- const until = legacyRemoteCursorFallbackUntil.get(crewMachineId);
240
- if (until === undefined)
241
- return remotePeerAdvertisesForwardCursor(crewMachineId);
242
- if (until <= Date.now()) {
243
- legacyRemoteCursorFallbackUntil.delete(crewMachineId);
244
- return remotePeerAdvertisesForwardCursor(crewMachineId);
245
- }
246
- return false;
247
- }
248
- export function remoteEventCursorForSubscription(crewMachineId, subscription) {
249
- const fallbackUntil = legacyRemoteCursorFallbackUntil.get(crewMachineId);
250
- if (fallbackUntil !== undefined && fallbackUntil > Date.now())
251
- return undefined;
252
- if (fallbackUntil !== undefined)
253
- legacyRemoteCursorFallbackUntil.delete(crewMachineId);
254
- const advertised = remotePeerForwardCursorSupport(crewMachineId);
255
- // An explicit old version wins over a stale persisted cursor (for example,
256
- // after a peer downgrade). Unknown manifests are different: a persisted
257
- // cursor is still positive evidence until the peer identity is refreshed.
258
- if (advertised === false)
259
- return undefined;
260
- // A persisted cursor is positive evidence from a previous successful
261
- // forward-page response. It remains usable even when the peer's manifest is
262
- // temporarily unavailable during reconnect.
221
+ function remoteEventCursorForSubscription(crewMachineId, subscription) {
222
+ // Every current daemon speaks the append-only forward cursor protocol. The
223
+ // capability gate lives in catchUpSubscriptionEventsInternal so an
224
+ // unavailable/old peer fails with a structured protocol error rather than
225
+ // silently switching to an unsafe legacy page.
226
+ void crewMachineId;
263
227
  if (subscription.remoteEventCursor)
264
228
  return subscription.remoteEventCursor;
265
- if (advertised !== true || !canUseRemoteEventCursor(crewMachineId))
266
- return undefined;
267
229
  return subscription.remoteEventSeq
268
230
  ? `event_${String(subscription.remoteEventSeq).padStart(12, "0")}`
269
231
  : initialRemoteEventCursor;
270
232
  }
271
- function remotePeerAdvertisesForwardCursor(crewMachineId) {
272
- return remotePeerForwardCursorSupport(crewMachineId) === true;
273
- }
274
233
  function remotePeerForwardCursorSupport(crewMachineId) {
275
234
  const machine = relayMachineRegistry.get(crewMachineId);
276
- const daemonVersion = machine?.capabilities.daemonVersion?.trim();
277
- const advertisedFeature = machine?.capabilities.runtimeEvents?.forwardCursor;
278
- if (advertisedFeature === false)
279
- return false;
280
- if (!daemonVersion) {
281
- if (advertisedFeature === true || remoteForwardCursorConfirmed.has(crewMachineId))
282
- return true;
283
- return undefined;
284
- }
285
- // Published beta495 introduced the append-only eventsPage `after` field.
286
- // Workspace daemons also publish an explicit feature bit. The historical
287
- // package version 0.1.0 alone is not evidence: older development daemons
288
- // used that same version while rejecting `after`.
289
- const match = /^0\.1\.0-beta\.(\d+)$/.exec(daemonVersion);
290
- if (match && Number(match[1]) < forwardEventCursorSinceBeta)
291
- return false;
292
- if (advertisedFeature === true || remoteForwardCursorConfirmed.has(crewMachineId))
293
- return true;
294
- if (match)
295
- return Number(match[1]) >= forwardEventCursorSinceBeta;
296
- return undefined;
297
- }
298
- function isRemoteEventCursorCompatibilityFailure(error) {
299
- if (!(error instanceof RemoteCommandError))
300
- return false;
301
- return error.code === "REMOTE_COMMAND_TIMEOUT" ||
302
- error.code === "REMOTE_MACHINE_ERROR" ||
303
- /INVALID|UNKNOWN|UNRECOGNIZED|SCHEMA|PROTOCOL|CURSOR|AFTER/u.test(error.code);
235
+ return machine?.capabilities.runtimeEvents?.forwardCursor;
304
236
  }
305
237
  async function observeSubscriptionRuntimeEvents(input) {
306
238
  const crewMachineId = input.crewMachineId ?? input.subscription.crewMachineId ?? input.config.machineId;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-daemon",
3
- "version": "0.1.0-beta.501",
3
+ "version": "0.1.0-beta.502",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -1,9 +1,17 @@
1
1
  /**
2
- * Install a stable, state-root-local launcher. The launcher deliberately resolves
3
- * the active slot at invocation time so an update never leaves a shell shim
4
- * pointing at a removed staging directory.
2
+ * Select the Node runtime used by stable launchers. An explicit executable is
3
+ * always authoritative (the `--node` escape hatch); otherwise retain the
4
+ * previously verified launcher runtime across upgrades, rollback and recovery.
5
+ * A stale/broken launcher record is not trusted and falls back to the runtime
6
+ * currently executing the updater.
5
7
  */
6
- export declare function writeStableLaunchers(stateRoot: string, nodeExecutable: string): Promise<{
8
+ export declare function resolveStableNodeExecutable(stateRoot: string, explicitNodeExecutable?: string): Promise<string>;
9
+ /**
10
+ * Install a stable, state-root-local launcher. The launcher deliberately
11
+ * resolves the active slot at invocation time so an update never leaves a
12
+ * shell shim pointing at a removed staging directory.
13
+ */
14
+ export declare function writeStableLaunchers(stateRoot: string, nodeExecutable?: string): Promise<{
7
15
  cliPath: string;
8
16
  updaterPath: string;
9
17
  }>;
@@ -3,36 +3,61 @@ import path from "node:path";
3
3
  import { updaterPaths } from "./paths.js";
4
4
  import { readCurrentSlot } from "./slots.js";
5
5
  import { atomicWriteJson } from "./state.js";
6
+ import { inspectNodeRuntime } from "./node-runtime.js";
6
7
  /**
7
- * Install a stable, state-root-local launcher. The launcher deliberately resolves
8
- * the active slot at invocation time so an update never leaves a shell shim
9
- * pointing at a removed staging directory.
8
+ * Select the Node runtime used by stable launchers. An explicit executable is
9
+ * always authoritative (the `--node` escape hatch); otherwise retain the
10
+ * previously verified launcher runtime across upgrades, rollback and recovery.
11
+ * A stale/broken launcher record is not trusted and falls back to the runtime
12
+ * currently executing the updater.
13
+ */
14
+ export async function resolveStableNodeExecutable(stateRoot, explicitNodeExecutable) {
15
+ if (explicitNodeExecutable !== undefined)
16
+ return (await inspectNodeRuntime(explicitNodeExecutable)).executable;
17
+ const manifestPath = path.join(updaterPaths(stateRoot).launcherDir, "launcher.json");
18
+ try {
19
+ const parsed = JSON.parse(await fs.readFile(manifestPath, "utf8"));
20
+ if (typeof parsed.nodeExecutable === "string" && parsed.nodeExecutable.trim()) {
21
+ return (await inspectNodeRuntime(parsed.nodeExecutable)).executable;
22
+ }
23
+ }
24
+ catch {
25
+ // The launcher may not exist during the first install, or its old runtime
26
+ // may have been removed. In either case use the updater's current Node.
27
+ }
28
+ return (await inspectNodeRuntime(process.execPath)).executable;
29
+ }
30
+ /**
31
+ * Install a stable, state-root-local launcher. The launcher deliberately
32
+ * resolves the active slot at invocation time so an update never leaves a
33
+ * shell shim pointing at a removed staging directory.
10
34
  */
11
35
  export async function writeStableLaunchers(stateRoot, nodeExecutable) {
12
36
  const current = await readCurrentSlot(stateRoot);
13
37
  if (!current)
14
38
  throw Object.assign(new Error("Cannot create launcher before a current slot exists"), { code: "UPDATE_SLOT_MISSING" });
39
+ const selectedNodeExecutable = await resolveStableNodeExecutable(stateRoot, nodeExecutable);
15
40
  const paths = updaterPaths(stateRoot);
16
41
  await fs.mkdir(paths.launcherDir, { recursive: true, mode: 0o700 });
17
42
  const runtimeScript = path.join(paths.launcherDir, "launcher-runtime.cjs");
18
- await fs.writeFile(runtimeScript, runtimeScriptSource(stateRoot, nodeExecutable), { mode: 0o700 });
43
+ await fs.writeFile(runtimeScript, runtimeScriptSource(stateRoot, selectedNodeExecutable), { mode: 0o700 });
19
44
  const cliEntry = path.join(current.slotPath, "node_modules", "@cydm", "happy-elves", "apps", "cli", "dist", "index.js");
20
45
  const updaterEntry = path.join(current.slotPath, "node_modules", "@cydm", "happy-elves", "apps", "updater", "dist", "cli.js");
21
46
  await fs.access(cliEntry);
22
47
  await fs.access(updaterEntry);
23
48
  if (process.platform === "win32") {
24
- await atomicWriteText(paths.launcherPath, windowsLauncher(nodeExecutable, runtimeScript, "cli"));
25
- await atomicWriteText(paths.updaterLauncherPath, windowsLauncher(nodeExecutable, runtimeScript, "updater"));
49
+ await atomicWriteText(paths.launcherPath, windowsLauncher(selectedNodeExecutable, runtimeScript, "cli"));
50
+ await atomicWriteText(paths.updaterLauncherPath, windowsLauncher(selectedNodeExecutable, runtimeScript, "updater"));
26
51
  }
27
52
  else {
28
- await atomicWriteText(paths.launcherPath, posixLauncher(nodeExecutable, runtimeScript, "cli"));
29
- await atomicWriteText(paths.updaterLauncherPath, posixLauncher(nodeExecutable, runtimeScript, "updater"));
53
+ await atomicWriteText(paths.launcherPath, posixLauncher(selectedNodeExecutable, runtimeScript, "cli"));
54
+ await atomicWriteText(paths.updaterLauncherPath, posixLauncher(selectedNodeExecutable, runtimeScript, "updater"));
30
55
  await fs.chmod(paths.launcherPath, 0o700);
31
56
  await fs.chmod(paths.updaterLauncherPath, 0o700);
32
57
  }
33
58
  await atomicWriteJson(path.join(paths.launcherDir, "launcher.json"), {
34
59
  schema: 1,
35
- nodeExecutable,
60
+ nodeExecutable: selectedNodeExecutable,
36
61
  runtimeScript,
37
62
  version: current.version,
38
63
  cliPath: cliEntry,
@@ -7,7 +7,7 @@ import { startUpdaterRelay } from "./relay.js";
7
7
  import fs from "node:fs/promises";
8
8
  import { cancelHostJob, readHostJob, readHostJobByOperation, recoverHostJobs, startHostJob } from "./host-jobs.js";
9
9
  import { daemonEntrypoint, localDaemonStatus, spawnUpdaterFromStableLauncher, startDaemon, stopLocalDaemon, waitForUpdaterReady } from "./daemon.js";
10
- import { writeStableLaunchers } from "./launcher.js";
10
+ import { resolveStableNodeExecutable, writeStableLaunchers } from "./launcher.js";
11
11
  import { clearStaleUpdaterMarkers, claimUpdaterOwner, markUpdaterHandoff, readUpdaterOwner, removeUpdaterPidIfOwned, restoreUpdaterOwner, writeUpdaterPid, writeUpdaterReady } from "./ownership.js";
12
12
  import { randomUUID } from "node:crypto";
13
13
  let daemonLifecycleTail = Promise.resolve();
@@ -132,13 +132,14 @@ async function recoverStaleUpdate(stateRoot) {
132
132
  const targetWasActivated = Boolean(current?.version && current.version === operation.targetVersion && operation.fromVersion);
133
133
  if (targetWasActivated && operation.fromVersion) {
134
134
  try {
135
+ const nodeExecutable = await resolveStableNodeExecutable(stateRoot);
135
136
  if (operation.daemonWasRunning !== false)
136
137
  await stopLocalDaemon(stateRoot, 15_000);
137
138
  const packageJson = await readSlotPackage(stateRoot, operation.fromVersion);
138
139
  const restored = await activateSlot(stateRoot, { version: operation.fromVersion, digest: operation.fromDigest, buildId: operation.fromBuildId ?? (typeof packageJson?.buildId === "string" ? packageJson.buildId : undefined) });
139
- await writeStableLaunchers(stateRoot, process.execPath);
140
+ await writeStableLaunchers(stateRoot, nodeExecutable);
140
141
  if (operation.daemonWasRunning !== false) {
141
- const pid = await startDaemon({ stateRoot, nodeExecutable: process.execPath, daemonEntrypoint: daemonEntrypoint(restored.slotPath), generation: `recovery-${Date.now()}` });
142
+ const pid = await startDaemon({ stateRoot, nodeExecutable, daemonEntrypoint: daemonEntrypoint(restored.slotPath), generation: `recovery-${Date.now()}` });
142
143
  if (!(await waitForDaemonPid(stateRoot, pid, 20_000)))
143
144
  throw new Error("Recovered daemon did not become ready");
144
145
  }
@@ -170,7 +171,8 @@ async function handoffToCurrentSlot(stateRoot, owner) {
170
171
  // replacement can therefore restore the old marker without a duplicate
171
172
  // entrypoint deleting it.
172
173
  await fs.rm(readyPath, { force: true }).catch(() => undefined);
173
- const nextPid = spawnUpdaterFromStableLauncher({ stateRoot, nodeExecutable: process.execPath, handoffFromPid: process.pid, handoffToken: token });
174
+ const nodeExecutable = await resolveStableNodeExecutable(stateRoot);
175
+ const nextPid = spawnUpdaterFromStableLauncher({ stateRoot, nodeExecutable, handoffFromPid: process.pid, handoffToken: token });
174
176
  if (await waitForUpdaterReady(stateRoot, nextPid, 10_000, { handoffToken: token }))
175
177
  return { ok: true };
176
178
  await recoverFailedHandoff(stateRoot, owner, token, previousReady);
@@ -264,7 +266,8 @@ async function handleDaemonCommand(command, input) {
264
266
  const current = await readCurrentSlot(input.stateRoot);
265
267
  if (!current)
266
268
  return daemonError(command, input.machineId, "BOOTSTRAP_REQUIRED", "No installed daemon slot is available", action);
267
- const pid = await startDaemon({ stateRoot: input.stateRoot, nodeExecutable: process.execPath, daemonEntrypoint: daemonEntrypoint(current.slotPath), generation: `host-${Date.now()}` });
269
+ const nodeExecutable = await resolveStableNodeExecutable(input.stateRoot);
270
+ const pid = await startDaemon({ stateRoot: input.stateRoot, nodeExecutable, daemonEntrypoint: daemonEntrypoint(current.slotPath), generation: `host-${Date.now()}` });
268
271
  const status = await waitForDaemonPid(input.stateRoot, pid, 5_000);
269
272
  if (!status)
270
273
  throw Object.assign(new Error("Daemon exited before becoming ready"), { code: "DAEMON_START_FAILED" });
@@ -11,7 +11,7 @@ import { resolveOfficialRelease, downloadAndVerifyRelease, installOfficialPackag
11
11
  import { daemonControlRequest } from "./control.js";
12
12
  import { daemonEntrypoint, localDaemonStatus, startDaemon, startUpdater, stopLocalDaemon, waitForUpdaterReady } from "./daemon.js";
13
13
  import { activeHostJobCount } from "./host-jobs.js";
14
- import { writeStableLaunchers } from "./launcher.js";
14
+ import { resolveStableNodeExecutable, writeStableLaunchers } from "./launcher.js";
15
15
  import { installAutostart } from "./autostart.js";
16
16
  import { readDaemonConfig } from "./daemon-config.js";
17
17
  export async function supervisorStatus(options) {
@@ -19,7 +19,14 @@ export async function supervisorStatus(options) {
19
19
  const operation = await readOperation(stateRoot);
20
20
  const current = await readCurrentSlot(stateRoot);
21
21
  const daemon = await localDaemonStatus(stateRoot);
22
- const node = await inspectNodeRuntime(options.nodeExecutable ?? process.execPath).catch((error) => ({ errorCode: errorCode(error), error: error instanceof Error ? error.message : String(error) }));
22
+ let node;
23
+ try {
24
+ const selectedNode = await resolveStableNodeExecutable(stateRoot, options.nodeExecutable);
25
+ node = await inspectNodeRuntime(selectedNode);
26
+ }
27
+ catch (error) {
28
+ node = { errorCode: errorCode(error), error: error instanceof Error ? error.message : String(error) };
29
+ }
23
30
  return {
24
31
  stateRoot,
25
32
  updaterVersion: options.updaterVersion ?? "0.1.0",
@@ -35,7 +42,7 @@ export async function bootstrapFromTarball(input) {
35
42
  const daemon = await localDaemonStatus(stateRoot);
36
43
  if (daemon.running)
37
44
  throw Object.assign(new Error("Bootstrap requires a local maintenance window; stop the existing daemon after confirming it is idle"), { code: "BOOTSTRAP_MAINTENANCE_REQUIRED" });
38
- const node = await inspectNodeRuntime(input.nodeExecutable ?? process.execPath);
45
+ const node = await inspectNodeRuntime(await resolveStableNodeExecutable(stateRoot, input.nodeExecutable));
39
46
  const existingCurrent = await readCurrentSlot(stateRoot);
40
47
  if (existingCurrent?.version === input.version)
41
48
  throw Object.assign(new Error("The requested bootstrap version is already active"), { code: "UPDATE_ALREADY_CURRENT" });
@@ -93,7 +100,7 @@ export async function bootstrapFromTarball(input) {
93
100
  export async function installCurrentPackage(input) {
94
101
  const stateRoot = resolveStateRoot(input.stateRoot);
95
102
  const config = await readDaemonConfig(stateRoot);
96
- const node = await inspectNodeRuntime(input.nodeExecutable ?? process.execPath);
103
+ const node = await inspectNodeRuntime(await resolveStableNodeExecutable(stateRoot, input.nodeExecutable));
97
104
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
98
105
  const manifest = JSON.parse(await fs.readFile(path.join(packageRoot, "package.json"), "utf8"));
99
106
  const version = typeof manifest.version === "string" && manifest.version ? manifest.version : undefined;
@@ -150,7 +157,7 @@ async function waitForDaemonPid(stateRoot, pid, timeoutMs) {
150
157
  }
151
158
  export async function applyOfficialUpdate(input) {
152
159
  const stateRoot = resolveStateRoot(input.stateRoot);
153
- const node = await inspectNodeRuntime(input.nodeExecutable ?? process.execPath);
160
+ const node = await inspectNodeRuntime(await resolveStableNodeExecutable(stateRoot, input.nodeExecutable));
154
161
  const existing = await readOperation(stateRoot);
155
162
  const operationId = input.operationId ?? randomId("upd");
156
163
  if (existing && existing.operationId === operationId) {
@@ -269,6 +276,7 @@ export async function commitDeferredUpdate(input) {
269
276
  }
270
277
  /** Restore the previous slot after a replacement updater fails its handoff. */
271
278
  export async function recoverDeferredUpdate(input) {
279
+ const nodeExecutable = await resolveStableNodeExecutable(input.stateRoot, input.nodeExecutable);
272
280
  const operation = await readOperation(input.stateRoot);
273
281
  if (!operation || operation.operationId !== input.operationId)
274
282
  throw Object.assign(new Error("Deferred update operation was not found"), { code: "UPDATE_OPERATION_MISSING" });
@@ -281,9 +289,9 @@ export async function recoverDeferredUpdate(input) {
281
289
  await stopLocalDaemon(input.stateRoot, 15_000).catch(() => undefined);
282
290
  await activateSlot(input.stateRoot, { version: previous.version, digest: operation.fromDigest ?? previous.digest, buildId: operation.fromBuildId ?? previous.buildId });
283
291
  if (operation.daemonWasRunning !== false) {
284
- await startAndVerify({ stateRoot: input.stateRoot, machineId: input.machineId, nodeExecutable: input.nodeExecutable ?? process.execPath, version: previous.version, generation: operation.barrierGeneration ?? randomId("rollback") });
292
+ await startAndVerify({ stateRoot: input.stateRoot, machineId: input.machineId, nodeExecutable, version: previous.version, generation: operation.barrierGeneration ?? randomId("rollback") });
285
293
  }
286
- await writeStableLaunchers(input.stateRoot, input.nodeExecutable ?? process.execPath);
294
+ await writeStableLaunchers(input.stateRoot, nodeExecutable);
287
295
  await clearBarrier(input.stateRoot, input.machineId, input.operationId).catch(() => undefined);
288
296
  return await updateOperation(input.stateRoot, { phase: "recovered", errorCode: input.errorCode ?? "UPDATE_HANDOFF_FAILED", error: input.error ?? "Replacement updater failed; the previous slot was restored.", recoveredFrom: operation.targetVersion, targetVersion: previous.version });
289
297
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves",
3
- "version": "0.1.0-beta.501",
3
+ "version": "0.1.0-beta.502",
4
4
  "description": "Remote controller for local coding agents with hosted or self-hosted relay support.",
5
- "buildId": "4edbb3ee3e6735ee0abb55e9b0612b17",
5
+ "buildId": "9ee7dcc44e6e7465aaa011b2c13085c8",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "happy-elves": "./apps/cli/dist/index.js",
@@ -594,6 +594,16 @@ class CodexHeadlessRuntimeProvider {
594
594
  if (codexTurnId && !active.settled && !active.terminalObserved) {
595
595
  active.codexTurnId = codexTurnId;
596
596
  this.activeTurnsByCodexTurn.set(codexTurnId, active);
597
+ // Persist ownership as soon as the provider materialises the native
598
+ // turn. A failed/interrupted HE turn is still an HE-owned append;
599
+ // waiting until `turn/completed` made a restart misclassify that
600
+ // failed turn as an external native handoff.
601
+ // Serialize the durable metadata write with provider event handling,
602
+ // but do not await it here: app-servers may issue a tool/approval
603
+ // request immediately after the `turn/start` response and before
604
+ // the first notification. Blocking that response would let a fast
605
+ // terminal notification fence the still-active request.
606
+ this.enqueueActive(active, () => this.bindStartedCodexTurn(active, codexTurnId));
597
607
  }
598
608
  };
599
609
  startProviderTurn(codexThreadId).catch(async (error) => {
@@ -1127,6 +1137,27 @@ class CodexHeadlessRuntimeProvider {
1127
1137
  providerMetadata,
1128
1138
  });
1129
1139
  }
1140
+ async bindStartedCodexTurn(active, codexTurnId) {
1141
+ const priorIds = Array.isArray(active.providerMetadata?.nativeHistoryTurnIds)
1142
+ ? active.providerMetadata.nativeHistoryTurnIds.filter((value) => typeof value === "string")
1143
+ : [];
1144
+ const nativeHistoryTurnIds = priorIds.includes(codexTurnId) ? priorIds : [...priorIds, codexTurnId];
1145
+ const ownedPriorIds = Array.isArray(active.providerMetadata?.headlessOwnedNativeTurnIds)
1146
+ ? active.providerMetadata.headlessOwnedNativeTurnIds.filter((value) => typeof value === "string")
1147
+ : [];
1148
+ const headlessOwnedNativeTurnIds = ownedPriorIds.includes(codexTurnId) ? ownedPriorIds : [...ownedPriorIds, codexTurnId];
1149
+ const providerMetadata = {
1150
+ ...active.providerMetadata,
1151
+ nativeHistoryTurnIds,
1152
+ headlessOwnedNativeTurnIds,
1153
+ nativeHistoryVersion: nativeHistoryVersionForTurnIds(nativeHistoryTurnIds),
1154
+ };
1155
+ active.providerMetadata = providerMetadata;
1156
+ await active.context.bindThreadProvider({
1157
+ providerSessionId: active.codexThreadId,
1158
+ providerMetadata,
1159
+ });
1160
+ }
1130
1161
  enqueueActive(active, task) {
1131
1162
  if (active.settled)
1132
1163
  return;