Add bounded Plan analysis status polling - #8
Conversation
Let local Foundation Plan authors observe the exact current analysis without exposing private state or choosing a new origin. Keep waits bounded and generation-stable, distinguish retryable reads from protocol drift, and preserve the audited push contract through shared state and response helpers.
Technical reviewA new command plus a refactor that moves 326 lines out of The refactor claim has good evidence behind it"Share the existing audited state and HTTP response boundaries with The evidence is that That is a better argument than any amount of reading, and it is worth doing this way round: extract into shared modules, change no tests, let the existing suite adjudicate. The wait loop is the new risk, and it holds upPolling loops go wrong in predictable ways. I checked each. Termination. Without A single request cannot outlive the wait. The per-request timeout is clamped: const requestTimeout =
deadline === null
? REQUEST_TIMEOUT_MS
: Math.max(1, Math.min(REQUEST_TIMEOUT_MS, deadline - now()));Without the Identity pinning. This is the one I would most expect to be missing: current = parsed;
first ??= parsed;
if (
current.analysis.id !== first.analysis.id ||
current.analysis.graph_version !== first.analysis.graph_version
) {
throw new PlanStatusChangedError(current);
}If a different analysis appears mid-poll, Timeout carries state. I also confirmed the origin claim: The retryable distinction is real, and documented where a caller will find itThe two new failure modes are separated rather than merged, and the README says what to do with each:
That is the distinction the body promises, and it continues the contract work from #6 and #7 rather than inventing a parallel scheme. The per-command table now spans all four subcommands, so a caller can see which codes are reachable from what it ran. Worth flagging for the Skill side: firstdraft/skills#8 pins the CLI contract at Finding: one verification bullet claims more than was doneThe body says the PR will "exercise the packed executable against a real local endpoint." Those are two separate things in this diff, and neither is the conjunction.
The real local endpoint is in So both halves happened and the coverage is good. The claim just joins them in a way the diff does not support, which matters a little more here than usual because the repository holds itself to describing evidence at its observed boundary. Rewording to something like "exercise the packed executable offline and the status client against a real local endpoint" would match what is there. The practical exposure is small. |
Lesson: the polling loop you probably wrote wrongYou need to wait for something on a server to finish. Here is the loop almost everyone writes first: loop do
result = fetch_status
break if result.done?
sleep 1
endIt reads fine. It has at least four bugs, and this PR fixes all of them in about twenty lines of JavaScript. Worth walking through, because you will write this loop again. Bug one: it never stopsIf the job never finishes, the loop never exits. Your script hangs forever, and in CI that means a build that runs until someone notices. You need a deadline, not a retry count. A count of fifty with a one second sleep is not fifty seconds, because each request also takes time. A deadline says what you actually mean: const deadline = wait ? now() + WAIT_TIMEOUT_MS : null;
// ...
if (deadline !== null && now() >= deadline) throw new TimeoutError(current);Bug two: one slow request outlives the deadlineSay your wait is two minutes and each request has a thirty second timeout. A request that starts at 1:59 can run until 2:29. Clamp the request timeout to whatever is left: const requestTimeout = Math.max(1, Math.min(REQUEST_TIMEOUT_MS, deadline - now()));The Bug three: you might be watching a different jobThis is the subtle one, and the reason I wanted to write this lesson. You push a change and start waiting. While you wait, a teammate pushes their own change. The server starts a new analysis. Your next poll asks "what is the status?" and gets back Succeeded for whose work? Not yours. But your script reports success and moves on, and now a deploy proceeds on the strength of a check that ran against somebody else's code. The fix is to remember what you started watching and refuse if it changes: current = parsed;
first ??= parsed;
if (
current.analysis.id !== first.analysis.id ||
current.analysis.graph_version !== first.analysis.graph_version
) {
throw new PlanStatusChangedError(current);
}A polling loop must know the identity of the thing it is polling, not just its status. If the endpoint only tells you "the current job," you are asking a question whose answer can silently change meaning underneath you. This generalizes past polling. Any time you read a value, act on it, and read it again, ask whether "it" is guaranteed to be the same "it." Bug four: timing out tells you nothing
Carry the last good state out with the error: throw new PlanStatusTimeoutError(current);Now the caller can distinguish "still running after two minutes, probably fine, check again" from "it was already failing." Those need different responses. There is a nice detail in the guard here too: the deadline checks require The version that survives contact with realitydef wait_for_completion(deadline: 2.minutes.from_now)
first = nil
loop do
remaining = deadline - Time.current
raise TimeoutError.new(last: first) if remaining <= 0
current = fetch_status(timeout: [remaining, 30.seconds].min)
first ||= current
raise ChangedError if current.id != first.id
return current if current.finished?
sleep [1, remaining].min
end
endStill short. Every line is there because of a failure mode, not because of ceremony. One more thing, about fixed intervalsThis polls every second, which is right for a two minute wait against a local service. For anything longer or anything you do not own, back off: one second, two, four, capped somewhere sensible. A thousand clients polling a struggling service every second is how a slow recovery becomes an outage. And add jitter, a small random offset, so clients that all started together do not stay synchronized and arrive in waves. The rule of thumb: poll fast when you are the only caller and the wait is short; back off with jitter otherwise. And whenever the option exists, prefer being told over asking. A webhook or a subscription beats even a well-written polling loop, which is ultimately a way of asking "are we there yet" until somebody says yes. |
|
Corrected the PR body to keep the evidence boundaries separate: the packed executable is exercised offline, while the status client is exercised against a real local endpoint. No code change was needed. |
Summary
firstdraft plan statusand a bounded--waitmode for current whole-graph analysisplan pushwithout changing its public behaviorBehavior
A successful status read exits 0 for every validated domain status. Callers branch on
analysis.status.--waitrepeats only a validatedprocessingresponse, pins the first analysis identity and generation, stops on the first failed read, and times out after two minutes with the last validated state.Status uses only the origin pinned by a successful push. It never reads an API override, sends the private ETag, follows redirects, writes local state, or retries writes.
Verification
asdf exec npm run checkPart of firstdraft/firstdraft#133.