1202 Commits
Author SHA1 Message Date
Yao 2c3b0c808c fix(worker): confirm a claim before running the item, not only when taking it (#637)
A claim is a lease on running a work item, not ownership of it: `claim()` hands
out an item with a 60s window and the row becomes claimable again the instant it
passes. The gap between claiming and starting is unbounded - the worker returns
from the claim, resolves its workdir, and may be a process that was paused or
descheduled - so an item whose window passed could be reclaimed and run by a
second worker while the first one was also running it. The tool call then
happened twice on the operator's machine.

A heartbeat does not close this. A renewal from a superseded holder is refused
as `not_claimed_by_worker`, which the CLI deliberately treats as a suspicion and
keeps running: right for an item already in flight, whose effect cannot be
un-run, and useless as a guard against starting one.

`POST /v1/x/worker/accept` is that guard. It is fenced on the item still being
claimed by the calling worker, unstopped, and inside its lease window, and only
a success authorizes execution. The lease fence is deliberately stricter than
`heartbeat`'s: a lapsed claim is reclaimable at any instant, so accepting one
would authorize a start on work the queue may hand to another worker in the same
instant, while a renewal cannot cause a second execution. Both a stopped item
and a lapsed lease answer `409` with the engine-neutral `work_lease_lost`,
because the instruction to the worker is the same - do not run it - and the
message says which of the two it was. A foreign holder gets `409` without a
code, since the work is alive for its owner, and an unknown id gets `404`.

A refusal is never a completion: `worker poll` does not run the item and does
not report a result, which would assert an effect that never happened.

Acceptance is recorded in a new `accepted_at` column (migration 046), cleared
when another worker reclaims the item and idempotent for the holder. Existing
rows stay NULL, reading as "held but never started" - the honest back-fill,
since there was no accept step for an earlier-build row to have been accepted
by, and inventing a timestamp would let the queue treat a worker that died
before starting as one that may already have run the item.
2026-09-27 21:23:53 +08:00
Yao 33975de0d3 fix(sandbox): report why a bounded work-item wait gave up (#635)
`WorkQueue.await` ended every failure with "work item <id> timed out", so a
caller could not tell apart three situations that need opposite responses: an
item nobody ever claimed, which is safe to submit again; an item an executor
held and never reported, where the effect may already have happened on the
operator's machine; and an item the session stopped, which is not wanted at all.
Through the session-error path an unrecognized code reported `unknown`, which
that documentation itself calls an invitation to retry the failures that must
not be retried.

The failure now carries a code, classified from the row the wait loop already
read. A stop is reported ahead of the item's state, because a stopped item that
nobody had claimed is still `pending`, and reporting that as "submit it again"
would send work back to a session that has ended. `pending` is the one case that
is provably replayable, since a row becomes `claimed` when it is handed out and
nothing moves it back. Everything else is the unknown outcome, a row that cannot
be read included: absence proves nothing about whether the work ran, and of the
two ways to be wrong, replaying an effect that already happened is the expensive
one.

`retry_status` classifies the two new codes rather than leaving them to fall
through, and a result reported before the deadline still resolves the wait,
stopped work included - the marker says the result is not wanted, not that the
effect did not happen.
2026-09-27 20:19:41 +08:00
Yao e9a07f44d9 fix(sandbox): read the session's status before handing out its work (#633)
`queue.stop()` is reached only through the sandbox release. A terminal session
is allowed to skip that release - `cleanup_pending` does, to retain the
workspace until child-tree cleanup can be proven - and a session that simply
finishes its turn never releases one at all. Work left unmarked in either case
was claimable, so a worker executed a tool call for a session that was over.

The claim predicate now asks the session directly. That also covers rows
already sitting unmarked in an existing database, where the insert-time check
cannot reach. The refusal is part of the selection as well as the guarded
update: refusing a row after selecting it would leave the queue holding work it
cannot hand over, starving workers of the items behind it.

The holder may still report a result that happened - the session ending says
the work is not wanted, not that the effect did not occur. Work for an unknown
session id, and for a status that is merely not running, stays claimable.
2026-09-27 19:00:28 +08:00
Yao 493a8fd0ab fix(sandbox): carry a session's stop into work that arrives later (#632)
`WorkQueue.stop(sessionId)` marks the session's unfinished work, and
`enqueue()` then inserted new rows with `stopped_at` NULL unconditionally.
The stop is a decision about the session, so work that arrived afterwards - a
tool call from a turn that was still in flight, or one enqueued by the second
process this protocol exists to tolerate - was claimable, and a worker
executed it for a session that was already over.

The session's status is now read inside the same statement as the insert, so
there is no window between the check and the write, and the terminal statuses
are derived from the session state machine rather than listed again. An id
with no session row stays claimable: an unknown id is a caller's mistake
rather than a stop. A status that is merely not running stays claimable too -
paused, requires_action, and failed are all resumable, and `failed` has a
transition back to `running`.

Rows that were already queued remain the queue stop's business. The one path
where that leaves a gap - a terminal session that skips its sandbox release,
so nothing calls `queue.stop()` for it - is filed separately as #631.
2026-09-27 18:07:14 +08:00
Yao ffed523b61 feat(cli): stop a worker item when the server says its lease is lost (#629)
The stop was persisted and announced; the worker still ran the command to its end.
2026-09-27 17:09:56 +08:00
Yao 201f67e0f1 test(pi-launcher): wait for a complete result document, not for the path (#627)
* test(pi-launcher): wait for a complete result document, not for the path

The helper returned on existence, so a loaded run could parse a partial file.

* docs(changelog): record the fixture wait fix

Test-only change, recorded like the other test-only entries in the section.
2026-09-27 16:19:27 +08:00
Yao 1fbcdfa2b9 feat(sandbox): answer a renewal on stopped work with work_lease_lost (#626)
Stopping work was persisted but never reached the process executing it.
2026-09-27 15:52:56 +08:00
Yao f0d99c4c60 fix(sandbox): cover claimed work with the session stop marker (#623)
A lapsed claim on stopped work was reclaimable, so an ended session's item could still run.
2026-09-27 15:08:22 +08:00
Yao 6c8dc94a62 feat(sandbox): stop a session's queued work when its sandbox is released (#621)
A pending item outlived its session and stayed claimable, so a worker could run it after the stop.
2026-09-27 14:25:19 +08:00
Yao 9058d8e715 feat(cli): renew the worker claim while a polled item runs (#619)
A 300s item against a 60s lease was reclaimed mid-flight and run twice.
2026-09-27 13:57:01 +08:00
Yao 655644e186 feat(sandbox): let a worker renew its claim lease (#617)
* feat(sandbox): let a worker renew its claim lease

Only the holder can renew, so a long item is not reclaimed mid-flight.

* docs(contracts): register the heartbeat route in the route inventories

A mounted route is enumerated in three places; the gate named all three.

* docs(contracts): register the heartbeat route in the route inventories

A mounted route is enumerated in three places; the gate named all three.
2026-09-27 13:31:50 +08:00
Yao 572a61c650 feat(sandbox): reclaim a work item whose claim lease expired (#615)
claimed_at was written and never read, so a dead worker claim stood forever.
2026-09-27 12:39:26 +08:00
Yao a4a68d08b2 test(sandbox): pin that an unknown work item is 404, not 409 (#613)
* test(sandbox): pin that an unknown work item is 404, not 409

The route has two refusals and only the cross-worker one was driven.

* test(sandbox): pin that an unknown work item is 404, not 409

The route has two refusals and only the cross-worker one was driven. The error-code coverage fixture records the new not_found mention.
2026-09-27 12:09:57 +08:00
Yao 5ae0581127 test(pi): pin recovery of a crashed lease by its timestamp (#611)
An unparseable lease is judged by mtime, and the pass side of that window had no case.
2026-09-27 11:41:28 +08:00
Yao 3c4ca9a820 test(pi): pin that suspending the heartbeat keeps the lease (#609)
* test(pi): pin that suspending the heartbeat keeps the lease

The launcher suspends instead of releasing while a cleanup is pending, so the exclusion has to survive.

* test(pi): report a withdrawn lease before reading it

The existence check runs first so the probe names the missing lease instead of an ENOENT.
2026-09-27 11:18:21 +08:00
Yao d6dd00e909 test(pi): pin that release spares a lease another owner holds (#607)
The owner-id check is what stops a superseded owner from cancelling the new exclusion.
2026-09-27 10:51:20 +08:00
Yao 2d5ab862b9 test(pi): pin that an unreadable lease survives release (#605)
Release removes only a lease whose ownership it can prove by reading it back.
2026-09-27 10:29:50 +08:00
Yao e741be2d6b test(credentials): pin that a keyed credential keeps its excluded position (#603)
A list that was given is respected as written; the empty-location default does not apply to it.
2026-09-27 10:04:40 +08:00
Yao 232e49d3f5 test(credentials): pin that another server's credential is never decrypted (#601)
The server-binding guard sits above the decrypt call; a malformed ciphertext makes reaching it observable.
2026-09-27 09:40:07 +08:00
Yao 67a87005f7 test(api): report the session-resource case failure at its cause (#599)
The memory store and file the case mounts are now asserted where they are created, and the session create names both ids.
2026-09-27 09:17:00 +08:00
Yao a4aa2de22e test(events): pin one projector instance reused across two sessions (#597)
Reconciling one session must not close another session preview; the suite only drove one connection.
2026-09-27 08:36:15 +08:00
Yao cdfd42a7f9 test(model): pin two simultaneously resolved models reporting their own ids (#595)
The existing readback creates and reads each model in one statement, so a single last-resolved slot would satisfy it.
2026-09-27 08:08:42 +08:00
Yao b91a4b8741 test(sandbox): pin one provisioning pass for concurrent callers (#593)
The in-flight map was consulted by no test; sequential reuse resolves before a second caller can arrive.
2026-09-27 07:45:40 +08:00
Yao 7676be26da test(workers): pin the claim scoped to a session and an environment (#591)
The combined-scope branch carries the environment join and was exercised by no test.
2026-09-27 07:22:46 +08:00
Yao bca2c533a9 test(listings): pin the order inside one tie group (#589)
Vaults and memory stores break ties on rowid because created_at is second-granular; no test put two rows in one tie group.
2026-09-27 06:58:20 +08:00
Yao 6c0ad07706 test(deployments): pin how the cron day fields combine (#587)
POSIX selects the union when both day fields are restricted; no test named either field before this.
2026-09-27 06:35:08 +08:00
Yao 209a428630 test(skills): pin one pagination rule on every page of the listing (#585)
The cursor envelope replaced has_more/first_id/last_id; the suite named two of the three and only checked the first page.
2026-09-27 06:12:14 +08:00
Yao 3e485ff33c test(sessions): pin the parked-wait sweep across more than one page (#583)
The re-read-page rule is explained in the sweep but only exercised within a single page: every existing case seeds fewer sessions than the page size.
2026-09-27 05:47:18 +08:00
Yao 7d6303556a test(usage): pin one canonical usage record per model request (#581)
The rule is stated in event-logger.ts but never asserted: the existing suite reports no usage, and the wire assertion accepts any number.
2026-09-27 05:23:19 +08:00
Yao 2d58fc3360 test(security): pin the credential secret envelope properties (#579)
The existing assertions are symmetric: they pass for an unauthenticated mode, a reused nonce, or a regenerated workspace key.
2026-09-27 04:59:50 +08:00
Yao bb1328d711 test(webhooks): verify the delivered signature off the wire (#577)
Every existing signature assertion is self-consistent: the verifier calls the signer, and the secret test compares the signer with itself. The signed content and the decoded key are claims about a receiver.
2026-09-27 04:36:29 +08:00
Yao f2128f43dd test(sessions): pin where deletion lands in the retained log (#575)
Presence is the weakest form of the retention claim. The delete records a termination and then the deletion itself, and the first expectation written for this failed because only one of the two writers was read.
2026-09-27 04:13:18 +08:00
Yao 8008493e2d test(sessions): pin the event log cursor contract (#573)
The forward-only scan, the session-bound cursor, and the refusal of a cursor naming an absent event had no coverage; the last one would otherwise answer a request to continue with the whole log.
2026-09-27 03:46:13 +08:00
Yao 85634c5c28 test(conformance): share one driver across the shape-layer suites (#571)
Two copies of the harness is where they start to disagree, and a difference in the harness reads as a difference in the runtime. The driver contract is asserted rather than described.
2026-09-27 03:05:50 +08:00
Yao df1cf7425b test(memory): pin already_exists for an occupied mount path (#569)
The unique-violation branch has no pre-check by design, and nothing checked that the refused write leaves the first record intact or that the constraint is scoped to the store.
2026-09-27 02:42:57 +08:00
Yao fc3e02fb86 test(api): pin the two wire codes that no test spells (#567)
pi_policy_mismatch and outcome_rubric_file_not_found are asserted only through their imported constants, so both published strings were unpinned: drift the value and every existing assertion still passes.
2026-09-27 02:17:54 +08:00
Yao c1bfb92944 test(api): pin invalid_json on the settings validate route (#565)
The code, the empty path, the warnings array on the failure path and the contrast with a schema-invalid body; the sibling route answers the same fault in a different shape, recorded rather than resolved.
2026-09-27 01:58:04 +08:00
Yao f4df959d04 test(conformance): assert the unserved-path JSON envelope (#563)
The status was asserted; the envelope the fallback comment says clients depend on was not, nor the documented refusal to reflect the requested path. The non-reflection case reads the raw body so it cannot fail for a content-type reason.
2026-09-27 00:55:38 +08:00
Yao 68738a51c5 test(conformance): assert the CMA beta-header resource-family contract (#561)
The path decides which beta a request needs, with one documented exception; the code says what the regex matches, and only a request says whether the exception is reached.
2026-09-27 00:31:46 +08:00
Yao 0d481f4e1f docs(api): record why two Pi transport codes stay unknown and pin it (#559)
Each covers sub-cases with opposite dispositions, so a stated value would be wrong for one of them; the test makes a symmetry-driven entry fail rather than land silently.
2026-09-27 00:10:08 +08:00
Yao a903311522 test(api): pin the pi_rpc_dialog_unsupported wire code and its frame-trust boundary (#558)
The declined-dialog behaviour was covered through the error class, but the published code a client switches on was asserted nowhere, so it could be renamed with every test still green.
2026-09-26 23:48:22 +08:00
Yao 6754083e43 fix(api): report the transport-unretryable Pi failures as not_retryable (#556)
A timeout and an unknown outcome both carry outcomeUnknown, and the transport states the command must not be retried blindly. Unknown invited that retry.
2026-09-26 23:26:48 +08:00
Yao 4cd13256f7 fix(api): state the retry disposition of pi_rpc_protocol_error (#553)
The code was absent from the retry-classification table, so a permanent frame-trust failure was published as unknown, which tells a client it might succeed on retry. The transport requires the opposite.
2026-09-26 23:00:39 +08:00
Yao 20ab2ece17 test(api): pin the pi_rpc_gate_lost wire code and its gate pair (#552)
The unguarded-execution behaviour was covered through the error class, but the published code a client switches on was asserted nowhere, so it could be renamed with every test still green.
2026-09-26 22:22:45 +08:00
Yao 019b6bc100 test(api): pin pi_rpc_outcome_unknown and its unknown-outcome marker (#550)
The code and the marker that survives serialization were asserted nowhere, so losing the marker would silently stop an outcome-unknown failure from being recognized across a boundary.
2026-09-26 21:50:37 +08:00
Yao 60e22de42e test(api): pin pi_rpc_protocol_error and its distance from the retryable codes (#547)
The frame-trust failure had no literal assertion, so drift that merged it with a retryable code would have told clients to resend a frame the runtime always refuses.
2026-09-26 21:27:41 +08:00
Yao f4a5c5eec9 test(api): pin pi_rpc_closed and its distinctness from pi_rpc_session_closed (#545)
* test(api): pin pi_rpc_closed and its distinctness from pi_rpc_session_closed

The transport-closed code had no assertion, so the documented pair a caller branches on for retry disposition could collapse into one code unnoticed.

* test(api): assert the rpc closure pair against the constants, not two literals

The first version compared two literals, so collapsing the pair in rpc-wire.ts could not disconfirm it: the probe passed unchanged. Reviewed after the probe failed to fail, which also required regenerating the measured coverage fixture.
2026-09-26 21:02:25 +08:00
Yao 9fb020cf56 test(api): pin the model_auth_failed wire spelling and its boundary (#543)
The code was asserted only through its constant, so the literal callers switch on could change unnoticed. The new test asserts the spelling, the 401/403 boundary, that 404 stays distinct, and that a status is never guessed from a message.
2026-09-26 20:36:52 +08:00
Yao dd921c8989 test(api): pin model_config_invalid and its resumability (#540)
The model configuration failure carried an unasserted code, and its membership in the resumable model-failure set was untested, so a repairable configuration mistake could silently start terminating sessions.
2026-09-26 20:14:59 +08:00
Yao 64da087ce8 test(api): pin store_unavailable and the store it names (#538)
The memory store-unavailable refusal was asserted by no test, so neither its wire code nor the store identifier in its message was pinned. The new unit test covers both and requires that two stores do not report the same message.
2026-09-26 19:40:29 +08:00