@cydm/happy-elves 0.1.0-beta.40 → 0.1.0-beta.42

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.
@@ -27,6 +27,8 @@ const valueFlags = new Set([
27
27
  "reason",
28
28
  "relay",
29
29
  "root",
30
+ "runtime-limit",
31
+ "runtime-session",
30
32
  "shell",
31
33
  "actions",
32
34
  "expires-in",
@@ -58,6 +60,7 @@ const booleanFlags = new Set([
58
60
  "h",
59
61
  "include-archived",
60
62
  "json",
63
+ "keep-source",
61
64
  "local",
62
65
  "no-open",
63
66
  "no-wait",
@@ -50,7 +50,8 @@ Check local controller config, relay reachability, and daemon state.
50
50
  happy-elves session history --machine <machineId> [--cwd <path>] [--agent <agent>] [--include-archived] [--limit 20] --json
51
51
  happy-elves session continue <runtimeSessionId> --machine <machineId> [--name <name>] --json
52
52
  happy-elves session status <sessionId> [--verbose] [--json]
53
- happy-elves session diagnose <sessionId> [--limit 200] [--repair-head] [--json]
53
+ happy-elves session diagnose <sessionId> [--limit 200] [--runtime-limit 500] [--repair-head] [--json]
54
+ happy-elves session repair-binding <sessionId> [--runtime-session <runtimeSessionId>] [--runtime-limit 500] [--keep-source] --json
54
55
  happy-elves session close <sessionId> [--reason <reason>] --json
55
56
 
56
57
  Session management:
@@ -64,6 +65,8 @@ Session management:
64
65
  counters, and latest materialized relay page counts for debugging empty or
65
66
  stale transcripts. --repair-head advances a stale canonical head to the
66
67
  latest completed relay event when the relay already has the event basis.
68
+ repair-binding imports the matching runtime history row as a new loaded
69
+ session and closes the empty/stale source session unless --keep-source is set.
67
70
  close archives a loaded session. There is no separate archive command.
68
71
  `,
69
72
  turn: `Usage:
@@ -141,8 +144,9 @@ Core commands:
141
144
  session create --machine <machineId> [--agent codex] [--cwd <path>] [--name <name>] --json
142
145
  session history --machine <machineId> [--cwd <path>] [--limit 20] --json
143
146
  session continue <runtimeSessionId> --machine <machineId> --json
147
+ session repair-binding <sessionId> [--runtime-session <runtimeSessionId>] --json
144
148
  session status <sessionId> [--verbose] [--json]
145
- session diagnose <sessionId> [--limit 200] [--repair-head] [--json]
149
+ session diagnose <sessionId> [--limit 200] [--runtime-limit 500] [--repair-head] [--json]
146
150
  session close <sessionId> [--reason <reason>] --json
147
151
 
148
152
  turn list <sessionId> [--limit 10] [--json]
@@ -217,6 +221,9 @@ Session management:
217
221
  provider sessions unless --include-archived is set.
218
222
  session continue <runtimeSessionId> --machine <machineId> --json continues a
219
223
  runtime history row as a loaded session, or returns an existing sessionId.
224
+ session repair-binding <sessionId> --json recovers a loaded session that was
225
+ bound to the wrong/empty provider history by importing the matching runtime
226
+ history row as a new loaded session.
220
227
  session close <sessionId> --reason "archived" --json archives a loaded
221
228
  session. There is no separate archive command.
222
229
 
@@ -2,7 +2,7 @@ import { CliError } from "../../errors.js";
2
2
  import { showMachine } from "./session-view.js";
3
3
  export async function listWorkspaceHistoricalSessions(client, input) {
4
4
  const snapshot = await client.snapshot();
5
- const providerLimit = Math.min(200, Math.max(input.limit, 50));
5
+ const providerPageSize = Math.min(200, Math.max(Math.min(input.limit, 200), 50));
6
6
  const selectedMachines = snapshot.machines.filter((machine) => {
7
7
  if (input.machineId)
8
8
  return machine.id === input.machineId;
@@ -17,13 +17,26 @@ export async function listWorkspaceHistoricalSessions(client, input) {
17
17
  const rows = await Promise.all(machines.map(async ({ machine, projected }) => {
18
18
  const machineName = projected.metadata.name ?? projected.metadata.hostname ?? machine.name;
19
19
  try {
20
- const sessions = await client.listHistoricalSessions({
21
- machineId: machine.id,
22
- cwd: input.cwd,
23
- agent: input.agent,
24
- includeArchived: input.includeArchived,
25
- limit: providerLimit,
26
- });
20
+ const sessions = [];
21
+ let cursor;
22
+ const seenCursors = new Set();
23
+ while (sessions.length < input.limit) {
24
+ const page = await client.listHistoricalSessionsPage({
25
+ machineId: machine.id,
26
+ cwd: input.cwd,
27
+ agent: input.agent,
28
+ includeArchived: input.includeArchived,
29
+ limit: providerPageSize,
30
+ cursor,
31
+ });
32
+ sessions.push(...page.sessions);
33
+ if (!page.hasMore || !page.nextCursor)
34
+ break;
35
+ if (seenCursors.has(page.nextCursor))
36
+ break;
37
+ seenCursors.add(page.nextCursor);
38
+ cursor = page.nextCursor;
39
+ }
27
40
  return {
28
41
  sessions: sessions
29
42
  .filter((session) => input.includeArchived === true || session.runtimeState !== "archived")
@@ -125,6 +125,7 @@ function repairHeadInputForLatestCompletedEvent(events) {
125
125
  return {
126
126
  currentHead,
127
127
  lastTurnId,
128
+ status: "completed",
128
129
  basis: {
129
130
  turnId: doneEvent.turnId,
130
131
  eventMessageId: doneEvent.messageId,
@@ -217,6 +218,130 @@ function diagnosticSessionMetadata(metadata) {
217
218
  emptyRuntimeSession: metadata.emptyRuntimeSession === true,
218
219
  };
219
220
  }
221
+ function addRuntimeSessionId(ids, value) {
222
+ if (typeof value === "string" && value.trim())
223
+ ids.add(value.trim());
224
+ }
225
+ function metadataRuntimeSessionIds(metadata) {
226
+ const ids = new Set();
227
+ addRuntimeSessionId(ids, stringMetadata(metadata, "importedFromRuntimeSessionId"));
228
+ const runtime = isRecord(metadata.runtime) ? metadata.runtime : {};
229
+ addRuntimeSessionId(ids, runtime.backendSessionId);
230
+ addRuntimeSessionId(ids, runtime.agentSessionId);
231
+ addRuntimeSessionId(ids, runtime.acpxRecordId);
232
+ if (isRecord(runtime.externalHandle))
233
+ addRuntimeSessionId(ids, runtime.externalHandle.sessionId);
234
+ return ids;
235
+ }
236
+ function normalizedMatchValue(value) {
237
+ return typeof value === "string" && value.trim() ? value.trim().toLocaleLowerCase() : undefined;
238
+ }
239
+ function runtimeBindingCandidates(session, metadata, historicalSessions) {
240
+ const knownRuntimeIds = metadataRuntimeSessionIds(metadata);
241
+ const expectedAgent = stringMetadata(metadata, "agent") ?? session.agent;
242
+ const expectedCwd = stringMetadata(metadata, "cwd") ?? session.cwd;
243
+ const names = new Set([
244
+ normalizedMatchValue(stringMetadata(metadata, "name")),
245
+ normalizedMatchValue(stringMetadata(metadata, "providerName")),
246
+ normalizedMatchValue(session.name),
247
+ ].filter((value) => Boolean(value)));
248
+ const summaries = new Set([
249
+ normalizedMatchValue(stringMetadata(metadata, "importedSummary")),
250
+ normalizedMatchValue(session.name),
251
+ ].filter((value) => Boolean(value)));
252
+ return historicalSessions
253
+ .filter((historical) => historical.agent === expectedAgent &&
254
+ historical.cwd === expectedCwd &&
255
+ !knownRuntimeIds.has(historical.runtimeSessionId))
256
+ .map((historical) => {
257
+ const reasons = [];
258
+ let score = 0;
259
+ const historicalName = normalizedMatchValue(historical.name ?? historical.runtimeSessionName);
260
+ const historicalSummary = normalizedMatchValue(historical.summary);
261
+ if (historicalName && names.has(historicalName)) {
262
+ score += 100;
263
+ reasons.push("name");
264
+ }
265
+ if (historicalSummary && summaries.has(historicalSummary)) {
266
+ score += 30;
267
+ reasons.push("summary");
268
+ }
269
+ if (historical.loadedSessionId) {
270
+ score -= 20;
271
+ reasons.push("already-loaded");
272
+ }
273
+ if (score <= 0)
274
+ return undefined;
275
+ return {
276
+ runtimeSessionId: historical.runtimeSessionId,
277
+ agent: historical.agent,
278
+ cwd: historical.cwd,
279
+ name: historical.name ?? historical.runtimeSessionName,
280
+ summary: historical.summary,
281
+ updatedAt: historical.updatedAt,
282
+ score,
283
+ reasons,
284
+ ...(historical.runtimeState === "archived" ? { runtimeState: "archived" } : {}),
285
+ };
286
+ })
287
+ .filter((candidate) => Boolean(candidate))
288
+ .sort((left, right) => right.score - left.score || right.updatedAt - left.updatedAt || left.runtimeSessionId.localeCompare(right.runtimeSessionId));
289
+ }
290
+ function shouldSearchRuntimeBindingCandidates(latestEvents, metadata) {
291
+ const available = numberMetadata(metadata, "availableHistoricalEvents");
292
+ const emitted = numberMetadata(metadata, "backfilledEvents");
293
+ return latestEvents.length === 0 &&
294
+ (available === undefined || available === 0) &&
295
+ (emitted === undefined || emitted === 0);
296
+ }
297
+ function runtimeBindingSuspected(candidates) {
298
+ return candidates.some((candidate) => candidate.reasons.includes("name") && !candidate.reasons.includes("already-loaded"));
299
+ }
300
+ async function findRuntimeBindingCandidates(client, session, metadata, flags) {
301
+ const limit = parsePositiveIntFlag(flags, "runtime-limit") ?? 500;
302
+ const result = await listWorkspaceHistoricalSessions(client, {
303
+ machineId: session.machineId,
304
+ cwd: stringMetadata(metadata, "cwd") ?? session.cwd,
305
+ agent: stringMetadata(metadata, "agent") ?? session.agent,
306
+ includeArchived: true,
307
+ limit,
308
+ });
309
+ return {
310
+ candidates: runtimeBindingCandidates(session, metadata, result.sessions).slice(0, 8),
311
+ searched: true,
312
+ limit,
313
+ };
314
+ }
315
+ async function resolveRuntimeBindingRepairTarget(client, session, metadata, flags) {
316
+ const runtimeSessionId = typeof flags["runtime-session"] === "string" ? flags["runtime-session"].trim() : undefined;
317
+ const limit = parsePositiveIntFlag(flags, "runtime-limit") ?? 500;
318
+ const result = await listWorkspaceHistoricalSessions(client, {
319
+ machineId: session.machineId,
320
+ cwd: stringMetadata(metadata, "cwd") ?? session.cwd,
321
+ agent: stringMetadata(metadata, "agent") ?? session.agent,
322
+ includeArchived: true,
323
+ limit,
324
+ });
325
+ const candidateDiagnostics = runtimeBindingCandidates(session, metadata, result.sessions);
326
+ if (runtimeSessionId) {
327
+ const explicit = result.sessions.find((item) => item.runtimeSessionId === runtimeSessionId);
328
+ if (!explicit)
329
+ throw new CliError(`Runtime session not found in the first ${limit} historical rows: ${runtimeSessionId}`, "RUNTIME_SESSION_NOT_FOUND");
330
+ return { candidate: explicit, candidates: candidateDiagnostics.slice(0, 8) };
331
+ }
332
+ const usableCandidates = candidateDiagnostics.filter((candidate) => !candidate.reasons.includes("already-loaded"));
333
+ if (usableCandidates.length === 0) {
334
+ throw new CliError("No unambiguous runtime binding repair candidate found. Re-run with --runtime-session <id>.", "SESSION_BINDING_REPAIR_UNAVAILABLE");
335
+ }
336
+ const [first, second] = usableCandidates;
337
+ if (second && second.score === first.score) {
338
+ throw new CliError("Multiple runtime binding repair candidates found. Re-run with --runtime-session <id>.", "SESSION_BINDING_REPAIR_AMBIGUOUS");
339
+ }
340
+ const selected = result.sessions.find((item) => item.runtimeSessionId === first.runtimeSessionId);
341
+ if (!selected)
342
+ throw new CliError("Selected repair candidate disappeared from runtime history", "SESSION_BINDING_REPAIR_UNAVAILABLE");
343
+ return { candidate: selected, candidates: candidateDiagnostics.slice(0, 8) };
344
+ }
220
345
  export async function handleSession({ domain, action, positional, flags }) {
221
346
  if (domain !== "session")
222
347
  return false;
@@ -363,15 +488,15 @@ export async function handleSession({ domain, action, positional, flags }) {
363
488
  if (!initialSession)
364
489
  throw new CliError("Session not found", "SESSION_NOT_FOUND");
365
490
  let session = initialSession;
366
- if (flags["repair-head"] === true && session.status === "running") {
367
- throw new CliError("Cannot repair a running session head", "SESSION_HEAD_REPAIR_RUNNING");
368
- }
369
491
  const machine = snapshot.machines.find((item) => item.id === session.machineId);
370
492
  const metadata = await client.decodeSessionMetadata(session);
371
493
  const latestPage = await client.collectPage(session.id, { limit });
372
494
  const turns = summarizeTurns(latestPage.events);
373
495
  const latestCompletedTurn = turns.find((turn) => turn.status === "completed");
374
496
  const latestTerminalTurn = turns.find((turn) => turn.status !== "running");
497
+ const runtimeBinding = shouldSearchRuntimeBindingCandidates(latestPage.events, metadata)
498
+ ? await findRuntimeBindingCandidates(client, session, metadata, flags)
499
+ : { candidates: [], searched: false, limit: parsePositiveIntFlag(flags, "runtime-limit") ?? 500 };
375
500
  const repair = flags["repair-head"] === true
376
501
  ? await client.repairSessionHead(session.id, repairHeadInputForLatestCompletedEvent(latestPage.events))
377
502
  : undefined;
@@ -430,12 +555,25 @@ export async function handleSession({ domain, action, positional, flags }) {
430
555
  backfillAvailableEvents: numberMetadata(metadata, "availableHistoricalEvents"),
431
556
  backfillEmittedEvents: numberMetadata(metadata, "backfilledEvents"),
432
557
  backfillStatus: stringMetadata(metadata, "historicalBackfillStatus") ?? stringMetadata(metadata, "historicalBackfill"),
558
+ wrongRuntimeBindingSuspected: runtimeBindingSuspected(runtimeBinding.candidates),
559
+ runtimeBindingCandidateCount: runtimeBinding.candidates.length,
433
560
  staleHeadSuspected: staleHeadSuspected(session, latestCompletedTurn),
434
561
  headMatchesLatestCompletedTurn: headMatchesTurn(session, latestCompletedTurn),
435
562
  },
563
+ runtimeBinding: {
564
+ searched: runtimeBinding.searched,
565
+ searchLimit: runtimeBinding.limit,
566
+ suspected: runtimeBindingSuspected(runtimeBinding.candidates),
567
+ candidates: runtimeBinding.candidates,
568
+ repairCommand: runtimeBinding.candidates[0]
569
+ ? `happy-elves session repair-binding ${session.id} --runtime-session ${runtimeBinding.candidates[0].runtimeSessionId} --json`
570
+ : undefined,
571
+ },
436
572
  repair: repair ? {
437
573
  advanced: repair.advanced,
574
+ settled: repair.settled,
438
575
  basis: repair.basis,
576
+ status: repair.session.status,
439
577
  currentHead: repair.session.currentHead,
440
578
  lastTurnId: repair.session.lastTurnId,
441
579
  reason: repair.reason,
@@ -452,9 +590,59 @@ export async function handleSession({ domain, action, positional, flags }) {
452
590
  console.log(`Backfill: ${data.diagnosis.backfillStatus ?? "-"} available=${data.diagnosis.backfillAvailableEvents ?? "-"} emitted=${data.diagnosis.backfillEmittedEvents ?? "-"}`);
453
591
  console.log(`Head: ${session.currentHead ?? "-"} lastTurn=${session.lastTurnId ?? "-"} stale=${data.diagnosis.staleHeadSuspected ? "yes" : "no"}`);
454
592
  console.log(`Latest page: ${latestPage.events.length} events, visible=${latestPage.events.filter(isVisibleTranscriptPayloadEvent).length}, hasMore=${latestPage.hasMoreBefore}`);
593
+ if (data.runtimeBinding.suspected && data.runtimeBinding.candidates[0]) {
594
+ console.log(`Runtime binding: suspected wrong binding; candidate=${data.runtimeBinding.candidates[0].runtimeSessionId}`);
595
+ console.log(`Repair: ${data.runtimeBinding.repairCommand}`);
596
+ }
455
597
  }
456
598
  return true;
457
599
  }
600
+ if (action === "repair-binding") {
601
+ const sessionId = requirePositional(positional[0], "sessionId");
602
+ const client = await makeClient();
603
+ const session = await client.getSession(sessionId);
604
+ if (!session)
605
+ throw new CliError("Session not found", "SESSION_NOT_FOUND");
606
+ if (session.status === "running")
607
+ throw new CliError("Cannot repair binding for a running session", "SESSION_BINDING_REPAIR_RUNNING");
608
+ const metadata = await client.decodeSessionMetadata(session);
609
+ const { candidate, candidates } = await resolveRuntimeBindingRepairTarget(client, session, metadata, flags);
610
+ const name = typeof flags.name === "string" && flags.name.trim()
611
+ ? flags.name.trim()
612
+ : stringMetadata(metadata, "name") ?? session.name ?? candidate.name ?? candidate.summary ?? candidate.runtimeSessionId;
613
+ const imported = await client.importHistoricalSession({
614
+ machineId: session.machineId,
615
+ agent: candidate.agent,
616
+ cwd: candidate.cwd,
617
+ runtimeSessionId: candidate.runtimeSessionId,
618
+ name,
619
+ summary: candidate.summary,
620
+ runtimeState: candidate.runtimeState,
621
+ ...(candidate.name ? { providerName: candidate.name } : {}),
622
+ });
623
+ const closeSource = flags["keep-source"] !== true;
624
+ let sourceClosed = false;
625
+ if (closeSource) {
626
+ await client.close(session.id, `replaced by runtime session ${candidate.runtimeSessionId}`);
627
+ sourceClosed = true;
628
+ }
629
+ ok("session.repair-binding", {
630
+ sourceSessionId: session.id,
631
+ sessionId: imported.sessionId,
632
+ runtimeSessionId: candidate.runtimeSessionId,
633
+ mode: "created",
634
+ sourceClosed,
635
+ candidate: {
636
+ runtimeSessionId: candidate.runtimeSessionId,
637
+ name: candidate.name ?? candidate.runtimeSessionName,
638
+ summary: candidate.summary,
639
+ updatedAt: candidate.updatedAt,
640
+ ...(candidate.runtimeState === "archived" ? { runtimeState: "archived" } : {}),
641
+ },
642
+ candidates,
643
+ }, { requestId: imported.requestId, machineId: session.machineId, sessionId: imported.sessionId });
644
+ return true;
645
+ }
458
646
  if (action === "history") {
459
647
  const machineId = requireString(flags, "machine");
460
648
  const limit = parsePositiveIntFlag(flags, "limit") ?? 20;
@@ -4,7 +4,7 @@ import type { DaemonConfig } from "../types.js";
4
4
  export declare function emitEvent(ws: WebSocket, config: DaemonConfig, sessionId: string, turnId: string, payload: SessionEventPayload): Promise<void>;
5
5
  export declare function emitHistoricalEvents(ws: WebSocket, config: DaemonConfig, sessionId: string, events: RuntimeHistoricalEvent[], startIndex?: number): Promise<void>;
6
6
  export declare function emitRuntimeTailEvents(ws: WebSocket, config: DaemonConfig, sessionId: string, events: RuntimeHistoricalEvent[], startIndex?: number): Promise<void>;
7
- export declare function emitSessionHeadAdvanced(ws: WebSocket, sessionId: string, head: HistoricalSessionHead, encryptedMetadata?: EncryptedEnvelope): Promise<void>;
7
+ export declare function emitSessionHeadAdvanced(ws: WebSocket, sessionId: string, head: HistoricalSessionHead, encryptedMetadata?: EncryptedEnvelope, status?: "completed" | "cancelled" | "failed"): Promise<void>;
8
8
  export declare function emitTranscriptReplacement(ws: WebSocket, config: DaemonConfig, sessionId: string, events: RuntimeHistoricalEvent[], input?: {
9
9
  requestId?: string;
10
10
  currentHead?: string;
@@ -32,13 +32,14 @@ export async function emitRuntimeTailEvents(ws, config, sessionId, events, start
32
32
  }, ws);
33
33
  }
34
34
  }
35
- export async function emitSessionHeadAdvanced(ws, sessionId, head, encryptedMetadata) {
35
+ export async function emitSessionHeadAdvanced(ws, sessionId, head, encryptedMetadata, status) {
36
36
  await sendReliable({
37
37
  type: "machine:sessionHeadAdvanced",
38
38
  sessionId,
39
39
  currentHead: head.currentHead,
40
40
  lastTurnId: head.lastTurnId,
41
41
  basis: head.basis,
42
+ status,
42
43
  encryptedMetadata,
43
44
  }, ws);
44
45
  }
@@ -605,7 +605,7 @@ async function backfillHistoricalEvents(ws, config, session, runtimeSessionId =
605
605
  await emitSessionHeadAdvanced(ws, session.sessionId, head, await encryptedSessionMetadata(config, session.sessionId, backfilledSession, {
606
606
  ...historicalBackfillMetadata(result),
607
607
  historicalBackfilledAt: new Date().toISOString(),
608
- }));
608
+ }), "completed");
609
609
  }
610
610
  return result;
611
611
  }
@@ -121,7 +121,7 @@ export async function handlePrompt(ws, config, command) {
121
121
  currentHead: result.currentHead,
122
122
  lastTurnId: result.lastTurnId,
123
123
  });
124
- await refreshAuthoritativeTranscriptAfterTurn(ws, config, session, command.requestId, result.currentHead, result.lastTurnId);
124
+ await refreshAuthoritativeTranscriptAfterTurn(ws, config, session, command.requestId, result.currentHead, result.lastTurnId, result.status);
125
125
  releasePromptTurn();
126
126
  await sendReliable({
127
127
  type: "machine:turnDone",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves-daemon",
3
- "version": "0.1.0-beta.40",
3
+ "version": "0.1.0-beta.42",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -249,7 +249,7 @@ export function registerHttpRoutes(app, context, controllerInviteTtlMs) {
249
249
  .get(token.account_id, params.sessionId);
250
250
  if (!session)
251
251
  throw new HttpError(404, "SESSION_NOT_FOUND", "Session not found");
252
- if (session.status === "running") {
252
+ if (session.status === "running" && !body.status) {
253
253
  throw new HttpError(409, "SESSION_HEAD_REPAIR_RUNNING", "Cannot repair a running session head");
254
254
  }
255
255
  const basisEvent = context.db
@@ -265,18 +265,28 @@ export function registerHttpRoutes(app, context, controllerInviteTtlMs) {
265
265
  const currentOrder = sessionHeadBasisOrderForRow(context.db, session);
266
266
  const rewriteFenced = typeof session.head_basis_message_id === "string" &&
267
267
  session.head_basis_message_id.startsWith("rewrite:");
268
- const advanced = !rewriteFenced && compareHeadOrders(nextOrder, currentOrder) > 0;
269
- if (advanced) {
268
+ const comparison = compareHeadOrders(nextOrder, currentOrder);
269
+ const currentHeadAlreadySettled = session.current_head === `event:${basisEvent.id}` ||
270
+ session.current_head === body.currentHead ||
271
+ session.last_turn_id === body.lastTurnId;
272
+ const advanced = !rewriteFenced && comparison > 0;
273
+ const statusChanged = !rewriteFenced &&
274
+ body.status !== undefined &&
275
+ session.status === "running" &&
276
+ comparison === 0 &&
277
+ currentHeadAlreadySettled;
278
+ if (advanced || statusChanged) {
270
279
  const ts = context.now();
271
280
  context.db.prepare(`UPDATE sessions
272
- SET current_head = ?,
273
- last_turn_id = ?,
274
- head_basis_event_id = ?,
275
- head_basis_message_id = ?,
276
- head_basis_logical_time = ?,
277
- head_basis_turn_seq = ?,
281
+ SET status = COALESCE(?, status),
282
+ current_head = COALESCE(?, current_head),
283
+ last_turn_id = COALESCE(?, last_turn_id),
284
+ head_basis_event_id = COALESCE(?, head_basis_event_id),
285
+ head_basis_message_id = COALESCE(?, head_basis_message_id),
286
+ head_basis_logical_time = COALESCE(?, head_basis_logical_time),
287
+ head_basis_turn_seq = COALESCE(?, head_basis_turn_seq),
278
288
  updated_at = ?
279
- WHERE id = ? AND account_id = ?`).run(body.currentHead, body.lastTurnId, basisEvent.id, basisEvent.message_id, nextOrder.time, nextOrder.seq, ts, params.sessionId, token.account_id);
289
+ WHERE id = ? AND account_id = ?`).run(body.status && session.status === "running" ? body.status : null, advanced ? body.currentHead : null, advanced ? body.lastTurnId : null, advanced ? basisEvent.id : null, advanced ? basisEvent.message_id : null, advanced ? nextOrder.time : null, advanced ? nextOrder.seq : null, ts, params.sessionId, token.account_id);
280
290
  }
281
291
  const row = context.db
282
292
  .prepare("SELECT * FROM sessions WHERE account_id = ? AND id = ?")
@@ -294,6 +304,7 @@ export function registerHttpRoutes(app, context, controllerInviteTtlMs) {
294
304
  logicalTime: nextOrder.time,
295
305
  turnSeq: nextOrder.seq,
296
306
  },
307
+ ...(statusChanged ? { settled: true } : {}),
297
308
  ...(rewriteFenced ? { reason: "rewrite-fenced" } : {}),
298
309
  };
299
310
  });
@@ -49,6 +49,11 @@ export declare const sessionEventsQuerySchema: z.ZodObject<{
49
49
  export declare const sessionHeadAdvanceSchema: z.ZodObject<{
50
50
  currentHead: z.ZodString;
51
51
  lastTurnId: z.ZodString;
52
+ status: z.ZodOptional<z.ZodEnum<{
53
+ completed: "completed";
54
+ failed: "failed";
55
+ cancelled: "cancelled";
56
+ }>>;
52
57
  basis: z.ZodObject<{
53
58
  turnId: z.ZodString;
54
59
  eventMessageId: z.ZodString;
@@ -56,6 +56,7 @@ export const sessionEventsQuerySchema = z.object({
56
56
  export const sessionHeadAdvanceSchema = z.object({
57
57
  currentHead: z.string().min(1),
58
58
  lastTurnId: z.string().min(1),
59
+ status: z.enum(["completed", "cancelled", "failed"]).optional(),
59
60
  basis: z.object({
60
61
  turnId: z.string().min(1),
61
62
  eventMessageId: z.string().min(1),
@@ -418,6 +418,7 @@ export function handleMachineMessage(context, connection, message) {
418
418
  currentHead: message.currentHead,
419
419
  lastTurnId: message.lastTurnId,
420
420
  basis: message.basis,
421
+ status: message.status,
421
422
  ...(message.encryptedMetadata ? { encryptedMetadata: message.encryptedMetadata } : {}),
422
423
  };
423
424
  const result = applySessionHeadAdvance(db, input, ts);
@@ -634,6 +635,7 @@ function pendingRowToHeadAdvanceInput(row) {
634
635
  turnSeq: row.basis_turn_seq,
635
636
  ...(row.basis_occurred_at !== null ? { occurredAt: row.basis_occurred_at } : {}),
636
637
  },
638
+ status: "completed",
637
639
  ...(row.encrypted_metadata ? { encryptedMetadata: JSON.parse(row.encrypted_metadata) } : {}),
638
640
  };
639
641
  }
@@ -655,19 +657,28 @@ function applySessionHeadAdvance(db, input, ts) {
655
657
  sessionRow.head_basis_message_id.startsWith(REWRITE_HEAD_BASIS_PREFIX);
656
658
  const comparison = compareHeadBasisOrder(nextOrder, currentOrder);
657
659
  const currentHeadIsBasisEvent = sessionRow.current_head === `event:${basisEvent.id}`;
660
+ const currentHeadAlreadySettled = currentHeadIsBasisEvent ||
661
+ sessionRow.current_head === input.currentHead ||
662
+ sessionRow.last_turn_id === input.lastTurnId;
658
663
  const shouldAdvance = !rewriteFenced && (comparison > 0 || (comparison === 0 && currentHeadIsBasisEvent));
659
- if (!shouldAdvance)
664
+ const shouldSettleStatus = !rewriteFenced &&
665
+ input.status !== undefined &&
666
+ sessionRow.status === "running" &&
667
+ comparison === 0 &&
668
+ currentHeadAlreadySettled;
669
+ if (!shouldAdvance && !shouldSettleStatus)
660
670
  return { status: "noop", session: sessionRow };
661
671
  db.prepare(`UPDATE sessions
662
- SET current_head = ?,
663
- last_turn_id = ?,
664
- head_basis_event_id = ?,
665
- head_basis_message_id = ?,
666
- head_basis_logical_time = ?,
667
- head_basis_turn_seq = ?,
672
+ SET status = COALESCE(?, status),
673
+ current_head = COALESCE(?, current_head),
674
+ last_turn_id = COALESCE(?, last_turn_id),
675
+ head_basis_event_id = COALESCE(?, head_basis_event_id),
676
+ head_basis_message_id = COALESCE(?, head_basis_message_id),
677
+ head_basis_logical_time = COALESCE(?, head_basis_logical_time),
678
+ head_basis_turn_seq = COALESCE(?, head_basis_turn_seq),
668
679
  encrypted_metadata = COALESCE(?, encrypted_metadata),
669
680
  updated_at = ?
670
- WHERE id = ? AND account_id = ? AND machine_id = ?`).run(input.currentHead, input.lastTurnId, basisEvent.id, basisEvent.message_id, nextOrder.time, nextOrder.seq, input.encryptedMetadata ? JSON.stringify(input.encryptedMetadata) : null, ts, input.sessionId, input.accountId, input.machineId);
681
+ WHERE id = ? AND account_id = ? AND machine_id = ?`).run(input.status && sessionRow.status === "running" ? input.status : null, shouldAdvance ? input.currentHead : null, shouldAdvance ? input.lastTurnId : null, shouldAdvance ? basisEvent.id : null, shouldAdvance ? basisEvent.message_id : null, shouldAdvance ? nextOrder.time : null, shouldAdvance ? nextOrder.seq : null, input.encryptedMetadata ? JSON.stringify(input.encryptedMetadata) : null, ts, input.sessionId, input.accountId, input.machineId);
671
682
  const session = db.prepare("SELECT * FROM sessions WHERE id = ?").get(input.sessionId);
672
683
  return { status: "applied", session };
673
684
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cydm/happy-elves",
3
- "version": "0.1.0-beta.40",
3
+ "version": "0.1.0-beta.42",
4
4
  "description": "Remote controller for local coding agents with hosted or self-hosted relay support.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -165,6 +165,7 @@ export class ControllerClient {
165
165
  const checkedSessionId = requireNonEmpty(sessionId, "sessionId");
166
166
  const currentHead = requireNonEmpty(input.currentHead, "currentHead");
167
167
  const lastTurnId = requireNonEmpty(input.lastTurnId, "lastTurnId");
168
+ const status = input.status;
168
169
  const turnId = requireNonEmpty(input.basis.turnId, "basis.turnId");
169
170
  const eventMessageId = requireNonEmpty(input.basis.eventMessageId, "basis.eventMessageId");
170
171
  return await authenticatedJson(this.config, `/api/sessions/${encodeURIComponent(checkedSessionId)}/head`, {
@@ -173,6 +174,7 @@ export class ControllerClient {
173
174
  body: JSON.stringify({
174
175
  currentHead,
175
176
  lastTurnId,
177
+ ...(status ? { status } : {}),
176
178
  basis: {
177
179
  turnId,
178
180
  eventMessageId,
@@ -183,6 +183,7 @@ export function parseRepairSessionHeadResult(value) {
183
183
  const reason = record.reason;
184
184
  return {
185
185
  advanced: readBooleanField(record, "advanced", "Session head repair"),
186
+ ...(typeof record.settled === "boolean" ? { settled: record.settled } : {}),
186
187
  session: session,
187
188
  basis: {
188
189
  eventId: readNumberField(basis, "eventId", "Session head repair basis"),
@@ -205,6 +205,7 @@ export type CollectInput = {
205
205
  export type RepairSessionHeadInput = {
206
206
  currentHead: string;
207
207
  lastTurnId: string;
208
+ status?: "completed" | "cancelled" | "failed";
208
209
  basis: {
209
210
  turnId: string;
210
211
  eventMessageId: string;
@@ -214,6 +215,7 @@ export type RepairSessionHeadInput = {
214
215
  };
215
216
  export type RepairSessionHeadResult = {
216
217
  advanced: boolean;
218
+ settled?: boolean;
217
219
  session: SessionSnapshot;
218
220
  basis: {
219
221
  eventId: number;
@@ -144,6 +144,7 @@ export type MachineClientMessage = {
144
144
  currentHead: string;
145
145
  lastTurnId: string;
146
146
  basis: SessionHeadAdvanceBasis;
147
+ status?: "completed" | "cancelled" | "failed";
147
148
  encryptedMetadata?: EncryptedEnvelope;
148
149
  } | {
149
150
  type: "machine:turnDone";
@@ -169,6 +169,7 @@ const machineMessageSchema = z.discriminatedUnion("type", [
169
169
  messageId: z.string().min(1).optional(),
170
170
  currentHead: z.string().min(1),
171
171
  lastTurnId: z.string().min(1),
172
+ status: z.enum(["completed", "cancelled", "failed"]).optional(),
172
173
  basis: z.object({
173
174
  turnId: z.string().min(1),
174
175
  eventMessageId: z.string().min(1),