feat(tasks): allow switching a task's executor before materialization (#3559)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Henry
2026-09-13 22:25:16 +08:00
committed by GitHub
parent 0c2901a788
commit 20308a2d16
103 changed files with 7902 additions and 394 deletions
@@ -0,0 +1,266 @@
---
status: draft
system: tasks
created: 2026-09-07
owners:
- kandev
---
# Runner Switch Before Materialization Action Requirements
## Overview
The runner switch action itself: how a runner change is authorized, serialized,
applied, and refused. The mutability rule the action enforces is in
[Runner switch before materialization](runner-switch-before-materialization.md);
what a successful switch changes, and the surface a user drives it from, are in
[Runner switch before materialization effects](runner-switch-before-materialization-effects.md).
This file exists because the parts together exceed the requirement file-size
limit. The split is by layer, not by importance: every acceptance criterion
keeps the identifier it had when they were one document.
## Requirements
### REQ-TASKS-RUNNER-SWITCH-002: Runner switch action
**Intent:** Changing a task's runner is one authorized, atomic, retry-safe
operation that either moves the runner or explains precisely why it cannot,
and that never damages the rest of the task's metadata.
#### Acceptance criteria
- **AC-TASKS-RUNNER-SWITCH-002.1:** The system shall expose a task action named
`task.runner` accepting a payload with `id` naming the task and
`executor_profile_id` naming the target runner. The action name shall consist
of the task namespace plus exactly one further segment, so that the existing
authorization backstop for top-level task actions applies and promotes `id`
to the task identifier.
- **AC-TASKS-RUNNER-SWITCH-002.2:** The system shall authorize the caller against
the named task before evaluating either gate, at the position
AC-TASKS-RUNNER-SWITCH-002.17 fixes. This authorization shall run for every
caller on every transport, including the WebSocket gateway. The gateway-level
backstop is an additional layer for that one transport and shall never be the
only authorization for the operation: the service shall not vary its
authorization by the transport a request arrived on, and shall not depend on
being able to determine that transport.
- **AC-TASKS-RUNNER-SWITCH-002.3:** When the action executes, the system shall
re-evaluate every condition of AC-TASKS-RUNNER-SWITCH-001.3 inside a single
transaction that is serialized against session creation and task lifecycle
cleanup for the same task.
- **AC-TASKS-RUNNER-SWITCH-002.3a:** The serialization rule is general and is
stated once, because enumerating a subset of the conditions is what lets the
remainder race. **Every writer that can cause any condition of
AC-TASKS-RUNNER-SWITCH-001.3 to begin holding for a task, or that can change
which repository the compatibility gate of AC-TASKS-RUNNER-SWITCH-002.7
resolves against, shall take the same task-scoped lock the switch takes.** The
second clause is not a separate rule: a verdict computed against a repository
the task no longer has is as wrong as a gate condition that began holding
unobserved, and a writer that swaps a repository link in place changes no row
count, so the first clause alone would not reach it. No condition is exempt on
the ground that its row is reached through a session: none of the rows the gate
reads is keyed to a session, and a condition that is only observed rather than
serialized is not enforceable. The guarantee
AC-TASKS-RUNNER-SWITCH-002.13 gives for sessions shall hold identically for
every writer in scope: the switch commits first and the writer proceeds against
the new runner, or the writer commits first and the switch is refused with that
condition's code. A writer added later that can make a gate condition hold, or
can change the resolved repository, falls under this criterion without it being
amended.
- **AC-TASKS-RUNNER-SWITCH-002.3b:** The set of writers in scope for
AC-TASKS-RUNNER-SWITCH-002.3a is finite, is determined by audit rather than by
assumption, and shall be enumerated so the obligation is bounded and testable
rather than left for whoever implements it to discover. A writer satisfies the
rule in one of two ways, and the distinction is what bounds the work:
1. **Already satisfied, no change required** — the writer updates the task's own
row, and so takes that row's lock inherently, or takes the task-scoped lock
explicitly. Archiving the task, writing the task's host workspace path, and
writing the task's workspace mode are all writes to the task row. Session
creation and task environment creation take the task-scoped lock explicitly.
2. **Requires the lock to be added** — the writer writes a different table and
takes no task-scoped lock today. This capability shall bring each of these
under the same lock: the running-executor record write (condition 6); the
workspace source attachment that creates repository links and workspace
folder attachments (conditions 2, 3 and 7), which today takes the lock only
when it is also checking an expected parent and therefore takes none at all
for a task with no parent — the shape this capability most commonly serves;
the workspace group membership insert (condition 9), which is owned by the
office system rather than the task system; and the in-place update of an
existing task-repository link, which is in scope under the second clause of
AC-TASKS-RUNNER-SWITCH-002.3a.
Each writer in class 2 shall have its own acceptance evidence: a demonstration
that it and a concurrent switch produce one of the two outcomes
AC-TASKS-RUNNER-SWITCH-002.3a permits and never a third. For the three writers
that make a gate condition begin holding, those two outcomes are the ones that
criterion names: switch first and the writer proceeds against the new runner, or
writer first and the switch is refused with that condition's code. The in-place
link update has no condition code, because it makes no condition hold; its two
outcomes are that the switch commits first and the link update then applies, or
the link update commits first and the switch is refused as the retriable
`evaluation_unavailable` of AC-TASKS-RUNNER-SWITCH-002.7c. Both are outcomes the
closed vocabulary already contains; this criterion introduces none. A writer in class 1
shall have the same evidence, but needs no code change to produce it. An
implementation that changes no writer cannot satisfy this criterion, and one
that reports the rule as met without per-writer evidence has not met it.
- **AC-TASKS-RUNNER-SWITCH-002.4:** When a switch succeeds, the only task
metadata that changes shall be the stored executor profile identifier. Every
other metadata entry, including the agent profile identifier and any deferred
launch record, shall be unchanged afterwards.
- **AC-TASKS-RUNNER-SWITCH-002.5:** When a switch is rejected for any reason,
the system shall persist no change to the task.
- **AC-TASKS-RUNNER-SWITCH-002.6:** When a switch is rejected because the
mutability gate fails, the system shall return a typed conflict carrying the
same reason code, chosen by the same ordering, that
AC-TASKS-RUNNER-SWITCH-001.3 defines.
- **AC-TASKS-RUNNER-SWITCH-002.7:** When the mutability gate passes but the
target runner requires a git clone URL and the task's repository provides
none, the system shall reject the switch with the typed conflict
`target_cannot_materialize_repository`. This code shall never appear in the
projection defined by REQ-TASKS-RUNNER-SWITCH-001, because it describes the
target rather than the task.
- **AC-TASKS-RUNNER-SWITCH-002.7a:** Clone-URL resolution shall distinguish a
determinate answer from a failed lookup. A candidate that is absent, empty, or
whitespace-only supplies no URL and resolution continues to the next candidate.
Only an exhausted candidate list shall reject with
`target_cannot_materialize_repository`. A candidate that cannot be evaluated,
because the lookup errored or timed out, shall reject with
`evaluation_unavailable` per AC-TASKS-RUNNER-SWITCH-002.18. A transient failure
to read a repository's origin shall never be reported as a permanent statement
that the repository cannot be moved.
- **AC-TASKS-RUNNER-SWITCH-002.7b:** The compatibility gate shall not be
evaluated while the task row lock is held. The target profile's executor, its
clone-URL requirement, and any repository clone URL shall be resolved before
the transaction of AC-TASKS-RUNNER-SWITCH-002.3 opens, so no filesystem or
subprocess call occurs inside the locked section. Resolution shall be bounded
by a timeout; exceeding it is a failed lookup under
AC-TASKS-RUNNER-SWITCH-002.7a. What may be resolved early is only what the
filesystem or a subprocess is needed for; which repository the task has is a
task-scoped fact and is governed by AC-TASKS-RUNNER-SWITCH-002.7c, not by this
criterion. Resolving early does not move the decision: the
resolved result is applied at stage 6 of AC-TASKS-RUNNER-SWITCH-002.17, so a
task that fails the mutability gate reports that gate's code even when the
target is also incompatible.
- **AC-TASKS-RUNNER-SWITCH-002.7c:** A compatibility verdict is only valid for
the repository it was computed against, because it is resolved before the
transaction that validates which repository the task has. The system shall
record the identity of the repository the resolution used, and inside the
transaction, after the mutability gate has passed, shall confirm that the
task's single attached repository is still that same repository. This
confirmation shall compare both the repository identity and the link's
last-updated column, `task_repositories.updated_at`, against the values
observed at resolution time, so that a link mutated in place is detected even
though its identity column may not have moved. This confirmation is a
task-scoped read of two columns and involves no filesystem or subprocess call,
so it does not violate AC-TASKS-RUNNER-SWITCH-002.7b. It is a second line of
defence rather than the primary one: the writer that mutates the link is
itself in scope for AC-TASKS-RUNNER-SWITCH-002.3a, and the confirmation exists
so that a writer added outside that rule is still caught. When the repository has changed, the resolved
verdict shall be discarded and the request shall reject as
`evaluation_unavailable` under AC-TASKS-RUNNER-SWITCH-002.18, which is
retriable, rather than applying a verdict computed against a repository the
task no longer has. When the task does not have exactly one repository
attachment at resolution time, resolution shall be skipped and shall yield no
verdict; the request then reports `no_repository` or `multiple_repositories`
from the mutability gate at stage 5, and skipping shall never itself produce an
outcome. This keeps the ordering of AC-TASKS-RUNNER-SWITCH-002.17 intact: the
compatibility gate can only ever speak at stage 6.
- **AC-TASKS-RUNNER-SWITCH-002.8:** An identifier in this payload is **blank**
when it is absent, empty, or contains only whitespace. That single definition
governs every identifier the action accepts, so `id` and `executor_profile_id`
are judged by the same test and a whitespace-only value is never treated as a
value that could be looked up. When either identifier is blank the system shall
reject the request as invalid. A blank `executor_profile_id` shall never be
interpreted as clearing the task's runner, and a blank `id` shall be reported
as invalid rather than as not found.
- **AC-TASKS-RUNNER-SWITCH-002.9:** When the payload names a task that does not
exist, the system shall reject the request as not found, distinctly from every
conflict code.
- **AC-TASKS-RUNNER-SWITCH-002.10:** When the payload names an executor profile
that does not exist, or one whose executor is not available for use, the
system shall reject the request as invalid, distinctly from every conflict
code. An executor is available for use when it is not soft-deleted **and** its
status is active; failing either test makes it unavailable, and both produce
the same invalid outcome.
- **AC-TASKS-RUNNER-SWITCH-002.11:** When a switch names the executor profile
the task already stores, the system shall report success, shall leave the
stored value unchanged, and shall publish a task-updated event only when the
stored value actually changed. This success is reported only after both gates
have passed; naming the already-stored profile shall not bypass either. A task
that has since materialized therefore receives the gate's conflict code, not
success, even when the requested profile equals the stored one.
- **AC-TASKS-RUNNER-SWITCH-002.12:** When two switches for the same task run
concurrently, the system shall serialize them on the task. Both may succeed;
the stored runner afterwards shall be the one written by the transaction that
committed second, and no intermediate or blended metadata state shall be
observable.
- **AC-TASKS-RUNNER-SWITCH-002.13:** When a switch and a session creation for
the same task run concurrently, the system shall produce exactly one of two
outcomes: the switch commits first and the session is then prepared with the
new runner, or the session commits first and the switch is rejected with
`session_exists`. The system shall never leave a changed runner alongside a
session that was prepared under the previous runner.
- **AC-TASKS-RUNNER-SWITCH-002.14:** When a caller repeats the same switch after
an ambiguous failure, without supplying any idempotency token, and the task's
materialization state has not changed in between, the system shall reach the
same terminal state as a single successful call. The action assigns a value
rather than accumulating one, so retries need no deduplication key. When the
task did materialize in between, the repeat is refused by the gate per
AC-TASKS-RUNNER-SWITCH-002.11; the stored runner remains the one the first call
committed, so only the reported outcome differs.
- **AC-TASKS-RUNNER-SWITCH-002.15:** When a switch and an archive of the same
task run concurrently, the system shall serialize them: either the switch
commits before the archive, or the switch is rejected with `task_archived`. A
switch shall never commit against an archived task.
- **AC-TASKS-RUNNER-SWITCH-002.16:** The comparison in
AC-TASKS-RUNNER-SWITCH-002.11 shall be against the value the task stores, not
against the runner the task would otherwise resolve. Storing an explicit
executor profile on a task that previously stored none is a change even when
the named profile is the one the workspace default would have selected.
- **AC-TASKS-RUNNER-SWITCH-002.17:** When more than one rejection class applies
to the same request, the system shall report the outcome of the **first**
failing stage in exactly this order, so that a given request always produces
the same outcome regardless of implementation:
1. **Malformed request** (invalid): `id` or `executor_profile_id` blank in the
sense AC-TASKS-RUNNER-SWITCH-002.8 defines. Evaluated before any lookup, so
a blank identifier never reaches the not-found or target-invalid stages.
2. **Task not found** per AC-TASKS-RUNNER-SWITCH-002.9.
3. **Caller not authorized** per AC-TASKS-RUNNER-SWITCH-002.2, after existence
so the outcome matches the other top-level task actions.
4. **Target invalid** per AC-TASKS-RUNNER-SWITCH-002.10.
5. **Mutability gate** per AC-TASKS-RUNNER-SWITCH-002.6, itself ordered by
AC-TASKS-RUNNER-SWITCH-001.3.
6. **Compatibility gate** per AC-TASKS-RUNNER-SWITCH-002.7.
7. **Assignment**, including the no-op of AC-TASKS-RUNNER-SWITCH-002.11.
An evaluation failure under AC-TASKS-RUNNER-SWITCH-002.18 may arise at stages
2 and 4 through 7 and preempts the outcome of the stage where it occurs. Stage
7 is included because that criterion covers the write and the commit, not only
the reads: a request that passed every gate and then failed to commit reports
the retriable outcome rather than a success or a conflict.
- **AC-TASKS-RUNNER-SWITCH-002.18:** When the system cannot complete an
operation it needs in order to decide or to apply the request, the action shall
reject with a typed `evaluation_unavailable` outcome, distinct from every
mutability conflict code, from `target_cannot_materialize_repository`, and from
the invalid and not-found outcomes. It shall be marked retriable and shall
persist no change per AC-TASKS-RUNNER-SWITCH-002.5. **This covers writes and
not only reads:** a failure to acquire the task-scoped lock, a failure of the
metadata write, and a failure to commit the transaction all produce this
outcome, exactly as a failed read does. Naming only reads would leave the write
path with no outcome class at all, and the caller would receive something
outside the vocabulary AC-TASKS-RUNNER-SWITCH-004.4b is required to present.
Such a failure shall never be reported as a mutability conflict: a conflict
says the task is ineligible, a failed operation only that the system could not
complete it. A caller that receives it may repeat the request under
AC-TASKS-RUNNER-SWITCH-002.14, which is why this outcome and only this outcome
is retriable.
- **AC-TASKS-RUNNER-SWITCH-002.19:** A task-updated event for a runner change
shall carry the task's updated-at value from the committing transaction, and a
client shall ignore an event whose updated-at is older than the value it holds
for that task. Clients receiving two switches' events out of order shall
converge on the value written by the transaction that committed second, per
AC-TASKS-RUNNER-SWITCH-002.12. Ordered delivery is not required; a late event
must not overwrite a newer one.
@@ -0,0 +1,169 @@
---
status: draft
system: tasks
created: 2026-09-07
owners:
- kandev
---
# Runner Switch Before Materialization Effects Requirements
## Overview
What a runner switch changes, and how a user drives it. The decision contract
itself is split across two neighbours: the mutability projection is in
[Runner switch before materialization](runner-switch-before-materialization.md)
and the switch action is in
[Runner switch before materialization action](runner-switch-before-materialization-action.md).
This file exists because the parts together exceed the requirement file-size
limit. The split is by layer, not by importance: every acceptance criterion
keeps the identifier it had when they were one document.
## Requirements
### REQ-TASKS-RUNNER-SWITCH-003: Effect of a switch on the next launch
**Intent:** A successful switch is observable in exactly one place, the next
session the task prepares, and nowhere else.
#### Acceptance criteria
- **AC-TASKS-RUNNER-SWITCH-003.1:** When a task's runner has been switched and
the task then prepares its first session, the system shall bind that session
to the executor profile named by the switch and to the executor that profile
belongs to. Some launch paths name an executor profile explicitly in the launch
request rather than leaving it to be read from the task; the task edit dialog is
one of them. Where a launch is issued by the same interaction that performed the
switch, the profile it names shall be the switched-to profile, so that an
explicitly named profile can never disagree with the value the switch just
committed. A launch issued by some other caller that names a profile explicitly
is unchanged by this capability and continues to use the profile it names; this
criterion constrains only the launch that shares an interaction with a switch,
because that is the only one whose disagreement the switch itself would cause.
- **AC-TASKS-RUNNER-SWITCH-003.2:** When a switch succeeds, the system shall not
create, modify, or remove any session, task environment, worktree, container,
or running-executor record.
- **AC-TASKS-RUNNER-SWITCH-003.3:** When a switch changes the stored runner, the
system shall publish a task-updated event whose payload carries both the new
executor profile and the recomputed values required by
AC-TASKS-RUNNER-SWITCH-001.1.
- **AC-TASKS-RUNNER-SWITCH-003.3a:** Publication of that event is best-effort and
is not part of the committed transaction. A publication that fails shall not
roll back, retry, or otherwise alter the committed switch, and shall not be
reported to the caller as a failed switch; the switch succeeded and the stored
runner is the new one. Because a repeat of the request is a no-op under
AC-TASKS-RUNNER-SWITCH-002.11 and therefore publishes nothing, the event shall
not be the only way a client can learn the new value: any subsequent read of
the task shall carry the committed runner and the recomputed verdict, so a
client that missed the event converges on its next read of that task rather
than staying wrong until something unrelated refreshes it. No durable outbox,
replay log, or delivery receipt is required by this contract.
- **AC-TASKS-RUNNER-SWITCH-003.4:** When the task's workflow step pins an agent
profile, that pin shall continue to govern the agent profile only. It shall not
override the executor profile a switch stored.
- **AC-TASKS-RUNNER-SWITCH-003.5:** When a task with a parent has a stored
executor profile because of a switch, the launch shall use that profile and
shall not fall back to inheriting the parent's runner.
### REQ-TASKS-RUNNER-SWITCH-004: Runner editing surface
**Intent:** The runner picker that already exists in the task dialog becomes
effective when editing a task, and it is gated by the server's verdict rather
than by a client-side approximation of it.
#### Acceptance criteria
- **AC-TASKS-RUNNER-SWITCH-004.1:** When a user edits a task whose projected
`runner_editable` is `true`, the task dialog shall offer the executor profile
selector, and saving a changed selection shall perform the runner switch.
- **AC-TASKS-RUNNER-SWITCH-004.2:** When a user edits a task whose projected
`runner_editable` is `false`, the dialog shall not offer the selector for
editing and shall present the projected reason to the user.
- **AC-TASKS-RUNNER-SWITCH-004.3:** The dialog shall decide whether the runner
is editable only from the projected verdict. It shall not decide editability
from the task's workflow state, and the previous state-only gate shall no
longer govern the runner selector.
- **AC-TASKS-RUNNER-SWITCH-004.4:** When a switch is rejected, the dialog shall
remain open, shall present the returned reason, and shall not show the
rejected selection as if it had been applied.
- **AC-TASKS-RUNNER-SWITCH-004.4a:** When one save changes the executor profile
as well as other task fields, the dialog shall issue the runner switch **first**
and the remaining updates only after it succeeds. When the switch is rejected
the dialog shall issue no other update, shall leave every other edited field
unsaved and still populated, and shall report that nothing was saved. A
rejected switch shall never leave part of the save applied.
- **AC-TASKS-RUNNER-SWITCH-004.4c:** The save is an ordered sequence of
independent calls and is not atomic, so the rule covers the whole sequence
rather than only its first step. When any call fails, the dialog shall stop at
that call and issue none of the calls after it; it shall leave the fields those
later calls would have saved unsaved and still populated; and it shall report
which part of the save was applied and which was not, rather than reporting a
single undifferentiated success or failure. A call that already committed shall
not be reversed, because no compensating action exists for it: a committed
runner switch stays committed even when a later field update fails, and the
dialog shall present that state truthfully instead of implying the whole save
was rejected. Retrying such a save is safe in the sense that it cannot
double-apply the switch, because the switch is a value assignment rather than an
accumulation (AC-TASKS-RUNNER-SWITCH-002.11, AC-TASKS-RUNNER-SWITCH-002.14). It
is not guaranteed to succeed: if a later call in the failed sequence materialized
the task, the repeated switch is refused by the mutability gate with that
condition's code even though the stored runner is already the intended one. The
dialog shall present that refusal as the gate outcome it is, and shall not
present it as the switch having failed to apply.
- **AC-TASKS-RUNNER-SWITCH-004.4b:** When the outcome carries no reason code,
being invalid, not-found, or evaluation-unavailable rather than a typed
conflict, the dialog shall present text specific to that outcome class. An
evaluation-unavailable outcome shall be presented as retriable and shall keep
the user's selection; because AC-TASKS-RUNNER-SWITCH-002.18 covers a failed write
or commit as well as a failed read, a switch that could not be applied arrives in
this class rather than in a fourth one, and no further outcome class exists for
the dialog to handle. The dialog shall never render an empty reason or a raw
code.
- **AC-TASKS-RUNNER-SWITCH-004.4d:** A save that also starts an agent issues a
session launch, and that launch is a member of the ordered sequence
AC-TASKS-RUNNER-SWITCH-004.4c governs, not something outside it. It shall be the
**last** call in the sequence, because it is the only one that materializes the
task and therefore the only one that can make an earlier call in the same
sequence irreversible in effect. When the runner switch is rejected, the dialog
shall issue no launch, for the same reason it issues no field update: a launch
would materialize the task under the runner the user was just refused permission
to change. When the switch succeeds, the launch shall carry the switched-to
profile per AC-TASKS-RUNNER-SWITCH-003.1.
- **AC-TASKS-RUNNER-SWITCH-004.5:** When the user saves without having changed the
executor profile selection, the dialog shall issue no runner switch. **Changed
means changed by the user in this editing session.** A value the dialog placed in
the selector itself, whether by resolving a default, by restoring a remembered
preference, or by any other seeding it performs on open, is not a user change,
and the dialog shall not issue a switch on account of it. Value inequality against
the stored runner is therefore not the test: for a task that stores no executor
profile at all, a seeded selection necessarily differs from the stored value, and
treating that difference as a change would make an untouched save silently store
an explicit runner. AC-TASKS-RUNNER-SWITCH-002.16 makes that consequential, since
storing where nothing was stored is a real change that pins the task against any
later change of the resolved default.
- **AC-TASKS-RUNNER-SWITCH-004.5a:** When a task stores no executor profile, the
dialog shall present the runner the task would resolve to if launched now, and
shall present it as a resolved default rather than as a stored choice, so a user
can see what will happen without that display constituting a decision. The task
shall remain editable in this state per AC-TASKS-RUNNER-SWITCH-001.11: the user
may choose a runner explicitly, and choosing one that differs from the seeded
value shall issue a switch.
- **AC-TASKS-RUNNER-SWITCH-004.5b:** When the user's final selection is the value
the dialog seeded, the dialog shall issue no runner switch, whether the user
never touched the control or changed it and changed it back. The comparison is
against the seeded value, not against the history of interactions, so the rule
has one observable form and does not depend on tracking intermediate states. The
consequence is deliberate: a user cannot use this surface to pin the resolved
default as an explicit stored choice, because the dialog cannot distinguish that
intent from a change reverted. Silently converting a resolved default into a
durable pin is the worse of the two failures, and it is the one
AC-TASKS-RUNNER-SWITCH-002.16 would make permanent, so this criterion chooses
against it. Explicit default-pinning is named in `## Out of scope`.
- **AC-TASKS-RUNNER-SWITCH-004.6:** Desktop and mobile shall use the same gate,
the same reason text, and the same interaction. This capability introduces no
new layout and no new mobile interaction pattern.
- **AC-TASKS-RUNNER-SWITCH-004.7:** Every user-visible string this capability
introduces, including each reason code's presentation, shall be available in
all shipped locales.
@@ -0,0 +1,211 @@
---
status: draft
system: tasks
created: 2026-09-07
owners:
- kandev
---
# Runner Switch Before Materialization Requirements
## Overview
A task's runner is chosen once, when its first session is prepared, and frozen
on that session from then on. Because the workspace default executor is normally
the worktree executor, every task starts there. Moving a task today means editing
stored task metadata by hand, deleting the session, discarding the task
environment, and forcing a new session: two of those steps destroy durable state,
and none is reachable from the product.
This capability lets a user change the runner of a task that has not yet
materialized anything, without destroying state, because nothing physical exists
to destroy. It answers two independent questions and keeps them separate:
- **Is this task's runner still mutable?** A property of the task alone.
- **Can a particular target runner actually run this task?** A property of the
target paired with the task's repository.
Conflating them is what makes the rule drift, so the first is projected on the
task and the second is enforced when a target is named.
## Terminology
- **Runner:** the executor profile a task will use, with the executor that
profile belongs to.
- **Materialized:** the task owns at least one durable runtime artifact: a
session, a task environment, a running-executor record, an attached workspace
folder, or a shared workspace group membership.
- **Mutability gate:** the ordered conditions in AC-TASKS-RUNNER-SWITCH-001.3
deciding whether the runner can change at all. Depends only on the task.
- **Compatibility gate:** the condition in AC-TASKS-RUNNER-SWITCH-002.7 deciding
whether a named target runner can materialize this task's repository. Depends
on the target.
- **Runner switch:** one application of the runner action to one task.
## Requirements
### REQ-TASKS-RUNNER-SWITCH-001: Runner mutability projection
**Intent:** A client learns whether a task's runner can be changed from one
server-derived verdict, so the rule lives in one place and cannot drift into a
second implementation in the client.
**User story:** As a user looking at a task I have not started, I want to see
whether I can still change its runner, so that I do not attempt a change the
system will refuse.
#### Acceptance criteria
- **AC-TASKS-RUNNER-SWITCH-001.1:** When the system projects a task to any
client, the projection shall carry a boolean `runner_editable` and a string
`runner_ineligible_reason`. Both fields shall always be present, including
when `runner_editable` is `true` and when `runner_ineligible_reason` carries
the eligible value. Neither field is omitted on any projection path.
`runner_ineligible_reason` shall always hold a member of the closed vocabulary;
the empty string is never a permitted value.
- **AC-TASKS-RUNNER-SWITCH-001.1a:** When a projection path emits a task without
having run the mutability evaluation at all, it shall emit `runner_editable` as
`false` and `runner_ineligible_reason` as `evaluation_unavailable`. This covers
a path that never attempted an evaluation, where
AC-TASKS-RUNNER-SWITCH-001.8 covers one that ran and failed. Such a path shall
never emit `true` and never emit an empty reason. This is a backstop, not a
licence: each of the four paths named in AC-TASKS-RUNNER-SWITCH-001.5 shall
perform the evaluation, so this criterion governs only paths outside that
set.
- **AC-TASKS-RUNNER-SWITCH-001.2:** When no condition in
AC-TASKS-RUNNER-SWITCH-001.3 holds for a task, the system shall project
`runner_editable` as `true` and `runner_ineligible_reason` as `eligible`.
- **AC-TASKS-RUNNER-SWITCH-001.3:** When at least one of the following
conditions holds for a task, the system shall project `runner_editable` as
`false` and shall project the reason code of the **first** condition that
holds, evaluated in exactly this order. The order is part of the contract, so
a task that trips several conditions always reports the same code:
1. `task_archived`: the task is archived.
2. `no_repository`: the task has no repository attachment.
3. `multiple_repositories`: the task has more than one repository attachment.
4. `session_exists`: the task has at least one session, in any state,
including terminal states.
5. `environment_exists`: the task has at least one task environment, in any
status, including a status that indicates creation in progress or failure.
6. `executor_running`: the task has at least one running-executor record, in
any status.
7. `workspace_folder_attached`: the task has at least one attached workspace
folder.
8. `workspace_path_set`: the task declares a non-empty host workspace path.
9. `workspace_group_member`: the task holds a workspace group membership that
has not been released.
10. `workspace_binding_not_independent`: the task's workspace binding is not
independent. A task with no parent is independent unless it declares a
shared-group workspace mode. A task with a parent is independent only when
it declares the new-workspace mode explicitly; an absent, inherited, or
shared-group mode is not independent.
- **AC-TASKS-RUNNER-SWITCH-001.4:** When the same task is evaluated repeatedly,
every evaluation completes, and no persisted state changed between them, the
system shall project the same `runner_editable` and `runner_ineligible_reason`
values every time. Determinism is a property of the evaluation, not of the
infrastructure under it: an evaluation that could not complete reports
`evaluation_unavailable` per AC-TASKS-RUNNER-SWITCH-001.8, and a later
evaluation of unchanged state that does complete reports the real verdict
instead. That difference is not a violation of this criterion, because one of
the two evaluations did not happen.
- **AC-TASKS-RUNNER-SWITCH-001.5:** When one task is read individually, within a
list, within a board snapshot, and within a task event payload, and the
evaluation completes on each, the system shall project the same
`runner_editable` and `runner_ineligible_reason` values on all four paths for
the same persisted state. As in AC-TASKS-RUNNER-SWITCH-001.4, a path whose
evaluation did not complete reports `evaluation_unavailable` under
AC-TASKS-RUNNER-SWITCH-001.8a and is not counted as a disagreement; the
criterion constrains the rule the four paths apply, not the availability of the
reads beneath any one of them.
- **AC-TASKS-RUNNER-SWITCH-001.6:** When a task is queued behind a work-in-
progress limit and has not been admitted, the system shall evaluate its
runner mutability exactly as it would for an admitted task; queuing alone
shall not make the runner immutable.
- **AC-TASKS-RUNNER-SWITCH-001.7:** When a task has a parent, the system shall
not project `runner_editable` as `false` for that reason alone; only condition
10 of AC-TASKS-RUNNER-SWITCH-001.3 governs the parent relationship.
- **AC-TASKS-RUNNER-SWITCH-001.8:** When the system cannot complete the
evaluation because a required read fails, it shall project `runner_editable`
as `false` and `runner_ineligible_reason` as `evaluation_unavailable` rather
than omitting the fields or projecting `true`.
- **AC-TASKS-RUNNER-SWITCH-001.8a:** The fail-closed substitution of
AC-TASKS-RUNNER-SWITCH-001.8 shall apply per task, to exactly those tasks whose
own evaluation could not be completed, however many tasks the failing read
covered. When a projection evaluates several tasks together and one read fails,
a task whose verdict does not depend on the failed read shall carry its real
verdict, and only the tasks whose verdict does depend on it shall carry
`evaluation_unavailable`. A batched read that covers every task in the
projection and fails therefore degrades every task in it, and a read that
covers some degrades only those; in both cases the rule is the same one. This
does not conflict with AC-TASKS-RUNNER-SWITCH-001.5, which constrains paths
reading the same persisted state: a transient read failure is not persisted
state, so a task may carry `evaluation_unavailable` on one path and its real
verdict on another at the same moment.
- **AC-TASKS-RUNNER-SWITCH-001.9:** When a client merges a task payload that
does not carry `runner_editable`, the client shall treat the runner as not
editable and shall not retain a previously cached `true`. Unlike the derived
executor identity fields, which are gap-filled from cache, this verdict fails
closed: a stale `true` offers an action the server will refuse, while a stale
`false` only hides one a refresh restores.
- **AC-TASKS-RUNNER-SWITCH-001.9a:** When a payload's `runner_editable` is
`false` but its `runner_ineligible_reason` is absent, empty, or outside the
closed vocabulary, the client shall treat the reason as
`evaluation_unavailable`. It shall not display an unrecognized code and shall
not fall back to a cached reason.
- **AC-TASKS-RUNNER-SWITCH-001.10:** When a task is read while a runner switch
for that task is in flight, the projection shall reflect either the state
before the switch or the state after it, and the projected executor profile
and the projected verdict in one payload shall describe the same state.
- **AC-TASKS-RUNNER-SWITCH-001.11:** When a task has never stored an explicit
executor profile and would resolve its runner from the workspace default, the
system shall evaluate its runner mutability normally and shall be able to
project it as editable.
- **AC-TASKS-RUNNER-SWITCH-001.12:** The evaluation shall not read the task's
workflow state, workflow step, or priority. A task in any state, including a
terminal one, that has materialized nothing shall be projected as editable.
REQ-TASKS-RUNNER-SWITCH-002 (the runner switch action) lives in
[Runner switch before materialization action](runner-switch-before-materialization-action.md).
REQ-TASKS-RUNNER-SWITCH-003 (effect of a switch on the next launch) and
REQ-TASKS-RUNNER-SWITCH-004 (the runner editing surface) live in
[Runner switch before materialization effects](runner-switch-before-materialization-effects.md).
They were split out to keep each file within the requirement file-size limit;
the contract is unchanged and every acceptance criterion keeps its identifier.
## Out of scope
- Family tasks, excluded by condition 10 of AC-TASKS-RUNNER-SWITCH-001.3.
Moving one requires deciding what happens to its siblings, a different
contract.
- Multi-repository tasks, excluded by condition 3. A multi-repository move must
decide per-repository materialization, which this capability does not model.
- Any change to the session-ensure behavior that prepares a session when a task
with no session is opened. The typed conflict in
AC-TASKS-RUNNER-SWITCH-002.13 is deliberately the backstop for that race,
rather than a new gate on session preparation.
- Pre-filtering the runner picker by the compatibility gate. An incompatible
target is rejected on save with `target_cannot_materialize_repository` rather
than hidden. Hiding it needs a per-target verdict for every offered profile, a
second projection contract; this capability defines only the per-task one.
- Narrowing the generic task-metadata replacement surfaces. They can still
overwrite the stored executor profile wholesale, because they replace the whole
metadata document. Constraining them would change the contract for every
metadata key and belongs to whichever change takes that on.
- Changing the workspace default executor, or how a task's initial runner is
chosen at creation; owned by
[Task Create Executor Default](task-create-executor-default.md).
- Moving a runner after materialization, and any migration of an existing
session, environment, or worktree between runners.
- Pinning the resolved default as an explicit stored choice from the edit dialog.
A task that stores no executor profile is shown the runner it would resolve to,
and selecting that same value is treated as no change
(AC-TASKS-RUNNER-SWITCH-004.5b), because the dialog cannot tell a deliberate pin
from a change reverted. Offering it would need a control that expresses "pin this
default" as distinct from "select this profile", which is a surface decision this
capability does not make. Anything taking it on needs to know that the switch
action itself already supports the write — AC-TASKS-RUNNER-SWITCH-002.16 makes
storing the default an ordinary change — so the missing piece is the control and
its copy, not the contract underneath it.
- Bulk runner switching across several tasks.
@@ -0,0 +1,430 @@
---
status: draft
system: tasks
requirements:
- REQ-TASKS-RUNNER-SWITCH-001
- REQ-TASKS-RUNNER-SWITCH-002
- REQ-TASKS-RUNNER-SWITCH-003
- REQ-TASKS-RUNNER-SWITCH-004
---
# Runner Switch Before Materialization System Design
## Purpose and boundaries
The task system owns task metadata, the point at which a launch resolves an
executor, and the durable rows that record what a task has materialized. All of
the state this capability reads and the single value it writes are task-owned,
so the task system owns this contract.
Two adjacent contracts are consumed, not owned:
- The executor system owns executor profiles, executors, and the per-executor
capability that says whether a runtime needs a git clone URL. The compatibility
gate calls that capability; it does not enumerate executor types.
- The workspace system owns repositories, worktrees, and workspace groups. The
mutability gate reads task-scoped rows only and does not reach into workspace
ownership rules.
- The office system owns shared workspace groups and their membership rows. This
is a boundary the capability genuinely crosses rather than merely consumes: the
membership insert is one of the writers AC-TASKS-RUNNER-SWITCH-002.3b requires
to take the task-scoped lock, so satisfying the concurrency contract means
changing office-owned code, not only task-owned code. Naming it here is what
keeps that from being discovered during implementation.
## Prior art
**Wiki leg (our own prior reasoning).** Receipt: searched for a `wiki-query`
skill in `~/.claude/skills/`, `.claude/skills/`, and the repository's
`.agents/skills/`; searched `~/.claude` and `~/.claude-kandev` for any path
matching `*wiki*`; checked for `~/.obsidian-wiki/config`; checked `PATH` for
`wiki-query`, `wiki-switch`, and `qmd`. None of these exist in this environment,
so no `OBSIDIAN_VAULT_PATH` could be resolved and no QMD collection was queried.
This leg is unavailable, not empty. In its place the repository's own durable
record was searched: `docs/specs/tasks/` and `docs/specs/executors/` indexes,
and every requirement naming an executor. That search produced one directly
adjacent position, described below.
**In-repo prior reasoning.**
[Task Create Executor Default](../requirements/task-create-executor-default.md)
already settled how a task's *initial* runner is chosen, and settled it in a
direction this design deliberately extends rather than contradicts: a
repository-backed task must start in an isolated workspace unless an explicit
contrary choice is made, and a portable last-used preference must never silently
switch the executor. That decision is why every card starts on the worktree
runner, which is the condition this capability exists to relieve. It is also why
this design does not change any default: the existing requirement's position is
that defaults are safety, and an explicit per-task move is the correct escape
hatch rather than a looser default.
[Kanban task cache preserves executor fields across merges](../requirements/kanban-task-executor-cache-staleness.md)
is the second position consulted. It establishes that derived, omit-when-empty
executor fields get blanked out by client cache merges, and that the fix is to
gap-fill from cache. AC-TASKS-RUNNER-SWITCH-001.9 deliberately departs from that
position for the mutability verdict: gap-fill is right for an identity label and
wrong for a permission-shaped flag, so this field is always emitted and fails
closed on absence instead.
**External products leg.** Receipt: the `saas-kb` MCP server and its
`search_fsm_docs` tool are not present in this session's tool set, and no MCP
configuration in this environment registers them. This leg is unavailable, and
no claim about what other products shipped is made here.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-TASKS-RUNNER-SWITCH-001` | [Components and responsibilities](#components-and-responsibilities), [Data and contracts](#data-and-contracts) |
| `REQ-TASKS-RUNNER-SWITCH-002` | [Control flow](#control-flow), [Persistence](#persistence), [Failure and recovery](#failure-and-recovery) |
| `REQ-TASKS-RUNNER-SWITCH-003` | [Control flow](#control-flow) |
| `REQ-TASKS-RUNNER-SWITCH-004` | [Client surface](#client-surface) |
Every anchor above resolves to a heading in this document.
## Components and responsibilities
- **Mutability evaluator (task service).** Single implementation of the ordered
condition list. Every caller, the projection and the action alike, uses it, so
a projected verdict and an enforced verdict cannot disagree.
- **Runner action handler (task service).** Authorizes the caller, opens the
serialized transaction, re-runs the evaluator, applies the compatibility gate,
writes the single metadata key, and publishes the update event.
- **Task projection (task DTO).** Emits the verdict alongside the executor
profile identifier it already derives from task metadata.
- **Task dialog (web).** Renders the existing executor profile selector under
the projected verdict and calls the action on save.
## Data and contracts
**Projected fields.** `runner_editable` (boolean) and
`runner_ineligible_reason` (string) are added to the task projection. Neither is
omit-when-empty; both are always serialized. The reason vocabulary is closed:
`eligible`, `evaluation_unavailable`, and the ten condition codes named in
AC-TASKS-RUNNER-SWITCH-001.3.
**Action.** `task.runner`, payload `{ id, executor_profile_id }`. The two-segment
name is load-bearing: the gateway's top-level task-action rule grants the
task-authorization backstop to the task namespace plus exactly one segment and
promotes `id` to the task identifier. A deeper name silently loses both.
**Conflict vocabulary.** The action returns the ten mutability codes plus
`target_cannot_materialize_repository`. Invalid-request and not-found outcomes
are distinct from all of them, so a client can tell "you may not" from "that is
not a thing". A fourth outcome class, `evaluation_unavailable`, is distinct from
all of the above and is the only retriable one: it says the system could not
decide, not that the answer is no. The full outcome precedence, which stage wins
when several apply, is fixed by AC-TASKS-RUNNER-SWITCH-002.17.
**State read by the evaluator.** Task archive state, the count of the task's
repository attachments, the existence of any session, task environment, running-
executor record, workspace folder attachment, or unreleased workspace group
membership, the task's declared host workspace path, and the task's declared
workspace mode paired with whether it has a parent.
Two of those reads are non-obvious and are the reason the rule is a service
concern rather than a column lookup. A task environment is keyed to the task,
not to a session, so removing every session leaves the environment behind; and a
running-executor record has no foreign key to the session either, so session
deletion does not remove it. Both must be queried directly.
## Control flow
1. A client reads a task. The projection calls the evaluator and emits the
verdict with the rest of the task.
2. The user changes the executor profile in the task dialog and saves. The dialog
issues `task.runner` only when the **user** changed the selection during this
editing session — not merely when the selection differs from the stored value
(AC-TASKS-RUNNER-SWITCH-004.5). The two tests are not equivalent, and the case
that separates them is the common one: the dialog seeds an empty selector on
open, so a task that stores no executor profile always shows a selection that
differs from the stored value. Issuing on inequality would make an untouched
save store an explicit runner derived from a remembered preference, which
AC-TASKS-RUNNER-SWITCH-002.16 then makes durable — the silent carry-over the
prior art cited above exists to prevent. The dialog therefore tracks whether
the user touched the control, and a seeded value is displayed
(AC-TASKS-RUNNER-SWITCH-004.5a) without being treated as a choice.
3. The handler authorizes the caller against the task. WebSocket callers are
additionally covered by the gateway backstop; that backstop is not relied on
for any other caller.
4. **Before opening any transaction**, the compatibility gate runs. It resolves
the target profile's executor, rejecting as invalid when the profile is
missing or its executor is soft-deleted or not active; asks the executor
capability whether that runtime requires a clone URL; and if it does, resolves
whether the task's single repository can produce one. Only the *ordering* of
candidates is taken from the launch path: a recorded remote URL, then a
provider-derived URL, then the repository's local checkout origin. The launch
path's own helper cannot be reused as-is, because it collapses "no origin is
configured" and "the origin lookup failed" into the same empty answer and puts
no timeout on the subprocess it shells out to. This gate needs those two cases
to produce different outcomes and needs the call bounded, so the resolution
used here preserves the error and timeout signal separately from empty rather
than inheriting that helper's return shape. Absent,
empty and whitespace-only candidates fall through to the next; an exhausted
list yields `target_cannot_materialize_repository`, while a lookup that errors
or exceeds its timeout yields `evaluation_unavailable`. Because this runs
before the transaction that validates how many repositories the task has, the
step records *which* repository it resolved against, and skips entirely when
the task does not have exactly one at that moment — a skip produces no verdict
and no outcome, leaving `no_repository` or `multiple_repositories` to be
reported by the mutability gate (AC-TASKS-RUNNER-SWITCH-002.7c). This step
**resolves** the verdict but does not report it: the result is carried into the
transaction and applied at its ordered position in step 7, after the mutability
re-check, so a task that fails both reports the mutability code
(AC-TASKS-RUNNER-SWITCH-002.17). Resolution runs outside the lock precisely
because its last step inspects a local checkout with a subprocess rather than
reading a column: holding the task row lock across that call would stall every
session-creation path for the task for as long as the subprocess ran. The same
property is why the gate is in the action and not in the per-task projection.
5. The handler opens a transaction that takes **the raw task row lock, not the
task cleanup barrier**. Two primitives exist and they are not
interchangeable: the barrier takes the row lock *and* refuses the caller when
a resource-cleanup job for that task is prepared, pending, running, or waiting
to retry, while the raw lock takes the row lock only. The switch takes the raw
lock. Using the barrier would introduce a cleanup-in-progress rejection that
is outside the closed conflict vocabulary and has no stage in the outcome
precedence, so a builder would have to invent an outcome for it. Ordering
against cleanup is what the contract asks for, and the raw lock supplies it;
being *rejected* by cleanup is not required and is not wanted. The gate
already answers correctly in both cleanup phases without it: while the rows
still exist the session, environment, or running-executor condition refuses
the switch, and once cleanup has removed them the task genuinely holds nothing
and a switch is legitimate. Taking that lock serializes the transaction
against every writer that can make a gate condition start holding
(AC-TASKS-RUNNER-SWITCH-002.3a) — every session-creation path, including the
Office one that takes the row lock independently rather than through the
barrier, plus task environment creation, running-executor record creation,
repository attachment, workspace folder attachment, group membership, and the
workspace path and mode writes. The locked section performs database reads and
one write, and nothing else.
6. Inside the transaction the evaluator runs again. A failing condition aborts
with the typed conflict for the first failing condition in the fixed order. A
read that fails aborts with `evaluation_unavailable`.
7. The compatibility verdict resolved in step 4 is applied — but first the
handler confirms, still inside the transaction, that the task's single
attached repository is the one step 4 resolved against. That is a column read,
not a subprocess, so it does not reintroduce the cost step 4 exists to avoid.
If the repository changed in between, the resolved verdict describes something
the task no longer has, so it is discarded and the request rejects as the
retriable `evaluation_unavailable` rather than being applied
(AC-TASKS-RUNNER-SWITCH-002.7c). If it rejects on its own terms, the handler
aborts with that code. Otherwise it compares the target against the
stored value: equal means a no-op that commits nothing and reports success,
having passed both gates; different means it writes only the executor profile
key and commits.
8. On a committed change the handler publishes the task-updated event carrying
the refreshed projection and the task's updated-at value from the committing
transaction. Clients discard an event older than the value they hold, so an
out-of-order delivery cannot resurrect a superseded runner
(AC-TASKS-RUNNER-SWITCH-002.19).
9. The next session preparation for the task reads the stored executor profile
and binds it, and the executor it belongs to, onto the created session. No
other code path changes.
## Failure and recovery
- Every rejection happens before or inside the transaction and commits nothing,
so a rejected switch is indistinguishable from one never attempted.
- The evaluator fails closed. A read error yields `evaluation_unavailable` in
the projection and aborts the action with the `evaluation_unavailable` outcome;
it never yields an eligible verdict and never reports a mutability conflict,
which would tell the user their task is ineligible when the system merely could
not tell. That outcome is retriable and is the only one that is.
- A projection path that never runs the evaluation at all, rather than running it
and failing, emits the same fail-closed pair. The verdict fields are never
emitted as language defaults, because the empty string is outside the closed
vocabulary and a `false` with no reason is indistinguishable from a real
refusal (AC-TASKS-RUNNER-SWITCH-001.1a).
- The action is a value assignment, so a client that cannot tell whether an
attempt committed may simply repeat it. No idempotency token is defined,
because none is needed and an unused one would be another thing to keep
correct.
- The known race is a user opening a task, which prepares a session for a task
that has none, while a switch is in flight. The row lock forces a total order:
either the switch commits and the session takes the new runner, or the session
commits and the switch is refused with `session_exists`. The dialog surfaces
that refusal; the session-preparation path is not modified.
- The sibling races all have that same shape and that same resolution, and the
rule is stated once for all of them rather than per row
(AC-TASKS-RUNNER-SWITCH-002.3a): every writer that can make a gate condition
start holding takes the same task-scoped lock. Task environment creation and
running-executor record creation are the least obvious members, because a task
environment survives the deletion of every session and a running-executor record
has no foreign key to a session either, so each can appear on a task the session
checks would call clean — but repository attachment, workspace folder
attachment, group membership, and the workspace path and mode writes are in the
same set for the same reason. Serializing only some of them is what would leave
the rest observed but not enforceable. The rule also reaches a writer that
changes no row count at all — an in-place update of an existing task-repository
link — because it can change which repository the compatibility gate resolved
against, which is why AC-TASKS-RUNNER-SWITCH-002.3a states that second clause
alongside the first.
- Publishing the task-updated event is outside the transaction and best-effort. A
failed publication does not roll back or retry the committed switch and is not
reported as a failed switch, because the switch did commit
(AC-TASKS-RUNNER-SWITCH-003.3a). The recovery is not a replayed event: a repeat
of the request is a no-op that publishes nothing, so what closes the gap is that
any later read of the task carries the committed runner and the recomputed
verdict. That is why no outbox or delivery receipt is specified — the read path
is already the convergence path.
### The writer audit, performed
AC-TASKS-RUNNER-SWITCH-002.3b requires this set to be enumerated rather than left
to be discovered, so it is recorded here. Two classes, and the split is what makes
the obligation a bounded piece of work rather than an open-ended one.
**Class 1 — already serialized, no change required.** The switch's transaction
locks the task's own row, so any writer that updates that same row is ordered
against it for free. That covers archiving the task, writing the task's host
workspace path, and writing the task's workspace mode: all three live in the task
row, and the metadata writes reach them with a targeted update of that row rather
than a separate table. Session creation and task environment creation are also in
this class, but for the other reason — they take the task-scoped lock explicitly,
one through the cleanup barrier and one directly. Both barrier and raw lock take
the same row, so a writer using either is ordered against a switch using the other.
**Class 2 — requires the lock to be added.** These write a different table and take
no task-scoped lock today:
| Writer | Gate condition | Why it is unserialized today |
| --- | --- | --- |
| Running-executor record write | 6 `executor_running` | Executes directly on the shared handle, in no transaction. |
| Workspace source attachment | 2, 3 `no_repository` / `multiple_repositories`, 7 `workspace_folder_attached` | Opens a transaction, but its only task-row lock is inside a parent-relation guard that returns immediately when no expected parent is supplied — so a task with no parent, the shape this capability most commonly serves, is not locked at all. |
| Workspace group membership insert | 9 `workspace_group_member` | Executes directly on the shared handle, in no transaction. Office-owned. |
| In-place task-repository link update | none — changes the resolved repository | Executes directly on the shared handle. The attachment count does not move, so the first clause of AC-TASKS-RUNNER-SWITCH-002.3a does not reach it; the second clause does. |
Four writers across two subsystems. That is the whole of the retrofit, and it is
stated here so the work can be sized before it starts rather than found during it.
The in-transaction repository confirmation of AC-TASKS-RUNNER-SWITCH-002.7c stays
even once the fourth writer takes the lock: it compares the link's last-updated
column as well as its identity, and exists so that a writer introduced outside this
audit is still caught rather than silently trusted.
## Client surface
The task dialog already renders the executor profile selector when editing. It is
never written to the task — but it is **not** unused, and the difference matters.
The edit save already forwards the selected profile to the session launch it
issues when an agent profile is chosen, so today the picker silently governs the
first session while leaving the task's stored runner untouched. Two consequences
follow, and both are contract, not implementation detail. First, the edit
interaction can materialize the task, which is why the launch is the last member
of the ordered save sequence and is suppressed by a rejected switch
(AC-TASKS-RUNNER-SWITCH-004.4d). Second, a launch that names a profile explicitly
must name the one the switch just committed, or the observable effect of a switch
would depend on which of the two paths ran (AC-TASKS-RUNNER-SWITCH-003.1).
This capability makes the picker effective on the task itself and puts it under
the server's verdict rather than the current task-state approximation, which gates
on workflow state and matches none of the ten conditions.
The selector's baseline is part of the contract for the same reason. The dialog
seeds an empty selector on open, so for a task that stores no executor profile the
displayed value is always something the dialog chose, never something the task
holds. It is presented as a resolved default rather than as a stored choice, and
displaying it is not selecting it: the switch is issued when the final selection
differs from the value the dialog seeded, not when it differs from the value the
task stores (AC-TASKS-RUNNER-SWITCH-004.5, AC-TASKS-RUNNER-SWITCH-004.5a,
AC-TASKS-RUNNER-SWITCH-004.5b). Comparing against the seed rather than against the
interaction history is what keeps the rule to one observable form, and it is why
pinning a resolved default is a named exclusion rather than an unstated gap.
The dialog reads `runner_editable` and `runner_ineligible_reason` from the task
it was opened with and never recomputes either. When the verdict is `false` the
selector is not offered for editing and the reason is shown; an absent or
unrecognized reason is presented as `evaluation_unavailable` rather than as raw
text.
Save ordering is load-bearing because the dialog's save is already several
non-transactional calls and this adds one more. The runner switch goes first, the
field updates run only if it succeeds, and the session launch, when there is one,
goes last. A rejection leaves every other edit unsaved and still in the open
dialog. That ordering is what keeps a refused switch from leaving half a save
behind, and it is why the rejection path reports that nothing was saved rather
than only that the runner did not move. The launch is last because it is the only
call that materializes the task: once it commits, the switch ahead of it can no
longer be retried on equal terms, since the task now has a session and the gate
would refuse it.
Ordering first is not the same as being atomic, so the rule covers the rest of
the sequence too (AC-TASKS-RUNNER-SWITCH-004.4c). A failure at any call stops the
sequence there, and the calls that already committed stay committed — there is no
compensating action for a runner switch, and inventing one would mean a second
switch that the gate could refuse. So the dialog reports which part of the save
applied instead of collapsing the whole thing to one success or one failure, and
a retry is safe because repeating the switch is a no-op.
Desktop and mobile share this gate, this copy and this interaction; the
capability introduces no new layout and no new mobile interaction pattern.
## Persistence
The write is a single-key update to the task's metadata document. It must not be
a read-modify-write of the whole map: the generic protected metadata update
replaces the document wholesale and preserves only the deferred launch record,
so routing this change through it would delete the task's agent profile and
leave the card unable to start. The repository already provides single-key
metadata writes that set one JSON path in the database rather than rewriting the
document, and a conditional variant of the same primitive that refuses the write
when the task has an active session. This capability uses that family of writes.
Note for implementation, of the same kind as the running-executor note below:
every member of that family currently executes on the shared database handle and
none of them accepts a transaction. The switch's write must happen *inside* the
serialized transaction, so a variant that takes the transaction handle is
required; the repository already has this shape elsewhere, where a metadata
removal is parameterized over its executor rather than bound to the shared
handle. Writing through the existing handle-bound form instead would put the
write outside the lock, where it would land even when the transaction rolls back
and would silently defeat the guarantees that a rejected switch persists nothing
and that a switch never commits alongside a session prepared under the old
runner.
No schema change is required. `runner_editable` and `runner_ineligible_reason`
are derived per read and are never stored.
Because the verdict is derived, a list projection must not fan out into a
per-task query for each of the eight row-existence checks.
AC-TASKS-RUNNER-SWITCH-001.5 is the observable constraint: the same task must
carry the same verdict whether it was read alone or in a list, which is what
makes a batched implementation verifiable rather than merely faster.
The evaluator's reads are several, and the executor profile it is projected
beside is a ninth. Left as independent point-in-time queries they can straddle a
concurrent switch and produce a payload whose profile and verdict describe
different states, which AC-TASKS-RUNNER-SWITCH-001.10 forbids. So the reads
backing one projected task — the stored executor profile and every row-existence
check behind its verdict — resolve against a single consistent snapshot, whether
that is one read transaction, one snapshot read, or one joined query. This is
about coherence within a payload, not authority: the projection stays advisory
and the action stays the thing that decides, per AC-TASKS-RUNNER-SWITCH-002.3.
An advisory verdict may be stale, and the client handles that
(AC-TASKS-RUNNER-SWITCH-004.4); it may not be internally inconsistent.
A read that fails degrades only the tasks whose own verdict depended on it
(AC-TASKS-RUNNER-SWITCH-001.8a), so a batched query covering a whole board
degrades the whole board and one covering a subset degrades that subset, under
one rule rather than two.
## Security
Authorization is per task and is the same authorization that governs the other
top-level task actions. The service authorizes every caller on every transport,
including WebSocket. The gateway backstop covers WebSocket dispatch and is an
extra layer there, not a substitute for the service guard and not a reason to
skip it — the service cannot reliably tell which transport a request arrived on,
and the other top-level task mutations in this system already authorize
unconditionally for exactly that reason. The action accepts exactly two fields and writes
exactly one derived value, so it is not a path for a caller to inject arbitrary
task metadata.
## Observability
A successful switch logs the task, the previous executor profile, and the new
one. A rejection logs the task and the reason code. The reason code is the same
closed vocabulary the client receives, so a support question about a refused
switch can be answered from logs without reproducing the state.
## Related decisions
- [ADR 0028: task-create last-used source of truth](../../../decisions/0028-task-create-last-used-source-of-truth.md)
- [ADR 0041: backend-owned portable user settings](../../../decisions/0041-backend-owned-portable-user-settings.md)