fix(office): keep an agent's session alive while it's still working the task (#3556)
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,195 @@
|
||||
---
|
||||
status: draft
|
||||
system: office
|
||||
created: 2026-09-08
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Office Seat Claim Decision Guard Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
`AC-OFFICE-SEAT-PROVENANCE-002.5` requires that a decision already recorded
|
||||
against an agent is never reattributed to a different agent by a later
|
||||
registration. Two mechanisms defend it in the shipped code, and they defend
|
||||
different windows.
|
||||
|
||||
The first is a filter applied while a registration is choosing which seat to
|
||||
claim: a seat that already carries a decision is not selected. It runs inside
|
||||
the registration's transaction and covers every decision that was committed
|
||||
before the registration looked.
|
||||
|
||||
The second is a condition on the update that actually reassigns the seat,
|
||||
requiring that no decision exists against it at the moment of the write. It
|
||||
covers only the window *between* those two points - a decision that commits
|
||||
after the registration chose the seat and before it reassigned it.
|
||||
|
||||
That window is now closed for every decision the shipped product records.
|
||||
Decision recording serializes on the same per-`(task, role)` exclusion the
|
||||
registration holds for its whole transaction, so a decision carrying a role
|
||||
cannot commit inside another writer's window. The exclusion is acquired only
|
||||
when the decision carries a role, and the decision store deliberately supports
|
||||
recording one without: the only validation on that path requires a task, a
|
||||
step, a participant and a verdict. A decision with no role skips the exclusion
|
||||
entirely, and for that decision the condition on the update is the only thing
|
||||
standing between a recorded verdict and its silent reattribution to whoever
|
||||
registers next.
|
||||
|
||||
No shipped caller produces a decision without a role - both entry points
|
||||
resolve one, or refuse - so this is a latent path, not a live defect. It is
|
||||
worth stating because the guard defending it is currently documented as
|
||||
something else. The concurrency test covering this behavior asserts that the
|
||||
two writers lock on non-contending namespaces and that the condition on the
|
||||
update is therefore load-bearing. The first half stopped being true when the
|
||||
shared exclusion was introduced, and the second half is now true only on the
|
||||
path the test does not exercise. A test that cannot fail is worse than no test,
|
||||
because it is counted.
|
||||
|
||||
Office owns this contract: it owns the registration path, the decision store's
|
||||
office-facing entry points, and the exclusion the two share.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Registration** - a request to seat a named agent profile in a participant
|
||||
role on a task, which may claim an existing automatically-cast seat or write
|
||||
a new one.
|
||||
- **Claimable seat** - the sole automatically-cast, undecided seat in a role's
|
||||
slate, which a registration may reassign to the registering agent.
|
||||
- **Seat exclusion** - the per-`(task, role)` mutual exclusion that automatic
|
||||
casting, registration and role-carrying decision recording all acquire, held
|
||||
for the duration of the acquiring transaction.
|
||||
- **Roleless decision** - a decision recorded without a participant role. The
|
||||
decision store accepts one; it supersedes prior decisions by participant
|
||||
rather than by decider and role, and does not acquire the seat exclusion.
|
||||
- **Reattribution** - a seat carrying a recorded decision coming to name an
|
||||
agent other than the one whose decision is on file, so that the verdict reads
|
||||
as having been given by an agent that did not give it.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-OFFICE-SEAT-GUARD-001: A recorded decision survives a concurrent claim
|
||||
|
||||
**Intent:** `AC-OFFICE-SEAT-PROVENANCE-002.5` must hold on every path that can
|
||||
record a decision, including the one that does not participate in the seat
|
||||
exclusion. The shipped code already satisfies this requirement; it is written
|
||||
down because nothing states it, nothing tests it, and the code comment
|
||||
explaining the mechanism describes a different one.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-OFFICE-SEAT-GUARD-001.1:** When a decision exists against a seat, a
|
||||
registration shall not reassign that seat to another agent, whether or not
|
||||
that decision carries a participant role.
|
||||
- **AC-OFFICE-SEAT-GUARD-001.2:** When a roleless decision against a seat
|
||||
commits after a registration has selected that seat as claimable and before
|
||||
that registration reassigns it, the registration shall reassign no seat and
|
||||
shall write a new seat for the registering agent instead.
|
||||
- **AC-OFFICE-SEAT-GUARD-001.3:** The behavior required by
|
||||
`AC-OFFICE-SEAT-GUARD-001.2` shall not depend on the decision writer and the
|
||||
registration serializing on the seat exclusion, because the roleless decision
|
||||
path does not acquire it. An implementation that removes the condition on the
|
||||
reassigning update and relies on the exclusion alone does not satisfy this
|
||||
requirement.
|
||||
- **AC-OFFICE-SEAT-GUARD-001.4:** When a registration declines to reassign a
|
||||
seat under `AC-OFFICE-SEAT-GUARD-001.2`, it shall leave that seat's agent
|
||||
profile, provenance, decision-required flag, position and creation time
|
||||
unchanged, and shall leave the decision itself unchanged.
|
||||
- **AC-OFFICE-SEAT-GUARD-001.5:** A registration that declines to reassign
|
||||
under `AC-OFFICE-SEAT-GUARD-001.2` shall report the same outcome, and produce
|
||||
the same effects, as a registration that found no claimable seat at all: a
|
||||
new seat is written, and none of the effects a claim earns - the claim
|
||||
activity record, the displaced agent's session termination, the displaced
|
||||
agent's queued run cancellation - occurs. Nothing was displaced, so nothing
|
||||
shall be reported as displaced.
|
||||
- **AC-OFFICE-SEAT-GUARD-001.6:** A registration shall not be failed by the
|
||||
condition in `AC-OFFICE-SEAT-GUARD-001.2`. Finding the seat decided is a
|
||||
normal outcome and shall be reported as success.
|
||||
- **AC-OFFICE-SEAT-GUARD-001.7:** This requirement adds no acceptance criterion
|
||||
to the participant-seat-provenance contract and amends none of its criteria.
|
||||
It names the path on which `AC-OFFICE-SEAT-PROVENANCE-002.5` is defended by
|
||||
the condition on the update alone.
|
||||
- **AC-OFFICE-SEAT-GUARD-001.8:** Any decision against the seat shall block the
|
||||
reassignment, whether or not it has been superseded, and whether or not more
|
||||
than one exists. Both defenses shall agree on this: a seat the selection
|
||||
rejects as decided shall not be one the update would accept, and the reverse.
|
||||
Superseded decisions are retained as the audit trail of a reworked review, and
|
||||
a seat that has ever been decided has an attribution history that reassigning
|
||||
it would falsify.
|
||||
- **AC-OFFICE-SEAT-GUARD-001.9:** A registration that declined to reassign under
|
||||
`AC-OFFICE-SEAT-GUARD-001.2` shall leave the registering agent seated by the
|
||||
new seat it wrote, so a repeat of the same registration is governed by the
|
||||
existing already-seated rule and writes nothing further. This document adds no
|
||||
retry and no second attempt at the claim.
|
||||
|
||||
### REQ-OFFICE-SEAT-GUARD-002: The guard is verified by a test that fails without it
|
||||
|
||||
**Intent:** A guard whose test passes when the guard is deleted is not covered.
|
||||
The suite must contain at least one test that distinguishes the two, and the
|
||||
tests that cannot must stop claiming they do.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-OFFICE-SEAT-GUARD-002.1:** The test suite shall contain a test that
|
||||
fails when the condition required by `AC-OFFICE-SEAT-GUARD-001.3` is removed
|
||||
from the reassigning update, and that passes when it is present.
|
||||
- **AC-OFFICE-SEAT-GUARD-002.2:** That test shall reach the window described in
|
||||
`AC-OFFICE-SEAT-GUARD-001.2` deterministically. Its result shall not depend
|
||||
on thread or transaction scheduling, and it shall not be capable of passing
|
||||
by never entering the window it targets.
|
||||
- **AC-OFFICE-SEAT-GUARD-002.3:** Any mechanism introduced to make
|
||||
`AC-OFFICE-SEAT-GUARD-002.2` deterministic shall have no effect in a
|
||||
production build: with the mechanism unset, the code path shall behave
|
||||
exactly as it does today.
|
||||
- **AC-OFFICE-SEAT-GUARD-002.4:** A test that establishes that the two writers
|
||||
serialize on the seat exclusion shall say that, and shall assert an outcome
|
||||
that serialization can produce. It shall not document itself as establishing
|
||||
that the condition on the update is load-bearing, which serialization
|
||||
prevents it from showing.
|
||||
- **AC-OFFICE-SEAT-GUARD-002.5:** No test documentation in the suite shall
|
||||
assert that role-carrying decision recording and registration lock on
|
||||
non-contending exclusions. That premise is false and is the reason
|
||||
`AC-OFFICE-SEAT-GUARD-002.4` is needed.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Removing the condition on the reassigning update.** Named because the
|
||||
premise that it is unreachable is the tempting conclusion from the shared
|
||||
exclusion, and it is wrong: `AC-OFFICE-SEAT-GUARD-001.3` requires it to stay,
|
||||
and the roleless decision path is why.
|
||||
- **Making the roleless decision path acquire the seat exclusion.** This is the
|
||||
other way to close the window, and it is deliberately not taken here. The
|
||||
path has no role to key the exclusion on and would have to derive one from
|
||||
the seat it references, turning a write into a read-then-lock and widening
|
||||
decision recording's contention for a path no shipped caller uses. The
|
||||
condition on the update already delivers the outcome
|
||||
`AC-OFFICE-SEAT-GUARD-001.2` requires. Recorded so the trade is on the record
|
||||
rather than an omission.
|
||||
- **Rejecting roleless decisions.** Whether the decision store should require a
|
||||
role at all is a separate contract question about the decision model. This
|
||||
document takes the store's accepted input shapes as given and requires the
|
||||
seat behavior to be correct for all of them.
|
||||
- **The concurrency test covering a claim target removed mid-transaction.** A
|
||||
sibling follow-up owns that test and its own determinism gap. If a shared
|
||||
mechanism satisfies `AC-OFFICE-SEAT-GUARD-002.3` for both, that is a welcome
|
||||
outcome and not a requirement of this document, which constrains only the
|
||||
decision guard's own coverage.
|
||||
- **Any user-visible surface.** No behavior change ships to a user from this
|
||||
document: `AC-OFFICE-SEAT-GUARD-001` records what the code already does. No
|
||||
copy, no i18n, no UI work.
|
||||
|
||||
## Prior art
|
||||
|
||||
The prior-art legs for this card are recorded once, in
|
||||
[Office Task Session Termination Requirements](task-session-termination.md#prior-art),
|
||||
with their receipts. Both external legs were unavailable on this runner; the
|
||||
in-repo prior decisions that applied to this document are
|
||||
`AC-OFFICE-SEAT-PROVENANCE-002.5`, which this requirement defends without
|
||||
amending, and the shared seat exclusion introduced to close the role-carrying
|
||||
decision race, whose own documentation records that the two writers previously
|
||||
never contended.
|
||||
|
||||
## System design
|
||||
|
||||
The technical design is
|
||||
[part 1](../system-design/seat-claim-decision-guard-01.md).
|
||||
@@ -0,0 +1,354 @@
|
||||
---
|
||||
status: draft
|
||||
system: office
|
||||
created: 2026-09-08
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Office Task Session Termination Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
An office session is keyed on a `(task_id, agent_profile_id)` pair.
|
||||
REQ-OFFICE-SESSION-IDENTITY-001 makes that pair name exactly one live durable
|
||||
conversation, and `AC-OFFICE-SESSION-IDENTITY-001.8` states the consequence
|
||||
explicitly: the office side treats the pair as one conversation *regardless of
|
||||
which path created the row*. Nothing in that contract says when the
|
||||
conversation ends.
|
||||
|
||||
Three paths end it today, and all three end it on the loss of a single
|
||||
capacity: a participant role is removed by hand, a seat is taken from its
|
||||
occupant by a manual claim, or the task is reassigned. Each flips the one
|
||||
shared session row to `COMPLETED` without asking whether the agent still holds
|
||||
another capacity on the same task.
|
||||
|
||||
That question is not hypothetical. `AC-OFFICE-REVIEW-SEATS-002.4`, `-002.5`
|
||||
and `-002.9` each seat the task's runner, or an agent already seated in another
|
||||
role, as a reviewer. An agent holding two capacities at once is a normal,
|
||||
fallback-default outcome of the shipped casting rules, and when such an agent
|
||||
loses one of them the current behavior ends the session it is still using for
|
||||
the other.
|
||||
|
||||
In the configuration every profile ships (`features.officeSessionIdentity`
|
||||
disabled), the defect is sharper than "sometimes over-eager". With that flag
|
||||
off, a task that has a runner routes *every* office run through the session
|
||||
owned by the runner, so no session is keyed on a non-runner participant at all.
|
||||
A termination aimed at a displaced or removed non-runner therefore finds
|
||||
nothing and does nothing, and a termination aimed at an agent that *is* the
|
||||
runner finds the task's only live session. On the role-removal and seat-claim
|
||||
paths, the operation is either a silent no-op or the exact case this document
|
||||
forbids. Enabling the flag gives each agent its own row and removes the no-op
|
||||
half, but not the defect: the self-review agent still holds one row for both
|
||||
capacities.
|
||||
|
||||
Office owns this contract because office owns the rule that a pair names one
|
||||
conversation, and owns all three paths that end one. The task system owns the
|
||||
session row's storage and its non-office lifecycle, which this document leaves
|
||||
untouched.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Capacity** - a distinct standing an agent holds on a task that causes the
|
||||
system to address it there. Two exist: the runner capacity and a seat
|
||||
capacity.
|
||||
- **Runner capacity** - the agent is the task's effective runner, as that is
|
||||
resolved for every other reader of the task's runner.
|
||||
- **Seat capacity** - the agent holds a seat in the task's effective
|
||||
participant slate, in any role.
|
||||
- **Effective participant slate** - the seats a reader of the task observes at
|
||||
the task's current step, after per-task rows take precedence over
|
||||
template-level rows. It is the same slate the task's own readers see; this
|
||||
document does not define a second one.
|
||||
- **Guarded path** - one of the three paths this document constrains: manual
|
||||
removal of a participant role, displacement of a seat's occupant by a claim,
|
||||
and reassignment of the task away from its runner.
|
||||
- **Cascade path** - a path that ends every live session belonging to an agent
|
||||
across every task, because the agent instance itself is going away.
|
||||
- **Live** and **terminal** - as defined by
|
||||
REQ-OFFICE-SESSION-IDENTITY. This document adds no state and redefines
|
||||
neither.
|
||||
|
||||
## Prior art
|
||||
|
||||
**Our own prior reasoning (wiki):** the leg did not run, and no result is
|
||||
reported for it. Receipt: the `wiki-query` skill is not installed on this
|
||||
runner (absent from `~/.claude/skills/` and from the task-local
|
||||
`.claude/skills/`), `OBSIDIAN_VAULT_PATH` is unset, `~/.obsidian-wiki/` does
|
||||
not exist, no vault directory exists under `~`, and `qmd` is not on `PATH`, so
|
||||
neither the QMD path nor the documented `grep` fallback had a vault to resolve.
|
||||
Recorded as unavailable rather than as an empty result.
|
||||
|
||||
**What others shipped (saas-kb):** the leg did not run. Receipt: no `saas-kb`
|
||||
MCP server is exposed to this session - the only MCP tools present are the
|
||||
`kandev` server's - so `search_fsm_docs` could not be called with or without
|
||||
`category: "ai_sdlc"`. Recorded as unavailable, not as a search that found
|
||||
nothing.
|
||||
|
||||
**In-repo prior decisions**, which did apply and which carried the weight the
|
||||
two unavailable legs would have:
|
||||
|
||||
- **REQ-OFFICE-SESSION-IDENTITY-001 and -004** forecloses the obvious fix.
|
||||
Making session identity role-scoped would add a role dimension to the pair
|
||||
that -001 has just finished making singular, and
|
||||
`AC-OFFICE-SESSION-IDENTITY-004.5` forbids the new column such a dimension
|
||||
would need. The card that raised this defect offered role-scoped identity or
|
||||
a retained-capacity guard as alternatives; that contract decides between
|
||||
them, and this document takes the guard.
|
||||
- **`AC-OFFICE-SEAT-PROVENANCE-002.12`** already scopes the claim path's
|
||||
termination to an agent "holding a live session for that task **in that
|
||||
role**". The implementation is broader than its own frozen criterion, because
|
||||
the session model cannot express "in that role". This document does not amend
|
||||
that criterion; it makes the behavior conform to what the criterion already
|
||||
says.
|
||||
- **`AC-OFFICE-REVIEW-SEATS-002.3`** already treats an agent holding two
|
||||
capacities as undesirable-but-permitted, preferring a candidate that is
|
||||
"neither the task's runner nor already seated in another participant role"
|
||||
and falling back when none exists. The fallback is what makes this defect
|
||||
reachable, and it is deliberate, so the fix belongs on the termination side
|
||||
rather than in casting.
|
||||
- **ADR 0005 (agent model unification)** made `agent_profile_id` shared between
|
||||
kanban and office, which is why the capacity question must be answered on the
|
||||
office code path rather than by a constraint on the shared row.
|
||||
|
||||
**What we are doing differently:** the shape this codebase reaches for when a
|
||||
lifecycle event is over-broad is a narrower key. That is rejected here for the
|
||||
first reason above, and the alternative is unusual enough to name: the key stays
|
||||
as wide as it is and the *decision to act on it* acquires a precondition.
|
||||
Ending the row becomes conditional on "this agent's one conversation about this
|
||||
task" having stopped being true, rather than on one reason to suspect it might
|
||||
have.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-OFFICE-SESSION-TERM-001: A session ends only with the last capacity
|
||||
|
||||
**Intent:** A guarded path removes one capacity. It must end the agent's
|
||||
session only when that was the agent's last one, so an agent that is still
|
||||
addressed on the task keeps the conversation it is still using.
|
||||
|
||||
**User story:** As an operator claiming a review seat from the agent that is
|
||||
also writing the code, I want that agent to keep working, so that taking over
|
||||
the review does not silently stop the task.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-OFFICE-SESSION-TERM-001.1:** When a guarded path would end an agent's
|
||||
office session for a task, the system shall determine whether that agent
|
||||
retains a capacity on that task, and shall end the session only when it
|
||||
retains none.
|
||||
- **AC-OFFICE-SESSION-TERM-001.2:** The determination shall observe state in
|
||||
which the mutation that removed the capacity has already committed, so the
|
||||
capacity being removed is never itself observed as retained. A determination
|
||||
made before that commit would suppress every termination and satisfies
|
||||
nothing in this document.
|
||||
- **AC-OFFICE-SESSION-TERM-001.3:** When the agent retains at least one
|
||||
capacity, the system shall leave the session row entirely unwritten - its
|
||||
state, its reason and every other field unchanged - and shall report success
|
||||
to the guarded path's caller. A suppressed termination is not an error and
|
||||
shall not be reported as one.
|
||||
- **AC-OFFICE-SESSION-TERM-001.4:** When the agent retains no capacity, the
|
||||
system shall end the session exactly as it does today: the row becomes
|
||||
`COMPLETED` carrying the reason that path already supplies, and no other
|
||||
observable behavior of that path changes.
|
||||
- **AC-OFFICE-SESSION-TERM-001.5:** The determination shall have exactly one
|
||||
implementation in application code, reachable from all three guarded paths,
|
||||
and the system shall not introduce a second. Three copies that agree today
|
||||
are three copies that can disagree later, which is the failure this criterion
|
||||
exists to prevent.
|
||||
- **AC-OFFICE-SESSION-TERM-001.6:** The guarded paths shall be exactly three:
|
||||
removal of a participant role from a task, displacement of a seat's occupant
|
||||
by a claim on that seat, and reassignment of a task away from its runner. The
|
||||
system shall not extend the determination to a path not named here without
|
||||
amending this criterion.
|
||||
- **AC-OFFICE-SESSION-TERM-001.7:** If the determination cannot be completed
|
||||
because the state it reads is unavailable, then the system shall not end the
|
||||
session, shall record the failure under REQ-OFFICE-SESSION-TERM-004, and
|
||||
shall report success to the guarded path's caller. The determination fails
|
||||
closed: ending the session of an agent that is still working is the defect
|
||||
being removed, and leaving a session row live is recoverable by the next
|
||||
guarded path that runs, whereas ending one is not.
|
||||
- **AC-OFFICE-SESSION-TERM-001.8:** When either the task identifier or the
|
||||
agent profile identifier is empty, the system shall neither evaluate the
|
||||
determination nor end a session, and shall report success. No pair is named,
|
||||
so there is nothing to decide.
|
||||
- **AC-OFFICE-SESSION-TERM-001.9:** When no live session exists for the pair,
|
||||
the observable outcome shall be identical whether or not the determination
|
||||
was evaluated. The system may skip it, and a test shall not depend on whether
|
||||
it did.
|
||||
- **AC-OFFICE-SESSION-TERM-001.10:** When two guarded paths run concurrently
|
||||
for the same agent and task, each removing a different capacity, the system
|
||||
shall end the session. This follows from
|
||||
`AC-OFFICE-SESSION-TERM-001.2` without further coordination: each path reads
|
||||
strictly after its own commit, so whichever commits second observes both
|
||||
removals and finds no retained capacity. The system shall not add a lock, a
|
||||
retry or a re-read to obtain this outcome, and shall not rely on which path
|
||||
reads first.
|
||||
- **AC-OFFICE-SESSION-TERM-001.11:** The system shall not end a session that
|
||||
the same guarded path has already ended, and repeating a guarded path's
|
||||
termination step for a pair shall produce no further write. This preserves
|
||||
the existing idempotency of the termination step unchanged.
|
||||
|
||||
### REQ-OFFICE-SESSION-TERM-002: What counts as a retained capacity
|
||||
|
||||
**Intent:** The determination must ask a question the rest of the system
|
||||
already answers the same way, and must ask it over a scope that cannot pick up
|
||||
a capacity the agent no longer holds.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-OFFICE-SESSION-TERM-002.1:** The system shall treat an agent as
|
||||
retaining a capacity on a task when it is the task's effective runner, or
|
||||
when it holds a seat in the task's effective participant slate, and shall
|
||||
treat it as retaining none otherwise. The two are alternatives, not an
|
||||
ordered test: neither takes precedence and the determination shall not depend
|
||||
on which is evaluated first.
|
||||
- **AC-OFFICE-SESSION-TERM-002.2:** The system shall resolve the task's
|
||||
effective runner by the same rule every other reader of that task's runner
|
||||
uses, so that the determination and the rest of the system cannot disagree
|
||||
about who the runner is. It shall not resolve the runner by reading
|
||||
participant seats directly, which would answer a different question: seats
|
||||
written for a step the task has since left remain readable, and the runner
|
||||
rule already fixes their precedence.
|
||||
- **AC-OFFICE-SESSION-TERM-002.3:** The seat scope shall be the task's
|
||||
effective participant slate at the task's current step. A seat recorded at a
|
||||
step the task is not standing on shall not, on its own, count as a retained
|
||||
capacity. Without this bound a task that has moved through steps accumulates
|
||||
seats naming a previous occupant, and a termination that is correct would be
|
||||
suppressed forever.
|
||||
- **AC-OFFICE-SESSION-TERM-002.4:** A template-level seat that the slate
|
||||
projects onto the task shall count exactly as a per-task seat does. The agent
|
||||
is addressed on the task either way, which is the only thing the
|
||||
determination asks.
|
||||
- **AC-OFFICE-SESSION-TERM-002.5:** Every participant role shall count,
|
||||
including a role that carries no decision obligation. The determination asks
|
||||
whether the agent is still addressed on the task, not whether it still owes a
|
||||
decision.
|
||||
- **AC-OFFICE-SESSION-TERM-002.6:** The determination shall depend on the
|
||||
presence of a capacity and not on how many the agent holds. An agent holding
|
||||
two seats and an agent holding one shall be treated identically.
|
||||
- **AC-OFFICE-SESSION-TERM-002.7:** The determination shall not depend on the
|
||||
order in which seats are returned, and shall produce the same result for any
|
||||
ordering of the same slate.
|
||||
- **AC-OFFICE-SESSION-TERM-002.8:** When the task has no current step, the
|
||||
effective slate shall be empty and only the runner capacity shall be capable
|
||||
of being retained. When the task does not resolve at all, the determination
|
||||
shall be treated as having failed under `AC-OFFICE-SESSION-TERM-001.7` rather
|
||||
than as having found no capacity.
|
||||
- **AC-OFFICE-SESSION-TERM-002.9:** The determination shall compare agent
|
||||
profile identifiers and shall not require the named profile to resolve to a
|
||||
live agent. A seat naming a profile that no longer resolves still names this
|
||||
agent, and an agent instance that is genuinely going away is handled by a
|
||||
cascade path instead.
|
||||
- **AC-OFFICE-SESSION-TERM-002.10:** The determination shall produce the same
|
||||
result on every database engine the product supports.
|
||||
- **AC-OFFICE-SESSION-TERM-002.11:** The determination selects nothing. It
|
||||
answers whether any capacity is held, so no ordering, tiebreak or
|
||||
deterministic choice among capacities is required, and the system shall not
|
||||
introduce one. Which capacity is named in `AC-OFFICE-SESSION-TERM-004.1`'s
|
||||
record may be any that was found.
|
||||
- **AC-OFFICE-SESSION-TERM-002.12:** When a capacity is granted to the agent
|
||||
between the guarded path's commit and the determination's read, the system
|
||||
shall observe it and suppress the termination. An agent that has just been
|
||||
seated again is addressed on the task, and the criterion asks about the
|
||||
present, not about the state at the moment the capacity was lost.
|
||||
|
||||
### REQ-OFFICE-SESSION-TERM-003: What this contract does not change
|
||||
|
||||
**Intent:** This is a narrow precondition on three call sites. It is stated
|
||||
negatively as well as positively so that an implementation cannot satisfy it by
|
||||
a mechanism that also reaches the paths that must keep working, and so that the
|
||||
inconsistency it deliberately leaves in place is not "fixed".
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-OFFICE-SESSION-TERM-003.1:** A cascade path shall remain unguarded and
|
||||
shall continue to end every live session belonging to the agent across every
|
||||
task. An agent instance being deleted or reconciled away retains no capacity
|
||||
anywhere by definition, so evaluating the determination there would be a cost
|
||||
with no possible effect.
|
||||
- **AC-OFFICE-SESSION-TERM-003.2:** On the reassignment path, cancellation of
|
||||
the previous runner's *running execution* shall be unchanged, and shall
|
||||
continue to happen whether or not the session row's termination is
|
||||
suppressed. The two are deliberately decoupled: the agent must stop the work
|
||||
it is no longer the runner for, and must keep the conversation it is still
|
||||
seated for. An implementation that suppresses the cancellation alongside the
|
||||
termination does not satisfy this document.
|
||||
- **AC-OFFICE-SESSION-TERM-003.3:** Session creation, reuse, lookup and
|
||||
identity shall be unchanged. The system shall introduce no new column on the
|
||||
session store, no new index on it, and no backfill or repair of existing
|
||||
rows.
|
||||
- **AC-OFFICE-SESSION-TERM-003.4:** A suppressed termination shall change
|
||||
exactly one thing relative to today: the session row is not written. It shall
|
||||
not withhold, delay, duplicate or reorder any participant seat write,
|
||||
decision, queued or cancelled run, activity entry or change notification that
|
||||
its guarded path performs, and shall not itself wake, queue or re-queue
|
||||
anything.
|
||||
- **AC-OFFICE-SESSION-TERM-003.5:** The behavior required by
|
||||
REQ-OFFICE-SESSION-TERM-001 and -002 shall hold identically whether
|
||||
`features.officeSessionIdentity` is enabled or disabled. The defect is
|
||||
reachable in both states and the fix shall not be conditioned on either.
|
||||
- **AC-OFFICE-SESSION-TERM-003.6:** No participant casting, seat provenance, or
|
||||
claim behavior shall change. This document adds no acceptance criterion to
|
||||
the participant-seat-provenance contract and amends none of its criteria.
|
||||
|
||||
### REQ-OFFICE-SESSION-TERM-004: A suppressed termination is observable
|
||||
|
||||
**Intent:** The new outcome is a session that stays live where one used to end.
|
||||
Left unrecorded it is indistinguishable from a termination that failed, from a
|
||||
path that never ran, and from the leak a mistake in
|
||||
`AC-OFFICE-SESSION-TERM-002.3` would produce.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-OFFICE-SESSION-TERM-004.1:** When a termination is suppressed because
|
||||
the agent retains a capacity, the system shall emit a record identifying the
|
||||
task, the agent profile, the guarded path's reason, and which capacity was
|
||||
retained.
|
||||
- **AC-OFFICE-SESSION-TERM-004.2:** When a termination is suppressed because
|
||||
the determination failed, the system shall emit a record that is
|
||||
distinguishable from `AC-OFFICE-SESSION-TERM-004.1`'s and that identifies the
|
||||
failure. A read fault and a retained capacity are different events and shall
|
||||
not be reported as one.
|
||||
- **AC-OFFICE-SESSION-TERM-004.3:** The system shall increment a counter for
|
||||
each suppression, labelled only by bounded dimensions. It shall not label a
|
||||
counter with a task, agent, step or session identifier.
|
||||
- **AC-OFFICE-SESSION-TERM-004.4:** A termination that proceeds shall continue
|
||||
to record what it records today, with the same reason values. This document
|
||||
adds no reason value and retires none.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Role-scoped session identity.** Named because the card that raised this
|
||||
defect offered it as the alternative fix, and because it is the shape a
|
||||
reader will reach for first. It would add a role dimension to the
|
||||
`(task, agent)` pair that REQ-OFFICE-SESSION-IDENTITY-001 exists to make
|
||||
singular, and would need a discriminator column
|
||||
`AC-OFFICE-SESSION-IDENTITY-004.5` forbids. Rejected here, not deferred: the
|
||||
guard in REQ-OFFICE-SESSION-TERM-001 delivers the same observable outcome
|
||||
with no change to session identity at all.
|
||||
- **Repairing sessions already ended by the current behavior.** No migration,
|
||||
backfill or repair is authorized by this document. A session wrongly
|
||||
completed before this contract ships stays completed; the pair's next office
|
||||
wakeup creates a fresh row by the existing rules, which is the recovery that
|
||||
already exists.
|
||||
- **Ending a session when an agent loses its last capacity by a path not named
|
||||
in `AC-OFFICE-SESSION-TERM-001.6`.** An agent can stop being addressed on a
|
||||
task in ways none of the three guarded paths observes - a step transition
|
||||
that changes the slate, for one. Those paths end no session today and this
|
||||
document does not make them start: the scope is the over-eager termination
|
||||
that exists, not an audit of session lifetime.
|
||||
- **The `features.officeSessionIdentity` rollout.** Whether that flag is
|
||||
promoted, and what its enablement requires, is its own decision.
|
||||
`AC-OFFICE-SESSION-TERM-003.5` only requires this contract to be indifferent
|
||||
to it.
|
||||
- **The casting rules that make an agent hold two capacities.**
|
||||
`AC-OFFICE-REVIEW-SEATS-002.3` through `-002.9` are deliberate and unchanged.
|
||||
- **Any user-visible surface.** The observable consequence is that a session
|
||||
stays in the live participant indicators the frontend already renders from
|
||||
existing session state. No new copy, no new component, no i18n work.
|
||||
|
||||
## System design
|
||||
|
||||
The technical design is
|
||||
[part 1](../system-design/task-session-termination-01.md).
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
status: draft
|
||||
system: office
|
||||
requirements:
|
||||
- REQ-OFFICE-SEAT-GUARD-001
|
||||
- REQ-OFFICE-SEAT-GUARD-002
|
||||
---
|
||||
|
||||
# Office Seat Claim Decision Guard System Design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
Office owns the registration path that claims a seat, and the office-facing
|
||||
entry points that record a decision. This design changes no production
|
||||
behavior: it records which of two overlapping defenses covers which window, and
|
||||
makes the one that is currently untested testable.
|
||||
|
||||
Contracts used but not owned: the workflow repository's decision store and the
|
||||
per-`(task, role)` seat exclusion it shares with registration.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design section |
|
||||
| --- | --- |
|
||||
| `REQ-OFFICE-SEAT-GUARD-001` | [Two defenses, two windows](#two-defenses-two-windows) |
|
||||
| `REQ-OFFICE-SEAT-GUARD-002` | [Making the window reachable](#making-the-window-reachable), [Verification obligations](#verification-obligations) |
|
||||
|
||||
## Two defenses, two windows
|
||||
|
||||
A registration that finds a claimable seat runs two statements inside one
|
||||
transaction: a select that chooses the seat, and an update that reassigns it.
|
||||
|
||||
The **select** rejects a seat that already carries a decision. It is a plain
|
||||
read inside the registration's transaction and it sees everything committed
|
||||
before it ran.
|
||||
|
||||
The **update** carries a `NOT EXISTS` condition over decisions against the same
|
||||
seat, and re-checks the seat's provenance. It can only add something the select
|
||||
did not already provide when a decision commits *between* the two statements.
|
||||
|
||||
Whether anything can commit there is entirely a question about the seat
|
||||
exclusion:
|
||||
|
||||
- **A decision carrying a role** acquires the exclusion, which the registration
|
||||
holds for its whole transaction. It cannot commit in the window. The update's
|
||||
condition is unreachable through this path, and a test that races this path
|
||||
cannot distinguish the condition being present from its being absent.
|
||||
- **A decision carrying no role** does not acquire the exclusion. The decision
|
||||
writer's own code branches on the role being empty - it supersedes by
|
||||
participant instead of by decider-and-role, and skips the lock with the
|
||||
reasoning that there is nothing to serialize against. That reasoning is
|
||||
incomplete: there is something, and it is this window. For this path the
|
||||
update's condition is the whole defense.
|
||||
|
||||
Neither defense filters on `superseded_at`, and `AC-OFFICE-SEAT-GUARD-001.8`
|
||||
requires that they keep agreeing. A seat that has ever been decided stays
|
||||
unclaimable: the superseded rows are the audit trail of a reworked review, and
|
||||
reassigning a seat that carries one would leave a verdict attributed to an agent
|
||||
that did not give it. Adding a `superseded_at IS NULL` filter to either
|
||||
statement would make the two disagree, and would make a reworked seat claimable
|
||||
by whoever registers next.
|
||||
|
||||
Because the condition matches on the seat identity rather than on the
|
||||
decision's role, it covers the roleless decision without modification. Nothing
|
||||
in the production code needs to change for
|
||||
`REQ-OFFICE-SEAT-GUARD-001` to hold - which is why that requirement is written
|
||||
as a statement of existing behavior with the reachability named, not as a
|
||||
change order.
|
||||
|
||||
No shipped caller produces a roleless decision: both office entry points
|
||||
resolve a role first and refuse the request when they cannot. The path is
|
||||
supported by the store and unused by the product, which is the precise sense in
|
||||
which this is latent.
|
||||
|
||||
## Making the window reachable
|
||||
|
||||
`AC-OFFICE-SEAT-GUARD-002.2` requires the test to enter the window
|
||||
deterministically. Two approaches were considered.
|
||||
|
||||
**Racing real goroutines** on the roleless path would genuinely reach the
|
||||
window, because that path takes no exclusion. It is rejected: nothing forces
|
||||
the interleaving, so the test would pass most of the time by never entering the
|
||||
window at all - which is the same defect being fixed here, reintroduced in a
|
||||
different test. A sibling follow-up already records this failure mode against a
|
||||
neighbouring concurrency test.
|
||||
|
||||
**A test-only interleaving hook** is the design. An unexported package-level
|
||||
function value in the office repository's sqlite package, called between the
|
||||
select and the update, is nil in every build that does not set it. The
|
||||
test-only `SetClaimWindowHook` setter in `export_test.go` makes the yield point
|
||||
available to the external `sqlite_test` package. The SQLite test writes the
|
||||
roleless decision through the in-flight transaction. The Postgres-gated test
|
||||
uses the workflow repository from the hook, so that decision commits through a
|
||||
second pooled connection before the update runs.
|
||||
|
||||
Three properties make this acceptable rather than a production concession, and
|
||||
they map to `AC-OFFICE-SEAT-GUARD-002.3`:
|
||||
|
||||
- The variable is unexported, and its setter is compiled only into the test
|
||||
binary, so production code cannot set it and it needs no build tag.
|
||||
- Unset, the call site is a nil check on a value that is never written, so the
|
||||
production path is byte-for-byte the behavior that ships today.
|
||||
- It carries no behavior of its own. It is a yield point, not a policy hook,
|
||||
and nothing production reads it.
|
||||
|
||||
The hook is placed in the registration's claim attempt, between the statement
|
||||
that chooses the seat and the one that reassigns it. That is the only window
|
||||
the requirement is about.
|
||||
|
||||
If the sibling follow-up's claim-target-removed test wants the same yield
|
||||
point, it is the same hook at the same site. That is a welcome economy and not
|
||||
a dependency in either direction - neither card's tests need the other's.
|
||||
|
||||
## Data and contracts
|
||||
|
||||
No schema change, no production interface change, and no production export. The
|
||||
test binary exposes only the setter needed by the external-package tests. The
|
||||
condition on the update, the select's filter and the seat exclusion all stay
|
||||
exactly as they are.
|
||||
|
||||
## Control flow
|
||||
|
||||
Unchanged in production. Under test, one nil-valued call site between two
|
||||
existing statements of an existing transaction. The SQLite test uses that
|
||||
transaction directly; the Postgres test uses a separate pooled connection to
|
||||
prove a real cross-transaction commit.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
Unchanged. A registration that finds the seat decided at update time affects
|
||||
zero rows, falls through to inserting a new seat, and reports success - the
|
||||
existing fallthrough, which `AC-OFFICE-SEAT-GUARD-001.5` and `-001.6` describe
|
||||
rather than modify. Nothing is retried and nothing is failed.
|
||||
|
||||
## Persistence
|
||||
|
||||
No schema change, no migration, no backfill. The registration's transaction
|
||||
boundaries and the exclusion it acquires are untouched.
|
||||
|
||||
## Security
|
||||
|
||||
No new surface. The hook is unexported, unset in production, and carries no
|
||||
input from any request.
|
||||
|
||||
## Observability
|
||||
|
||||
Nothing new. The declined-claim outcome is already indistinguishable from "no
|
||||
claimable seat existed" by design (`AC-OFFICE-SEAT-GUARD-001.5`), and this
|
||||
design does not make it distinguishable - a new signal there would report a
|
||||
path no shipped caller reaches.
|
||||
|
||||
## Verification obligations
|
||||
|
||||
Three changes to the suite, and one of them is a deletion of a claim rather
|
||||
than of a test:
|
||||
|
||||
1. **The new guard test** (`-002.1`, `-002.2`). Drives a registration with the
|
||||
hook set, writes a roleless decision against the chosen seat inside the
|
||||
window, and asserts the decision and seat are unchanged while a new seat is
|
||||
written for the registering agent. The SQLite test writes through the open
|
||||
transaction; the Postgres-gated variant commits from a second connection.
|
||||
Removing the `NOT EXISTS` condition must make these tests fail; that is the
|
||||
acceptance check for the tests themselves, and should be performed once by
|
||||
hand when they are written.
|
||||
2. **The existing concurrency test's documentation** (`-002.4`, `-002.5`). Its
|
||||
stated premise - that decision recording and registration lock on
|
||||
non-contending namespaces - is false since the shared exclusion landed. It
|
||||
is re-documented as establishing what it can: that the two writers serialize
|
||||
on the shared exclusion, complete without deadlock in either acquisition
|
||||
order, and leave no reattributed decision. The test's assertions already
|
||||
support that reading; only its docstring asserts more than it shows.
|
||||
3. **No test is deleted or weakened.** The existing concurrency test keeps its
|
||||
iterations and its multi-connection pool - serialization under real
|
||||
concurrency is worth covering, and the deadlock question is real, since the
|
||||
decision path acquires two exclusions in a fixed order and the registration
|
||||
path acquires one of them.
|
||||
|
||||
## Related decisions
|
||||
|
||||
- [ADR 0005 - agent model unification](../../../decisions/0005-agent-model-unification.md)
|
||||
@@ -0,0 +1,227 @@
|
||||
---
|
||||
status: draft
|
||||
system: office
|
||||
requirements:
|
||||
- REQ-OFFICE-SESSION-TERM-001
|
||||
- REQ-OFFICE-SESSION-TERM-002
|
||||
- REQ-OFFICE-SESSION-TERM-003
|
||||
- REQ-OFFICE-SESSION-TERM-004
|
||||
---
|
||||
|
||||
# Office Task Session Termination System Design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
Office owns the rule that a `(task, agent)` pair names one durable conversation
|
||||
and owns the three paths that end one. This design adds a precondition to those
|
||||
three paths and changes nothing else.
|
||||
|
||||
Contracts this design uses but does not own: the session row's storage and
|
||||
state machine (task system); the participant seat store and the exclusion that
|
||||
protects it (workflow system); the effective-runner resolution shared by every
|
||||
reader of a task's runner (office task projection). None of the three is
|
||||
modified.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design section |
|
||||
| --- | --- |
|
||||
| `REQ-OFFICE-SESSION-TERM-001` | [Where the precondition lives](#where-the-precondition-lives), [Control flow](#control-flow) |
|
||||
| `REQ-OFFICE-SESSION-TERM-002` | [The capacity question](#the-capacity-question) |
|
||||
| `REQ-OFFICE-SESSION-TERM-003` | [Control flow](#control-flow) |
|
||||
| `REQ-OFFICE-SESSION-TERM-004` | [Observability](#observability) |
|
||||
|
||||
## Components and responsibilities
|
||||
|
||||
- **The office dashboard service** owns the three guarded call sites
|
||||
(`internal/office/dashboard/service_tasks.go`) and gains one new private
|
||||
helper that answers the capacity question. It already holds the office
|
||||
repository, which is the only component that can answer it.
|
||||
- **The orchestrator's session terminator**
|
||||
(`internal/orchestrator/office_session_terminator.go`) is unchanged. It stays
|
||||
the dumb executor of "flip this pair's row to `COMPLETED`, idempotently", and
|
||||
keeps both its methods and its two interfaces exactly as they are.
|
||||
- **The office repository** supplies the two reads the question needs. Both
|
||||
already exist: the task's execution fields, whose assignee field is the
|
||||
shared effective-runner projection, and the task's effective participant
|
||||
slate at its current step.
|
||||
|
||||
## Where the precondition lives
|
||||
|
||||
The guard goes in the dashboard service, immediately before each
|
||||
`TerminateOfficeSession` call, not inside the terminator.
|
||||
|
||||
The terminator is in `internal/orchestrator` and reaches the session repository
|
||||
only. The capacity question needs participant seats and the runner projection,
|
||||
both owned by `internal/office/repository/sqlite`, which the orchestrator does
|
||||
not and should not import - the `SessionTerminator` interfaces exist precisely
|
||||
so office does not import orchestrator and orchestrator does not import office.
|
||||
Pushing the question down would either invert that dependency or widen the
|
||||
interface with a callback, for no gain.
|
||||
|
||||
Putting it in the service also keeps the two cascade paths correct by
|
||||
construction. `TerminateAllForAgent` is a different method on the same type
|
||||
with different callers (agent deletion, config-sync reconciliation), and
|
||||
`AC-OFFICE-SESSION-TERM-003.1` requires it to stay unguarded. A guard inside
|
||||
the shared terminator would have to be argued out of that method; a guard in
|
||||
the dashboard service never reaches it.
|
||||
|
||||
`AC-OFFICE-SESSION-TERM-001.5` requires one implementation. The three call
|
||||
sites are three lines in one file calling one unexported helper on the same
|
||||
receiver; there is no second copy to drift.
|
||||
|
||||
## The capacity question
|
||||
|
||||
One helper, one signature: given a task and an agent profile, does the agent
|
||||
still hold a capacity on that task?
|
||||
|
||||
**The runner capacity** is read from the task's execution fields, whose
|
||||
assignee value is produced by the shared runner projection
|
||||
(`RunnerProjection`). `AC-OFFICE-SESSION-TERM-002.2` requires this rather than
|
||||
a direct read of runner seats, and the reason is specific: that projection has
|
||||
three precedence tiers, and its last tier deliberately reads runner rows across
|
||||
*all* steps, ordered by creation time. A hand-written "is there a runner seat
|
||||
naming this agent" query would therefore disagree with the projection exactly
|
||||
when a task has moved between steps - which is the case the guard is most
|
||||
likely to meet. Reusing the projection makes disagreement impossible instead of
|
||||
unlikely.
|
||||
|
||||
**The seat capacity** is read from the task's effective slate at its current
|
||||
step (`ListAllTaskParticipants`), which already applies per-task precedence
|
||||
over template-level rows and already scopes to the current step. That scoping
|
||||
is what `AC-OFFICE-SESSION-TERM-002.3` requires and it is load-bearing: seats
|
||||
are keyed `(step, task, role, agent)` and are not cleaned up when a task
|
||||
leaves a step, so an unscoped read would find a seat naming a previous occupant
|
||||
and suppress a correct termination permanently. The slate read is also what
|
||||
`AC-OFFICE-SESSION-TERM-002.4` and `-002.5` fall out of: it already merges
|
||||
template rows in, and it already returns every role.
|
||||
|
||||
Neither read is ordered-dependent (`-002.7`): the helper asks whether any
|
||||
returned row names the agent, and stops. Both are plain reads on the read pool,
|
||||
taking no lock and joining no transaction - the guard is not trying to be
|
||||
atomic with anything, and `AC-OFFICE-SESSION-TERM-001.10` explains why it does
|
||||
not need to be.
|
||||
|
||||
**Failure is closed** (`-001.7`). Either read failing means "do not terminate",
|
||||
recorded and swallowed. This inverts the usual best-effort default in these
|
||||
call sites, deliberately: the surrounding code swallows a *termination*
|
||||
failure because a session left live is recoverable, and the same reasoning says
|
||||
a session wrongly ended is not.
|
||||
|
||||
## Control flow
|
||||
|
||||
Each guarded path already commits its mutation before it reaches the
|
||||
termination step, which is what `AC-OFFICE-SESSION-TERM-001.2` needs and why no
|
||||
reordering is required:
|
||||
|
||||
1. **Role removal.** The participant delete commits, then the service asks the
|
||||
question and terminates only on "no capacity". The removed role is already
|
||||
absent from the slate.
|
||||
2. **Seat claim.** The claim transaction commits with the seat's agent profile
|
||||
already reassigned to the claiming agent, then the post-commit effects run
|
||||
on a cancellation-detached context. The displaced agent no longer holds that
|
||||
seat when the question is asked. The question is inserted before the
|
||||
existing termination call and leaves the displaced-run cancellation beside
|
||||
it untouched (`AC-OFFICE-SESSION-TERM-003.4`).
|
||||
3. **Reassignment.** The assignee update commits - which also rewrites the
|
||||
runner seat at the task's current step - then the reactivity pipeline runs,
|
||||
then the termination step. By the time the question is asked, the projection
|
||||
already returns the new runner, so the previous runner is retained only if
|
||||
it holds a seat in another role.
|
||||
|
||||
The third is where `AC-OFFICE-SESSION-TERM-003.2` matters. The reactivity
|
||||
pipeline's hard cancel of the previous runner's running execution happens
|
||||
*before* the termination step and is not guarded. An agent reassigned away from
|
||||
the runner role but still seated as a reviewer stops the coding turn it no
|
||||
longer owns and keeps the row it still reviews through. Those are two different
|
||||
objects, and the design keeps them decoupled on purpose.
|
||||
|
||||
### Concurrency
|
||||
|
||||
`AC-OFFICE-SESSION-TERM-001.10` is satisfied without coordination, and the
|
||||
argument is worth recording because the obvious reading is that two concurrent
|
||||
removals could both suppress.
|
||||
|
||||
Each path reads strictly after its own commit. Take two paths removing
|
||||
different capacities from the same agent: whichever commits second issues its
|
||||
read after both commits are visible, observes neither capacity, and terminates.
|
||||
The first may well suppress - it can still see the second's capacity - but the
|
||||
outcome is the pair ending with a terminated session either way. The window
|
||||
therefore closes itself, and adding a lock or a re-read would buy nothing.
|
||||
|
||||
This depends only on each read being a fresh statement issued after that
|
||||
caller's own commit, which holds on both engines under their default isolation.
|
||||
It does not depend on which caller reads first.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
- A failed capacity read suppresses the termination and is recorded
|
||||
(`-001.7`, `-004.2`). The session row stays live and is re-evaluated by the
|
||||
next guarded path that runs on the pair.
|
||||
- A suppressed termination is not an error to its caller (`-001.3`). All three
|
||||
call sites already treat termination as best-effort and log rather than
|
||||
propagate; suppression is quieter than that and returns success.
|
||||
- A session left live because a capacity was retained needs no recovery: it is
|
||||
the conversation the agent is still using, and the existing reuse rules will
|
||||
hand it back on the agent's next wakeup.
|
||||
- A session left live by a *mistaken* retained-capacity answer is the residual
|
||||
risk this design accepts, and `-004.1`'s record with the retained capacity
|
||||
named is how it is diagnosed.
|
||||
|
||||
## Persistence
|
||||
|
||||
No schema change. No new column, index, migration or backfill
|
||||
(`AC-OFFICE-SESSION-TERM-003.3`). The guard adds two reads on the read-only
|
||||
pool per guarded termination and removes some writes; it opens no transaction
|
||||
and takes no lock.
|
||||
|
||||
Sessions already ended by the pre-existing behavior are left as they are - the
|
||||
requirement's "Out of scope" says so explicitly, and no repair is authorized.
|
||||
|
||||
## Security
|
||||
|
||||
No new surface. All three guarded paths already enforce their own
|
||||
authorization before reaching the termination step - the participant mutators
|
||||
behind the approve-permission gate, reassignment behind the assign-tasks gate -
|
||||
and the guard runs after those checks, never instead of one. The question reads
|
||||
only state the caller has already been authorized to mutate.
|
||||
|
||||
## Observability
|
||||
|
||||
Suppression is logged at the existing call sites' logger with the task, agent
|
||||
profile, guarded reason and which capacity was retained (`-004.1`), and a read
|
||||
failure is logged distinguishably (`-004.2`). Both follow the structured-zap
|
||||
idiom the surrounding code already uses.
|
||||
|
||||
A counter under the office namespace counts suppressions, labelled only by
|
||||
bounded dimensions - the guarded reason and which capacity was retained
|
||||
(`-004.3`). Neither is unbounded: the reasons are a fixed set of three and the
|
||||
capacity is one of two. No task, agent, step or session identifier is a label,
|
||||
matching `AC-OFFICE-REVIEW-SEATS-004.6`'s rule for the same reason.
|
||||
|
||||
Terminations that proceed keep their existing reason values and log line
|
||||
(`-004.4`), so the two outcomes remain comparable in one place.
|
||||
|
||||
## Verification obligations
|
||||
|
||||
The capacity question's own tests are ordinary. Two are worth naming because
|
||||
they are the ones that fail silently if written casually:
|
||||
|
||||
- The **step-scoping** criterion (`-002.3`) needs a task that has *moved*: a
|
||||
seat written at a step the task has left, naming the agent, must not suppress
|
||||
a termination. A test that never moves the task passes whether or not the
|
||||
scope is bounded.
|
||||
- The **decoupling** criterion (`-003.2`) needs a reassignment of an agent that
|
||||
keeps a reviewer seat, asserting both halves: the execution is cancelled
|
||||
*and* the session row is untouched. Asserting only the second passes for an
|
||||
implementation that wrongly suppresses both.
|
||||
|
||||
The shipped-configuration reachability described in the requirement's overview
|
||||
also gives a direct regression test that needs no flag: with
|
||||
`features.officeSessionIdentity` off, an agent that is both runner and
|
||||
auto-cast reviewer holds the task's only session, and displacing it from the
|
||||
reviewer seat must leave that session live.
|
||||
|
||||
## Related decisions
|
||||
|
||||
- [ADR 0005 - agent model unification](../../../decisions/0005-agent-model-unification.md)
|
||||
Reference in New Issue
Block a user