@cydm/happy-elves 0.1.0-beta.277 → 0.1.0-beta.283

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.
@@ -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.277",
3
+ "version": "0.1.0-beta.283",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -1,6 +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
+ export type HistoricalBackfillDirection = "latest" | "older";
5
+ type HistoricalBackfillMode = HistoricalBackfillDirection | "auto";
4
6
  export type HistoricalBackfillResult = {
5
7
  availableEvents: number;
6
8
  cursor: number;
@@ -16,7 +18,7 @@ export type HistoricalBackfillResult = {
16
18
  export declare function handleCreateSession(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
17
19
  export declare function handleImportHistoricalSession(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
18
20
  export declare function handleListHistoricalSessions(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
19
- export declare function resumeSession(ws: WebSocket, config: DaemonConfig, sessionId: string, requestId: string, fallback?: SessionFallback, announceSuccess?: boolean): 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>;
20
22
  export declare function usesAuthoritativeTranscript(session: SessionState): boolean;
21
23
  export declare function replaceAuthoritativeTranscript(ws: WebSocket, config: DaemonConfig, session: SessionState, runtimeSessionId?: string | undefined, input?: {
22
24
  currentHead?: string;
@@ -26,5 +28,22 @@ export declare function replaceAuthoritativeTranscript(ws: WebSocket, config: Da
26
28
  status?: "completed" | "cancelled" | "failed";
27
29
  strict?: boolean;
28
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
+ };
29
47
  export declare function handleResume(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
30
48
  export declare function handleClose(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;
49
+ export {};
@@ -256,12 +256,14 @@ 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) {
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);
263
263
  const nameChanged = refreshed.name !== existing.name;
264
- const backfill = await backfillHistoricalEvents(ws, config, refreshed, undefined, refreshed.historicalBackfill ?? fallback?.historicalBackfill);
264
+ const backfill = await backfillHistoricalEvents(ws, config, refreshed, undefined, refreshed.historicalBackfill ?? fallback?.historicalBackfill, {
265
+ direction: syncDirection,
266
+ });
265
267
  const resumed = withHistoricalBackfill(refreshed, backfill);
266
268
  sessions.set(sessionId, resumed);
267
269
  await appendAudit({
@@ -311,7 +313,9 @@ export async function resumeSession(ws, config, sessionId, requestId, fallback,
311
313
  const state = await refreshRuntimeSessionName({ sessionId, agent, cwd, name, handle });
312
314
  sessions.set(sessionId, state);
313
315
  const nameChanged = state.name !== name;
314
- const backfill = await backfillHistoricalEvents(ws, config, state, resumeSessionId, fallback?.historicalBackfill);
316
+ const backfill = await backfillHistoricalEvents(ws, config, state, resumeSessionId, fallback?.historicalBackfill, {
317
+ direction: syncDirection,
318
+ });
315
319
  const resumed = withHistoricalBackfill(state, backfill);
316
320
  sessions.set(sessionId, resumed);
317
321
  await appendAudit({
@@ -558,7 +562,7 @@ async function backfillHistoricalEvents(ws, config, session, runtimeSessionId =
558
562
  if (!runtimeSessionId)
559
563
  return completedBackfillResult(0);
560
564
  if (usesAuthoritativeTranscript(session)) {
561
- if (previousBackfill?.status === "partial" && previousBackfill.providerCursor) {
565
+ if ((options.direction ?? "latest") === "older" && previousBackfill?.status === "partial" && previousBackfill.providerCursor) {
562
566
  return await prependAuthoritativeTranscript(ws, config, session, runtimeSessionId, previousBackfill, { strict: options.strict });
563
567
  }
564
568
  return await replaceAuthoritativeTranscript(ws, config, session, runtimeSessionId, { strict: options.strict });
@@ -580,33 +584,37 @@ async function backfillHistoricalEvents(ws, config, session, runtimeSessionId =
580
584
  throw error;
581
585
  historicalEvents = [];
582
586
  }
583
- const tailWindow = historicalTailWindowByTurn(historicalEvents, previousBackfill?.totalEvents);
587
+ const direction = options.direction ?? "auto";
588
+ const plan = planAppendOnlyHistoricalBackfill(historicalEvents, previousBackfill, direction);
589
+ const tailWindow = plan.tailWindow;
584
590
  if (tailWindow.events.length > 0) {
585
591
  await emitRuntimeTailEvents(ws, config, session.sessionId, tailWindow.events, tailWindow.startIndex);
586
592
  }
587
- const cursor = normalizedBackfillCursor(previousBackfill, historicalEvents.length);
588
- const olderWindow = historicalEventsWindowByTurn(historicalEvents, cursor, historicalBackfillEventLimit);
593
+ const olderWindow = plan.olderWindow;
589
594
  if (olderWindow.events.length > 0) {
590
595
  await emitHistoricalEvents(ws, config, session.sessionId, olderWindow.events, olderWindow.startIndex);
591
596
  }
592
- const status = olderWindow.nextCursor > 0 ? "partial" : "completed";
593
- const computedHead = latestCompletedHistoricalHead(session.sessionId, historicalEvents, {
594
- ...(previousBackfill?.totalEvents !== undefined && previousBackfill.head === undefined
595
- ? { preferTailBasisForExistingEvents: true }
596
- : {}),
597
- ...(previousBackfill?.totalEvents !== undefined ? { previousTotalEvents: previousBackfill.totalEvents } : {}),
598
- ...(tailWindow.events.length > 0 ? { tailStartIndex: tailWindow.startIndex } : {}),
599
- });
600
- 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);
601
609
  const result = {
602
610
  emittedEvents: tailWindow.events.length + olderWindow.events.length,
603
611
  availableEvents: historicalEvents.length,
604
- cursor: olderWindow.nextCursor,
612
+ cursor: plan.cursor,
605
613
  olderBackfilledEvents: olderWindow.events.length,
606
- status,
614
+ status: plan.status,
607
615
  tailRepairedEvents: tailWindow.events.length,
608
616
  totalEvents: historicalEvents.length,
609
- truncated: status === "partial",
617
+ truncated: plan.truncated,
610
618
  ...(head ? { head } : {}),
611
619
  };
612
620
  if (head) {
@@ -648,6 +656,23 @@ function normalizedBackfillCursor(previousBackfill, totalEvents) {
648
656
  return 0;
649
657
  return Math.max(0, Math.min(previousBackfill.cursor, totalEvents));
650
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
+ }
651
676
  function historicalTailWindowByTurn(events, previousTotalEvents) {
652
677
  if (previousTotalEvents === undefined || events.length <= previousTotalEvents) {
653
678
  return { events: [], startIndex: events.length };
@@ -722,6 +747,7 @@ function historicalBackfillMetadata(backfill) {
722
747
  historicalOlderBackfilledEvents: backfill.olderBackfilledEvents,
723
748
  historicalTailRepairedEvents: backfill.tailRepairedEvents,
724
749
  historicalHead: backfill.head,
750
+ runtimeCurrentEventCount: backfill.totalEvents,
725
751
  availableHistoricalEvents: backfill.availableEvents,
726
752
  truncatedHistoricalBackfill: backfill.truncated,
727
753
  };
@@ -731,7 +757,7 @@ export async function handleResume(ws, config, command) {
731
757
  return;
732
758
  if (!claimCommandRequest(ws, command.requestId))
733
759
  return;
734
- await resumeSession(ws, config, command.sessionId, command.requestId, await sessionFallbackFromCommand(config, command));
760
+ await resumeSession(ws, config, command.sessionId, command.requestId, await sessionFallbackFromCommand(config, command), true, command.syncDirection ?? "auto");
735
761
  }
736
762
  export async function handleClose(ws, config, command) {
737
763
  if (command.type !== "machine:close")
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-daemon",
3
- "version": "0.1.0-beta.277",
3
+ "version": "0.1.0-beta.283",
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;
@@ -354,6 +354,7 @@ export function handleControllerMessage(context, connection, message) {
354
354
  type: "machine:resume",
355
355
  requestId: message.requestId,
356
356
  sessionId: message.sessionId,
357
+ ...(message.syncDirection ? { syncDirection: message.syncDirection } : {}),
357
358
  encryptedMetadata: sessionRow.encrypted_metadata ? parseJson(sessionRow.encrypted_metadata, undefined) : undefined,
358
359
  });
359
360
  return;
@@ -784,21 +785,20 @@ export function handleControllerMessage(context, connection, message) {
784
785
  sendAck(connection, { type: "server:ack", requestId: message.requestId, ok: true });
785
786
  return;
786
787
  }
788
+ const closedSession = setSessionStatus(sessionRow.id, "closed");
789
+ finishSessionClaim({
790
+ accountId: connection.accountId,
791
+ sessionId: sessionRow.id,
792
+ terminalStatus: "cancelled",
793
+ errorCode: "SESSION_CLOSED",
794
+ });
795
+ if (closedSession)
796
+ broadcastControllers(connection.accountId, { type: "server:session", session: closedSession });
797
+ sendAck(connection, { type: "server:ack", requestId: message.requestId, ok: true });
787
798
  const machineConnection = connectedMachine(connection.accountId, sessionRow.machine_id);
788
799
  if (!machineConnection) {
789
- const session = setSessionStatus(sessionRow.id, "closed");
790
- finishSessionClaim({
791
- accountId: connection.accountId,
792
- sessionId: sessionRow.id,
793
- terminalStatus: "cancelled",
794
- errorCode: "SESSION_CLOSED",
795
- });
796
- if (session)
797
- broadcastControllers(connection.accountId, { type: "server:session", session });
798
- sendAck(connection, { type: "server:ack", requestId: message.requestId, ok: true });
799
800
  return;
800
801
  }
801
- markCommandPending(connection.accountId, sessionRow.machine_id, message.requestId);
802
802
  send(machineConnection.socket, {
803
803
  type: "machine:close",
804
804
  requestId: message.requestId,
@@ -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();
@@ -223,7 +223,7 @@ export function handleMachineMessage(context, connection, message) {
223
223
  current_head = COALESCE(?, current_head),
224
224
  last_turn_id = COALESCE(?, last_turn_id),
225
225
  updated_at = ?
226
- WHERE id = ? AND account_id = ? AND machine_id = ?`).run(message.result.terminalStatus, message.result.currentHead ?? null, message.result.lastTurnId ?? message.result.turnId ?? null, ts, message.result.sessionId, connection.accountId, connection.machineId);
226
+ WHERE id = ? AND account_id = ? AND machine_id = ? AND status != 'closed'`).run(message.result.terminalStatus, message.result.currentHead ?? null, message.result.lastTurnId ?? message.result.turnId ?? null, ts, message.result.sessionId, connection.accountId, connection.machineId);
227
227
  finishSessionClaim({
228
228
  accountId: connection.accountId,
229
229
  sessionId: message.result.sessionId,
@@ -237,6 +237,30 @@ export function handleMachineMessage(context, connection, message) {
237
237
  if (row)
238
238
  broadcastControllers(connection.accountId, { type: "server:session", session: sessionSnapshot(row) });
239
239
  }
240
+ if (message.result.status === "unknown" || message.result.status === "not_found" || message.result.status === "uninspectable") {
241
+ const claim = activePromptClaim(db, connection.accountId, message.result.sessionId, connection.machineId);
242
+ const turnId = message.result.turnId ?? claim?.turn_id;
243
+ const requestId = message.result.requestId ?? claim?.request_id;
244
+ const claimMatches = Boolean(claim) &&
245
+ (!message.result.requestId || message.result.requestId === claim?.request_id) &&
246
+ (!message.result.turnId || message.result.turnId === claim?.turn_id);
247
+ if (claimMatches && turnId) {
248
+ db.prepare(`UPDATE sessions
249
+ SET status = 'failed',
250
+ current_head = COALESCE(current_head, ?),
251
+ last_turn_id = ?,
252
+ updated_at = ?
253
+ WHERE id = ? AND account_id = ? AND machine_id = ? AND status != 'closed'`).run(turnId, turnId, ts, message.result.sessionId, connection.accountId, connection.machineId);
254
+ if (requestId) {
255
+ db.prepare(`UPDATE relay_admissions
256
+ SET last_error_code = COALESCE(last_error_code, ?), updated_at = ?
257
+ WHERE account_id = ? AND session_id = ? AND request_id = ?`).run(message.result.status === "not_found" ? "EXECUTION_NOT_FOUND" : "EXECUTION_UNKNOWN", ts, connection.accountId, message.result.sessionId, requestId);
258
+ }
259
+ const row = db.prepare("SELECT * FROM sessions WHERE id = ?").get(message.result.sessionId);
260
+ if (row)
261
+ broadcastControllers(connection.accountId, { type: "server:session", session: sessionSnapshot(row) });
262
+ }
263
+ }
240
264
  rememberCommandResponse(connection.accountId, message.requestId, response);
241
265
  clearCommandPending(connection.accountId, message.requestId);
242
266
  broadcastControllers(connection.accountId, response);
@@ -371,6 +395,10 @@ export function handleMachineMessage(context, connection, message) {
371
395
  acknowledgeMachineMessage();
372
396
  return;
373
397
  }
398
+ if (sessionRow.status === "closed") {
399
+ acknowledgeMachineMessage();
400
+ return;
401
+ }
374
402
  const replacementBasis = message.basis;
375
403
  const replacementBasisEvent = replacementBasis
376
404
  ? message.events.find((event) => event.messageId === replacementBasis.eventMessageId &&
@@ -508,6 +536,10 @@ export function handleMachineMessage(context, connection, message) {
508
536
  acknowledgeMachineMessage();
509
537
  return;
510
538
  }
539
+ if (sessionRow.status === "closed") {
540
+ acknowledgeMachineMessage();
541
+ return;
542
+ }
511
543
  const ts = now();
512
544
  const inserted = [];
513
545
  db.exec("BEGIN");
@@ -570,6 +602,10 @@ export function handleMachineMessage(context, connection, message) {
570
602
  const sessionBefore = db
571
603
  .prepare("SELECT * FROM sessions WHERE id = ? AND account_id = ? AND machine_id = ?")
572
604
  .get(message.sessionId, connection.accountId, connection.machineId);
605
+ if (sessionBefore?.status === "closed") {
606
+ acknowledgeMachineMessage();
607
+ return;
608
+ }
573
609
  const activeClaim = sessionBefore
574
610
  ? activePromptClaim(db, connection.accountId, message.sessionId, connection.machineId)
575
611
  : undefined;
@@ -651,6 +687,10 @@ export function handleMachineMessage(context, connection, message) {
651
687
  .get(message.sessionId, connection.accountId, connection.machineId);
652
688
  if (!sessionRow)
653
689
  return;
690
+ if (sessionRow.status === "closed") {
691
+ acknowledgeMachineMessage();
692
+ return;
693
+ }
654
694
  maybeAliasHistoricalRuntimeEventToActiveClaim({
655
695
  db,
656
696
  accountId: connection.accountId,
@@ -709,6 +749,18 @@ export function handleMachineMessage(context, connection, message) {
709
749
  acknowledgeMachineMessage();
710
750
  return;
711
751
  }
752
+ if (sessionRow.status === "closed") {
753
+ finishSessionClaim({
754
+ accountId: connection.accountId,
755
+ sessionId: message.sessionId,
756
+ turnId: message.turnId,
757
+ terminalStatus: status,
758
+ ...(message.error ? { errorCode: "TURN_ERROR" } : {}),
759
+ finishedAt: ts,
760
+ });
761
+ acknowledgeMachineMessage();
762
+ return;
763
+ }
712
764
  const currentOrder = sessionHeadBasisOrder(db, sessionRow);
713
765
  const basisComparison = basisOrder ? compareHeadBasisOrder(basisOrder, currentOrder) : null;
714
766
  const isRuntimeAliasForCurrentRequest = Boolean(runtimeBasisEvent && runtimeTurnId !== message.turnId && (sessionRow.last_turn_id === message.turnId || requestBasisEvent));
@@ -860,6 +912,8 @@ function applySessionHeadAdvance(db, input, ts) {
860
912
  .get(input.sessionId, input.accountId, input.machineId);
861
913
  if (!sessionRow)
862
914
  return { status: "session-not-found" };
915
+ if (sessionRow.status === "closed")
916
+ return { status: "noop", session: sessionRow };
863
917
  const basisEvent = resolveHeadAdvanceBasisEvent(db, input.accountId, input.sessionId, input.machineId, input.basis);
864
918
  if (!basisEvent)
865
919
  return { status: "missing", session: sessionRow };
@@ -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;
@@ -366,6 +400,29 @@ export function createRelayContext(options) {
366
400
  if (!machine)
367
401
  return "queued";
368
402
  const command = JSON.parse(row.message);
403
+ if (command.type === "machine:prompt") {
404
+ const session = options.db
405
+ .prepare("SELECT status FROM sessions WHERE account_id = ? AND id = ? AND machine_id = ?")
406
+ .get(accountId, command.sessionId, row.machine_id);
407
+ if (session?.status === "closed") {
408
+ const ts = now();
409
+ options.db
410
+ .prepare(`UPDATE relay_dispatch_outbox
411
+ SET state = 'delivered', delivered_at = ?, updated_at = ?
412
+ WHERE account_id = ? AND request_id = ? AND state = 'queued'`)
413
+ .run(ts, ts, accountId, requestId);
414
+ finishSessionClaim({
415
+ accountId,
416
+ sessionId: command.sessionId,
417
+ requestId,
418
+ turnId: command.turnId,
419
+ terminalStatus: "cancelled",
420
+ errorCode: "SESSION_CLOSED",
421
+ finishedAt: ts,
422
+ });
423
+ return "queued";
424
+ }
425
+ }
369
426
  send(machine.socket, command);
370
427
  const ts = now();
371
428
  options.db
@@ -499,10 +556,10 @@ export function createRelayContext(options) {
499
556
  head_basis_logical_time = ?,
500
557
  head_basis_turn_seq = ?,
501
558
  updated_at = ?
502
- 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);
503
560
  }
504
561
  else {
505
- 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);
506
563
  }
507
564
  return {
508
565
  id: eventId,
@@ -525,6 +582,8 @@ export function createRelayContext(options) {
525
582
  WHERE account_id = ? AND id = ? AND machine_id = ?`)
526
583
  .get(params.accountId, params.sessionId, params.machineId);
527
584
  const currentHead = session?.current_head;
585
+ if (session?.status === "closed")
586
+ return false;
528
587
  if (!currentHead)
529
588
  return true;
530
589
  if (typeof session?.head_basis_message_id === "string" &&
@@ -635,7 +694,9 @@ export function createRelayContext(options) {
635
694
  }
636
695
  function setSessionStatus(sessionId, status) {
637
696
  const ts = now();
638
- options.db.prepare("UPDATE sessions SET status = ?, updated_at = ? WHERE id = ?").run(status, ts, sessionId);
697
+ options.db
698
+ .prepare("UPDATE sessions SET status = ?, updated_at = ? WHERE id = ? AND (status != 'closed' OR ? = 'closed')")
699
+ .run(status, ts, sessionId, status);
639
700
  const row = options.db.prepare("SELECT * FROM sessions WHERE id = ?").get(sessionId);
640
701
  return row ? sessionSnapshot(row) : undefined;
641
702
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves",
3
- "version": "0.1.0-beta.277",
3
+ "version": "0.1.0-beta.283",
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>;
@@ -70,7 +75,9 @@ export declare class ControllerClient {
70
75
  collectPage(sessionId: string, input?: CollectInput): Promise<DecodedSessionEventsPage>;
71
76
  repairSessionHead(sessionId: string, input: RepairSessionHeadInput): Promise<RepairSessionHeadResult>;
72
77
  wait(sessionId: string, input?: WaitInput): Promise<SessionSnapshot>;
73
- resume(sessionId: string): Promise<CommandResult>;
78
+ resume(sessionId: string, options?: {
79
+ syncDirection?: "latest" | "older";
80
+ }): Promise<CommandResult>;
74
81
  listHistoricalSessionsPage(input: HistoricalSessionsInput): Promise<HistoricalSessionListPage>;
75
82
  listHistoricalSessions(input: HistoricalSessionsInput): Promise<HistoricalSessionSnapshot[]>;
76
83
  listDirectory(input: DirectoryInput): Promise<DirectoryListing>;
@@ -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);
@@ -302,10 +343,15 @@ export class ControllerClient {
302
343
  await sleep(intervalMs);
303
344
  }
304
345
  }
305
- async resume(sessionId) {
346
+ async resume(sessionId, options = {}) {
306
347
  const checkedSessionId = requireNonEmpty(sessionId, "sessionId");
307
348
  const requestId = randomId("req");
308
- return await this.sendCommand({ type: "controller:resume", requestId, sessionId: checkedSessionId });
349
+ return await this.sendCommand({
350
+ type: "controller:resume",
351
+ requestId,
352
+ sessionId: checkedSessionId,
353
+ ...(options.syncDirection ? { syncDirection: options.syncDirection } : {}),
354
+ });
309
355
  }
310
356
  async listHistoricalSessionsPage(input) {
311
357
  const machineId = requireNonEmpty(input.machineId, "machineId");
@@ -488,6 +534,9 @@ export class ControllerClient {
488
534
  const agent = requireNonEmpty(input.agent, "agent");
489
535
  const runtimeSessionId = requireNonEmpty(input.runtimeSessionId, "runtimeSessionId");
490
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: [] };
491
540
  const requestId = randomId("req");
492
541
  const result = await this.sendCommand({
493
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 & {
@@ -50,6 +50,7 @@ export type ControllerClientMessage = {
50
50
  type: "controller:resume";
51
51
  requestId: string;
52
52
  sessionId: string;
53
+ syncDirection?: "latest" | "older";
53
54
  } | {
54
55
  type: "controller:listHistoricalSessions";
55
56
  requestId: string;
@@ -384,6 +385,7 @@ export type MachineCommand = {
384
385
  type: "machine:resume";
385
386
  requestId: string;
386
387
  sessionId: string;
388
+ syncDirection?: "latest" | "older";
387
389
  encryptedMetadata?: EncryptedEnvelope;
388
390
  } | {
389
391
  type: "machine:listHistoricalSessions";
@@ -57,6 +57,7 @@ const controllerMessageSchema = z.discriminatedUnion("type", [
57
57
  type: z.literal("controller:resume"),
58
58
  requestId: z.string().min(1),
59
59
  sessionId: z.string().min(1),
60
+ syncDirection: z.enum(["latest", "older"]).optional(),
60
61
  }),
61
62
  z.object({
62
63
  type: z.literal("controller:listHistoricalSessions"),
@@ -458,6 +459,7 @@ const machineCommandSchema = z.discriminatedUnion("type", [
458
459
  type: z.literal("machine:resume"),
459
460
  requestId: z.string().min(1),
460
461
  sessionId: z.string().min(1),
462
+ syncDirection: z.enum(["latest", "older"]).optional(),
461
463
  encryptedMetadata: encryptedEnvelopeSchema.optional(),
462
464
  }),
463
465
  z.object({