@cueloop/daemon 0.1.0-alpha.34 → 0.1.0-alpha.36

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.34",
3
+ "version": "0.1.0-alpha.36",
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": {
@@ -28,7 +28,7 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@cueloop/schema": "0.1.0-alpha.34",
31
+ "@cueloop/schema": "0.1.0-alpha.36",
32
32
  "valibot": "^1.4.2"
33
33
  },
34
34
  "engines": {
@@ -7,7 +7,7 @@ import { join } from "node:path";
7
7
  import type { ReviewSession } from "@cueloop/schema";
8
8
  import { DaemonServer } from "./server";
9
9
  import { DaemonClient } from "./client";
10
- import { openReview, resolveWorkspace } from "./review";
10
+ import { awaitResolve, openReview, resolveWorkspace } from "./review";
11
11
 
12
12
  const PLAN = "# Rollout Plan\n\nShip it in two stages.\n";
13
13
 
@@ -275,3 +275,88 @@ describe("awaitVerdict: chunked loop (the pi shape)", () => {
275
275
  expect(await review.awaitVerdict({ timeoutMs: 120, pollMs: 50 })).toBe("pending");
276
276
  });
277
277
  });
278
+
279
+ describe("awaitResolve: the adapter wake seam (session id only)", () => {
280
+ test("resolves to the outcome when the verdict lands during the wait", async () => {
281
+ // Arrange
282
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
283
+ const waiting = awaitResolve(client, review.id, { pollMs: 100 });
284
+
285
+ // Act
286
+ await client.sessionResolve(review.id, "approve", "Ship it.");
287
+ const verdict = await waiting;
288
+
289
+ // Assert
290
+ expect(verdict).not.toBeNull();
291
+ expect(verdict!.allow).toBe(true);
292
+ expect(verdict!.feedback).toContain("Ship it.");
293
+ expect(verdict!.session.verdict!.kind).toBe("approve");
294
+ });
295
+
296
+ test("returns the stored verdict immediately when the session already resolved", async () => {
297
+ // Arrange - a detached waiter that only attaches after the human decided
298
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
299
+ await client.sessionResolve(review.id, "request_changes", "One stage only.");
300
+
301
+ // Act
302
+ const verdict = await awaitResolve(client, review.id);
303
+
304
+ // Assert
305
+ expect(verdict).not.toBeNull();
306
+ expect(verdict!.allow).toBe(false);
307
+ expect(verdict!.feedback).toContain("One stage only.");
308
+ });
309
+
310
+ test("returns null when the signal aborts first", async () => {
311
+ // Arrange
312
+ const review = await openReview(client, { type: "plan", content: PLAN, cwd: home });
313
+ const controller = new AbortController();
314
+ const waiting = awaitResolve(client, review.id, { pollMs: 100, signal: controller.signal });
315
+
316
+ // Act
317
+ controller.abort();
318
+
319
+ // Assert
320
+ expect(await waiting).toBeNull();
321
+ expect((await client.sessionGet(review.id)).status).toBe("pending");
322
+ });
323
+ });
324
+
325
+ describe("awaitResolve: the held wait keeps the daemon alive", () => {
326
+ test("a pending session does not idle-exit while a waiter is parked, then the verdict lands", async () => {
327
+ // Arrange - a daemon with an aggressive idle timer and its own home
328
+ const idleHome = mkdtempSync(join(tmpdir(), "cueloop-idle-"));
329
+ let idleExits = 0;
330
+ const idleServer = new DaemonServer({
331
+ home: idleHome,
332
+ idleExitMs: 30,
333
+ onIdleExit: () => idleExits++,
334
+ });
335
+ idleServer.start();
336
+ const waiterClient = await DaemonClient.connect({ home: idleHome });
337
+ try {
338
+ const review = await openReview(waiterClient, { type: "plan", content: PLAN, cwd: idleHome });
339
+ const waiting = awaitResolve(waiterClient, review.id, { pollMs: 40 });
340
+
341
+ // Act - wait well past the idle window while the review is still pending
342
+ await Bun.sleep(120);
343
+
344
+ // Assert - the parked waiter and the pending session both keep it alive
345
+ expect(idleExits).toBe(0);
346
+ expect((await waiterClient.ping()).pid).toBe(process.pid);
347
+
348
+ // Act - the human returns a verdict; the parked wait collects it
349
+ const resolver = await DaemonClient.connect({ home: idleHome });
350
+ await resolver.sessionResolve(review.id, "approve", "Good to go.");
351
+ resolver.close();
352
+ const verdict = await waiting;
353
+
354
+ // Assert
355
+ expect(verdict!.allow).toBe(true);
356
+ } finally {
357
+ waiterClient.close();
358
+ idleServer.stop();
359
+ rmSync(idleHome, { recursive: true, force: true });
360
+ }
361
+ }, 15_000);
362
+ });
package/src/review.ts CHANGED
@@ -187,6 +187,39 @@ function outcome(session: ReviewSession): VerdictOutcome {
187
187
  return { ...verdictResponse(session), session };
188
188
  }
189
189
 
190
+ export interface AwaitResolveOptions {
191
+ /** Long-poll chunk length; the wait re-arms each chunk until resolved or aborted. Default 30s. */
192
+ pollMs?: number;
193
+ /** Abort the wait (the harness session shut down); resolves to null. */
194
+ signal?: AbortSignal;
195
+ }
196
+
197
+ /**
198
+ * Park until a review session resolves, then return the verdict outcome; null
199
+ * when the signal aborts first. Where ReviewHandle.awaitVerdict needs the handle
200
+ * that opened the review, this needs only a session id - so a background waiter
201
+ * that woke on a session it did not open (a detached Claude Code / Codex waiter,
202
+ * or pi's session_start listener) can collect the same verdict. This is the
203
+ * wake seam every non-blocking adapter builds on. Loops the daemon long-poll, so
204
+ * a verdict that lands between chunks is never missed and a session already
205
+ * resolved returns on the first chunk. The held connection also keeps the daemon
206
+ * off its idle-exit path for the whole wait.
207
+ */
208
+ export async function awaitResolve(
209
+ client: DaemonClient,
210
+ sessionId: string,
211
+ options: AwaitResolveOptions = {},
212
+ ): Promise<VerdictOutcome | null> {
213
+ const chunkMs = options.pollMs ?? 30_000;
214
+ const { signal } = options;
215
+ for (;;) {
216
+ if (signal?.aborted) return null;
217
+ const resolved = await raceAbort(client.sessionWait(sessionId, chunkMs), signal);
218
+ if (resolved === ABORTED) return null;
219
+ if (resolved !== null) return outcome(resolved);
220
+ }
221
+ }
222
+
190
223
  /** Open a review session (or revise the agent session's existing one) and hand back the wait surface. */
191
224
  export async function openReview(
192
225
  client: DaemonClient,