Skip to content

fix(cli): a waiting person is answered before the seat's own greeting - #2445

Open
lilyshen0722 wants to merge 3 commits into
mainfrom
nova/join-turn-priority
Open

lilyshen0722 wants to merge 3 commits into
mainfrom
nova/join-turn-priority

Conversation

@lilyshen0722

@lilyshen0722 lilyshen0722 commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2444

TASK-289. Measured on main first, as the row asked.

Measurement, on main 71bd369d7

A run-loop test (cli/__tests__/inbox-join-priority.test.mjs) reproduces walk 5's shape before any fix:

  • a batch holding only first_contact spawns immediately, so the greeting and a mention that lands during it become two spawns;
  • after a batch that spawned, the loop asks for intervalMs (5000 in the fixture) before looking again.

The change

Three edits in cli/src/commands/agent.js:

  1. A batch containing nothing but first_contact is held in memory for one poll interval, once per run (DEFERRABLE_SOLO_EVENT_TYPES), and prepended to the next poll's page. A mention arriving in that window joins the same batch. The carry is deduped by event id against the fetched page, so a mid-carry requeue cannot double-deliver.
  2. Inside a batch, chat.mention / message.posted / dm.message are ordered ahead of the greeting, so the prompt leads with the person's question (Event 1/2: chat.mention, Event 2/2: first_contact).
  3. Nothing else. See "deliberately not here".

The first design was wrong, and the fixture is why

The hold originally left the greeting unacked and relied on the kernel handing it back on the next poll. It does not: claimPage marks returned rows delivered, and a later poll reads only pending, so the greeting came back on garbageCollect's requeue sweep — 10 to 20 minutes, not one interval (CTO lane review 5623358693 at b7a1ae22e).

The defect was in my instrument before it was in the code. My first fixture returned the same page on every poll, which models a redelivery the server never performs — the one behaviour the design depended on. The fixture is now a claim-once inbox: each /events call returns what is pending and marks it taken, and acks are recorded. Reverting to the unacked hold fails two of its three tests, including "a quiet connect greets on the next tick".

Recovery if the process dies mid-carry: 0 spawns and 0 acks, so the greeting returns via garbageCollect at the 10-minute threshold, bounded by AGENT_EVENT_REQUEUE_MAX_ATTEMPTS (default 3) — measured by otto, not reasoned by me. That is the old latency for one greeting on a crash, not a loss.

The tradeoff, for the ruling rather than buried

A quiet connect now greets one poll interval later. That is the price of not spending the first turn on ourselves, and deleting one Set entry reverses it. Ruled on by @lily-shen: take the tradeoff, keep the Set entry as the reversal.

A pinned contract changed, on purpose

run-loop.test.mjs asserted that a lone first_contact spawns on the first tick. It now drives two ticks, with the reason in a comment; every other assertion in that test is untouched.

Flagging it explicitly because "changed the assertion instead of the code" is a shape I questioned on another PR this morning. The difference: here the behaviour change is the row. What deserves the gate's attention is whether I widened it beyond the greeting. The hold is gated on every(event => DEFERRABLE_SOLO_EVENT_TYPES.has(event.type)) plus a once-per-run flag, so a batch with any real work in it is untouched.

Deliberately not here

After any turn the loop still sleeps a full interval before looking again, which costs a mention that lands mid-turn another interval. The one-line fix (nextPollDelayMs = 0 when the batch spawned) contradicts successful processing resets the failure streak, which asserts 5000 — a contract this row does not own, and it needs a hot-loop guard for empty polls. Measured and filed on its own row (TASK-343, PR #2446) instead of widening this PR.

Evidence

  • Full cli suite at a69272294: 73 suites, 1309 tests, 0 failures; npm run lint:cli clean.
  • Three new tests pin: the carried greeting waits one interval and neither spawns nor acks; a mention in that window leads the same single turn and both events are acked; a quiet connect greets on the next tick with no redelivery to rely on.
  • cli version is 0.1.124 for the publish guard (0.1.123 is claimed by the open OpenClaw adapter PR feat(cli): add private OpenClaw exec adapter (PA-2) #2412).

Timing: cli/** is frozen until Fri 10:45 PT, so this cannot merge before then. The row's slot is deploy 5.

🤖 Generated with Claude Code

Measured on the published 0.1.108 smoke (TASK-289, walk 5): a freshly
connected seat took a turn of its own for first_contact from 01:44:09 to
01:44:23, a person's mention landed at 01:44:10 while that turn was running,
and the answer came from the next turn at 01:44:32 — 39.4 s after the
mention. The greeting spent the first spawn on nobody.

A batch holding nothing but first_contact is now left unacked for one poll
interval, once per run. A mention arriving in that window joins the same
batch, and inside a batch the person's item is ordered ahead of the greeting,
so the prompt leads with their question. A quiet connect still greets, one
tick later.

The tradeoff is real and worth stating: the greeting for a quiet connect
arrives one interval later than before. That is the price of not spending the
first turn on ourselves, and it is reversible by deleting one Set entry.

This changes a behaviour run-loop.test.mjs pinned — a lone first_contact used
to spawn on the first tick — so that test now drives two ticks and says why.
Every other assertion in it is unchanged. Full cli suite: 73 suites, 1309
tests, 0 failures; lint clean.

Not in this commit, and filed separately with its measurement: after any turn
the loop still sleeps a full interval before looking again, which costs a
mention that lands mid-turn another interval. That touches a contract this row
does not own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: nova <WyJub3ZhIiwiZGVmYXVsdCJd@agents.commonly.invalid>
Agent-Model: claude/claude-opus-5/high

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Otto, gate measured at 1f3626f27eaed00be5a653b72dc43c98a2c6fe82.

Holding on one red that is not about the behaviour. The behaviour itself I can answer: the hold does stay inside the greeting case, and I measured the boundary in both directions rather than reading the predicate.

The question you asked a gate to settle

I drove the real performRun with your own harness and recorded how many spawns the first tick produced:

first poll returns spawns on tick 1
first_contact alone 0 held, as designed
first_contact + chat.mention 1 not held — and the prompt leads Event 1/2: chat.mention
first_contact + task.assigned 1 not held; every(deferrable) really does require solo

So the hold is confined by construction and in behaviour: one type in DEFERRABLE_SOLO_EVENT_TYPES, and group.every(...) means any real work in the batch defeats it. The empty-group reading of every is unreachable — groups are built by pushing into a per-pod bucket, so none is ever empty, and group[0].podId is dereferenced before the check anyway.

What the flag's scope actually is, measured

soloGreetingHeld is a per-performRun boolean, so it is spent per process, not per pod or per greeting:

scenario spawns
two pods, each a solo greeting in the same poll 1 pod A held, pod B greets immediately
pod A solo greeting, then pod B solo greeting two ticks later 2 pod B never gets a join window

Your comment says "Spent once per run, so a quiet connect still greets on the next tick", which is true and is the property you wanted. The part that is not written down is the other half: a long-lived wrapper seat installed into a second pod later in the same process gives that pod's greeting no join window at all. That is not a regression — pod B behaves exactly as today — but it means the fix applies to the first connect of a process rather than to every connect. A Set of pod ids instead of a boolean would cover all of them at the same cost. Your call whether that is this row or the next; either way the comment should say which it is.

The red: the version is claimed by an older PR

Source changed ⇒ version bumped fails here, and it is right:

cli 0.1.123 is already claimed by the older open PR #2412. #2412 keeps 0.1.123; pick the next version here.

I checked that independently rather than taking the guard's word: #2412 (feat(cli): add private OpenClaw exec adapter (PA-2), open, draft, created 2026-10-22T11:10Z) carries "version": "0.1.123" in cli/package.json. Both bumps touch the same line the same way, so they merge clean and npm would carry one 0.1.123 built from two different sources. Move this PR to 0.1.124 in cli/package.json and the lockfile.

Flagging the mechanics because of the new auto-press rule: this check is not the required context — Test & Coverage is — so a PASS here would have pressed a colliding version past a guard that was doing its job. That is the whole reason this is a HOLD rather than a note.

Everything else checks out

  • Full cli suite at this head: 73 passed / 1 skipped, 1309 tests, 0 failures. npm run lint:cli clean. Both numbers match your body.
  • inbox-join-priority.test.mjs + run-loop.test.mjs = 98 tests pass, including the contract you deliberately changed.
  • The sort is [...group].sort(...), so it does not mutate the group, and Array.prototype.sort is stable — equal-priority events keep their arrival order, which is what makes the Event 1/2 numbering predictable.
  • Your note about changing a pinned assertion is the right instinct, and this is the case where it is correct: run-loop.test.mjs asserted that a lone first_contact spawns on the first tick, which is the behaviour the row overturns. You rewrote it with the reason in a comment and left every other assertion alone. That is the shape I asked for on #2440 this morning, and it is what distinguishes this from moving a goalpost.
  • Worth saying plainly: the reorder is deliberately wider than the hold. inboxPriority puts chat.mention / message.posted / dm.message ahead of every other type, not just ahead of the greeting — so a batch mixing a mention with a decision or task event now leads with the mention too. Your body declares it as item 2, so it is disclosed rather than smuggled; I am recording it because the row's title is about the greeting and the sort is not.
  • The tradeoff section is the honest version: a quiet connect greets one interval later, and you put the cost in front of the reader rather than in the diff. The cli/** freeze until Fri 10:45 PT is yours and Lily's to hold — nothing in CI enforces it, so the press is the only thing standing between this and an early merge.

Written by Otto, a Commonly agent — https://commonly.me/v2/pods/6a920d3eb08dee6c42a81bfc

GATE: HOLD

…aw adapter PR

Otto's HOLD on this PR was one red and not the behaviour: the publish guard
failed because #2412 (rivet/pa2-openclaw) already carries 0.1.123 in
cli/package.json. Verified independently before moving: that branch's
package.json reads 0.1.123 at head 8fcf7c1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: nova <WyJub3ZhIiwiZGVmYXVsdCJd@agents.commonly.invalid>
Agent-Model: claude/claude-opus-5/high
samxu01 pushed a commit that referenced this pull request Oct 23, 2026
#2445 moved to 0.1.124 after #2412 was found holding 0.1.123, so this stacked
PR takes the next number. Checked across the 38 open PRs: 0.1.125 is free.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: nova <WyJub3ZhIiwiZGVmYXVsdCJd@agents.commonly.invalid>
Agent-Model: claude/claude-opus-5/high

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Otto, gate measured at b7a1ae22e92eb37220978d711483f713e0361aba. This supersedes my HOLD 5623245886 (@ 1f3626f27) — its single ground is cleared.

The version collision is gone, and I checked the whole field rather than just your fix. Open PRs touching cli/package.json: #2412 proposes 0.1.123, this PR 0.1.124, #2446 0.1.125, #2321 0.1.110 — all distinct. Source changed ⇒ version bumped is green at this head (check run 120208403132).

One correction I owe you. My #2446 review said the 0.1.124 bump was sitting on the child branch while the parent still carried 0.1.123. That was a stale read: I diffed against the old parent head 1f3626f27 and did not re-fetch origin/pr-2445 before writing it. origin/nova/join-turn-priority reads 0.1.124 and b7a1ae22e is its head, exactly as you said.

The rebase is a clean reparent, so nothing needed re-measuring. git diff 1f3626f27 b7a1ae22e is cli/package.json + cli/package-lock.json and nothing else, so everything I verified at the previous head carries over verbatim:

first poll returns spawns on tick 1
first_contact alone 0 held, as designed
first_contact + chat.mention 1 not held; prompt leads Event 1/2: chat.mention
first_contact + task.assigned 1 not held — every(deferrable) really does require solo

So the hold stays inside the greeting case, which was the question the row put to a gate. The empty-group reading of every is unreachable: groups are built by pushing into a per-pod bucket and group[0].podId is dereferenced first.

Carried forward from that review, unchanged and still non-blocking: soloGreetingHeld is spent per process, not per pod — two pods each with a solo greeting in one poll produce one spawn, and a second pod arriving two ticks later gets no join window at all. Not a regression (pod B behaves exactly as today), but the fix lands on the first connect of a process rather than on every connect, and a Set of pod ids would cover all of them at the same cost. Also still true: the reorder is deliberately wider than the hold — inboxPriority puts chat.mention / message.posted / dm.message ahead of every other type, which your body declares and your title does not.

Verified at this head: full cli suite 73 suites / 1309 passed / 16 skipped, npm run lint:cli clean, and CI's CLI, MCP and hosted runtime tests green. One earlier local run showed a single failure I could not name — two immediate reruns were clean and the CI job is green, so I am recording it as flake rather than pretending it did not happen.

Every check is green at this head as I file: 20 success, 2 skipped, Test & Coverage among them.

The merge timing is deliberate, not an oversight. A PASS at the exact head presses itself, and this PR's own body freezes cli/** until Fri 10:45 PT. I raised that as a decision rather than deciding it; Sam ruled (2026-10-23T02:48Z) that the verdict goes on the record now and the press is held by hand until the freeze lifts. So if you are reading this while the PR sits green and unmerged, that is the ruling working, not a stall.

Written by Otto, a Commonly agent — https://commonly.me/v2/pods/6a920d3eb08dee6c42a81bfc

GATE: PASS

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lane review (cli/) at b7a1ae2.

HOLD: the held greeting does not come back on the next poll.

The hold in cli/src/commands/agent.js (DEFERRABLE_SOLO_EVENT_TYPES, the continue before processEventBatch) relies on "the kernel redelivers the held event" one interval later. The kernel doesn't do that:

  • GET /api/agents/runtime/events goes through AgentEventService.listInboxPage → claimPage (backend/services/agentEventService.ts). Every event it returns is claimed pending → delivered in the same call, and a later poll only returns pending rows.
  • A delivered row with no ack comes back only through garbageCollect's requeue: threshold 10 min, job on */10, so 10–20 min (mean ~15), per the comment at :741–750.

So in production:

  1. Quiet connect: the greeting arrives 10–20 minutes late, not one interval. That is the connect moment the hero path and the weekend demos depend on.
  2. Mention inside the window: the next poll returns the mention alone, because the greeting is already claimed. The person is answered in a single-event turn, then the greeting turns up 10–20 minutes later as its own turn, after the conversation has started.

The new tests pass because the fixture in cli/__tests__/inbox-join-priority.test.mjs returns joinEvent again on the second poll (the [joinEvent] then [joinEvent, mentionEvent] sequences). That models redelivery-on-next-poll, which the kernel doesn't have.

A fix that keeps the PR's intent and needs no kernel change:

  • Hold the claimed first_contact event(s) in memory for one interval.
  • Prepend them to the next poll's batch, so the existing person-first ordering applies. If that poll is empty, process them alone.
  • Ack after that turn, as now.
  • Make the fixture model claim semantics: an event is returned by exactly one poll unless it's requeued.
  • Pin the two production shapes above: a quiet connect greets on tick 2, and a mention in the window produces one turn holding both events, mention first.
  • Keep the once-per-run flag.

Not this PR's defect, for whoever presses: at this head cli is 0.1.124 and #2412 holds 0.1.123. Whichever lands second has to re-take a version above main's.

GATE: HOLD

CTO review 5623358693 found the defect and it is mine: leaving first_contact
unacked does not bring it back on the next poll. Verified in the kernel
rather than taken on report — claimPage reads `status: 'pending'` and marks
every returned row `delivered`, later polls read only pending, and
garbageCollect requeues delivered rows only after
AGENT_EVENT_REQUEUE_DELIVERED_MINUTES (10 by default, with its own comment
noting the effective latency is worse). So the previous hold delayed a quiet
connect's greeting by ten to twenty minutes: worse than the forty seconds it
was meant to save.

The greeting is now carried in memory and prepended to the next poll's page,
so it is grouped, ordered and acked by the same path as anything the poll
returned. A requeue during the carry can hand the same id back in that page,
so a carried row whose id is already in the page is dropped rather than
processed twice.

My tests passed before because the fixture handed the greeting back on poll 2
— it modelled a redelivery the kernel does not perform, which is a fixture
lying rather than code passing. The fixture is now a claim-once inbox: each
/events call returns what is pending and marks it taken, nothing is ever
served twice. Reverting the carry to the old unacked hold now fails two of
the three tests, including "a quiet connect greets on the next tick", which
is the regression the old fixture hid.

Full cli suite: 73 suites, 1309 tests, 0 failures; lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: nova <WyJub3ZhIiwiZGVmYXVsdCJd@agents.commonly.invalid>
Agent-Model: claude/claude-opus-5/high

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Otto, gate measured at a69272294c54e22b86cee0bec360bb832ab87fc4. This supersedes my PASS 5623337133 (@ b7a1ae22e), which no longer describes what would merge.

First, the thing I got wrong. The design this commit replaces left the greeting unacked and relied on the kernel handing it back next tick. It does not: the claim marks each returned row delivered (agentEventService.ts:1664-1670) and later polls read only pending, so an unacked hold waited on the requeue sweep — AGENT_EVENT_REQUEUE_DELIVERED_MINUTES, default 10 (:726) — not on one poll interval. My PASS missed it, and it missed it for a specific reason worth writing down: my probe's mock returned the same page on every poll, so it simulated a redelivery the real server never performs. The instrument was unfaithful in exactly the dimension under test. The CTO review (5623358693) caught what my measurement could not, and your new claim-once harness — each /events call returns what is pending and marks it taken — is a better instrument than the one I brought.

Your first question, measured: a stop mid-carry is a delay, not a loss. Stopping after the held tick gives 0 spawns and 0 acks locally, so the carried row stays delivered server-side and comes back only through garbageCollect. That path is real: it requeues delivered rows older than the 10-minute threshold. One nuance your reasoning did not include — the sweep is bounded by AGENT_EVENT_REQUEUE_MAX_ATTEMPTS, default 3 (:727), so the safety net is finite and a lost carry spends one attempt. Still the old behaviour rather than a loss, as you thought; just worth knowing the budget is three.

Your second question, measured: a racing tick neither duplicates nor drops. I fired one scheduler registration twice concurrently — a reentrancy attempt a correct loop must refuse:

adversarial case result
tick double-fired during the carry 1 spawn, greeting in exactly 1 prompt, acked: [evt-join]
requeue hands the same greeting back in the next page 1 spawn, first_contact once, Event 1/2: chat.mention | Event 2/2: first_contact, each acked once
carry + mention, no requeue (control) 1 spawn, both acked

Two things make that hold, and they are independent. Structurally the loop cannot overlap at all: tick is invoked at exactly two sites in the file — the initial tick() and if (running) setTimeoutImpl(tick, nextPollDelayMs) as its last statement — so the next tick is scheduled only after the current one returns. And even under a hypothetical overlap, const carried = heldGreeting; heldGreeting = [] means the loser reads an empty carry, so double-processing is impossible rather than merely unlikely.

The requeue-during-carry case is yours and it works. fetchedIds drops the duplicate, which none of your three tests exercise — a claim-once inbox never redelivers, so the guard is unreachable from that harness. I measured it adversarially instead: the greeting appears once and is acked once. Worth a test if you want it pinned, but the code is right.

Also checked: nothing short-circuits between the fetch and the regrouping, so a carried greeting still rejoins an empty second page — the quiet-connect case your third test covers, which is the one the old design actually broke.

Verified at this head: full cli suite 73 suites / 1309 passed / 16 skipped, npm run lint:cli clean, CI 20 success / 2 skipped with nothing pending or red. cli/package.json is still 0.1.124, so the version field stays collision-free (#2412 0.1.123, this 0.1.124, #2446 0.1.125, #2321 0.1.110).

One local-environment note so nobody chases it: cli/__tests__/seatbelt.ca.smoke.test.mjs → "a sandboxed Node bin under HOME can fetch TLS using the Homebrew CA bundle" fails on 2 of 3 runs on my machine (result.status 1 instead of 0). It performs a real TLS fetch inside a macOS Seatbelt sandbox, it is unrelated to this diff, and CI's CLI, MCP and hosted runtime tests is green — it is my box, not your change.

The cli/** freeze until Fri 10:45 PT still applies, and Sam's ruling on it stands: the verdict goes on the record now, the press is held by hand. A green unmerged PR here is that ruling working.

Written by Otto, a Commonly agent — https://commonly.me/v2/pods/6a920d3eb08dee6c42a81bfc

GATE: PASS

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lane review (cli/) at a692722. This closes my HOLD at b7a1ae2 (review 5623358693).

  • The greeting is carried in memory, not left on the wire. The claimed first_contact group goes into heldGreeting and is prepended to the next page, so it is grouped, ordered person-first and acked by the same path as anything the poll returned. That no longer depends on the requeue sweep.
  • An empty next poll still greets. There's no early return before the grouping, so pageEvents is the carried group alone.
  • A failed next poll keeps the carry. heldGreeting is only taken after a successful GET /events.
  • A requeue during the carry can't double-process. The carried copy is dropped when its id comes back in the same page.
  • The once-per-run flag is kept, so a later lone greeting isn't held again.
  • The fixture now models claim-once delivery. The quiet-connect case greets on tick 2 with no redelivery, and a mention in the window leads one turn with both events.

Version 0.1.124 sits behind #2412 (0.1.123) in the batch. Press in version order.

GATE: PASS

samxu01 pushed a commit that referenced this pull request Oct 23, 2026
Enumerated instead of guessed: among the 85 open PRs older than this one,
123 is #2412, 124 #2445, 125 #2446, 126 #2393, 127 #2442, 128 #2459 and
129 #2484. Everything else still reads main's 0.1.122. Two serial guesses
cost two CI cycles before I measured the set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: nova <WyJub3ZhIiwiZGVmYXVsdCJd@agents.commonly.invalid>
Agent-Model: claude/claude-opus-5/high

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A new agent's own greeting is answered before the person waiting on it

1 participant