Skip to content

feat(session-control): escalate to the human as a peer (session_escalate) - #8613

Open
CrysisDeu wants to merge 1 commit into
fix/member-dispatch-homefrom
feat/escalation-user-peer
Open

feat(session-control): escalate to the human as a peer (session_escalate)#8613
CrysisDeu wants to merge 1 commit into
fix/member-dispatch-homefrom
feat/escalation-user-peer

Conversation

@CrysisDeu

@CrysisDeu CrysisDeu commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

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_send with a reserved target="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 unless target was 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 inherited session_send's containment by accident with no way to write a rule that tells them apart. This revision ships session_escalate as its own tool and reverts session_send to target + message.

What was built:

  • session_escalate(message, deadline?, default_action?, options?, goal?)POST /api/session-control/escalate. No target; "user" is no longer a reserved word (a session may be titled user, nothing consults titles). Delivery lands one escalation row 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 of authorize_target applies 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"): in SESSION_CONTROL_TOOLS (caller identity); in CHANNEL_AGENT_BLOCKED_TOOLSblocked, not on session_send's grounds but on send_notification's (the bell mirror is exactly the reach-the-user path that list closes), and the backend's linked_session_caller / mirrored_caller gate 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 in MCP_DASHBOARD_SCHEMAS. An escalation field passed to session_send is 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 records defaulted, never a failure; no deadline wakeup — the member that declared the default is the actor that runs it); attention budget (one group_key per 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_escalations derived and projected on the slots frame and GET /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 owner user row with meta.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 — their human_reply is 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 validated escalation_id; the plural escalation_ids is 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 raw KIROCREW_HOME it was resolved under, so a home change reads as unprimed until the next off-loop prime — never another home's badge — and prime() 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).

  • options and the two other choice surfaces (review). options is 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 with meta.escalation_id. This PR draws the row on every surface by default: EscalationNotice is registered in the default renderer registry (so the Crew Members thread — a ChatPane, 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. deriveFollowUpOptions gains an explicit escalation branch 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). The ask_question interaction 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_escalation emits /members?member=<crew name>; MembersPage now consumes that param once the roster loads (opens the member's thread through the same idempotent memberThread path a click uses, then strips the param with replace), so a notification opens the thread that needs the human rather than an unselected roster. GET /api/members/{slug}/conversation's 400 now carries code: invalid_member_slug like every other refusal in that handler.

Tests

test/test_escalation.py — delivery into own / creating-member / plain thread with no turn started (any _run_chat call asserts); a session titled user is not a collision; session_send has no escalation arm (a deadline on 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_send schema back to target+message; escalate schema has no target; description promises no click); MCP dispatch posts /api/session-control/escalate under the verified key and renders the non-blocking reply; caller gates + SEL denial audit (escalate:<code>); index record before card, needs_you projection, 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.py pin the advertised set with session_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 — an escalation row ends the scan and offers nothing, its own meta.options never leak into the bar. test_mixed_merged_batch_replays_chip_then_text_in_order pins both orders of a mixed drained batch (chip→text answers both; text→chip leaves the second pending) and test_replay_helper_falls_back_to_the_chips_alone_when_the_view_is_unavailable the non-member / unprimed fallback; TestPendingViewFollowsTheDataHome pins that a KIROCREW_HOME change makes the memoized view unprimed, not another home's. test_restore_sweeps_orphaned_pending_records now 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_contend pins that a chip whose save is slower than the typed reply that follows still marks first, so both records settle. TestPendingCeiling (unit) and test_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_id pins that escalation_ids and an ungrammatical escalation_id never reach the row; test_reconcile_retracts_orphans_defers_young_and_applies_durable_answers now 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_transcript pins 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_overwritten pins 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_answers pins that human_reply alone decides. MembersPage.test.tsx?member= opens the named member's thread once and strips the param; an unknown name leaves the roster. messageRenderers.test.tsescalation resolves 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 to answered and cleared needs_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 by website/scripts/capture-escalation-notice.mjs (fixture website/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.

Escalation row in the member thread, dark

Light theme

Escalation row in the member thread, light

Related Issues

no linked issue: stacked feature work (see Stack).

Stack

  1. fix(members): dispatch server carries the gateway home override #8611 fix/member-dispatch-home (base of this branch)
  2. this PR feat/escalation-user-peer — backend verb + conversation index
  3. feat(members): chat-profile projection and escalation cards in the DM thread #8614 feat/crew-chat-profile — Crew Members chat projection + escalation card (option chips, needs_you badge, goal grouping)

Checklist

  • At most two commits (one is the norm), with a Conventional Commits title (feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable)
  • No secrets, credentials, or internal references in the diff

@CrysisDeu
CrysisDeu requested a review from a team as a code owner September 5, 2026 01:12
@CrysisDeu
CrysisDeu requested review from dwu96 and removed request for a team September 5, 2026 01:12
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of 516ed6a9d109baa02f3d6ba6f80bb7ab5dd6bb79 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

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

  • The card tells the human "Unless you reply by t, member will: X", but the spec itself concedes "defaulted … records that the window closed with a default declared — not that the action ran": a member whose turn ends before t runs nothing, so a human who wanted X and therefore stayed silent is left believing it happened. The gap is disclosed to the member (tool description) but not to the human the card addresses.
  • Settlement-by-inference from free text (exactly-one-pending rule, in-order mixed-batch replay, rewind reopening, counting-only phantoms) is a permanent contract that must stay identical across the live hook, the recovery replay, and the chat projection; it also lets an unrelated typed message silently settle the single open decision. Heavily test-pinned today, but it is the surface most likely to drift once feat(members): chat-profile projection and escalation cards in the DM thread #8614 adds a fourth reader.

Suggestions

  • In EscalationNotice, word the veto line as the member's stated intent ("says it will") rather than a guarantee, and render defaulted as "window closed" — the backend already refuses to vouch for the action having run.

[DESIGN-REVIEWED] 516ed6a

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 516ed6a9d109baa02f3d6ba6f80bb7ab5dd6bb79 and found no blocking issues.

This comment is updated in place on each push.

Review details

FINDING -- src/kiro_crew/crew_conversation.py:569 -- after a reply or elapsed deadline, "The chat projection applies the same rule client-side" is false and the renderer still shows “Needs you” -> Fix: derive the settled state in the changed renderer from later replies and the deadline.
[GPT-REVIEWED] 516ed6a

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 516ed6a9d109baa02f3d6ba6f80bb7ab5dd6bb79: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of 516ed6a9d109baa02f3d6ba6f80bb7ab5dd6bb79 — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

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 api/members/.*/conversation, needs_you|pending_escalations, and escalation_id). The capture harness, screenshots, and i18n additions all follow established repo conventions. Final review follows.

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 ships

Intent: let an unattended crew member raise a decision to its human without blocking or acting silently — an ADDITION.

  1. New session_escalate tool + POST endpoint, own policy classification per site — justified
  2. Escalation card drawn in the member's DM thread by default (EscalationNotice) — justified
  3. Bell mirror with per-goal stacking and a /members?member= deep link — justified
  4. MembersPage consumes that deep-link param once — justified
  5. Persisted per-member conversation.json index + restore-time reconciliation — declared rider (from fix(members): dispatch server carries the gateway home override #8611/feat(members): thin per-member conversation index #8612); see Watch
  6. needs_you/pending_escalations on the slots frame and GET /api/members — zero consumers
  7. GET /api/members/{slug}/conversation — zero consumers
  8. Chip-reply machinery (escalation_id through steer/queue/drain, merged replay) — zero producers until feat(members): chat-profile projection and escalation cards in the DM thread #8614
  9. Backlog ceiling (MAX_PENDING_ESCALATIONS, 429) — justified
  10. Escalation row ends the follow-up-pill scan — justified (closes a named stale-chip mis-answer)

Watch

  • The description's goal names "a badge on the roster", but no badge ships: grep of website/src for needs_you|pending_escalations finds 0 consumers, so until a later PR draws it the only human-visible signal is the bell.
  • Grep of website/src for escalation_id finds 0 POST producers — chips are feat(members): chat-profile projection and escalation cards in the DM thread #8614 — so the free-text rule is the only live answer path; the mixed-batch replay (_replay_merged_escalation_replies, escalation_ids) is exercised by nothing a user can do today.
  • conversation.json is re-derived from the transcript on every generation change and on every process start (_RECONCILED is process-local); the orphan-grace/generation/phantom/reopen machinery is the cost of persisting that second copy. Its defensible causes are the atomic 429 ceiling and the loop-synchronous needs_you read; anything beyond an in-memory view primed by that same derivation is cache.

Subtractions

  • Defer api_member_conversation and its route (routes/agents.py, handlers/members.py) to the PR that fetches it — 0 consumers counted outside test_escalation.py.
  • Defer client acceptance of meta.escalation_id: have _sanitize_escalation_meta (chat_handlers.py) strip both keys unconditionally, and move validated acceptance plus the drain-merge replay to feat(members): chat-profile projection and escalation cards in the DM thread #8614 with its producer — 0 producers counted in this diff.

[FIRST-PRINCIPLES-REVIEWED] 516ed6a

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 516ed6a9d109baa02f3d6ba6f80bb7ab5dd6bb79 — this comment is updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] 516ed6a

Verdict parsed from the review's SHA-scoped output markers for commit 516ed6a9d109baa02f3d6ba6f80bb7ab5dd6bb79.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 516ed6a9d109baa02f3d6ba6f80bb7ab5dd6bb79: <one-sentence reason>

@CrysisDeu
CrysisDeu force-pushed the feat/crew-conversation-index branch from 3be88bd to 0675595 Compare September 5, 2026 01:50
@CrysisDeu
CrysisDeu force-pushed the feat/escalation-user-peer branch from 916eda8 to 2e8d603 Compare September 5, 2026 01:51
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 5, 2026
@CrysisDeu
CrysisDeu force-pushed the feat/crew-conversation-index branch from 0675595 to 1d1628e Compare September 5, 2026 02:07
@CrysisDeu
CrysisDeu force-pushed the feat/escalation-user-peer branch from 2e8d603 to 7e122ff Compare September 5, 2026 02:07
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 5, 2026
@CrysisDeu
CrysisDeu force-pushed the feat/crew-conversation-index branch from 1d1628e to 032d9e7 Compare September 5, 2026 02:27
@CrysisDeu
CrysisDeu force-pushed the feat/escalation-user-peer branch from 7e122ff to c7e3957 Compare September 5, 2026 02:27
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 5, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author
  • The options line reads as maybe-buttons — fixed in f0734a145c1483abedb14b30a536ab88c19cd7fe, the smallest way you named: the options are bound to the answer path in one line — "Reply in this thread with one of these to answer: Grant the role · Ship from the shared runner · Hold" — which also takes your Suggestion: the separate reply hint is shown only when there are no options, so two adjacent muted lines about the same act no longer restate each other. Copy updated in all 12 catalogs; capture self-check asserts the binding.

"I honestly cannot tell if these are buttons… I would NOT dare click 'Grant the role'"

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author
  • "defaulted" records a promise, not an executed action — rebutted for this PR, with the boundary you name carried to the card PR. The index state is a fact about the window: defaulted = "the veto window closed and a default had been declared", expired = "closed with none". Nothing in this PR renders that state to the human — the plain row shows the promise in the member's own words ("Unless you reply by t, Radar will: X") and no completion claim — and the spec pins the actor of record: "no deadline wakeup … the member that declared the default is the one that acts on it; a member whose turn ends before its own deadline must arrange its own wake." The rendering that could over-claim is feat(members): chat-profile projection and escalation cards in the DM thread #8614's escalation_default_applied copy ("the default is now in effect"); I have noted there that it should read as "window closed — Radar said it would: X" rather than as confirmation, which is exactly your suggested framing. A member-side confirmation that the default ran would be a new write from the member's own turn and belongs with the member runtime, not this index.

the card promised "unless you stop me by t, I do X" and the index then tells the human it defaulted.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author
  • The free-text one-pending settle rule concentrates the hard machinery — accepted as the trade-off it is, and documented: with chips not yet shipped, typed text is the only way the human answers, so the rule cannot be dropped in this PR; it fails toward under-answering (the safe side), and every piece of machinery you list (in-order merged-drain replay, phantom eviction candidates, provisional reopen, home-bound snapshots) exists to keep that one rule faithful to transcript order — the spec now states the rule and each mechanism in one place so a later reader can remove them together. Revisiting whether typed text still needs to settle implicitly once feat(members): chat-profile projection and escalation cards in the DM thread #8614's chips are the primary path is the right follow-up; it is a product decision about the human's answer path, so I have raised it in the feat(members): chat-profile projection and escalation cards in the DM thread #8614 review thread rather than filing an issue nobody owns.

this is where the long-term maintenance cost of the feature is concentrated; once #8614's chips are the primary answer path, revisit whether typed text still needs to settle records implicitly.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author
  • Delete append_ref and the ref entry type — accepted and done in f0734a145c1483abedb14b30a536ab88c19cd7fe: append_ref had zero non-test callers and its docstring admitted "a future ref writer"; the function, the ref row in the spec table and its test are gone, and the size-cap tests now exercise the eviction rule through settled escalations (the real evictable kind) instead. The bar you quote — the index "rides here with its consumer" — is the right one and this item failed it.

grep append_ref finds 0 non-test callers (its docstring says "a future ref writer")

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author
  • Delete api_member_conversation, its route and public_view — rebutted: the consumer exists, it is the stacked frontend PR. feat(members): chat-profile projection and escalation cards in the DM thread #8614 (feat/crew-chat-profile, stacked directly on this branch and landing with it) adds api.memberConversation(slug)GET /api/members/{slug}/conversation, the MemberConversationIndex type, and reads payload.entries to drive the escalation cards' state and the roster's pending count (its diff: website/src/api/client.ts + pages/members/*). A grep of website/src on this branch finds no caller because the caller is one PR up the stack, by the same split you credit in item 2 ("escalation card drawn … 12 locales — justified"). public_view is that endpoint's projection. Deleting them here would re-add them in feat(members): chat-profile projection and escalation cards in the DM thread #8614 unchanged.

grep website/src for the conversation endpoint finds 0 callers

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

participants has 0 readers, justified only by "a later goal:<id> conversation", i.e. so-we-can-later.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author
  • defaulted can assert an action that never ran — nothing on the tool surface tells the member to keep the promise — fixed in 8e169ba6e1fe783d56e6c41d284ddb1f4fe8efe8, exactly as suggested: the session_escalate description now says "The promise is yours to keep: nothing wakes you at the deadline, so if your turn may end before it, schedule your own wake (a monitor loop or a one-shot job) — the record will read 'defaulted' whether or not you actually acted", and the MCP success reply adds "Nothing wakes you at the deadline — if your turn may end first, schedule your own wake." The spec's non-goal now states both, and that defaulted records the window closing with a default declared, not that the action ran. The only actor the spec says owns the promise is now told, at the moment it makes it.

nothing on the tool surface (description or MCP success reply) tells the member that.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author
  • Until chips land, a typed reply with ≥2 records pending settles nothing, and no surface says why — accepted as the trade-off it is, and narrowed. It is real only when one member has two or more escalations open at once and the human types instead of naming one; the failure direction is the safe one (nothing is mis-answered; needs_you stays lit until the human replies to each in turn, which is also what the backlog looks like). A per-row "two are open, reply to each" hint would need the row to know the pending set — index state the plain default-registry row deliberately does not read (it is store-free so every surface can draw it); the interactive card in feat(members): chat-profile projection and escalation cards in the DM thread #8614 has that state and shows per-card chips, which is where the ambiguity is resolved for good. Documented in the spec's reply-rule bullet; not widened here.

the human replies, needs_you stays lit, and no surface says why.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author
  • Options mechanics: typed or clicked? — fixed in 8e169ba6e1fe783d56e6c41d284ddb1f4fe8efe8 with the wording you proposed: "To answer, type one of these as a reply in this thread: Grant the role · Ship from the shared runner · Hold" (12 catalogs; capture self-check asserts it).

"I cannot tell whether they are buttons, links, or just words I am supposed to type back"

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author
  • Deadline-only variant states no consequence — fixed in 8e169ba6e1fe783d56e6c41d284ddb1f4fe8efe8: the deadline string now reads "Reply by ; with no reply, nothing happens and the request stays open." — the exact contrast with the veto line, where silence means the member acts. 12 catalogs, test updated, screenshot re-captured.

the reader was unsure "whether it will also act on its own or just wait forever."

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author
  • Time-format drift between row and bell — fixed in 8e169ba6e1fe783d56e6c41d284ddb1f4fe8efe8: _humanize_deadline now renders "Sep 6, 09:42 UTC", the same month-day-then-clock order as the row's "Sep 6, 2026, 9:42 AM UTC", so bell → thread reads as one shape twice (the row keeps the year and the reader's locale; the bell, having no locale, stays UTC).

made the reader double-check they were the same alert; align _humanize_deadline's day-month order with the row's.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author
  • Evidence gap: bell "Open" → member thread appears in no screenshot or recording — rebutted as disproportional for this PR, with the behaviour pinned another way. The deep-link consumption is a routing effect (?member=openMember → the same thread view a roster click renders), not a new pixel; it is pinned by MembersPage.test.tsx (?member=research opens member-research exactly once and strips the param; an unknown name leaves the roster) and the destination surface is the very thread the committed screenshots show. A bell → thread recording needs a live gateway with a real member and a real bell push; that is feat(members): chat-profile projection and escalation cards in the DM thread #8614's end-to-end capture (its card is what the human then acts on), and I would rather not add a second harness for one navigation hop.

a short capture of bell → thread would close it.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

nothing in website/src reads needs_you/pending_escalations (grep: 0 hits outside i18n).

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author
  • The merged-reply machinery exists because the queue drain merges distinct human replies into one row; the PR adapts to that rather than fixing it — accepted as an accurate reading, and deliberately so. The drain's merge (dashboard.merge_queued_messages) predates this PR and serves every slot; changing it here would widen an escalation PR into the chat runner's queue semantics for all users. Adapting — carrying each reply's identity through the merge and replaying the batch in order — keeps this PR's blast radius to its own feature. The spec's reply-rule bullet names the merge as the cause so a future change to the drain knows what it can then remove.

The cause is the drain's merge, out of this change's scope, and the PR should be read as adapting to it, not fixing it.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Grep /conversation|memberConversation across src/ + website/: route, handler, and tests only — 0 consumers

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author
  • Shrink conversation.json to entries alone — rebutted as disproportional here, as last round, with the cost stated again: participants/sessions/conversation_id are part of the record shape the consumer's type already models (MemberConversationIndex in feat(members): chat-profile projection and escalation cards in the DM thread #8614), the upkeep is two small helpers and one pruning line, and removing them is a schema change that ripples into feat(members): chat-profile projection and escalation cards in the DM thread #8614's types and this PR's recovery tests for no behavioural gain — in the file that carried this PR's two residue regressions. append_ref/ref, the one genuinely dead piece, was deleted last round. If the maintainer wants the record narrowed, it is a one-line ruling and I will do it in both PRs at once.

participants, sessions, conversation_id are written, pruned, and echoed by public_view, never read

…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.
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author
  • Valid human replies can leave escalations pending (span=b7169a506a76) — fixed in 516ed6a9d109baa02f3d6ba6f80bb7ab5dd6bb79, as suggested: the content-prefix guard and its _PEER_SEND_PROVENANCE_PREFIX constant are removed from the answer hook; human_reply — stamped server-side only for the authenticated owner and never carried by a peer send — is the whole test. test_a_human_reply_that_happens_to_start_with_the_peer_prefix_still_answers pins that such a reply answers; test_peer_delivered_user_row_does_not_answer still pins that an unstamped peer row does not.

Owner reply starting [sent by session -> authenticated composer stamps human_reply -> answer hook is skipped -> escalation and badge remain pending.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author
  • An unreadable index loses pending decisions (span=d0d689ceda76) — fixed in 516ed6a9d109baa02f3d6ba6f80bb7ab5dd6bb79, as suggested and as the invariant rather than a point fix: _load_index now distinguishes missing (the scaffold is the truth) from unreadable (a file is there but is not an index). Writers (record_escalation, mark_answered) refuse an unreadable file with IndexUnreadable — surfaced as escalation_index_unavailable / a logged skipped mark — and retract_escalation returns False, so nothing ever overwrites the only copy of a recoverable lifecycle. The roster forces a reconciliation whenever the index is unreadable (index_unreadable, regardless of the transcript's generation), and reconcile_with_transcript rebuilds every card row the transcript holds as a real pending record from the row's own meta (id, deadline, default, goal, options, created_ts, from_session, plus the thread's session_key/member), then runs the normal replay and sweep — so the repaired index carries the answers the transcript holds. Phantoms remain only for a readable index's evicted-settled cards. Pinned by test_unreadable_index_is_rebuilt_from_the_transcript_and_never_overwritten: torn file → writers refuse and the bytes stay intact → reconcile rebuilds both cards, answers the one the transcript answers, badge lights for the other, file reads as repaired, second pass rebuilds nothing.

Malformed index with intact transcript cards -> roster reconciliation starts from an empty scaffold and retains cards only as non-persisted phantoms -> badge disappears, and the next write overwrites the recoverable lifecycle.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

after a reply or elapsed deadline, "The chat projection applies the same rule client-side" is false and the renderer still shows "Needs you"

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author
  • Card promises a default the system never guarantees runs — repeat of the Watch item dispositioned on the previous two heads; unchanged position. The veto sentence already reads as the member's declaration ("Unless you reply by t, member will: X"), and rendering defaulted as "window closed" is feat(members): chat-profile projection and escalation cards in the DM thread #8614's card (this PR's row does not re-render on state change). The tool description and success reply already tell the member the deadline does not wake it, so the member is the party that closes this gap by scheduling its own wake.

a human who wanted X and therefore stayed silent is left believing it happened.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author
  • Settlement-by-inference is a permanent multi-reader contract — acknowledged, as on earlier heads. The rule lives in one place (crew_conversation.mark_answered / reconcile_with_transcript), the live hook and the recovery replay call the same code, and it is pinned by tests for the exactly-one-pending rule, mixed-batch order, rewind reopen and phantom counting. feat(members): chat-profile projection and escalation cards in the DM thread #8614 consumes the index's verdict (pending_escalations) rather than re-deriving it, so it is not a fourth implementation of the rule.

a permanent contract that must stay identical across the live hook, the recovery replay, and the chat projection

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author
  • Options affordance — repeat of the earlier Watch; the current copy already frames them as literal text ("To answer, type one of these as a reply in this thread: …"). Quoting each option is a 13-catalog copy change; tappable chips are feat(members): chat-profile projection and escalation cards in the DM thread #8614, which replaces this line entirely. Not re-spinning a review round for interim punctuation.

I cannot tell if they are tappable

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

a veto card (silence acts) and a passive card (silence waits) both head "Needs you"

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

the mismatch made the reader double-check they were the same request

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

until a later PR draws it the only human-visible signal is the bell.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

the mixed-batch replay is exercised by nothing a user can do today.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author
  • Index re-derived on every generation; anything beyond an in-memory view is cache — repeat; position unchanged. The persisted index is what makes the 429 ceiling atomic across processes and makes needs_you a loop-synchronous read on start before any transcript is parsed; the reconciliation is what keeps that persisted copy honest. This head makes the direction explicit: an unreadable index is rebuilt from the transcript, never the reverse.

anything beyond an in-memory view primed by that same derivation is cache.

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Defer api_member_conversation and its route

@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

have _sanitize_escalation_meta strip both keys unconditionally

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

readiness: passed Eligible automated validation passed for the current revision

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants