@cueloop/daemon 0.1.0-alpha.98 → 0.1.0-alpha.99

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.
@@ -0,0 +1,397 @@
1
+ import * as v from "valibot";
2
+ import {
3
+ newAnnotationId,
4
+ type Annotation,
5
+ type Artifact,
6
+ type Message,
7
+ type MessageOutcome,
8
+ type Thread,
9
+ type ThreadAgentState,
10
+ type WorkspaceKey,
11
+ } from "@cueloop/schema";
12
+ import { DaemonClient, DaemonClientError, type ConnectOptions } from "./client";
13
+ import { DaemonTransportError, type DaemonRequestOptions } from "./client-errors";
14
+ import {
15
+ createSdkThreadId,
16
+ createSdkOperationId,
17
+ createSdkSubmissionId,
18
+ type SdkThreadId,
19
+ type SdkOperationId,
20
+ type SdkSubmissionId,
21
+ } from "./sdk-identities";
22
+ export {
23
+ createSdkThreadId,
24
+ createSdkOperationId,
25
+ createSdkSubmissionId,
26
+ type SdkThreadId,
27
+ type SdkOperationId,
28
+ type SdkSubmissionId,
29
+ } from "./sdk-identities";
30
+ import { ThreadRecordSchema } from "./validate";
31
+ import { ThreadAgentSchema } from "./thread-agent-validation";
32
+ import { connectThreadObserver, subscribeThreadState } from "./thread-subscription";
33
+
34
+ /** Validated SDK Thread reads carry a distinct identity without changing the wire model. */
35
+ export type SdkThread = Thread & { id: SdkThreadId };
36
+ const SdkThreadRecordSchema = v.pipe(
37
+ ThreadRecordSchema,
38
+ v.transform((thread): SdkThread => ({ ...thread, id: createSdkThreadId(thread.id) })),
39
+ );
40
+
41
+ /** These errors preserve daemon rejection codes and uncertainty after a lost response. */
42
+ export type DaemonSdkError = DaemonClientError | DaemonTransportError;
43
+
44
+ /** Acceptance identifies the exact batch; idle Thread state alone does not prove completion. */
45
+ export interface AgentAcceptance {
46
+ threadId: SdkThreadId;
47
+ operationId: SdkOperationId;
48
+ submissionIds: readonly SdkSubmissionId[];
49
+ }
50
+
51
+ /** Explicit cancellation and failed execution remain distinct terminal outcomes. */
52
+ export interface AgentCompletion extends AgentAcceptance {
53
+ outcome: "completed" | "failed" | "cancelled";
54
+ state: ThreadAgentState;
55
+ }
56
+
57
+ /** A review capability cannot create Threads, submit agents, or resolve reviews. */
58
+ export class ReviewDaemonSdk {
59
+ readonly ids = {
60
+ thread: createSdkThreadId,
61
+ operation: createSdkOperationId,
62
+ submission: createSdkSubmissionId,
63
+ };
64
+
65
+ protected closed = false;
66
+ private readonly observers = new Set<() => void>();
67
+
68
+ constructor(
69
+ protected readonly client: DaemonClient,
70
+ protected readonly connectionOptions: ConnectOptions = {},
71
+ ) {}
72
+
73
+ /** Snapshot observation owns a separate connection and never replays mutations. */
74
+ observeThread(input: {
75
+ threadId: SdkThreadId;
76
+ onValue: (thread: SdkThread) => void;
77
+ onError: (error: DaemonSdkError) => void;
78
+ }): () => void {
79
+ if (this.closed) {
80
+ input.onError(new DaemonTransportError("connection", "SDK connection is closed", "not_sent"));
81
+
82
+ return () => {};
83
+ }
84
+ const dispose = subscribeThreadState({
85
+ connect: connectThreadObserver(this.connectionOptions),
86
+ matches: (event) => event.sessionId === input.threadId,
87
+ read: (client, signal) =>
88
+ client.request("session.get", { id: input.threadId }, SdkThreadRecordSchema, { signal }),
89
+ onValue: input.onValue,
90
+ onError: (error) => input.onError(normalizeSdkError(error)),
91
+ });
92
+ const stop = () => {
93
+ dispose();
94
+ this.observers.delete(stop);
95
+ };
96
+
97
+ this.observers.add(stop);
98
+
99
+ return stop;
100
+ }
101
+
102
+ get threads() {
103
+ return {
104
+ get: (threadId: SdkThreadId, options?: DaemonRequestOptions): Promise<SdkThread> =>
105
+ this.client.request("session.get", { id: threadId }, SdkThreadRecordSchema, options),
106
+ };
107
+ }
108
+
109
+ readonly comments = {
110
+ add: (
111
+ input: { threadId: SdkThreadId; annotation: Parameters<DaemonClient["sessionComment"]>[1] },
112
+ options?: DaemonRequestOptions,
113
+ ): Promise<SdkThread> =>
114
+ this.client.request(
115
+ "session.comment",
116
+ { id: input.threadId, annotation: input.annotation },
117
+ SdkThreadRecordSchema,
118
+ options,
119
+ ),
120
+ list: async (threadId: SdkThreadId, options?: DaemonRequestOptions): Promise<Annotation[]> =>
121
+ (await this.threads.get(threadId, options)).annotations,
122
+ reply: async (
123
+ input: { threadId: SdkThreadId; commentId: string; body: string; replyId?: string },
124
+ options?: DaemonRequestOptions,
125
+ ): Promise<SdkThread> => {
126
+ const thread = await this.threads.get(input.threadId, options);
127
+ const comment = thread.annotations.find((entry) => entry.id === input.commentId);
128
+
129
+ if (!comment)
130
+ throw new DaemonClientError("not_found", "SDK comment reply target does not exist");
131
+ const rootId = comment.replyTo ?? comment.id;
132
+ const root = thread.annotations.find((entry) => entry.id === rootId);
133
+
134
+ if (!root) throw new DaemonClientError("not_found", "SDK comment reply root does not exist");
135
+
136
+ return this.client.request(
137
+ "session.comment",
138
+ {
139
+ id: input.threadId,
140
+ annotation: {
141
+ id: input.replyId ?? newAnnotationId(),
142
+ kind: "comment",
143
+ anchor: root.anchor,
144
+ target: root.target,
145
+ replyTo: root.id,
146
+ body: input.body,
147
+ },
148
+ },
149
+ SdkThreadRecordSchema,
150
+ options,
151
+ );
152
+ },
153
+ };
154
+
155
+ close(): void {
156
+ this.closed = true;
157
+ for (const stop of this.observers) stop();
158
+ this.client.close();
159
+ }
160
+ }
161
+
162
+ /** Owner methods still require daemon token authorization and the experimental agent flag. */
163
+ export class OwnerDaemonSdk extends ReviewDaemonSdk {
164
+ get threads() {
165
+ return {
166
+ ...super.threads,
167
+ create: (
168
+ input: { workspace: WorkspaceKey; artifact: Artifact },
169
+ options?: DaemonRequestOptions,
170
+ ): Promise<SdkThread> =>
171
+ this.client.request("session.create", input, SdkThreadRecordSchema, options),
172
+ };
173
+ }
174
+
175
+ readonly sessions = {
176
+ sendMessage: async (
177
+ input: {
178
+ threadId: SdkThreadId;
179
+ operationId: SdkOperationId;
180
+ outcome: MessageOutcome;
181
+ summary: string;
182
+ actionBodies?: Record<string, string>;
183
+ },
184
+ options?: DaemonRequestOptions,
185
+ ): Promise<Message> => {
186
+ const thread = await this.client.sessionSendMessage(
187
+ input.threadId,
188
+ input.outcome,
189
+ input.summary,
190
+ input.actionBodies,
191
+ input.operationId,
192
+ options,
193
+ );
194
+
195
+ if (!thread.message)
196
+ throw new DaemonTransportError(
197
+ "protocol",
198
+ "SDK accepted review message is missing",
199
+ "unknown",
200
+ "session.sendMessage",
201
+ );
202
+
203
+ return thread.message;
204
+ },
205
+ };
206
+
207
+ readonly agents = {
208
+ get: (threadId: SdkThreadId, options?: DaemonRequestOptions): Promise<ThreadAgentState> =>
209
+ this.client.request("agent.get", { id: threadId }, ThreadAgentSchema, options),
210
+ prompt: async (
211
+ input: {
212
+ threadId: SdkThreadId;
213
+ operationId: SdkOperationId;
214
+ text: string;
215
+ context?: string;
216
+ retry?: SdkSubmissionId;
217
+ },
218
+ options?: DaemonRequestOptions,
219
+ ): Promise<AgentAcceptance> => {
220
+ const state = await this.client.agentPrompt(
221
+ {
222
+ id: input.threadId,
223
+ operationId: input.operationId,
224
+ text: input.text,
225
+ context: input.context,
226
+ retry: input.retry,
227
+ },
228
+ options,
229
+ );
230
+ const receipt = state.promptOperations?.find(
231
+ (entry) => entry.operationId === input.operationId,
232
+ );
233
+
234
+ if (!receipt)
235
+ throw new DaemonTransportError(
236
+ "protocol",
237
+ "SDK accepted agent operation receipt is missing",
238
+ "unknown",
239
+ "agent.prompt",
240
+ );
241
+
242
+ return {
243
+ threadId: input.threadId,
244
+ operationId: input.operationId,
245
+ submissionIds: receipt.result.map(createSdkSubmissionId),
246
+ };
247
+ },
248
+ replyToComment: (
249
+ input: { threadId: SdkThreadId; commentId: string; body: string },
250
+ options?: DaemonRequestOptions,
251
+ ): Promise<ThreadAgentState> =>
252
+ this.client.request(
253
+ "agent.reply",
254
+ { id: input.threadId, commentId: input.commentId, body: input.body },
255
+ ThreadAgentSchema,
256
+ options,
257
+ ),
258
+ wait: (accepted: AgentAcceptance, options?: DaemonRequestOptions): Promise<AgentCompletion> =>
259
+ this.waitAgentCompletion(accepted, options),
260
+ cancel: (threadId: SdkThreadId, options?: DaemonRequestOptions): Promise<ThreadAgentState> =>
261
+ this.client.request("agent.cancel", { id: threadId }, ThreadAgentSchema, options),
262
+ };
263
+
264
+ private readonly waits = new Set<AbortController>();
265
+
266
+ override close(): void {
267
+ this.closed = true;
268
+ for (const wait of this.waits) wait.abort();
269
+ super.close();
270
+ }
271
+
272
+ private waitAgentCompletion(
273
+ accepted: AgentAcceptance,
274
+ options: DaemonRequestOptions = {},
275
+ ): Promise<AgentCompletion> {
276
+ if (this.closed)
277
+ return Promise.reject(
278
+ new DaemonTransportError("connection", "SDK connection is closed", "not_sent"),
279
+ );
280
+ if (!accepted.submissionIds.length)
281
+ return Promise.reject(
282
+ new DaemonClientError("invalid_params", "SDK wait requires accepted submissions"),
283
+ );
284
+
285
+ const controller = new AbortController();
286
+
287
+ this.waits.add(controller);
288
+ const signal = options.signal
289
+ ? AbortSignal.any([controller.signal, options.signal])
290
+ : controller.signal;
291
+
292
+ return new Promise((resolve, reject) => {
293
+ let done = false;
294
+ let detach: (() => void) | undefined;
295
+ const finish = (result?: AgentCompletion, error?: DaemonSdkError) => {
296
+ if (done) return;
297
+ done = true;
298
+ clearTimeout(timer);
299
+ signal.removeEventListener("abort", abort);
300
+ this.waits.delete(controller);
301
+ detach?.();
302
+ if (result) resolve(result);
303
+ else reject(error);
304
+ };
305
+ const abort = () =>
306
+ finish(
307
+ undefined,
308
+ new DaemonTransportError("cancelled", "SDK agent wait cancelled locally", "not_sent"),
309
+ );
310
+ const timer = setTimeout(
311
+ () =>
312
+ finish(
313
+ undefined,
314
+ new DaemonTransportError(
315
+ "timeout",
316
+ "SDK agent completion deadline elapsed",
317
+ "not_sent",
318
+ ),
319
+ ),
320
+ options.timeoutMs ?? 30_000,
321
+ );
322
+
323
+ signal.addEventListener("abort", abort, { once: true });
324
+ if (signal.aborted) {
325
+ abort();
326
+
327
+ return;
328
+ }
329
+ detach = subscribeThreadState({
330
+ connect: connectThreadObserver(this.connectionOptions),
331
+ matches: (event) =>
332
+ event.event === "agent.updated" && event.sessionId === accepted.threadId,
333
+ read: (client, readSignal) =>
334
+ client.request("agent.get", { id: accepted.threadId }, ThreadAgentSchema, {
335
+ signal: readSignal,
336
+ }),
337
+ onValue: (state) => {
338
+ const receipt = state.promptOperations?.find(
339
+ (entry) => entry.operationId === accepted.operationId,
340
+ );
341
+
342
+ if (!receipt || receipt.result.join("\n") !== accepted.submissionIds.join("\n")) {
343
+ finish(
344
+ undefined,
345
+ new DaemonClientError("not_found", "SDK accepted agent operation no longer exists"),
346
+ );
347
+
348
+ return;
349
+ }
350
+ if (receipt.outcome) finish({ ...accepted, outcome: receipt.outcome, state });
351
+ },
352
+ onError: (error) => {
353
+ if (
354
+ error instanceof DaemonClientError ||
355
+ (error instanceof DaemonTransportError && error.kind === "protocol")
356
+ )
357
+ finish(undefined, normalizeSdkError(error));
358
+ },
359
+ });
360
+ });
361
+ }
362
+
363
+ constructor(client: DaemonClient, connectionOptions: ConnectOptions) {
364
+ super(client, connectionOptions);
365
+ }
366
+ }
367
+
368
+ /** Choose capabilities at connection time; the daemon remains the authority. */
369
+ export async function connectOwnerSdk(
370
+ options: Omit<ConnectOptions, "role"> = {},
371
+ ): Promise<OwnerDaemonSdk> {
372
+ return new OwnerDaemonSdk(await DaemonClient.connect({ ...options, role: "owner" }), {
373
+ ...options,
374
+ role: "owner",
375
+ });
376
+ }
377
+
378
+ /** Collaborators and review agents expose only shared review operations. */
379
+ export async function connectReviewSdk(
380
+ options: ConnectOptions & { role: "collaborator" | "agent" },
381
+ ): Promise<ReviewDaemonSdk> {
382
+ return new ReviewDaemonSdk(await DaemonClient.connect(options), options);
383
+ }
384
+
385
+ /** Preserve typed failures; unexpected transport exceptions carry their original cause. */
386
+ // eslint-disable-next-line type-evidence/no-unknown-parameters -- Promise rejection is an untyped transport boundary.
387
+ export function normalizeSdkError(error: unknown): DaemonSdkError {
388
+ if (error instanceof DaemonClientError || error instanceof DaemonTransportError) return error;
389
+
390
+ return new DaemonTransportError(
391
+ "connection",
392
+ "SDK daemon operation failed",
393
+ "unknown",
394
+ undefined,
395
+ { cause: error },
396
+ );
397
+ }
@@ -0,0 +1,55 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Effect } from "effect";
3
+ import { CueloopSdk } from "../daemon-sdk-effect";
4
+
5
+ const reviewConversation = Effect.gen(function* () {
6
+ const sdk = yield* CueloopSdk;
7
+ const thread = yield* sdk.threads.create({
8
+ workspace: { repoRoot: process.cwd(), branch: "main" },
9
+ artifact: { type: "plan", content: "Review this proposal.", meta: { title: "SDK example" } },
10
+ });
11
+
12
+ yield* sdk.threads.get(thread.id);
13
+ yield* sdk.comments.add({
14
+ threadId: thread.id,
15
+ annotation: {
16
+ id: "question",
17
+ kind: "comment",
18
+ body: "Explain the proposal",
19
+ anchor: { quote: "proposal", prefix: "Review this ", suffix: "." },
20
+ },
21
+ });
22
+ const accepted = yield* sdk.agents.prompt({
23
+ threadId: thread.id,
24
+ operationId: sdk.ids.operation(randomUUID()),
25
+ text: "Explain this Thread and its comment.",
26
+ });
27
+ const completed = yield* sdk.agents.wait(accepted, { timeoutMs: 300_000 });
28
+
29
+ if (completed.outcome !== "completed")
30
+ throw new Error(`SDK example agent finished: ${completed.outcome}`);
31
+ const comments = yield* sdk.comments.list(thread.id);
32
+
33
+ if (comments[0])
34
+ yield* sdk.comments.reply({
35
+ threadId: thread.id,
36
+ commentId: comments[0].id,
37
+ body: "The explanation is clear.",
38
+ });
39
+ yield* sdk.sessions.sendMessage({
40
+ threadId: thread.id,
41
+ operationId: sdk.ids.operation(randomUUID()),
42
+ outcome: "approved",
43
+ summary: "Review complete",
44
+ });
45
+
46
+ return thread.id;
47
+ });
48
+
49
+ if (import.meta.main) {
50
+ const threadId = await Effect.runPromise(
51
+ reviewConversation.pipe(Effect.provide(CueloopSdk.ownerLayer({ autostart: true }))),
52
+ );
53
+
54
+ console.log(`Thread approved: ${threadId}`);
55
+ }
@@ -164,6 +164,7 @@ describe("durable harness delivery", () => {
164
164
  harnessSessionId: "fake_1",
165
165
  });
166
166
  const sent = first.sessionSendMessage(thread.id, "approved", "Ready.");
167
+
167
168
  first.store.upsert({
168
169
  ...first.sessionGet(thread.id),
169
170
  createdAt: "2020-01-01T00:00:00.000Z",
@@ -0,0 +1,111 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { DaemonCore } from "./api";
6
+
7
+ test("send message operation receipts survive restart and reject changed payloads", () => {
8
+ const home = mkdtempSync(join(tmpdir(), "cueloop-operation-"));
9
+ let core = new DaemonCore(home);
10
+
11
+ try {
12
+ const thread = core.sessionCreate({
13
+ workspace: { repoRoot: home, branch: "main" },
14
+ artifact: { type: "plan", content: "Review this", meta: {} },
15
+ });
16
+ const accepted = core.sessionSendMessage(thread.id, "comment", "First", undefined, "op-first");
17
+ const message = structuredClone(accepted.message);
18
+
19
+ core.sessionSendMessage(thread.id, "comment", "Second", undefined, "op-second");
20
+ core.dispose();
21
+ core = new DaemonCore(home);
22
+ const replay = core.sessionSendMessage(thread.id, "comment", "First", undefined, "op-first");
23
+
24
+ expect(replay.message).toEqual(message);
25
+ expect(
26
+ core.sessionGet(thread.id).history?.entries.filter((entry) => entry.type === "message"),
27
+ ).toHaveLength(2);
28
+ expect(() =>
29
+ core.sessionSendMessage(thread.id, "comment", "Changed", undefined, "op-first"),
30
+ ).toThrow("Operation payload conflict");
31
+ } finally {
32
+ core.dispose();
33
+ rmSync(home, { recursive: true, force: true });
34
+ }
35
+ });
36
+
37
+ test("receipt capacity never evicts accepted IDs and share blobs omit private receipts", async () => {
38
+ const { packSessionBlob, unpackSessionBlob } = await import("./share-blob");
39
+ const home = mkdtempSync(join(tmpdir(), "cueloop-operation-capacity-"));
40
+ const core = new DaemonCore(home);
41
+
42
+ try {
43
+ const thread = core.sessionCreate({
44
+ workspace: { repoRoot: home, branch: "main" },
45
+ artifact: { type: "plan", content: "Review", meta: {} },
46
+ });
47
+ const first = structuredClone(
48
+ core.sessionSendMessage(thread.id, "comment", "First", undefined, "first").message,
49
+ );
50
+
51
+ for (let index = 1; index < 128; index++)
52
+ core.sessionSendMessage(
53
+ thread.id,
54
+ "comment",
55
+ `Message ${index}`,
56
+ undefined,
57
+ `operation-${index}`,
58
+ );
59
+ expect(() =>
60
+ core.sessionSendMessage(thread.id, "comment", "Overflow", undefined, "overflow"),
61
+ ).toThrow("Operation receipt capacity");
62
+ expect(
63
+ core.sessionSendMessage(thread.id, "comment", "First", undefined, "first").message,
64
+ ).toEqual(first);
65
+ expect(
66
+ unpackSessionBlob(packSessionBlob(core.sessionGet(thread.id))).messageOperations,
67
+ ).toBeUndefined();
68
+ } finally {
69
+ core.dispose();
70
+ rmSync(home, { recursive: true, force: true });
71
+ }
72
+ });
73
+
74
+ test("failed receipt persistence never acknowledges an in-memory-only review message", async () => {
75
+ const { mkdirSync, readdirSync } = await import("node:fs");
76
+ const { threadsDir } = await import("./paths");
77
+ const home = mkdtempSync(join(tmpdir(), "cueloop-operation-disk-failure-"));
78
+ const core = new DaemonCore(home);
79
+
80
+ try {
81
+ const thread = core.sessionCreate({
82
+ workspace: { repoRoot: home, branch: "main" },
83
+ artifact: { type: "plan", content: "Review", meta: {} },
84
+ });
85
+ const bucket = readdirSync(threadsDir(home))[0]!;
86
+ const tempPath = join(threadsDir(home), bucket, `${thread.id}.jsonl.tmp`);
87
+
88
+ mkdirSync(tempPath);
89
+ expect(() =>
90
+ core.sessionSendMessage(thread.id, "comment", "Once", undefined, "persist-once"),
91
+ ).toThrow();
92
+ expect(core.sessionGet(thread.id).messageOperations).toBeUndefined();
93
+ expect(core.sessionGet(thread.id).message).toBeNull();
94
+ rmSync(tempPath, { recursive: true });
95
+ const accepted = core.sessionSendMessage(
96
+ thread.id,
97
+ "comment",
98
+ "Once",
99
+ undefined,
100
+ "persist-once",
101
+ );
102
+
103
+ expect(accepted.messageOperations).toHaveLength(1);
104
+ expect(
105
+ core.sessionSendMessage(thread.id, "comment", "Once", undefined, "persist-once").message,
106
+ ).toEqual(accepted.message);
107
+ } finally {
108
+ core.dispose();
109
+ rmSync(home, { recursive: true, force: true });
110
+ }
111
+ });
@@ -0,0 +1,91 @@
1
+ import type { ThreadAgentState, OperationReceipt, AgentPromptRequest } from "@cueloop/schema";
2
+ import { createHash } from "node:crypto";
3
+ import { DaemonError } from "./errors";
4
+
5
+ /** Hash normalized payloads rather than persisting another copy of private input. */
6
+ export function operationFingerprint(
7
+ payload: readonly (string | null | readonly (readonly [string, string])[])[],
8
+ ): string {
9
+ return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
10
+ }
11
+
12
+ /** A duplicate returns its original acceptance even after later operations or restart. */
13
+ export function findOperationReceipt<Result>(
14
+ receipts: readonly OperationReceipt<Result>[] | undefined,
15
+ operationId: string | undefined,
16
+ fingerprint: string,
17
+ ): OperationReceipt<Result> | undefined {
18
+ if (!operationId) return;
19
+ const receipt = receipts?.find((entry) => entry.operationId === operationId);
20
+
21
+ if (receipt && receipt.fingerprint !== fingerprint)
22
+ throw new DaemonError(
23
+ "operation_conflict",
24
+ "Operation payload conflict: use a new operation ID for changed input",
25
+ );
26
+ if (!receipt && (receipts?.length ?? 0) >= 128)
27
+ throw new DaemonError(
28
+ "operation_capacity",
29
+ "Operation receipt capacity: create a new Thread before submitting more operations",
30
+ );
31
+
32
+ return receipt;
33
+ }
34
+
35
+ /** Each accepted prompt freezes the original batch before it can start a harness turn. */
36
+ export function recordPromptOperation(
37
+ state: ThreadAgentState,
38
+ before: ThreadAgentState,
39
+ input: AgentPromptRequest,
40
+ ): void {
41
+ if (!input.operationId) return;
42
+ const previous = new Set(before.submissions?.map((entry) => entry.id));
43
+ const accepted = input.retry
44
+ ? [input.retry]
45
+ : (state.submissions ?? []).filter((entry) => !previous.has(entry.id)).map((entry) => entry.id);
46
+
47
+ if (!accepted.length) throw new DaemonError("invalid_params", "Agent operation requires input");
48
+ const fingerprint = promptOperationFingerprint(input);
49
+
50
+ (state.promptOperations ??= []).push({
51
+ operationId: input.operationId,
52
+ fingerprint,
53
+ result: accepted,
54
+ });
55
+ }
56
+
57
+ /** Completion is an immutable fact about acceptance, including across explicit retries. */
58
+ export function settlePromptOperations(state: ThreadAgentState): void {
59
+ for (const receipt of state.promptOperations ?? []) {
60
+ if (receipt.outcome || !receipt.result.length) continue;
61
+ const submissions = receipt.result.map((id) =>
62
+ state.submissions?.find((entry) => entry.id === id),
63
+ );
64
+
65
+ if (
66
+ submissions.some((entry) => !entry || entry.status === "queued" || entry.status === "running")
67
+ )
68
+ continue;
69
+ receipt.outcome = submissions.some((entry) => entry?.cancelled)
70
+ ? "cancelled"
71
+ : submissions.some((entry) => entry?.status === "failed")
72
+ ? "failed"
73
+ : "completed";
74
+ }
75
+ }
76
+
77
+ /** Prompt identity hashes only explicit input; a retry does not consume newly pending comments. */
78
+ export function findPromptOperationReceipt(
79
+ state: ThreadAgentState,
80
+ input: AgentPromptRequest,
81
+ ): OperationReceipt<string[]> | undefined {
82
+ return findOperationReceipt(
83
+ state.promptOperations,
84
+ input.operationId,
85
+ promptOperationFingerprint(input),
86
+ );
87
+ }
88
+
89
+ function promptOperationFingerprint(input: AgentPromptRequest): string {
90
+ return operationFingerprint([input.text, input.context ?? null, input.retry ?? null]);
91
+ }
package/src/pr-poller.ts CHANGED
@@ -29,6 +29,7 @@ export class PrReviewPoller {
29
29
  trackPr(sessionId: string, pr: string, reviewedRefs: PullRequestRefs | null = null): void {
30
30
  if (this.polls.has(sessionId)) return;
31
31
  const timer = setInterval(() => void this.refreshHead(sessionId), this.intervalMs);
32
+
32
33
  // the poll timer must not by itself keep the daemon alive against idle-exit
33
34
  timer.unref?.();
34
35
  this.polls.set(sessionId, { pr, lastRefs: reviewedRefs, timer });