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:
@@ -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)
|
||||
Reference in New Issue
Block a user