@cydm/happy-elves 0.1.0-beta.500 → 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/runner.js +22 -10
- package/apps/daemon/dist/orchestration/store.d.ts +5 -0
- package/apps/daemon/dist/orchestration/store.js +27 -0
- package/apps/daemon/dist/orchestration/watcher.d.ts +0 -1
- package/apps/daemon/dist/orchestration/watcher.js +41 -86
- package/apps/daemon/dist/relay/connection.js +44 -24
- package/apps/daemon/package.json +1 -1
- package/apps/updater/dist/daemon.js +3 -2
- package/apps/updater/dist/launcher.d.ts +12 -4
- package/apps/updater/dist/launcher.js +34 -9
- package/apps/updater/dist/managed-environment.d.ts +11 -0
- package/apps/updater/dist/managed-environment.js +125 -0
- 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
|
@@ -10,7 +10,7 @@ import { rehydrateRuntimeSession } from "../session/lifecycle.js";
|
|
|
10
10
|
import { runtimeRead } from "../session/runtime-truth.js";
|
|
11
11
|
import { encryptedSessionMetadata } from "../session/metadata.js";
|
|
12
12
|
import { loadSessionProjections } from "../session/store.js";
|
|
13
|
-
import { appendAttention, expireInflightLeases, leaseNextAttention, latestAttentionForTask, markAttentionDelivered, markAttentionDeliveryFailed, markSubscriptionLiveness, recordTurnRuntimeBinding, reopenUnresolvedArtifactFeedbackAttentions, readOrchestrationStore, } from "./store.js";
|
|
13
|
+
import { appendAttention, closeSubscriptionForTurn, expireInflightLeases, leaseNextAttention, latestAttentionForTask, markAttentionDelivered, markAttentionDeliveryFailed, markSubscriptionLiveness, recordTurnRuntimeBinding, reopenUnresolvedArtifactFeedbackAttentions, readOrchestrationStore, } from "./store.js";
|
|
14
14
|
import { attentionFromReconcile, catchUpSubscriptionEvents } from "./watcher.js";
|
|
15
15
|
import { scheduleRemoteOrchestrationWork } from "./remote-work-budget.js";
|
|
16
16
|
import { resolveWakeIdentity } from "./wake-identity.js";
|
|
@@ -146,9 +146,6 @@ async function reconcileSubscriptionsOnce(config) {
|
|
|
146
146
|
if (reconciledSubscriptions.has(key))
|
|
147
147
|
continue;
|
|
148
148
|
reconciledSubscriptions.add(key);
|
|
149
|
-
const latest = await latestAttentionForTask(subscription.taskId);
|
|
150
|
-
if (latest?.eventTypes.some((type) => type === "terminal" || type === "failure"))
|
|
151
|
-
continue;
|
|
152
149
|
if (crewMachineId !== config.machineId) {
|
|
153
150
|
// Remote reconcile shares the same bounded/fair scheduler as transcript
|
|
154
151
|
// catch-up. Startup recovery must not create an unbounded second fleet
|
|
@@ -252,8 +249,20 @@ async function reconcileRemoteSubscription(config, subscription) {
|
|
|
252
249
|
return;
|
|
253
250
|
}
|
|
254
251
|
const attention = attentionFromReconcile(result);
|
|
255
|
-
if (!attention
|
|
252
|
+
if (!attention)
|
|
256
253
|
return;
|
|
254
|
+
const closesTurn = attention.eventType === "terminal" || attention.eventType === "failure";
|
|
255
|
+
if (!subscription.eventTypes.includes(attention.eventType)) {
|
|
256
|
+
if (closesTurn) {
|
|
257
|
+
await closeSubscriptionForTurn({
|
|
258
|
+
taskId: subscription.taskId,
|
|
259
|
+
consumerSessionId: subscription.consumerSessionId,
|
|
260
|
+
crewSessionId: subscription.crewSessionId,
|
|
261
|
+
turnId: subscription.turnId,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
257
266
|
await appendAttention({
|
|
258
267
|
taskId: subscription.taskId,
|
|
259
268
|
consumerMachineId: subscription.consumerMachineId ?? config.machineId,
|
|
@@ -270,6 +279,7 @@ async function reconcileRemoteSubscription(config, subscription) {
|
|
|
270
279
|
turnId: subscription.turnId,
|
|
271
280
|
ref: result.status,
|
|
272
281
|
},
|
|
282
|
+
closeSubscription: closesTurn,
|
|
273
283
|
});
|
|
274
284
|
}
|
|
275
285
|
catch (error) {
|
|
@@ -307,14 +317,16 @@ async function markStaleSubscriptions(config) {
|
|
|
307
317
|
if (Number.isFinite(lastSeenAt) && now - lastSeenAt < staleMs)
|
|
308
318
|
continue;
|
|
309
319
|
const latest = await latestAttentionForTask(subscription.taskId);
|
|
310
|
-
if (latest?.
|
|
311
|
-
continue;
|
|
312
|
-
if (latest?.eventTypes.includes("stale") && latest.status !== "acked")
|
|
320
|
+
if (latest?.turnId === subscription.turnId && latest.eventTypes.includes("stale") && latest.status !== "acked")
|
|
313
321
|
continue;
|
|
314
322
|
if ((subscription.crewMachineId ?? config.machineId) === config.machineId) {
|
|
315
323
|
await reconcileLocalSubscription(config, subscription, "stale-check");
|
|
316
|
-
const
|
|
317
|
-
|
|
324
|
+
const reconciledStore = await readOrchestrationStore();
|
|
325
|
+
const currentSubscription = reconciledStore.subscriptions.find((record) => record.taskId === subscription.taskId &&
|
|
326
|
+
record.consumerSessionId === subscription.consumerSessionId &&
|
|
327
|
+
record.crewSessionId === subscription.crewSessionId &&
|
|
328
|
+
record.turnId === subscription.turnId);
|
|
329
|
+
if (!currentSubscription || currentSubscription.status !== "active")
|
|
318
330
|
continue;
|
|
319
331
|
if (await localSubscriptionTurnIsActive(subscription))
|
|
320
332
|
continue;
|
|
@@ -123,6 +123,8 @@ export type AttentionInput = {
|
|
|
123
123
|
eventType: TaskAttentionEventType;
|
|
124
124
|
reason: string;
|
|
125
125
|
payloadRef?: TaskAttentionPayloadRef;
|
|
126
|
+
/** Terminal/failure attention closes the exact turn subscription atomically. */
|
|
127
|
+
closeSubscription?: boolean;
|
|
126
128
|
now?: string;
|
|
127
129
|
};
|
|
128
130
|
export declare function readOrchestrationStore(): Promise<OrchestrationStore>;
|
|
@@ -249,6 +251,9 @@ export declare function markSubscriptionRemoteScan(taskId: string, input: {
|
|
|
249
251
|
at?: string;
|
|
250
252
|
}): Promise<void>;
|
|
251
253
|
export declare function appendAttention(input: AttentionInput): Promise<TaskAttentionRecord[]>;
|
|
254
|
+
export declare function closeSubscriptionForTurn(input: Pick<AttentionInput, "taskId" | "consumerSessionId" | "crewSessionId" | "turnId"> & {
|
|
255
|
+
at?: string;
|
|
256
|
+
}): Promise<void>;
|
|
252
257
|
export declare function listAttentions(input?: {
|
|
253
258
|
consumerSessionId?: string;
|
|
254
259
|
status?: TaskAttentionStatus[];
|
|
@@ -794,6 +794,9 @@ export async function appendAttention(input) {
|
|
|
794
794
|
let updated;
|
|
795
795
|
const store = await updateOrchestrationStore((current) => {
|
|
796
796
|
updated = appendAttentionRecord(current, { ...input, now: input.now ?? new Date().toISOString() });
|
|
797
|
+
if (input.closeSubscription && (input.eventType === "terminal" || input.eventType === "failure")) {
|
|
798
|
+
closeSubscriptionForTurnRecord(current, input);
|
|
799
|
+
}
|
|
797
800
|
return current;
|
|
798
801
|
});
|
|
799
802
|
if (!updated)
|
|
@@ -802,6 +805,30 @@ export async function appendAttention(input) {
|
|
|
802
805
|
record.consumerSessionId === updated?.consumerSessionId &&
|
|
803
806
|
record.eventCursor === updated?.eventCursor);
|
|
804
807
|
}
|
|
808
|
+
export async function closeSubscriptionForTurn(input) {
|
|
809
|
+
if (!input.turnId)
|
|
810
|
+
return;
|
|
811
|
+
const at = input.at ?? new Date().toISOString();
|
|
812
|
+
await updateOrchestrationStore((current) => {
|
|
813
|
+
closeSubscriptionForTurnRecord(current, { ...input, now: at });
|
|
814
|
+
return current;
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
function closeSubscriptionForTurnRecord(store, input) {
|
|
818
|
+
if (!input.turnId)
|
|
819
|
+
return;
|
|
820
|
+
const now = input.now ?? new Date().toISOString();
|
|
821
|
+
for (const subscription of store.subscriptions) {
|
|
822
|
+
if (subscription.status !== "active")
|
|
823
|
+
continue;
|
|
824
|
+
if (subscription.taskId !== input.taskId || subscription.consumerSessionId !== input.consumerSessionId)
|
|
825
|
+
continue;
|
|
826
|
+
if (subscription.crewSessionId !== input.crewSessionId || subscription.turnId !== input.turnId)
|
|
827
|
+
continue;
|
|
828
|
+
subscription.status = "deleted";
|
|
829
|
+
subscription.updatedAt = now;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
805
832
|
function appendAttentionRecord(store, input) {
|
|
806
833
|
const index = store.attentions.findIndex((record) => record.taskId === input.taskId && record.consumerSessionId === input.consumerSessionId);
|
|
807
834
|
const payloadRefs = input.payloadRef ? [input.payloadRef] : [];
|
|
@@ -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;
|
|
@@ -2,15 +2,11 @@ import { randomId, } from "../../../../packages/shared/dist/index.js";
|
|
|
2
2
|
import { appendAudit } from "../audit.js";
|
|
3
3
|
import { RemoteCommandError, requestRemoteMachineResult } from "../relay/remote-command.js";
|
|
4
4
|
import { runtimeRead } from "../session/runtime-truth.js";
|
|
5
|
-
import { activeSubscriptionsForTurn, appendAttention, markSubscriptionLiveness, markSubscriptionRemoteScan, recordTurnRuntimeBinding, } from "./store.js";
|
|
5
|
+
import { activeSubscriptionsForTurn, appendAttention, closeSubscriptionForTurn, markSubscriptionLiveness, markSubscriptionRemoteScan, recordTurnRuntimeBinding, } from "./store.js";
|
|
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) {
|
|
@@ -21,8 +17,18 @@ export async function observeSessionEvent(input) {
|
|
|
21
17
|
}
|
|
22
18
|
const subscriptions = await activeSubscriptionsForTurn(input.sessionId, input.turnId);
|
|
23
19
|
for (const subscription of subscriptions) {
|
|
24
|
-
|
|
20
|
+
const closesTurn = attention.eventType === "terminal" || attention.eventType === "failure";
|
|
21
|
+
if (!subscription.eventTypes.includes(attention.eventType)) {
|
|
22
|
+
if (closesTurn) {
|
|
23
|
+
await closeSubscriptionForTurn({
|
|
24
|
+
taskId: subscription.taskId,
|
|
25
|
+
consumerSessionId: subscription.consumerSessionId,
|
|
26
|
+
crewSessionId: subscription.crewSessionId,
|
|
27
|
+
turnId: subscription.turnId,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
25
30
|
continue;
|
|
31
|
+
}
|
|
26
32
|
await appendAttention({
|
|
27
33
|
taskId: subscription.taskId,
|
|
28
34
|
consumerMachineId: subscription.consumerMachineId ?? input.config.machineId,
|
|
@@ -39,6 +45,7 @@ export async function observeSessionEvent(input) {
|
|
|
39
45
|
turnId: input.turnId,
|
|
40
46
|
eventSeq: input.seq,
|
|
41
47
|
},
|
|
48
|
+
closeSubscription: closesTurn,
|
|
42
49
|
});
|
|
43
50
|
await appendOrchestrationAudit(input.config, "orchestration.attention.append", `Recorded ${attention.eventType} attention for ${subscription.taskId}`, {
|
|
44
51
|
taskId: subscription.taskId,
|
|
@@ -89,6 +96,7 @@ export async function observeTurnReconcile(input) {
|
|
|
89
96
|
turnId: input.result.turnId,
|
|
90
97
|
ref: input.result.status,
|
|
91
98
|
},
|
|
99
|
+
closeSubscription: attention.eventType === "terminal" || attention.eventType === "failure",
|
|
92
100
|
});
|
|
93
101
|
await appendOrchestrationAudit(input.config, "orchestration.attention.reconcile", `Recorded ${attention.eventType} reconcile attention for ${subscription.taskId}`, {
|
|
94
102
|
taskId: subscription.taskId,
|
|
@@ -122,8 +130,10 @@ async function catchUpSubscriptionEventsInternal(input) {
|
|
|
122
130
|
const remoteCursor = crewMachineId !== input.config.machineId
|
|
123
131
|
? remoteEventCursorForSubscription(crewMachineId, input.subscription)
|
|
124
132
|
: undefined;
|
|
125
|
-
const remoteCursorEnabled = remoteCursor !== undefined;
|
|
126
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
|
+
}
|
|
127
137
|
const result = crewMachineId === input.config.machineId
|
|
128
138
|
? await runtimeRead(input.config, {
|
|
129
139
|
requestId: randomId("orch_catchup_events"),
|
|
@@ -146,8 +156,7 @@ async function catchUpSubscriptionEventsInternal(input) {
|
|
|
146
156
|
action: "turn.eventsPage",
|
|
147
157
|
sessionId: input.subscription.crewSessionId,
|
|
148
158
|
turnId: input.subscription.turnId,
|
|
149
|
-
|
|
150
|
-
...(input.subscription.remoteEventItemId && !remoteCursor ? { afterItemId: input.subscription.remoteEventItemId } : {}),
|
|
159
|
+
after: remoteCursor,
|
|
151
160
|
...(input.subscription.runtimeTurnId ?? input.subscription.providerTurnId
|
|
152
161
|
? { runtimeTurnId: input.subscription.runtimeTurnId ?? input.subscription.providerTurnId }
|
|
153
162
|
: {}),
|
|
@@ -169,19 +178,8 @@ async function catchUpSubscriptionEventsInternal(input) {
|
|
|
169
178
|
});
|
|
170
179
|
return { status: "unreachable" };
|
|
171
180
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
// this machine; in particular, never advance a durable cursor from a
|
|
175
|
-
// page whose peer cannot prove append-only progress.
|
|
176
|
-
if (crewMachineId !== input.config.machineId && result.cursorSupported === true) {
|
|
177
|
-
remoteForwardCursorConfirmed.add(crewMachineId);
|
|
178
|
-
}
|
|
179
|
-
else if (crewMachineId !== input.config.machineId && remoteCursorEnabled && !result.nextAfter) {
|
|
180
|
-
// A peer that accepted the forward request but cannot prove the cursor
|
|
181
|
-
// contract is no longer trusted. Remove the positive handshake so the
|
|
182
|
-
// next safe, legacy-compatible request can renegotiate it.
|
|
183
|
-
remoteForwardCursorConfirmed.delete(crewMachineId);
|
|
184
|
-
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);
|
|
185
183
|
}
|
|
186
184
|
const observed = await observeSubscriptionRuntimeEvents({
|
|
187
185
|
config: input.config,
|
|
@@ -191,7 +189,7 @@ async function catchUpSubscriptionEventsInternal(input) {
|
|
|
191
189
|
skipThroughSeq: crewMachineId === input.config.machineId ? 0 : input.subscription.remoteEventSeq ?? 0,
|
|
192
190
|
});
|
|
193
191
|
if (crewMachineId !== input.config.machineId) {
|
|
194
|
-
const canAdvance =
|
|
192
|
+
const canAdvance = result.cursorSupported === true;
|
|
195
193
|
await markSubscriptionRemoteScan(input.subscription.taskId, {
|
|
196
194
|
crewMachineId,
|
|
197
195
|
crewSessionId: input.subscription.crewSessionId,
|
|
@@ -209,86 +207,32 @@ async function catchUpSubscriptionEventsInternal(input) {
|
|
|
209
207
|
};
|
|
210
208
|
}
|
|
211
209
|
catch (error) {
|
|
212
|
-
if (crewMachineId !== input.config.machineId && remoteCursorEnabled && isRemoteEventCursorCompatibilityFailure(error)) {
|
|
213
|
-
remoteForwardCursorConfirmed.delete(crewMachineId);
|
|
214
|
-
legacyRemoteCursorFallbackUntil.set(crewMachineId, Date.now() + legacyRemoteCursorFallbackMs);
|
|
215
|
-
}
|
|
216
210
|
await appendOrchestrationAudit(input.config, "orchestration.subscription.catchup_failed", `Subscription catch-up failed for ${input.subscription.taskId}`, {
|
|
217
211
|
taskId: input.subscription.taskId,
|
|
218
212
|
crewMachineId,
|
|
219
213
|
crewSessionId: input.subscription.crewSessionId,
|
|
220
214
|
turnId: input.subscription.turnId,
|
|
215
|
+
errorCode: error instanceof RemoteCommandError ? error.code : undefined,
|
|
221
216
|
error: error instanceof Error ? error.message : String(error),
|
|
222
217
|
});
|
|
223
218
|
return { status: "failed" };
|
|
224
219
|
}
|
|
225
220
|
}
|
|
226
|
-
function
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
return remotePeerAdvertisesForwardCursor(crewMachineId);
|
|
233
|
-
}
|
|
234
|
-
return false;
|
|
235
|
-
}
|
|
236
|
-
export function remoteEventCursorForSubscription(crewMachineId, subscription) {
|
|
237
|
-
const fallbackUntil = legacyRemoteCursorFallbackUntil.get(crewMachineId);
|
|
238
|
-
if (fallbackUntil !== undefined && fallbackUntil > Date.now())
|
|
239
|
-
return undefined;
|
|
240
|
-
if (fallbackUntil !== undefined)
|
|
241
|
-
legacyRemoteCursorFallbackUntil.delete(crewMachineId);
|
|
242
|
-
const advertised = remotePeerForwardCursorSupport(crewMachineId);
|
|
243
|
-
// An explicit old version wins over a stale persisted cursor (for example,
|
|
244
|
-
// after a peer downgrade). Unknown manifests are different: a persisted
|
|
245
|
-
// cursor is still positive evidence until the peer identity is refreshed.
|
|
246
|
-
if (advertised === false)
|
|
247
|
-
return undefined;
|
|
248
|
-
// A persisted cursor is positive evidence from a previous successful
|
|
249
|
-
// forward-page response. It remains usable even when the peer's manifest is
|
|
250
|
-
// 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;
|
|
251
227
|
if (subscription.remoteEventCursor)
|
|
252
228
|
return subscription.remoteEventCursor;
|
|
253
|
-
if (advertised !== true || !canUseRemoteEventCursor(crewMachineId))
|
|
254
|
-
return undefined;
|
|
255
229
|
return subscription.remoteEventSeq
|
|
256
230
|
? `event_${String(subscription.remoteEventSeq).padStart(12, "0")}`
|
|
257
231
|
: initialRemoteEventCursor;
|
|
258
232
|
}
|
|
259
|
-
function remotePeerAdvertisesForwardCursor(crewMachineId) {
|
|
260
|
-
return remotePeerForwardCursorSupport(crewMachineId) === true;
|
|
261
|
-
}
|
|
262
233
|
function remotePeerForwardCursorSupport(crewMachineId) {
|
|
263
234
|
const machine = relayMachineRegistry.get(crewMachineId);
|
|
264
|
-
|
|
265
|
-
const advertisedFeature = machine?.capabilities.runtimeEvents?.forwardCursor;
|
|
266
|
-
if (advertisedFeature === false)
|
|
267
|
-
return false;
|
|
268
|
-
if (!daemonVersion) {
|
|
269
|
-
if (advertisedFeature === true || remoteForwardCursorConfirmed.has(crewMachineId))
|
|
270
|
-
return true;
|
|
271
|
-
return undefined;
|
|
272
|
-
}
|
|
273
|
-
// Published beta495 introduced the append-only eventsPage `after` field.
|
|
274
|
-
// Workspace daemons also publish an explicit feature bit. The historical
|
|
275
|
-
// package version 0.1.0 alone is not evidence: older development daemons
|
|
276
|
-
// used that same version while rejecting `after`.
|
|
277
|
-
const match = /^0\.1\.0-beta\.(\d+)$/.exec(daemonVersion);
|
|
278
|
-
if (match && Number(match[1]) < forwardEventCursorSinceBeta)
|
|
279
|
-
return false;
|
|
280
|
-
if (advertisedFeature === true || remoteForwardCursorConfirmed.has(crewMachineId))
|
|
281
|
-
return true;
|
|
282
|
-
if (match)
|
|
283
|
-
return Number(match[1]) >= forwardEventCursorSinceBeta;
|
|
284
|
-
return undefined;
|
|
285
|
-
}
|
|
286
|
-
function isRemoteEventCursorCompatibilityFailure(error) {
|
|
287
|
-
if (!(error instanceof RemoteCommandError))
|
|
288
|
-
return false;
|
|
289
|
-
return error.code === "REMOTE_COMMAND_TIMEOUT" ||
|
|
290
|
-
error.code === "REMOTE_MACHINE_ERROR" ||
|
|
291
|
-
/INVALID|UNKNOWN|UNRECOGNIZED|SCHEMA|PROTOCOL|CURSOR|AFTER/u.test(error.code);
|
|
235
|
+
return machine?.capabilities.runtimeEvents?.forwardCursor;
|
|
292
236
|
}
|
|
293
237
|
async function observeSubscriptionRuntimeEvents(input) {
|
|
294
238
|
const crewMachineId = input.crewMachineId ?? input.subscription.crewMachineId ?? input.config.machineId;
|
|
@@ -360,8 +304,18 @@ async function observeSubscriptionPayload(input) {
|
|
|
360
304
|
}
|
|
361
305
|
return;
|
|
362
306
|
}
|
|
363
|
-
|
|
307
|
+
const closesTurn = attention.eventType === "terminal" || attention.eventType === "failure";
|
|
308
|
+
if (!input.subscription.eventTypes.includes(attention.eventType)) {
|
|
309
|
+
if (closesTurn) {
|
|
310
|
+
await closeSubscriptionForTurn({
|
|
311
|
+
taskId: input.subscription.taskId,
|
|
312
|
+
consumerSessionId: input.subscription.consumerSessionId,
|
|
313
|
+
crewSessionId: input.subscription.crewSessionId,
|
|
314
|
+
turnId: input.subscription.turnId,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
364
317
|
return;
|
|
318
|
+
}
|
|
365
319
|
await appendAttention({
|
|
366
320
|
taskId: input.subscription.taskId,
|
|
367
321
|
consumerMachineId: input.subscription.consumerMachineId ?? input.config.machineId,
|
|
@@ -378,6 +332,7 @@ async function observeSubscriptionPayload(input) {
|
|
|
378
332
|
turnId: input.subscription.turnId,
|
|
379
333
|
eventSeq: input.seq,
|
|
380
334
|
},
|
|
335
|
+
closeSubscription: closesTurn,
|
|
381
336
|
});
|
|
382
337
|
await appendOrchestrationAudit(input.config, "orchestration.attention.append", `Recorded ${input.crewMachineId === input.config.machineId ? "" : "remote "}${attention.eventType} attention for ${input.subscription.taskId}`, {
|
|
383
338
|
taskId: input.subscription.taskId,
|
|
@@ -703,10 +703,37 @@ export async function connectOnce(config) {
|
|
|
703
703
|
let loopTimer;
|
|
704
704
|
let scheduleTimer;
|
|
705
705
|
let orchestrationTimer;
|
|
706
|
+
let authenticated = false;
|
|
707
|
+
const startAuthenticatedWork = () => {
|
|
708
|
+
if (!authenticated || ws.readyState !== WebSocket.OPEN)
|
|
709
|
+
return;
|
|
710
|
+
if (loopTimer || scheduleTimer || orchestrationTimer)
|
|
711
|
+
return;
|
|
712
|
+
const onlineSinceMs = Date.now();
|
|
713
|
+
loopTimer = setInterval(() => {
|
|
714
|
+
void tickLoops(ws, config).catch((error) => {
|
|
715
|
+
console.error(`Loop tick failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
716
|
+
});
|
|
717
|
+
}, 1000);
|
|
718
|
+
scheduleTimer = setInterval(() => {
|
|
719
|
+
void tickSchedules(config, onlineSinceMs).catch((error) => {
|
|
720
|
+
console.error(`Schedule tick failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
721
|
+
});
|
|
722
|
+
}, 1000);
|
|
723
|
+
orchestrationTimer = setInterval(() => {
|
|
724
|
+
void tickOrchestration(config).catch((error) => {
|
|
725
|
+
console.error(`Orchestration tick failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
726
|
+
});
|
|
727
|
+
}, 1000);
|
|
728
|
+
void recoverCrewQueues(config).catch((error) => {
|
|
729
|
+
console.error(`Crew queue relay-open recovery failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
730
|
+
});
|
|
731
|
+
void drainPendingArtifactFeedbackAttentions(config).catch((error) => {
|
|
732
|
+
console.error(`Artifact feedback attention relay-open recovery failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
733
|
+
});
|
|
734
|
+
};
|
|
706
735
|
await new Promise((resolve) => {
|
|
707
736
|
ws.addEventListener("open", () => {
|
|
708
|
-
const onlineSinceMs = Date.now();
|
|
709
|
-
setActiveRelaySocket(ws);
|
|
710
737
|
const hello = {
|
|
711
738
|
type: "hello",
|
|
712
739
|
token: config.machineToken,
|
|
@@ -714,27 +741,6 @@ export async function connectOnce(config) {
|
|
|
714
741
|
nodeId: config.machineId,
|
|
715
742
|
};
|
|
716
743
|
ws.send(JSON.stringify(hello));
|
|
717
|
-
loopTimer = setInterval(() => {
|
|
718
|
-
void tickLoops(ws, config).catch((error) => {
|
|
719
|
-
console.error(`Loop tick failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
720
|
-
});
|
|
721
|
-
}, 1000);
|
|
722
|
-
scheduleTimer = setInterval(() => {
|
|
723
|
-
void tickSchedules(config, onlineSinceMs).catch((error) => {
|
|
724
|
-
console.error(`Schedule tick failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
725
|
-
});
|
|
726
|
-
}, 1000);
|
|
727
|
-
orchestrationTimer = setInterval(() => {
|
|
728
|
-
void tickOrchestration(config).catch((error) => {
|
|
729
|
-
console.error(`Orchestration tick failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
730
|
-
});
|
|
731
|
-
}, 1000);
|
|
732
|
-
void recoverCrewQueues(config).catch((error) => {
|
|
733
|
-
console.error(`Crew queue relay-open recovery failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
734
|
-
});
|
|
735
|
-
void drainPendingArtifactFeedbackAttentions(config).catch((error) => {
|
|
736
|
-
console.error(`Artifact feedback attention relay-open recovery failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
737
|
-
});
|
|
738
744
|
});
|
|
739
745
|
ws.addEventListener("message", (event) => {
|
|
740
746
|
let message;
|
|
@@ -745,11 +751,24 @@ export async function connectOnce(config) {
|
|
|
745
751
|
console.error(`Invalid relay message: ${error instanceof Error ? error.message : String(error)}`);
|
|
746
752
|
return;
|
|
747
753
|
}
|
|
748
|
-
void
|
|
754
|
+
void (async () => {
|
|
755
|
+
if (message.type === "server:hello" && !authenticated) {
|
|
756
|
+
// Relay authentication is complete only after the server hello.
|
|
757
|
+
// Do not activate the socket earlier: a reconnect with a durable
|
|
758
|
+
// outbox would otherwise flush publishPacket before the relay has
|
|
759
|
+
// installed the machine's authenticated routing context.
|
|
760
|
+
authenticated = true;
|
|
761
|
+
setActiveRelaySocket(ws);
|
|
762
|
+
}
|
|
763
|
+
await handleRelayWireMessage(ws, config, message);
|
|
764
|
+
if (message.type === "server:hello")
|
|
765
|
+
startAuthenticatedWork();
|
|
766
|
+
})().catch(async (error) => {
|
|
749
767
|
await appendUnscopedRelayError(config, "DAEMON_COMMAND_FAILED", error);
|
|
750
768
|
});
|
|
751
769
|
});
|
|
752
770
|
ws.addEventListener("close", () => {
|
|
771
|
+
authenticated = false;
|
|
753
772
|
clearActiveRelaySocket(ws);
|
|
754
773
|
if (loopTimer)
|
|
755
774
|
clearInterval(loopTimer);
|
|
@@ -761,6 +780,7 @@ export async function connectOnce(config) {
|
|
|
761
780
|
resolve();
|
|
762
781
|
});
|
|
763
782
|
ws.addEventListener("error", () => {
|
|
783
|
+
authenticated = false;
|
|
764
784
|
clearActiveRelaySocket(ws);
|
|
765
785
|
if (loopTimer)
|
|
766
786
|
clearInterval(loopTimer);
|
package/apps/daemon/package.json
CHANGED
|
@@ -6,6 +6,7 @@ import { promisify } from "node:util";
|
|
|
6
6
|
import { updaterPaths } from "./paths.js";
|
|
7
7
|
import { daemonControlRequest } from "./control.js";
|
|
8
8
|
import { readUpdaterMarker, readUpdaterOwner } from "./ownership.js";
|
|
9
|
+
import { managedProcessEnvironment } from "./managed-environment.js";
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
11
|
export async function localDaemonStatus(stateRoot) {
|
|
11
12
|
const file = updaterPaths(stateRoot).daemonPidPath;
|
|
@@ -91,7 +92,7 @@ export async function startDaemon(input) {
|
|
|
91
92
|
const stderr = fsSync.openSync(paths.daemonStderrPath, "a", 0o600);
|
|
92
93
|
const child = spawn(input.nodeExecutable, [input.daemonEntrypoint, "start"], {
|
|
93
94
|
cwd: input.stateRoot,
|
|
94
|
-
env: { ...
|
|
95
|
+
env: { ...managedProcessEnvironment(), HAPPY_ELVES_HOME: input.stateRoot, HAPPY_ELVES_UPDATER_GENERATION: input.generation },
|
|
95
96
|
detached: true,
|
|
96
97
|
stdio: ["ignore", stdout, stderr],
|
|
97
98
|
windowsHide: true,
|
|
@@ -119,7 +120,7 @@ export function spawnUpdaterFromStableLauncher(input) {
|
|
|
119
120
|
const child = spawn(input.nodeExecutable, [runtimeScript, "updater", "run", "--state-root", input.stateRoot], {
|
|
120
121
|
cwd: input.stateRoot,
|
|
121
122
|
env: {
|
|
122
|
-
...
|
|
123
|
+
...managedProcessEnvironment(),
|
|
123
124
|
HAPPY_ELVES_HOME: input.stateRoot,
|
|
124
125
|
...(input.handoffFromPid ? { HAPPY_ELVES_UPDATER_HANDOFF_FROM: String(input.handoffFromPid) } : {}),
|
|
125
126
|
...(input.handoffToken ? { HAPPY_ELVES_UPDATER_HANDOFF_TOKEN: input.handoffToken } : {}),
|
|
@@ -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,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the current user's Windows environment without making it part of any
|
|
3
|
+
* host-job request. A manager can itself be launched by a deliberately
|
|
4
|
+
* sanitized host job; in that case process.env no longer contains provider
|
|
5
|
+
* credentials that the normal user-launched daemon would inherit.
|
|
6
|
+
*
|
|
7
|
+
* The result is used only for manager-owned updater/daemon children. It is
|
|
8
|
+
* never logged, persisted, sent over Relay, or passed to arbitrary host jobs.
|
|
9
|
+
*/
|
|
10
|
+
export declare function managedProcessEnvironment(base?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, userEnvironment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
11
|
+
export declare function parseWindowsUserEnvironment(output: string, expansionEnvironment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
const environmentLookupTimeoutMs = 2_000;
|
|
3
|
+
/**
|
|
4
|
+
* Read the current user's Windows environment without making it part of any
|
|
5
|
+
* host-job request. A manager can itself be launched by a deliberately
|
|
6
|
+
* sanitized host job; in that case process.env no longer contains provider
|
|
7
|
+
* credentials that the normal user-launched daemon would inherit.
|
|
8
|
+
*
|
|
9
|
+
* The result is used only for manager-owned updater/daemon children. It is
|
|
10
|
+
* never logged, persisted, sent over Relay, or passed to arbitrary host jobs.
|
|
11
|
+
*/
|
|
12
|
+
export function managedProcessEnvironment(base = process.env, platform = process.platform, userEnvironment = platform === "win32" ? readWindowsUserEnvironment(base) : {}) {
|
|
13
|
+
if (platform !== "win32")
|
|
14
|
+
return { ...base };
|
|
15
|
+
const result = { ...base };
|
|
16
|
+
for (const [key, value] of Object.entries(userEnvironment)) {
|
|
17
|
+
if (!key || value === undefined || hasEnvironmentKey(result, key))
|
|
18
|
+
continue;
|
|
19
|
+
result[key] = value;
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
export function parseWindowsUserEnvironment(output, expansionEnvironment = {}) {
|
|
24
|
+
const entries = new Map();
|
|
25
|
+
const trimmed = output.trim();
|
|
26
|
+
if (trimmed.startsWith("{")) {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(trimmed);
|
|
29
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
30
|
+
if (!key)
|
|
31
|
+
continue;
|
|
32
|
+
if (typeof value === "string")
|
|
33
|
+
entries.set(key, { value, expand: false });
|
|
34
|
+
else if (value && typeof value === "object" && "value" in value && typeof value.value === "string") {
|
|
35
|
+
const typedValue = value;
|
|
36
|
+
entries.set(key, { value: typedValue.value, expand: isExpandableRegistryType(typedValue.type) });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return materializeEnvironment(entries, expansionEnvironment);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Fall through to the line-oriented reg.exe parser.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const line of output.split(/\r?\n/u)) {
|
|
46
|
+
// `reg query HKCU\\Environment` prints:
|
|
47
|
+
// NAME REG_SZ value
|
|
48
|
+
const match = /^\s{4,}([^\s]+)\s+(REG_[A-Z0-9_]+)\s*(.*)$/u.exec(line);
|
|
49
|
+
if (!match)
|
|
50
|
+
continue;
|
|
51
|
+
const [, key, type, rawValue] = match;
|
|
52
|
+
if (!key || !rawValue)
|
|
53
|
+
continue;
|
|
54
|
+
entries.set(key, { value: rawValue, expand: isExpandableRegistryType(type) });
|
|
55
|
+
}
|
|
56
|
+
return materializeEnvironment(entries, expansionEnvironment);
|
|
57
|
+
}
|
|
58
|
+
function isExpandableRegistryType(type) {
|
|
59
|
+
if (typeof type !== "string")
|
|
60
|
+
return false;
|
|
61
|
+
const normalized = type.trim().toUpperCase().replace(/[-_\s]/gu, "");
|
|
62
|
+
// PowerShell's RegistryValueKind.ToString() returns ExpandString, while
|
|
63
|
+
// reg.exe uses REG_EXPAND_SZ. Both describe the same registry semantics.
|
|
64
|
+
return normalized === "EXPANDSTRING" || normalized === "REGEXPANDSZ";
|
|
65
|
+
}
|
|
66
|
+
function readWindowsUserEnvironment(base) {
|
|
67
|
+
try {
|
|
68
|
+
const output = execFileSync("powershell.exe", [
|
|
69
|
+
"-NoLogo",
|
|
70
|
+
"-NoProfile",
|
|
71
|
+
"-NonInteractive",
|
|
72
|
+
"-Command",
|
|
73
|
+
"$ErrorActionPreference='Stop'; $OutputEncoding=New-Object System.Text.UTF8Encoding($false); [Console]::OutputEncoding=New-Object System.Text.UTF8Encoding($false); $k=[Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment'); if ($null -eq $k) { '{}' } else { $o=[ordered]@{}; foreach ($n in $k.GetValueNames()) { $o[$n]=[ordered]@{ value=[string]$k.GetValue($n,$null,[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames); type=$k.GetValueKind($n).ToString() } }; $o | ConvertTo-Json -Compress; $k.Dispose() }",
|
|
74
|
+
], {
|
|
75
|
+
encoding: "utf8",
|
|
76
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
77
|
+
windowsHide: true,
|
|
78
|
+
timeout: environmentLookupTimeoutMs,
|
|
79
|
+
});
|
|
80
|
+
return parseWindowsUserEnvironment(output, base);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
try {
|
|
84
|
+
const output = execFileSync("reg.exe", ["query", "HKCU\\Environment"], {
|
|
85
|
+
encoding: "utf8",
|
|
86
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
87
|
+
windowsHide: true,
|
|
88
|
+
timeout: environmentLookupTimeoutMs,
|
|
89
|
+
});
|
|
90
|
+
return parseWindowsUserEnvironment(output, base);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// A missing registry helper or a locked-down profile must not prevent
|
|
94
|
+
// the updater from starting. The daemon will surface a provider error
|
|
95
|
+
// if it genuinely needs a credential that is unavailable in its env.
|
|
96
|
+
return {};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function materializeEnvironment(entries, expansionEnvironment) {
|
|
101
|
+
const result = {};
|
|
102
|
+
const combined = { ...expansionEnvironment };
|
|
103
|
+
for (const [key, entry] of entries)
|
|
104
|
+
combined[key] = entry.value;
|
|
105
|
+
for (const [key, entry] of entries) {
|
|
106
|
+
result[key] = entry.expand ? expandWindowsVariables(entry.value, combined) : entry.value;
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
function hasEnvironmentKey(environment, key) {
|
|
111
|
+
if (Object.hasOwn(environment, key))
|
|
112
|
+
return true;
|
|
113
|
+
const normalized = key.toUpperCase();
|
|
114
|
+
return Object.keys(environment).some((candidate) => candidate.toUpperCase() === normalized);
|
|
115
|
+
}
|
|
116
|
+
function expandWindowsVariables(value, environment) {
|
|
117
|
+
return value.replace(/%([^%]+)%/gu, (match, name) => {
|
|
118
|
+
const direct = environment[name];
|
|
119
|
+
if (direct !== undefined)
|
|
120
|
+
return direct;
|
|
121
|
+
const existing = Object.entries(environment).find(([key]) => key.toUpperCase() === name.toUpperCase());
|
|
122
|
+
return existing?.[1] ?? match;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=managed-environment.js.map
|
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;
|