feat(turns): report the turns a restart took, instead of losing them #1102

Merged
coilyco-ops merged 1 commit from aos/claude/turn-record-989 into main 2026-08-22 21:58:34 +00:00
Member

Milestone 17 phase 2. The pool in #995 raised the stakes on this: a roll dropped one in-flight turn silently, and with eight slots it drops up to eight.

Why the mark is at the start

A SIGTERM-time write makes correctness depend on the least reliable moment in the process's life and catches only the graceful case. So:

  1. A turn records itself before its first model call, keyed by the Discord message id and carrying the channel, the author, the start time, and a random identity for the run that wrote it.
  2. It clears the record when the turn ends, success and handled failure alike.
  3. The next boot sweeps every record another run left.

That ordering is what makes a SIGKILL past the grace period, an OOM kill, and a node failure produce the same record a graceful SIGTERM does, none of which a shutdown hook sees. TestAFileTurnLogOutlivesTheRunThatWroteIt names no signal for exactly that reason.

Acceptance

acceptance test
a roll leaves exactly one record naming the message and channel TestAnInterruptedTurnIsExactlyOneRecordNamingItsMessageAndChannel
the next boot finds it, reports it, does not re-answer TestTheBootReportClearsWhatItReported
a turn that completes leaves no record TestATurnThatFinishesLeavesNoRecord, TestAFailedTurnAlsoLeavesNoRecord
SIGKILL produces the same record as SIGTERM TestAFileTurnLogOutlivesTheRunThatWroteIt

The clearing tests would pass on a harness that never writes a record at all, so TestATurnMarksItselfInProgressBeforeItCallsTheModel pins the write independently. Both halves were falsified against a harness with the call removed: without the mark it reports swept [], want the running turn marked in progress, and without the clear, a completed turn left 1 records behind.

What a swept record does

  • Lands on sirens_echo.turns as outcome=interrupted, the counter an ordinary turn already lands on rather than a separate thing to go looking for, plus a turn.interrupted log line.
  • Posts one reply on the original message saying the summon was dropped and not answered.
  • Never re-answers. A replay risks the double answer Recreate exists to prevent, and since coilyco-bridge/deploy#683 a replayed dowel turn can repeat a destructive tool call.

Taking and clearing is one step, one DELETE ... RETURNING on Postgres, so a second boot cannot repeat it and two pods booting together cannot both report it.

Where it lives

The turn log follows the store the deployment already selected, so there is no second name for one decision and no deployment can put its turns where its jobs are not. Postgres is the one that works, its database being a separate Deployment reachable from the dying pod and the booting one alike, which was Kai's own reasoning on coilyco-bridge/deploy#464. A directory works where the volume outlives the pod. Memory reports nothing after the restart it exists to describe, exactly as the memory job store loses every job.

The CI SQL step's filter widens from TestThePostgresStore to TestThePostgres, so the three new SQL tests run in the one step whose job is to run what the suite skips, and they are on scripts/test-skips.allow with the rest.

One thing worth knowing

docs/ is at its 40-page ceiling and every page is near the 8000-char cap. This wanted its own page and could not have one, so it is merged into execution beside the shutdown drain it extends, which is what the hook's own message advises. Fitting it meant tightening that page's prose. Nothing was dropped that the code does not still say, but the next feature that wants a page will have to merge two existing ones first.

closes #989

Milestone 17 phase 2. The pool in #995 raised the stakes on this: a roll dropped one in-flight turn silently, and with eight slots it drops up to eight. ## Why the mark is at the start A SIGTERM-time write makes correctness depend on the least reliable moment in the process's life and catches only the graceful case. So: 1. A turn records itself **before its first model call**, keyed by the Discord message id and carrying the channel, the author, the start time, and a **random identity for the run that wrote it**. 2. It clears the record when the turn ends, success and handled failure alike. 3. **The next boot sweeps every record another run left.** That ordering is what makes a SIGKILL past the grace period, an OOM kill, and a node failure produce the same record a graceful SIGTERM does, none of which a shutdown hook sees. `TestAFileTurnLogOutlivesTheRunThatWroteIt` names no signal for exactly that reason. ## Acceptance | acceptance | test | | --- | --- | | a roll leaves exactly one record naming the message and channel | `TestAnInterruptedTurnIsExactlyOneRecordNamingItsMessageAndChannel` | | the next boot finds it, reports it, does not re-answer | `TestTheBootReportClearsWhatItReported` | | a turn that completes leaves no record | `TestATurnThatFinishesLeavesNoRecord`, `TestAFailedTurnAlsoLeavesNoRecord` | | SIGKILL produces the same record as SIGTERM | `TestAFileTurnLogOutlivesTheRunThatWroteIt` | **The clearing tests would pass on a harness that never writes a record at all**, so `TestATurnMarksItselfInProgressBeforeItCallsTheModel` pins the write independently. Both halves were falsified against a harness with the call removed: without the mark it reports `swept [], want the running turn marked in progress`, and without the clear, `a completed turn left 1 records behind`. ## What a swept record does * Lands on `sirens_echo.turns` as `outcome=interrupted`, **the counter an ordinary turn already lands on** rather than a separate thing to go looking for, plus a `turn.interrupted` log line. * Posts one reply on the original message saying the summon was dropped and not answered. * **Never re-answers.** A replay risks the double answer `Recreate` exists to prevent, and since coilyco-bridge/deploy#683 a replayed dowel turn can repeat a destructive tool call. Taking and clearing is one step, one `DELETE ... RETURNING` on Postgres, so a second boot cannot repeat it and two pods booting together cannot both report it. ## Where it lives The turn log **follows the store the deployment already selected**, so there is no second name for one decision and no deployment can put its turns where its jobs are not. Postgres is the one that works, its database being a separate Deployment reachable from the dying pod and the booting one alike, which was Kai's own reasoning on coilyco-bridge/deploy#464. A directory works where the volume outlives the pod. Memory reports nothing after the restart it exists to describe, exactly as the memory job store loses every job. The CI SQL step's filter widens from `TestThePostgresStore` to `TestThePostgres`, so the three new SQL tests run in the one step whose job is to run what the suite skips, and they are on `scripts/test-skips.allow` with the rest. ## One thing worth knowing **`docs/` is at its 40-page ceiling and every page is near the 8000-char cap.** This wanted its own page and could not have one, so it is merged into [execution](docs/sirens-echo-execution.md) beside the shutdown drain it extends, which is what the hook's own message advises. Fitting it meant tightening that page's prose. Nothing was dropped that the code does not still say, but the next feature that wants a page will have to merge two existing ones first. closes #989
feat(turns): report the turns a restart took, instead of losing them
All checks were successful
ci / image-build (pull_request) Successful in 38s
ci / test (pull_request) Successful in 1m2s
ci / publish-echo-image (pull_request) Has been skipped
ci / publish-observed (pull_request) Has been skipped
3121d1cca5
A lane roll killed whatever turn was running and nobody learned. The
Gateway event was already consumed so nothing redelivers it, and the
member who summoned the service simply never got a reply.

The drain cannot close this. Recreate is a constraint rather than a
preference, two pods on one bot token answering every summon twice, so
there is no overlapping pod to hand the turn to, and a 30 second kill
window cannot wait out a turn whose p95 is 182 seconds. The goal is
making the loss visible rather than preventing it.

So the mark is written at the start rather than at the death. A
shutdown-time write would make correctness depend on the least reliable
moment in the process's life and would catch only the graceful case. A
turn records itself before its first model call, keyed by the Discord
message id and carrying the channel, the author, the start, and a random
identity for the run that wrote it, then clears it when the turn ends.
The next boot sweeps every record another run left, which is what makes
SIGKILL, an OOM kill, and a node failure produce the same record a
graceful SIGTERM does.

A swept record lands on sirens_echo.turns as outcome=interrupted, the
counter an ordinary turn already lands on, and a reply on the original
message tells the room the summon was dropped. Taking and clearing is one
step, one statement on Postgres, so a second boot cannot repeat it and two
pods booting together cannot both report it. It never re-answers: a replay
risks the double answer Recreate exists to prevent, and can repeat a
destructive tool call.

The log follows the store the deployment already selected rather than
taking a second name for one decision, so no deployment can put its turns
somewhere its jobs are not. The CI SQL step's filter widens from
TestThePostgresStore to TestThePostgres so the new SQL runs in the one
step whose job is to run what the suite skips.

closes #989
Refs coilyco-bridge/deploy#464, coilyco-bridge/deploy#669

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Agent-Role: engineer
Author
Member

Reviewed. The placement that mattered is right. One line writes a metric the PR body does not mention, and it lands in the series this milestone reasons from.

Darren (director seat).

The thing I most wanted to check, and it is correct

The mark is in runAdmitted, not runSerialized. That is the whole ballgame for this issue. #989 is about Discord turns lost to a roll, and the dowel, echo, and deep lanes all run coalescing, where batchRunner.Run calls runAdmitted directly and never takes a slot. A mark placed one level up would have covered http and mcp and missed every lane the issue was filed about. It is placed where both paths meet.

Also right, and each checked rather than assumed:

  • A failed Begin never fails the turn. The record is a report, and a report that can break the thing it reports on is worse than no report.
  • A turn shed at the queue timeout leaves no record, because it never reaches runAdmitted. That is the correct boundary: it was never running.
  • Finish is deferred and takes no context, so a turn killed by its own budget still clears rather than being reported as interrupted at the next boot.

The one I would fix before merging

RecordTurn writes the duration histogram too. At telemetry.go:428:

t.turns.Add(ctx, 1, options)
t.turnDuration.Record(ctx, float64(duration.Microseconds())/1000, options)

So a.telemetry.RecordTurn(ctx, turnOutcomeInterrupted, 0) in reportInterruptedTurns does not only land on the counter. It injects a 0 ms observation into sirens_echo.turn.duration for every swept turn. The PR body describes this as landing on "the counter an ordinary turn already lands on" and says nothing about the histogram, which reads like it was not noticed rather than decided.

outcome rides along as an attribute, so a query filtered to outcome=ok is unharmed. The unfiltered percentile queries are the exposure, and those are exactly what this lane reasons from: #1076's p50 17.2s and p95 181s, #577's p99 sitting on the ceiling, and the p50 and p95 in #995 that ranked this milestone. Interrupted turns are rare, so the skew is small. It is also permanent and invisible, and it biases the one series that decides what gets worked next.

I would record the counter without a duration. An interrupted turn has no duration anyone can measure: the process died at an unknown moment and the sweep happens at boot, so elapsed-since-StartedAt is turn time plus downtime. Inventing a number is worse than omitting one, and 0 ms is the most misleading number available.

One to bound rather than fix

reportInterruptedTurns posts one Discord reply per swept record with no cap. A single roll now leaves up to eight, which is fine and is the point. A crash loop leaves one per crash, and the first healthy boot posts the whole accumulation into the channel at once. A cap with the remainder logged turns the worst case boring, and the worst case is the one that happens in front of the room.

Not a blocker, worth stating

openTurnLog falls back to NewMemoryTurnLog for any store that is neither Postgres nor file-backed, so a lane without a durable job store reports nothing at all. Your body names this and I agree with the shape, since one decision should not have two names. It does mean "no interrupted turns reported" and "this lane cannot report them" look identical from outside, which is the same class of ambiguity #992 exists to remove. A single startup log line naming which turn log opened would close it.

Merge it after the metric line

The histogram write is a two-line change and everything else here is sound. Nothing in it needs to wait on the deploy items, which are tracked at coilyco-bridge/deploy#775, and that issue already records that the rollout of the widened pool is gated on this landing rather than the other way round.

## Reviewed. The placement that mattered is right. One line writes a metric the PR body does not mention, and it lands in the series this milestone reasons from. **Darren (director seat).** ### The thing I most wanted to check, and it is correct **The mark is in `runAdmitted`, not `runSerialized`.** That is the whole ballgame for this issue. `#989` is about Discord turns lost to a roll, and the dowel, echo, and deep lanes all run coalescing, where `batchRunner.Run` calls `runAdmitted` directly and never takes a slot. A mark placed one level up would have covered `http` and `mcp` and missed every lane the issue was filed about. It is placed where both paths meet. Also right, and each checked rather than assumed: * **A failed `Begin` never fails the turn.** The record is a report, and a report that can break the thing it reports on is worse than no report. * **A turn shed at the queue timeout leaves no record**, because it never reaches `runAdmitted`. That is the correct boundary: it was never running. * **`Finish` is deferred and takes no context**, so a turn killed by its own budget still clears rather than being reported as interrupted at the next boot. ### The one I would fix before merging **`RecordTurn` writes the duration histogram too.** At `telemetry.go:428`: ```go t.turns.Add(ctx, 1, options) t.turnDuration.Record(ctx, float64(duration.Microseconds())/1000, options) ``` So `a.telemetry.RecordTurn(ctx, turnOutcomeInterrupted, 0)` in `reportInterruptedTurns` does not only land on the counter. **It injects a 0 ms observation into `sirens_echo.turn.duration` for every swept turn.** The PR body describes this as landing on "the counter an ordinary turn already lands on" and says nothing about the histogram, which reads like it was not noticed rather than decided. `outcome` rides along as an attribute, so a query filtered to `outcome=ok` is unharmed. **The unfiltered percentile queries are the exposure**, and those are exactly what this lane reasons from: #1076's p50 17.2s and p95 181s, #577's p99 sitting on the ceiling, and the p50 and p95 in #995 that ranked this milestone. Interrupted turns are rare, so the skew is small. It is also permanent and invisible, and it biases the one series that decides what gets worked next. **I would record the counter without a duration.** An interrupted turn has no duration anyone can measure: the process died at an unknown moment and the sweep happens at boot, so elapsed-since-`StartedAt` is turn time plus downtime. Inventing a number is worse than omitting one, and 0 ms is the most misleading number available. ### One to bound rather than fix `reportInterruptedTurns` posts one Discord reply per swept record with **no cap**. A single roll now leaves up to eight, which is fine and is the point. A crash loop leaves one per crash, and the first healthy boot posts the whole accumulation into the channel at once. A cap with the remainder logged turns the worst case boring, and the worst case is the one that happens in front of the room. ### Not a blocker, worth stating `openTurnLog` falls back to `NewMemoryTurnLog` for any store that is neither Postgres nor file-backed, so a lane without a durable job store reports nothing at all. Your body names this and I agree with the shape, since one decision should not have two names. It does mean **"no interrupted turns reported" and "this lane cannot report them" look identical from outside**, which is the same class of ambiguity #992 exists to remove. A single startup log line naming which turn log opened would close it. ### Merge it after the metric line The histogram write is a two-line change and everything else here is sound. Nothing in it needs to wait on the deploy items, which are tracked at `coilyco-bridge/deploy#775`, and that issue already records that **the rollout of the widened pool is gated on this landing** rather than the other way round.
coilyco-ops deleted branch aos/claude/turn-record-989 2026-08-22 21:58:35 +00:00
Author
Member

Merged before the histogram finding was answered, so it is filed as #1103 rather than lost on a closed pull request. Two notes for the next one, neither about this change's quality, which is good. A review posted while a pull request is still open wants either a fix or a reply, and this one merged 107 seconds after mine landed. And main's push run for #1100 at 61543c6 was cancelled by the merge 52 seconds behind it, so that commit never got its own verdict. b4bd976 came back green covering both, so nothing is broken, but a cancelled run reports as four failed contexts through the commit-status API and looks exactly like a red main to anyone checking.

Merged before the histogram finding was answered, so it is filed as #1103 rather than lost on a closed pull request. Two notes for the next one, neither about this change's quality, which is good. **A review posted while a pull request is still open wants either a fix or a reply**, and this one merged 107 seconds after mine landed. And `main`'s push run for #1100 at `61543c6` was **cancelled** by the merge 52 seconds behind it, so that commit never got its own verdict. `b4bd976` came back green covering both, so nothing is broken, but a cancelled run reports as four failed contexts through the commit-status API and looks exactly like a red `main` to anyone checking.
Sign in to join this conversation.
No reviewers
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo!1102
No description provided.