@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,35 @@
1
+ import * as v from "valibot";
2
+
3
+ const ThreadIdSchema = v.pipe(v.string(), v.minLength(1), v.maxLength(128), v.brand("SdkThreadId"));
4
+ const OperationIdSchema = v.pipe(
5
+ v.string(),
6
+ v.minLength(1),
7
+ v.maxLength(128),
8
+ v.brand("SdkOperationId"),
9
+ );
10
+ const SubmissionIdSchema = v.pipe(
11
+ v.string(),
12
+ v.minLength(1),
13
+ v.maxLength(128),
14
+ v.brand("SdkSubmissionId"),
15
+ );
16
+
17
+ /** Thread, operation and submission IDs share a wire representation, not a domain type. */
18
+ export type SdkThreadId = v.InferOutput<typeof ThreadIdSchema>;
19
+ /** Operation IDs are caller-owned and persist across explicit retries. */
20
+ export type SdkOperationId = v.InferOutput<typeof OperationIdSchema>;
21
+ /** Submission IDs are minted by the daemon after accepting agent input. */
22
+ export type SdkSubmissionId = v.InferOutput<typeof SubmissionIdSchema>;
23
+
24
+ /** External Thread identities must be nonempty and at most 128 characters. */
25
+ export function createSdkThreadId(value: string): SdkThreadId {
26
+ return v.parse(ThreadIdSchema, value);
27
+ }
28
+ /** Caller operation identities are stable across retries and at most 128 characters. */
29
+ export function createSdkOperationId(value: string): SdkOperationId {
30
+ return v.parse(OperationIdSchema, value);
31
+ }
32
+ /** Submission identities are daemon-minted; parsing grants no capability. */
33
+ export function createSdkSubmissionId(value: string): SdkSubmissionId {
34
+ return v.parse(SubmissionIdSchema, value);
35
+ }
package/src/server.ts CHANGED
@@ -353,6 +353,7 @@ export class DaemonServer {
353
353
  private async callAgentTool(threadId: string, name: string, input: string): Promise<string> {
354
354
  const args = parseAgentToolInput(name, input);
355
355
  const origin = this.core.sessionGet(threadId);
356
+
356
357
  assertAgentToolScope(origin, args);
357
358
  if (args.kind === "api" && args.method === "session.list") {
358
359
  const { filter } = parseParams("session.list", args.params);
@@ -362,8 +363,8 @@ export class DaemonServer {
362
363
  if (args.kind === "reply") {
363
364
  const thread = origin;
364
365
  const comment = thread.annotations.find((entry) => entry.id === args.commentId);
365
- if (!comment)
366
- return JSON.stringify(this.threadAgent.reply(args.id, args.commentId, args.body));
366
+
367
+ if (!comment) return JSON.stringify(this.replyAgentComment(args));
367
368
  const root =
368
369
  thread.annotations.find((entry) => entry.id === (comment.replyTo ?? comment.id)) ?? comment;
369
370
 
@@ -384,6 +385,7 @@ export class DaemonServer {
384
385
  );
385
386
  }
386
387
  const method = args.method;
388
+
387
389
  if (!isKnownMethod(method)) throw new Error("Thread agent tool API method is unavailable");
388
390
 
389
391
  return JSON.stringify(
@@ -394,35 +396,62 @@ export class DaemonServer {
394
396
  );
395
397
  }
396
398
 
399
+ private replyAgentComment(params: {
400
+ id: string;
401
+ commentId: string;
402
+ body: string;
403
+ }): ReturnType<ThreadAgentManager["reply"]> {
404
+ this.threadAgent.assertEnabled(params.id);
405
+
406
+ return this.threadAgent.reply(params.id, params.commentId, params.body);
407
+ }
408
+
397
409
  private readonly handlers: Record<MethodName, MethodHandler> = {
398
410
  "agent.configure": (_connection, request) => {
399
411
  const params = parseParams("agent.configure", request.params);
412
+
400
413
  this.threadAgent.assertEnabled(params.id);
414
+
401
415
  return this.threadAgent.configure(params);
402
416
  },
403
417
  "agent.get": (_connection, request) => {
404
418
  const { id } = parseParams("agent.get", request.params);
419
+
405
420
  this.threadAgent.assertEnabled(id);
421
+
406
422
  return this.threadAgent.get(id);
407
423
  },
408
424
  "agent.prompt": (_connection, request) => {
409
425
  const params = parseParams("agent.prompt", request.params);
426
+
410
427
  this.threadAgent.assertEnabled(params.id);
428
+
411
429
  return this.threadAgent.prompt(params);
412
430
  },
413
431
  "agent.cancel": (_connection, request) => {
414
432
  const { id } = parseParams("agent.cancel", request.params);
433
+
415
434
  this.threadAgent.assertEnabled(id);
435
+
416
436
  return this.threadAgent.cancel(id);
417
437
  },
438
+ "agent.reply": (_connection, request) => {
439
+ const params = parseParams("agent.reply", request.params);
440
+
441
+ return this.replyAgentComment(params);
442
+ },
418
443
  "agent.comment": (_connection, request) => {
419
444
  const params = parseParams("agent.comment", request.params);
445
+
420
446
  this.threadAgent.assertEnabled(params.id);
447
+
421
448
  return this.threadAgent.comment(params);
422
449
  },
423
450
  "agent.permission": (_connection, request) => {
424
451
  const params = parseParams("agent.permission", request.params);
452
+
425
453
  this.threadAgent.assertEnabled(params.id);
454
+
426
455
  return this.threadAgent.permission(params);
427
456
  },
428
457
  "daemon.ping": () => ({ pid: process.pid, version: this.version }),
@@ -645,6 +674,7 @@ export class DaemonServer {
645
674
  params.outcome,
646
675
  params.summary,
647
676
  params.actionBodies,
677
+ params.operationId,
648
678
  );
649
679
  },
650
680
  "harness.bind": (_connection, request) => {
package/src/share-blob.ts CHANGED
@@ -25,7 +25,9 @@ export const DEFAULT_SHARE_PORT = 22;
25
25
 
26
26
  /** Serialise + compress a session for upload. */
27
27
  export function packSessionBlob(session: Thread): Buffer {
28
- return gzipSync(Buffer.from(JSON.stringify(session), "utf8"));
28
+ const { messageOperations: _privateReceipts, ...shared } = session;
29
+
30
+ return gzipSync(Buffer.from(JSON.stringify(shared), "utf8"));
29
31
  }
30
32
 
31
33
  /** Decompress + validate an uploaded blob, or throw a precise reason. */
@@ -28,6 +28,7 @@ describe("JSONL thread store", () => {
28
28
  test("a thread is written under a per-project bucket keyed by root commit", () => {
29
29
  const home = tmpHome();
30
30
  const store = new ThreadStore(home);
31
+
31
32
  store.recover();
32
33
  store.upsert(
33
34
  createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z", {
@@ -36,6 +37,7 @@ describe("JSONL thread store", () => {
36
37
  );
37
38
 
38
39
  const bucket = threadBucket("abcdef0123456789", home);
40
+
39
41
  expect(readdirSync(bucket)).toContain("ses_a.jsonl");
40
42
  });
41
43
 
@@ -52,6 +54,7 @@ describe("JSONL thread store", () => {
52
54
  test("a repo-less thread lands in the standalone bucket", () => {
53
55
  const home = tmpHome();
54
56
  const store = new ThreadStore(home);
57
+
55
58
  store.recover();
56
59
  store.upsert(createTestThreadRecord("ses_s", "2026-09-01T00:00:00.000Z"));
57
60
 
@@ -60,8 +63,10 @@ describe("JSONL thread store", () => {
60
63
 
61
64
  test("boot migrates the old whole-record file and parks the original", () => {
62
65
  const home = tmpHome();
66
+
63
67
  mkdirSync(sessionsDir(home), { recursive: true });
64
68
  const legacy = createTestThreadRecord("ses_old", "2026-09-01T00:00:00.000Z");
69
+
65
70
  writeFileSync(join(sessionsDir(home), "ses_old.json"), JSON.stringify(legacy));
66
71
 
67
72
  const store = new ThreadStore(home);
@@ -80,6 +85,7 @@ describe("JSONL thread store", () => {
80
85
  const home = tmpHome();
81
86
  // a newer JSONL already holds this thread (e.g. written after a rollback)
82
87
  const store = new ThreadStore(home);
88
+
83
89
  store.recover();
84
90
  store.upsert(
85
91
  createTestThreadRecord("ses_x", "2026-09-01T00:00:00.000Z", {
@@ -99,6 +105,7 @@ describe("JSONL thread store", () => {
99
105
  );
100
106
 
101
107
  const reopened = new ThreadStore(home);
108
+
102
109
  reopened.recover();
103
110
  // the newer JSONL wins; the legacy snapshot is parked, not applied
104
111
  expect(reopened.get("ses_x")?.workspace.branch).toBe("newer");
@@ -108,6 +115,7 @@ describe("JSONL thread store", () => {
108
115
  test("a long log compacts to a single line, and the record survives it", () => {
109
116
  const home = tmpHome();
110
117
  const store = new ThreadStore(home);
118
+
111
119
  store.recover();
112
120
  // more upserts than the compaction threshold
113
121
  for (let iteration = 0; iteration < 60; iteration++) {
@@ -123,9 +131,11 @@ describe("JSONL thread store", () => {
123
131
  const lines = readFileSync(file, "utf8")
124
132
  .split("\n")
125
133
  .filter((l) => l.trim());
134
+
126
135
  expect(lines.length).toBeLessThan(60);
127
136
  // the latest snapshot survives the compaction and a restart
128
137
  const reopened = new ThreadStore(home);
138
+
129
139
  reopened.recover();
130
140
  expect(reopened.get("ses_a")?.workspace.branch).toBe("b59");
131
141
  });
@@ -133,15 +143,18 @@ describe("JSONL thread store", () => {
133
143
  test("a crash's torn trailing line is skipped; the last valid snapshot wins", () => {
134
144
  const home = tmpHome();
135
145
  const store = new ThreadStore(home);
146
+
136
147
  store.recover();
137
148
  store.upsert(createTestThreadRecord("ses_a", "2026-09-01T00:00:00.000Z"));
138
149
 
139
150
  // simulate a crash mid-append: a half-written line at the end
140
151
  const file = join(threadBucket(undefined, home), "ses_a.jsonl");
152
+
141
153
  appendFileSync(file, '{"id":"ses_a","half');
142
154
 
143
155
  const reopened = new ThreadStore(home);
144
156
  const report = reopened.recover();
157
+
145
158
  expect(report.recovered).toEqual(["ses_a"]);
146
159
  expect(reopened.get("ses_a")?.id).toBe("ses_a");
147
160
 
@@ -153,6 +166,7 @@ describe("JSONL thread store", () => {
153
166
  }),
154
167
  );
155
168
  const restarted = new ThreadStore(home);
169
+
156
170
  restarted.recover();
157
171
  expect(restarted.get("ses_a")?.workspace.branch).toBe("after-tear");
158
172
  });
package/src/store.ts CHANGED
@@ -94,6 +94,7 @@ export class ThreadStore implements ThreadRepository {
94
94
  for (const file of readdirSync(bucketPath)) {
95
95
  if (!file.endsWith(".jsonl")) continue;
96
96
  const filePath = join(bucketPath, file);
97
+
97
98
  try {
98
99
  const { session, lines, torn } = readThread(filePath);
99
100
 
@@ -181,19 +182,25 @@ export class ThreadStore implements ThreadRepository {
181
182
  }
182
183
 
183
184
  upsert(session: Thread): void {
184
- this.sessions.set(session.id, session);
185
185
  const filePath = this.files.get(session.id);
186
186
  const lines = this.lineCounts.get(session.id) ?? 0;
187
187
 
188
188
  // the first write, an over-long log, and a resolved (now immutable) thread all compact to one
189
189
  // line; every other mutation is a cheap append that a torn tail on crash simply drops on read
190
- if (filePath === undefined || lines >= COMPACT_THRESHOLD || session.status === "resolved") {
190
+ if (
191
+ filePath === undefined ||
192
+ lines >= COMPACT_THRESHOLD ||
193
+ session.status === "resolved" ||
194
+ session.messageOperations?.length
195
+ ) {
191
196
  this.writeWhole(session);
197
+ this.sessions.set(session.id, session);
192
198
 
193
199
  return;
194
200
  }
195
201
  appendFileSync(filePath, JSON.stringify(session) + "\n");
196
202
  this.lineCounts.set(session.id, lines + 1);
203
+ this.sessions.set(session.id, session);
197
204
  }
198
205
 
199
206
  delete(id: string): boolean {
@@ -290,9 +297,11 @@ export class MemoryThreadStore implements ThreadRepository {
290
297
  export type SessionRepository = ThreadRepository;
291
298
  /** @deprecated use ThreadStore */
292
299
  export const SessionStore = ThreadStore;
300
+
293
301
  /** @deprecated use ThreadStore */
294
302
  export type SessionStore = ThreadStore;
295
303
  /** @deprecated use MemoryThreadStore */
296
304
  export const MemorySessionStore = MemoryThreadStore;
305
+
297
306
  /** @deprecated use MemoryThreadStore */
298
307
  export type MemorySessionStore = MemoryThreadStore;
@@ -80,6 +80,17 @@ const StoredAgentSchema = v.object({
80
80
  quote: v.optional(v.string()),
81
81
  context: v.optional(v.string()),
82
82
  status: v.picklist(["queued", "running", "completed", "failed"]),
83
+ cancelled: v.optional(v.boolean()),
84
+ }),
85
+ ),
86
+ ),
87
+ promptOperations: v.optional(
88
+ v.array(
89
+ v.object({
90
+ operationId: v.string(),
91
+ fingerprint: v.string(),
92
+ result: v.array(v.string()),
93
+ outcome: v.optional(v.picklist(["completed", "failed", "cancelled"])),
83
94
  }),
84
95
  ),
85
96
  ),
@@ -72,10 +72,13 @@ test("a non-ACP harness streams and resumes through the same Thread manager", as
72
72
  });
73
73
  expect(manager.get(thread.id).messages[1]?.text).toBe("Native answer");
74
74
  const promptDiscussion = { id: manager.get(thread.id).submissions![0]!.id };
75
+
75
76
  expect(manager.isReadOnly(thread.id, promptDiscussion.id)).toBe(true);
76
77
  const promptReply = manager.reply(thread.id, promptDiscussion.id, "The prompt is clear");
78
+
77
79
  expect(promptReply.comments.at(-1)?.replyTo).toBe(promptDiscussion.id);
78
80
  const answer = manager.get(thread.id).messages[1]!;
81
+
79
82
  manager.comment({
80
83
  id: thread.id,
81
84
  comment: {
@@ -87,6 +90,7 @@ test("a non-ACP harness streams and resumes through the same Thread manager", as
87
90
  },
88
91
  });
89
92
  const replied = manager.reply(thread.id, "answer-question", "This is the explanation");
93
+
90
94
  expect(replied.comments.at(-1)).toMatchObject({
91
95
  replyTo: "answer-question",
92
96
  messageId: answer.id,
@@ -305,3 +309,104 @@ test("rejected first batches leave originals editable and create no queued turn"
305
309
  rmSync(home, { recursive: true, force: true });
306
310
  }
307
311
  });
312
+
313
+ test("prompt operation receipts survive restart without requeueing accepted inputs", () => {
314
+ const home = mkdtempSync(join(tmpdir(), "cueloop-agent-receipt-"));
315
+ const thread = createTestThread(home);
316
+ const adapter: AgentHarnessAdapter = {
317
+ id: "receipt-test",
318
+ label: "Receipt test",
319
+ connect: () => ({
320
+ start: async () => "session",
321
+ prompt: () => new Promise(() => {}),
322
+ cancel() {},
323
+ permission() {},
324
+ close() {},
325
+ }),
326
+ };
327
+ const options = { home, enabled: true, adapter, getThread: () => thread, onChange() {} };
328
+ let manager = new ThreadAgentManager(options);
329
+
330
+ try {
331
+ const input = { id: thread.id, text: "Once", operationId: "once" };
332
+ const accepted = manager.prompt(input);
333
+
334
+ manager.dispose();
335
+ manager = new ThreadAgentManager(options);
336
+ const replay = manager.prompt(input);
337
+
338
+ expect(replay.promptOperations?.[0]?.result).toEqual(accepted.promptOperations?.[0]?.result);
339
+ expect(replay.submissions).toHaveLength(1);
340
+ expect(replay.promptOperations?.[0]?.outcome).toBe("failed");
341
+ expect(() => manager.prompt({ ...input, text: "Different" })).toThrow(
342
+ "Operation payload conflict",
343
+ );
344
+ manager.prompt({
345
+ id: thread.id,
346
+ text: "",
347
+ retry: replay.submissions![0]!.id,
348
+ operationId: "retry",
349
+ });
350
+ expect(manager.get(thread.id).promptOperations?.[0]?.outcome).toBe("failed");
351
+ } finally {
352
+ manager.dispose();
353
+ rmSync(home, { recursive: true, force: true });
354
+ }
355
+ });
356
+
357
+ test("retrying accepted work after a failed running-state save starts the harness once", async () => {
358
+ const home = mkdtempSync(join(tmpdir(), "cueloop-agent-start-save-"));
359
+ const thread = createTestThread(home);
360
+ const blockedPath = join(home, "thread-agents", `${thread.id}.json.tmp`);
361
+ let saves = 0;
362
+ let prompts = 0;
363
+ const started = Promise.withResolvers<void>();
364
+ const adapter: AgentHarnessAdapter = {
365
+ id: "start-save-test",
366
+ label: "Start save test",
367
+ connect: () => ({
368
+ start: async () => "session",
369
+ prompt: () => {
370
+ prompts++;
371
+ started.resolve();
372
+
373
+ return new Promise(() => {});
374
+ },
375
+ cancel() {},
376
+ permission() {},
377
+ close() {},
378
+ }),
379
+ };
380
+ const manager = new ThreadAgentManager({
381
+ home,
382
+ enabled: true,
383
+ adapter,
384
+ getThread: () => thread,
385
+ onChange() {
386
+ if (++saves === 1) mkdirSync(blockedPath);
387
+ },
388
+ });
389
+
390
+ try {
391
+ const input = { id: thread.id, text: "Start once", operationId: "start-once" };
392
+
393
+ expect(() => manager.prompt(input)).toThrow();
394
+ expect(manager.get(thread.id).phase.kind).toBe("idle");
395
+ expect(manager.get(thread.id).submissions?.[0]?.status).toBe("queued");
396
+ expect(prompts).toBe(0);
397
+ const acceptedIds = manager.get(thread.id).promptOperations?.[0]?.result;
398
+
399
+ rmSync(blockedPath, { recursive: true });
400
+ const replay = manager.prompt(input);
401
+
402
+ expect(replay.promptOperations?.[0]?.result).toEqual(acceptedIds);
403
+ expect(replay.submissions).toHaveLength(1);
404
+ await started.promise;
405
+ expect(prompts).toBe(1);
406
+ manager.prompt(input);
407
+ expect(prompts).toBe(1);
408
+ } finally {
409
+ manager.dispose();
410
+ rmSync(home, { recursive: true, force: true });
411
+ }
412
+ });
@@ -9,6 +9,7 @@ import {
9
9
  stepAgentSubmission,
10
10
  isAgentNote,
11
11
  type AgentSubmission,
12
+ type AgentPromptRequest,
12
13
  type AgentHarnessTools,
13
14
  resolveAnchor,
14
15
  type AgentComment,
@@ -19,6 +20,11 @@ import {
19
20
  type AgentHarnessEvent,
20
21
  type AgentHarnessResult,
21
22
  } from "@cueloop/schema";
23
+ import {
24
+ findPromptOperationReceipt,
25
+ recordPromptOperation,
26
+ settlePromptOperations,
27
+ } from "./operation-receipts";
22
28
  import { writeHarnessDiagnostic } from "./harness-diagnostics";
23
29
  import { stepAgentTurn, agentTurnCancelled, type AgentTurn } from "./agent-turn";
24
30
  import { ThreadAgentSchema } from "./thread-agent-validation";
@@ -90,7 +96,11 @@ export class ThreadAgentManager {
90
96
  };
91
97
 
92
98
  if (state.threadId !== id) throw new Error("Thread agent record has the wrong thread identity");
93
- if (state.phase.kind === "running" || state.phase.kind === "permission") {
99
+ if (
100
+ state.phase.kind === "running" ||
101
+ state.phase.kind === "permission" ||
102
+ state.submissions?.some((entry) => entry.status === "queued" || entry.status === "running")
103
+ ) {
94
104
  state.phase = {
95
105
  kind: "failed",
96
106
  error: "Agent was interrupted by a daemon restart. Send a message to resume.",
@@ -103,21 +113,29 @@ export class ThreadAgentManager {
103
113
  if (tool.status === "pending" || tool.status === "in_progress") tool.status = "cancelled";
104
114
  }
105
115
  }
116
+ settlePromptOperations(state);
106
117
  this.states.set(id, state);
107
118
 
108
119
  return structuredClone(state);
109
120
  }
110
121
 
111
122
  /** Freeze each pending comment once and queue an individual answer at the Thread tail. */
112
- prompt(params: { id: string; text: string; context?: string; retry?: string }): ThreadAgentState {
123
+ prompt(params: AgentPromptRequest): ThreadAgentState {
113
124
  this.assertEnabled(params.id);
114
- if (!this.options.adapter) throw new Error("Thread agent harness is not configured");
115
125
  const state = this.mutable(params.id);
116
126
  const thread = this.options.getThread(params.id);
127
+ const receipt = findPromptOperationReceipt(state, params);
128
+
129
+ if (receipt) {
130
+ this.drain(state);
117
131
 
132
+ return structuredClone(state);
133
+ }
134
+ if (!this.options.adapter) throw new Error("Thread agent harness is not configured");
118
135
  if (thread.status !== "pending") throw new Error("Thread agent review is already resolved");
119
136
  assertHarnessIdentity(state, this.options.adapter);
120
137
  const before = structuredClone(state);
138
+
121
139
  try {
122
140
  state.harness ??= { id: this.options.adapter.id, label: this.options.adapter.label };
123
141
  state.submissions ??= [];
@@ -141,6 +159,7 @@ export class ThreadAgentManager {
141
159
  (total, message) => total + Buffer.byteLength(message.text),
142
160
  0,
143
161
  );
162
+
144
163
  if (used + Buffer.byteLength(input.prompt) > 2 * 1024 * 1024)
145
164
  throw new Error("Thread agent input exceeds transcript limit");
146
165
  const submission: AgentSubmission = {
@@ -166,9 +185,11 @@ export class ThreadAgentManager {
166
185
 
167
186
  if (!submission || submission.status !== "failed")
168
187
  throw new Error("Thread agent retry requires a failed submission");
188
+ delete submission.cancelled;
169
189
  submission.status = stepAgentSubmission(submission.status, "retry", true);
170
190
  } else {
171
191
  const accepted = new Set(submissions.map((entry) => entry.commentId));
192
+
172
193
  for (const annotation of thread.annotations) {
173
194
  if (!isPendingAgentInput(annotation, accepted)) continue;
174
195
  const root =
@@ -182,6 +203,7 @@ export class ThreadAgentManager {
182
203
  enqueue({ commentId: annotation.id, prompt: annotation.body, quote: root.anchor.quote });
183
204
  // The discussion snapshot is passed as context, while the mirror shows only the new comment.
184
205
  const submission = submissions.at(-1)!;
206
+
185
207
  submission.context = JSON.stringify({
186
208
  target: root.target,
187
209
  discussion: discussion.map((entry) => ({ id: entry.id, body: entry.body })),
@@ -195,6 +217,7 @@ export class ThreadAgentManager {
195
217
  messageId: comment.messageId,
196
218
  });
197
219
  const root = agentCommentRoot(state, comment.replyTo ?? comment.id) ?? comment;
220
+
198
221
  submissions.at(-1)!.context = JSON.stringify({
199
222
  discussion: [root, ...state.comments.filter((entry) => entry.replyTo === root.id)].map(
200
223
  (entry) => ({ id: entry.id, body: entry.body }),
@@ -204,6 +227,7 @@ export class ThreadAgentManager {
204
227
  }
205
228
  if (params.text.trim()) enqueue({ prompt: params.text.trim(), quote: params.context });
206
229
  }
230
+ recordPromptOperation(state, before, params);
207
231
  this.save(state);
208
232
  } catch (error) {
209
233
  restoreAgentState(state, before);
@@ -231,14 +255,20 @@ export class ThreadAgentManager {
231
255
  if (!this.options.adapter) throw new Error("Thread agent harness is not configured");
232
256
  const state = this.mutable(params.id);
233
257
 
234
- if (state.phase.kind === "running" || state.phase.kind === "permission")
258
+ if (state.phase.kind === "running" || state.phase.kind === "permission") {
259
+ // Discovery can read existing choices without reconfiguring an active turn.
260
+ if (params.configId === undefined || params.value === undefined)
261
+ return structuredClone(state);
235
262
  throw new Error("Thread agent configuration waits for the current turn");
263
+ }
236
264
  const active = await this.connect(state, this.options.getThread(params.id), "", 1);
237
265
 
238
266
  if (params.configId === undefined || params.value === undefined) {
239
267
  const current = this.get(params.id);
268
+
240
269
  if (current.phase.kind !== "running" && current.phase.kind !== "permission")
241
270
  active.turn = stepAgentTurn(active.turn, "finished");
271
+
242
272
  return structuredClone(state);
243
273
  }
244
274
  if (!active.connection.configure)
@@ -260,6 +290,8 @@ export class ThreadAgentManager {
260
290
  const submission = state.submissions?.find((entry) => entry.status === "queued");
261
291
 
262
292
  if (!submission) return;
293
+ const before = structuredClone(state);
294
+
263
295
  submission.status = stepAgentSubmission(submission.status, "start", true);
264
296
  state.phase = { kind: "running" };
265
297
  const thread = this.options.getThread(state.threadId);
@@ -278,7 +310,13 @@ Quote: ${submission.quote ?? ""}
278
310
  Discussion context: ${submission.context ?? submission.messageId ?? ""}
279
311
  Input: ${submission.prompt}`;
280
312
 
281
- this.save(state);
313
+ try {
314
+ this.save(state);
315
+ } catch (error) {
316
+ // Acceptance is already durable; a retry can resume the queue before any harness request.
317
+ restoreAgentState(state, before);
318
+ throw error;
319
+ }
282
320
  void this.run(state, thread, submission.id, revision, prompt, [], submission);
283
321
  }
284
322
 
@@ -324,8 +362,10 @@ Input: ${submission.prompt}`;
324
362
  reply(id: string, commentId: string, body: string): ThreadAgentState {
325
363
  const state = this.mutable(id);
326
364
  const comment = agentCommentRoot(state, commentId);
365
+
327
366
  if (!comment) throw new Error("Thread agent reply comment does not exist");
328
367
  const root = agentCommentRoot(state, comment.replyTo ?? comment.id) ?? comment;
368
+
329
369
  if (state.comments.length >= 128)
330
370
  throw new Error("Thread agent prototype reached its comment limit");
331
371
  state.comments.push({
@@ -348,6 +388,9 @@ Input: ${submission.prompt}`;
348
388
 
349
389
  if (active && (state.phase.kind === "running" || state.phase.kind === "permission")) {
350
390
  active.turn = stepAgentTurn(active.turn, "stop");
391
+ const submission = state.submissions?.find((entry) => entry.status === "running");
392
+
393
+ if (submission) submission.cancelled = true;
351
394
  for (const tool of state.tools) {
352
395
  if (tool.status === "pending" || tool.status === "in_progress") tool.status = "cancelled";
353
396
  }
@@ -410,6 +453,7 @@ Input: ${submission.prompt}`;
410
453
  }
411
454
 
412
455
  private save(state: ThreadAgentState): void {
456
+ settlePromptOperations(state);
413
457
  if (this.states.get(state.threadId) !== state) return;
414
458
  const data = JSON.stringify(state);
415
459
 
@@ -449,6 +493,7 @@ Input: ${submission.prompt}`;
449
493
  const result = await active.connection.prompt(prompt);
450
494
 
451
495
  finalizeAgentMessages(state, messageStart, result.outcome);
496
+ if (submission && result.outcome === "cancelled") submission.cancelled = true;
452
497
  if (submission)
453
498
  submission.status = stepAgentSubmission(
454
499
  submission.status,
@@ -485,6 +530,7 @@ Input: ${submission.prompt}`;
485
530
  revision: number,
486
531
  ): Promise<ActiveAgent> {
487
532
  const pending = this.initializing.get(state.threadId);
533
+
488
534
  if (pending) return pending;
489
535
  const promise = this.startConnection(state, thread, turnId, revision);
490
536
 
@@ -551,6 +597,7 @@ Input: ${submission.prompt}`;
551
597
  if (event.kind === "config") {
552
598
  state.configOptions = event.options;
553
599
  this.save(state);
600
+
554
601
  return;
555
602
  }
556
603
  const active = this.active.get(state.threadId);
@@ -656,6 +703,7 @@ function isPendingAgentInput(
656
703
 
657
704
  function restoreAgentState(state: ThreadAgentState, before: ThreadAgentState): void {
658
705
  if (before.submissions === undefined) delete state.submissions;
706
+ if (before.promptOperations === undefined) delete state.promptOperations;
659
707
  if (before.harness === undefined) delete state.harness;
660
708
  Object.assign(state, before);
661
709
  }
@@ -74,6 +74,7 @@ export async function resolveWorkspace(cwd = process.cwd()): Promise<WorkspaceKe
74
74
  const remote = nativeJjRoot ? null : gitRemote;
75
75
 
76
76
  const workspace: WorkspaceKey = { repoRoot, branch };
77
+
77
78
  // a repo with no commits (or a shallow clone) has no reliable root, so the thread stays standalone
78
79
  if (rootCommit) workspace.rootCommit = rootCommit;
79
80
  if (remote) workspace.remote = remote;