@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 +2 -2
- package/apps/cli/package.json +1 -1
- package/apps/daemon/dist/orchestration/watcher.d.ts +0 -1
- package/apps/daemon/dist/orchestration/watcher.js +15 -83
- package/apps/daemon/package.json +1 -1
- package/apps/updater/dist/launcher.d.ts +12 -4
- package/apps/updater/dist/launcher.js +34 -9
- package/apps/updater/dist/run.js +8 -5
- package/apps/updater/dist/supervisor.js +15 -7
- package/package.json +2 -2
- package/packages/headless-runtime/dist/codex-provider.js +31 -0
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.
|
|
10
|
-
npm.cmd exec --yes --package @cydm/happy-elves@0.1.0-beta.
|
|
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
|
|
package/apps/cli/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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 =
|
|
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
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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;
|
package/apps/daemon/package.json
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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,
|
|
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(
|
|
25
|
-
await atomicWriteText(paths.updaterLauncherPath, windowsLauncher(
|
|
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(
|
|
29
|
-
await atomicWriteText(paths.updaterLauncherPath, posixLauncher(
|
|
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,
|
package/apps/updater/dist/run.js
CHANGED
|
@@ -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,
|
|
140
|
+
await writeStableLaunchers(stateRoot, nodeExecutable);
|
|
140
141
|
if (operation.daemonWasRunning !== false) {
|
|
141
|
-
const pid = await startDaemon({ stateRoot, nodeExecutable
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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.
|
|
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": "
|
|
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;
|