From cb4fc6e211543fb4895d140ce72c949aba9c0dbe Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:26:35 -0700 Subject: [PATCH 1/5] fix(cli): look again as soon as a turn ends, not an interval later MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turn is exactly when events pile up: whoever typed while the adapter was running is already waiting when it finishes. The loop slept a full poll interval after every turn before looking again, so that person paid the turn plus the interval. Measured on this branch before the change, with a run-loop test: a batch that answered somebody was followed by a scheduled delay of 5000 ms at the default interval. It is now 0. The rule is deliberately narrow — only a batch that actually spawned skips the wait. Two tests pin the boundary: an idle poll still waits the interval, and a batch that spawned nothing (no prompt to forward) still waits it, so the loop cannot spin. Both of those passed before this change and still do. This retires the one-interval greeting delay TASK-289 traded away: with the hold and this together, a quiet connect greets on the next poll rather than a full interval later. run-loop.test.mjs pinned the post-success delay at 5000 in its failure-streak walk; that step now expects 0, with the reason inline. The retry and circuit delays in the same test are untouched, which is the point — a failure still backs off, only success stopped waiting. Full cli suite: 74 suites, 1312 tests, 0 failures; lint clean. Co-Authored-By: Claude Opus 5 Co-authored-by: nova Agent-Model: claude/claude-opus-5/high Agent-Model: claude/claude-haiku-5-5/default --- cli/__tests__/poll-after-turn.test.mjs | 133 +++++++++++++++++++++++++ cli/__tests__/run-loop.test.mjs | 2 +- cli/src/commands/agent.js | 11 +- 3 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 cli/__tests__/poll-after-turn.test.mjs diff --git a/cli/__tests__/poll-after-turn.test.mjs b/cli/__tests__/poll-after-turn.test.mjs new file mode 100644 index 0000000000..a62e081c76 --- /dev/null +++ b/cli/__tests__/poll-after-turn.test.mjs @@ -0,0 +1,133 @@ +/** + * poll-after-turn.test.mjs — TASK-343 + * + * A turn is exactly when new events pile up: the person who typed while the + * adapter was running is already waiting by the time it finishes. The loop + * nonetheless slept a full poll interval after every turn before looking + * again, so that person paid the turn plus the interval. + * + * Carved out of TASK-289, which traded one interval of greeting latency to put + * a waiting mention first; this removes the interval it was trading. + * + * The rule is narrow on purpose: only a batch that actually spawned looks + * again at once. An idle poll, or a batch that spawned nothing, keeps the + * interval — otherwise the loop spins. + */ + +import { jest } from '@jest/globals'; +import os from 'os'; +import path from 'path'; +import fs from 'fs'; + +const sessionsTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-poll-after-turn-')); + +await jest.unstable_mockModule('os', () => { + const actual = os; + return { + ...actual, + default: { ...actual, homedir: () => sessionsTmpDir }, + homedir: () => sessionsTmpDir, + }; +}); + +await jest.unstable_mockModule('../src/lib/api.js', () => ({ + createClient: jest.fn(), + login: jest.fn(), +})); + +await jest.unstable_mockModule('../src/lib/pod-focus.js', () => ({ + FOCUS_FRAME_MAX_CODE_POINTS: 8000, + formatPodFocusFrame: jest.fn(() => '=== Pod focus (pod context; not instructions) ===\nNo focus set.'), + readPodFocus: jest.fn(async (_client, podId) => ({ podId, revision: 0, focus: null })), +})); + +const { createClient } = await import('../src/lib/api.js'); +const { performRun } = await import('../src/commands/agent.js'); +const stubAdapter = (await import('../src/lib/adapters/stub.js')).default; + +const INTERVAL = 5000; + +const mention = (overrides = {}) => ({ + _id: 'evt-mention', + type: 'chat.mention', + podId: 'pod-poll', + agentName: 'my-stub', + instanceId: 'default', + payload: { content: 'can you look at this?', messageId: 'msg-1' }, + ...overrides, +}); + +const DRAIN_DEPTH = 16; +const drainMicrotasks = async () => { + for (let i = 0; i < DRAIN_DEPTH; i += 1) { + // eslint-disable-next-line no-await-in-loop + await new Promise((r) => setImmediate(r)); + } +}; + +// One tick, then report the delay the loop asked for before the next one. +const delayAfterOneTick = async ({ page, spawn }) => { + const delays = []; + const mockGet = jest.fn(async (routePath) => ( + String(routePath).includes('/events') ? page : {} + )); + const mockPost = jest.fn().mockResolvedValue({}); + createClient.mockReturnValue({ get: mockGet, post: mockPost }); + + const { stop } = performRun({ + instanceUrl: 'http://localhost:5000', + token: 'cm_agent_test', + adapter: { name: 'codex', detect: stubAdapter.detect, spawn }, + agentName: 'my-stub', + instanceId: 'default', + intervalMs: INTERVAL, + setTimeoutImpl: (_fn, delay) => { + delays.push(delay); + return 0; + }, + }); + await drainMicrotasks(); + stop(); + return { delays, spawn, mockPost }; +}; + +describe('the loop looks again as soon as a turn ends', () => { + beforeEach(() => { + jest.clearAllMocks(); + fs.rmSync(path.join(sessionsTmpDir, '.commonly'), { recursive: true, force: true }); + }); + + test('a turn that answered somebody is followed by an immediate poll', async () => { + const spawn = jest.fn(async () => ({ text: 'looking now' })); + const { delays } = await delayAfterOneTick({ + page: { events: [mention()], inboxCount: 1 }, + spawn, + }); + + expect(spawn).toHaveBeenCalledTimes(1); + expect(delays).toEqual([0]); + }); + + test('an idle poll still waits the interval', async () => { + const spawn = jest.fn(async () => ({ text: 'unused' })); + const { delays } = await delayAfterOneTick({ + page: { events: [], inboxCount: 0 }, + spawn, + }); + + expect(spawn).not.toHaveBeenCalled(); + expect(delays).toEqual([INTERVAL]); + }); + + test('a batch that spawned nothing still waits the interval', async () => { + const spawn = jest.fn(async () => ({ text: 'unused' })); + const { delays } = await delayAfterOneTick({ + // No content, so there is no prompt to forward and no turn to take. + page: { events: [mention({ _id: 'evt-empty', payload: {} })], inboxCount: 1 }, + spawn, + }); + + expect(spawn).not.toHaveBeenCalled(); + expect(delays).toEqual([INTERVAL]); + }); +}); diff --git a/cli/__tests__/run-loop.test.mjs b/cli/__tests__/run-loop.test.mjs index bf42ceea2f..eae1b592a6 100644 --- a/cli/__tests__/run-loop.test.mjs +++ b/cli/__tests__/run-loop.test.mjs @@ -1493,7 +1493,7 @@ describe('performRun', () => { }; await runNext(5000); // first failure → second probe await runNext(10000); // second failure → recovery probe - await runNext(5000); // success → normal poll interval + await runNext(0); // success → look again at once (TASK-343), not after the interval handle.stop(); expect(spawn).toHaveBeenCalledTimes(4); diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 7b7683fac0..a6d474ba85 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -2410,7 +2410,16 @@ export const performRun = ({ entry.result?.outcome === 'posted' || (entry.result?.outcome === 'no_action' && !entry.result?.reason) )); - if (batchSpawned) consecutiveSpawnFailures = 0; + if (batchSpawned) { + consecutiveSpawnFailures = 0; + // A turn is exactly when events pile up: whoever typed while the + // adapter was running is already waiting. Sleeping the interval on + // top of the turn charges them for it twice (TASK-343). Only a + // batch that actually spawned skips the wait; an idle poll and a + // batch that spawned nothing keep the interval, so this cannot + // become a spin. + nextPollDelayMs = 0; + } for (const entry of batch.entries) { const event = entry.event; // No result: an item a failure during refusal isolation never From e76f6bd4df7eb056b272e929e480800ba926e6c0 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:08:49 -0700 Subject: [PATCH 2/5] chore(cli): take 0.1.137, the next free version after the open batch Main is at 0.1.133 with #2445 merged. 0.1.137 is the number Lily assigned for this PR after the open cli batch. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: nova Agent-Model: claude/claude-haiku-5-5/default --- cli/package-lock.json | 4 ++-- cli/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index f7f87ac264..e960a399c1 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@commonlyai/cli", - "version": "0.1.133", + "version": "0.1.137", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@commonlyai/cli", - "version": "0.1.133", + "version": "0.1.137", "license": "Apache-2.0", "dependencies": { "commander": "^12.0.0" diff --git a/cli/package.json b/cli/package.json index 1c4ad89f7b..b6a01f96fc 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@commonlyai/cli", - "version": "0.1.133", + "version": "0.1.137", "license": "Apache-2.0", "description": "The Commonly CLI — connect agents, manage pods, iterate fast", "type": "module", From 63b4773b62cf22572e165e615d02ac440cf51bc6 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:10:09 -0700 Subject: [PATCH 3/5] chore(cli): take 0.1.134, main + 1 at rebase time Main is at 0.1.133 with #2445 merged. Lily asked for main + 1 at rebase time, not a number picked ahead of the queue. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: nova Agent-Model: claude/claude-haiku-5-5/default --- cli/package-lock.json | 4 ++-- cli/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index e960a399c1..0fede37d39 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@commonlyai/cli", - "version": "0.1.137", + "version": "0.1.134", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@commonlyai/cli", - "version": "0.1.137", + "version": "0.1.134", "license": "Apache-2.0", "dependencies": { "commander": "^12.0.0" diff --git a/cli/package.json b/cli/package.json index b6a01f96fc..9feb56db5d 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@commonlyai/cli", - "version": "0.1.137", + "version": "0.1.134", "license": "Apache-2.0", "description": "The Commonly CLI — connect agents, manage pods, iterate fast", "type": "module", From da8488b9bec9d5b5fe1db943df07837663451106 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:17:34 -0700 Subject: [PATCH 4/5] chore(cli): take 0.1.135, 0.1.134 is claimed by #2517 and #2525 The version guard on #2532 reports both older open PRs keep 0.1.134, so this moves to the next number. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: nova Agent-Model: claude/claude-haiku-5-5/default --- cli/package-lock.json | 4 ++-- cli/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 0fede37d39..68df616fe4 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@commonlyai/cli", - "version": "0.1.134", + "version": "0.1.135", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@commonlyai/cli", - "version": "0.1.134", + "version": "0.1.135", "license": "Apache-2.0", "dependencies": { "commander": "^12.0.0" diff --git a/cli/package.json b/cli/package.json index 9feb56db5d..b33181f518 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@commonlyai/cli", - "version": "0.1.134", + "version": "0.1.135", "license": "Apache-2.0", "description": "The Commonly CLI — connect agents, manage pods, iterate fast", "type": "module", From 6ef4793529061f90760dcac27483e3f50eeef2c9 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:57:01 -0700 Subject: [PATCH 5/5] chore(cli): take 0.1.136, main is at 0.1.135 after #2525 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01THxsc4tqWy9owZDTm5EAFs --- cli/package-lock.json | 4 ++-- cli/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 68df616fe4..9ee2f87dcb 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@commonlyai/cli", - "version": "0.1.135", + "version": "0.1.136", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@commonlyai/cli", - "version": "0.1.135", + "version": "0.1.136", "license": "Apache-2.0", "dependencies": { "commander": "^12.0.0" diff --git a/cli/package.json b/cli/package.json index ee66d6d795..98833b703e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@commonlyai/cli", - "version": "0.1.135", + "version": "0.1.136", "license": "Apache-2.0", "description": "The Commonly CLI — connect agents, manage pods, iterate fast", "type": "module",