@cueloop/daemon 0.1.0-alpha.79 → 0.1.0-alpha.80

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/src/client.ts CHANGED
@@ -4,16 +4,20 @@
4
4
  * autostart spawns a detached daemon when the socket is dead, then attaches.
5
5
  */
6
6
 
7
- import { existsSync, readFileSync, rmSync } from "node:fs";
7
+ import { existsSync, readFileSync } from "node:fs";
8
8
  import * as v from "valibot";
9
9
  import type {
10
10
  Annotation,
11
11
  Artifact,
12
12
  DiffFileStatus,
13
+ DiffFileContents,
13
14
  HunkRejection,
14
15
  ShareLink,
15
16
  Thread,
16
- VerdictKind,
17
+ HarnessBinding,
18
+ Delivery,
19
+ PendingDelivery,
20
+ MessageOutcome,
17
21
  WorkspaceKey,
18
22
  } from "@cueloop/schema";
19
23
  import {
@@ -30,7 +34,14 @@ import type { SharedMerge } from "./api";
30
34
 
31
35
  export type { SharedMerge } from "./api";
32
36
  import { cueloopHome, ownerTokenPath, socketPath } from "./paths";
33
- import { Params, ThreadRecordSchema, DiffFileContentsSchema } from "./validate";
37
+ import {
38
+ Params,
39
+ ThreadRecordSchema,
40
+ DiffFileContentsSchema,
41
+ HarnessBindingSchema,
42
+ DeliverySchema,
43
+ PendingDeliverySchema,
44
+ } from "./validate";
34
45
  import type { WorkingTreeDiff } from "./working-tree";
35
46
  import { DAEMON_VERSION } from "./version";
36
47
 
@@ -64,7 +75,7 @@ const HerdrTabResultSchema = v.nullable(v.object({ tabId: v.string(), paneId: v.
64
75
  * blob-backed one. Depending on this interface - not DaemonClient - is what
65
76
  * lets the same <App> render a local session or a decrypted share unchanged.
66
77
  */
67
- export interface SessionClient {
78
+ export interface ThreadClient {
68
79
  onEvent(listener: (event: EventFrame) => void): () => void;
69
80
  subscribe(): Promise<void>;
70
81
  sessionGet(id: string): Promise<Thread>;
@@ -123,12 +134,25 @@ export interface SessionClient {
123
134
  sessionDelete(id: string): Promise<void>;
124
135
  /** Record the caller's own identity name (collaborator self-naming on a share). */
125
136
  sessionSetSelfName(id: string, name: string): Promise<Thread>;
126
- sessionResolve(
137
+ sessionSendMessage(
127
138
  id: string,
128
- verdictKind: VerdictKind,
139
+ outcome: MessageOutcome,
129
140
  summary: string,
130
141
  actionBodies?: Record<string, string>,
131
142
  ): Promise<Thread>;
143
+ harnessBind?(
144
+ threadId: string,
145
+ harness: string,
146
+ harnessSessionId: string,
147
+ ): Promise<HarnessBinding>;
148
+ harnessGetBinding?(bindingId: string): Promise<HarnessBinding>;
149
+ harnessConsumeApprovedRetry?(
150
+ bindingId: string,
151
+ messageId: string,
152
+ content: string,
153
+ ): Promise<boolean>;
154
+ deliveryPending?(bindingId: string): Promise<PendingDelivery[]>;
155
+ deliveryAcknowledge?(deliveryId: string): Promise<Delivery>;
132
156
  close(): void;
133
157
  }
134
158
 
@@ -159,7 +183,7 @@ function readOwnerToken(home: string): string | undefined {
159
183
  return parsed.output;
160
184
  }
161
185
 
162
- export class DaemonClient implements SessionClient {
186
+ export class DaemonClient implements ThreadClient {
163
187
  private socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
164
188
  private writer: BackpressureWriter | null = null;
165
189
  private pending = new Map<number, PendingRequest>();
@@ -201,15 +225,15 @@ export class DaemonClient implements SessionClient {
201
225
  // caller hears why instead of the client replacing a running daemon
202
226
  if (!options.autostart || err instanceof DaemonClientError) throw err;
203
227
  }
204
- // Socket dead, absent, or just-replaced: clean a stale file and spawn detached.
228
+ // Socket dead, absent, or just-replaced: let the new daemon own socket cleanup.
205
229
  return client.attachFreshDaemon(home, path);
206
230
  }
207
231
 
208
232
  /**
209
233
  * Tear down a daemon from an earlier build so a fresh one can bind: ask it to
210
234
  * shut down (owner-gated) or, failing that, signal its pid, then wait for it to
211
- * release the socket. Best-effort - attachFreshDaemon's stale-socket and
212
- * stale-lock cleanup recovers even a daemon that never ran its own teardown.
235
+ * release the socket. Best-effort - the next daemon reclaims stale socket
236
+ * and lock files after it takes ownership.
213
237
  */
214
238
  private async stopStaleDaemon(path: string): Promise<void> {
215
239
  const pid = this.daemonPid;
@@ -241,9 +265,8 @@ export class DaemonClient implements SessionClient {
241
265
  this.pending.clear();
242
266
  }
243
267
 
244
- /** Clean any stale socket, spawn a detached daemon, and dial it until it answers. */
268
+ /** Spawn a detached daemon and dial the lock owner until it answers. */
245
269
  private async attachFreshDaemon(home: string, path: string): Promise<DaemonClient> {
246
- if (existsSync(path)) rmSync(path, { force: true });
247
270
  spawnDaemon(home);
248
271
  // Generous: a cold or loaded machine pays for a runtime start before the
249
272
  // socket exists, and giving up early looks to callers like a broken daemon.
@@ -513,30 +536,62 @@ export class DaemonClient implements SessionClient {
513
536
  sessionSetSelfName(id: string, _name: string): Promise<Thread> {
514
537
  return this.sessionGet(id);
515
538
  }
516
- sessionResolve(
539
+ sessionSendMessage(
517
540
  id: string,
518
- verdictKind: VerdictKind,
541
+ outcome: MessageOutcome,
519
542
  summary: string,
520
543
  actionBodies?: Record<string, string>,
521
544
  ): Promise<Thread> {
522
545
  return this.request(
523
- "session.resolve",
524
- { id, verdictKind, summary, actionBodies },
546
+ "session.sendMessage",
547
+ { id, outcome, summary, actionBodies },
525
548
  ThreadRecordSchema,
526
549
  );
527
550
  }
551
+ harnessBind(
552
+ threadId: string,
553
+ harness: string,
554
+ harnessSessionId: string,
555
+ ): Promise<HarnessBinding> {
556
+ return this.request(
557
+ "harness.bind",
558
+ { threadId, harness, harnessSessionId },
559
+ HarnessBindingSchema,
560
+ );
561
+ }
562
+ harnessGetBinding(bindingId: string): Promise<HarnessBinding> {
563
+ return this.request("harness.getBinding", { bindingId }, HarnessBindingSchema);
564
+ }
565
+ harnessConsumeApprovedRetry(
566
+ bindingId: string,
567
+ messageId: string,
568
+ content: string,
569
+ ): Promise<boolean> {
570
+ return this.request(
571
+ "harness.consumeApprovedRetry",
572
+ { bindingId, messageId, content },
573
+ v.boolean(),
574
+ );
575
+ }
576
+ deliveryPending(bindingId: string): Promise<PendingDelivery[]> {
577
+ return this.request("delivery.pending", { bindingId }, v.array(PendingDeliverySchema));
578
+ }
579
+ deliveryAcknowledge(deliveryId: string): Promise<Delivery> {
580
+ return this.request("delivery.acknowledge", { deliveryId }, DeliverySchema);
581
+ }
528
582
  sessionSubmitRevision(
529
583
  id: string,
530
584
  content: string,
531
585
  addressedAnnotationIds: string[] = [],
586
+ files?: DiffFileContents[],
532
587
  ): Promise<Thread> {
533
588
  return this.request(
534
589
  "session.submitRevision",
535
- { id, content, addressedAnnotationIds },
590
+ { id, content, addressedAnnotationIds, files },
536
591
  ThreadRecordSchema,
537
592
  );
538
593
  }
539
- /** herdr adapter scratch: the tab opened for a review; local-only, off the SessionClient contract. */
594
+ /** herdr adapter scratch: the tab opened for a review; local-only, off the ThreadClient contract. */
540
595
  herdrGetTab(id: string): Promise<HerdrTabHandle | null> {
541
596
  return this.request("herdr.getTab", { id }, HerdrTabResultSchema);
542
597
  }
@@ -10,7 +10,7 @@ import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { DaemonCore } from "./api";
12
12
  import { workingTreeDiff } from "./working-tree";
13
- import { resolveWorkspace } from "./review";
13
+ import { resolveWorkspace } from "./thread-review";
14
14
  import type { Artifact } from "@cueloop/schema";
15
15
 
16
16
  function git(args: string[], cwd: string): void {
@@ -134,7 +134,7 @@ describe("session.refreshDiff", () => {
134
134
  // Given a diff session that has been resolved
135
135
  const session = await openDiffSession();
136
136
 
137
- core.sessionResolve(session.id, "approve", "");
137
+ core.sessionSendMessage(session.id, "approved", "");
138
138
  const resolvedContent = core.sessionGet(session.id).artifact.content;
139
139
 
140
140
  writeFileSync(join(repo, "a.ts"), "export const a = 99;\n");
@@ -0,0 +1,193 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { DaemonCore } from "./api";
6
+ import { harnessStatePath } from "./paths";
7
+
8
+ let home: string;
9
+ let cores: DaemonCore[];
10
+
11
+ beforeEach(() => {
12
+ home = mkdtempSync(join(tmpdir(), "cueloop-harness-delivery-"));
13
+ cores = [];
14
+ });
15
+
16
+ afterEach(() => {
17
+ for (const core of cores) core.dispose();
18
+ rmSync(home, { recursive: true, force: true });
19
+ });
20
+
21
+ function core(): DaemonCore {
22
+ const instance = new DaemonCore(home);
23
+
24
+ cores.push(instance);
25
+
26
+ return instance;
27
+ }
28
+
29
+ function createPlan(instance: DaemonCore) {
30
+ return instance.sessionCreate({
31
+ workspace: { repoRoot: "/repo", branch: "main" },
32
+ artifact: { type: "plan", content: "# Plan", meta: {} },
33
+ });
34
+ }
35
+
36
+ describe("durable harness delivery", () => {
37
+ test("redelivers after reload until the harness acknowledges the Message", () => {
38
+ const first = core();
39
+ const thread = createPlan(first);
40
+ const binding = first.harnessBind({
41
+ threadId: thread.id,
42
+ harness: "fake",
43
+ harnessSessionId: "fake_1",
44
+ });
45
+ const sent = first.sessionSendMessage(thread.id, "changes_requested", "Add detail.");
46
+ const initial = first.deliveryPending(binding.id);
47
+
48
+ expect(initial).toHaveLength(1);
49
+ expect(initial[0]!.message).toEqual(sent.message!);
50
+
51
+ const reloaded = core();
52
+ const redelivered = reloaded.deliveryPending(binding.id);
53
+
54
+ expect(redelivered).toEqual(initial);
55
+ reloaded.deliveryAcknowledge(redelivered[0]!.delivery.id);
56
+
57
+ expect(core().deliveryPending(binding.id)).toEqual([]);
58
+ });
59
+
60
+ test("retains an expired Thread until its pending Message is acknowledged", () => {
61
+ const first = core();
62
+ const thread = createPlan(first);
63
+ const binding = first.harnessBind({
64
+ threadId: thread.id,
65
+ harness: "fake",
66
+ harnessSessionId: "fake_1",
67
+ });
68
+
69
+ first.sessionSendMessage(thread.id, "approved", "Ready.");
70
+ const oldThread = first.sessionGet(thread.id);
71
+
72
+ first.store.upsert({ ...oldThread, createdAt: "2020-01-01T00:00:00.000Z" });
73
+
74
+ const reloaded = core();
75
+ const delivery = reloaded.deliveryPending(binding.id)[0];
76
+
77
+ expect(delivery?.message.body).toContain("Ready.");
78
+ reloaded.deliveryAcknowledge(delivery!.delivery.id);
79
+
80
+ expect(core().store.get(thread.id)).toBeUndefined();
81
+ });
82
+
83
+ test("rebuilds a delivery if the Thread saved its Message before enqueue", () => {
84
+ const first = core();
85
+ const thread = createPlan(first);
86
+ const binding = first.harnessBind({
87
+ threadId: thread.id,
88
+ harness: "fake",
89
+ harnessSessionId: "fake_1",
90
+ });
91
+ const sent = first.sessionSendMessage(thread.id, "approved", "Ready.");
92
+ first.store.upsert({
93
+ ...first.sessionGet(thread.id),
94
+ createdAt: "2020-01-01T00:00:00.000Z",
95
+ });
96
+
97
+ writeFileSync(harnessStatePath(home), JSON.stringify({ bindings: [binding], deliveries: [] }));
98
+
99
+ const recovered = core().deliveryPending(binding.id);
100
+
101
+ expect(recovered).toHaveLength(1);
102
+ expect(recovered[0]!.message).toEqual(sent.message!);
103
+ });
104
+
105
+ test("routes an approved retry as a new Message", () => {
106
+ const instance = core();
107
+ const thread = createPlan(instance);
108
+ const binding = instance.harnessBind({
109
+ threadId: thread.id,
110
+ harness: "fake",
111
+ harnessSessionId: "fake_1",
112
+ });
113
+
114
+ instance.sessionSendMessage(thread.id, "changes_requested", "Try once more.");
115
+ const first = instance.deliveryPending(binding.id)[0]!;
116
+
117
+ instance.deliveryAcknowledge(first.delivery.id);
118
+ instance.sessionSubmitRevision(thread.id, "# Plan");
119
+ instance.sessionSendMessage(thread.id, "approved", "Ready.");
120
+ const retry = instance.deliveryPending(binding.id);
121
+
122
+ expect(retry).toHaveLength(1);
123
+ expect(retry[0]!.message.outcome).toBe("approved");
124
+ expect(retry[0]!.message.id).not.toBe(first.message.id);
125
+ });
126
+
127
+ test("keeps an older pending Message available after a Thread revision", () => {
128
+ const instance = core();
129
+ const thread = createPlan(instance);
130
+ const binding = instance.harnessBind({
131
+ threadId: thread.id,
132
+ harness: "fake",
133
+ harnessSessionId: "fake_1",
134
+ });
135
+
136
+ instance.sessionSendMessage(thread.id, "changes_requested", "Add detail.");
137
+ const first = instance.deliveryPending(binding.id)[0]!;
138
+
139
+ instance.sessionSubmitRevision(thread.id, "# Revised plan");
140
+ instance.sessionSendMessage(thread.id, "approved", "Ready.");
141
+
142
+ const pending = core().deliveryPending(binding.id);
143
+
144
+ expect(pending.map((item) => item.message.id)).toEqual([first.message.id, expect.any(String)]);
145
+ expect(pending[0]!.message.body).toBe(first.message.body);
146
+ });
147
+
148
+ test("defaults delivery to the submitting binding only", () => {
149
+ const instance = core();
150
+ const thread = createPlan(instance);
151
+ const submitter = instance.harnessBind({
152
+ threadId: thread.id,
153
+ harness: "fake",
154
+ harnessSessionId: "fake_1",
155
+ });
156
+ const other = instance.harnessBind({
157
+ threadId: thread.id,
158
+ harness: "other",
159
+ harnessSessionId: "other_1",
160
+ });
161
+
162
+ instance.sessionSendMessage(thread.id, "approved", "Ready.");
163
+
164
+ expect(instance.deliveryPending(submitter.id)).toHaveLength(1);
165
+ expect(instance.deliveryPending(other.id)).toEqual([]);
166
+ });
167
+
168
+ test("consumes the unchanged approved retry once across daemon reload", () => {
169
+ const instance = core();
170
+ const thread = createPlan(instance);
171
+ const binding = instance.harnessBind({
172
+ threadId: thread.id,
173
+ harness: "fake",
174
+ harnessSessionId: "fake_1",
175
+ });
176
+ const sent = instance.sessionSendMessage(thread.id, "approved", "Ready.");
177
+ const messageId = sent.message!.id;
178
+
179
+ expect(() => instance.harnessConsumeApprovedRetry(binding.id, messageId, "# Plan")).toThrow(
180
+ "no unchanged approved plan",
181
+ );
182
+
183
+ const delivery = instance.deliveryPending(binding.id)[0]!.delivery;
184
+
185
+ instance.deliveryAcknowledge(delivery.id);
186
+
187
+ expect(instance.harnessConsumeApprovedRetry(binding.id, messageId, "# Plan")).toBe(true);
188
+ expect(core().harnessConsumeApprovedRetry(binding.id, messageId, "# Plan")).toBe(false);
189
+ expect(() => core().harnessConsumeApprovedRetry(binding.id, messageId, "# Changed")).toThrow(
190
+ "no unchanged approved plan",
191
+ );
192
+ });
193
+ });
@@ -0,0 +1,65 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { HarnessStateStore } from "./harness-state-store";
6
+ import { harnessStatePath } from "./paths";
7
+
8
+ let home: string;
9
+
10
+ beforeEach(() => {
11
+ home = mkdtempSync(join(tmpdir(), "cueloop-harness-state-"));
12
+ });
13
+
14
+ afterEach(() => {
15
+ rmSync(home, { recursive: true, force: true });
16
+ });
17
+
18
+ describe("HarnessStateStore", () => {
19
+ test("redelivers an unacknowledged Message after reload", () => {
20
+ const store = new HarnessStateStore(home);
21
+ const binding = store.bind({
22
+ threadId: "ses_1",
23
+ harness: "fake",
24
+ harnessSessionId: "fake_1",
25
+ });
26
+ const delivery = store.enqueue({ bindingId: binding.id, messageId: "msg_1" });
27
+
28
+ expect(store.pending(binding.id)).toEqual([delivery]);
29
+
30
+ const reloaded = new HarnessStateStore(home);
31
+
32
+ expect(reloaded.pending(binding.id)).toEqual([delivery]);
33
+ });
34
+
35
+ test("does not redeliver an acknowledged Message", () => {
36
+ const store = new HarnessStateStore(home);
37
+ const binding = store.bind({
38
+ threadId: "ses_1",
39
+ harness: "fake",
40
+ harnessSessionId: "fake_1",
41
+ });
42
+ const delivery = store.enqueue({ bindingId: binding.id, messageId: "msg_1" });
43
+
44
+ store.acknowledge(delivery.id, "2026-09-20T20:00:00.000Z");
45
+
46
+ expect(new HarnessStateStore(home).pending(binding.id)).toEqual([]);
47
+ });
48
+
49
+ test("reuses the binding for the same harness session and thread", () => {
50
+ const store = new HarnessStateStore(home);
51
+ const input = {
52
+ threadId: "ses_1",
53
+ harness: "fake",
54
+ harnessSessionId: "fake_1",
55
+ };
56
+
57
+ expect(store.bind(input)).toEqual(store.bind(input));
58
+ });
59
+
60
+ test("does not discard pending deliveries when persisted state is corrupt", () => {
61
+ writeFileSync(harnessStatePath(home), "{broken");
62
+
63
+ expect(() => new HarnessStateStore(home)).toThrow();
64
+ });
65
+ });
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Durable harness routing state. Bindings identify where a Thread came from;
3
+ * deliveries record which Message still needs to reach that binding. Keeping
4
+ * both outside Thread records prevents harness identity from leaking into the
5
+ * product primitive and makes redelivery independent of the review lifecycle.
6
+ */
7
+
8
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
9
+ import type { Delivery, HarnessBinding } from "@cueloop/schema";
10
+ import * as v from "valibot";
11
+ import { harnessStatePath } from "./paths";
12
+ import { DeliverySchema, HarnessBindingSchema } from "./validate";
13
+
14
+ type BindInput = Pick<HarnessBinding, "threadId" | "harness" | "harnessSessionId">;
15
+ type EnqueueInput = Pick<Delivery, "bindingId" | "messageId">;
16
+
17
+ const HarnessStateSchema = v.object({
18
+ bindings: v.array(HarnessBindingSchema),
19
+ deliveries: v.array(DeliverySchema),
20
+ });
21
+ const FileErrorSchema = v.object({ code: v.string() });
22
+
23
+ export class HarnessStateStore {
24
+ private readonly path: string;
25
+ private bindings = new Map<string, HarnessBinding>();
26
+ private deliveries = new Map<string, Delivery>();
27
+
28
+ constructor(home: string) {
29
+ this.path = harnessStatePath(home);
30
+ this.load();
31
+ }
32
+
33
+ bind(input: BindInput): HarnessBinding {
34
+ const existing = [...this.bindings.values()].find(
35
+ (binding) =>
36
+ binding.threadId === input.threadId &&
37
+ binding.harness === input.harness &&
38
+ binding.harnessSessionId === input.harnessSessionId,
39
+ );
40
+
41
+ if (existing) return existing;
42
+ const binding: HarnessBinding = {
43
+ ...input,
44
+ id: newRoutingId("bind"),
45
+ createdAt: new Date().toISOString(),
46
+ };
47
+
48
+ this.bindings.set(binding.id, binding);
49
+ this.persist();
50
+
51
+ return binding;
52
+ }
53
+
54
+ binding(id: string): HarnessBinding | null {
55
+ return this.bindings.get(id) ?? null;
56
+ }
57
+
58
+ consumeApprovedRetry(bindingId: string, messageId: string): boolean {
59
+ const binding = this.bindings.get(bindingId);
60
+
61
+ if (!binding) throw new Error(`no harness binding ${bindingId}`);
62
+
63
+ if (binding.approvedRetryMessageId === messageId) return false;
64
+
65
+ this.bindings.set(bindingId, { ...binding, approvedRetryMessageId: messageId });
66
+ this.persist();
67
+
68
+ return true;
69
+ }
70
+
71
+ /** The first binding is the submitter; other bindings need explicit future routing. */
72
+ submittingBinding(threadId: string): HarnessBinding | null {
73
+ return [...this.bindings.values()].find((binding) => binding.threadId === threadId) ?? null;
74
+ }
75
+
76
+ enqueue(input: EnqueueInput): Delivery {
77
+ const existing = [...this.deliveries.values()].find(
78
+ (delivery) =>
79
+ delivery.bindingId === input.bindingId && delivery.messageId === input.messageId,
80
+ );
81
+
82
+ if (existing) return existing;
83
+ const delivery: Delivery = {
84
+ ...input,
85
+ id: newRoutingId("del"),
86
+ status: "pending",
87
+ createdAt: new Date().toISOString(),
88
+ };
89
+
90
+ this.deliveries.set(delivery.id, delivery);
91
+ this.persist();
92
+
93
+ return delivery;
94
+ }
95
+
96
+ pending(bindingId: string): Delivery[] {
97
+ return [...this.deliveries.values()].filter(
98
+ (delivery) => delivery.bindingId === bindingId && delivery.status === "pending",
99
+ );
100
+ }
101
+
102
+ /** A pending delivery keeps its Thread's immutable Message body available for redelivery. */
103
+ pendingThreadIds(): Set<string> {
104
+ const ids = new Set<string>();
105
+
106
+ for (const delivery of this.deliveries.values()) {
107
+ if (delivery.status !== "pending") continue;
108
+ const binding = this.bindings.get(delivery.bindingId);
109
+
110
+ if (binding) ids.add(binding.threadId);
111
+ }
112
+
113
+ return ids;
114
+ }
115
+
116
+ acknowledged(bindingId: string, messageId: string): boolean {
117
+ return [...this.deliveries.values()].some(
118
+ (delivery) =>
119
+ delivery.bindingId === bindingId &&
120
+ delivery.messageId === messageId &&
121
+ delivery.status === "acknowledged",
122
+ );
123
+ }
124
+
125
+ delivery(deliveryId: string): Delivery | null {
126
+ return this.deliveries.get(deliveryId) ?? null;
127
+ }
128
+
129
+ acknowledge(deliveryId: string, acknowledgedAt = new Date().toISOString()): Delivery {
130
+ const delivery = this.deliveries.get(deliveryId);
131
+
132
+ if (!delivery) throw new Error(`no delivery ${deliveryId}`);
133
+
134
+ if (delivery.status === "acknowledged") return delivery;
135
+ const acknowledged: Delivery = {
136
+ ...delivery,
137
+ status: "acknowledged",
138
+ acknowledgedAt,
139
+ };
140
+
141
+ this.deliveries.set(deliveryId, acknowledged);
142
+ this.persist();
143
+
144
+ return acknowledged;
145
+ }
146
+
147
+ private load(): void {
148
+ let serialized: string;
149
+
150
+ try {
151
+ serialized = readFileSync(this.path, "utf8");
152
+ } catch (error) {
153
+ const parsed = v.safeParse(FileErrorSchema, error);
154
+
155
+ if (parsed.success && parsed.output.code === "ENOENT") {
156
+ return;
157
+ }
158
+
159
+ throw error;
160
+ }
161
+
162
+ const state = v.parse(HarnessStateSchema, JSON.parse(serialized));
163
+
164
+ this.bindings = new Map(state.bindings.map((binding) => [binding.id, binding]));
165
+ this.deliveries = new Map(state.deliveries.map((delivery) => [delivery.id, delivery]));
166
+ }
167
+
168
+ private persist(): void {
169
+ const temporaryPath = this.path + ".tmp";
170
+ const state = {
171
+ bindings: [...this.bindings.values()],
172
+ deliveries: [...this.deliveries.values()],
173
+ };
174
+
175
+ writeFileSync(temporaryPath, JSON.stringify(state, null, 2));
176
+ renameSync(temporaryPath, this.path);
177
+ }
178
+ }
179
+
180
+ function newRoutingId(prefix: "bind" | "del"): string {
181
+ return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
182
+ }
@@ -66,7 +66,7 @@ function newSession(overrides: Partial<Thread> = {}): Thread {
66
66
  artifact: { type: "plan", content: "# P", meta: { cwd: "/repo/work", title: "Rollout Plan" } },
67
67
  revisions: [{ revision: 1, content: "# P", submittedAt: "now" }],
68
68
  annotations: [],
69
- verdict: null,
69
+ message: null,
70
70
  status: "pending",
71
71
  createdAt: "now",
72
72
  ...overrides,
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The daemon's public surface. Consumers reach the client and the review
3
- * helpers through the ./client and ./review subpaths; this barrel carries
3
+ * helpers through the ./client and ./thread-review subpaths; this barrel carries
4
4
  * only the two names imported bare: the server and the home-dir resolver.
5
5
  */
6
6
 
package/src/paths.ts CHANGED
@@ -64,3 +64,8 @@ export function ownerTokenPath(home = cueloopHome()): string {
64
64
  export function herdrTabsPath(home = cueloopHome()): string {
65
65
  return join(home, "herdr-tabs.json");
66
66
  }
67
+
68
+ /** Harness bindings and Message deliveries, stored independently of Thread records. */
69
+ export function harnessStatePath(home = cueloopHome()): string {
70
+ return join(home, "harness-state.json");
71
+ }
@@ -36,7 +36,7 @@ function session(id: string, createdAt: string, status: SessionStatus = "resolve
36
36
  artifact: { type: "plan", content: "", meta: {} },
37
37
  revisions: [],
38
38
  annotations: [],
39
- verdict: null,
39
+ message: null,
40
40
  status,
41
41
  createdAt,
42
42
  };
@@ -126,6 +126,15 @@ describe("pruneExpiredSessions", () => {
126
126
  expect(store.get("ses_new")).toBeDefined();
127
127
  });
128
128
 
129
+ test("keeps an expired resolved Thread while its Message is awaiting delivery", () => {
130
+ const store = new ThreadStore(tempDir("cueloop-retention-home-"));
131
+
132
+ store.upsert(session("ses_waiting", daysAgo(40)));
133
+
134
+ expect(pruneExpiredSessions(store, 30, NOW_MS, new Set(["ses_waiting"]))).toEqual([]);
135
+ expect(store.get("ses_waiting")).toBeDefined();
136
+ });
137
+
129
138
  test("never deletes an active pending session however old", () => {
130
139
  // Arrange
131
140
  const home = tempDir("cueloop-retention-home-");