feat(conductor): op-shaped dashboard tools + durable inbox/dispatch record - #6237
feat(conductor): op-shaped dashboard tools + durable inbox/dispatch record#6237iamwhatever wants to merge 1 commit into
Conversation
…ecord The kirocrew-dashboard server advertised eight tools -- four folder verbs and four session verbs -- and `tools/list` is read once per session, so every stub is a standing context cost for that whole session whether or not the capability is used. Collapse them into two op-shaped tools, `chat_folder_ctl(op, args)` and `session_ctl(op, args)`, the same shape the `browser` tool already uses. The eight handler bodies are untouched: each is still the same endpoint call validated by the same per-op schema, so this is a surface change. TWO tools rather than one, deliberately. Channel containment (`CHANNEL_AGENT_BLOCKED_TOOLS`) matches on the tool NAME against rendered permission text and never sees an `op`, so one merged name would make that block all-or-nothing: either a channel agent regains session control -- the exact regression the old hand-copied per-site list already caused once -- or it loses the folder organization it has today. Two consequences are implemented rather than left to prose: * The caller-identity gate runs on the RESOLVED op. Gating on the op-shaped name would gate folder work too; gating before translation would leave every session op ungated. Its list (`SESSION_CONTROL_INNER`) is derived from the op->handler map, so an op added later is identity-gated the moment it is mapped. * The advertised enum, the op->handler map, and the refusal text for an unknown op all read the same two tuples, so an op cannot be half-added. Also adds `scripts/queue.py` to the goal-conductor skill: the durable inbox and dispatch record. It holds the two things that previously lived only in the skill's prose, and therefore only in context, which is what compaction takes away -- a mid-flight user message parked until the round boundary, and whether an item was already dispatched. `dispatch_begin` returns the SAME id on a retry and names the unseeded window; a `claim` does not delete, so a turn that dies after claiming leaves the message recoverable. Dispatch is detectable-and-convergent, NOT atomic: the create/send pair are MCP calls the model makes, so only gateway-side dispatch would make a duplicate impossible, and that is left out of scope. The script reads no identity, opens no socket and runs no subprocess -- pinned by a source ratchet, since that is the property that makes it safe as a script rather than a tool. Docs: `docs/system-specs/modules/session-control.md` described the four session verbs as the advertised surface and its table omitted `send` entirely (a gap that predates this change); it now documents the ops, their routes and their internal handler names. `docs/architecture/mcp.md` listed only the four folder tools for this server. Design of record: `docs/request-for-change/rfc-conductor-op-tool-and-script-boundary.md`. Tests: op routing for all eight verbs against an explicit expected map, unknown op refused without touching the gateway, non-object `args` refused, identity gate ordering in both directions, per-op schema validation, containment coverage by tool name plus the deliberate non-block of `chat_folder_ctl`, and 26 cases for queue.py including the message-survives-a-dead-turn and retry-converges-on-one-id properties.
541c0d4 to
78a211e
Compare
|
🤖 Kiro Crew [operator: bolichen97#bb3ad1ca]: This PR was flagged for triage with a merge conflict. I reviewed the blockers but they require your input:
When you've rebased and settled the approval-mapping question, the pipeline will re-assess on its next cycle. |
bolichen97
left a comment
There was a problem hiding this comment.
Description / code mismatch
The Description presents the new queue.py test module as complete, passing evidence for the script, but the module is POSIX-only and fails at import time on Windows, where the backend test job collects it.
1. New queue.py test module is POSIX-only and errors the Windows backend-test job at collection
The Description says —
26 cases for
queue.py, including the two properties that matter — a claimed message survives a turn that never callsdone, and a retrieddispatch_beginconverges on one id — plus goal-id traversal refusals and the no-identity source ratchet.
and, for the evidence behind it:
Local: 9,091 passed in the affected area (mcp / channel / conductor / validation / session_control / skill). flake8, isort, mypy clean.
The code does — the module reaches for POSIX-only behaviour on the import and collection path rather than inside a guarded test. test/test_conductor_queue.py:269 opens the unreadable-state case, state.chmod(0o000) at test/test_conductor_queue.py:274 relies on POSIX mode bits meaning "unreadable" (a 0o000 file remains readable by its owner on Windows, so the case cannot express what it asserts), and the root check that would otherwise skip it calls os.geteuid() at test/test_conductor_queue.py:52 — an attribute that does not exist in os on Windows, so it raises AttributeError where it is evaluated. test/test_conductor_queue.py:89 compares state_path against a /-separated suffix via endswith, which never matches a Windows path built with \.
Nothing excludes the module from the Windows run. test/conftest.py:142 builds collect_ignore from test/windows-collect-ignore.txt — the repository's documented mechanism for exactly this case, whose own header records that a skip marker cannot prevent an import-time failure — and test_conductor_queue.py is not listed in it.
Risk — module import happens before pytest-split deselects by --group, so every Windows shard errors, not only the one that would own this file. A collection error takes the rest of that shard's work with it. No backend or lint check-run exists on this head SHA, so nothing in CI has contradicted the Description's local-only pass claim yet; the breakage stays invisible until the PR becomes mergeable and CI actually runs. AGENTS.md states the backend supports Windows natively, and ci.yml documents the Windows job as the line that keeps a POSIX-only regression from landing silently.
Required change — either make the module run on Windows or make the Description's claim conditional, and in code:
- guard the module (or the three affected tests) with
pytest.mark.skipif(sys.platform == "win32", …), following the existing_POSIX_ONLYprecedent; - replace
os.geteuid()withgetattr(os, "geteuid", lambda: 1)(), the pattern already used attest/test_tailnet_peer.py:557; - compare
state_pathusingPath(...)parts rather than a/-separatedendswith.
Adding the file to test/windows-collect-ignore.txt is the alternative if the cases are meant to stay POSIX-only, but the skip guard is the narrower fix because it keeps the platform-independent cases running there.
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
|
Closing this in favour of #8489, which lands the half of it that is still valid. What is being kept. What is being abandoned, and why. The other half collapsed the eight The RFC document from this branch is not carried over. If the op-shaped surface |
Pull request was closed
What
Two changes that share one design note (
docs/request-for-change/rfc-conductor-op-tool-and-script-boundary.md):kirocrew-dashboardadvertises two op-shaped tools instead of eight verbs.chat_folder_ctl(op, args)andsession_ctl(op, args), same shape as theexisting
browsertool. The eight handler bodies are untouched — each is thesame endpoint call validated by the same per-op schema — so this is a surface
change, not a rewrite.
scripts/queue.pyin the goal-conductor skill: the durable inbox anddispatch record.
Why the surface change is worth it
tools/listis read once per session, so every stub is a standing context costfor that whole session whether or not the capability is ever used. Six of the
eight go away. The per-op detail moves into the skill, which is loaded on demand.
The alternative that motivated this — moving session control out of MCP into
bundled scripts — was rejected on a boundary that is easy to miss and is written
down in the RFC: every identity source the strict resolver accepts is injected by
the gateway into the MCP server process, and a child of
execute_bashhasnone of them (measured:
KIROCREW_SESSION_KEYabsent,KIROCREW_HOST_PIDpresent). A script could therefore only assert which session it is. The op
shape gets the stub reduction that idea was after without giving up a live
authorization check: under
session_ctlthe tool-call hook still sees a tool nameplus real args, so it can apply per-op rules; under a script it would see one
execute_bash python3 …and the arguments would be inside the process.Why TWO tools and not one
Collapsing all eight into one name looked cleaner and is wrong. Channel
containment (
channel.CHANNEL_AGENT_BLOCKED_TOOLS) matches on the tool nameagainst rendered permission-request text and never sees an
op. One merged namewould make that block all-or-nothing: either a channel agent regains session
control — the exact regression the old hand-copied per-site list already caused
once, where
session_createwas identity-gated but reachable from a channel — orit loses the folder organization it has today. Splitting on the capability class
keeps the block exact and still removes six of the eight stubs.
Two consequences are implemented rather than left to prose:
name would gate folder work too; gating before translation would leave every
session op ungated. Its list (
SESSION_CONTROL_INNER) is derived from theop→handler map, so an op added later is identity-gated the moment it is mapped —
that derivation replaces the hand-copied list whose per-site drift caused the
earlier regression.
all read the same two tuples, so an op cannot be half-added.
queue.py — and what it does NOT do
It holds the two things that previously lived only in the skill's prose, and
therefore only in context, which is exactly what compaction takes away:
boundary; between arrival and that boundary the message lived nowhere. Now
enqueueparks it,claimdrains it at the boundary,donedrops what wasapplied. A claim does not delete — a turn that dies after claiming leaves
the message recoverable, and a later claim re-serves anything claimed longer ago
than
stale_secs. A full inbox and an oversize message both refuse: silentlydropping a message the user typed is the failure this exists to prevent, and half
a steering instruction can invert its meaning.
dispatch_beginpre-assigns the idand returns the same id on a retry (a fresh id per attempt is what opens two
sessions for one item), flagging the window where a session may exist with no
seed — which otherwise looks exactly like a session that is merely quiet.
Stated plainly, in the script's own docstring and in the RFC: dispatch is
detectable-and-convergent, not atomic. The
create/sendpair are MCP callsthe model makes, so only moving dispatch into gateway-side code would make a
duplicate impossible. That is deliberately out of scope here.
It touches no identity by construction — no session key, no gateway
credential, no SEL trust root, no socket, no subprocess. That is the property that
makes it safe as a script rather than a tool, so it is pinned by a source ratchet
rather than trusted to review.
Docs
docs/system-specs/modules/session-control.mddescribed the four session verbsas the advertised surface, and its table omitted
sendentirely — a gap thatpredates this change. It now documents the ops, their routes, and their internal
handler names (unchanged, and still what the audit trail and error strings say).
docs/architecture/mcp.mdlisted only the four folder tools for this server.Tests
New and updated:
read
_OP_TO_INNERto check_OP_TO_INNERwould pass on any mapping).gateway; missing op the same; non-object
argsrefused.caller is refused and never posts; a folder op is not caught by that gate.
op="send"with nomessageraises).session_ctl, coverage ofall four ops by that one name, and the deliberate non-block of
chat_folder_ctl.queue.py, including the two properties that matter — a claimedmessage survives a turn that never calls
done, and a retrieddispatch_beginconverges on one id — plus goal-id traversal refusals and theno-identity source ratchet.
Local: 9,091 passed in the affected area (mcp / channel / conductor / validation /
session_control / skill). flake8, isort, mypy clean. New files are black-clean; the
six pre-existing files this touches are already in
.github/black-baseline.txtandfail identically on a clean
main, so no new offender is introduced and nounrelated reformatting is carried.
Post-open review: measured, and two defects fixed
The claims above were asserted, so they were checked. Head moved
541c0d401→78a211e90.1. The advantage is real but smaller than "8 → 2" sounds. Measured on the real
_tool_definitions()output at both commits:tools/listpayloadThe stub count fell 75%; the cost fell 35%. The gap is deliberate — the two
descriptions keep enough per-op detail to be driven by an agent carrying no
conductor skill — but "six of eight stubs go away" must not be read as a 75%
saving. The RFC now carries this table instead of the earlier unquantified claim.
2. The authorization claim holds, with one honest limit.
hooks.py'son_tool_call(tool_name, *, raw_params: dict | None = None, …)does receive realarguments, so a per-op rule is expressible under one tool name. Nothing today keys
a rule on
op; the value is that the op shape KEEPS that possible where a scriptwould foreclose it. Also verified: no governance, security, config or frontend
surface keyed on the eight retired tool names, so nothing became dead config.
3. Two real defects in
queue.py, found by reviewing it adversarially ratherthan re-reading the design. Both are mutation-verified — the fix reverted, the
new test observed to fail, the fix restored:
_readreturned a BLANK record for anyOSError, andevery mode then wrote that blank back — so a state file that merely could not be
read this time was overwritten, destroying exactly the parked messages the
script exists to keep. Now only absent reads as blank; unreadable raises and
is surfaced as
state_unreadablewith the bytes left untouched. Mutation: withthe old behaviour restored the new test fails
assert True is False, i.e. theenqueue succeeded in overwriting.
_Lock.__enter__hadexcept OSError: continuearoundthe staleness
stat, skipping both the deadline check and the sleep — astatthat keeps failing spins a core forever instead of answering
locked. It nowfalls through to the deadline and, deliberately, does not steal a lock whose
staleness it cannot judge. Mutation: with the old code restored the new test is
killed by a 25s timeout; with the fix it completes in under a second.
Five new cases cover both, plus the stale-lock steal that keeps a crashed holder
from wedging a goal forever.
test_conductor_queue.pyis now 31 cases; theaffected-area suites are 229 passed; flake8, isort, mypy and black clean on both
new files.