Skip to content

fix(control-plane,mocactl): count completed turns, so sessions stop reporting 0 (#420) - #672

Merged
cwiklik merged 1 commit into
rossoctl:mainfrom
cwiklik:fix/420-session-turns-counter
Nov 2, 2026
Merged

cwiklik merged 1 commit into
rossoctl:mainfrom
cwiklik:fix/420-session-turns-counter

Conversation

@cwiklik

@cwiklik cwiklik commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Closes #420

The bug

turns was on the runtime hash's allow-list (ownership.ts:103) and read in three places — the session summary (handlers.ts:375), the resources view (resources.ts:80) and the TUI's Sessions overlay (Sessions.tsx:84) — but nothing ever wrote it. runtimeFieldsForTurn stamps turnStartedAt / turnEndedAt / lastTurnAt, and no other putRuntime caller exists. So every real deployment reported 0 turns, and #416 left the field out of mocactl sessions because of it.

The fix

Piece Where
hIncrBy added to CpRedisLike control-plane/src/ownership.ts
bumpTurns() — one server-side HINCRBY, through the same guard as every other index write same
RUNTIME_COUNTERS — turns stays readable but is no longer settable via putRuntime same
The reporter bumps when the fields carry turnEndedAt knative-server/src/turn-auth.ts
TURNS column and turns in --json for mocactl sessions mocactl/src/headless.ts

HINCRBY, not a read-modify-write. The server does not serialize concurrent turns of one session (ADR-0036, Negative/accepted-cost bullet), so reading the old value would have both turns read N and both write N+1, losing a count.

turns is not settable through putRuntime. It is the only runtime field whose new value depends on its old one, and the tier that self-reports this hash is untrusted (spec §7.4) — a fields.turns would let a compromised harness claim any count. It is dropped on write, as a field off the allow-list is, rather than stored and then ignored.

The bump rides turnEndedAt rather than sitting at each of the six reporter call sites. A completed turn is exactly the report that stamps that timestamp, so no call site can report an end and forget to count it, and the counter cannot drift from the timestamp beside it.

mocactl sessions brings the field back

The issue notes that once there is a writer, the field can return. There is now, so it does: a TURNS column in the table and turns in --json.

Both read the server's summary. The local transcript store is deliberately not the source — a headless mocactl run writes no transcript, and a session resumed on another machine has none at all, yet both have run turns. That is the other half of what #417 reports (0 turns · local history after a headless run). The TUI already renders s.turns, so it shows real counts with no client change.

Tests

12 new assertions across 5 files:

  • bumpTurns counts, increments server-side (asserting on the command, since a fake that merely ends at the right number would also pass for a racy implementation run sequentially), is unforgeable through putRuntime, and maps a transport failure to redis_unavailable
  • the reporter increments once per turn end, does not count a turn that has only started, still writes the end timestamps beside the counter, and never fails the turn when the increment fails
  • end-to-end: the session summary's count grows as turns complete
  • mocactl sessions reports the server's count, not the local transcript's; a session with no turns reads 0, not a blank

The fake Redis's op log records the field name, because turns must move by HINCRBY and never by the merging hSet — a log saying only hIncrBy <key> could not tell those apart.

Mutation testing

Eight mutants, all caught:

Mutant Caught by
Read-modify-write instead of HINCRBY the command assertion + redis_unavailable
RUNTIME_COUNTERS guard dropped (turns forgeable) the unforgeability test
Reporter never bumps increments-once + timestamps-still-written
Reporter bumps unconditionally does-not-count-a-started-turn
Summary hardcoded to 0 the end-to-end summary test
Fake op log loses the field name increments-server-side
TURNS column fed from the local transcript server's-count-not-the-transcript's
Header added with no cell table-shape + column assertions

Verification

Gate Result
tsc --noEmit × control-plane, knative-server, mocactl 0 errors
ownership + handlers-sessions 105 passed
turn-auth 68 passed
headless-manage + cli 82 passed
prettier --check on the edited source files clean

Two things I could not verify locally, both pre-existing:

make typecheck and make lint both invoke pnpm install, which fails here on ERR_PNPM_IGNORED_BUILDS; I ran tsc and prettier directly against the same configs instead.

Assisted-By: Claude

…eporting 0

`turns` was on the runtime hash's allow-list (ownership.ts) and read by both
the session summary (handlers.ts) and the resources view (resources.ts), but
nothing ever wrote it: `runtimeFieldsForTurn` stamps turnStartedAt /
turnEndedAt / lastTurnAt and no other putRuntime caller exists. So every real
deployment reported 0 turns, visible in the mocactl TUI's Sessions overlay --
and rossoctl#416 left the field out of `mocactl sessions` because of it.

Counted with HINCRBY, not a read-modify-write. The server does not serialize
concurrent turns of one session (ADR-0036), so reading the old value would
have both turns read N and both write N+1, losing a count. This adds hIncrBy
to CpRedisLike and a bumpTurns() that goes through the same guard as every
other index write, so a transport failure is still redis_unavailable.

`turns` is now readable but NOT settable through putRuntime (RUNTIME_COUNTERS).
It is the only runtime field whose new value depends on its old one, and the
tier that self-reports this hash is untrusted (spec §7.4) -- a `turns` passed
to putRuntime would let a compromised harness claim any count. It is dropped
on write, as a field off the allow-list is, rather than stored and then
ignored.

The reporter bumps when the fields carry turnEndedAt, rather than at each of
the six call sites: a completed turn is exactly the report that stamps that
timestamp, so no call site can report an end and forget to count it, and the
counter cannot drift from the timestamp beside it.

With a writer in place, `mocactl sessions` brings the field back: a TURNS
column in the table and `turns` in `--json`, both straight from the server's
summary. The local transcript store is deliberately not the source -- a
headless `mocactl run` writes no transcript and a session resumed on another
machine has none, yet both have run turns (rossoctl#417's other half). The TUI already
renders `s.turns` (Sessions.tsx), so it shows real counts with no change.

Eight mutants, all caught: a read-modify-write instead of HINCRBY, a dropped
RUNTIME_COUNTERS guard (turns forgeable through putRuntime), a reporter that
never bumps, one that bumps unconditionally (counting started turns), a
summary hardcoded to 0, a fake-redis op log that cannot tell HINCRBY from
hSet, a TURNS column fed from the local transcript, and a header with no cell.

Closes rossoctl#420.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: cwiklik <cwiklikj@gmail.com>

@ingpaolodettori-dev ingpaolodettori-dev left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Summary

This PR gives the runtime hash's turns field its first writer. OwnershipIndex.bumpTurns() is a server-side HINCRBY +1 through the same guard. putRuntime now drops turns, and the knative-server runtime reporter bumps the count on every report that carries turnEndedAt. mocactl sessions gets a TURNS column and turns in --json, read from the server summary. The TUI Sessions overlay already renders s.turns, so it picks up real counts with no change. I checked the three end-report sites in server.ts (JSON :395, SSE :653, detachable :789). Each one reports end once per turn, in a finally, and turns refused before the start report (429 quota, 409) are never counted. So the "rides turnEndedAt" design holds, and no call site double-counts. The failure modes are fine for display-only data. If putRuntime throws, the bump is skipped. If the bump throws, the reporter's existing catch swallows it and rebuilds the client. node-redis 6.3 hIncrBy(key, field, n) matches the new CpRedisLike signature. Tests are assertive, and the op-log check on the fake proves the HINCRBY path is used.

Author: cwiklik (MEMBER, maintainer)
Areas reviewed: TypeScript (control-plane, knative-server, mocactl), tests, docs/API contract
Agent/IDE config (.claude/.vscode): none
Commits: 1 commit, all signed-off: yes
CI status: passing (all 17 checks green, including k8s-kind-e2e, check, lint, codeql, trivy)

// rather than a flag of its own: no call site can report an end and forget to count it, and
// the counter cannot drift from the timestamp beside it (#420). Separate from the putRuntime
// above because it is an HINCRBY -- `turns` is never settable through the merging hSet.
if (fields.turnEndedAt !== undefined) await index?.bumpTurns(sessionId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: The PR calls this a count of "completed" turns. In practice, every end-report site in server.ts (:395, :653, :789) runs in a finally, so a turn whose executeTurn threw, an aborted turn, and a client-gone detachable turn all stamp turnEndedAt and bump the counter. Only turns refused before the start report (429/409) are skipped. That matches the TUI's local count (transcripts.ts:281 counts done and error terminals), so I think it's the right behaviour. But the contract doesn't say so: turns: { type: integer } has no description at docs/api/openapi.yaml:146 or :887. Consider adding one, e.g. "turns that ran to an end, including errored or aborted ones; counted by the harness at turn end; sessions from before this change start at 0". Then API clients and the new mocactl sessions column have a documented meaning.

* (spec §7.4), which a `fields.turns` passed to `putRuntime` would be. `bumpTurns` -- a server-side
* HINCRBY of exactly +1 -- is the only writer; everything else is dropped on write.
*/
const RUNTIME_COUNTERS: readonly string[] = ['turns'];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: This comment and the test at ownership.test.ts:310 say this makes turns unforgeable by "a compromised harness". But the harness is the tier that calls bumpTurns, over a Redis connection that can HSET/HINCRBY the runtime key directly. So the allow-list stops well-behaved reporter code from setting an arbitrary value. It doesn't stop a compromised harness. That's the same limit as the existing owner allow-list, and display-only data makes it acceptable. Consider wording it as "the reporter cannot set it", so later readers don't treat turns as trusted.

@cwiklik
cwiklik merged commit 8ae5172 into rossoctl:main Nov 2, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Session runtime 'turns' is never written, so every listing reports 0 turns

2 participants