@cueloop/daemon 0.1.0-alpha.87 → 0.1.0-alpha.89

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/daemon",
3
- "version": "0.1.0-alpha.87",
3
+ "version": "0.1.0-alpha.89",
4
4
  "description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -38,7 +38,7 @@
38
38
  "access": "public"
39
39
  },
40
40
  "dependencies": {
41
- "@cueloop/schema": "0.1.0-alpha.87",
41
+ "@cueloop/schema": "0.1.0-alpha.89",
42
42
  "@pierre/diffs": "^1.4.1",
43
43
  "effect": "^4.0.0-rc.116",
44
44
  "smol-toml": "^1.8.0",
package/src/api.ts CHANGED
@@ -106,6 +106,7 @@ export interface SharedMerge {
106
106
  export type EventName =
107
107
  | "session.created"
108
108
  | "session.updated"
109
+ | "session.deleted"
109
110
  | "message.sent"
110
111
  | "session.revised"
111
112
  | "inbox.changed";
@@ -124,7 +125,7 @@ export class DaemonCore {
124
125
  readonly herdrThreadSurfaces: HerdrThreadSurfaceStore;
125
126
  readonly ghosttyThreadSurfaces: GhosttyThreadSurfaceStore;
126
127
  readonly harnessState: HarnessStateStore;
127
- private waiters = new Map<string, ((session: Thread) => void)[]>();
128
+ private waiters = new Map<string, ((session: Thread | null) => void)[]>();
128
129
  private listeners = new Set<EventListener>();
129
130
  private seq = 0;
130
131
  /** Drives diff hot-reload: watches each live diff session's repo for working-tree changes. */
@@ -158,6 +159,9 @@ export class DaemonCore {
158
159
  this.herdrThreadSurfaces.delete(threadId);
159
160
  this.ghosttyThreadSurfaces.delete(threadId);
160
161
  }
162
+ this.harnessState.deleteOrphanedThreadState(
163
+ new Set(this.store.list().map((session) => session.id)),
164
+ );
161
165
  this.diffWatcher = new DiffWatcher((repoRoot) => void this.refreshDiffsForRepo(repoRoot));
162
166
  this.prPoller = new PrReviewPoller((sessionId, refs) =>
163
167
  this.markPrRefreshAvailable(sessionId, refs),
@@ -377,7 +381,7 @@ export class DaemonCore {
377
381
  if (waiterIndex !== -1) list.splice(waiterIndex, 1);
378
382
  resolve(null);
379
383
  }, timeoutMs);
380
- const waiter = (session: Thread) => {
384
+ const waiter = (session: Thread | null) => {
381
385
  if (done) return;
382
386
  done = true;
383
387
  clearTimeout(timer);
@@ -656,7 +660,7 @@ export class DaemonCore {
656
660
  return session;
657
661
  }
658
662
 
659
- /** Remove a session for good (inbox delete); resolved or pending, both go. */
663
+ /** Remove a Thread and all local state that refers to it. */
660
664
  sessionDelete(id: string): void {
661
665
  const session = this.store.get(id);
662
666
 
@@ -665,6 +669,12 @@ export class DaemonCore {
665
669
  this.diffRefreshGenerations.delete(id);
666
670
  this.herdrThreadSurfaces.delete(id);
667
671
  this.ghosttyThreadSurfaces.delete(id);
672
+ this.harnessState.deleteThreadState(id);
673
+ const parked = this.waiters.get(id) ?? [];
674
+
675
+ this.waiters.delete(id);
676
+ for (const parkedWaiter of parked) parkedWaiter(null);
677
+ this.emit("session.deleted", id);
668
678
  this.emit("inbox.changed", id);
669
679
  }
670
680
 
@@ -66,6 +66,49 @@ describe("durable harness delivery", () => {
66
66
  expect(core().harnessBindingsForSession("pi", "pi-current")).toEqual([current]);
67
67
  });
68
68
 
69
+ test("Thread deletion removes pi, Codex, and Claude harness state", () => {
70
+ const instance = core();
71
+ const thread = createPlan(instance);
72
+ const bindings = ["pi", "codex", "claude-code"].map((harness) =>
73
+ instance.harnessBind({
74
+ threadId: thread.id,
75
+ harness,
76
+ harnessSessionId: `${harness}-session`,
77
+ }),
78
+ );
79
+
80
+ instance.sessionSendMessage(thread.id, "approved", "Ready.");
81
+ const deliveryId = instance.deliveryPending(bindings[0]!.id)[0]!.delivery.id;
82
+
83
+ instance.sessionDelete(thread.id);
84
+
85
+ for (const [index, harness] of ["pi", "codex", "claude-code"].entries()) {
86
+ expect(instance.harnessBindingsForSession(harness, `${harness}-session`)).toEqual([]);
87
+ expect(() => instance.harnessGetBinding(bindings[index]!.id)).toThrow("no harness binding");
88
+ }
89
+ expect(() => instance.deliveryAcknowledge(deliveryId)).toThrow("no delivery");
90
+ });
91
+
92
+ test("startup removes harness state orphaned by an older deletion", () => {
93
+ const first = core();
94
+ const thread = createPlan(first);
95
+ const binding = first.harnessBind({
96
+ threadId: thread.id,
97
+ harness: "codex",
98
+ harnessSessionId: "codex-orphan",
99
+ });
100
+
101
+ first.sessionSendMessage(thread.id, "approved", "Ready.");
102
+ const deliveryId = first.deliveryPending(binding.id)[0]!.delivery.id;
103
+
104
+ first.store.delete(thread.id);
105
+ const restarted = core();
106
+
107
+ expect(restarted.harnessBindingsForSession("codex", "codex-orphan")).toEqual([]);
108
+ expect(() => restarted.harnessGetBinding(binding.id)).toThrow("no harness binding");
109
+ expect(() => restarted.deliveryAcknowledge(deliveryId)).toThrow("no delivery");
110
+ });
111
+
69
112
  test("redelivers after reload until the harness acknowledges the Message", () => {
70
113
  const first = core();
71
114
  const thread = createPlan(first);
@@ -57,6 +57,34 @@ describe("HarnessStateStore", () => {
57
57
  expect(store.bind(input)).toEqual(store.bind(input));
58
58
  });
59
59
 
60
+ test("deletes every harness binding and delivery for a Thread", () => {
61
+ const store = new HarnessStateStore(home);
62
+ const bindings = ["pi", "codex", "claude-code"].map((harness) =>
63
+ store.bind({ threadId: "ses_1", harness, harnessSessionId: `${harness}_1` }),
64
+ );
65
+ const deliveries = bindings.map((binding, index) =>
66
+ store.enqueue({ bindingId: binding.id, messageId: `msg_${index}` }),
67
+ );
68
+
69
+ expect(store.deleteThreadState("ses_1")).toBeTrue();
70
+ expect(store.deleteThreadState("ses_1")).toBeFalse();
71
+ const reloaded = new HarnessStateStore(home);
72
+
73
+ for (const binding of bindings) expect(reloaded.binding(binding.id)).toBeNull();
74
+ for (const delivery of deliveries) expect(reloaded.delivery(delivery.id)).toBeNull();
75
+ });
76
+
77
+ test("deletes state for Threads missing at startup", () => {
78
+ const store = new HarnessStateStore(home);
79
+
80
+ store.bind({ threadId: "ses_kept", harness: "pi", harnessSessionId: "pi_1" });
81
+ store.bind({ threadId: "ses_orphan", harness: "codex", harnessSessionId: "codex_1" });
82
+
83
+ expect(store.deleteOrphanedThreadState(new Set(["ses_kept"]))).toEqual(["ses_orphan"]);
84
+ expect(store.bindingsForSession("pi", "pi_1")).toHaveLength(1);
85
+ expect(store.bindingsForSession("codex", "codex_1")).toEqual([]);
86
+ });
87
+
60
88
  test("does not discard pending deliveries when persisted state is corrupt", () => {
61
89
  writeFileSync(harnessStatePath(home), "{broken");
62
90
 
@@ -54,6 +54,38 @@ export class HarnessStateStore {
54
54
  );
55
55
  }
56
56
 
57
+ deleteThreadState(threadId: string): boolean {
58
+ const bindingIds = new Set(
59
+ [...this.bindings.values()]
60
+ .filter((binding) => binding.threadId === threadId)
61
+ .map((binding) => binding.id),
62
+ );
63
+
64
+ return this.deleteBindingsAndDeliveries(bindingIds);
65
+ }
66
+
67
+ deleteOrphanedThreadState(existingThreadIds: ReadonlySet<string>): string[] {
68
+ const orphanedBindings = [...this.bindings.values()].filter(
69
+ (binding) => !existingThreadIds.has(binding.threadId),
70
+ );
71
+ const orphanedThreadIds = [...new Set(orphanedBindings.map((binding) => binding.threadId))];
72
+
73
+ this.deleteBindingsAndDeliveries(new Set(orphanedBindings.map((binding) => binding.id)));
74
+
75
+ return orphanedThreadIds;
76
+ }
77
+
78
+ private deleteBindingsAndDeliveries(bindingIds: ReadonlySet<string>): boolean {
79
+ if (bindingIds.size === 0) return false;
80
+ for (const bindingId of bindingIds) this.bindings.delete(bindingId);
81
+ for (const delivery of this.deliveries.values()) {
82
+ if (bindingIds.has(delivery.bindingId)) this.deliveries.delete(delivery.id);
83
+ }
84
+ this.persist();
85
+
86
+ return true;
87
+ }
88
+
57
89
  consumeApprovedRetry(bindingId: string, messageId: string): boolean {
58
90
  const binding = this.bindings.get(bindingId);
59
91