feat(session-control): escalate to the human as a peer (session_escalate) - #8613
feat(session-control): escalate to the human as a peer (session_escalate)#8613CrysisDeu wants to merge 1 commit into
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound verb-of-its-own design with strong recovery story; the one real risk is the card promising a default action the system never guarantees runs. Watch
Suggestions
[DESIGN-REVIEWED] 516ed6a |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/crew_conversation.py:569 -- after a reply or elapsed deadline, False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of I have what I need: I've read the contract, the intent file, the full spec/diff hunks for the backend verb, the index, the reply-matching machinery, and the frontend, and I've counted consumers for each new surface (grepped the patch and the HEAD tree for First-Principles-Verdict: CONCERNS Three surfaces ship ahead of their only consumer or producer, all waiting on the stacked #8614: the conversation endpoint, the roster fields, and the chip-reply merge machinery. What this change shipsIntent: let an unattended crew member raise a decision to its human without blocking or acting silently — an ADDITION.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 516ed6a |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
3be88bd to
0675595
Compare
916eda8 to
2e8d603
Compare
0675595 to
1d1628e
Compare
2e8d603 to
7e122ff
Compare
1d1628e to
032d9e7
Compare
7e122ff to
c7e3957
Compare
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
…ate) A crew member that hits a wall -- a permission it lacks, something only a person can reach, a one-way door -- gets its own verb to raise it to the human: `session_escalate(message, deadline?, default_action?, options?, goal?)`. It never starts a turn: it lands one `escalation` row in the DM thread of the member that owns the caller (a worker's creating member; a plain session's own transcript), mirrors it onto the bell bus with a per-goal group_key, and returns while the caller keeps working. Its own tool, not a reserved `target` of `session_send` (review): a send delivers text the target RUNS as a turn, an escalation writes a row and runs nothing, and a shared name made their policy inseparable. Each policy site now classifies the verb on its own: in SESSION_CONTROL_TOOLS (caller identity), in CHANNEL_AGENT_BLOCKED_TOOLS (on send_notification's grounds -- the bell mirror -- with the backend's linked/mirrored-caller gate agreeing), granted to members (writes only into their own thread), mounted-but-gated for the conductors, own schema in MCP_DASHBOARD_SCHEMAS. `session_send` is back to `target` + `message`; an escalation field on it is refused as unknown, never dropped. The per-member conversation index (crew_conversation.py, #8612) rides here with its consumer: pending records written before the card under a pre-minted row id, `needs_you` derived and projected on the slots frame and the roster, replies matched by `meta.escalation_id` or, for typed text, by exactly-one-pending at the row's position, restore-time reconciliation against the transcript. `options` is the card's contract (#8614 renders it); until then the row is a plain line and the options are readable in the bell mirror, not clickable -- the tool description promises exactly that. An `escalation` row ends the follow-up pill scan so a previous turn's `[OPTIONS:]` chips can never post as the escalation's answer.
|
|
|
|
|
|
|
|
|
|
|
|
|
Problem / Motivation
A crew member that hits a wall — a permission it lacks, something only a person can reach, a one-way door — has had two bad options: ring the bell (a notification with no conversation around it) or write an
[OPTIONS:]line in its own transcript and wait. Neither is how it talks to a peer, and neither leaves a record of what is waiting on the human.Why it matters
Members run with nobody at the keyboard. Without a non-blocking way to raise a decision and carry on, a member either stalls (blocking
ask_question) or acts without saying so. The human, in turn, has no roster-level signal of which member needs them.What changed (motivation → approach → change)
Goal: make the human addressable as a peer — a card in the owning member's DM thread, nothing blocks, a badge on the roster.
Approach (revised on review): the first revision overloaded
session_sendwith a reservedtarget="user". The review showed that does not survive contact with the rest of the surface: two disjoint parameter sets under one name (four fields silently dead unlesstargetwas one literal), two kinds of operation (a send runs a turn on the target; an escalation writes a row and runs nothing), and — the expensive part — policy keyed on the tool name, so escalation inheritedsession_send's containment by accident with no way to write a rule that tells them apart. This revision shipssession_escalateas its own tool and revertssession_sendtotarget+message.What was built:
session_escalate(message, deadline?, default_action?, options?, goal?)→POST /api/session-control/escalate. Notarget; "user" is no longer a reserved word (a session may be titleduser, nothing consults titles). Delivery lands oneescalationrow in the DM thread of the member that owns the caller (a member: its own thread; a worker a member created: the creating member's thread; any other session: its own transcript, no index), mirrors it onto the bell bus (kind: escalation,group_key: escalation:<slug>:<goal>) and returns. Every caller gate ofauthorize_targetapplies with the same codes; the human is not a slot so no target gate does.Each policy site classifies the verb explicitly (spec table in
session-control.md→ "Escalating to the human"): inSESSION_CONTROL_TOOLS(caller identity); inCHANNEL_AGENT_BLOCKED_TOOLS— blocked, not onsession_send's grounds but onsend_notification's (the bell mirror is exactly the reach-the-user path that list closes), and the backend'slinked_session_caller/mirrored_callergate agrees, so letting a channel agent escalate is a deliberate two-site change rather than an accident of a shared name; granted to members in_MEMBER_DASHBOARD_GRANTS(it runs nothing and writes only into the member's own thread — the mid-turn, nobody-at-the-keyboard case is what the verb exists for); mounted-but-gated for both conductors like every other write; own schema inMCP_DASHBOARD_SCHEMAS. An escalation field passed tosession_sendis now refused as an unknown field, never dropped.Three constraints (spec): non-blocking with a veto window (
deadline+default_action= "unless you stop me by t, I do X"; the window closing recordsdefaulted, never a failure; no deadline wakeup — the member that declared the default is the actor that runs it); attention budget (onegroup_keyper goal, no transport-specific switch); escalation is not approval (anything the human must actively grant stays on the approval surfaces).The per-member conversation index (
crew_conversation.py, feat(members): thin per-member conversation index #8612) now rides here with its consumer (its former host fix(members): dispatch server carries the gateway home override #8611 was narrowed to the dispatch-home fix). Record written before the card under a pre-minted row id;needs_you/pending_escalationsderived and projected on theslotsframe andGET /api/members;GET /api/members/{slug}/conversation(owner-only) exposes it. Open decisions per member are capped (MAX_PENDING_ESCALATIONS= 50): pending records are never evicted by the file's size cap, so at the ceiling the next escalation is refused atomically (escalation_backlog_full, 429) with the count, telling a runaway member to wait for answers rather than grow the index without bound; settled records (answered, or past a deadline) free slots. Reply matching is one rule on both sides: a live owneruserrow withmeta.escalation_id(through idle / queue / steer / merged-drain paths; a non-owner entry never launders an id onto an owner row) answers that record; typed text answers only when exactly one record was pending at the row's position; a replayed row answers nothing. A mixed merged batch (chips and typed text drained as one row) is replayed in order at the drain against the pending view, and the row then names every record the sequence answered — so "chip for A, then text" answers A and B exactly as the two replies would have separately, while "text, then chip for A" leaves B pending exactly as the sequence would; with no pending view the row names the chips alone (the under-answering side). Restore-time reconciliation re-derives the index from the transcript (orphans retracted after a grace, durable replies applied, judged at the reply row's own timestamp); live rows not yet flushed count for card presence only — theirhuman_replyis stripped before the replay, so a record is never settled on disk ahead of the reply that settles it — and an answered record whose card survives but whose reply was rewound away is reopened (the transcript is the truth), so reconciliation runs on every generation change, pending or not; cards the transcript holds but the size cap already evicted from the index count as candidates in that replay (never written back), so a reply that was ambiguous when made stays ambiguous. An index file that exists but cannot be read is not an empty index: writers refuse to overwrite it, the roster forces a reconciliation, and the reconciliation rebuilds every card the transcript holds as a real pending record before replaying replies — repaired from the truth, never replaced by a blank. A client POST may name one record via a validatedescalation_id; the pluralescalation_idsis the drain's merge artifact and is stripped from client meta, so one body can never answer every decision at once. The on-loop pending view is bound to the rawKIROCREW_HOMEit was resolved under, so a home change reads as unprimed until the next off-loop prime — never another home's badge — andprime()takes the same per-slug lock the writers hold, so a restore-time prime cannot install a stale view over a writer's fresh one. The live hook's persist-and-mark tasks are serialised per slot in transcript order, so a chip's slow save can no longer let a later typed reply be judged first (and decline against two pending).optionsand the two other choice surfaces (review).optionsis the card's contract — feat(members): chat-profile projection and escalation cards in the DM thread #8614 (stacked on this PR) renders it as the row's own chips and answers withmeta.escalation_id. This PR draws the row on every surface by default:EscalationNoticeis registered in the default renderer registry (so the Crew Members thread — aChatPane, the surface the bell deep-links to — shows the member's markdown, the veto window in words — "Unless you reply by <localized time, with its zone>, Radar will: ", the same actor and clock the bell names — and the options bound to how to answer them ("Reply in this thread with one of these to answer: …"); feat(members): chat-profile projection and escalation cards in the DM thread #8614's interactive card replaces that entry by claiming the role. The bell mirror body says the same things in words (a UTC clock time, no glyphs). The tool description promises exactly that and no click.deriveFollowUpOptionsgains an explicitescalationbranch that ends the pill scan, so an escalation row can never inherit a previous assistant turn's[OPTIONS:]chips (the stale-chip mis-answer the review traced). Theask_questioninteraction is now a stated rule: a card is resolved through its own endpoint and writes no transcript row, so answering it never answers an escalation, and an escalation arriving while a card is open changes nothing about the card.The bell's "Open" lands on the thread.
_mirror_escalationemits/members?member=<crew name>;MembersPagenow consumes that param once the roster loads (opens the member's thread through the same idempotentmemberThreadpath a click uses, then strips the param withreplace), so a notification opens the thread that needs the human rather than an unselected roster.GET /api/members/{slug}/conversation's 400 now carriescode: invalid_member_sluglike every other refusal in that handler.Tests
test/test_escalation.py— delivery into own / creating-member / plain thread with no turn started (any_run_chatcall asserts); a session titleduseris not a collision;session_sendhas no escalation arm (adeadlineon it is refused as unknown, never forwarded); the verb is classified at every policy site (SESSION_CONTROL_TOOLS,CHANNEL_AGENT_BLOCKED_TOOLS,MCP_DASHBOARD_SCHEMAS, member grants, not conductor grants;session_sendschema back totarget+message; escalate schema has notarget; description promises no click); MCP dispatch posts/api/session-control/escalateunder the verified key and renders the non-blocking reply; caller gates + SEL denial audit (escalate:<code>); index record before card,needs_youprojection, reply clears (live) / does not (replayed), merged-drain id laundering, deadline →defaulted, field validation refused before append, bell mirror payload now including the readable options line, route passthrough + 400, roster + conversation endpoint.test/test_crew_conversation.py(moved here with the index).test_mcp_dashboard_registration.py/test_mcp_dashboard_folders.pypin the advertised set withsession_escalate;test_channel_blocked_tools.py's existing "every session-control tool is contained" pin covers the containment entry.website/src/test/deriveFollowUpOptions.test.ts— anescalationrow ends the scan and offers nothing, its ownmeta.optionsnever leak into the bar.test_mixed_merged_batch_replays_chip_then_text_in_orderpins both orders of a mixed drained batch (chip→text answers both; text→chip leaves the second pending) andtest_replay_helper_falls_back_to_the_chips_alone_when_the_view_is_unavailablethe non-member / unprimed fallback;TestPendingViewFollowsTheDataHomepins that aKIROCREW_HOMEchange makes the memoized view unprimed, not another home's.test_restore_sweeps_orphaned_pending_recordsnow also pins that a reply present only in the live window does NOT settle its record on a roster read, and does once persisted.test_reply_marks_settle_in_transcript_order_even_when_saves_contendpins that a chip whose save is slower than the typed reply that follows still marks first, so both records settle.TestPendingCeiling(unit) andtest_backlog_ceiling_refuses_with_backpressure_and_no_row(route) pin the open-decision ceiling: refused at the cap with no row written, settled records do not count, an answer frees a slot.test_client_meta_carries_only_the_validated_singular_escalation_idpins thatescalation_idsand an ungrammaticalescalation_idnever reach the row;test_reconcile_retracts_orphans_defers_young_and_applies_durable_answersnow also pins that rewinding a reply reopens the records the transcript no longer answers, and that the reply coming back re-answers them with their original stamps;test_reconcile_counts_index_evicted_cards_still_in_the_transcriptpins that an index-evicted card still in the transcript keeps an ambiguous reply ambiguous and never reaches the index;test_unreadable_index_is_rebuilt_from_the_transcript_and_never_overwrittenpins that a torn index refuses writers, is rebuilt from the card rows (meta intact, then answered by the transcript's reply) and reads as repaired;test_a_human_reply_that_happens_to_start_with_the_peer_prefix_still_answerspins thathuman_replyalone decides.MembersPage.test.tsx—?member=opens the named member's thread once and strips the param; an unknown name leaves the roster.messageRenderers.test.ts—escalationresolves to a default entry;EscalationNotice.test.tsx— veto line in words (no raw timestamp), options as text, deadline-only and bare variants, defensive meta read. The bell-mirror test asserts the sentence form and the absence of glyphs.Manual verification
Verified live on the previous revision (isolated pod, crew "Radar", KAS backend): card landed in the member's thread, roster and slots frame reported
needs_you: true, index held the pending record, the member's turn continued; a reply in the thread flipped the record toansweredand clearedneeds_you. This revision changes the tool name, route and policy classification, not the delivery path; those are pinned by the tests above.Screenshots / video
The escalation row as the Crew Members thread draws it (the surface the bell deep-links to), rendered through the real
ChatMessageList+ default registry — three variants (deadline + default + options; deadline only; bare) — and the bell mirror body in words. Captured bywebsite/scripts/capture-escalation-notice.mjs(fixturewebsite/capture/escalation-notice.{html,tsx}), which self-checks the claim: three cards claimed by default, the veto line a sentence, no raw ISO timestamp, no glyphs.Light theme
Related Issues
no linked issue: stacked feature work (see Stack).
Stack
fix/member-dispatch-home(base of this branch)feat/escalation-user-peer— backend verb + conversation indexfeat/crew-chat-profile— Crew Members chat projection + escalation card (option chips,needs_youbadge, goal grouping)Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)