@cydm/happy-elves 0.1.0-beta.52 → 0.1.0-beta.54

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.
@@ -21,6 +21,8 @@ export function normalizeOrchestratorStatus(session, orchestration, latestTurn)
21
21
  return "needsInput";
22
22
  if (orchestration.blocked)
23
23
  return "blocked";
24
+ if (orchestration.statusConflict?.evidence === "running-turn")
25
+ return "inProgress";
24
26
  if (latestTurn?.status === "running" || session.status === "running" || session.status === "new")
25
27
  return "inProgress";
26
28
  if (latestTurn?.status === "failed" || session.status === "failed")
@@ -39,6 +39,23 @@ function orchestrationStateFromStatus(status) {
39
39
  return "closed";
40
40
  }
41
41
  export function deriveOrchestration(session, events = []) {
42
+ const turns = summarizeTurns(events);
43
+ const latestTurn = turns[0];
44
+ const activeTurn = turns.find((turn) => turn.status === "running");
45
+ if (session.status === "failed" && latestTurn?.status === "running") {
46
+ return {
47
+ state: "active",
48
+ rawStatus: session.status,
49
+ needsUser: false,
50
+ blocked: false,
51
+ completed: false,
52
+ statusConflict: {
53
+ rawStatus: session.status,
54
+ evidence: "running-turn",
55
+ turnId: latestTurn.turnId,
56
+ },
57
+ };
58
+ }
42
59
  if (session.status !== "running") {
43
60
  const state = orchestrationStateFromStatus(session.status);
44
61
  return {
@@ -49,8 +66,6 @@ export function deriveOrchestration(session, events = []) {
49
66
  completed: state === "completed",
50
67
  };
51
68
  }
52
- const turns = summarizeTurns(events);
53
- const activeTurn = turns.find((turn) => turn.status === "running");
54
69
  const latestTerminalTurn = turns.find((turn) => turn.status !== "running");
55
70
  const relevantTurnId = activeTurn?.turnId ?? latestTerminalTurn?.turnId;
56
71
  const needsUserEvent = relevantTurnId
@@ -42,6 +42,11 @@ export type OrchestrationSummary = {
42
42
  completed: boolean;
43
43
  reason?: string;
44
44
  lastEventId?: number;
45
+ statusConflict?: {
46
+ rawStatus: SessionSnapshot["status"];
47
+ evidence: "running-turn";
48
+ turnId: string;
49
+ };
45
50
  };
46
51
  export type LoopRecord = {
47
52
  id: string;
@@ -143,7 +143,7 @@ async function readExactTurn(client, threadId, turnId) {
143
143
  return summarizeTurns(events).find((turn) => turn.turnId === turnId);
144
144
  }
145
145
  async function eventsForThreadSummary(client, session) {
146
- if (session.status !== "running" && session.status !== "new")
146
+ if (session.status !== "running" && session.status !== "new" && session.status !== "failed")
147
147
  return [];
148
148
  return await client.history(session.id, defaultTurnHistoryLimit);
149
149
  }
@@ -496,6 +496,7 @@ export async function handleSession({ domain, action, positional, flags }) {
496
496
  const metadata = await client.decodeSessionMetadata(session);
497
497
  const latestPage = await client.collectPage(session.id, { limit });
498
498
  const turns = summarizeTurns(latestPage.events);
499
+ const orchestration = deriveOrchestration(session, latestPage.events);
499
500
  const latestCompletedTurn = turns.find((turn) => turn.status === "completed");
500
501
  const latestTerminalTurn = turns.find((turn) => turn.status !== "running");
501
502
  const runtimeBinding = shouldSearchRuntimeBindingCandidates(latestPage.events, metadata)
@@ -551,6 +552,7 @@ export async function handleSession({ domain, action, positional, flags }) {
551
552
  first: turns.slice(0, 4).map(compactTurnRef),
552
553
  last: turns.slice(-4).map(compactTurnRef),
553
554
  },
555
+ orchestration,
554
556
  diagnosis: {
555
557
  importedRuntimeSessionId: stringMetadata(metadata, "importedFromRuntimeSessionId"),
556
558
  latestPageEmpty: latestPage.events.length === 0,
@@ -561,6 +563,8 @@ export async function handleSession({ domain, action, positional, flags }) {
561
563
  backfillStatus: stringMetadata(metadata, "historicalBackfillStatus") ?? stringMetadata(metadata, "historicalBackfill"),
562
564
  wrongRuntimeBindingSuspected: runtimeBindingSuspected(runtimeBinding.candidates),
563
565
  runtimeBindingCandidateCount: runtimeBinding.candidates.length,
566
+ statusConflictSuspected: Boolean(orchestration.statusConflict),
567
+ statusConflict: orchestration.statusConflict,
564
568
  staleHeadSuspected: staleHeadSuspected(session, latestCompletedTurn),
565
569
  headMatchesLatestCompletedTurn: headMatchesTurn(session, latestCompletedTurn),
566
570
  },
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { resolveCliCommand } from "../../../../packages/runtime-cli/dist/index.js";
6
6
  import { configDir } from "../paths.js";
7
7
  import { appendAudit } from "../audit.js";
8
- import { claimCommandRequest, sendCommandResponse } from "./send.js";
8
+ import { claimCommandRequest, relayOutboxDiagnostics, sendCommandResponse } from "./send.js";
9
9
  const maxOutputLength = 16_000;
10
10
  const maxArtifactBytesPerStream = 1_000_000;
11
11
  const defaultTimeoutMs = 60_000;
@@ -94,6 +94,7 @@ async function collectDiagnostics() {
94
94
  HAPPY_ELVES_DEV_REMOTE_EXEC: process.env.HAPPY_ELVES_DEV_REMOTE_EXEC,
95
95
  },
96
96
  commands: await Promise.all(commandNames.map(commandDiagnostics)),
97
+ relayOutbox: relayOutboxDiagnostics(),
97
98
  };
98
99
  }
99
100
  async function commandDiagnostics(name) {
@@ -1,6 +1,15 @@
1
+ import fs from "node:fs/promises";
1
2
  import type { MachineClientMessage } from "../../../../packages/shared/dist/index.js";
2
3
  import type { RuntimeTurn } from "../../../../packages/runtime/dist/index.js";
3
4
  import type { DaemonConfig } from "../types.js";
5
+ type OutboxFs = Pick<typeof fs, "chmod" | "mkdir" | "readFile" | "rename" | "rm" | "writeFile">;
6
+ export type OutboxPersistFailureDiagnostic = {
7
+ code?: string;
8
+ message: string;
9
+ path?: string;
10
+ at: string;
11
+ attempts: number;
12
+ };
4
13
  export declare function send(ws: WebSocket, message: unknown): void;
5
14
  export declare function configureRelaySender(config: DaemonConfig): void;
6
15
  export declare function setActiveRelaySocket(ws: WebSocket): void;
@@ -8,9 +17,15 @@ export declare function clearActiveRelaySocket(ws: WebSocket): void;
8
17
  export declare function flushRelayOutbox(): Promise<void>;
9
18
  export declare function handleMachineMessageAck(messageId: string): Promise<void>;
10
19
  export declare function clearRelayOutboxForTests(): Promise<void>;
20
+ export declare function setRelayOutboxFsForTests(fsOverride?: Partial<OutboxFs>): void;
21
+ export declare function relayOutboxDiagnostics(): {
22
+ latestPersistFailure?: OutboxPersistFailureDiagnostic;
23
+ };
24
+ export declare function resetRelayOutboxLoadForTests(): void;
11
25
  export declare function sendReliable(message: MachineClientMessage, ws?: WebSocket): Promise<MachineClientMessage>;
12
26
  export declare function sendCommandResponse(ws: WebSocket, message: MachineClientMessage): Promise<void>;
13
27
  export declare function claimCommandRequest(ws: WebSocket, requestId: string): boolean;
14
28
  export declare function claimSessionTurn(sessionId: string, requestId: string, turnId: string, source: "prompt" | "loop"): boolean;
15
29
  export declare function attachClaimedTurn(sessionId: string, requestId: string, turnId: string, turn: RuntimeTurn): boolean;
16
30
  export declare function releaseSessionTurn(sessionId: string, requestId: string): void;
31
+ export {};
@@ -20,9 +20,12 @@ const criticalMessageTypes = new Set([
20
20
  let activeRelaySocket = null;
21
21
  let relayConfig = null;
22
22
  let outboxLoaded = false;
23
+ let outboxDiskStateUnknown = false;
23
24
  let outboxLoadPromise = null;
24
- let outboxWritePromise = Promise.resolve();
25
+ let outboxWritePromise = Promise.resolve(true);
25
26
  let flushing = false;
27
+ let outboxFs = fs;
28
+ let latestPersistFailure;
26
29
  const outbox = new Map();
27
30
  export function send(ws, message) {
28
31
  const socket = openSocket(activeRelaySocket) ?? openSocket(ws);
@@ -63,9 +66,22 @@ export async function handleMachineMessageAck(messageId) {
63
66
  export async function clearRelayOutboxForTests() {
64
67
  outbox.clear();
65
68
  outboxLoaded = true;
69
+ outboxDiskStateUnknown = false;
66
70
  outboxLoadPromise = null;
71
+ latestPersistFailure = undefined;
67
72
  await persistOutbox();
68
73
  }
74
+ export function setRelayOutboxFsForTests(fsOverride) {
75
+ outboxFs = fsOverride ? { ...fs, ...fsOverride } : fs;
76
+ }
77
+ export function relayOutboxDiagnostics() {
78
+ return latestPersistFailure ? { latestPersistFailure: { ...latestPersistFailure } } : {};
79
+ }
80
+ export function resetRelayOutboxLoadForTests() {
81
+ outboxLoaded = false;
82
+ outboxDiskStateUnknown = false;
83
+ outboxLoadPromise = null;
84
+ }
69
85
  export async function sendReliable(message, ws) {
70
86
  if (!isCriticalMachineMessage(message)) {
71
87
  const socket = openSocket(activeRelaySocket) ?? openSocket(ws);
@@ -150,7 +166,7 @@ async function ensureOutboxLoaded() {
150
166
  async function loadOutbox() {
151
167
  outbox.clear();
152
168
  try {
153
- const text = await fs.readFile(relayOutboxPath, "utf8");
169
+ const text = await outboxFs.readFile(relayOutboxPath, "utf8");
154
170
  for (const line of text.split(/\n/u)) {
155
171
  if (!line.trim())
156
172
  continue;
@@ -159,25 +175,88 @@ async function loadOutbox() {
159
175
  outbox.set(parsed.messageId, parsed);
160
176
  }
161
177
  }
178
+ outboxDiskStateUnknown = false;
162
179
  }
163
180
  catch (error) {
164
- if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT")
165
- throw error;
181
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
182
+ outboxDiskStateUnknown = false;
183
+ outboxLoaded = true;
184
+ return;
185
+ }
186
+ outboxDiskStateUnknown = true;
187
+ latestPersistFailure = persistFailureDiagnostic(error, relayOutboxPath, 1);
188
+ await appendPersistFailureAudit(latestPersistFailure);
166
189
  }
167
190
  outboxLoaded = true;
168
191
  }
169
192
  async function persistOutbox() {
170
- outboxWritePromise = outboxWritePromise.then(writeOutbox, writeOutbox);
171
- await outboxWritePromise;
193
+ if (outboxDiskStateUnknown) {
194
+ latestPersistFailure = {
195
+ code: "OUTBOX_DISK_STATE_UNKNOWN",
196
+ message: "Skipped relay outbox persistence because the existing outbox could not be loaded safely.",
197
+ path: relayOutboxPath,
198
+ at: new Date().toISOString(),
199
+ attempts: 0,
200
+ };
201
+ await appendPersistFailureAudit(latestPersistFailure);
202
+ return false;
203
+ }
204
+ outboxWritePromise = outboxWritePromise.then(writeOutboxWithRetries, writeOutboxWithRetries);
205
+ return outboxWritePromise;
206
+ }
207
+ async function writeOutboxWithRetries() {
208
+ const maxAttempts = 4;
209
+ let attempts = 0;
210
+ let lastError;
211
+ let lastTempPath;
212
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
213
+ attempts = attempt;
214
+ lastTempPath = uniqueTempPath();
215
+ try {
216
+ await writeOutbox(lastTempPath);
217
+ if (latestPersistFailure?.path !== relayOutboxPath)
218
+ latestPersistFailure = undefined;
219
+ return true;
220
+ }
221
+ catch (error) {
222
+ lastError = error;
223
+ await outboxFs.rm(lastTempPath, { force: true }).catch(() => undefined);
224
+ if (!isTransientOutboxError(error) || attempt === maxAttempts)
225
+ break;
226
+ await delay(25 * attempt);
227
+ }
228
+ }
229
+ latestPersistFailure = persistFailureDiagnostic(lastError, lastTempPath, attempts);
230
+ await appendPersistFailureAudit(latestPersistFailure);
231
+ return false;
172
232
  }
173
- async function writeOutbox() {
174
- await fs.mkdir(configDir, { recursive: true });
233
+ async function writeOutbox(tempPath) {
234
+ await outboxFs.mkdir(configDir, { recursive: true });
175
235
  const text = [...outbox.values()].map((record) => JSON.stringify(record)).join("\n");
176
236
  const next = text ? `${text}\n` : "";
177
- const tempPath = `${relayOutboxPath}.tmp`;
178
- await fs.writeFile(tempPath, next, { mode: 0o600 });
179
- await fs.rename(tempPath, relayOutboxPath);
180
- await fs.chmod(relayOutboxPath, 0o600).catch(() => undefined);
237
+ await outboxFs.writeFile(tempPath, next, { mode: 0o600 });
238
+ await outboxFs.rename(tempPath, relayOutboxPath);
239
+ await outboxFs.chmod(relayOutboxPath, 0o600).catch(() => undefined);
240
+ }
241
+ function uniqueTempPath() {
242
+ return `${relayOutboxPath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
243
+ }
244
+ function isTransientOutboxError(error) {
245
+ return error instanceof Error && "code" in error && ["EPERM", "EACCES", "EBUSY"].includes(String(error.code));
246
+ }
247
+ function persistFailureDiagnostic(error, pathValue, attempts) {
248
+ const err = error instanceof Error ? error : undefined;
249
+ const code = err && "code" in err ? String(err.code) : undefined;
250
+ return {
251
+ code,
252
+ message: err?.message ?? (error ? String(error) : "Unknown relay outbox persistence failure"),
253
+ path: pathValue,
254
+ at: new Date().toISOString(),
255
+ attempts,
256
+ };
257
+ }
258
+ async function delay(ms) {
259
+ await new Promise((resolve) => setTimeout(resolve, ms));
181
260
  }
182
261
  async function attemptSendRecord(record, ws) {
183
262
  const socket = openSocket(activeRelaySocket) ?? openSocket(ws);
@@ -212,5 +291,20 @@ async function appendOutboxAudit(record, status, error) {
212
291
  requestId: "requestId" in record.message ? record.message.requestId : undefined,
213
292
  error: error instanceof Error ? error.message : error ? String(error) : undefined,
214
293
  },
294
+ }).catch((auditError) => {
295
+ latestPersistFailure = persistFailureDiagnostic(auditError, undefined, 1);
215
296
  });
216
297
  }
298
+ async function appendPersistFailureAudit(diagnostic) {
299
+ if (!relayConfig) {
300
+ console.warn(`Relay outbox persist failed: ${diagnostic.code ?? "UNKNOWN"} ${diagnostic.message}`);
301
+ return;
302
+ }
303
+ await appendAudit({
304
+ machineId: relayConfig.machineId,
305
+ actor: "system",
306
+ action: "relay.outbox.persist_failed",
307
+ summary: "Failed to persist reliable relay outbox",
308
+ evidence: diagnostic,
309
+ }).catch(() => undefined);
310
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-daemon",
3
- "version": "0.1.0-beta.52",
3
+ "version": "0.1.0-beta.54",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -487,7 +487,7 @@ function handleMachineError(context, connection, message, acknowledgeMachineMess
487
487
  acknowledgeMachineMessage();
488
488
  return;
489
489
  }
490
- if (!nonFatalSessionErrors.has(message.code ?? "")) {
490
+ if (isFatalSessionMachineError(pending, message.code, nonFatalSessionErrors)) {
491
491
  context.clearAcceptedRunsForSession(connection.accountId, { machineId: connection.machineId, sessionId: message.sessionId });
492
492
  const session = context.setSessionStatus({ accountId: connection.accountId, machineId: connection.machineId, sessionId: message.sessionId, status: "failed" });
493
493
  if (session)
@@ -519,6 +519,29 @@ function handleMachineError(context, connection, message, acknowledgeMachineMess
519
519
  });
520
520
  acknowledgeMachineMessage();
521
521
  }
522
+ function isFatalSessionMachineError(pending, code, nonFatalCodes) {
523
+ if (pending) {
524
+ if (isControlPlaneSessionAction(pending.action))
525
+ return false;
526
+ if (pending.action === "session.run")
527
+ return !nonFatalCodes.has(code ?? "");
528
+ if (pending.action === "session.create" || pending.action === "session.import")
529
+ return true;
530
+ }
531
+ return !nonFatalCodes.has(code ?? "");
532
+ }
533
+ function isControlPlaneSessionAction(action) {
534
+ return [
535
+ "directory.list",
536
+ "file.preview",
537
+ "machine.diagnose",
538
+ "machine.devExec",
539
+ "session.cancel",
540
+ "session.history",
541
+ "session.rename",
542
+ "session.resume",
543
+ ].includes(action);
544
+ }
522
545
  function completeWithAck(context, connection, message) {
523
546
  context.completeCommandWithResponse(connection.accountId, connection.machineId, message.requestId, message);
524
547
  context.broadcastAck(connection.accountId, message);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves",
3
- "version": "0.1.0-beta.52",
3
+ "version": "0.1.0-beta.54",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@cydm/happy-elves",
9
- "version": "0.1.0-beta.52",
9
+ "version": "0.1.0-beta.54",
10
10
  "license": "Apache-2.0",
11
11
  "dependencies": {
12
12
  "@fastify/cors": "11.2.0",
@@ -1543,18 +1543,18 @@
1543
1543
  "license": "MIT"
1544
1544
  },
1545
1545
  "node_modules/toad-cache": {
1546
- "version": "3.7.1",
1547
- "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.1.tgz",
1548
- "integrity": "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==",
1546
+ "version": "3.7.4",
1547
+ "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz",
1548
+ "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==",
1549
1549
  "license": "MIT",
1550
1550
  "engines": {
1551
1551
  "node": ">=20"
1552
1552
  }
1553
1553
  },
1554
1554
  "node_modules/tsx": {
1555
- "version": "4.22.5",
1556
- "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.5.tgz",
1557
- "integrity": "sha512-F7JnSfPl5ASt6LqwWyUQ3T8BwN3q0eQEbFMYa2iRWaVQmmudo0d7fRmwM4O002gsvW1bs0yBYioutsAjqLJMvQ==",
1555
+ "version": "4.23.0",
1556
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz",
1557
+ "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==",
1558
1558
  "license": "MIT",
1559
1559
  "dependencies": {
1560
1560
  "esbuild": "~0.28.0"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves",
3
- "version": "0.1.0-beta.52",
3
+ "version": "0.1.0-beta.54",
4
4
  "description": "Remote controller for local coding agents with hosted or self-hosted relay support.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,6 @@
1
1
  import { type RuntimePermissionMode } from "./permissions.js";
2
2
  export declare function normalizeCodexPermissionMode(mode?: RuntimePermissionMode): RuntimePermissionMode;
3
3
  export declare function normalizeRuntimeSelection(value?: string | null): string | null;
4
- export declare function normalizeCodexReasoningEffort(value?: string | null): string | null;
5
4
  export declare function codexThreadStartParams(cwd: string, permissionMode: RuntimePermissionMode, model?: string | null): Record<string, unknown>;
6
5
  export declare function codexThreadResumeParams(threadId: string, cwd: string, permissionMode: RuntimePermissionMode, model?: string | null): Record<string, unknown>;
7
6
  export declare function codexTurnStartParams(threadId: string, cwd: string, text: string, permissionMode: RuntimePermissionMode, model?: string | null, thoughtLevel?: string | null): Record<string, unknown>;
@@ -8,7 +8,7 @@ export function normalizeRuntimeSelection(value) {
8
8
  const normalized = value.trim();
9
9
  return normalized && normalized !== "default" ? normalized : null;
10
10
  }
11
- export function normalizeCodexReasoningEffort(value) {
11
+ function normalizeCodexReasoningEffort(value) {
12
12
  const normalized = normalizeRuntimeSelection(value);
13
13
  if (normalized === null)
14
14
  return null;
@@ -387,6 +387,15 @@ export declare const machineDiagnosticsSchema: z.ZodObject<{
387
387
  version: z.ZodOptional<z.ZodString>;
388
388
  error: z.ZodOptional<z.ZodString>;
389
389
  }, z.core.$strip>>;
390
+ relayOutbox: z.ZodOptional<z.ZodObject<{
391
+ latestPersistFailure: z.ZodOptional<z.ZodObject<{
392
+ code: z.ZodOptional<z.ZodString>;
393
+ message: z.ZodString;
394
+ path: z.ZodOptional<z.ZodString>;
395
+ at: z.ZodString;
396
+ attempts: z.ZodNumber;
397
+ }, z.core.$strip>>;
398
+ }, z.core.$strip>>;
390
399
  }, z.core.$strip>;
391
400
  export declare const machineDevExecResultSchema: z.ZodObject<{
392
401
  command: z.ZodString;
@@ -171,6 +171,15 @@ export const machineDiagnosticsSchema = z.object({
171
171
  version: z.string().optional(),
172
172
  error: z.string().optional(),
173
173
  })),
174
+ relayOutbox: z.object({
175
+ latestPersistFailure: z.object({
176
+ code: z.string().optional(),
177
+ message: z.string(),
178
+ path: z.string().optional(),
179
+ at: z.string(),
180
+ attempts: z.number().int().nonnegative(),
181
+ }).optional(),
182
+ }).optional(),
174
183
  });
175
184
  export const machineDevExecResultSchema = z.object({
176
185
  command: z.string().min(1),
@@ -149,6 +149,15 @@ export type MachineDiagnostics = {
149
149
  version?: string;
150
150
  error?: string;
151
151
  }>;
152
+ relayOutbox?: {
153
+ latestPersistFailure?: {
154
+ code?: string;
155
+ message: string;
156
+ path?: string;
157
+ at: string;
158
+ attempts: number;
159
+ };
160
+ };
152
161
  };
153
162
  export type MachineDevExecResult = {
154
163
  command: string;