Promote refusals: cause to the model, receipt-only for live turns, one typed System row for host-issued acts

A supervisor refusal of a chat-issued promote used to reach the owner as a
standalone Ouroboros bubble (WORKSPACE_UNUSABLE ...), reach the model without
its cause, and label the owner's message "Choose a target" with no options.

- Every workspace refusal returns `detail` (cause + repair) composed by
  `workspace_admission.workspace_repair_hint` from the typed source of the
  refused folder; `_persist_promote_rejection` is the single durable writer.
  `_fail_promoted_task_loudly` and `_explicit_workspace_remedy` are gone.
- Placement follows who can narrate: a tool-issued act gets its receipt, the
  failed-call error row and `detail`; a host-issued act (skill card, Swarm,
  picker click, stamped `host_initiated`) gets ONE typed System row
  (`task_not_started` / `task_start_unconfirmed`) in the chat the owner wrote
  in, from the one publication boundary `_handle_promote_chat_to_task` wraps
  around every promote outcome. The skill-repair untyped bubble is removed;
  steer cancel-pending notices obey the same owner-labelled rule.
- The host owns the owner-facing sentence: `project_dialogue.routing_refusal_cause`
  (action + status + reason, e.g. "Not started: the working folder can't be
  used") rides the annotation, the live `message_annotation` frame, history
  replay, `MessageAnnotationOutbound`/`DecisionResponse` and the picker's 409
  body; the browser renders `cause` verbatim and keeps no client table.
- Admission-notice rows stay in the chat they were sent to on replay
  (`room_membership` ignores the never-started task's project binding); the
  "Project · Started" row is announced only after the task is really queued.
- `workspace_root` naming the Ouroboros repository itself maps to the existing
  `workspace="none"` sentinel at the promote tool with a disclosure; subfolders,
  the data drive and every other caller keep the typed refusal.
- Docs (DESIGN, architecture 01/03/04/05/06/12, DEVELOPMENT naming rule),
  generated inventories, python and web tests.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
Anton Razzhigaev 2026-09-16 03:56:03 +03:00
parent acec39f420
commit 2f8aa7d4e2
44 changed files with 1259 additions and 216 deletions

View file

@ -225,7 +225,11 @@ relabel the whole still-working task. A failed child keeps a compact factual
authoritative status. Internal reason codes belong in details and diagnostics,
not compact headlines. Where a card does show a cause, it says it in the owner's
words while the record keeps the machine code; a cause with no sentence yet stays
raw rather than borrowing a wrong one. A terminal whose preserved output was
raw rather than borrowing a wrong one. The routing receipt under an owner
message is such a surface: a refused addressing act carries the host-composed
`cause` sentence (`project_dialogue.routing_refusal_cause` — one host table for
the receipt line, the System row and the picker toast), a landed act carries
none, and an unknown reason stays raw. A terminal whose preserved output was
never reviewed shows that output labelled rather than hidden: a short labelled
excerpt beside the pointer to the full copy, so a `Failed` card over applied work
is never a bare headline and never names preserved bytes without a way to reach
@ -523,7 +527,17 @@ addressing calls, without error, is a receipt row too. So a turn that only
addressed work («turn this into a project») draws no block, live or on reload:
the annotation on the owner message and the managed root's own card are its
whole record (owner decision 11.09). A failed addressing call is an error row
and therefore content, as is any recorded tool error. No client list of tool
and therefore content, as is any recorded tool error. A REFUSED addressing act
is told where the work lives and never in Ouroboros's voice (owner 16.09): the
receipt line states the cause in the owner's words, the failed call stays the
error row inside the block, and the tool result carries the cause with its
repair (`detail`) so the model narrates — no host bubble interrupts a narrating
turn. When the host itself issued the act (a Swarm message, a skill-card repair,
a picker click) no turn narrates, so the refusal lands as ONE typed System row —
`task_not_started`, or `task_start_unconfirmed` when admission could not be
confirmed — in the chat the owner wrote in, keyed to the never-started task,
beside the receipt; the Project start row is announced only once the task is
really queued. No client list of tool
names decides presence (`docs/development/02-naming-and-boundaries.md`, "an
open default behind a closed exception list").

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -154,7 +154,7 @@ A built-in `command` frame carries a slash command and enters the same bridge wi
Built-in outbound envelopes: `chat`, `photo`, `video`, `document`, `typing`, `log`, `heartbeat`, `extension_lifecycle`, `message_annotation`, `projects_changed`, `task_named`, `update_status_ready`, and `update_progress_changed`. The latter only invalidates the process-local update observation; it is never boot-completion proof. Chat progress may carry task lineage, role, requested/effective model lane, delegated route, terminal execution evidence, review projection, cancellation eligibility, outcome axes, artifact references, and nullable cost/finality fields — additive presentation facts; consumers must not infer a missing execution receipt, cost, or task result from the absence of one optional field.
Thread routing is explicit. Project chat, typing, media, and log frames carry `chat_id`; a Project panel consumes its own thread, while Main admits required Project question pointers and the two host-stamped Project lifecycle rows (`project_started`, `project_completion_summary`). `projects_changed` carries a new chat id so every tab can extend its fan-out set before fetching the registry; when even that ordering loses the race, the server-stamped `project_thread` marker on the frame itself keeps Main from adopting it — set once at the message-bus broadcast choke from the registry (a membership lens, never a numeric range, so external transport ids such as Telegram stay unstamped) and enforced by Main's fan-out gate (`chat_activity.mainThreadAccepts`). Task-scoped LOG events acquire their final chat id at supervisor ingress: worker diagnostics carry only their own `task_id`, and `supervisor/log_addressing.py::address_task_event` stamps the audience from host-attested truth (the precedence chain lives in its docstring; an explicit event chat_id of 0 is the hidden partition, `HIDDEN_CHAT_ID`, never "missing"); direct turns carry their chat BY VALUE, stamped at the producer, because the registry entry dies with the turn while queued events drain later. Addressing is honest — an A2A row keeps its true audience, suppressed only at the broadcast choke (`push_log`) so machine traffic never reaches the browser; the same addressing runs in the server-process append sink and at every supervisor handler owning a suppressed type's explicit push, and a genuinely unaddressable event keeps the legacy chat-0 frame. `message_annotation` updates one canonical owner message without creating another bubble; `task_named` updates a card only where that task already exists. Media/document consumers validate MIME, base64, and download-route shapes before building browser URLs.
Thread routing is explicit. Project chat, typing, media, and log frames carry `chat_id`; a Project panel consumes its own thread, while Main admits required Project question pointers and the two host-stamped Project lifecycle rows (`project_started`, `project_completion_summary`). `projects_changed` carries a new chat id so every tab can extend its fan-out set before fetching the registry; when even that ordering loses the race, the server-stamped `project_thread` marker on the frame itself keeps Main from adopting it — set once at the message-bus broadcast choke from the registry (a membership lens, never a numeric range, so external transport ids such as Telegram stay unstamped) and enforced by Main's fan-out gate (`chat_activity.mainThreadAccepts`). Task-scoped LOG events acquire their final chat id at supervisor ingress: worker diagnostics carry only their own `task_id`, and `supervisor/log_addressing.py::address_task_event` stamps the audience from host-attested truth (the precedence chain lives in its docstring; an explicit event chat_id of 0 is the hidden partition, `HIDDEN_CHAT_ID`, never "missing"); direct turns carry their chat BY VALUE, stamped at the producer, because the registry entry dies with the turn while queued events drain later. Addressing is honest — an A2A row keeps its true audience, suppressed only at the broadcast choke (`push_log`) so machine traffic never reaches the browser; the same addressing runs in the server-process append sink and at every supervisor handler owning a suppressed type's explicit push, and a genuinely unaddressable event keeps the legacy chat-0 frame. `message_annotation` updates one canonical owner message without creating another bubble — a refused routing act's frame, its replayed annotation and the picker's 409 `dispatch_rejected` body all carry the host's `cause` sentence beside the machine `reason`; `task_named` updates a card only where that task already exists. Media/document consumers validate MIME, base64, and download-route shapes before building browser URLs.
Extension WebSocket traffic is structurally namespaced by `extension_loader.extension_surface_name()` so an extension cannot shadow a built-in type. On each incoming extension frame the gateway resolves the owning skill and reconciles whether its extension is still desired, reviewed, granted, enabled, and live. A missing or failed handler returns a visible log frame. Out-of-process handlers execute in their extension child off the event loop; in-process handlers first record the required execution/cost disclosure. A non-`None` result returns as `<request-type>.reply`; exceptions become typed error log frames rather than terminating the socket loop.

View file

@ -4,7 +4,7 @@ This chapter owns the single scheduler for pooled work: what a healthy tick does
`server.py::_run_supervisor()` is the single scheduler for pooled tasks. A healthy tick publishes liveness, rotates the runtime logs, checks worker health, drains worker, direct-chat, and consciousness events, accepts owner bridge input, enforces deadlines and schedules, runs throttled reconciliation and evolution admission, assigns eligible work, and persists `state/queue_snapshot.json`. Bridge intake precedes timeout, maintenance, evolution, and assignment work so a slow control-plane step cannot make a new owner message invisible. Three consecutive loop failures clear supervisor readiness, stop its watchdog generation, and notify the owner instead of leaving a healthy-looking server that no longer assigns work; a failure raised while a shutdown or restart is already in progress (the lifespan teardown sets a process-local stop event first and joins the loop for a bounded window before workers, bridge and event bus go down) is not a crash — the loop exits quietly, without the counter, the error, or the alarm — and the crash backoff waits on that stop event so a shutdown is never held by it.
`PENDING` and `RUNNING`, guarded by `supervisor.queue._queue_lock`, are the live task-lifecycle authority. Admission reserves identity before project, workspace, attachment, or routing side effects can create a duplicate; refuses a disabled pool, duplicate task, project deletion, accepted or sealed root, or exhausted root budget; attaches the task contract; and preserves stable priority order. Assignment runs against the same locked state and skips reaping slots, budget-paused work, closed project roots, conflicting project writers, and tasks exceeding the root's subagent capacity or depth reservation; evolution tasks are dropped there when `evolution_block_reason()` is set (Light runtime mode, `supervisor/workers.py`). That is the last of three evolution-only runtime-mode fences: owner and post-task entry points refuse a campaign start, `enqueue_evolution_task_if_needed()` independently pauses and disables a carried campaign before queueing it, and assignment drops what still slipped through (`supervisor/evolution_lifecycle.py`). Generic `supervisor.queue.enqueue_task()` has no runtime-mode predicate at all. Configured worker count is therefore not available capacity: the truthful value is the currently assignable idle count after custody, reaping, and admission fences.
`PENDING` and `RUNNING`, guarded by `supervisor.queue._queue_lock`, are the live task-lifecycle authority. Admission reserves identity before project, workspace, attachment, or routing side effects can create a duplicate; refuses a disabled pool, duplicate task, project deletion, accepted or sealed root, exhausted root budget, or an unusable or unprovisionable workspace (typed, with the repair in `detail`); attaches the task contract; and preserves stable priority order. Assignment runs against the same locked state and skips reaping slots, budget-paused work, closed project roots, conflicting project writers, and tasks exceeding the root's subagent capacity or depth reservation; evolution tasks are dropped there when `evolution_block_reason()` is set (Light runtime mode, `supervisor/workers.py`). That is the last of three evolution-only runtime-mode fences: owner and post-task entry points refuse a campaign start, `enqueue_evolution_task_if_needed()` independently pauses and disables a carried campaign before queueing it, and assignment drops what still slipped through (`supervisor/evolution_lifecycle.py`). Generic `supervisor.queue.enqueue_task()` has no runtime-mode predicate at all. Configured worker count is therefore not available capacity: the truthful value is the currently assignable idle count after custody, reaping, and admission fences.
A headless task is ADDRESSED when it is admitted, not when it is displayed (`log_addressing.ingress_chat_id`). A registered project's run has exactly ONE destination: an explicit `chat_id` may only agree with that thread, and any other value — the hidden partition included — is refused with a typed 400 rather than honoured or silently overridden, because a run addressed away from its room is the one shape that puts a card in Main whose project holds none of its work. Without a Project, ordinary API tasks default to `HIDDEN_CHAT_ID` (0). The confirmed browser Publish flow explicitly carries `source="web"` and `WEB_UI_CHAT_ID` to request Main; source is caller-declared addressing on the existing owner API, not a new authentication proof. Other non-Project conversation addresses remain refused. A run scoped to a REGISTERED, active project is admitted into that project's thread (dialogue, children, attachments and answer in the room the owner already has; Main still receives the one host-stamped completion row), and Main is told it finished only when its work is actually in that room — addressed there at admission or BOUND to the project; Registration alone does not qualify. Every other run stays in the hidden partition, silent in every chat, read back through the terminal, `--result-json-out`, the chat-blind Logs panel and `GET /api/tasks/<id>`; a reserved but inactive project keeps its chat acceptable so the queue's lifecycle fence refuses with its own typed reason, and a project deleted mid-run keeps its reserved chat. The run is also NAMED at admission and chat promotion, without a new model call: a caller-supplied `title` (`ouroboros run --title` or the top-level contract field; `metadata.title` is refused with a 400 like `metadata.project_id`) is authorship and fills both `title` and `suggested_name`; otherwise the request's first line, stripped of markdown and capped at the project-name length, fills `suggested_name` ALONE, so a truncated prompt never outranks a real name coined later, and a `task_named` frame is broadcast on admission so the live card is never born showing its status phrase as a title — the client buffers a `task_named` that arrives before the card's record exists (`web/modules/chat.js`), so frame order does not matter.

File diff suppressed because one or more lines are too long

View file

@ -36,7 +36,7 @@ Operation correlation (#667): a named injected message has `operation_ref=<chat_
Successful extension children also carry a bounded `ws_relay_failures` count map from the existing PluginAPI transport owner through their result envelope and process facts. Missing transport, network errors and HTTP refusals remain visible without changing `send_ws_message -> None` or the producer's success. One host warning reports the aggregate; diagnostics contain no message bodies, URLs or credentials. A child that dies before its final envelope may lose this aggregate; the existing measured death/timeout facts remain authoritative. The separate streaming route implementation carries the same aggregate in completion X after body/background work.
Presence flow: `POST /presence/turn` requires the content-hash-bound `presence` permission, one `binding_id`, one exact transport event, and optionally staged files confined to the skill's state root; the binding resolves from `state/presence_bindings.json` with exact provider/account/conversation/thread origin verification; cross-process locks enforce the installation-wide cap and serialize one `conversation_key`; a stable event-derived task id makes transport retries idempotent; input and output join ordinary dialogue history with full transport/actor provenance. The one typed outcome is message/silent/tool_delivered/deferred — `deferred` only with a correlated `work_ref`, because an unanchored "deferred" would be an unanchored promise — and `GET /presence/work/{work_ref}` polls the bound late result without exposing the general task API. Promotion of Presence work into a managed task clears requested Project/workspace/source widening: a public conversation may promote long work but cannot choose new authority, and the cost ceiling plus return destination follow the promoted root by value. `presence_cancel_work` acts only on a `work_ref` whose stored binding and conversation match the current turn; owner chat and Background Consciousness may `initiate_presence` on an existing enabled binding. Every admitted turn's ceiling also carries the cognitive-memory baseline (knowledge read/write/list, scratchpad, identity, chat history): one mind keeps one memory in every channel, so an external message can prompt an in-turn memory or identity revision on the profile's model slot rather than only a post-task knowledge write; the correspondent gains no tool, and public text still carries no owner-command authority; on the read side, an unselected baseline `chat_history` grant is unbound, so — like `knowledge_read` over the notes kept about people — a reply can surface the owner's own words to the correspondent through the model's judgment. Ceilings frozen before the baseline existed verify their own digest and carry no baseline until their profile is recompiled.
Presence flow: `POST /presence/turn` requires the content-hash-bound `presence` permission, one `binding_id`, one exact transport event, and optionally staged files confined to the skill's state root; the binding resolves from `state/presence_bindings.json` with exact provider/account/conversation/thread origin verification; cross-process locks enforce the installation-wide cap and serialize one `conversation_key`; a stable event-derived task id makes transport retries idempotent; input and output join ordinary dialogue history with full transport/actor provenance. The one typed outcome is message/silent/tool_delivered/deferred — `deferred` only with a correlated `work_ref`, because an unanchored "deferred" would be an unanchored promise — and `GET /presence/work/{work_ref}` polls the bound late result without exposing the general task API. Promotion of Presence work into a managed task clears requested Project/workspace/source widening: a public conversation may promote long work but cannot choose new authority, and the cost ceiling plus return destination follow the promoted root by value. An unusable profile folder refuses that promotion typed (`workspace_unusable`, repair in `detail`) and sends no chat text. `presence_cancel_work` acts only on a `work_ref` whose stored binding and conversation match the current turn; owner chat and Background Consciousness may `initiate_presence` on an existing enabled binding. Every admitted turn's ceiling also carries the cognitive-memory baseline (knowledge read/write/list, scratchpad, identity, chat history): one mind keeps one memory in every channel, so an external message can prompt an in-turn memory or identity revision on the profile's model slot rather than only a post-task knowledge write; the correspondent gains no tool, and public text still carries no owner-command authority; on the read side, an unselected baseline `chat_history` grant is unbound, so — like `knowledge_read` over the notes kept about people — a reply can surface the owner's own words to the correspondent through the model's judgment. Ceilings frozen before the baseline existed verify their own digest and carry no baseline until their profile is recompiled.
Companion processes are host-supervised: reviewed manifest-declared descriptors enter durable custody, reconcile after lifecycle changes and restart, and stop on disable/unload/panic; `state/extension_generation.json` carries the opposite direction — the server's published live set, which a running task worker adopts at a task's start or at a dispatch miss, so an enable after boot is not invisible until the pool respawns. Worker-side changes write durable reconcile requests (`state/extension_reconcile/`) rather than spawning server-owned children; every reconcile state names the process that answered and whether that marker request was written, and the tool and review receipts pass both facts through. Health observations are process-qualified: aggregate and Skills UI health use the server observation as authority and expose the worker observation with its handoff outcome only as a qualifier, so failed handoff success cannot advance `last_known_good`; restart-budget exhaustion persists a terminal reason in that health state, cleared only by a later successful start. A companion's cwd is the reviewed payload directory, so a payload edit stales review before reload instead of silently mutating a live process. The live projection is `state/extension_companions.json`.

View file

@ -155,10 +155,13 @@ task-specific auto-retry, fallback, cleanup, resume, or terminal-flow state
machines.
Explicitly naming a documented default is never a different request. An argument
whose value is what omitting it already means (`directory_strategy="direct"` with
no `scope_paths`) takes the omitted path on a shape that cannot serve the argument
at all; only values that genuinely ask for something are refused there, typed, at
the earliest layer holding the authority to judge them, with the repair named.
whose value is what omitting it already means — `directory_strategy="direct"` with
no `scope_paths` on a shape that cannot serve the argument at all, or a value
whose meaning equals the omitted path's documented meaning (`workspace_root`
naming the Ouroboros repository itself, where a workspace-less task already
works) — takes the omitted path, disclosed in the result; only values that
genuinely ask for something are refused there, typed, at the earliest layer
holding the authority to judge them, with the repair named.
A producer that already knows its call failed publishes that fact typed: a
`ToolResult` through `tool_result._publish_tool_result`, or a first-line
@ -354,7 +357,11 @@ from the one table it owns (`ouroboros/tool_capabilities.py::ROUTING_VERBS`:
`routing_action` on the live tool-call frames, `routing_tool_calls` in the task
metrics, `typed_routing_action` on the terminal event), never a client-side
exception list. The same shape hides in "hide unless kind ∈ {…}" and "count
unless name ∈ {…}": when the list is the rule, the rule is missing.
unless name ∈ {…}": when the list is the rule, the rule is missing. The owner
sentence on a routing receipt follows the same rule: the host composes it from
the typed reason (`ouroboros/project_dialogue.py::routing_refusal_cause`) and
ships it as `cause`; the browser prints it verbatim and keeps no reason→sentence
map of its own.
### Task-authored messages are never owner text

View file

@ -2,7 +2,7 @@
Machine extraction of the `docs/ARCHITECTURE.md` "Data layout (`~/Ouroboros/`)" tree — the durable-file orientation carrier (this tree's counterpart of the reference PERSISTENCE_OWNERS derivation checklist) — regenerated by `python scripts/regenerate_inventories.py`. Do not edit. Every entry is probed against reality: repo entries must exist as tracked paths; data-plane entries must appear as a literal in the runtime sources that construct them. A durable file renamed or removed in code while its tree row survives = red (`tests/test_generated_inventories.py`).
Source: `docs/architecture/01-high-level-architecture.md`, physical LF lines 564-654; UTF-8 SHA-256 `ef1a039cc0bee57ea2a096f49dc64ce970b09aa98b1877473e51249abc36dd77`.
Source: `docs/architecture/01-high-level-architecture.md`, physical LF lines 564-654; UTF-8 SHA-256 `9477aa40239b63ec896cc68ee6da9859027603e65c9328aea7642abffd1596bf`.
- entries: **79** (code-ref: 72, repo-dir: 6, repo-path: 1)

View file

@ -56,11 +56,11 @@ AST-derived inventory of compatibility facades, regenerated by `python scripts/r
| `ouroboros/tools/subagent_integration.py` | D07 | 13 | `ouroboros/headless.py` (2 ✗D17)<br>`ouroboros/tools/subagent_integration_delegated.py` (11) |
| `ouroboros/usage_accounting.py` | D16 | 42 | `ouroboros/_usage_cache_splits.py` (4)<br>`ouroboros/_usage_rows.py` (6)<br>`ouroboros/_usage_rows_memo.py` (6)<br>`ouroboros/usage_ledger.py` (18)<br>`ouroboros/usage_legacy_import.py` (5)<br>`ouroboros/utils.py` (3 ✗D18) |
| `server.py` | D11 | 49 | `ouroboros/server_liveness.py` (4)<br>`ouroboros/server_maintenance.py` (14)<br>`ouroboros/server_owner_routing.py` (5)<br>`ouroboros/server_process.py` (6)<br>`ouroboros/server_restart.py` (7)<br>`ouroboros/server_routing_context.py` (13) |
| `supervisor/events.py` | D08 | 93 | `ouroboros/config.py` (1 ✗D12)<br>`ouroboros/contracts/task_constraint.py` (1 ✗D19)<br>`ouroboros/cost_projection.py` (3 ✗D16)<br>`ouroboros/subagent_messages.py` (1 ✗D07)<br>`ouroboros/task_results.py` (2 ✗D17)<br>`ouroboros/tool_capabilities.py` (2 ✗D04)<br>`ouroboros/utils.py` (4 ✗D18)<br>`supervisor/cognitive_operations.py` (2)<br>`supervisor/events_budget.py` (4)<br>`supervisor/events_chat_delivery.py` (9)<br>`supervisor/events_coop_checkpoint.py` (6)<br>`supervisor/events_evolution_done.py` (1)<br>`supervisor/events_project_routing.py` (9)<br>`supervisor/events_runtime_controls.py` (6)<br>`supervisor/events_schedule_task.py` (4)<br>`supervisor/events_subagent_admission.py` (15)<br>`supervisor/events_task_done.py` (8)<br>`supervisor/events_worker_reports.py` (7)<br>`supervisor/log_addressing.py` (4)<br>`supervisor/queue_transitions.py` (1)<br>`supervisor/steering.py` (2 ✗D09)<br>`supervisor/task_dispatch.py` (1) |
| `supervisor/events.py` | D08 | 94 | `ouroboros/config.py` (1 ✗D12)<br>`ouroboros/contracts/task_constraint.py` (1 ✗D19)<br>`ouroboros/cost_projection.py` (3 ✗D16)<br>`ouroboros/subagent_messages.py` (1 ✗D07)<br>`ouroboros/task_results.py` (2 ✗D17)<br>`ouroboros/tool_capabilities.py` (2 ✗D04)<br>`ouroboros/utils.py` (4 ✗D18)<br>`supervisor/cognitive_operations.py` (2)<br>`supervisor/events_budget.py` (4)<br>`supervisor/events_chat_delivery.py` (9)<br>`supervisor/events_coop_checkpoint.py` (6)<br>`supervisor/events_evolution_done.py` (1)<br>`supervisor/events_project_routing.py` (10)<br>`supervisor/events_runtime_controls.py` (6)<br>`supervisor/events_schedule_task.py` (4)<br>`supervisor/events_subagent_admission.py` (15)<br>`supervisor/events_task_done.py` (8)<br>`supervisor/events_worker_reports.py` (7)<br>`supervisor/log_addressing.py` (4)<br>`supervisor/queue_transitions.py` (1)<br>`supervisor/steering.py` (2 ✗D09)<br>`supervisor/task_dispatch.py` (1) |
| `supervisor/git_ops.py` | D10 | 37 | `ouroboros/utils.py` (1 ✗D18)<br>`supervisor/git_ops_remotes.py` (4)<br>`supervisor/git_ops_rescue.py` (8)<br>`supervisor/git_ops_reset.py` (10)<br>`supervisor/git_ops_updates.py` (8)<br>`supervisor/state.py` (4 ✗D08)<br>`supervisor/update_recovery.py` (2) |
| `supervisor/queue.py` | D08 | 102 | `ouroboros/config.py` (6 ✗D12)<br>`ouroboros/contracts/task_contract.py` (3 ✗D19)<br>`ouroboros/schedule_contract.py` (2)<br>`ouroboros/skill_loader.py` (1 ✗D14)<br>`ouroboros/utils.py` (3 ✗D18)<br>`supervisor/evolution_lifecycle.py` (12 ✗D15)<br>`supervisor/message_bus.py` (3)<br>`supervisor/queue_schedules.py` (12)<br>`supervisor/queue_snapshot.py` (5)<br>`supervisor/queue_timeouts.py` (8)<br>`supervisor/queue_transitions.py` (3)<br>`supervisor/schedule_time.py` (7)<br>`supervisor/state.py` (6)<br>`supervisor/task_admission.py` (9)<br>`supervisor/task_lifecycle.py` (17 ✗D09)<br>`supervisor/task_reaper.py` (5 ✗D09) |
| `supervisor/state.py` | D08 | 4 | `ouroboros/utils.py` (4 ✗D18) |
| `supervisor/task_lifecycle.py` | D09 | 32 | `supervisor/cancel_publication.py` (20)<br>`supervisor/queue_transitions.py` (11 ✗D08)<br>`supervisor/task_admission.py` (1 ✗D08) |
| `supervisor/task_model_wait.py` | D08 | 1 | `ouroboros/model_wait.py` (1 ✗D09) |
| `supervisor/update_merge.py` | D10 | 26 | `supervisor/update_candidate.py` (23)<br>`supervisor/update_merge_plan.py` (3) |
| `supervisor/workers.py` | D08 | 45 | `supervisor/log_addressing.py` (1)<br>`supervisor/message_bus.py` (1)<br>`supervisor/worker_assignment.py` (3)<br>`supervisor/worker_chat_lane.py` (5)<br>`supervisor/worker_health.py` (4)<br>`supervisor/worker_pool_lifecycle.py` (15)<br>`supervisor/worker_process.py` (6)<br>`supervisor/worker_promotion.py` (10) |
| `supervisor/workers.py` | D08 | 44 | `supervisor/log_addressing.py` (1)<br>`supervisor/message_bus.py` (1)<br>`supervisor/worker_assignment.py` (3)<br>`supervisor/worker_chat_lane.py` (5)<br>`supervisor/worker_health.py` (4)<br>`supervisor/worker_pool_lifecycle.py` (15)<br>`supervisor/worker_process.py` (6)<br>`supervisor/worker_promotion.py` (9) |

View file

@ -479,7 +479,7 @@ class ProjectsChangedOutbound(TypedDict):
class MessageAnnotationOutbound(TypedDict):
"""Bubble-free presentation update for one canonical owner message."""
"""Bubble-free presentation update for one owner message; ``cause``: the host's sentence for a refused act."""
type: Literal["message_annotation"]
annotation_type: Literal["routing_ack"]
@ -494,10 +494,10 @@ class MessageAnnotationOutbound(TypedDict):
project_chat_id: NotRequired[int]
options: NotRequired[List[Dict[str, Any]]]
attachment_manifest: NotRequired[List[AttachmentManifestEntry]]
# #198: the exact refusal-attempt identity — the picker card composes its
# decision_id (routing:{client_message_id}:{routing_token}) from it; a
# presentation frame without it renders text, never a clickable card.
# #198: the exact refusal-attempt identity — the picker card composes its decision_id
# (routing:{client_message_id}:{routing_token}) from it; a frame without it renders text, never a card.
routing_token: NotRequired[str]
cause: NotRequired[str]
ts: NotRequired[str]

View file

@ -47,7 +47,8 @@ class DecisionResponse(TypedDict, total=False):
a settled task is 409 with the true state (expired_terminal/answered),
so the card settles instead of inviting retries. Routing adds dispatched
(confirmed durable receipt), task_id (derived promoted id), latest_status
(superseding status on 409), and reason/detail diagnostics.
(superseding status on 409), reason/detail diagnostics, and cause (the
owner-facing sentence for a refused routing act).
Model waits distinguish accepted (202, applied false) from the worker's
applied_request_id. wait carries the current projection and revision;
@ -67,6 +68,7 @@ class DecisionResponse(TypedDict, total=False):
latest_status: str
reason: str
detail: str
cause: str
request_id: str
applied: bool
saved: Optional[bool]

View file

@ -191,7 +191,7 @@ def _user_annotation(
return {
key: annotation.get(key)
for key in (
"action", "target", "target_label", "status", "detail", "options",
"action", "target", "target_label", "status", "detail", "cause", "options",
"attachment_manifest", "routing_token", "project_id", "project_chat_id",
)
if key in annotation

View file

@ -94,7 +94,11 @@ def handle_routing_decision(
if not client_message_id or not token:
return 400, {"ok": False, "error": "malformed_decision_id",
"decision_id": decision_id}
from ouroboros.project_dialogue import append_chat_annotation, latest_chat_annotations
from ouroboros.project_dialogue import (
append_chat_annotation,
latest_chat_annotations,
routing_refusal_cause,
)
# The card is live only while its token is the message's LATEST act:
# receipts are kept per token, but a newer routing attempt on the same
@ -214,6 +218,9 @@ def handle_routing_decision(
# routing decision on a refused message, so it wears the
# route_to_project receipt label regardless of source chat.
"routed_from_main": True,
# The owner's click, not a model turn, issued this promote: a
# refusal is told by the handler's typed System row (no narrator).
"host_initiated": True,
"client_message_id": client_message_id,
"attachment_uploads": attachment_uploads,
**provenance,
@ -313,9 +320,18 @@ def handle_routing_decision(
# the latest row — re-assert the refusal under the ORIGINAL token so
# the card the UI re-opens still validates and replays cleanly.
_reopen_refusal()
reason = str(outcome.get("reason") or outcome_status)
# The same act the receipt wore, so the sentence carries its prefix.
receipt_action = (
"steer_task" if action == "steer_task"
else ("route_to_project" if evt.get("routed_from_main") else "promote_chat_to_task")
)
return 409, {"ok": False, "error": "dispatch_rejected",
"decision_id": decision_id, "state": "open",
"reason": str(outcome.get("reason") or outcome_status)}
"reason": reason,
# The owner-facing sentence from the host's own table (the
# toast shows it instead of the raw code).
"cause": routing_refusal_cause(receipt_action, "needs_manual_target", reason, None)}
# Unconfirmed: honestly retriable — the derived identities make a replay
# of the SAME request byte-identical, so the supervisor dedupes it.
return 503, {"ok": False, "error": "dispatch_unconfirmed",

View file

@ -206,7 +206,10 @@ def room_membership(chat_id: int, project_chat_ids: set, source_refs: list,
row = entry if isinstance(entry, dict) else {}
if is_a2a_chat_id(entry_chat):
return False
bound = bound_room_chat(bindings, row)
# An admission notice ("<title> · Not started: …") is addressed to the
# chat the OWNER wrote in; its task id is bound to the destination
# project it never started in, so binding lineage must not move it.
bound = 0 if row.get("type") in ADMISSION_NOTICE_TYPES else bound_room_chat(bindings, row)
lifecycle = row.get("type") in {"project_started", "project_completion_summary"}
if chat_id in project_chat_ids:
return not lifecycle and (bound == chat_id or entry_chat == chat_id
@ -398,6 +401,7 @@ def append_chat_annotation(
routing_token: str = "",
reason: str = "",
detail: str = "",
cause: str = "",
options: Any = None,
attachment_manifest: Any = None,
require_latest_status: Any = None,
@ -438,6 +442,10 @@ def append_chat_annotation(
row["reason"] = str(reason)[:200]
if str(detail or ""):
row["detail"] = str(detail)[:1000]
if str(cause or ""):
# Q3=A: the host-owned owner-facing sentence for a refused act; the
# browser renders it verbatim on the receipt line (reason stays a code).
row["cause"] = str(cause)[:200]
if isinstance(options, list):
row["options"] = [dict(item) for item in options[:100] if isinstance(item, dict)]
if isinstance(attachment_manifest, list):
@ -498,6 +506,106 @@ def routing_target_label(
return "Task"
# Q3=A: the HOST owns the owner-facing sentence for a REFUSED routing act. One
# factual PHRASE per typed reason (what the producer branch observed: ≤ 60
# chars, lower-case start, no codes, no trailing period); the prefix comes from
# the ACT and its outcome in routing_refusal_cause, so one reason reads
# "Not started: …" on a promote and "Not moved: …" on a scope bind. The receipt
# under the owner's message, the host-initiated System row and the picker's
# 409 toast all read this table; the browser renders the sentence verbatim (no
# client table). A reason without a row stays raw ("Not started (<reason>)")
# so a new refusal is visible before it has words.
ROUTING_REFUSAL_CAUSES: Dict[str, str] = {
"workspace_unusable": "the working folder can't be used",
"workspace_provisioning_failed": "no working folder could be created for the project",
"worker_pool_unavailable": "no worker is available right now",
"worker_pool_state_unavailable": "the worker pool could not be checked",
"duplicate_task_id": "this task already exists",
"admission_reservation_owned": "another request already owns this task id",
"admission_reservation_lost": "the task lost its place in the queue",
"admission_reservation_failed": "the task could not be admitted",
"admission_fence": "the task could not be admitted",
"admission_rejected": "the task could not be admitted",
"invalid_admission_reservation": "the task id or its token was missing",
"task_id_lookup_failed": "the task record could not be read",
"empty_objective": "the request was empty",
"project_routing_fence": "the project no longer accepts new work",
"project_routing_fence_lookup_failed": "the project state could not be checked",
"project_binding_failed": "the project could not be set up",
"project_registration_failed": "the project could not be set up",
"ensure_project_scope_failed": "the project could not be set up",
"project_source_error": "the project folder could not be attached",
"attachment_admission_rejected": "the attachments could not be staged",
"staging_unavailable": "the attachments could not be staged",
"queue_snapshot_persist_unavailable": "the task queue could not be saved",
"queue_snapshot_persist_failed": "the task queue could not be saved",
"invalid_skill_repair_constraint": "the skill repair request was invalid",
"skill_repair_payload_missing": "the skill's files are missing",
"skill_repair_payload_unreadable": "the skill's files could not be read",
"skill_repair_admission_unwritable": "the skill repair request could not be recorded",
"repair_promotion_failed": "the skill repair request could not be started",
"task_acceptance_fence": "the task tree is already being accepted",
"invalid_task_depth": "the task depth was invalid",
"promotion_persistence_failed": "the task record could not be saved",
"routing_receipt_persist_failed": "the receipt could not be saved",
"routing_annotation_persist_failed": "the receipt could not be saved",
"source_continuation_publish_failed": "the source hand-off could not be published",
"confirmation_timeout": "no confirmation arrived in time",
"target_unknown": "that task is no longer running",
"direct_chat_turn": "that reply has already been given",
"subagent_target": "that task is a helper of another task",
"chat_mismatch": "that task belongs to another chat",
"cancel_pending": "that task is being stopped",
"target_closed": "that task has already finished",
"target_finished": "that task has already finished",
"acceptance_fence_sealed": "that task has already finished",
"mailbox_write_failed": "the message could not be saved",
"project_scope_conflict": "the task already belongs to another project",
"missing_task_or_project": "no task or project was named",
}
# The typed System rows a host-initiated admission refusal sends (Q2=A): plain
# system bubbles addressed to the chat the OWNER wrote in, never moved by the
# refused task's project binding (room_membership) and never a terminal fact.
ADMISSION_NOTICE_TYPES = frozenset({"task_not_started", "task_start_unconfirmed"})
# Statuses of an act that LANDED (or is still in flight): no cause sentence.
_LANDED_ROUTING_STATUSES = frozenset({"scheduled", "delivered", "pending", "dispatch_pending", "accepted"})
def routing_refusal_cause(action: str, status: str, reason: str, options: Any = None) -> str:
"""The owner-facing sentence for one routing receipt; "" when the act landed
or when the picker keeps «Choose a target» (a refusal WITH options).
The prefix states only what the act's outcome proves: an UNCONFIRMED act
reads "Not confirmed" whatever it was; a refused steer "Not delivered"; a
refused scope bind "Not moved"; every other refused act (promote, route,
skill repair) "Not started". An unconfirmed act with an unknown reason
reads as the honest "may or may not have started"; any other unknown reason
stays raw (``Not started (<reason>)`` — DESIGN sanctions raw over invented)."""
status_text = str(status or "").strip()
if status_text in _LANDED_ROUTING_STATUSES:
return ""
if status_text == "needs_manual_target" and isinstance(options, list) and options:
return ""
action_text = str(action or "").strip()
if status_text == "unconfirmed":
prefix = "Not confirmed"
elif action_text == "steer_task":
prefix = "Not delivered"
elif action_text == "ensure_project_scope":
prefix = "Not moved"
else:
prefix = "Not started"
reason_text = str(reason or "").strip()
phrase = ROUTING_REFUSAL_CAUSES.get(reason_text, "")
if phrase:
return f"{prefix}: {phrase}"
if status_text == "unconfirmed":
return "Not confirmed: the task may or may not have started"
return f"{prefix} ({reason_text})" if reason_text else prefix
def routing_options_with_labels(drive_root: Any, options: Any) -> List[Dict[str, Any]]:
"""Stamp human labels on manual task choices while retaining their raw ids."""
rows: List[Dict[str, Any]] = []

View file

@ -447,7 +447,7 @@ def _route_owner_message(bridge: Any, ctx: Any, incoming: Dict[str, Any]) -> Non
# An explicit selected-skill development request already asks for a
# managed task. Preserve its source/caller facts while ordinary promotion
# validates the payload and records the admitted revision.
from supervisor.events import _handle_promote_chat_to_task
from supervisor.events import _handle_promote_chat_to_task, _notify_host_initiated_refusal
ctx.consciousness.inject_observation(
f"Message from my human: {incoming.get('log_text') or ''}"
@ -462,6 +462,10 @@ def _route_owner_message(bridge: Any, ctx: Any, incoming: Dict[str, Any]) -> Non
"client_message_id": client_message_id,
"task_constraint": task_constraint,
"routed_from_main": True,
# The host issued this promote (skill card), so no model turn waits on
# the receipt: a refusal is told to the owner by ONE typed System row
# from the promote handler, in the chat the owner wrote in.
"host_initiated": True,
}
metadata = task_metadata if isinstance(task_metadata, dict) else {}
if isinstance(metadata.get("client_surface"), dict):
@ -484,6 +488,8 @@ def _route_owner_message(bridge: Any, ctx: Any, incoming: Dict[str, Any]) -> Non
"reason": "repair_promotion_failed",
"task_id": task_id,
}
# Minted OUTSIDE the handler, so its publication boundary never saw it.
_notify_host_initiated_refusal(ctx, event, outcome)
outcome = outcome if isinstance(outcome, dict) else {"status": "scheduled", "task_id": task_id}
outcome_status = str(outcome.get("status") or "needs_manual_target")
if outcome_status == "scheduled":
@ -494,15 +500,8 @@ def _route_owner_message(bridge: Any, ctx: Any, incoming: Dict[str, Any]) -> Non
)
except Exception:
log.debug("Repair promotion success notification failed", exc_info=True)
else:
reason = str(outcome.get("reason") or outcome_status)
try:
ctx.send_with_budget(
chat_id,
f"⚠️ Repair task was not started ({reason}). Please retry from the skill card.",
)
except Exception:
log.debug("Repair promotion refusal notification failed", exc_info=True)
# A refusal is already told by the promote handler's typed System row
# (host_initiated) plus the receipt under the owner's message.
return
reserved_project = _reserved_project_for_chat(ctx, chat_id)
project_id = (
@ -584,6 +583,9 @@ def _route_owner_message(bridge: Any, ctx: Any, incoming: Dict[str, Any]) -> Non
"client_message_id": client_message_id, "task_constraint": task_constraint,
"force_plan": True, "force_plan_source": task_metadata.get("force_plan_source"),
"attachment_uploads": list(task_metadata.get("chat_attachment_uploads") or []),
# Swarm: the host promotes with no model turn waiting on the receipt,
# so a refusal reaches the owner as the handler's typed System row.
"host_initiated": True,
}
if isinstance(task_metadata.get("client_surface"), dict):
event["client_surface"] = dict(task_metadata["client_surface"])

View file

@ -162,7 +162,7 @@ def get_tools() -> List[ToolEntry]:
"project_name": {"type": "string", "description": "Set ONLY to create a brand-new NAMED project now and start a NEW independent task in it (e.g. 'airi research'); to move THIS task into a project use ensure_project_scope. The display name; a filesystem id is derived from it.", "default": ""},
"expected_output": {"type": "string", "description": "What done looks like.", "default": ""},
"project_id": {"type": "string", "description": "Optional EXISTING project scope (filesystem-clean id).", "default": ""},
"workspace_root": {"type": "string", "description": "Optional absolute working-folder path (validated at admission as an ordinary folder or Git worktree root outside the Ouroboros repo/data). Git-specific operations require a Git worktree; ordinary file and process work is supported directly in a validated folder. When omitted for a project-scoped task, the project's registered working_dir is used by default.", "default": ""},
"workspace_root": {"type": "string", "description": "Optional absolute working-folder path (validated at admission as an ordinary folder or Git worktree root outside the Ouroboros repo/data). Git-specific operations require a Git worktree; ordinary file and process work is supported directly in a validated folder. When omitted for a project-scoped task, the project's registered working_dir is used by default. Leave empty to work in Ouroboros's own repository (the Main default).", "default": ""},
"workspace": {"type": "string", "description": "Pass 'none' to opt OUT of the project room's default working folder (a folder-less task in a folder-ful project). Leave empty otherwise.", "default": ""},
"source": {"type": "string", "description": "Attach or clone the project's working folder in ONE move: a git URL (https://... or git@host:path — cloned server-side into the projects root; private repos fail typed auth_required) or an existing folder path (validated attach). The folder is registered on the project (provenance + trusted_at) and becomes this task's active workspace. Use for 'help me debug this GitHub repo / this folder' asks.", "default": ""},
"predecessor_task_id": {"type": "string", "description": "Required explicit selector: pass an empty string for fresh work, or the completed result id shown by the host routing manifest to continue it."},

View file

@ -303,6 +303,36 @@ def _promote_chat_to_task(
current_chat_id = int(getattr(ctx, "current_chat_id", None) or 0)
except (TypeError, ValueError):
current_chat_id = 0
requested_root = str(workspace_root or "").strip()
workspace_sentinel = str(workspace or "").strip().lower()
repo_root_note = ""
if requested_root:
# Q4=A: naming the Ouroboros repository ITSELF names the documented
# default (no separate workspace — the ordinary self-modification task),
# so the EXACT root maps onto the existing "none" sentinel, in Main and
# in every project room alike. A subfolder, the data drive and every
# other path pass through unchanged; admission refuses them with the
# repair hint.
from ouroboros.tool_access import paths_overlap_casefold
from ouroboros.workspace_admission import WORKSPACE_NONE
system_repo = getattr(ctx, "system_repo_dir", None) or getattr(ctx, "repo_dir", None)
try:
requested_path = Path(requested_root).expanduser().resolve(strict=False)
system_repo_path = Path(str(system_repo)).resolve(strict=False) if system_repo else None
same_root = (
system_repo_path is not None
and len(requested_path.parts) == len(system_repo_path.parts)
and paths_overlap_casefold(requested_path, system_repo_path)
)
except (OSError, ValueError, RuntimeError):
same_root = False
if same_root:
requested_root, workspace_sentinel = "", WORKSPACE_NONE
repo_root_note = (
" (workspace_root named the Ouroboros repository itself; started as an "
"ordinary task over it — no separate workspace)"
)
tid = uuid.uuid4().hex[:16]
routing_token = uuid.uuid4().hex
disabled_reason = _promotion_pool_disabled_from_snapshot(ctx)
@ -328,13 +358,13 @@ def _promote_chat_to_task(
"project_id": pid,
"project_name": display_name,
"title": str(title or "").strip()[:80],
"workspace_root": str(workspace_root or "").strip(),
"workspace_root": requested_root,
# Source admission is intentionally supervisor-side, after the
# authoritative worker-pool and duplicate-id gates.
"source": str(source or "").strip(),
# v6.58.0: "none" opts a project-room task OUT of the room's working_dir
# default (a folder-less task in a folder-ful project stays possible).
"workspace": str(workspace or "").strip().lower(),
"workspace": workspace_sentinel,
"chat_id": current_chat_id,
"client_message_id": str(
((getattr(ctx, "task_metadata", {}) or {}).get("client_message_id") or "")
@ -361,6 +391,7 @@ def _promote_chat_to_task(
"presence": dict(presence),
"task_contract": dict(getattr(ctx, "task_contract", {}) or {}),
})
repo_root_note = "" # Presence runs in its admitted folder, never over the repo
_attach_origin_from_metadata(ctx, evt)
predecessor_error = _attach_predecessor_authority_from_metadata(
ctx, evt, predecessor_task_id,
@ -383,7 +414,8 @@ def _promote_chat_to_task(
effective_pid = str(confirmation.get("effective_project_id") or "")
scope_note = _effective_scope_note(ctx, effective_pid)
response = (
f"OK: task {tid}{scope_note} accepted and durably scheduled ({mode}).{source_confirmation} "
f"OK: task {tid}{scope_note} accepted and durably scheduled ({mode}){repo_root_note}."
f"{source_confirmation} "
"The task now runs independently, and follow-up chat can steer it. "
"Use wait_task/get_task_result if its result "
"is needed in this conversation."

View file

@ -175,6 +175,85 @@ def resolve_room_workspace(
return (str(resolved) if resolved else ""), ""
def workspace_repair_hint(
*,
ws_error: str,
explicit_workspace: str = "",
project_id: str = "",
project_folder: str = "",
presence: bool = False,
retired_worktree: bool = False,
drive_root: Any = None,
system_repo_dir: Any = None,
) -> str:
"""The MODEL-facing repair for one refused workspace: the typed cause plus
the one move that fixes it, following the SOURCE of the refused folder.
``resolve_room_workspace`` already types the source, so the repair follows
it instead of sending the caller to a Projects setting the failure never
read: a Presence profile's folder is fixed in the profile; a path the
REQUEST named is re-promoted against the project's folder or with
``workspace='none'`` (a subfolder of the Ouroboros repository can never be a
workspace, and a delegated-run worktree is gone once its run ends); a
project ``working_dir`` — or a failed auto-provision — is fixed in Projects.
The project folder is named only when the registry can be read; the
worktree/repo facts are derived here unless the caller already knows them.
Never raises; this text rides ``detail`` into the typed refusal.
"""
cause = str(ws_error or "").strip().rstrip(".")
if presence:
return (
f"The folder configured in the Presence profile is unusable: {cause}. "
"Fix the Presence profile's workspace_root or clear it."
)
explicit = str(explicit_workspace or "").strip()
if not explicit:
return (
f"{cause}. Fix the project's working folder (Projects → this project) "
"or re-promote with workspace='none' for a folder-less task."
)
requested = pathlib.Path(explicit).expanduser()
if system_repo_dir is not None:
from ouroboros.tool_access import paths_overlap_casefold
try:
under_repo = paths_overlap_casefold(requested, pathlib.Path(system_repo_dir))
except Exception:
under_repo = False
log.debug("workspace repair hint: repo-overlap check failed for %r", explicit, exc_info=True)
if under_repo:
return (
f"{cause}. workspace_root must be a folder outside the Ouroboros repository, "
"or empty (or workspace='none') to work in the repository itself."
)
if not retired_worktree:
try:
from ouroboros.config import get_subagent_worktree_root
from ouroboros.tool_access_paths import path_is_relative_to
retired_worktree = path_is_relative_to(requested, pathlib.Path(get_subagent_worktree_root()))
except Exception:
log.debug("workspace repair hint: worktree-root check failed for %r", explicit, exc_info=True)
folder = str(project_folder or "").strip()
if not folder and str(project_id or "").strip() and drive_root is not None:
try:
from ouroboros.projects_registry import get_project
folder = str((get_project(drive_root, project_id) or {}).get("working_dir") or "").strip()
except Exception:
log.debug("workspace repair hint: project working_dir unreadable for %s", project_id, exc_info=True)
named_folder = f" ({folder})" if folder else ""
if retired_worktree:
return (
f"{cause}. That path is inside a delegated-run worktree, which is removed when "
f"its run ends; re-promote with the project's folder{named_folder} or with workspace='none'."
)
return (
f"{cause}. This task asked for {explicit} explicitly; re-promote it against the "
f"project folder{named_folder} or with workspace='none' for a folder-less task."
)
def room_chat_lens_dir(drive_root: Any, project_id: str) -> tuple[str, str]:
"""The selected folder for a direct conversation, with an availability note.

View file

@ -190,6 +190,7 @@ from supervisor.events_project_routing import ( # noqa: E402, F401 -- intention
_handle_project_digest,
_handle_promote_chat_to_task,
_handle_routing_manual_target,
_notify_host_initiated_refusal,
_persist_promote_rejection,
_prepare_promote_source_off_loop,
_publish_routing_ack,

View file

@ -61,6 +61,8 @@ def _emit_routing_receipt(
publish: bool = True,
) -> Dict[str, Any]:
"""Persist and publish one token-bound routing annotation receipt."""
from ouroboros.project_dialogue import routing_refusal_cause
if target and not str(target_label or "").strip():
from ouroboros.project_dialogue import routing_target_label
@ -69,6 +71,9 @@ def _emit_routing_receipt(
routing_token = str(evt.get("routing_token") or "").strip()
annotation_status = "not_applicable"
project_address = _routing_project_address(ctx, target, status)
# Q3=A: the owner-facing sentence for a REFUSED act (host table; "" for a
# landed row or the picker), computed once for the durable row and the ack.
cause = routing_refusal_cause(action, status, reason, options)
if client_message_id:
try:
from ouroboros.project_dialogue import append_chat_annotation
@ -85,6 +90,7 @@ def _emit_routing_receipt(
routing_token=routing_token,
reason=reason,
detail=detail,
cause=cause,
options=options,
attachment_manifest=attachment_manifest,
**project_address,
@ -129,6 +135,7 @@ def _emit_routing_receipt(
status=effective_status,
options=options,
attachment_manifest=attachment_manifest,
cause=cause,
)
return receipt
@ -143,6 +150,7 @@ def _publish_routing_ack(
status: str,
options: Optional[list] = None,
attachment_manifest: Optional[list] = None,
cause: str = "",
) -> None:
"""Publish a live non-bubble acknowledgement after durable authority exists."""
try:
@ -170,6 +178,8 @@ def _publish_routing_ack(
ack_kwargs["options"] = options
if attachment_manifest is not None:
ack_kwargs["attachment_manifest"] = attachment_manifest
if str(cause or ""):
ack_kwargs["cause"] = str(cause)
if str(evt.get("routing_token") or ""):
ack_kwargs["routing_token"] = str(evt.get("routing_token"))
ack(
@ -316,6 +326,8 @@ def _prepare_promote_source_off_loop(evt: Dict[str, Any], ctx: Any) -> None:
task_id = str(evt.get("task_id") or "")
routing_token = str(evt.get("routing_token") or "")
supervisor_queue.release_task_admission(task_id, routing_token)
# No host producer (skill card, Swarm, picker click) stamps `source`, so
# this exit never bypasses the wrapper's host-initiated refusal notice.
failed = {
"status": "unconfirmed",
"reason": "source_continuation_publish_failed",
@ -341,12 +353,14 @@ def _prepare_promote_source_off_loop(evt: Dict[str, Any], ctx: Any) -> None:
log.exception("Failed to persist promote source continuation failure")
def _handle_promote_chat_to_task(evt: Dict[str, Any], ctx: Any) -> Dict[str, Any]:
def _promote_chat_to_task_outcome(evt: Dict[str, Any], ctx: Any) -> Dict[str, Any]:
"""Spawn a first-class pooled owner task from a conversation-lane promote.
Unlike ``schedule_subagent`` the child is NOT a subagent: it is a normal
owner task (live card, canonical drive, project lease participation). The
conversation lane that emitted the event stays free.
conversation lane that emitted the event stays free. Every exit returns the
typed outcome to ``_handle_promote_chat_to_task``, the one publication
boundary that tells the owner about a host-initiated refusal.
"""
from supervisor.workers import (
_broadcast_task_named,
@ -380,6 +394,9 @@ def _handle_promote_chat_to_task(evt: Dict[str, Any], ctx: Any) -> Dict[str, Any
"status": str((admission or {}).get("status") or "unconfirmed"),
"task_id": task_id,
"reason": str((admission or {}).get("reason") or ""),
# A replay of an already-settled admission: the owner was
# told once, so the refusal notice stays silent.
"replayed": True,
}
if reservation_status == "already_reserved":
return {"status": "preparing", "task_id": task_id}
@ -617,6 +634,49 @@ def _handle_promote_chat_to_task(evt: Dict[str, Any], ctx: Any) -> Dict[str, Any
return failed_outcome
def _notify_host_initiated_refusal(ctx: Any, evt: Dict[str, Any], outcome: Any) -> None:
"""The ONE place a HOST-issued promote (skill card, Swarm, picker click)
tells the owner it did not start (Q2=A). No model turn narrates such a
refusal, so exactly one typed System row lands in the chat the OWNER wrote
in — never ``task["chat_id"]``, which project admission may have rewritten
to a new room — bound to the task that never started. A tool-issued promote
gets nothing here (its receipt plus the failed call are the record and the
model narrates); preparing, scheduled and replayed outcomes send nothing.
"""
if not evt.get("host_initiated") or not isinstance(outcome, dict) or outcome.get("replayed"):
return
status = str(outcome.get("status") or "")
if status not in {"needs_manual_target", "unconfirmed"}:
return
try:
from ouroboros.project_dialogue import routing_refusal_cause
from supervisor.message_bus import notification_chat_route
chat = notification_chat_route(evt.get("chat_id"))
if chat is None:
return
first_line = next(iter(str(evt.get("objective") or "").strip().splitlines()), "")
title = str(evt.get("title") or evt.get("suggested_name") or "").strip() or first_line[:60] or "Task"
action = "route_to_project" if bool(evt.get("routed_from_main")) else "promote_chat_to_task"
reason = str(outcome.get("reason") or ("admission_rejected" if status == "needs_manual_target" else ""))
ctx.send_with_budget(
chat, f"{title} · {routing_refusal_cause(action, status, reason, None)}", role="system",
system_type="task_start_unconfirmed" if status == "unconfirmed" else "task_not_started",
task_id=str(outcome.get("task_id") or evt.get("task_id") or ""),
)
except Exception:
log.debug("host-initiated refusal row failed for %s", evt.get("task_id"), exc_info=True)
def _handle_promote_chat_to_task(evt: Dict[str, Any], ctx: Any) -> Dict[str, Any]:
"""The one publication boundary of a promote: every outcome of the handler
body — the reservation-blocked and unconfirmed early returns included —
passes the host-initiated refusal notice before it is returned."""
outcome = _promote_chat_to_task_outcome(evt, ctx)
_notify_host_initiated_refusal(ctx, evt, outcome)
return outcome
def _record_obligation_transfer(ctx: Any, transfer: Dict[str, Any]) -> None:
"""The promoter's task details name where its planning obligation went."""
promoter = str(transfer.get("from") or "")

View file

@ -566,6 +566,7 @@ class LocalChatBridge:
options: Optional[List[Dict[str, Any]]] = None,
attachment_manifest: Optional[List[Dict[str, Any]]] = None,
routing_token: str = "",
cause: str = "",
) -> None:
"""Emit a typed routing receipt without creating an assistant bubble.
@ -593,6 +594,9 @@ class LocalChatBridge:
# #198: the picker card's click identity; presentation-only frames
# without it stay text lines.
payload["routing_token"] = str(routing_token)
if str(cause or ""):
# Q3=A: the host-owned owner-facing sentence for a refused act.
payload["cause"] = str(cause)
if options is not None:
payload["options"] = [dict(row) for row in options if isinstance(row, dict)]
if attachment_manifest is not None:

View file

@ -106,6 +106,10 @@ def _refuse_steering_while_cancelling(
)
# A cancel-pending refusal is a live notice to the OWNER who asked; a task
# that spoke for itself reads its typed refusal in the tool result instead.
# An act that wears an owner message already shows its cause on that
# message's receipt line; only an UNLABELLED owner act (a synthetic receipt
# id) needs the standalone sentence — the same rule as the other refusals.
notify = notify and not _relayed_owner_message(str(evt.get("client_message_id") or "").strip())
if notify and not _task_issued(evt) and chat_id:
try:
ctx.send_with_budget(chat_id, _cancel_pending_notice(target_label))
@ -455,10 +459,13 @@ def _handle_steer_task(evt: Dict[str, Any], ctx: Any) -> None:
# linger in the dying task's artifact store. The notice is the owner's:
# a task that spoke for itself has its typed refusal and Logs row.
if not task_issued and chat_id:
try:
ctx.send_with_budget(chat_id, _cancel_pending_notice(target_label))
except Exception:
log.debug("steer_task cancel-pending notice failed", exc_info=True)
# Same owner-labelled rule as the up-front check: a relayed owner
# message already carries the cause on its receipt line.
if not relayed_owner_message_id:
try:
ctx.send_with_budget(chat_id, _cancel_pending_notice(target_label))
except Exception:
log.debug("steer_task cancel-pending notice failed", exc_info=True)
if delivered:
if fence_generation_changed:
ctx.persist_queue_snapshot(reason="acceptance_fence_owner_message")

View file

@ -232,6 +232,7 @@ def _admit_project_scope(
"status": "needs_manual_target",
"reason": "project_routing_fence",
"project_lifecycle": existing_lifecycle,
"detail": f"project lifecycle: {existing_lifecycle}",
"task_id": tid,
}, attachment_manifest)
except Exception:
@ -310,7 +311,10 @@ def _admit_project_scope(
# project off-loop (_prepare_promote_source_off_loop) — same
# agent-initiated creation, so the announce gate honors it.
project = {**(project or {}), "created": True}
_pool()._announce_created_project(project, tid, task=task)
# The "Project · Started" row is owed only once the task is REALLY in the
# queue: promote_chat_to_task announces it after enqueue_task succeeded,
# so a workspace refusal after project creation announces nothing.
task["_announce_project"] = project
except Exception:
log.warning("promote: project registration failed for %s", pid, exc_info=True)
return _pool()._reject_promoted_after_attachment_stage({
@ -555,6 +559,8 @@ def promote_chat_to_task(evt: dict, ctx: Any) -> dict:
task["attachment_images"] = [row for row in attachment_manifest if row.get("is_image")]
if isinstance(task.get("task_contract"), dict):
task["task_contract"].update(authority)
# Popped BEFORE the row is built/queued so the announce fact never rides it.
announce_project = task.pop("_announce_project", None)
attach_task_contract(task)
admitted = ctx.enqueue_task(task)
if isinstance(admitted, dict) and admitted.get("_admission_blocked"):
@ -564,6 +570,8 @@ def promote_chat_to_task(evt: dict, ctx: Any) -> dict:
"project_lifecycle": str(admitted.get("_project_lifecycle") or ""),
"task_id": tid,
}, attachment_manifest)
if announce_project is not None:
_pool()._announce_created_project(announce_project, tid, task=task)
# Owner 3=A: the promoter's unmet planning obligation now belongs to this root
# (stamped by _promoted_force_plan_metadata above); release it on the promoter's
# live row BEFORE the snapshot persist below, so one persist shows both facts.
@ -635,6 +643,7 @@ def _admit_promoted_workspace(evt: dict, ctx: Any, task: dict, *, pid: str, tid:
bounded_workspace_preflight,
compose_workspace_block,
resolve_room_workspace,
workspace_repair_hint,
)
if task.get("_presence_origin"):
@ -646,8 +655,10 @@ def _admit_promoted_workspace(evt: dict, ctx: Any, task: dict, *, pid: str, tid:
project_id="", explicit_workspace=str(workspace.get("root") or ""),
)
if ws_error:
_fail_promoted_task_loudly(ctx, task, ws_error)
return {"status": "needs_manual_target", "reason": "workspace_unusable", "task_id": tid}
return {
"status": "needs_manual_target", "reason": "workspace_unusable", "task_id": tid,
"detail": workspace_repair_hint(ws_error=ws_error, presence=True),
}
if resolved_ws:
task.update(workspace_root=resolved_ws, workspace_mode="external", memory_mode="shared")
return None
@ -693,15 +704,14 @@ def _admit_promoted_workspace(evt: dict, ctx: Any, task: dict, *, pid: str, tid:
# Bind-or-fail (v6.58.0): falling through to a workspace-less
# self_modification-profile task over the system repo is exactly
# the silent degradation the admission SSOT exists to kill.
_fail_promoted_task_loudly(
ctx, task,
f"project {pid!r} has no working folder and auto-provisioning one failed; "
"see the supervisor log (ensure_project_workspace)",
)
return {
"status": "needs_manual_target",
"reason": "workspace_provisioning_failed",
"task_id": tid,
"detail": workspace_repair_hint(
ws_error=f"project {pid!r} has no working folder and auto-provisioning "
"one failed; see the supervisor log (ensure_project_workspace)",
),
}
task.setdefault("metadata", {})["workspace_autoprovisioned"] = True
@ -713,11 +723,13 @@ def _admit_promoted_workspace(evt: dict, ctx: Any, task: dict, *, pid: str, tid:
workspace_sentinel=str(evt.get("workspace") or ""),
)
if ws_error:
_fail_promoted_task_loudly(
ctx, task, ws_error,
explicit_workspace=str(evt.get("workspace_root") or "").strip(), project_id=pid,
)
return {"status": "needs_manual_target", "reason": "workspace_unusable", "task_id": tid}
return {
"status": "needs_manual_target", "reason": "workspace_unusable", "task_id": tid,
"detail": workspace_repair_hint(
ws_error=ws_error, explicit_workspace=str(evt.get("workspace_root") or "").strip(),
project_id=pid, drive_root=_pool().DRIVE_ROOT, system_repo_dir=_pool().REPO_DIR,
),
}
if resolved_ws:
task["workspace_root"] = resolved_ws
task["workspace_mode"] = "external"
@ -767,85 +779,6 @@ def _admit_promoted_workspace(evt: dict, ctx: Any, task: dict, *, pid: str, tid:
return None
def _explicit_workspace_remedy(explicit: str, project_id: str) -> str:
"""What to do about a folder the REQUEST named, not the project's own.
``resolve_room_workspace`` already types the source, so the remedy follows
it instead of sending the owner to a Projects setting the failure never
touched. The project's folder is named only when the registry can be read,
and a path under the delegated-run worktree root gets the one clause that
explains why it is gone."""
import pathlib
folder = ""
try:
from ouroboros.projects_registry import get_project
folder = str((get_project(_pool().DRIVE_ROOT, project_id) or {}).get("working_dir") or "").strip()
except Exception:
log.debug("promote loud-fail: project working_dir unreadable for %s", project_id, exc_info=True)
retired = False
try:
from ouroboros.config import get_subagent_worktree_root
from ouroboros.tool_access_paths import path_is_relative_to
retired = path_is_relative_to(pathlib.Path(explicit), pathlib.Path(get_subagent_worktree_root()))
except Exception:
log.debug("promote loud-fail: worktree-root check failed for %r", explicit, exc_info=True)
return (
f"This task asked for {explicit} explicitly, so the project's working folder was never used."
+ (" That path is inside a delegated-run worktree, which is removed when its run ends." if retired else "")
+ " Re-promote it against"
+ (f" the project folder ({folder})" if folder else " the project folder")
+ " or with workspace='none' for a folder-less task."
)
def _fail_promoted_task_loudly(
ctx: Any, task: dict, ws_error: str, *,
explicit_workspace: str = "", project_id: str = "",
) -> None:
"""v6.58.0 loud-fail invariant: a room task whose workspace is SET-but-unusable
is terminally FAILED at admission with a visible card + chat message — never
silently admitted workspace-less (which would run the self_modification profile
over the system repo). Never raises.
The remedy follows the SOURCE of the refused folder: a request that named its
own path is not fixed in Projects, and saying so is the difference between an
actionable message and one that points at a setting the failure never read."""
tid = str(task.get("id") or "")
chat_id = 0
try:
chat_id = int(task.get("chat_id") or 0)
except (TypeError, ValueError):
chat_id = 0
explicit = str(explicit_workspace or "").strip()
remedy = (
_explicit_workspace_remedy(explicit, str(project_id or "")) if explicit else
"Fix the project's working folder (Projects → this project) or re-promote with "
"workspace='none' for a folder-less task."
)
message = f"⚠️ WORKSPACE_UNUSABLE: task {tid} was NOT started — {ws_error} {remedy}"
try:
from ouroboros.task_results import STATUS_FAILED, write_task_result
write_task_result(
_pool().DRIVE_ROOT, tid, STATUS_FAILED,
reason_code="workspace_unusable",
result=message,
description=str(task.get("description") or ""),
chat_id=chat_id,
project_id=str(task.get("project_id") or ""),
)
except Exception:
log.warning("promote loud-fail: task_result write failed for %s", tid, exc_info=True)
try:
if chat_id:
ctx.send_with_budget(chat_id, message)
except Exception:
log.debug("promote loud-fail: chat message failed for %s", tid, exc_info=True)
def ensure_project_scope(evt: dict, ctx: Any) -> dict:
"""Create/attach the registry project for an in-task ensure_project_scope call
and bind the CURRENT (already-running) task to it, then broadcast so the UI moves

View file

@ -2169,7 +2169,6 @@ from supervisor.worker_process import ( # noqa: E402, F401 -- intentional publi
from supervisor.worker_promotion import ( # noqa: E402, F401 -- intentional public re-exports
_admit_promoted_workspace,
_canonical_promoted_repair_constraint,
_fail_promoted_task_loudly,
_origin_from_mapping,
_origin_from_task_record,
_promote_duplicate_reason,

View file

@ -109,7 +109,6 @@ TERMINAL_WRITERS = {
('supervisor/worker_health.py::_recover_crashed_task_without_terminal', 'STATUS_CANCELLED'): 'terminal',
('supervisor/worker_health.py::_recover_crashed_task_without_terminal', 'STATUS_FAILED'): 'terminal',
('supervisor/worker_pool_lifecycle.py::_write_failure_result', 'final_status'): 'dynamic',
('supervisor/worker_promotion.py::_fail_promoted_task_loudly', 'STATUS_FAILED'): 'terminal',
('supervisor/workers.py::_settle_cancelled_pending_row', 'status_cancelled'): 'dynamic',
('supervisor/workers.py::_terminalize_invalid_pending_depth', 'STATUS_FAILED'): 'terminal',
}

View file

@ -60,11 +60,6 @@ ALLOWED = {
"chat_id means 'the event carried no chat' and the owner chat is the "
"fallback address, not the hidden partition.",
),
("supervisor/worker_promotion.py", "if chat_id:"): (
1,
"Same promote lane: the loud-fail notice needs a reader, and the hidden "
"partition has none.",
),
("supervisor/worker_chat_lane.py", "if not chat_id:"): (
1,
"Auto-resume gate, where owner_chat_id 0 means 'no owner chat "

View file

@ -1137,3 +1137,19 @@ def test_chat_history_replays_the_live_subtree_ceiling_for_a_running_root(tmp_pa
# Only ROOT lineage reaches the ledger; the finished root is served by its
# durable terminal truth, not by a live read.
assert sorted(seen_roots) == ["root-empty", "root-live"]
def test_user_annotation_projects_the_host_cause_sentence():
"""Q3=A: the owner-facing sentence for a refused routing act replays with the
owner message; the reason code stays a model artefact off the live frame."""
from ouroboros.gateway.history import _user_annotation
projected = _user_annotation("user", "cm-1", {"cm-1": {
"action": "promote_chat_to_task", "status": "needs_manual_target",
"reason": "workspace_unusable", "cause": "Not started: the working folder can't be used",
"detail": "explicit workspace_root is unusable: …",
}})
assert projected["cause"] == "Not started: the working folder can't be used"
assert projected["status"] == "needs_manual_target"
assert "reason" not in projected

View file

@ -457,6 +457,7 @@ def test_gateway_contract_endpoint_index_matches_router_and_types(tmp_path):
"project_id",
"project_chat_id",
"routing_token",
"cause",
"status",
"options",
"attachment_manifest",

View file

@ -698,3 +698,26 @@ def test_project_thread_stamp_survives_meta_and_covers_typing_and_echo(monkeypat
echoes = [f for f in frames if f.get("role") == "user"]
assert echoes[0]["project_thread"] is True
assert "project_thread" not in echoes[1]
def test_send_routing_ack_carries_the_cause_only_when_present(monkeypatch):
"""Q3=A: the host's owner-facing sentence rides the live routing_ack frame
and the outbound transport copy; a landed act carries no cause at all."""
bridge = _make_bridge(monkeypatch)
frames, events = [], []
bridge._broadcast_fn = frames.append
monkeypatch.setattr(message_bus, "publish_event", lambda topic, data: events.append((topic, data)))
bridge.send_routing_ack(
1, client_message_id="cm-1", action="promote_chat_to_task", target="t1",
status="needs_manual_target", routing_token="tok-1",
cause="Not started: the working folder can't be used",
)
bridge.send_routing_ack(
1, client_message_id="cm-2", action="promote_chat_to_task", target="t2",
status="scheduled", routing_token="tok-2",
)
assert frames[0]["cause"] == "Not started: the working folder can't be used"
assert events[0][1]["cause"] == frames[0]["cause"]
assert "cause" not in frames[1] and "cause" not in events[1][1]

View file

@ -247,3 +247,41 @@ def test_explicit_project_choice_still_wins_over_presence_folder(installed):
assert resolve_project_id({**task, "project_id": "chosen-project"}) == "chosen-project"
ordinary = {"workspace_root": str(installed.workspace)}
assert resolve_project_id(ordinary).startswith("proj_")
def test_promotion_with_an_unusable_presence_folder_returns_the_typed_repair_and_sends_nothing(
installed, monkeypatch,
):
"""The Presence branch of workspace admission: the admitted folder vanished
before the promote was admitted, so the typed refusal names the Presence
profile as the place to fix it (the cause rides `detail` to the model) and
admission sends no chat text — nothing fans out into a public conversation."""
import shutil
from ouroboros.tools.control import _promote_chat_to_task
from supervisor import workers
from tests.test_promote_chat_flow import _confirm_promote
task = _build_task(_admit(installed), _event(), drive_root=installed.data, staged_files=())
ctx = _context(installed, task)
_confirm_promote(monkeypatch)
monkeypatch.setattr(workers, "DRIVE_ROOT", installed.data)
monkeypatch.setattr(workers, "REPO_DIR", installed.repo)
result = _promote_chat_to_task(ctx, "Finish the report", predecessor_task_id="")
assert result.startswith("OK: task"), result
event = ctx.pending_events[0]
shutil.rmtree(installed.workspace) # the admitted folder disappears before admission
sent, enqueued = [], []
outcome = workers.promote_chat_to_task(event, SimpleNamespace(
enqueue_task=lambda row: enqueued.append(row) or row,
persist_queue_snapshot=lambda **_kwargs: True,
load_state=lambda: {"owner_chat_id": 1},
send_with_budget=lambda *args, **kwargs: sent.append((args, kwargs)),
))
assert (outcome["status"], outcome["reason"]) == ("needs_manual_target", "workspace_unusable")
assert outcome["detail"].startswith("The folder configured in the Presence profile is unusable: ")
assert str(installed.workspace) in outcome["detail"]
assert outcome["detail"].endswith("Fix the Presence profile's workspace_root or clear it.")
assert sent == [] and enqueued == []

View file

@ -2796,11 +2796,13 @@ def test_decision_turn_metadata_injects_running_tasks_and_client_id(tmp_path):
# --- Q10=A (owner, 2026-08-08): file-less project promotes auto-provision -----
def _promote_ctx(enqueued):
def _promote_ctx(enqueued, sent=None):
return types.SimpleNamespace(
enqueue_task=lambda task: enqueued.append(task),
persist_queue_snapshot=lambda **_kwargs: True,
load_state=lambda: {"owner_chat_id": 1},
# Records every chat send, so a refusal can prove it sent NOTHING.
send_with_budget=lambda *args, **kwargs: (sent if sent is not None else []).append((args, kwargs)),
)
@ -2887,17 +2889,23 @@ def test_promote_broken_working_dir_loud_fails_never_blind_ensures(tmp_path, mon
gone = tmp_path / "gone-folder"
update_project(tmp_path, "brokenp", working_dir=str(gone)) # never existed
enqueued = []
enqueued, sent = [], []
outcome = workers.promote_chat_to_task({
"type": "promote_chat_to_task",
"task_id": "broken1",
"objective": "Continue",
"project_id": "brokenp",
"chat_id": 1,
}, _promote_ctx(enqueued))
}, _promote_ctx(enqueued, sent))
assert outcome["status"] == "needs_manual_target"
assert outcome["reason"] == "workspace_unusable"
# The repair rides `detail` to the MODEL (PROMOTE_REJECTED renders it);
# admission itself sends no chat text (Q1=A) and writes no task result —
# the handler's single rejection writer does.
assert outcome["detail"].startswith("project 'brokenp' working_dir is unusable")
assert "Projects → this project" in outcome["detail"] and "workspace='none'" in outcome["detail"]
assert sent == []
assert enqueued == []
# The broken value is preserved for the owner to fix — not overwritten.
assert get_project(tmp_path, "brokenp")["working_dir"] == str(gone)
@ -2911,17 +2919,20 @@ def test_promote_provisioning_failure_loud_fails_not_silent_fileless(tmp_path, m
monkeypatch.setattr(workers, "DRIVE_ROOT", tmp_path)
monkeypatch.setattr(projects_registry, "ensure_project_workspace", lambda *a, **k: "")
enqueued = []
enqueued, sent = [], []
outcome = workers.promote_chat_to_task({
"type": "promote_chat_to_task",
"task_id": "provfail1",
"objective": "Build",
"project_id": "provfail-proj",
"chat_id": 1,
}, _promote_ctx(enqueued))
}, _promote_ctx(enqueued, sent))
assert outcome["status"] == "needs_manual_target"
assert outcome["reason"] == "workspace_provisioning_failed"
assert outcome["detail"].startswith("project 'provfail-proj' has no working folder and auto-provisioning")
assert "Projects → this project" in outcome["detail"] and "workspace='none'" in outcome["detail"]
assert sent == []
assert enqueued == []
@ -3048,78 +3059,96 @@ def test_the_steer_tool_renders_the_typed_reason_and_still_defaults_without_one(
assert "(target_not_steerable)" in control_routing._steer_task(ctx, "t1", "go")
def _loud_workspace_failure(tmp_path, monkeypatch, ws_error: str, **kwargs):
"""Run the loud-fail writer directly and return (chat message, stored row)."""
import supervisor.workers as workers
from ouroboros.task_results import load_task_result
from supervisor import worker_promotion
def _repair_hint(**kwargs):
"""The MODEL-facing repair for one refused workspace: one line, never empty."""
from ouroboros.workspace_admission import workspace_repair_hint
monkeypatch.setattr(workers, "DRIVE_ROOT", tmp_path)
sent: list = []
ctx = types.SimpleNamespace(send_with_budget=lambda chat_id, text: sent.append(text))
worker_promotion._fail_promoted_task_loudly(
ctx, {"id": "wsfail", "chat_id": 3}, ws_error, **kwargs,
)
assert len(sent) == 1
return sent[0], load_task_result(tmp_path, "wsfail")
hint = workspace_repair_hint(**kwargs)
assert hint and "\n" not in hint
return hint
def test_loud_workspace_failure_remedy_follows_the_source_of_the_refused_folder(
tmp_path, monkeypatch):
def test_workspace_repair_hint_follows_the_source_of_the_refused_folder(tmp_path):
"""The request named its own folder, so the project's working folder was never
read: sending the owner to Projects points at a setting the failure never
read: sending the caller to Projects points at a setting the failure never
touched. The project folder is named as the way back when it is readable."""
from ouroboros.projects_registry import create_project
create_project(tmp_path, "roomp", name="RoomP", working_dir=str(tmp_path / "room-tree"))
message, stored = _loud_workspace_failure(
tmp_path, monkeypatch,
"explicit workspace_root is unusable: not a git checkout.",
hint = _repair_hint(
ws_error="explicit workspace_root is unusable: not a git checkout.",
explicit_workspace=str(tmp_path / "asked-for"), project_id="roomp",
drive_root=tmp_path, system_repo_dir=tmp_path / "system-repo",
)
assert "asked for" in message and str(tmp_path / "asked-for") in message
assert "Projects → this project" not in message
assert str(tmp_path / "room-tree") in message
assert "workspace='none'" in message
assert stored["status"] == "failed" and stored["reason_code"] == "workspace_unusable"
assert hint.startswith("explicit workspace_root is unusable: not a git checkout. ")
assert "asked for" in hint and str(tmp_path / "asked-for") in hint
assert "Projects → this project" not in hint
assert str(tmp_path / "room-tree") in hint
assert "workspace='none'" in hint
def test_loud_workspace_failure_keeps_the_projects_remedy_for_a_project_folder(
tmp_path, monkeypatch):
"""A project working_dir failure — and an unreadable registry entry — is fixed
exactly where today's message says, so that text is unchanged."""
def test_workspace_repair_hint_keeps_the_projects_remedy_for_a_project_folder():
"""A project working_dir failure, an unreadable registry entry and a failed
auto-provision are fixed exactly where the sentence says: in Projects."""
for ws_error in (
"project 'roomp' working_dir is unusable: not a git checkout.",
"project 'roomp' registry entry is unreadable (OSError: boom) — cannot determine "
"the task's workspace",
"project 'roomp' has no working folder and auto-provisioning one failed; "
"see the supervisor log (ensure_project_workspace)",
):
message, stored = _loud_workspace_failure(tmp_path, monkeypatch, ws_error)
assert ws_error in message
assert message.endswith(
hint = _repair_hint(ws_error=ws_error)
assert hint.startswith(ws_error.rstrip("."))
assert hint.endswith(
"Fix the project's working folder (Projects → this project) or re-promote with "
"workspace='none' for a folder-less task."
)
assert "asked for" not in message
assert stored["reason_code"] == "workspace_unusable"
assert "asked for" not in hint
def test_loud_workspace_failure_names_a_retired_delegated_run_worktree(tmp_path, monkeypatch):
def test_workspace_repair_hint_names_a_retired_delegated_run_worktree(tmp_path, monkeypatch):
"""The path the agent passed twice in one minute was a delegated-run worktree
its own run had already retired; nothing in the old message said so."""
from ouroboros import config
worktrees = tmp_path / "subagent_worktrees"
monkeypatch.setattr(config, "get_subagent_worktree_root", lambda: str(worktrees))
message, stored = _loud_workspace_failure(
tmp_path, monkeypatch,
"explicit workspace_root is unusable: path does not exist.",
hint = _repair_hint(
ws_error="explicit workspace_root is unusable: path does not exist.",
explicit_workspace=str(worktrees / "dlg_060055d5_x"), project_id="",
)
assert "delegated-run worktree" in message and "removed when its run ends" in message
assert "Projects → this project" not in message
assert stored["reason_code"] == "workspace_unusable"
assert "delegated-run worktree" in hint and "removed when its run ends" in hint
assert "Projects → this project" not in hint and "workspace='none'" in hint
def test_workspace_repair_hint_points_a_repo_subfolder_at_the_empty_default(tmp_path):
"""A SUBFOLDER of the Ouroboros repository can never be a workspace (the exact
root never reaches admission: the promote tool maps it onto no workspace);
the repair names the argument shape that works instead."""
repo = tmp_path / "Ouroboros" / "repo"
(repo / "ouroboros").mkdir(parents=True)
hint = _repair_hint(
ws_error="explicit workspace_root is unusable: workspace_root must not overlap the "
"Ouroboros system repo",
explicit_workspace=str(repo / "ouroboros"), system_repo_dir=repo,
)
assert "workspace_root must be a folder outside the Ouroboros repository" in hint
assert "empty (or workspace='none') to work in the repository itself" in hint
assert "asked for" not in hint
def test_workspace_repair_hint_names_the_presence_profile():
hint = _repair_hint(
ws_error="explicit workspace_root is unusable: workspace_root is not a directory: /gone",
presence=True,
)
assert hint.startswith("The folder configured in the Presence profile is unusable: ")
assert "workspace_root is not a directory: /gone" in hint
assert hint.endswith("Fix the Presence profile's workspace_root or clear it.")
def test_promote_without_an_explicit_target_inherits_the_binding_then_the_origin(
@ -3306,3 +3335,186 @@ def test_the_implicit_promote_claim_creates_and_binds_under_the_claim_lock(
assert enqueued[0]["project_id"] == "the-work"
assert (registry.project_binding_for_task(tmp_path, "root01") or {}).get(
"project_id") == "the-work"
# --- promote refusals: placement (Q1/Q2=A), the host cause (Q3=A), the repo root (Q4=A) ---
def _host_promote(host, monkeypatch, **overrides):
"""Drive the REAL promote handler (reservation, admission, receipts, the
publication boundary) for a Main event; ``host.notices`` records every chat
send with its kwargs and ``host.bridge.acks`` every live receipt."""
from supervisor.events import _handle_promote_chat_to_task
evt = {
"type": "promote_chat_to_task", "task_id": "refused0001", "routing_token": "tok-refused-1",
"objective": "Audit the GitHub tool", "chat_id": 1, "client_message_id": "cm-refused-1",
"workspace": "none",
}
evt.update(overrides)
return evt, _handle_promote_chat_to_task(evt, host.ctx)
def test_host_initiated_workspace_refusal_sends_one_typed_row_to_the_owner_chat(swarm_host, monkeypatch):
"""Q2=A: no model turn narrates a host-issued promote, so the owner is told
ONCE — a typed System row in the chat the owner wrote in (never the project
room admission just created), bound to the never-started task — and the
receipt under the message carries the same host sentence (Q3=A)."""
from tests.test_swarm_host_admission import rows
host = swarm_host
announced = []
monkeypatch.setattr(
"supervisor.terminal_delivery.enqueue_terminal_delivery",
lambda _root, event, **_k: announced.append(dict(event)) or True,
)
evt, outcome = _host_promote(
host, monkeypatch, host_initiated=True, title="Аудит GitHub-инструмента",
project_id="refused-room", project_name="Refused Room",
workspace="", workspace_root=str(host.root / "missing-folder"),
)
assert (outcome["status"], outcome["reason"]) == ("needs_manual_target", "workspace_unusable")
assert outcome["detail"].startswith("explicit workspace_root is unusable")
assert host.pending == []
[notice] = host.notices
assert notice["chat_id"] == 1 # the OWNER's chat, not the new project room
assert notice["text"] == "Аудит GitHub-инструмента · Not started: the working folder can't be used"
assert notice["role"] == "system" and notice["system_type"] == "task_not_started"
assert notice["task_id"] == evt["task_id"]
# R9: the project was created, but its "Started" row is owed only to real work.
assert announced == []
row = rows(host.root / "logs/chat_annotations.jsonl")[-1]
assert (row["status"], row["reason"]) == ("needs_manual_target", "workspace_unusable")
assert row["cause"] == "Not started: the working folder can't be used"
assert host.bridge.acks[-1]["status"] == "needs_manual_target"
assert host.bridge.acks[-1]["cause"] == "Not started: the working folder can't be used"
def test_tool_issued_workspace_refusal_sends_nothing(swarm_host, monkeypatch):
"""Q1=A: a model turn waits on the receipt and narrates; the receipt (with
its cause) and the failed call are the record — no host chat text at all."""
host = swarm_host
evt, outcome = _host_promote(
host, monkeypatch, workspace="", workspace_root=str(host.root / "missing-folder"),
)
assert (outcome["status"], outcome["reason"]) == ("needs_manual_target", "workspace_unusable")
assert host.notices == []
assert host.bridge.acks[-1]["cause"] == "Not started: the working folder can't be used"
def test_host_initiated_reservation_refusal_sends_one_row_and_a_replay_sends_nothing(
swarm_host, monkeypatch,
):
"""R13: the reservation-blocked exit (a disabled worker pool) passes the same
publication boundary; a replay of the settled admission (same token) is
silent — the owner was told once. The picker's route (`routed_from_main`)
wears the same «Not started» prefix as a promote (R14)."""
import supervisor.workers as workers
from supervisor.events import _handle_promote_chat_to_task
host = swarm_host
monkeypatch.setattr(
workers, "_worker_pool_execution_state",
lambda *_a, **_k: {"available": False, "disabled_reason": "maintenance"},
)
evt, outcome = _host_promote(host, monkeypatch, host_initiated=True, routed_from_main=True)
assert (outcome["status"], outcome["reason"]) == ("needs_manual_target", "worker_pool_unavailable")
[notice] = host.notices
assert notice["text"] == "Audit the GitHub tool · Not started: no worker is available right now"
assert notice["system_type"] == "task_not_started" and notice["task_id"] == evt["task_id"]
replay = _handle_promote_chat_to_task(dict(evt), host.ctx)
assert replay["replayed"] is True and replay["reason"] == "worker_pool_unavailable"
assert len(host.notices) == 1
def test_host_initiated_unconfirmed_admission_sends_the_unconfirmed_row(swarm_host, monkeypatch):
"""R14: an admission whose receipt could not be persisted is UNCONFIRMED — the
row says exactly that (`task_start_unconfirmed`, «Not confirmed»), never
«Not started»."""
host = swarm_host
monkeypatch.setattr("ouroboros.project_dialogue.append_chat_annotation", lambda *_a, **_k: False)
evt, outcome = _host_promote(host, monkeypatch, host_initiated=True)
assert (outcome["status"], outcome["reason"]) == ("unconfirmed", "routing_annotation_persist_failed")
[notice] = host.notices
assert notice["text"] == "Audit the GitHub tool · Not confirmed: the receipt could not be saved"
assert notice["role"] == "system" and notice["system_type"] == "task_start_unconfirmed"
assert notice["task_id"] == evt["task_id"]
def test_promote_maps_the_exact_repo_root_onto_no_workspace(tmp_path, monkeypatch):
"""Q4=A: naming the Ouroboros repository ITSELF names the documented default
(the ordinary self-modification task): the event carries the existing
"none" sentinel — in a project room too — and the tool result discloses it."""
from ouroboros.tools.control import _promote_chat_to_task
_confirm_promote(monkeypatch)
repo = tmp_path / "Ouroboros" / "repo"
repo.mkdir(parents=True)
events = []
ctx = types.SimpleNamespace(
pending_events=events, event_queue=None, current_chat_id=1, drive_root=tmp_path / "data",
repo_dir=repo, system_repo_dir=repo,
)
out = _promote_chat_to_task(
ctx, "Audit the GitHub tool", project_id="racer", workspace_root=f"{repo}/",
predecessor_task_id="",
)
assert out.startswith("OK: task")
assert (
"(workspace_root named the Ouroboros repository itself; started as an ordinary task "
"over it — no separate workspace)"
) in out
[evt] = events
assert evt["workspace_root"] == "" and evt["workspace"] == "none"
assert evt["project_id"] == "racer"
def test_promote_passes_a_repo_subfolder_through_to_admission_unchanged(tmp_path, monkeypatch):
"""A SUBFOLDER is a different ask the shape cannot serve: it reaches admission
unchanged and is refused there with the repair hint."""
from ouroboros.tools.control import _promote_chat_to_task
_confirm_promote(monkeypatch)
repo = tmp_path / "Ouroboros" / "repo"
sub = repo / "ouroboros"
sub.mkdir(parents=True)
events = []
ctx = types.SimpleNamespace(
pending_events=events, event_queue=None, current_chat_id=1, drive_root=tmp_path / "data",
repo_dir=repo, system_repo_dir=repo,
)
out = _promote_chat_to_task(ctx, "Audit the GitHub tool", workspace_root=str(sub), predecessor_task_id="")
assert out.startswith("OK: task") and "named the Ouroboros repository itself" not in out
[evt] = events
assert evt["workspace_root"] == str(sub) and evt["workspace"] == ""
def test_project_started_is_not_announced_when_workspace_admission_refuses(tmp_path, monkeypatch):
"""R9: the «Project · Started» row is owed only once the task is REALLY in the
queue — a promote that creates a project and then fails workspace admission
announces nothing (the project itself stays; the refusal names it)."""
import supervisor.workers as workers
from ouroboros.projects_registry import get_project
monkeypatch.setattr(workers, "DRIVE_ROOT", tmp_path)
queued = []
monkeypatch.setattr(
"supervisor.terminal_delivery.enqueue_terminal_delivery",
lambda _root, event, **_k: queued.append(dict(event)) or True,
)
enqueued, sent = [], []
outcome = workers.promote_chat_to_task({
"type": "promote_chat_to_task", "task_id": "doomed0001", "objective": "Build",
"project_id": "doomed-room", "project_name": "Doomed Room",
"workspace_root": str(tmp_path / "never-existed"), "chat_id": 1,
}, _promote_ctx(enqueued, sent))
assert (outcome["status"], outcome["reason"]) == ("needs_manual_target", "workspace_unusable")
assert get_project(tmp_path, "doomed-room")["name"] == "Doomed Room" # the project stays
assert queued == [] and enqueued == [] and sent == []

View file

@ -150,6 +150,9 @@ def test_promote_click_confirms_from_the_admission_record(tmp_path, monkeypatch)
def _supervisor_schedules(evt):
assert evt["task_id"] == derived_task_id
# The owner's click issued this promote: the handler's publication
# boundary owns any refusal notice (no model turn narrates it).
assert evt["host_initiated"] is True and evt["routed_from_main"] is True
def _mut(current):
from ouroboros.contracts.schema_versions import SCHEMA_VERSION_KEY
@ -327,6 +330,9 @@ def test_rejected_dispatch_reopens_the_original_card(tmp_path, monkeypatch):
status, body = handle_routing_decision(
tmp_path, request_id="r1", decision_id="routing:cm-1:tok-1", option_index=0)
assert (status, body["state"]) == (409, "open")
# R5/R16: the toast shows the host's sentence for the refused act, not the code.
assert body["reason"] == "target_closed"
assert body["cause"] == "Not delivered: that task has already finished"
reopened = chat_annotation_receipt(tmp_path, "cm-1", "tok-1")
assert reopened["status"] == "needs_manual_target"
assert [row["action"] for row in reopened["options"]] == [

View file

@ -514,3 +514,40 @@ def test_two_roots_under_one_owner_message_keep_two_readable_receipts_after_comp
assert latest_chat_annotations(tmp_path)["cm-1"]["routing_token"] == "tok-steer-2"
assert "msg-expired" not in latest_chat_annotations(tmp_path)
# --- (e) a relayed owner steer refused by a pending cancel: receipt, no bubble ---
def test_a_relayed_owner_steer_refused_by_a_pending_cancel_gets_no_standalone_bubble(
tmp_path, monkeypatch,
):
"""R15: an act that wears an owner message shows its cause on that message's
receipt line; only an UNLABELLED owner act (a synthetic receipt id, so no
chat row can show the refusal) still gets the standalone cancel-pending
notice."""
import ouroboros.cancel_intents as cancel_intents
from ouroboros.project_dialogue import AGENT_RECEIPT_ID_PREFIX, latest_chat_annotations
from supervisor.events import _handle_steer_task
monkeypatch.setattr(
cancel_intents, "cancel_pending", lambda _root, task_id, **_k: task_id == "t-target",
)
notices = []
supervisor = _supervisor_ctx(
tmp_path, notices, running={"t-target": {"task": {"id": "t-target", "chat_id": 1}}},
)
base = {"type": "steer_task", "target_task_id": "t-target", "message": "stop after this file",
"chat_id": 1}
_handle_steer_task({**base, "client_message_id": "cm-owner-1", "routing_token": "tok-1"}, supervisor)
row = latest_chat_annotations(tmp_path)["cm-owner-1"]
assert (row["status"], row["reason"]) == ("rejected", "cancel_pending")
assert row["cause"] == "Not delivered: that task is being stopped"
assert notices == []
unlabelled = f"{AGENT_RECEIPT_ID_PREFIX}tok-2"
_handle_steer_task({**base, "client_message_id": unlabelled, "routing_token": "tok-2"}, supervisor)
assert latest_chat_annotations(tmp_path)[unlabelled]["cause"] == "Not delivered: that task is being stopped"
assert len(notices) == 1

View file

@ -0,0 +1,177 @@
"""Host-owned cause sentences for refused routing acts (Q3=A; R6/R7/R11/R12/R14).
The receipt line under the owner's message, the host-initiated System row and
the picker's 409 toast all read ONE host table; the browser renders the
sentence verbatim. These pins keep the table complete against every producer
of a refusal reason, keep the phrases factual and short, keep the prefix an
honest statement of the act's outcome, and keep the admission-notice rows in
the chat the owner wrote in.
"""
from __future__ import annotations
import pathlib
import re
import pytest
from ouroboros.project_dialogue import (
ADMISSION_NOTICE_TYPES,
ROUTING_REFUSAL_CAUSES,
room_membership,
routing_refusal_cause,
)
REPO = pathlib.Path(__file__).resolve().parents[1]
# Every typed refusal reason that reaches `_emit_routing_receipt`, the promote
# outcome (the host-initiated notice) or the steer receipt — one row each.
REFUSAL_REASONS = (
# promote / route admission (worker_promotion, events_project_routing,
# queue.enqueue_task `_admission_blocked`, task_admission.reserve_task_admission)
"workspace_unusable", "workspace_provisioning_failed", "worker_pool_unavailable",
"worker_pool_state_unavailable", "duplicate_task_id", "admission_reservation_owned",
"admission_reservation_lost", "admission_reservation_failed", "admission_fence",
"admission_rejected", "invalid_admission_reservation", "task_id_lookup_failed",
"empty_objective", "project_routing_fence", "project_routing_fence_lookup_failed",
"project_binding_failed", "project_registration_failed", "project_source_error",
"attachment_admission_rejected", "staging_unavailable",
"queue_snapshot_persist_unavailable", "queue_snapshot_persist_failed",
"invalid_skill_repair_constraint", "skill_repair_payload_missing",
"skill_repair_payload_unreadable", "skill_repair_admission_unwritable",
"repair_promotion_failed", "task_acceptance_fence", "invalid_task_depth",
# the unconfirmed family (handler crash, receipt persistence, routing_wait)
"promotion_persistence_failed", "routing_receipt_persist_failed",
"routing_annotation_persist_failed", "source_continuation_publish_failed",
"confirmation_timeout",
# steer (supervisor/steering.py)
"target_unknown", "direct_chat_turn", "subagent_target", "chat_mismatch",
"cancel_pending", "target_closed", "target_finished", "acceptance_fence_sealed",
"mailbox_write_failed",
# ensure_project_scope (worker_promotion, events_project_routing)
"project_scope_conflict", "missing_task_or_project", "ensure_project_scope_failed",
)
PRODUCERS = (
"supervisor/worker_promotion.py",
"supervisor/events_project_routing.py",
"supervisor/steering.py",
"supervisor/queue.py",
"supervisor/task_admission.py",
"ouroboros/server_owner_routing.py",
)
_REASON_LITERAL = re.compile(r'(?:"reason":\s*|\breason=)"([a-z][a-z0-9_]*)"')
# Literals the producer scan finds that are NOT owner-facing refusal reasons.
EXEMPT = {
# snapshot-persist / rollback reasons (a persist call's audit label, never a receipt)
"promote_chat_to_task_rejected", "promote_chat_to_task_failed", "promote_chat_to_task",
"drain_all_pending", "acceptance_fence_owner_message", "evolve_off", "deep_self_review_enqueued",
# the bind-failure events.jsonl row's reason (project_binding_unreadable), never a receipt
"project_binding_unreadable",
# success / pseudo reasons on a delivered ensure_project_scope receipt
"created", "adopted", "attached", "delivered", "renamed", "name_unchanged", "rename_failed",
# the picker's claim/closing pseudo-reasons (`claimed_option:N` / `answered_option:N`)
# and its with-options refusal, which keeps «Choose a target» (no cause)
"answered_option", "claimed_option", "target_unspecified",
# internal wait/transport outcomes that never reach an owner surface
"client_message_id_missing", "event_serialization_failed", "handler_returned_no_outcome",
}
def test_every_refusal_reason_has_a_short_factual_phrase():
assert set(REFUSAL_REASONS) == set(ROUTING_REFUSAL_CAUSES)
for reason, phrase in ROUTING_REFUSAL_CAUSES.items():
assert phrase == phrase.strip() and len(phrase) <= 60, (reason, phrase)
assert "_" not in phrase and not phrase.endswith("."), (reason, phrase)
assert phrase[0].islower() and not phrase.startswith("Not "), (reason, phrase)
@pytest.mark.parametrize(
"action, status, expected",
[
("promote_chat_to_task", "needs_manual_target", "Not started"),
("route_to_project", "needs_manual_target", "Not started"),
("promote_chat_to_task", "rejected", "Not started"),
("steer_task", "needs_manual_target", "Not delivered"),
("steer_task", "rejected", "Not delivered"),
("ensure_project_scope", "rejected", "Not moved"),
("promote_chat_to_task", "unconfirmed", "Not confirmed"),
("steer_task", "unconfirmed", "Not confirmed"),
("ensure_project_scope", "unconfirmed", "Not confirmed"),
],
)
def test_prefix_follows_the_act_and_its_outcome(action, status, expected):
"""The prefix states only what the outcome proves: an UNCONFIRMED act reads
«Not confirmed» whatever it was; a refused steer «Not delivered»; a refused
scope bind «Not moved»; every other refused act «Not started»."""
sentence = routing_refusal_cause(action, status, "project_binding_failed")
assert sentence == f"{expected}: the project could not be set up"
def test_landed_rows_and_the_picker_carry_no_cause():
for status in ("scheduled", "delivered", "pending", "dispatch_pending", "accepted"):
assert routing_refusal_cause("promote_chat_to_task", status, "workspace_unusable") == ""
# A refusal WITH options is the picker: «Choose a target · A / B» stays.
assert routing_refusal_cause("route_decision", "needs_manual_target", "target_unspecified",
[{"action": "steer_task", "task_id": "t1"}]) == ""
assert routing_refusal_cause("route_decision", "needs_manual_target", "target_unspecified", []) == (
"Not started (target_unspecified)"
)
def test_unknown_reasons_stay_raw_and_unconfirmed_stays_honest():
"""DESIGN sanctions raw over invented: a reason with no row is shown as its
code (the raw fallback is exempt from the no-underscore rule), while an
unconfirmed act with an unknown reason reads as the honest uncertainty."""
assert routing_refusal_cause("promote_chat_to_task", "needs_manual_target", "x_y") == "Not started (x_y)"
assert routing_refusal_cause("steer_task", "rejected", "x_y") == "Not delivered (x_y)"
assert routing_refusal_cause("promote_chat_to_task", "unconfirmed", "x_y") == (
"Not confirmed: the task may or may not have started"
)
assert routing_refusal_cause("promote_chat_to_task", "needs_manual_target", "") == "Not started"
assert routing_refusal_cause("promote_chat_to_task", "needs_manual_target", "workspace_unusable") == (
"Not started: the working folder can't be used"
)
def test_producer_literals_are_all_covered_or_exempt():
"""Completeness against the code: every `"reason": "<literal>"` /
`reason="<literal>"` a producer writes has a sentence or an explicit exemption."""
found = {}
for relative in PRODUCERS:
source = (REPO / relative).read_text(encoding="utf-8")
for match in _REASON_LITERAL.finditer(source):
found.setdefault(match.group(1), set()).add(relative)
assert found, "the producer scan found nothing — the regex or the paths drifted"
missing = {
literal: sorted(files) for literal, files in found.items()
if literal not in ROUTING_REFUSAL_CAUSES and literal not in EXEMPT
}
assert missing == {}, f"refusal reasons without a host sentence: {missing}"
assert not (set(EXEMPT) & set(ROUTING_REFUSAL_CAUSES)), "a reason cannot be both exempt and worded"
def test_admission_notice_types_are_the_two_host_rows():
"""R3/R14: a refusal is `task_not_started`; an admission whose receipt could
not be confirmed is `task_start_unconfirmed` — never «not started»."""
assert ADMISSION_NOTICE_TYPES == frozenset({"task_not_started", "task_start_unconfirmed"})
@pytest.mark.parametrize("notice_type", sorted(ADMISSION_NOTICE_TYPES))
def test_admission_notice_rows_stay_in_the_owners_chat(notice_type):
"""R12: the refused task id is bound to the project it never started in, so
binding lineage would move the notice out of Main into that project on
reload. The notice is addressed to the chat the OWNER wrote in."""
bindings = {"refused-task": 77}
main = room_membership(1, {77}, [], bindings)
project = room_membership(77, {77}, [], bindings)
notice = {"direction": "system", "type": notice_type, "task_id": "refused-task", "chat_id": 1}
assert main(1, notice) is True
assert project(1, notice) is False
assert project(77, {**notice, "chat_id": 77}) is True
# An ordinary bound row keeps today's behaviour: lineage moves it to its room.
ordinary = {"direction": "out", "type": "chat", "task_id": "refused-task", "chat_id": 1}
assert main(1, ordinary) is False
assert project(1, ordinary) is True

View file

@ -69,44 +69,71 @@ def test_constrained_repair_promotes_managed_task_before_busy_direct_lane(monkey
"payload_root": "skills/external/alpha",
}
assert event["origin_suppressed"] is True
# The skill card issued this promote: the handler's publication boundary
# owns any refusal notice, so the event says so.
assert event["host_initiated"] is True
assert len(calls["sent"]) == 1
assert calls["sent"][0][0] == 1
assert "accepted and durably scheduled" in calls["sent"][0][1]
def test_constrained_repair_refusal_is_reported_to_owner(monkeypatch):
sent = []
_REPAIR_INCOMING = {
"chat_id": 1,
"text": "repair skill",
"client_message_id": "repair-1",
"task_constraint": {
"mode": "skill_repair",
"skill_name": "alpha",
"payload_root": "skills/external/alpha",
},
}
def test_constrained_repair_refusal_sends_no_untyped_bubble(monkeypatch):
"""The handler's own publication boundary tells the owner (one typed System
row); the routing lane adds no «⚠️ Repair task was not started» bubble."""
sent, events = [], []
ctx = SimpleNamespace(
consciousness=SimpleNamespace(inject_observation=lambda *_: None),
send_with_budget=lambda chat_id, text: sent.append((chat_id, text)),
send_with_budget=lambda chat_id, text, **kwargs: sent.append((chat_id, text, kwargs)),
)
monkeypatch.setattr(
"supervisor.events._handle_promote_chat_to_task",
lambda event, _ctx: {
lambda event, _ctx: events.append(event) or {
"status": "needs_manual_target",
"reason": "skill_repair_payload_missing",
"task_id": event["task_id"],
},
)
server._route_owner_message(
FakeBridge(),
ctx,
{
"chat_id": 1,
"text": "repair skill",
"client_message_id": "repair-1",
"task_constraint": {
"mode": "skill_repair",
"skill_name": "alpha",
"payload_root": "skills/external/alpha",
},
},
server._route_owner_message(FakeBridge(), ctx, dict(_REPAIR_INCOMING))
assert sent == []
assert events[0]["host_initiated"] is True
def test_constrained_repair_outer_failure_sends_one_typed_not_started_row(monkeypatch):
"""R13: `repair_promotion_failed` is minted OUTSIDE the handler, so the lane
calls the same notice helper — one typed row in the owner's chat, bound to
the task id that never started, with the host's sentence."""
sent = []
ctx = SimpleNamespace(
consciousness=SimpleNamespace(inject_observation=lambda *_: None),
send_with_budget=lambda chat_id, text, **kwargs: sent.append((chat_id, text, kwargs)),
)
assert len(sent) == 1
assert sent[0][0] == 1
assert "skill_repair_payload_missing" in sent[0][1]
def _crash(event, _ctx):
raise RuntimeError("handler crashed")
monkeypatch.setattr("supervisor.events._handle_promote_chat_to_task", _crash)
server._route_owner_message(FakeBridge(), ctx, dict(_REPAIR_INCOMING))
[(chat_id, text, kwargs)] = sent
assert chat_id == 1
assert text == "repair skill · Not started: the skill repair request could not be started"
assert kwargs["role"] == "system" and kwargs["system_type"] == "task_not_started"
assert kwargs["task_id"]
def test_repair_ui_copy_does_not_promise_a_removed_decision_round():

View file

@ -551,6 +551,7 @@
* @property {string=} latest_status
* @property {string=} reason
* @property {string=} detail
* @property {string=} cause // the owner-facing sentence for a refused routing act (409 dispatch_rejected)
*/
/**
@ -608,6 +609,7 @@
* @property {Array<Object>=} options
* @property {AttachmentManifestEntry[]=} attachment_manifest
* @property {string=} routing_token
* @property {string=} cause // host-authored owner sentence for a REFUSED act; absent on scheduled/delivered/pending and on the picker frame
* @property {boolean} suppress_bubble
* @property {string=} ts
*/

View file

@ -839,6 +839,11 @@ export function routingOptionLabel(option) {
/** Human text for a typed routing annotation ('' hides the line). */
export function routingAnnotationText(annotation) {
if (!annotation || typeof annotation !== 'object') return '';
// A refused act carries the host's own owner-facing sentence (`cause`);
// it outranks the status matrix below. Absent on scheduled/delivered/
// pending rows and on the picker frame, so those labels are unchanged.
const cause = String(annotation.cause || '').trim();
if (cause) return cause;
const action = String(annotation.action || '');
const status = String(annotation.status || '');
const target = String(annotation.target || '');

View file

@ -586,7 +586,7 @@ export function createChatDecision({
body.state === 'open' ? 'open' : body.state,
Number.isInteger(body.answered_index) ? body.answered_index : null);
showToast(body.state === 'open'
? `Not routed: ${body.reason || 'the destination refused this message'} — pick again.`
? `Not routed: ${body.cause || body.reason || 'the destination refused this message'} — pick again.`
: body.state === 'pending'
? 'Another choice is already being routed.'
: 'This message was already routed.', 'error');

View file

@ -404,3 +404,56 @@ test('Stop stays reachable on a census-vouched root, managed card and direct blo
} finally { f.close(); }
}
});
// Promote-refusal placement (Q1=A): a refused addressing call inside a live
// turn is a FAILED call — an error row, content the block stands on — while
// the owner message carries the host's sentence (`cause`) as its receipt.
// Live and on reload the block holds exactly one error row.
test('a refused promote keeps a block with exactly one error row live and on reload; the owner message carries the cause', async () => {
const cause = 'Not started: the working folder can\'t be used';
const refused = { annotation_type: 'routing_ack', client_message_id: 'owner-1', action: 'promote_chat_to_task',
status: 'needs_manual_target', target: 'never-started', target_label: 'Requested work', cause };
const f = fixture();
try {
f.census(direct());
f.emit('chat', ownerRow);
f.log({ type: 'tool_call_started', ...promote({ args: { objective: 'the project' } }) });
assert.equal(f.card(), null, 'still a receipt while the call runs');
f.emit('message_annotation', refused);
f.log({ type: 'tool_call_finished', ...promote({ is_error: true, error: 'workspace_unusable', duration_sec: 0.4 }) });
assert.ok(f.card(), 'the failed call is content');
assert.equal(f.rows().length, 1, 'the failure lands on the call\'s own row');
assert.match(f.rows()[0].innerHTML, /promote_chat_to_task/);
// The stub cannot repaint an in-place patch; the producer states the failed row.
const started = summarizeChatLiveEvent({ type: 'tool_call_started', task_id: TASK, ...promote({ args: { objective: 'the project' } }) });
const failure = summarizeChatLiveEvent({ type: 'tool_call_finished', task_id: TASK, ...promote({ is_error: true, error: 'workspace_unusable', duration_sec: 0.4 }) });
assert.equal(failure.dedupeKey, started.dedupeKey);
assert.deepEqual([started.receipt, failure.phase, failure.visible, Boolean(failure.receipt)], [true, 'error', true, false]);
const owner = f.messages.children.find((n) => n.dataset.clientMessageId === 'owner-1');
assert.equal(owner?.querySelector('.msg-routing-annotation')?.textContent, cause);
f.emit('chat', { ...final, tool_calls: 1, tool_errors: 1 });
f.log({ ...final, type: 'task_done', status: 'completed', tool_calls: 1, tool_errors: 1, _is_direct_chat: true });
f.log({ type: 'task_metrics_event', tool_calls: 1, tool_errors: 1, routing_tool_calls: 1, tool_call_counts: { promote_chat_to_task: 1 } });
assert.ok(f.card());
assert.equal(f.rows().length, 2, 'the call\'s own error row and the terminal note, no summary row');
assert.equal(f.rows().filter((n) => /promote_chat_to_task/.test(n.innerHTML)).length, 1);
assert.match(f.meta(), /1 error/);
} finally { f.close(); }
const g = fixture([
{ ...ownerRow, chat_annotation: refused },
{ ...final, tool_calls: 1, ts: '2026-09-15T12:00:05Z', chat_id: 1, _is_direct_chat: true },
{ ...final, role: 'system', system_type: 'task_summary', text: 'The working folder could not be used.', rounds: 2,
tool_calls: 1, tool_errors: 1, routing_tool_calls: 1, tool_call_counts: { promote_chat_to_task: 1 },
addressing_only: 'promote_chat_to_task', _is_direct_chat: true, ts: '2026-09-15T12:00:06Z', chat_id: 1 },
]);
try {
await g.instance.refreshHistory({ revision: 1 });
assert.ok(g.card(), 'a counted error is content, so the replay summary is not a receipt');
const errorRows = g.rows().filter((n) => /1 tool call · 1 error/.test(n.innerHTML));
assert.equal(errorRows.length, 1);
assert.ok(errorRows[0].classList.contains('warn'));
assert.match(g.meta(), /1 error/);
const owner = g.messages.children.find((n) => n.dataset.clientMessageId === 'owner-1');
assert.equal(owner?.querySelector('.msg-routing-annotation')?.textContent, cause);
} finally { g.close(); }
});

View file

@ -641,6 +641,48 @@ test('an actionable refusal renders the picker card; other statuses fall back to
} finally { fx.restore(); }
});
test('a refusal with a token but no options renders the host cause as the plain line, never a card', () => {
// The incident shape (client_message_id + routing_token, NO options): the
// host sends `cause`, the owner reads a sentence, and nothing is clickable.
const cause = 'Not started: the working folder can\'t be used';
const fx = fixture();
try {
const bubble = routingBubble('cm-refused');
const refused = {
action: 'promote_chat_to_task', status: 'needs_manual_target', routing_token: 'tok-r',
target: 'never-started', target_label: 'Аудит', cause,
};
assert.equal(fx.decision.renderRoutingDecision(bubble, refused), true);
assert.equal(bubble.querySelector('.chat-routing-card'), null);
const note = bubble.querySelector('.msg-routing-annotation');
assert.equal(note.textContent, cause);
assert.equal(note.dataset.annotationStatus, 'needs_manual_target');
assert.equal(bubble.dataset.chatAnnotationStatus, 'needs_manual_target');
assert.equal(fx.decision.renderRoutingDecision(bubble, refused), false);
} finally { fx.restore(); }
});
test('a routing 409 that reopens the card names the host cause before the raw reason', async () => {
const cause = 'Not started: the working folder can\'t be used';
const fx = fixture({
fetchImpl: async () => ({
ok: false, status: 409,
json: async () => ({ state: 'open', reason: 'workspace_unusable', cause }),
}),
});
try {
const bubble = routingBubble('cm-5');
fx.decision.renderRoutingDecision(bubble, ROUTING_ANNOTATION);
const card = bubble.querySelector('.chat-routing-card');
card.querySelectorAll('.chat-quiz-option')[0].click();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(card.dataset.state, 'open');
assert.equal(fx.toasts.length, 1);
assert.equal(fx.toasts[0].text, `Not routed: ${cause} — pick again.`);
assert.equal(fx.toasts[0].text.includes('workspace_unusable'), false);
} finally { fx.restore(); }
});
test('a routing click posts the routing decision id with a STABLE request id', async () => {
const fx = fixture();
try {

View file

@ -175,6 +175,32 @@ test('repeated physical history identity updates the routing annotation on the s
assert.match(note.textContent, /Investigation/);
});
test('a replayed refusal receipt shows the host cause and a later scheduled receipt patches the same note', async (t) => {
const cause = 'Not started: the working folder can\'t be used';
const message = row('chat:250', 'Audit the GitHub tool', {
role: 'user', client_message_id: 'owner-refused',
chat_annotation: {
action: 'promote_chat_to_task', status: 'needs_manual_target',
target: 'never-started', target_label: 'Аудит', cause,
},
});
const f = fixture(t, page([message]));
await f.refresh();
const bubble = f.bubbles().find((node) => node.dataset.historyId === 'chat:250');
assert.ok(bubble);
assert.equal(bubble.dataset.chatAnnotationStatus, 'needs_manual_target');
const note = bubble.querySelector('.msg-routing-annotation');
assert.equal(note.textContent, cause);
await f.refresh(page([{ ...message, chat_annotation: {
action: 'promote_chat_to_task', status: 'scheduled', target: 'task-started', target_label: 'Investigation',
} }]));
assert.equal(f.bubbles().filter((node) => node.dataset.historyId === 'chat:250').length, 1);
assert.equal(f.bubbles().find((node) => node.dataset.historyId === 'chat:250'), bubble);
assert.equal(bubble.querySelector('.msg-routing-annotation'), note);
assert.equal(bubble.dataset.chatAnnotationStatus, 'scheduled');
assert.equal(note.textContent, 'Started task · Investigation');
});
test('two physical rows with identical timestamp and body remain two messages across refresh', async (t) => {
const rows = [row('chat:300', 'Same words'), row('chat:400', 'Same words')];
const f = fixture(t, page(rows));

View file

@ -68,6 +68,24 @@ test('legacy routing receipts and manual choices use neutral labels, never raw i
assert.equal(manual.includes('opaque-'), false);
});
test('a host cause on a refused routing receipt outranks the status matrix; an empty cause changes nothing', () => {
// The host authors the owner sentence for a REFUSED act (`cause`); the
// client never derives one from `reason`. The field is absent/empty on
// scheduled, delivered and pending rows and on the picker frame, so those
// keep their labels (the options case above stays «Choose a target · …»).
const cause = 'Not started: the working folder can\'t be used';
const refused = routingAnnotationText({
action: 'promote_chat_to_task', status: 'needs_manual_target',
target: 'x', target_label: 'Аудит', cause,
});
assert.equal(refused, cause);
assert.equal(refused.includes('Choose a target'), false);
assert.equal(routingAnnotationText({
action: 'steer_task', status: 'delivered', target: 'opaque-task-id',
target_label: 'Launch', cause: '',
}), 'Steered task · Launch');
});
test('state snapshots and failure authority stay monotonic across reversed completion', () => {
const applied = [];
const requestTimes = [100, 200];

View file

@ -390,6 +390,108 @@ test('system row without a markdown flag renders plain (cancel_receipt class)',
}
});
// Promote-refusal placement (R3/R4/R14): an owner-initiated refusal is ONE typed
// system row bound to the never-started task's id, with plain `<title> · <cause>`
// text — `task_not_started` for a confirmed refusal («Not started: …») and
// `task_start_unconfirmed` when the host cannot tell («Not confirmed: …»).
// A typed keyed row is neither a terminal fact nor a plain untyped final, so it
// renders as an ordinary plain system bubble and mints/finishes no live card —
// live or on replay. Both types get the same assertions.
const ADMISSION_NOTICE_ROWS = [
{
chat_id: 2,
role: 'system',
system_type: 'task_not_started',
task_id: 'abc123',
content: 'Аудит · Not started: the working folder can\'t be used',
ts: '2026-09-16T00:00:04Z',
},
{
chat_id: 2,
role: 'system',
system_type: 'task_start_unconfirmed',
task_id: 'def456',
content: 'Аудит · Not confirmed: the task may or may not have started',
ts: '2026-09-16T00:00:05Z',
},
];
function findCard(node, taskId) {
if (node?.dataset?.taskId === taskId && node.classList?.contains('chat-live-card')) return node;
for (const child of node?.children || []) {
const hit = findCard(child, taskId);
if (hit) return hit;
}
return null;
}
function liveCards() {
const messages = globalThis.document.byId.get('chat-messages');
return messages.children.filter((node) => node.classList.contains('chat-live-card'));
}
for (const noticeRow of ADMISSION_NOTICE_ROWS) {
test(`${noticeRow.system_type} renders as a plain system bubble and mints no card, live and after reload`, async () => {
const expectedText = new RegExp(noticeRow.content.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
let liveHtml = '';
{
const { prior, mount } = installDom();
let instance;
try {
const made = makeInstance(mount);
instance = made.instance;
made.handlers.get('chat')(noticeRow);
const bubble = findBubble('system');
assert.ok(bubble, 'the notice row rendered a system bubble');
assert.match(bubble.innerHTML, /📋 System/);
assert.match(bubble.innerHTML, expectedText);
assert.doesNotMatch(bubble.innerHTML, /<br>|<h1|<h2|md-h1|md-h2|<strong/);
assert.equal(bubble.getAttribute('data-chat-markdown-enhanced'), '');
assert.equal(bubble.dataset.taskId, noticeRow.task_id, 'the row stays bound to the never-started task');
const messages = globalThis.document.byId.get('chat-messages');
assert.equal(findCard(messages, noticeRow.task_id), null, 'no live card is minted for the never-started task');
assert.equal(liveCards().length, 0);
liveHtml = bubble.innerHTML;
} finally {
instance?.destroy();
restoreDom(prior);
}
}
const historyRow = {
text: noticeRow.content,
role: 'system',
ts: noticeRow.ts,
is_progress: false,
system_type: noticeRow.system_type,
task_id: noticeRow.task_id,
markdown: false,
};
const { prior, mount } = installDom(async (url) => {
if (String(url).startsWith('/api/chat/history')) {
return { ok: true, json: async () => ({ messages: [historyRow] }) };
}
return { ok: true, json: async () => ({ active_direct_turns: [] }) };
});
let instance;
try {
({ instance } = makeInstance(mount));
await settle();
await settle();
const bubble = findBubble('system');
assert.ok(bubble, 'history replay rendered the notice row');
assert.equal(bubble.innerHTML, liveHtml,
'live DOM and reload DOM are byte-identical for the notice row');
const messages = globalThis.document.byId.get('chat-messages');
assert.equal(findCard(messages, noticeRow.task_id), null,
'replay neither mints nor finishes a card for the never-started task');
assert.equal(liveCards().length, 0);
} finally {
instance?.destroy();
restoreDom(prior);
}
});
}
test('render arm order and enhancement guard are pinned in source', () => {
// The plain-system arm sits between the dedicated skill_review renderer
// (bug report #8) and the byte-pinned final markdown arm