Skip to content

Provider onboarding: a provider is told it cannot run tasks before a member's task dies on it - #56

Merged
kelvin1295 merged 8 commits into
mainfrom
feat/distributed-tasks
Aug 24, 2026
Merged

Provider onboarding: a provider is told it cannot run tasks before a member's task dies on it#56
kelvin1295 merged 8 commits into
mainfrom
feat/distributed-tasks

Conversation

@kelvin1295

Copy link
Copy Markdown
Collaborator

What this is

Seven commits on the task plane. The headline is issue 58: every check of a provider's task
configuration used to run only after a task had been claimed — which is after a member of the
grid is already waiting. grid join printed "serving", grid project status said online, and the
provider looked healthy right up until somebody else's task died on it.

Not a documentation gap: the environment table in docs/cli.md was already complete. The wrong
moment.

The commits

commit what
e300d1c test(provider): prove live publishing by holding a gate, not by a stopwatch
e9c1200 docs(adr): D-k's premise is admits, not domain
dd7dcb4 feat(cli): say when the project no longer holds what a task changed — the CLI half of the relay's changed_since pair
43535f0 test(cli): a paused provider is not a reason to add another one
e1835f6 test(provider): a skipped workspace must not have its reservation released
0d8c394 refactor(provider): one home for the task opt-in (issue 57)
8ec0494 feat(cli): grid join says why it will not serve tasks (issue 58)

Issue 57 — one home for the task opt-in

GRID_TASKS and GRID_MAX_TASKS were read inside remote/serve.py, the serve child's own module.
The CLI parent needs the same two answers before it spawns that child, and cannot import serve for
them — so without this it would read them for itself, and two readings of one rule get edited apart
while every test stays green.

remote/task_opt_in.py holds both, with their reasoning intact. tests/test_task_opt_in.py
enforces the one-reading rule by parsing the source, not by convention — and it measures a
reading, not a mention: task_evict names GRID_MAX_TASKS in its docstring to say what bounds
what, so the scan inspects string literals in code with docstrings excluded, and matches the whole
name (GRID_TASK_ROOT shares a prefix, and a substring match reported the workspace root as a
second opt-in reading).

No behaviour change.

Issue 58 — grid join says why it will not serve tasks

task_agent.preflight_before_serving() asks the same two functions run_task asks — never a
copy of them, so no second opinion about the sandbox, the permission mode or the version floor
exists anywhere — plus one question asked nowhere else: whether this account could create a
workspace under GRID_TASK_ROOT at all. That one is unconditional, because an unwritable root fails
every task with the sandbox on or off. It probes and never creates.

A failing check does not fail the join. The provider serves inference, the answer names the fix
and grid join --respawn, and the child is spawned with the opt-in withheld so it claims nothing it
cannot finish. Refusing the whole join would take a working inference provider down over a task
misconfiguration — including for the operator who keeps GRID_TASKS=1 in a shell profile.

⚠️ The refusal cannot be printed by the serve child, and this is measured. It is spawned
detached with both streams redirected into the engine log, and the parent tails that log only
when the child died. Since a task-serving refusal must not kill the child, anything printed there
lands in a file nobody has a reason to open. The parent is the only place the sentence can be heard.

Test plan

  • .venv/bin/pytest tests/3148 passed, 9 skipped (after merging main down)
  • .venv/bin/ruff check . — clean
  • Measured on a real macOS provider, not only mocked:
    GRID_TASK_ROOT=/var/grid-fresh-provider is refused naming /var; a writable root is allowed
  • Every property of issue 58 mutation-proved — dropping the env override, moving the check after
    the join has acted, narrowing except (Exception, SystemExit) to except Exception, and
    adding a version floor to the new entry point are each killed by the test that claims to cover
    them
  • main merged down into the branch first, so what lands is what was tested (README-only, no
    conflict)
  • Cross-repo assertions in tests/test_task_lease.py SKIP unless grid-src sits beside this
    worktree — they skip in CI, and were run locally against the matching grid-src branch

Rollout

Nothing in these seven commits adds a cross-repo lockstep value. dd7dcb4 is the CLI half of
the relay's changed_since pair — relay-first, and that relay is already deployed, so this is step
two of that rollout.

…pwatch

ND-19. `test_tool_activity_is_published_while_the_agent_is_still_running`
asserted the tool event arrived within 1.5s against a child that slept 3s. It
measured 2.90s under the very load this feature creates — the machine at 9.03
with 29 `claude` processes of its own test round — and read as a regression when
the property it names still held: 2.90s was still inside the child's sleep. The
threshold was wrong, not the publisher. Any absolute bound there measures the
machine as much as the code.

The child now blocks on a gate this test holds shut, so it cannot reach its
result line — let alone exit — while the gate is closed. An event that arrives
in that window arrived while the agent was still running, on any machine at any
load. Both controls are machine-checked: a publisher that buffered to the end
delivers nothing until the child exits and the wait runs out; and `task.result`
must be ABSENT from the same snapshot, which is what proves the child was still
running rather than already finished — something an arrival time can never
establish.

Mutation, to prove the new shape still catches the defect the old one did: hold
every line in the pump until both pipes hit EOF, then replay. The task still
reports `completed` with the right output, and the test fails on the intended
assertion — "no tool activity was published while the child was blocked".

The gate is opened in a `finally`, so a failed assertion costs a second rather
than the run's whole deadline. Also drops 6s of `sleep` from the suite.
ND-21, measured 2026-08-23. D-k's argument was corrected once already — from
"a relay serves one network" to "on a private-domain grid, grid IS domain,
structurally". The second half is false too, and it is false on the supported
topology.

grid-apis `store.member_for_access` checks an allowlist row BEFORE anything
else, on every network type, and grid-src `grid_auth._requires_allowlist` names
three ways onto a private-domain grid — right domain, allowlist row, or owning
the grid — carrying the sentence "an invited account from another domain keeps
working after the switch". An invited outsider is therefore authenticated, so
D-k gives them every non-private project on the grid, imported company
repositories included, plus a `project_members` row minted on their first read
that needs a key.

The relay does not compare domains and cannot: `grid_auth.GridAuthContext`
carries `email`; `relay.AuthContext`, the object a route is handed, does not.
The premise is enforced entirely at the control plane, and the rule there
ADMITS rather than restricts.

Decided: correct the claim, do not narrow the gate. The premise now reads
"authenticated on this grid => a colleague OR somebody a colleague invited", and
the blast radius is accepted with it — the alternative asks the control plane
for a new value saying WHY a caller was admitted, which is a lockstep value and
a rollout order for a boundary that is already live; narrowing would revoke
access grid-apis explicitly promises keeps working; and `visibility` already
exists as the instrument. What is NOT accepted is a justification standing while
being false.

In fairness to the shape being accepted: the minted row IS visible afterwards in
`grid project member list`. Nobody is notified, and access is granted before
anyone looks.

Pinned from the other side by grid-src
`test_project_visibility.TestAnInvitedOutsiderIsAColleagueToThisRule` — a
characterization test, so a future narrowing announces itself in the ADR rather
than in a support ticket. Narrowing is follow-up work, not a defect against this
decision.
ND-16/F-3, the CLI half. The relay now answers `changed_since_count` and
`changed_since_paths` on `GET /tasks/{id}`; `grid task get` turns them into a
sentence, the file names, and the one command that shows what is there now.

The failure it closes is a task that reads like a success everywhere. The grid
applies every finished task to the project itself, and when two collide the step
that combines them runs in the OTHER person's conversation — where the decision
to drop somebody's work is recorded, and where they cannot read it, because
`grid task list --project` returns only the asker's own turns. Measured live
2026-08-20: `state=completed`, `grid task diff` showing their own change, and
the project holding somebody else's line.

Both keys, both type-checked, or it says nothing. Absence is what every relay
deployed before this sends, so a missing or unreadable pair must read as
"nothing to show" and never as a warning invented client side — the rollout is
relay before CLI, so that is the ordinary state of every grid for its duration.
`0` is the relay's positive answer that the work still stands, and it is the
common case: a note that appeared on every task would be furniture within a day.
`bool` is refused explicitly, since it is a subclass of `int` and `True` would
otherwise print "1 of the files ...".

The path in the suggested command goes through `shlex.quote`: a repository that
arrived through `import` holds whatever names its author gave it, and a warning
about lost work whose one next step does not run is worse than none.

A drift on the relay side is SILENT in the direction that costs somebody their
work — the CLI prints nothing for a renamed key exactly as it does for an old
relay — so `tests/test_task_lease.py` pins both names against grid-src's
`get_task`, and CLAUDE.md's register carries the row. It is pinned on `get_task`
and deliberately not on `task_view`: the answer costs two git reads, and
`task_view` also builds every row of `GET /tasks`.

`docs/cli.md` shows the output and says what `--json` carries.
B7.2. Two states look identical from outside — a queue that is not moving — and
they want OPPOSITE actions. Nobody serving means find another machine; everybody
serving and out of subscription headroom means wait, and a second subscription on
the same account would not have helped. Sending a team to `grid join` for the
second is the expensive kind of wrong.

The distinction is two branches of `_print_providers`, and nothing held them
apart. Measured by moving the `grid join` line into the withdrawn sentence: all
three existing cases stayed GREEN, because each asserts what IS said and none
asserts what must not be. The new pair closes it from both ends — the paused
sentence must name the headroom and must NOT name `grid join`, and the fleet with
nobody online must name it, or the first case would pass against a build that
gives no advice at all.
…eased

E4.3, and what it pins is not what the scenario is named for.

The scenario asks for three held workspaces against a cap of one, expecting the
answer *three remain* to be the discriminator. Measured: it is not. Under a sweep
that wrongly counts a skip as progress the answer is still three, because there
is nothing colder to evict — the fault is invisible in this shape, and the two
existing cases catch it precisely because they have a cold workspace to lose.

What no other case catches is a `release()` for a reservation the sweep never
took. That is the expensive one: eviction takes the SAME reservation a worker
takes (`tasks._reserve_workspace`) rather than reading a second registry, so a
spurious release hands away a workspace somebody is running a turn inside. One
registry, one owner, and this is what keeps it that way. Mutation: adding
`release(triple)` to the skip branch kills this case and nothing else.

The `asked` count is the harness's own control — *three remain* is also what a
sweep that never looked at anything produces, which is how a bound that has
quietly stopped working reads.

E4.4 and E4.6 needed nothing: moving the sweep above `ensure_workspace` kills
three cases, two of them pre-existing, and removing the per-store lock kills
`test_conversations_of_one_member_materialize_at_the_same_time_into_one_store`. A
case written for E4.4 here was duplication and was deleted rather than kept.
`GRID_TASKS` and `GRID_MAX_TASKS` were read inside `remote/serve.py`, the
serve child's own module. The CLI parent needs the same two answers before
it spawns that child (issues 58, 60, 61) and cannot ask `serve` for them —
so without this it would read them for itself, and two readings of one rule
get edited apart while every test stays green.

`remote/task_opt_in.py` holds both, with their reasoning intact: why the
opt-in is read at serve time rather than baked into the run record, and why
the worker count has deliberately no upper clamp.

`tests/test_task_opt_in.py` enforces the one-reading rule by parsing the
source rather than by convention. It measures a READING, not a MENTION:
`task_evict` names `GRID_MAX_TASKS` in its docstring to say what bounds
what, so the scan inspects string literals in code with docstrings excluded,
and matches the whole name — `GRID_TASK_ROOT` shares a prefix and a
substring match reported the workspace root as a second opt-in reading.

No behaviour change. 3135 passed, 9 skipped.
Everything that can be wrong in a provider's task configuration was checked
only after a task had been claimed — which is after a member is already
waiting. `preflight()` and `resolve_binary()` had one call site each, both
inside `run_task`. So the join printed "serving", `grid project status` said
online, and the provider looked healthy until somebody else's task died.

Not a documentation gap: the environment table was already complete. The
wrong moment. Issues 22 and 23 made these failures loud; they were loud to
the wrong person.

`task_agent.preflight_before_serving()` asks the same two functions from the
CLI parent — never a copy of them — plus one question asked nowhere else:
whether this account could create a workspace under GRID_TASK_ROOT at all.
That one is unconditional, because an unwritable root fails every task with
the sandbox on or off. It probes and never creates: which directory the root
should be is issue 62's decision.

The parent is the only place the sentence can be heard. The serve child is
detached with both streams redirected into the engine log, and the parent
tails that log only when the child died — so a refusal printed there, which
must not kill the child, lands in a file nobody opens.

A failing check does not fail the join. The provider serves inference, the
answer names the fix and `grid join --respawn`, and the child is spawned with
the opt-in withheld so it claims nothing it cannot finish.

Measured on macOS: GRID_TASK_ROOT=/var/grid-fresh-provider is refused naming
/var, and a writable root is allowed.

3148 passed, 9 skipped.
@kelvin1295
kelvin1295 merged commit 7fcb84a into main Aug 24, 2026
5 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