Skip to content

Keep the first model-list answer after a restart - #168

Merged
artyomsv merged 8 commits into
masterfrom
fix/harness-catalogue-first-answer
Sep 24, 2026
Merged

artyomsv merged 8 commits into
masterfrom
fix/harness-catalogue-first-answer

Conversation

@artyomsv

Copy link
Copy Markdown
Owner

Why

Found on the dev stack right after merging #167. After each restart the harness model list stayed
unknown until the next 10-minute refresh:

  1. harness-image-commands-in (run worker) and harness-image-results-in (orchestrator) used
    auto.offset.reset: latest. A consumer gets its partition only after a delay (a new group, or a
    rejoin waiting for the dead member's session to expire). The question or answer sent in that window
    was skipped. Observed: the worker answered with status OK, and the orchestrator's group showed no
    committed offset and never recorded it.
  2. HarnessCatalogues.refresh ticked at startup before its Kafka emitter was connected:
    SRMSG00019: Unable to connect an emitter with the channel harness-image-commands-out.

What changed

  • Both channels now use reset: earliest. Replay is harmless: answers are idempotent, consumed in
    order, and answers about an image no longer configured are dropped.
  • @Scheduled(..., delayed = "1m") on the refresh. The startup ask still covers boot.

Verification

  • :spire-orchestrator:test and :spire-run-worker:test pass.
  • Mutation checks: reverting each of the three changes fails exactly its new test.
  • Live: after a refresh the dev stack stored status OK, the pinned image id, and 8 codex models with
    their levels.

Not in this PR

The same startup race hits the existing WorkItemOutbox#publish timer (work-events-out). That is
older than #167 and left for a separate change.

Measured on the dev stack after merging PR #167: the model list stayed
unknown for ten minutes after every restart.

- Both image channels read with offset reset latest. A group is only
  assigned its partition after a delay - a new group, or one waiting
  for a dead member's session to expire after a restart - and a message
  sent in that window was skipped. Both now reset to earliest. Replay
  is harmless: answers are idempotent, handled in order, and an answer
  about an image no longer configured is dropped.
- The refresh timer's first tick ran at startup, before the Kafka
  emitter was connected, and logged an injection ERROR on every start.
  It now waits one minute; the startup ask already covers boot.
Review of PR #168: reading from the earliest offset made replay
possible, so every record on these channels must survive arriving late
or twice.

- Image questions carry the time they were asked; answers echo it. The
  catalogue keeps the answer to the newest question (V82), so a
  replayed older answer cannot roll the list and pin back. The worker
  skips questions older than 15 minutes instead of pulling an image
  nobody runs any more ahead of the current question.
- A sign-in start sent while the worker was rejoining was never read,
  leaving the row PENDING for ever. Both sign-in channels now read from
  earliest. A start carries the operator's press time: the worker
  opens no unit once the wait has run out, gives a late start only the
  time left, and ignores a replay for a unit still running. The
  orchestrator re-sends an unanswered start every 30 seconds while its
  wait lasts, then fails it as sign_in_not_started, and closes a prompt
  two minutes past its code's expiry.
- Dead-lettered harness records now replay onto their own topics
  instead of cs.commands.
- The agent image pins Codex 0.156.1, which lists the gpt-6 models.
  Flags, API-key login and its file shape were re-checked; the --json
  event stream waits for a paid run (UNVERIFIED section B).
- The build step says why no model can be picked when every one lacks
  a price; a select with every option disabled looked broken.
Third review of PR #168 found four ways a late record could still act.

- A start with no press time predates this change, so it can only be
  a replay; the worker gave it the full wait and could reopen a
  finished sign-in. It now opens no unit.
- A late start reported EXPIRED. With two workers, a late copy on one
  would fail a sign-in the other was running. A start too late to open
  a unit now opens nothing and reports nothing; the orchestrator ends
  unclaimed rows from the row's own clock.
- The retry pass failed rows through the ordinary path, which also
  accepts PROMPTED, so a prompt landing between its read and its write
  was ended as not started. The timeout now requires the row to still
  be PENDING when the write runs.
- Undated image questions were answered and undated answers could
  replace a cached row. Both are now treated as replays: skipped, and
  never replacing.

No test reproduces the third race deterministically; the conditional
write is the fix and is stated rather than proved.
Fourth review of PR #168: when a second worker's create hit the
container name another worker owned, and the lookup that followed
failed or found nothing, the error escaped as UNIT_FAILED. With two
workers that ended a sign-in the other one was running and the
operator could still approve.

The conflict itself proves the unit is held, so it now always means
AlreadyClaimed; the lookup only adds detail. A holder that no longer
exists is resolved by the orchestrator's deadlines.
Fifth review of PR #168: any failure before a worker owns the unit -
a connection reset, a timeout on create - became UNIT_FAILED. With
two workers that ended a sign-in the other one was running.

A failure before ownership is now logged and dropped; the orchestrator
re-sends the start. Because a broken worker now reports nothing, a
sign-in that shows no code within five minutes is ended as
sign_in_not_started, instead of after the fourteen minutes a person
may take. A worker shows the code within a minute; the rest is room
for a first pull of the agent image.
Sixth review of PR #168: the orchestrator ended a sign-in with no code
after five minutes, while the worker still admitted its start for the
fourteen minutes a person may take. A worker recovering at minute six
opened a unit for a row already ended, and a start admitted at 4:50
printed its code after the row was gone.

Start now carries startWithinSeconds (four minutes). The worker opens
a unit only if it can print the code before that window closes; the
orchestrator ends an unclaimed row two minutes after it, leaving room
for the code to cross the bus. One number, sent, instead of two that
could drift. The comment that justified the old deadline with an
image pull was wrong - the sign-in unit pulls nothing - and is gone.
Seventh review of PR #168: the worker checked the start window only
at admission and took the approval wait as a duration before the unit
started. A unit admitted in time that started slowly, or whose process
was paused, could publish a code after the row had been ended, and a
slow start stretched the fourteen-minute approval limit.

Start now exposes promptDeadline and approvalDeadline, both from the
press. The worker waits for the code no longer than the prompt
deadline, rechecks it before publishing - a late unit is destroyed and
reports nothing - and measures the code's expiry and its exit wait
against the approval deadline at the moment it uses them. The worker
reads a replaceable clock so a test can make a unit start slowly.

The orchestrator stops re-sending once the start window has closed;
the row stays PENDING until its deadline, since a code may be on its
way.
Eighth review of PR #168: the expiry added a duration taken from one
clock reading to a later reading, so after a pause the countdown ran
past the worker's real deadline. The expiry is now the sooner of the
vendor's lifetime from one reading and the approval deadline itself.
@artyomsv
artyomsv merged commit c08b2f2 into master Sep 24, 2026
18 checks passed
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.

1 participant