@cydm/happy-elves 0.1.0-beta.279 → 0.1.0-beta.284

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.
@@ -29,7 +29,7 @@ export type TurnSummary = {
29
29
  checkpointId?: string;
30
30
  runtimeTurnId?: string;
31
31
  turnSeq?: number;
32
- status: "running" | "completed" | "failed" | "cancelled";
32
+ status: "running" | "completed" | "failed" | "cancelled" | "unknown";
33
33
  prompt: string;
34
34
  output: string;
35
35
  terminalText: string;
@@ -243,6 +243,13 @@ export function summarizeTurns(events) {
243
243
  current.checkpointId = payload.currentHead;
244
244
  current.runtimeTurnId = payload.lastTurnId;
245
245
  }
246
+ else if (payload?.type === "status" && isReconcileUnknownTag(payload.tag)) {
247
+ if (current.status === "running") {
248
+ current.status = "unknown";
249
+ current.completedAt = event.createdAt;
250
+ current.terminalText = payload.text.trim();
251
+ }
252
+ }
246
253
  else if (payload?.type === "memory_used") {
247
254
  current.usedMemories = dedupeUsedMemories([...current.usedMemories, ...payload.memories]);
248
255
  }
@@ -472,6 +479,9 @@ function turnInProgressError(sessionId, turn, latestCompleted) {
472
479
  latestCompletedTurnId: latestCompleted?.turnId,
473
480
  });
474
481
  }
482
+ function isReconcileUnknownTag(tag) {
483
+ return tag === "reconcile:unknown" || tag === "reconcile:not_found" || tag === "reconcile:uninspectable";
484
+ }
475
485
  export async function rebuildTurnOutput(client, sessionId, turnId) {
476
486
  const events = await client.history(sessionId, 1000);
477
487
  const folded = foldAliasedRuntimeEvents(events);
@@ -34,7 +34,7 @@ function orchestrationStateFromStatus(status) {
34
34
  return "idle";
35
35
  if (status === "completed")
36
36
  return "completed";
37
- if (status === "failed" || status === "offline")
37
+ if (status === "failed" || status === "offline" || status === "unknown")
38
38
  return "blocked";
39
39
  return "closed";
40
40
  }
@@ -70,6 +70,17 @@ export function deriveOrchestration(session, events = []) {
70
70
  completed: false,
71
71
  };
72
72
  }
73
+ if (latestTerminalTurn?.status === "unknown") {
74
+ return {
75
+ state: "blocked",
76
+ rawStatus: session.status,
77
+ needsUser: false,
78
+ blocked: true,
79
+ completed: false,
80
+ reason: "execution-unknown",
81
+ lastEventId: latestTerminalTurn.lastEventId,
82
+ };
83
+ }
73
84
  const state = orchestrationStateFromStatus(session.status);
74
85
  return {
75
86
  state,
@@ -312,6 +312,26 @@ function shouldSearchRuntimeBindingCandidates(latestEvents, metadata) {
312
312
  function runtimeBindingSuspected(candidates) {
313
313
  return candidates.some((candidate) => candidate.reasons.includes("name") && !candidate.reasons.includes("already-loaded"));
314
314
  }
315
+ async function enrichHistoricalLoadedSessionIds(client, sessions) {
316
+ const snapshot = await client.snapshot();
317
+ const runtimeIdToSession = new Map();
318
+ for (const session of snapshot.sessions) {
319
+ if (session.status === "closed")
320
+ continue;
321
+ const metadata = await client.decodeSessionMetadata(session);
322
+ for (const runtimeSessionId of metadataRuntimeSessionIds(metadata)) {
323
+ const key = `${session.machineId}:${session.agent}:${runtimeSessionId}`;
324
+ const existing = runtimeIdToSession.get(key);
325
+ if (!existing || session.createdAt < existing.createdAt || (session.createdAt === existing.createdAt && session.id < existing.id)) {
326
+ runtimeIdToSession.set(key, session);
327
+ }
328
+ }
329
+ }
330
+ return sessions.map((historical) => {
331
+ const loaded = runtimeIdToSession.get(`${historical.machineId}:${historical.agent}:${historical.runtimeSessionId}`);
332
+ return loaded ? { ...historical, loadedSessionId: loaded.id } : historical;
333
+ });
334
+ }
315
335
  async function findRuntimeBindingCandidates(client, session, metadata, flags) {
316
336
  const limit = parsePositiveIntFlag(flags, "runtime-limit") ?? 500;
317
337
  const result = await listWorkspaceHistoricalSessions(client, {
@@ -573,6 +593,15 @@ export async function handleSession({ domain, action, positional, flags }) {
573
593
  : undefined;
574
594
  if (repair)
575
595
  session = repair.session;
596
+ const metadataAvailableEvents = numberMetadata(metadata, "availableHistoricalEvents");
597
+ const metadataTotalEvents = numberMetadata(metadata, "historicalBackfillTotalEvents");
598
+ const runtimeCurrentEventCount = numberMetadata(metadata, "runtimeCurrentEventCount");
599
+ const tailMissingEvents = runtimeCurrentEventCount !== undefined
600
+ ? Math.max(0, runtimeCurrentEventCount - (metadataTotalEvents ?? metadataAvailableEvents ?? 0))
601
+ : undefined;
602
+ const latestSyncLikelyStale = tailMissingEvents !== undefined
603
+ ? tailMissingEvents > 0
604
+ : metadataTotalEvents !== undefined && metadataAvailableEvents !== undefined && metadataAvailableEvents > metadataTotalEvents;
576
605
  const data = {
577
606
  generatedAt: new Date().toISOString(),
578
607
  session: {
@@ -623,7 +652,11 @@ export async function handleSession({ domain, action, positional, flags }) {
623
652
  latestPageEmpty: latestPage.events.length === 0,
624
653
  latestPageHasVisibleTranscript: latestPage.events.some(isVisibleTranscriptPayloadEvent),
625
654
  metadataReportsEmptyRuntimeSession: metadata.emptyRuntimeSession === true,
626
- backfillAvailableEvents: numberMetadata(metadata, "availableHistoricalEvents"),
655
+ runtimeCurrentEventCount,
656
+ metadataAvailableEvents,
657
+ tailMissingEvents,
658
+ latestSyncLikelyStale,
659
+ backfillAvailableEvents: metadataAvailableEvents,
627
660
  backfillEmittedEvents: numberMetadata(metadata, "backfilledEvents"),
628
661
  backfillStatus: stringMetadata(metadata, "historicalBackfillStatus") ?? stringMetadata(metadata, "historicalBackfill"),
629
662
  wrongRuntimeBindingSuspected: runtimeBindingSuspected(runtimeBinding.candidates),
@@ -701,7 +734,7 @@ export async function handleSession({ domain, action, positional, flags }) {
701
734
  sourceSessionId: session.id,
702
735
  sessionId: imported.sessionId,
703
736
  runtimeSessionId: candidate.runtimeSessionId,
704
- mode: "created",
737
+ mode: imported.mode === "existing" ? "existing" : "created",
705
738
  sourceClosed,
706
739
  candidate: {
707
740
  runtimeSessionId: candidate.runtimeSessionId,
@@ -725,7 +758,8 @@ export async function handleSession({ domain, action, positional, flags }) {
725
758
  includeArchived: flags["include-archived"] === true,
726
759
  limit,
727
760
  });
728
- const sessions = result.sessions.map((session) => ({
761
+ const enrichedSessions = await enrichHistoricalLoadedSessionIds(client, result.sessions);
762
+ const sessions = enrichedSessions.map((session) => ({
729
763
  runtimeSessionId: session.runtimeSessionId,
730
764
  agent: session.agent,
731
765
  cwd: session.cwd,
@@ -738,11 +772,11 @@ export async function handleSession({ domain, action, positional, flags }) {
738
772
  if (wantsJson(flags) || wantsVerbose(flags)) {
739
773
  ok("session.history", {
740
774
  source: "runtime-history",
741
- sessions: wantsVerbose(flags) ? result.sessions.map((session) => ({ ...session, sessionId: session.loadedSessionId })) : sessions,
775
+ sessions: wantsVerbose(flags) ? enrichedSessions.map((session) => ({ ...session, sessionId: session.loadedSessionId })) : sessions,
742
776
  page: {
743
777
  limit,
744
- returned: result.sessions.length,
745
- hasMore: result.sessions.length >= limit,
778
+ returned: enrichedSessions.length,
779
+ hasMore: enrichedSessions.length >= limit,
746
780
  },
747
781
  ...(wantsVerbose(flags) ? { errors: result.errors, machines: result.machines } : {}),
748
782
  }, { machineId });
@@ -774,7 +808,8 @@ export async function handleSession({ domain, action, positional, flags }) {
774
808
  includeArchived: true,
775
809
  limit: 200,
776
810
  });
777
- const historical = result.sessions.find((session) => session.runtimeSessionId === runtimeSessionId);
811
+ const enrichedSessions = await enrichHistoricalLoadedSessionIds(client, result.sessions);
812
+ const historical = enrichedSessions.find((session) => session.runtimeSessionId === runtimeSessionId);
778
813
  if (!historical)
779
814
  throw new CliError(`Runtime session not found: ${runtimeSessionId}`, "RUNTIME_SESSION_NOT_FOUND");
780
815
  if (historical.loadedSessionId) {
@@ -802,7 +837,7 @@ export async function handleSession({ domain, action, positional, flags }) {
802
837
  ok("session.continue", {
803
838
  sessionId: continued.sessionId,
804
839
  runtimeSessionId,
805
- mode: "created",
840
+ mode: continued.mode === "existing" ? "existing" : "created",
806
841
  ...(historical.runtimeState === "archived" ? { runtimeState: "archived" } : {}),
807
842
  }, { requestId: continued.requestId, machineId, sessionId: continued.sessionId });
808
843
  return true;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-cli",
3
- "version": "0.1.0-beta.279",
3
+ "version": "0.1.0-beta.284",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -1,7 +1,8 @@
1
1
  import { type MachineCommand } from "../../../../packages/shared/dist/index.js";
2
- import type { DaemonConfig, HistoricalBackfillStatus, SessionFallback, SessionState } from "../types.js";
2
+ import type { DaemonConfig, HistoricalBackfillState, HistoricalBackfillStatus, SessionFallback, SessionState } from "../types.js";
3
3
  import { type HistoricalSessionHead } from "./events.js";
4
- type HistoricalBackfillDirection = "latest" | "older";
4
+ export type HistoricalBackfillDirection = "latest" | "older";
5
+ type HistoricalBackfillMode = HistoricalBackfillDirection | "auto";
5
6
  export type HistoricalBackfillResult = {
6
7
  availableEvents: number;
7
8
  cursor: number;
@@ -17,7 +18,7 @@ export type HistoricalBackfillResult = {
17
18
  export declare function handleCreateSession(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
18
19
  export declare function handleImportHistoricalSession(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
19
20
  export declare function handleListHistoricalSessions(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
20
- export declare function resumeSession(ws: WebSocket, config: DaemonConfig, sessionId: string, requestId: string, fallback?: SessionFallback, announceSuccess?: boolean, syncDirection?: HistoricalBackfillDirection): Promise<SessionState | null>;
21
+ export declare function resumeSession(ws: WebSocket, config: DaemonConfig, sessionId: string, requestId: string, fallback?: SessionFallback, announceSuccess?: boolean, syncDirection?: HistoricalBackfillMode): Promise<SessionState | null>;
21
22
  export declare function usesAuthoritativeTranscript(session: SessionState): boolean;
22
23
  export declare function replaceAuthoritativeTranscript(ws: WebSocket, config: DaemonConfig, session: SessionState, runtimeSessionId?: string | undefined, input?: {
23
24
  currentHead?: string;
@@ -27,6 +28,22 @@ export declare function replaceAuthoritativeTranscript(ws: WebSocket, config: Da
27
28
  status?: "completed" | "cancelled" | "failed";
28
29
  strict?: boolean;
29
30
  }): Promise<HistoricalBackfillResult>;
31
+ export declare function planAppendOnlyHistoricalBackfill<T extends {
32
+ turnId: string;
33
+ }>(historicalEvents: T[], previousBackfill: HistoricalBackfillState | undefined, direction?: HistoricalBackfillMode, maxEvents?: number): {
34
+ cursor: number;
35
+ olderWindow: {
36
+ events: T[];
37
+ nextCursor: number;
38
+ startIndex: number;
39
+ };
40
+ status: HistoricalBackfillStatus;
41
+ tailWindow: {
42
+ events: T[];
43
+ startIndex: number;
44
+ };
45
+ truncated: boolean;
46
+ };
30
47
  export declare function handleResume(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
31
48
  export declare function handleClose(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
32
49
  export {};
@@ -256,7 +256,7 @@ function historicalSessionPage(value) {
256
256
  return { sessions: value };
257
257
  return value;
258
258
  }
259
- export async function resumeSession(ws, config, sessionId, requestId, fallback, announceSuccess = true, syncDirection = "latest") {
259
+ export async function resumeSession(ws, config, sessionId, requestId, fallback, announceSuccess = true, syncDirection = "auto") {
260
260
  const existing = sessions.get(sessionId);
261
261
  if (existing) {
262
262
  const refreshed = await refreshRuntimeSessionName(existing);
@@ -584,33 +584,37 @@ async function backfillHistoricalEvents(ws, config, session, runtimeSessionId =
584
584
  throw error;
585
585
  historicalEvents = [];
586
586
  }
587
- const tailWindow = historicalTailWindowByTurn(historicalEvents, previousBackfill?.totalEvents);
587
+ const direction = options.direction ?? "auto";
588
+ const plan = planAppendOnlyHistoricalBackfill(historicalEvents, previousBackfill, direction);
589
+ const tailWindow = plan.tailWindow;
588
590
  if (tailWindow.events.length > 0) {
589
591
  await emitRuntimeTailEvents(ws, config, session.sessionId, tailWindow.events, tailWindow.startIndex);
590
592
  }
591
- const cursor = normalizedBackfillCursor(previousBackfill, historicalEvents.length);
592
- const olderWindow = historicalEventsWindowByTurn(historicalEvents, cursor, historicalBackfillEventLimit);
593
+ const olderWindow = plan.olderWindow;
593
594
  if (olderWindow.events.length > 0) {
594
595
  await emitHistoricalEvents(ws, config, session.sessionId, olderWindow.events, olderWindow.startIndex);
595
596
  }
596
- const status = olderWindow.nextCursor > 0 ? "partial" : "completed";
597
- const computedHead = latestCompletedHistoricalHead(session.sessionId, historicalEvents, {
598
- ...(previousBackfill?.totalEvents !== undefined && previousBackfill.head === undefined
599
- ? { preferTailBasisForExistingEvents: true }
600
- : {}),
601
- ...(previousBackfill?.totalEvents !== undefined ? { previousTotalEvents: previousBackfill.totalEvents } : {}),
602
- ...(tailWindow.events.length > 0 ? { tailStartIndex: tailWindow.startIndex } : {}),
603
- });
604
- const head = preferPersistedHistoricalHeadBasis(computedHead, previousBackfill?.head);
597
+ const computedHead = direction === "older" && previousBackfill
598
+ ? previousBackfill.head
599
+ : latestCompletedHistoricalHead(session.sessionId, historicalEvents, {
600
+ ...(previousBackfill?.totalEvents !== undefined && previousBackfill.head === undefined
601
+ ? { preferTailBasisForExistingEvents: true }
602
+ : {}),
603
+ ...(previousBackfill?.totalEvents !== undefined ? { previousTotalEvents: previousBackfill.totalEvents } : {}),
604
+ ...(tailWindow.events.length > 0 ? { tailStartIndex: tailWindow.startIndex } : {}),
605
+ });
606
+ const head = direction === "older" && previousBackfill
607
+ ? previousBackfill.head
608
+ : preferPersistedHistoricalHeadBasis(computedHead, previousBackfill?.head);
605
609
  const result = {
606
610
  emittedEvents: tailWindow.events.length + olderWindow.events.length,
607
611
  availableEvents: historicalEvents.length,
608
- cursor: olderWindow.nextCursor,
612
+ cursor: plan.cursor,
609
613
  olderBackfilledEvents: olderWindow.events.length,
610
- status,
614
+ status: plan.status,
611
615
  tailRepairedEvents: tailWindow.events.length,
612
616
  totalEvents: historicalEvents.length,
613
- truncated: status === "partial",
617
+ truncated: plan.truncated,
614
618
  ...(head ? { head } : {}),
615
619
  };
616
620
  if (head) {
@@ -652,6 +656,23 @@ function normalizedBackfillCursor(previousBackfill, totalEvents) {
652
656
  return 0;
653
657
  return Math.max(0, Math.min(previousBackfill.cursor, totalEvents));
654
658
  }
659
+ export function planAppendOnlyHistoricalBackfill(historicalEvents, previousBackfill, direction = "auto", maxEvents = historicalBackfillEventLimit) {
660
+ const tailWindow = direction === "latest" || direction === "auto"
661
+ ? historicalTailWindowByTurn(historicalEvents, previousBackfill?.totalEvents)
662
+ : { events: [], startIndex: historicalEvents.length };
663
+ const cursor = normalizedBackfillCursor(previousBackfill, historicalEvents.length);
664
+ const olderWindow = direction === "older" || direction === "auto" || previousBackfill === undefined
665
+ ? historicalEventsWindowByTurn(historicalEvents, cursor, maxEvents)
666
+ : { events: [], nextCursor: cursor, startIndex: cursor };
667
+ const status = olderWindow.nextCursor > 0 ? "partial" : "completed";
668
+ return {
669
+ cursor: olderWindow.nextCursor,
670
+ olderWindow,
671
+ status,
672
+ tailWindow,
673
+ truncated: status === "partial",
674
+ };
675
+ }
655
676
  function historicalTailWindowByTurn(events, previousTotalEvents) {
656
677
  if (previousTotalEvents === undefined || events.length <= previousTotalEvents) {
657
678
  return { events: [], startIndex: events.length };
@@ -726,6 +747,7 @@ function historicalBackfillMetadata(backfill) {
726
747
  historicalOlderBackfilledEvents: backfill.olderBackfilledEvents,
727
748
  historicalTailRepairedEvents: backfill.tailRepairedEvents,
728
749
  historicalHead: backfill.head,
750
+ runtimeCurrentEventCount: backfill.totalEvents,
729
751
  availableHistoricalEvents: backfill.availableEvents,
730
752
  truncatedHistoricalBackfill: backfill.truncated,
731
753
  };
@@ -735,7 +757,7 @@ export async function handleResume(ws, config, command) {
735
757
  return;
736
758
  if (!claimCommandRequest(ws, command.requestId))
737
759
  return;
738
- await resumeSession(ws, config, command.sessionId, command.requestId, await sessionFallbackFromCommand(config, command), true, command.syncDirection ?? "latest");
760
+ await resumeSession(ws, config, command.sessionId, command.requestId, await sessionFallbackFromCommand(config, command), true, command.syncDirection ?? "auto");
739
761
  }
740
762
  export async function handleClose(ws, config, command) {
741
763
  if (command.type !== "machine:close")
@@ -4,6 +4,7 @@ import { getRuntime } from "../runtime.js";
4
4
  import { sessions, sessionTurns } from "../state.js";
5
5
  import { sendCommandResponse } from "../relay/send.js";
6
6
  import { settleScheduleTurnTerminal } from "../schedule/runner.js";
7
+ import { emitEvent } from "./events.js";
7
8
  import { commandSessionMetadata } from "./metadata.js";
8
9
  export async function handleReconcileTurn(ws, config, command) {
9
10
  if (command.type !== "machine:reconcileTurn")
@@ -89,11 +90,31 @@ export async function handleReconcileTurn(ws, config, command) {
89
90
  result,
90
91
  },
91
92
  });
93
+ if (isUnconfirmedReconcileResult(result) && result.turnId) {
94
+ await emitEvent(ws, config, command.sessionId, result.turnId, {
95
+ type: "status",
96
+ tag: `reconcile:${result.status}`,
97
+ text: reconcileStatusText(result),
98
+ });
99
+ }
92
100
  await sendReconcileResult(ws, config, command, result);
93
101
  if (result.status === "terminal" && result.terminalStatus && result.turnId) {
94
102
  await settleTerminalSideEffects(config, result);
95
103
  }
96
104
  }
105
+ function isUnconfirmedReconcileResult(result) {
106
+ return result.status === "unknown" || result.status === "not_found" || result.status === "uninspectable";
107
+ }
108
+ function reconcileStatusText(result) {
109
+ const reason = result.reason ? ` Reason: ${result.reason}` : "";
110
+ if (result.status === "not_found") {
111
+ return `Unable to confirm whether the previous execution finished; provider reported authoritative not found.${reason}`;
112
+ }
113
+ if (result.status === "uninspectable") {
114
+ return `Unable to confirm whether the previous execution finished; this runtime cannot inspect the turn.${reason}`;
115
+ }
116
+ return `Unable to confirm whether the previous execution finished.${reason}`;
117
+ }
97
118
  async function sendReconcileResult(ws, config, command, result) {
98
119
  await sendCommandResponse(ws, {
99
120
  type: "machine:turnReconciled",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-daemon",
3
- "version": "0.1.0-beta.279",
3
+ "version": "0.1.0-beta.284",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -6,6 +6,7 @@ export declare function createConnectionState(): {
6
6
  pendingCommandRequests: Map<string, {
7
7
  accountId: string;
8
8
  command?: string;
9
+ createdAt: number;
9
10
  machineId: string;
10
11
  requestId: string;
11
12
  sessionId?: string;
@@ -32,7 +32,7 @@ export function handleMachineMessage(context, connection, message) {
32
32
  updated_at = ?
33
33
  WHERE id = ? AND account_id = ? AND machine_id = ? AND status != 'closed'`).run(message.name ?? null, message.encryptedMetadata ? JSON.stringify(message.encryptedMetadata) : null, ts, message.sessionId, connection.accountId, connection.machineId);
34
34
  const row = db.prepare("SELECT * FROM sessions WHERE id = ?").get(message.sessionId);
35
- if (row)
35
+ if (row && row.status !== "closed")
36
36
  broadcastControllers(connection.accountId, { type: "server:session", session: sessionSnapshot(row) });
37
37
  broadcastAck(connection.accountId, {
38
38
  type: "server:ack",
@@ -52,7 +52,7 @@ export function handleMachineMessage(context, connection, message) {
52
52
  updated_at = ?
53
53
  WHERE id = ? AND account_id = ? AND machine_id = ? AND status != 'closed'`).run(message.name ?? null, message.encryptedMetadata ? JSON.stringify(message.encryptedMetadata) : null, ts, message.sessionId, connection.accountId, connection.machineId);
54
54
  const row = db.prepare("SELECT * FROM sessions WHERE id = ?").get(message.sessionId);
55
- if (row)
55
+ if (row && row.status !== "closed")
56
56
  broadcastControllers(connection.accountId, { type: "server:session", session: sessionSnapshot(row) });
57
57
  broadcastAck(connection.accountId, { type: "server:ack", requestId: message.requestId, ok: true });
58
58
  acknowledgeMachineMessage();
@@ -246,9 +246,9 @@ export function handleMachineMessage(context, connection, message) {
246
246
  (!message.result.turnId || message.result.turnId === claim?.turn_id);
247
247
  if (claimMatches && turnId) {
248
248
  db.prepare(`UPDATE sessions
249
- SET status = 'failed',
249
+ SET status = 'unknown',
250
250
  current_head = COALESCE(current_head, ?),
251
- last_turn_id = ?,
251
+ last_turn_id = COALESCE(last_turn_id, ?),
252
252
  updated_at = ?
253
253
  WHERE id = ? AND account_id = ? AND machine_id = ? AND status != 'closed'`).run(turnId, turnId, ts, message.result.sessionId, connection.accountId, connection.machineId);
254
254
  if (requestId) {
@@ -687,6 +687,10 @@ export function handleMachineMessage(context, connection, message) {
687
687
  .get(message.sessionId, connection.accountId, connection.machineId);
688
688
  if (!sessionRow)
689
689
  return;
690
+ if (sessionRow.status === "closed") {
691
+ acknowledgeMachineMessage();
692
+ return;
693
+ }
690
694
  maybeAliasHistoricalRuntimeEventToActiveClaim({
691
695
  db,
692
696
  accountId: connection.accountId,
@@ -6,6 +6,7 @@ import { createRelayRepositories } from "./repositories.js";
6
6
  import { filterSnapshotForScope, isFullControllerToken, messageForScopedController, scopedListAllows, scopedTokenPublic, tokenScope, } from "./scope.js";
7
7
  import { bearerToken, encodePairingCode, isPairingCodeMatch, tokenDigest } from "./security.js";
8
8
  const REWRITE_HEAD_BASIS_PREFIX = "rewrite:";
9
+ const PENDING_COMMAND_TTL_MS = 120_000;
9
10
  export function createRelayContext(options) {
10
11
  const { controllersByAccount, machinesByKey, pendingCommandRequests } = createConnectionState();
11
12
  const now = options.now ?? Date.now;
@@ -201,7 +202,19 @@ export function createRelayContext(options) {
201
202
  .get(accountId, sessionId);
202
203
  if (!claim)
203
204
  return undefined;
204
- if (isStaleSessionClaim(claim)) {
205
+ const staleResolution = staleSessionClaimResolution(claim);
206
+ if (staleResolution) {
207
+ if (staleResolution.terminalStatus) {
208
+ const ts = staleResolution.finishedAt ?? now();
209
+ options.db
210
+ .prepare(`UPDATE relay_admissions
211
+ SET finished_at = COALESCE(finished_at, ?),
212
+ terminal_status = COALESCE(terminal_status, ?),
213
+ last_error_code = COALESCE(?, last_error_code),
214
+ updated_at = ?
215
+ WHERE account_id = ? AND request_id = ?`)
216
+ .run(ts, staleResolution.terminalStatus, staleResolution.errorCode ?? null, ts, claim.account_id, claim.request_id);
217
+ }
205
218
  options.db
206
219
  .prepare("DELETE FROM relay_session_claims WHERE account_id = ? AND session_id = ? AND request_id = ?")
207
220
  .run(claim.account_id, claim.session_id, claim.request_id);
@@ -209,14 +222,14 @@ export function createRelayContext(options) {
209
222
  }
210
223
  return claim;
211
224
  }
212
- function isStaleSessionClaim(claim) {
225
+ function staleSessionClaimResolution(claim) {
213
226
  const admission = options.db
214
227
  .prepare(`SELECT dispatch, finished_at, terminal_status
215
228
  FROM relay_admissions
216
229
  WHERE account_id = ? AND request_id = ?`)
217
230
  .get(claim.account_id, claim.request_id);
218
231
  if (admission && (admission.finished_at !== null || admission.terminal_status !== null))
219
- return true;
232
+ return {};
220
233
  const session = options.db
221
234
  .prepare(`SELECT status, current_head, last_turn_id, updated_at
222
235
  FROM sessions
@@ -224,11 +237,13 @@ export function createRelayContext(options) {
224
237
  .get(claim.account_id, claim.session_id);
225
238
  if (admission?.dispatch === "delivered" &&
226
239
  session &&
227
- !["new", "running", "offline"].includes(session.status) &&
240
+ isSettledSessionProjectionStatus(session.status) &&
228
241
  session.updated_at > claim.created_at &&
229
- session.current_head !== claim.turn_id &&
230
- session.last_turn_id !== claim.turn_id) {
231
- return true;
242
+ (session.last_turn_id === claim.turn_id || (session.current_head !== claim.turn_id && session.last_turn_id !== claim.turn_id))) {
243
+ return {
244
+ terminalStatus: terminalStatusForSettledSessionStatus(session.status),
245
+ finishedAt: session.updated_at,
246
+ };
232
247
  }
233
248
  const newerTerminal = options.db
234
249
  .prepare(`SELECT 1
@@ -242,7 +257,17 @@ export function createRelayContext(options) {
242
257
  AND created_at > ?
243
258
  LIMIT 1`)
244
259
  .get(claim.account_id, claim.session_id, claim.request_id, claim.created_at);
245
- return Boolean(newerTerminal);
260
+ return newerTerminal ? {} : undefined;
261
+ }
262
+ function terminalStatusForSettledSessionStatus(status) {
263
+ if (status === "failed")
264
+ return "failed";
265
+ if (status === "cancelled" || status === "closed")
266
+ return "cancelled";
267
+ return "completed";
268
+ }
269
+ function isSettledSessionProjectionStatus(status) {
270
+ return status === "ready" || status === "completed" || status === "cancelled" || status === "closed";
246
271
  }
247
272
  function claimSessionTurn(params) {
248
273
  const existing = activeSessionClaim(params.accountId, params.sessionId);
@@ -290,13 +315,22 @@ export function createRelayContext(options) {
290
315
  function commandKey(accountId, requestId) {
291
316
  return `${accountId}:${requestId}`;
292
317
  }
318
+ function prunePendingCommands(ts = now()) {
319
+ for (const [key, pending] of pendingCommandRequests) {
320
+ if (ts - pending.createdAt > PENDING_COMMAND_TTL_MS)
321
+ pendingCommandRequests.delete(key);
322
+ }
323
+ }
293
324
  function markCommandPending(accountId, machineId, requestId, options = {}) {
294
- pendingCommandRequests.set(commandKey(accountId, requestId), { accountId, machineId, requestId, ...options });
325
+ prunePendingCommands();
326
+ pendingCommandRequests.set(commandKey(accountId, requestId), { accountId, machineId, requestId, createdAt: now(), ...options });
295
327
  }
296
328
  function isCommandPending(accountId, requestId) {
329
+ prunePendingCommands();
297
330
  return pendingCommandRequests.has(commandKey(accountId, requestId));
298
331
  }
299
332
  function isSessionCommandPending(accountId, sessionId) {
333
+ prunePendingCommands();
300
334
  for (const pending of pendingCommandRequests.values()) {
301
335
  if (pending.accountId === accountId && pending.sessionId === sessionId)
302
336
  return true;
@@ -522,10 +556,10 @@ export function createRelayContext(options) {
522
556
  head_basis_logical_time = ?,
523
557
  head_basis_turn_seq = ?,
524
558
  updated_at = ?
525
- WHERE id = ?`).run(`event:${eventId}`, params.turnId, eventId, params.messageId ?? null, order.time, order.seq, createdAt, params.sessionId);
559
+ WHERE id = ? AND status != 'closed'`).run(`event:${eventId}`, params.turnId, eventId, params.messageId ?? null, order.time, order.seq, createdAt, params.sessionId);
526
560
  }
527
561
  else {
528
- options.db.prepare("UPDATE sessions SET updated_at = ? WHERE id = ?").run(createdAt, params.sessionId);
562
+ options.db.prepare("UPDATE sessions SET updated_at = ? WHERE id = ? AND status != 'closed'").run(createdAt, params.sessionId);
529
563
  }
530
564
  return {
531
565
  id: eventId,
@@ -548,6 +582,8 @@ export function createRelayContext(options) {
548
582
  WHERE account_id = ? AND id = ? AND machine_id = ?`)
549
583
  .get(params.accountId, params.sessionId, params.machineId);
550
584
  const currentHead = session?.current_head;
585
+ if (session?.status === "closed")
586
+ return false;
551
587
  if (!currentHead)
552
588
  return true;
553
589
  if (typeof session?.head_basis_message_id === "string" &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves",
3
- "version": "0.1.0-beta.279",
3
+ "version": "0.1.0-beta.284",
4
4
  "description": "Remote controller for local coding agents with hosted or self-hosted relay support.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -59,6 +59,11 @@ export declare class ControllerClient {
59
59
  revokedAt: number;
60
60
  }>;
61
61
  listSessions(filter?: SessionFilter): Promise<SessionSnapshot[]>;
62
+ findLoadedRuntimeSession(input: {
63
+ machineId: string;
64
+ agent: string;
65
+ runtimeSessionId: string;
66
+ }): Promise<SessionSnapshot | undefined>;
62
67
  getSession(sessionId: string): Promise<SessionSnapshot | undefined>;
63
68
  history(sessionId: string, limit?: number): Promise<DecodedSessionEvent[]>;
64
69
  createSession(input: CreateSessionInput): Promise<CommandResult>;
@@ -7,6 +7,29 @@ import { sendAndWaitForDirectoryListing, sendAndWaitForDaemonLifecycleResult, se
7
7
  import { DEFAULT_SESSION_CREATE_WAIT_MS, } from "./types.js";
8
8
  import { isWaitCondition, matchesWaitCondition, parseEventCursor, parseEventLimit, parsePositiveDuration, requireNonEmpty, sleep, } from "./validation.js";
9
9
  const DEFAULT_BLOCKING_RUN_WAIT_MS = 30 * 60 * 1000;
10
+ function addRuntimeSessionId(ids, value) {
11
+ if (typeof value === "string" && value.trim())
12
+ ids.add(value.trim());
13
+ }
14
+ function runtimeSessionIdsForMetadata(metadata) {
15
+ const ids = new Set();
16
+ addRuntimeSessionId(ids, metadata.importedFromRuntimeSessionId);
17
+ const runtime = metadata.runtime;
18
+ if (runtime && typeof runtime === "object" && !Array.isArray(runtime)) {
19
+ const record = runtime;
20
+ addRuntimeSessionId(ids, record.backendSessionId);
21
+ addRuntimeSessionId(ids, record.agentSessionId);
22
+ addRuntimeSessionId(ids, record.acpxRecordId);
23
+ const externalHandle = record.externalHandle;
24
+ if (externalHandle && typeof externalHandle === "object" && !Array.isArray(externalHandle)) {
25
+ addRuntimeSessionId(ids, externalHandle.sessionId);
26
+ }
27
+ }
28
+ return ids;
29
+ }
30
+ function isCanonicalRuntimeSessionCandidate(left, right) {
31
+ return left.createdAt < right.createdAt || (left.createdAt === right.createdAt && left.id < right.id);
32
+ }
10
33
  function runSettlementTimeoutMs(options) {
11
34
  if (options.waitTimeoutMs !== undefined)
12
35
  return options.waitTimeoutMs;
@@ -169,6 +192,24 @@ export class ControllerClient {
169
192
  }
170
193
  return result;
171
194
  }
195
+ async findLoadedRuntimeSession(input) {
196
+ const machineId = requireNonEmpty(input.machineId, "machineId");
197
+ const agent = requireNonEmpty(input.agent, "agent");
198
+ const runtimeSessionId = requireNonEmpty(input.runtimeSessionId, "runtimeSessionId");
199
+ let selected;
200
+ for (const session of (await this.snapshot()).sessions) {
201
+ if (session.status === "closed")
202
+ continue;
203
+ if (session.machineId !== machineId || session.agent !== agent)
204
+ continue;
205
+ const metadata = await this.decodeSessionMetadata(session);
206
+ if (!runtimeSessionIdsForMetadata(metadata).has(runtimeSessionId))
207
+ continue;
208
+ if (!selected || isCanonicalRuntimeSessionCandidate(session, selected))
209
+ selected = session;
210
+ }
211
+ return selected;
212
+ }
172
213
  async getSession(sessionId) {
173
214
  const checkedSessionId = requireNonEmpty(sessionId, "sessionId");
174
215
  return (await this.snapshot()).sessions.find((session) => session.id === checkedSessionId);
@@ -493,6 +534,9 @@ export class ControllerClient {
493
534
  const agent = requireNonEmpty(input.agent, "agent");
494
535
  const runtimeSessionId = requireNonEmpty(input.runtimeSessionId, "runtimeSessionId");
495
536
  const name = requireNonEmpty(input.name, "name");
537
+ const existing = await this.findLoadedRuntimeSession({ machineId, agent, runtimeSessionId });
538
+ if (existing)
539
+ return { sessionId: existing.id, mode: "existing", events: [] };
496
540
  const requestId = randomId("req");
497
541
  const result = await this.sendCommand({
498
542
  type: "controller:importHistoricalSession",
@@ -13,6 +13,7 @@ export type CommandResult = {
13
13
  turnId?: string;
14
14
  admission?: "accepted" | "replayed";
15
15
  dispatch?: "queued" | "delivered";
16
+ mode?: "existing";
16
17
  events: EncryptedSessionEvent[];
17
18
  };
18
19
  export type DecodedSessionEvent = EncryptedSessionEvent & {
@@ -211,6 +211,7 @@ export declare const sessionSnapshotSchema: z.ZodObject<{
211
211
  new: "new";
212
212
  ready: "ready";
213
213
  running: "running";
214
+ unknown: "unknown";
214
215
  completed: "completed";
215
216
  failed: "failed";
216
217
  cancelled: "cancelled";
@@ -457,9 +458,9 @@ export declare const daemonLifecycleResultSchema: z.ZodObject<{
457
458
  platform: z.ZodOptional<z.ZodString>;
458
459
  arch: z.ZodOptional<z.ZodString>;
459
460
  installMode: z.ZodOptional<z.ZodEnum<{
461
+ unknown: "unknown";
460
462
  "npm-global": "npm-global";
461
463
  "repo-dev": "repo-dev";
462
- unknown: "unknown";
463
464
  }>>;
464
465
  updateSupported: z.ZodOptional<z.ZodBoolean>;
465
466
  updateUnsupportedReason: z.ZodOptional<z.ZodString>;
@@ -79,7 +79,7 @@ export const sessionSnapshotSchema = z.object({
79
79
  agent: z.string().min(1),
80
80
  cwd: z.string(),
81
81
  name: z.string().min(1),
82
- status: z.enum(["new", "ready", "running", "completed", "failed", "cancelled", "offline", "closed"]),
82
+ status: z.enum(["new", "ready", "running", "unknown", "completed", "failed", "cancelled", "offline", "closed"]),
83
83
  createdAt: z.number(),
84
84
  updatedAt: z.number(),
85
85
  encryptedMetadata: encryptedEnvelopeSchema.optional(),
@@ -57,7 +57,7 @@ export type SessionSnapshot = {
57
57
  agent: string;
58
58
  cwd: string;
59
59
  name: string;
60
- status: "new" | "ready" | "running" | "completed" | "failed" | "cancelled" | "offline" | "closed";
60
+ status: "new" | "ready" | "running" | "unknown" | "completed" | "failed" | "cancelled" | "offline" | "closed";
61
61
  createdAt: number;
62
62
  updatedAt: number;
63
63
  encryptedMetadata?: EncryptedEnvelope;
@@ -1,4 +1,4 @@
1
- export declare const sessionStatuses: readonly ["new", "ready", "running", "completed", "failed", "cancelled", "offline", "closed"];
1
+ export declare const sessionStatuses: readonly ["new", "ready", "running", "unknown", "completed", "failed", "cancelled", "offline", "closed"];
2
2
  export type SessionStatus = (typeof sessionStatuses)[number];
3
3
  export type ControllerSessionCommand = "prompt" | "cancel" | "resume" | "rewind" | "fork" | "rename" | "close";
4
4
  export type SessionCommandDecision = {
@@ -2,6 +2,7 @@ export const sessionStatuses = [
2
2
  "new",
3
3
  "ready",
4
4
  "running",
5
+ "unknown",
5
6
  "completed",
6
7
  "failed",
7
8
  "cancelled",
@@ -15,7 +16,7 @@ export function isSessionClosed(status) {
15
16
  return status === "closed";
16
17
  }
17
18
  export function isSessionBusy(status) {
18
- return status === "running";
19
+ return status === "running" || status === "unknown";
19
20
  }
20
21
  export function controllerSessionCommandDecision(status, command) {
21
22
  if (command === "close" && status === "closed")
@@ -27,11 +28,11 @@ export function controllerSessionCommandDecision(status, command) {
27
28
  message: "Session is closed",
28
29
  };
29
30
  }
30
- if (command === "prompt" && status === "running") {
31
+ if (command === "prompt" && isSessionBusy(status)) {
31
32
  return {
32
33
  allowed: false,
33
34
  code: "SESSION_BUSY",
34
- message: "Session already has a running turn",
35
+ message: status === "unknown" ? "Session has a turn whose execution status is unknown" : "Session already has a running turn",
35
36
  };
36
37
  }
37
38
  return { allowed: true };
@@ -43,6 +44,8 @@ export function statusAfterMachineDisconnect(status) {
43
44
  return "failed";
44
45
  if (status === "running")
45
46
  return "offline";
47
+ if (status === "unknown")
48
+ return "unknown";
46
49
  return status;
47
50
  }
48
51
  //# sourceMappingURL=session-state.js.map