Skip to content

waiting_until has no production writer — 89/89 runs report NULL while MCP promises 'until when' #591

Closed

Description

@serge-ivo

waiting_until can only ever be NULL, and the MCP description promises it

One writer: agent-loop-store.ts:272-273, binding wait?.until ?? null. Three production call sites, none passes until:

  • coding-session.ts:440 — null (a clear)
  • coding-session.ts:581 — { reason: wait }
  • coding-session.ts:713 — { reason: "platform_interrupt" }

Only run-liveness.test.ts:166 supplies one. This is the #570 pattern exactly: the helper is tested, the call sites are not, so the column reads as implemented while nothing populates it. Measured live: waitingUntil null on 89 of 89 runs, including one that parked ~6h51m by its own wall clock.

The instant exists and is thrown away

coding-wait.ts:210 returns until: now + ms, consumed at coding-pause.ts:174, and rendered into chat prose at coding-wait.ts:274,304. It never reaches recordLiveness, because pauseDeps(round, parkReason) closes over the reason only.

Two descriptions disagree, and the wrong one is the general-purpose read

  • composition.ts:255 — "waiting (deliberately parked — waitNote says what for and until when)"
  • coding.ts:170 — "saying what for"

The sibling that omits the promise is the correct one, which is the tell. work-report.ts:127's "expected to resume in …" clause is unreachable code.

Why this misleads

"Parked, resuming at 16:00" and "parked, indefinitely" are different decisions for the owner. The platform computes the first and reports the second.

Acceptance criteria

  1. recordLiveness receives the instant coding-wait.ts:210 already computes; waitingUntil is populated wherever a park has a known end.
  2. A park with genuinely no known end stores NULL and the prose says so — the distinction is the point, not the field being non-null.
  3. composition.ts:255 matches behaviour, or behaviour matches it.
  4. A guard asserts every writer of a nullable status column is exercised from a production call site, denominator stated per ADR 0002. A helper test that supplies an argument no caller supplies is the failure mode here and in instance_runtime_nodes.status is a write-once "online" column — every machine an instance has ever seen reports online forever, including one last seen four days ago #570.
  5. Red demonstrated: an engine-limit park records its reset instant.

Verified vs inferred

  • Verified: the single writer, all three call sites, the test-only supplier, both descriptions, coding-wait.ts:210/coding-pause.ts:174, and the 89/89 live measurement.
  • Inferred: nothing.

Activity

  1. serge-ivo commented on Aug 15, 2026

    @serge-ivo
    ContributorAuthor

    Landed — 4d4f52f, live in production

    Confirmed in the code before changing anything: one writer (agent-loop-store.ts:recordLiveness,
    binding wait?.until ?? null), three call sites, none passing until, and exactly one caller
    that did — run-liveness.test.ts. Your measurement reproduced: waiting_until null on 89 of 89
    runs.

    It was a plumbing fix, as the ticket said

    planEngineWait already returns until: now + ms (coding-wait.ts:210), it is already consumed in
    coding-pause.ts and already rendered into the owner's chat sentence. It never reached
    recordLiveness because PauseDeps.tick closed over the reason alone. So tick takes the park,
    waitForEngineReset passes plan.until, and the workflow's tick binds it.

    Passed on every tick rather than once: the ticks are what survive a replay, so a park that
    re-heartbeats after an eviction has to restate its end rather than blank it.

    One call site deliberately does NOT get an until, and this is the decision to check

    The human handoff has a computable 15-minute deadline — but it is when the run gives up, not
    when it resumes, and waiting_until is read as a resume time (work-report.ts renders it as
    "expected to resume in …"). Publishing a give-up time under a resume-time field would tell an owner
    to wait for something that is about to stop. waiting_reason: "human" is the honest half.

    If you want the human park to carry a deadline too, it needs a second field or a reason-aware
    renderer — not this one. Happy to file that; I did not assume it.

    platform_interrupt passes none for a different reason: Cloudflare replays the journal when it
    replays it, and there is no instant to state.

    AC4 — the guard is two-sided, because neither side alone can see this defect class

    workers/api/src/lib/run-park-writers.test.ts, 7 arms:

    1. the module that knows the instant hands it over — resolvePause driven for real, and every
      tick of an engine-limit park asserted to carry until; the human handoff asserted to carry none;
    2. the production call sites consume it — every recordLiveness site enumerated from disk,
      the count asserted (3: one clear, two parks), and a park without an until must be NAMED with
      the reason it has no knowable end;
    3. the general form AC4 states: every optional park field is supplied by at least one production
      call site
      , denominator 2 (reason, until);
    4. plus the column round-trip against the real schema.

    Verified red before green: reverting the two call-site lines fails 3 of the 7 arms, including
    the general one. A test that supplies the argument by hand fails none of them — which is why one
    existed and the column was still empty.

    The description mismatch — not fixed here, as instructed

    composition.ts:255 promises "waitNote says what for and until when"; coding.ts:170 says
    only "saying what for". With this change the wider promise becomes true for an engine_limit
    park
    and stays false for a human one, so the two descriptions are still not interchangeable and
    composition.ts is not mine to edit. Routing to you.

    Also worth naming: work-report.ts's "expected to resume in …" clause was unreachable code and is
    now reachable. The concurrent #588/#589 work landed a RUN_HEALTH_LEGEND carrying the same
    "and until when" promise — same status: true for engine_limit, not for human.

    What is not yet verified

    Nothing has parked since the deploy, so waiting_until is still null on all 89 rows. The write
    path has unit coverage and a real-schema round-trip; the first engine-limit park after this deploy
    is the production test. If it does not populate I will fix it forward.

    coding-session.ts stayed line-neutral against its 872-line pin — the explanation lives in
    coding-pause.ts, which is where the instant is known — so no pin was raised and no seam was forced.

    CI green on 4d4f52f; API deploy 31879980663 succeeded.

  2. added
    P2: correctnessReal defect, no live harm today — inert fields, miscounts, missing guards
    bugSomething isn't working
    backendBackend / Worker / API work
    mcpMCP server surface — the operator's instrument panel, not the product
    coderThe Coder wedge agent (#68) — Engine, Pilot, Co-pilot, Loop, Overseer
    observabilityA displayed value the code cannot produce, or that means something other than its label
    on Aug 18, 2026
  3. serge-ivo commented on Aug 18, 2026

    @serge-ivo
    ContributorAuthor

    Audit verdict — DELIVERED. The title's central claim is now refuted by production data. Closing.

    The measurement this issue rests on — waitingUntil null on 89 of 89 runs — no longer holds.

    Live, read from GET /v1/instances/bd43f4de-…/loop on 2026-08-18

    23 runs, of which 2 carry a non-null waitingUntil:

    runId status waitingReason waitingUntil
    10b62cd4 needs_human human 1787009820915
    2670cecc cancelled human 1787008845159

    The column has a production writer and production values. That is the defect this issue named, and it is gone.

    Per criterion

    AC1 — the instant reaches recordLiveness. workers/api/src/workflows/coding-session.ts:610 (on HEAD):

    				await recordLiveness(env, loopRunId, Date.now(), { reason: wait, until: park?.until ?? null }).catch(() => undefined);

    Passed on every tick, not once, so a park that re-heartbeats after an eviction restates its end rather than blanking it.

    AC2 — a park with no knowable end stores NULL. coding-session.ts:743 still passes { reason: "platform_interrupt" } with no until, because Cloudflare replays the journal when it replays it and there is no instant to state.

    AC3 — the two descriptions agree. Settled by #596 rather than by picking a winner, and the resolution is better than what this issue asked for. workers/mcp/src/instance-tools/composition.ts:263-271 now records that a park's deadline is not one kind of thing: waitingUntil is when the clock RUNS OUT, and waitingReason says what running out means — an engine_limit park resumes at it, a human handoff gives up at it. The tool text is rendered from RUN_HEALTH_GLOSS, so the "and until when" promise cannot be retyped back in.

    AC4 — the guard exists and is two-sided. workers/api/src/lib/run-park-writers.test.ts, 7 arms: the module that knows the instant hands it over; every production recordLiveness call site enumerated from disk with the count asserted; and the general form — every optional park field supplied by at least one production call site. Red was watched: reverting the two call-site lines fails 3 of 7, while a test that supplies the argument by hand fails none — which is exactly why one existed and the column was still empty.

    AC5 — red demonstrated. At the unit level, above. In production, the two rows in the table.

    One correction to this issue's own delivery comment, so it does not mislead

    The 4d4f52f comment said "One call site deliberately does NOT get an until … the human handoff", on the grounds that its 15-minute deadline is when the run gives up rather than when it resumes. #596 superseded that: a human handoff now DOES carry its give-up instant, and run-park-writers.test.ts:73 asserts it — "a human handoff ticks WITH its give-up instant, which is the deadline the owner can beat". That is why both live rows above are human parks. The reader-side ambiguity the original decision was protecting against is handled by the reason-aware gloss instead.

    What is genuinely not observed

    No engine_limit park has occurred since the deploy, so that specific reason has not been seen carrying an until in production. The write path is one line shared by every reason and is unit-pinned; I am not treating its absence as an open defect, but recording it rather than implying coverage I did not measure.

    Verified vs inferred

    • Verified: all four code citations against HEAD (not the dirty working tree); the guard file and its human-park arm; the live run rows.
    • Inferred: nothing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2: correctnessReal defect, no live harm today — inert fields, miscounts, missing guardsbackendBackend / Worker / API workbugSomething isn't workingcoderThe Coder wedge agent (#68) — Engine, Pilot, Co-pilot, Loop, OverseermcpMCP server surface — the operator's instrument panel, not the productobservabilityA displayed value the code cannot produce, or that means something other than its label

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions