codedeck 0.4.0 → 0.4.1

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.
@@ -49,6 +49,27 @@ function withLiveStatus(s) {
49
49
  const status = liveStatus(s.status, s.pid, processAlive(s.pid));
50
50
  return status === s.status ? s : { ...s, status: status };
51
51
  }
52
+ // Send-queue cap: bytes, matching MAX_SEND_BODY_BYTES on the web route.
53
+ const MAX_SEND_MESSAGE_BYTES = 64 * 1024;
54
+ function validateSendMessage(raw) {
55
+ if (typeof raw !== "string")
56
+ return { code: "INVALID", error: "message required" };
57
+ const message = raw.trim();
58
+ if (!message)
59
+ return { code: "INVALID", error: "message required" };
60
+ if (Buffer.byteLength(message, "utf8") > MAX_SEND_MESSAGE_BYTES) {
61
+ return { code: "INVALID", error: "message too large" };
62
+ }
63
+ return { message };
64
+ }
65
+ // PID identity triple, same rule as the stop path: a recycled PID must
66
+ // neither block a legitimate resume nor permit killing a stranger.
67
+ function livePidIdentity(s) {
68
+ return (s.pid != null &&
69
+ s.pidStartTime != null &&
70
+ processAlive(s.pid) &&
71
+ processStartTime(s.pid) === s.pidStartTime);
72
+ }
52
73
  class Daemon {
53
74
  db;
54
75
  sessions;
@@ -476,8 +497,20 @@ class Daemon {
476
497
  send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
477
498
  return;
478
499
  }
479
- if (isTerminalStatus(s.status)) {
480
- send({ result: { session: s } });
500
+ // Release owns the terminal outcome: a queued message must not
501
+ // survive as a phantom "na fila" on every exit, including the
502
+ // terminal early return below.
503
+ this.clearPending(s.id);
504
+ // Interrupted is an archival terminal: a power-shutdown row stays
505
+ // actionable and release may finalize it to completed/failed — unless
506
+ // its process is still alive under the same identity, which keeps
507
+ // the send path's stop-first guard instead of orphaning it.
508
+ if (s.status === "interrupted" && livePidIdentity(s)) {
509
+ send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} is still running (stop it first)` } });
510
+ return;
511
+ }
512
+ if (isTerminalStatus(s.status) && s.status !== "interrupted") {
513
+ send({ result: { session: this.sessions.get(s.id) } });
481
514
  return;
482
515
  }
483
516
  const targetStatus = p.status === "failed" ? "failed" : "completed";
@@ -550,6 +583,12 @@ class Daemon {
550
583
  }
551
584
  case "session.send": {
552
585
  const p = params;
586
+ const validated = validateSendMessage(p.message);
587
+ if ("code" in validated) {
588
+ send({ error: { code: validated.code, message: validated.error } });
589
+ return;
590
+ }
591
+ const message = validated.message;
553
592
  const s = this.sessions.get(p.id);
554
593
  if (!s) {
555
594
  send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
@@ -560,118 +599,94 @@ class Daemon {
560
599
  return;
561
600
  }
562
601
  const driver = this.registry.get(s.agent);
563
- // Do not start a second harness while the current one is still live:
564
- // both runtimes would tail the same per-session file and duplicate
565
- // every event from the follow-up turn.
602
+ if (!driver.capabilities().resume) {
603
+ send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Agent ${s.agent} does not support resume` } });
604
+ return;
605
+ }
606
+ if (s.status === "interrupted" && !s.nativeSessionId) {
607
+ send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Session ${s.id} cannot resume (no native session id or agent ${s.agent} does not support resume)` } });
608
+ return;
609
+ }
610
+ // Power-interrupted sessions with a live process keep the old rule:
611
+ // stop first, never queue behind a running harness.
612
+ if (s.status === "interrupted" && livePidIdentity(s)) {
613
+ send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} is still running (stop it first)` } });
614
+ return;
615
+ }
616
+ // A lifecycle operation owns the session: retry instead of queueing,
617
+ // so a send racing stop cannot strand a phantom slot with no stream
618
+ // tail left to consume it (stop cancels the queue on exit).
566
619
  if (this.sessionLocks.has(s.id)) {
567
620
  send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} has another lifecycle operation in progress` } });
568
621
  return;
569
622
  }
570
- if (s.status === "interrupted") {
571
- // Resume-turn admission for power-interrupted sessions: capability
572
- // BEFORE liveness, and liveness by process identity (same rule as
573
- // stop). A recycled PID must not block a legitimate resume.
574
- if (!s.nativeSessionId || !driver.capabilities().resume) {
575
- send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Session ${s.id} cannot resume (no native session id or agent ${s.agent} does not support resume)` } });
576
- return;
623
+ // Do not start a second harness while the current one is still live:
624
+ // both runtimes would tail the same per-session file and duplicate
625
+ // every event from the follow-up turn. Queue behind a live turn only.
626
+ const busyHandle = driver.getHandle?.(s.id);
627
+ const busyState = busyHandle && typeof busyHandle === "object"
628
+ ? busyHandle
629
+ : undefined;
630
+ const runtimeBusy = busyHandle !== undefined && (busyState?.done !== true || busyState?.drained !== true);
631
+ const busy = s.status === "starting" || runtimeBusy || livePidIdentity(s);
632
+ if (busy) {
633
+ // One-slot queue, last-wins: persist without holding the lifecycle
634
+ // lock, then converge — the turn may have ended between the busy
635
+ // read and this persist, in which case tryDispatch starts now.
636
+ const pendingAt = new Date().toISOString();
637
+ try {
638
+ this.sessions.update(s.id, { pendingMessage: message, pendingAt });
577
639
  }
578
- const liveIdentity = s.pid != null &&
579
- s.pidStartTime != null &&
580
- processAlive(s.pid) &&
581
- processStartTime(s.pid) === s.pidStartTime;
582
- if (liveIdentity) {
583
- send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} is still running (stop it first)` } });
640
+ catch (e) {
641
+ send({ error: { code: "SEND_FAILED", message: e instanceof Error ? e.message : String(e) } });
584
642
  return;
585
643
  }
586
- }
587
- else {
588
- const handle = driver.getHandle?.(s.id);
589
- const runtimeState = handle && typeof handle === "object"
590
- ? handle
591
- : undefined;
592
- const runtimeDraining = handle !== undefined && (runtimeState?.done !== true || runtimeState?.drained !== true);
593
- if (s.status === "starting" || runtimeDraining || (s.status === "working" && s.pid != null && processAlive(s.pid))) {
594
- send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} is still running` } });
595
- return;
644
+ const queuedEvent = {
645
+ type: "message.queued",
646
+ sessionId: s.id,
647
+ timestamp: pendingAt,
648
+ prompt: message,
649
+ pendingAt,
650
+ };
651
+ try {
652
+ this.events.append(s.id, queuedEvent);
596
653
  }
597
- if (!driver.capabilities().resume) {
598
- send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Agent ${s.agent} does not support resume` } });
599
- return;
654
+ catch { }
655
+ this.broadcast(s.id, queuedEvent);
656
+ try {
657
+ this.sessions.update(s.id, { lastEvent: `queued: ${message.slice(0, 80)}` });
600
658
  }
659
+ catch { }
660
+ void this.tryDispatch(s.id);
661
+ send({ result: { ok: true, queued: true } });
662
+ return;
601
663
  }
602
- const drvSession = {
603
- id: s.id,
604
- nativeSessionId: s.nativeSessionId,
605
- cwd: s.worktree || s.cwd,
606
- model: s.model,
607
- effort: s.effort,
608
- fast: s.fast,
609
- sandbox: s.sandbox,
610
- dangerouslyBypassApprovalsAndSandbox: s.dangerouslyBypassApprovalsAndSandbox,
611
- pid: s.pid,
612
- pidStartTime: s.pidStartTime,
613
- };
664
+ // Resumable now (terminal completed/failed/stopped/orphaned, or
665
+ // interrupted without a live process): a stale queued slot loses to
666
+ // the new message, which starts immediately.
614
667
  this.sessionLocks.add(s.id);
615
668
  try {
616
- if (this.shuttingDown) {
617
- send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
618
- return;
619
- }
620
- this.sessions.setStatus(s.id, "working", { lastEvent: `send: ${p.message.slice(0, 80)}` });
621
- const turnEvent = {
622
- type: "turn.started",
623
- sessionId: s.id,
624
- timestamp: new Date().toISOString(),
625
- prompt: p.message,
626
- };
627
- this.events.append(s.id, turnEvent);
628
- this.broadcast(s.id, turnEvent);
629
- await driver.send(drvSession, p.message);
630
- if (this.shuttingDown) {
631
- try {
632
- await driver.stop(drvSession);
633
- }
634
- catch { }
635
- send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
636
- return;
637
- }
638
- const handle = driver.getHandle?.(s.id);
639
- const handleNativeId = handle &&
640
- typeof handle === "object" &&
641
- "nativeSessionId" in handle &&
642
- typeof handle.nativeSessionId === "string"
643
- ? handle.nativeSessionId
644
- : undefined;
645
- const newNative = handleNativeId || drvSession.nativeSessionId;
646
- if (newNative && newNative !== s.nativeSessionId) {
647
- this.sessions.update(s.id, { nativeSessionId: newNative });
648
- }
649
- if (drvSession.pid) {
650
- this.sessions.update(s.id, {
651
- pid: drvSession.pid,
652
- pidStartTime: processStartTime(drvSession.pid),
653
- });
669
+ try {
670
+ this.sessions.update(s.id, { pendingMessage: null, pendingAt: null });
654
671
  }
655
- this.sessions.update(s.id, { status: "working" });
656
- // Attach event loop for new turn
657
- this.attachDriverEvents(s.id, driver, drvSession).catch(() => { });
658
- send({ result: { ok: true } });
672
+ catch { }
673
+ await this.runResumeTurn(s, message);
674
+ send({ result: { ok: true, queued: false } });
659
675
  }
660
676
  catch (e) {
661
- const message = e instanceof Error ? e.message : String(e);
662
- if (this.shuttingDown) {
663
- try {
664
- await driver.stop(drvSession);
665
- }
666
- catch { }
667
- send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
677
+ const errText = e instanceof Error ? e.message : String(e);
678
+ const code = e instanceof Error && e.code === "SERVICE_UNAVAILABLE"
679
+ ? "SERVICE_UNAVAILABLE"
680
+ : this.shuttingDown ? "SERVICE_UNAVAILABLE" : "SEND_FAILED";
681
+ if (code === "SERVICE_UNAVAILABLE") {
682
+ send({ error: { code, message: errText } });
668
683
  return;
669
684
  }
670
685
  try {
671
686
  this.sessions.setStatus(s.id, "failed");
672
687
  }
673
688
  catch { }
674
- send({ error: { code: "SEND_FAILED", message } });
689
+ send({ error: { code: "SEND_FAILED", message: errText } });
675
690
  }
676
691
  finally {
677
692
  if (!this.shuttingDown)
@@ -686,6 +701,9 @@ class Daemon {
686
701
  send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
687
702
  return;
688
703
  }
704
+ // Stop cancels a queued message on every exit (explicit cancel),
705
+ // including the BUSY/NOT_RUNNING/UNSAFE early returns below.
706
+ this.clearPending(s.id);
689
707
  const driver = this.registry.get(s.agent);
690
708
  if (this.sessionLocks.has(s.id)) {
691
709
  send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} has another lifecycle operation in progress` } });
@@ -747,6 +765,9 @@ class Daemon {
747
765
  this.events.append(s.id, ev);
748
766
  this.broadcast(s.id, ev);
749
767
  }
768
+ // A send may have persisted a slot after the entry clear (it saw a
769
+ // live turn before this stop took the lock): stop wins, clear again.
770
+ this.clearPending(s.id);
750
771
  send({ result: { ok: true } });
751
772
  }
752
773
  catch (e) {
@@ -1077,6 +1098,173 @@ class Daemon {
1077
1098
  // Attach events
1078
1099
  await this.attachDriverEvents(sessionId, driver, drvSession);
1079
1100
  }
1101
+ /** Best-effort queue cancel; stop/release own the terminal outcome. */
1102
+ clearPending(sessionId) {
1103
+ try {
1104
+ this.sessions.update(sessionId, { pendingMessage: null, pendingAt: null });
1105
+ }
1106
+ catch { }
1107
+ }
1108
+ /**
1109
+ + * Starts a resumed turn; caller MUST hold sessionLocks for the session.
1110
+ + * Shared by session.send (immediate) and tryDispatch (queued) so both
1111
+ + * paths emit the same frames in the same order.
1112
+ + */
1113
+ async runResumeTurn(s, message) {
1114
+ const driver = this.registry.get(s.agent);
1115
+ if (this.shuttingDown) {
1116
+ const early = new Error("daemon is shutting down");
1117
+ early.code = "SERVICE_UNAVAILABLE";
1118
+ throw early;
1119
+ }
1120
+ this.sessions.setStatus(s.id, "working", { lastEvent: `send: ${message.slice(0, 80)}` });
1121
+ const turnEvent = {
1122
+ type: "turn.started",
1123
+ sessionId: s.id,
1124
+ timestamp: new Date().toISOString(),
1125
+ prompt: message,
1126
+ };
1127
+ this.events.append(s.id, turnEvent);
1128
+ this.broadcast(s.id, turnEvent);
1129
+ const drvSession = {
1130
+ id: s.id,
1131
+ nativeSessionId: s.nativeSessionId,
1132
+ cwd: s.worktree || s.cwd,
1133
+ model: s.model,
1134
+ effort: s.effort,
1135
+ fast: s.fast,
1136
+ sandbox: s.sandbox,
1137
+ dangerouslyBypassApprovalsAndSandbox: s.dangerouslyBypassApprovalsAndSandbox,
1138
+ pid: s.pid,
1139
+ pidStartTime: s.pidStartTime,
1140
+ };
1141
+ try {
1142
+ await driver.send(drvSession, message);
1143
+ }
1144
+ catch (e) {
1145
+ if (this.shuttingDown) {
1146
+ try {
1147
+ await driver.stop(drvSession);
1148
+ }
1149
+ catch { }
1150
+ }
1151
+ throw e;
1152
+ }
1153
+ if (this.shuttingDown) {
1154
+ try {
1155
+ await driver.stop(drvSession);
1156
+ }
1157
+ catch { }
1158
+ const late = new Error("daemon is shutting down");
1159
+ late.code = "SERVICE_UNAVAILABLE";
1160
+ throw late;
1161
+ }
1162
+ const handle = driver.getHandle?.(s.id);
1163
+ const handleNativeId = handle &&
1164
+ typeof handle === "object" &&
1165
+ "nativeSessionId" in handle &&
1166
+ typeof handle.nativeSessionId === "string"
1167
+ ? handle.nativeSessionId
1168
+ : undefined;
1169
+ const newNative = handleNativeId || drvSession.nativeSessionId;
1170
+ if (newNative && newNative !== s.nativeSessionId) {
1171
+ this.sessions.update(s.id, { nativeSessionId: newNative });
1172
+ }
1173
+ if (drvSession.pid) {
1174
+ this.sessions.update(s.id, {
1175
+ pid: drvSession.pid,
1176
+ pidStartTime: processStartTime(drvSession.pid),
1177
+ });
1178
+ }
1179
+ this.sessions.update(s.id, { status: "working" });
1180
+ // Attach event loop for new turn
1181
+ this.attachDriverEvents(s.id, driver, drvSession).catch(() => { });
1182
+ }
1183
+ /**
1184
+ + * Starts the queued message as a new turn when the session is resumable.
1185
+ + * Never blocks: returns false leaving the slot intact when busy,
1186
+ + * unresumable, or shutting down. The stream tail calls this, and the
1187
+ + * busy send path calls it to converge on races (enqueue landing as the
1188
+ + * turn ends starts immediately instead of stranding).
1189
+ + */
1190
+ async tryDispatch(sessionId) {
1191
+ if (this.shuttingDown || this.sessionLocks.has(sessionId))
1192
+ return false;
1193
+ const queued = this.sessions.get(sessionId);
1194
+ if (!queued || !queued.pendingMessage || queued.origin === "open")
1195
+ return false;
1196
+ this.sessionLocks.add(sessionId);
1197
+ let message;
1198
+ let pendingAt;
1199
+ try {
1200
+ const s = this.sessions.get(sessionId);
1201
+ if (!s || !s.pendingMessage || s.origin === "open")
1202
+ return false;
1203
+ const driver = this.registry.get(s.agent);
1204
+ if (!driver.capabilities().resume)
1205
+ return false;
1206
+ const handle = driver.getHandle?.(s.id);
1207
+ const hs = handle && typeof handle === "object"
1208
+ ? handle
1209
+ : undefined;
1210
+ // Same resolution as SessionDriver.send: without a native id the start
1211
+ // would only throw after emitting turn.started and flipping status, so
1212
+ // leave the slot quietly for a manual send instead.
1213
+ const nativeId = s.nativeSessionId
1214
+ || (typeof hs?.nativeSessionId === "string" ? hs.nativeSessionId : undefined);
1215
+ if (!nativeId)
1216
+ return false;
1217
+ if (s.status === "starting")
1218
+ return false;
1219
+ if (handle !== undefined && (hs?.done !== true || hs?.drained !== true))
1220
+ return false;
1221
+ if (livePidIdentity(s))
1222
+ return false;
1223
+ message = s.pendingMessage;
1224
+ pendingAt = s.pendingAt;
1225
+ // Clear first so a crash mid-start cannot double-send; restored below
1226
+ // when the start fails.
1227
+ this.sessions.update(s.id, { pendingMessage: null, pendingAt: null });
1228
+ await this.runResumeTurn(s, message);
1229
+ return true;
1230
+ }
1231
+ catch (e) {
1232
+ const errText = e instanceof Error ? e.message : String(e);
1233
+ const unavailable = e?.code === "SERVICE_UNAVAILABLE" || this.shuttingDown;
1234
+ if (!unavailable) {
1235
+ const failure = classifyFailure(errText);
1236
+ const errEv = {
1237
+ type: "session.failed",
1238
+ sessionId,
1239
+ timestamp: new Date().toISOString(),
1240
+ error: errText,
1241
+ failure,
1242
+ };
1243
+ try {
1244
+ this.events.append(sessionId, errEv);
1245
+ }
1246
+ catch { }
1247
+ this.broadcast(sessionId, errEv);
1248
+ try {
1249
+ this.sessions.setStatus(sessionId, "failed", { lastEvent: errText.slice(0, 200), failure });
1250
+ }
1251
+ catch { }
1252
+ }
1253
+ // Restore the slot so a failed dispatch never vanishes silently; the
1254
+ // next manual send (or a future tail) retries it.
1255
+ try {
1256
+ if (message !== undefined) {
1257
+ this.sessions.update(sessionId, { pendingMessage: message, pendingAt: pendingAt ?? new Date().toISOString() });
1258
+ }
1259
+ }
1260
+ catch { }
1261
+ return false;
1262
+ }
1263
+ finally {
1264
+ if (!this.shuttingDown)
1265
+ this.sessionLocks.delete(sessionId);
1266
+ }
1267
+ }
1080
1268
  async attachDriverEvents(sessionId, driver, drvSession) {
1081
1269
  try {
1082
1270
  for await (const ev of driver.events(drvSession)) {
@@ -1166,6 +1354,11 @@ class Daemon {
1166
1354
  this.sessions.setStatus(sessionId, "failed", { lastEvent: error.slice(0, 200), failure });
1167
1355
  }
1168
1356
  }
1357
+ // A message queued while the turn ran starts now as the next turn.
1358
+ // tryDispatch rechecks resumability under the lifecycle lock; a stale
1359
+ // or unresumable slot stays put for a manual send.
1360
+ if (!this.shuttingDown)
1361
+ void this.tryDispatch(sessionId);
1169
1362
  }
1170
1363
  updateSessionFromEvent(sessionId, ev) {
1171
1364
  try {
@@ -1186,6 +1379,9 @@ class Daemon {
1186
1379
  else if (ev.type === "tool.started") {
1187
1380
  this.sessions.update(sessionId, { lastEvent: `tool: ${ev.tool.name}` });
1188
1381
  }
1382
+ else if (ev.type === "message.queued") {
1383
+ this.sessions.update(sessionId, { lastEvent: `queued: ${ev.prompt.slice(0, 80)}` });
1384
+ }
1189
1385
  else if (ev.type === "message") {
1190
1386
  this.sessions.update(sessionId, { lastEvent: ev.content.slice(0, 80) });
1191
1387
  }