A run lives through a sak restart: its tracker calls are tried again, each lands once, and an unreported end is logged #141
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "task/3724-a-run-lives-through-a-sak-restart-its-tr"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Tracker: #3724
Step 4 of plan #3694. This branch merged
origin/master(the 0.3.6 release and #3731's fix of the two web tests that time out under load,8cda22f) before its own commits, so the PR's diff against the plan's branch also shows those master commits. The merge was clean: master's #3731 change was already on the plan's branch, carried byte-identical as952a11e.Why runs 249 and 289 were lost
Both were lost to a sak restart, not to the runner's hand-over. Run 289's stream 7470 stops at 07:18:24Z on 2026-10-23, when sak restarted. On s32,
runner.loglines 1546–1561 show19:43:53 run 249 started, then "sak-runner 0.3.3: sak asks for 0.3.4" and the hand-over to slot b, and nofinishedline for run 249 ever. A deploy restarts sak (scripts/deploy_nas.sh:45runsdocker compose up -d sak) and Update all follows it (comment 17696), so run 249 died in the same restart, 29 s after it started.The hand-over did not drop it:
Channel.finishingstops only oncerunsis empty, a ticket settles only inretireafter its pump thread ends, andcheck_hand_overalready pins a step in flight endingdoneacross a hand-over. What lost it was the runner's single attempt at each tracker call: the pump killed the runtracker_faulton the first event batch sak did not take,executethen raised onclose_streamandcomplete, which sak could not take either, and in daemon mode nothing logged it. Its request stayedrunninguntil it was closed by hand.What this delivers
sak takes each call once. A call the runner sends again after its answer was lost is answered with what the first one made:
POST /api/runs/:id/claimandPOST /api/jobs/:id/claimby the runner that holds the request or jobclaimedanswer200with that claim again. A run's is built from the account and harness the request recorded when it was claimed, not by resolving the account again.POST /api/tasks/:id/streamstakes an optionalrun_idorjob_id, which the stream records. An open naming a run or job whose stream still runs answers200with that stream. A request queued again (account_limited) opens a stream of its own, since its first stream is closed.POST /api/streams/:id/eventsandPOST /api/streams/:id/messagestake an optionalkey. A key the stream or inbox already took writes nothing and answers200with that batch'slast_seqor that message.POST /api/runs/:id/gate-runstakesnumber(required), the gate run's number in its run's claim, and records the stream the run started on with it. A number the run already holds on that stream answers200with that gate run. A request queued again (account_limited) starts its next claim on a stream of its own, and that claim's gate runs, numbered from 1 again, are recorded as new ones. A record is taken only while the run isrunning: a request that is onlyclaimedhas no stream yet, and answers409 run_not_held, where the base took it.POST /api/memoriesnaming a run whose text that run already saved answers200with that memory.number.The runner tries its calls again.
--tracker-bound <seconds>(default 300, also a config file key) bounds how long a run's or a job's call is tried again, with a backoff from 250 ms doubling to 4 s, while sak is unreachable or answers 5xx. That covers the claim, the stream open, start, the gate feeds' batches and records, the forbidden-word message,renew,remember,report_usage, the close andcomplete, and a job's claim, open, start and completion. A 5xx past the bound now raisesUnavailablerather thanRefused.failed · pump_unstarted.tracker_faultonce the bound has passed since the last batch or ack sak took, or at once when sak refuses one.pump:<n>for the pump's,gate:<number>:<n>for a gate run's feed, andforbidden:<n>for the forbidden-word messages. The open names the run or job.complete(and a job's start and completion) tried more than once that sak answers409is read back. The call is taken when sak shows its effect.renew,rememberorreport_usagekeeps the home as before and is logged, but no longer stops the close andcomplete.executeraises logsrun <id> unreported <call>: <error>(orjob <id>), the call named as the tracker client's method. A claim whose answer never came logsrun <id> unreported claim: <error>.docs/api.md(Streams, Runs, Jobs, Memories) andrunner/README.md(the flag table, One run, the retry and unreported paragraphs) describe this.Rollout.
numberis required on a gate-run record. Once sak runs this, a runner older than this PR has its gate-run records refused400(loggedunrecorded) until Update all moves it. Its runs and gates still run and close as before.Answers to review run 680
76c3b9f). The runner numbers a run's gate runs from 1 on each claim, and a request that endsfailed · account_limitedis queued again under the same id, so the next claim's gate #1 was answered with the first claim's record and never recorded. A gate run now carries the stream its run started on, and the repeat and the unique index are on (run, stream, number). Since a claimed request has no stream until it starts, a record is taken only while the run isrunning.docs/api.mdsays so. The other keys the runner counts per execution,pump:<n>,gate:<number>:<n>andforbidden:<n>, are each known within their stream, and a new claim opens its own, so they need no change. A memory stays known by its run and text across claims. One case is left open, read from the code and not probed: a new claim's open names its run, and is answered with the first claim's stream if that stream is stillrunning. That happens only when the first claim's close failed past its bound while itscomplete(account_limited) landed.ff11275). Each batch or ack that lands now clears the pump's record of when sak stopped taking its calls, and resets the backoff, so the bound counts from the last call sak took.runner/README.mdsays so.Regressions, failing at the step's base and passing at its head
Each check below was run from a
git archivearm of the step's base, the plan's branch atc139ad1, holding this PR's test files. The arm used the gate cache's sx and, fortests/runner_daemon.sx, adist/sak-runnerbuilt from the base's runner. The base's tracker client has no key argument and no bound, so the base copy ofrunner_daemon.sxdrops the trailingnullofpost_messageand the lines that settracker_bound_ms. The head passed every one of these files, in arms and in tracker gate runs 371 (a379400) and 394 (ff11275):RUNNER DAEMON OK,RUNNER CORE OK,STREAMS API OK,STREAM INBOX OK,MEMORIES OK,GATES OK,RUN QUEUE OK,BLOCKERS OK.tests/runner_daemon.sx(7 checks fail at the base, and the two ofcheck_flakyata379400):--tracker-bound, sak taking each batch sent again (check_flaky, added for F2). Atff11275the stand-in dropped 25 answers, the run endeddone, and its stream holds all 42 lines once, in order;RUNNER DAEMON OK. Ata379400, from agit archivearm holding this test: the run was killedtracker_fault4 s after it started, 5 answers dropped, andFAIL the run ends done,FAIL every line the harness wrote is on its stream once, in order (7 lines),RUNNER DAEMON FAIL (2 checks). Every other check passed there.check_outage, the stub stream-json harness's newchattermode). At the head the run endsdonewith every line it wrote on the stream once, in order (46 and 52 lines in two head runs). At the base:FAIL the run ends done,FAIL every line the harness wrote is on its stream once, in order (1 lines),FAIL the stream closes done. The base's log for that run readsrun 5 claimed,run 5 started stream 5, and then nothing: its request staysrunningand no line logs its end.--tracker-boundand served again within the end calls' bound (check_bound). At the head the run is killed, its request readsfailed · tracker_fault, and its stream closeskilled. At the base:FAIL the run is killed, and its request reads failed · tracker_fault,FAIL its stream closes killed with tracker_fault.completeonce each, then to aremember(check_stand_in). The test's dispatch wrapper lets each named call reach sak, then answers it with a body that faults before its first byte, so the server closes the connection with no answer. At the head all 5 are dropped and sak holds the run's end as the runner recorded it (done, claimed by alpha), one stream, each event once, one gate-run record and each of the 3 memories once, and the runner's log holds nounreportedline. At the base:FAIL alpha runs the request, its claim's and its stream open's answers dropped. Its log readsrun 7 receivedand neverclaimed: the lost answer left the requestclaimedby alpha, which never started it.check_gone). At the head the runner logsrun 3 unreported close_stream: TrackerErr.Transportonce the end calls' bound passes. At the base:FAIL once the end calls' bound passes, the runner logs the close sak never took.tests/step_streams_api.sx:a second open naming a run whose stream still runs -> 200 with that stream, and opens noneanda batch whose key the stream already took -> 200 with that batch's last_seq, and writes nothingfail at the base.an open naming a run whose stream has closed, as a request queued again does -> 201 with a stream of its ownpasses at the base. It fails ate69779a, this branch's first server commit, whose open matched a closed stream too. The first head daemon run found that: a request queued again afteraccount_limitedwas handed its first, closed stream, and every batch was refused409 stream_closed.548852cfixes it.tests/step_stream_inbox.sx:a second post with a key the inbox already took -> 200 with the first message, and stores nonefails at the base.tests/memories.sx:a second memory of a run's text that run already saved -> 200 with that memory, and saves nonefails at the base. Two later checks fail with it, because the base saves a second memory.tests/gates.sx:a record of a number the run already holds -> 200 with that gate run, and records nonefails at the base, along with the record'snumber, the400for no number from 1, and the list of the run's gate runs.tests/gates.sxcheck_requeued(added for F1): a run claimed, started, its gate #1 recorded, then endedfailed · account_limited, claimed again and started on a new stream. Atff11275all pass (GATES OK). Ata379400, from an arm holding this test,GATES FAIL (3 checks):FAIL a run claimed and not yet started takes no gate run -> 409 run_not_held(a379400takes a record from aclaimedrequest, and here answered200with the first claim's);FAIL its next claim's gate of the same number -> 201 with a gate run of its own(a379400answered200with the first claim's record);FAIL the run lists the gate runs of both claims(it listed one).tests/step_run_queue.sx:a claim by the runner that holds the request claimed -> 200 with its claim again, on the account it recorded and not the one first by position nowfails at the base. Before the repeat, the test moves a second serving account to position 1.tests/blockers.sx:a claim by the runner that holds the job claimed -> 200 with its claim againfails at the base.At the base, the schema-version checks of these files fail too (
migration reaches the schema head, 44 against 45). Every test that pins the schema version moves to 45.tests/runner_core.sxcheck_renewal_unstorednow pins the new end rule: a renewal sak does not store keeps the renewed login in the run's home, and the run still closes and ends. Before, it pinned the run failing withRefused.Gates
sak-gate full#1, tracker gate run 369, at61ae1c4: failed in itstests/*.sxstep. Every runner and API test passed, includingrunner_daemon,runner_core,runner_handover,runner_view,runner_launchd,blockers,gates,memoriesandstep_run_queue. Three tests failed on migration 45 itself:tests/file_keys.sx"a database at 17 migrates" andtests/tools.sx"it upgrades". Each winds a database back (to 17 and to 40) by dropping what the later migrations added. Neither dropped migration 45'sstreamcolumns, soALTER TABLE stream ADD COLUMN run_request_idfailed on the way up again.tests/step_stream_links.sx"the upgrade keeps an index over a task's attempts" counted every index onstream, and migration 45 adds two.a379400makes the wind-backs drop migration 45's additions and counts the attempts index by its name. All three pass ata379400.sak-gate full#2, tracker gate run 371, ata379400(clean tree): passed. Everytests/*.sxfile passed, then release withstep9_release, the deploy contract, the main Playwright config (740 passed, 1 skipped, 3.8 min) and the runner Playwright config (10 passed).sak-gate full, tracker gate run 394, atff11275(clean tree), after the answers to review run 680: passed. Everytests/*.sxfile passed, then release withstep9_release, the deploy contract, the main Playwright config (740 passed, 1 skipped, 3.5 min) and the runner Playwright config (10 passed).Golden set: none.
sak takes a repeated call of a run once: a claim, a stream open, a batch, a gate-run record, a memory and an inbox post answer what the first one madeto A run lives through a sak restart: its tracker calls are tried again, each lands once, and an unreported end is logged