diff --git a/docs/CREATING_SKILLS.md b/docs/CREATING_SKILLS.md index b709fcc0d..3abc644d4 100644 --- a/docs/CREATING_SKILLS.md +++ b/docs/CREATING_SKILLS.md @@ -734,11 +734,14 @@ ceiling and reply context rather than widening authority. Transport custody preserves provider arrival order before Host admission; the host serializes one conversation and enforces the installation-wide active-turn -limit across processes. A current Presence turn may cancel only its own -binding-and-conversation-correlated `work_ref`. Owner chat or Background -Consciousness may initiate an existing binding, but the resulting cycle must use -an explicitly selected transport tool and finish `tool_delivered` to claim that -an external message was sent. +limit across processes. By default, the host-provided own-work readers, messaging +and cancellation reach only independent work started from the same nonempty binding, +across its conversations. A profile that explicitly selects global `recent_tasks` or +`get_task_result` retains those readers' global scope, including other bindings and +owner work; binding-scoped steering and cancellation do not widen with those reads. +Owner chat or Background Consciousness may initiate an existing binding, but +its cycle must use an explicitly selected transport tool and finish +`tool_delivered` to claim an external message was sent. #### Reporting actual Presence delivery @@ -765,6 +768,8 @@ status notices stay in the owner task; an empty deferred body sends nothing but still requires polling. Cached and late results preserve that empty body rather than substituting the task diagnostic. This does not turn failure into success. Ordinary implicit replies and genuine authored best-effort answers remain valid. +A forced final separates the task record from the reply: only a `presence_finish` +declared in that answer is spoken, so an undeclared record sends nothing new. `GET /identity` advertises `presence_delivery_version: 1` on supporting hosts. Only then request `delivery_reporting_version: 1` alongside `binding_id` and diff --git a/docs/DOMAIN_MAP.md b/docs/DOMAIN_MAP.md index f5e9d930b..f804aec59 100644 --- a/docs/DOMAIN_MAP.md +++ b/docs/DOMAIN_MAP.md @@ -22,13 +22,13 @@ The manifest is the SSOT of the module→domain assignment (1:1, complete over t | D12 | Settings & configuration | 15 | 0 | | D13 | Safety, guards & runtime mode | 9 | 0 | | D14 | Skills & extensions | 56 | 0 | -| D15 | Memory, knowledge, consciousness & self-evolution | 22 | 0 | +| D15 | Memory, knowledge, consciousness & self-evolution | 23 | 0 | | D16 | Observability, usage accounting & cost | 11 | 0 | | D17 | Projects, workspaces & task results | 23 | 0 | | D18 | Launcher, packaging, platform & shared substrate | 15 | 0 | | D19 | Frozen contracts (ABI) | 10 | 0 | | D20 | Presence | 10 | 0 | -| **total** | | **574** | **0** | +| **total** | | **575** | **0** | ## Dependency direction matrix (strict, pinned) @@ -720,6 +720,7 @@ No function body (≥ 10 normalized lines) is shared verbatim across domains. Ne - `ouroboros/knowledge.py` - `ouroboros/memory.py` - `ouroboros/memory_journal_compaction.py` +- `ouroboros/memory_nomination_receipts.py` - `ouroboros/post_task_evolution.py` - `ouroboros/project_facts.py` - `ouroboros/reflection.py` diff --git a/docs/PERSISTENCE.md b/docs/PERSISTENCE.md index d0468b59f..5df607d40 100644 --- a/docs/PERSISTENCE.md +++ b/docs/PERSISTENCE.md @@ -22,7 +22,7 @@ scanned data-relative path to be covered by a row here (count-anchored both ways keys migrate). Governs subagent worktrees, headless/task drives, task trees, service logs, consumed schedule receipts, confirmed capability probes, delegate recovery/supervision sweeps, code_intel - and reconcile-failed prunes, memory-journal digesting and agent media. + reconcile-failed prunes, and agent media. Memory journals retain full new rows independently of this knob. - **Rotation** — `supervisor/state.py::rotate_jsonl_log_if_needed`: >800 KB → atomic rename to `archive/_.jsonl` under the append lock. Applied on the supervisor tick to `chat.jsonl`, `progress.jsonl`, @@ -143,10 +143,10 @@ scanned data-relative path to be covered by a row here (count-anchored both ways | `memory/scratchpad.md` + `scratchpad_blocks.json` | `ouroboros/memory.py` (derived, regenerated from blocks under lock) | none | bounded: 10 blocks, eviction journaled first (fail-closed) | regenerated; evicted history in journal | | `memory/WORLD.md` | `ouroboros/world_profiler.py` (write-once) | none | fixed | regenerates on restart — deletion IS the refresh mechanism | | `memory/registry.md`, `memory/deep_review.md` | `ouroboros/tools/memory_tools.py` (section RMW), `ouroboros/agent.py` (overwrite) | none | unbounded / last-wins — accepted | recreated lazily | -| `memory/dialogue_blocks.json` + `dialogue_meta.json` | `ouroboros/consolidator.py` (locked atomic) | none | bounded by era compression (10 blocks, oldest 4 compressed) | blocks: compressed biography irreproducible; meta: full re-consolidation (cost, not loss) | +| `memory/dialogue_blocks.json` + `dialogue_meta.json` | `ouroboros/consolidator.py`, `memory_nomination_receipts.py` (locked atomic) | `pending_knowledge_nominations` source-entry IDs; legacy `last_unpublished_nominations` preserved | blocks bounded by era compression (10 blocks, oldest 4); unresolved nomination index unbounded; no tool-level resolver yet, later success never retires old debt | blocks: compressed biography irreproducible; meta: cursor and unpublished-obligation evidence lost | | `memory/dialogue_summary.md` | none — legacy read-only (reader in context.py) | none | frozen | legacy artifact; nothing writes it | | `memory/knowledge/**` (topic .md + `index-full.md` + `patterns.md`) | `ouroboros/tools/knowledge.py`, `consolidator.py` (index rebuild), `reflection.py` (patterns CAS rewrite) | none | topic files unbounded — accepted (curated by consolidation); backlog topic merge-only fail-closed | recreated lazily; knowledge lost | -| `memory/*_journal.jsonl`, `memory/knowledge_history.jsonl`, `memory/knowledge/patterns_history.jsonl` | `ouroboros/memory.py`, `tools/control_runtime.py`, `tools/knowledge.py`, `reflection.py` — every append through the `append_jsonl` sidecar-lock seam | scratchpad journal: `type` rows; others unversioned full-text snapshots; digested rows carry `content_digested: true` | full old+new text only inside GC retention: older identity/knowledge/patterns rows go digest-only (sha256+len) at startup (`memory_journal_compaction.py`, under the append lock, unreadable lines byte-preserved); scratchpad journal keeps its own eviction contract | undo/provenance record lost (live .md survives); eviction/rewrite paths fail closed when journal append fails; digested history is irreversible by design | +| `memory/*_journal.jsonl`, `memory/knowledge_history.jsonl`, `memory/knowledge/patterns_history.jsonl` | `ouroboros/memory.py`, `tools/control_runtime.py`, `tools/knowledge.py`, `reflection.py` — every append through the `append_jsonl` sidecar-lock seam | scratchpad journal: `type` rows; others unversioned full-text snapshots; historical digested rows retain `content_digested: true` | complete new old+new snapshots are retained indefinitely; `memory_journal_compaction.py` is a read-only compatibility entry point, not a source rewriter; existing digest-only rows cannot be restored; the `memory_journal_observation` startup event gives byte sizes (or missing/unreadable) for the three named journals; scratchpad keeps its eviction journal | deleting the journals loses undo/provenance; eviction/rewrite paths fail closed when journal append fails; historically digested content remains irrecoverable | | `memory/owner_mailbox/.jsonl` + `.acks.jsonl` | `ouroboros/owner_mailbox.py` (append-only; revocation appends, reader resolves) | `kind` discriminator | lifecycle-bounded: unlinked at task terminal; a startup sweep unlinks mailboxes whose task has a SETTLED durable result (no result / non-terminal keeps the mailbox fail-closed) | undelivered owner directives + restart-surviving hurry latch lost; acks lost ⇒ re-delivery | ## 7. Skills payloads, tasks, uploads, projects, services @@ -180,13 +180,15 @@ scanned data-relative path to be covered by a row here (count-anchored both ways Always safe (pure caches, recreated): `state/pycache`, `state/code_intel`, `state/evolution_metrics_cache.json`, `playwright-browsers/`, `state/cx`, `state/betterleaks`, lock files, `state/server_port`. -Safe with bounded cost: `WORLD.md` (regenerates), `dialogue_meta.json` -(re-consolidation), `state/usage_import_watermark.json` (safe re-import), +Safe with bounded cost: `WORLD.md` (regenerates), +`state/usage_import_watermark.json` (safe re-import), `ui_preferences.json`, `auth_secret.key` (one re-login). Fail-closed losses (system stays correct, work/authority is forgone): skill state dirs, `advisory_review.json`, `capability_evidence.json`, `pending_restart_verify.json`. Dangerous (authority/history destruction): `settings.json`, `state/usage_attempts.jsonl`, `task_results/**`, `logs/events.jsonl`, -`memory/**`, `archive/**`, `observability/**`, `state/subagent_worktrees.json` +`memory/**` (including `dialogue_meta.json`: deletion erases cursor and pending +nomination obligations; re-consolidation cannot reconstruct the old IDs), +`archive/**`, `observability/**`, `state/subagent_worktrees.json` (leak), `claudexor/**`, `state/python-userbase` (real deps). diff --git a/docs/architecture/01-high-level-architecture.md b/docs/architecture/01-high-level-architecture.md index bb8b26717..6b4a830f0 100644 --- a/docs/architecture/01-high-level-architecture.md +++ b/docs/architecture/01-high-level-architecture.md @@ -186,11 +186,12 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de ├── consciousness_wake.py ← The wake-up MESSAGE (`prompts/CONSCIOUSNESS.md` rendered as the turn's USER message, cuts disclosed as `(+N more)`) and the origin/authority envelope `wake_task_metadata` ├── consciousness_authority.py ← The three autonomy levels of a wake (observe/act/full) and their consequences — `disabled_tools`, bound at dispatch only so the prompt prefix matches an owner turn's, `runtime_mode_cap=light` below Full, and Observe's argument-level narrowing of the mutating names it keeps (§6 Background consciousness and Evolution) ├── consciousness_allowance.py ← Rolling-24h consciousness spend read off the usage ledger; typed `allowance_unknown` on a read failure; read by the alarm and the single admission door in `supervisor/queue.py` - ├── room_consolidation.py ← Per-room memory: one Light draft + one source-grounded correction per room, deterministic assembly of typed room sections into one block/era; no cross-room LLM recombine, legacy blocks keep unknown provenance (§6) - ├── consolidator.py ← Dialogue consolidation with a generation-aware cursor; an unfindable generation appends a loud `[MEMORY GAP]` block, never a silent offset reset; `last_consolidation_error` / `last_unpublished_nominations` in `dialogue_meta.json` (§6 Durable memory and project focus) + ├── room_consolidation.py ← Per-room Light draft/correction and deterministic assembly; no cross-room LLM recombine (§6) + ├── consolidator.py ← Generation cursor, explicit `[MEMORY GAP]`, and knowledge nomination outcomes in `dialogue_meta.json` (§6) + ├── memory_nomination_receipts.py ← Source-addressed pending nominations; no cross-batch retirement (§6) ├── memory.py ← Scratchpad, identity, chat history ├── knowledge.py ← `ouroboros/knowledge.py`: linked-Markdown note addressing, exact source reads, generated shelf indexes for global and project knowledge, and revision-checked writes, so concurrent cognition cannot silently overwrite a newer note (§6 Durable memory and project focus) - ├── memory_journal_compaction.py ← Digest-only compaction of old memory-journal snapshots: the digest replaces the snapshots it summarizes, never a silent drop + ├── memory_journal_compaction.py ← Startup read-only size facts (`memory_journal_observation`); new history stays complete, old digests unrecoverable ├── project_facts.py ← project_id resolution (explicit `--project-id` or workspace-path hash); per-project knowledge dir `projects//knowledge` isolated from `memory/knowledge`; journal/workpad helpers ├── task_tree_ledger.py ← Append-only `data/task_trees//blackboard.jsonl`: EPHEMERAL typed swarm coordination (`tree_note`/`tree_read`), mirrored into the durable project journal at root completion; pruned on root terminal ├── projects_registry.py ← Durable `data/state/projects.json`: 80-char names, `active|deleting|tombstoned`; deletion preserves bindings/history/folder/memory; a tombstone blocks resurrection; reconcile NEVER prunes (§6 Project registry and lease) diff --git a/docs/architecture/06-agent-core.md b/docs/architecture/06-agent-core.md index ca7f8a5a3..9a73ba0e4 100644 --- a/docs/architecture/06-agent-core.md +++ b/docs/architecture/06-agent-core.md @@ -16,7 +16,7 @@ Between the sends of one loop execution the transcript is append-only — each s Host plan/orphan disclosures ride beside the model answer as `terminal_host_notice`; delivery sends the model answer alone and the disclosure stays a field of the result (an owed pre-upgrade notice row still replays as the untyped System row it was), and the custody audit is its own typed row (`terminal_custody_notice` on the send event → `system_type="custody_notice"`, `card_row="timeline"`, its own owed delivery id), so the open-delegation fact is a row of the task's card and the unreconciled-runs note never enters the assistant text. CLI gets one host-labelled status (`terminal_host_notice_text`); external Presence speech excludes host notices (chapter 12). Stored answer bytes do not change: the answer keeps its hash and its real PASS or FAIL when only the notice changes, and equal text cannot revive a superseded verdict. Every reader that hands a result on — synthesis, parent handoff, `get_task_result`/`wait_task`/`wait_tasks`, the `task:` plan-evidence reader — carries the notice as a separate field, and the child-result and plan-evidence hashes include it, so a changed child limitation invalidates an old parent disposition or an old review. A host-salvaged terminal is labelled, not hidden: the durable row carries `Preserved intermediate output (not a final answer):` plus the bounded excerpt every terminal row uses, the untruncated copy stays with `get_task_result` and the stop receipt, and only a row written to the chat the stop receipt itself reached (`cancel_receipt.delivered_chat_id`, recorded after a successful send) reduces to its label beside the pointer; the two durable writes are not atomic, and no writer trades its only pointer for the label. One event is disclosed once, at the layer that owns it: the forced orphan note leaves a child to its own terminal row only where that row reached the reader the note addresses, and a rail that ended a routing turn is named by that turn's own row, never inferred from another layer's stamp. -The delivery-control protocol is resolved here and only here (DEVELOPMENT keeps the rule and points here). The candidate carries sticky loop-local provenance that its lineage has seen a host-issued delivery-control episode; without one, exact JSON is ordinary text. In a marked lineage both the ordinary and the forced resolver intercept recognizable whole-body envelopes and balanced trailing protocol attempts — valid `keep` resolves to the retained candidate, valid `replace` to `full_answer`, anything malformed preserves the retained candidate. Both strip one whole-body fence and treat a balanced protocol object at the very END of prose as a protocol attempt (`utils.extract_trailing_json_object` + `loop_delivery._parse_delivery_control_body`); the trailing-object rule deliberately refuses substring scanning, because quoted protocol literals mid-prose are legitimate text, so a control object quoted mid-prose stays prose and a truncated trailing fragment remains prose. During an ordinary acceptance continuation, `finalization_control="acceptance_feedback"` makes a complete revised answer ordinary prose and keeps `keep`/`replace`/`pending_review` optional. Prose resets the pending-review choice to `wait`; it never means `finish`. An outstanding effect, owner-revision or child-action control retains its stricter rule, and unread owner source still requires acknowledgement. Empty or recognizable malformed control bodies retain the candidate rather than becoming its replacement. The ordinary resolver takes one repair round then degraded-preserve; the forced resolver resolves purely and never re-loops — malformation preserves the retained candidate with the typed `delivery_control_degraded` reason, which is this forced rail's own code, while the ordinary repair path records `invalid_delivery_control_after_repair`. Every degradation carries the cause it computed: `outcomes.derive_loop_outcome` falls back to `delivery_control_degraded` only for a degradation that reports no cause and publishes the `loop_outcome.degraded`/`degraded_reason` pair the benchmark ledgers read. A malformed attempt in a marked lineage never leaks JSON, even after the transient latch clears. +The delivery-control protocol is resolved here and only here (DEVELOPMENT keeps the rule and points here). The candidate carries sticky loop-local provenance that its lineage has seen a host-issued delivery-control episode; without one, exact JSON is ordinary text. In a marked lineage both the ordinary and the forced resolver intercept recognizable whole-body envelopes and balanced trailing protocol attempts — valid `keep` resolves to the retained candidate, valid `replace` to `full_answer`, anything malformed preserves the retained candidate. Both strip one whole-body fence and treat a balanced protocol object at the very END of prose as a protocol attempt (`utils.extract_trailing_json_object` + `loop_delivery._parse_delivery_control_body`); the trailing-object rule deliberately refuses substring scanning, because quoted protocol literals mid-prose are legitimate text, so a control object quoted mid-prose stays prose and a truncated trailing fragment remains prose. During an ordinary acceptance continuation, `finalization_control="acceptance_feedback"` makes a complete revised answer ordinary prose and keeps `keep`/`replace`/`pending_review` optional. Prose resets the pending-review choice to `wait`; it never means `finish`. An outstanding effect, owner-revision or child-action control retains its stricter rule, and unread owner source still requires acknowledgement. Empty or recognizable malformed control bodies retain the candidate rather than becoming its replacement. The ordinary resolver takes one repair round then degraded-preserve; the forced resolver resolves purely and never re-loops — malformation preserves the retained candidate with the typed `delivery_control_degraded` reason, which is this forced rail's own code, while the ordinary repair path records `invalid_delivery_control_after_repair`. Every degradation carries the cause it computed: `outcomes.derive_loop_outcome` falls back to `delivery_control_degraded` only for a degradation that reports no cause and publishes the `loop_outcome.degraded`/`degraded_reason` pair the benchmark ledgers read. A malformed attempt in a marked lineage never leaks JSON, even after the transient latch clears. Presence forced calls (turn or root, not ceiling-only child) always arm `presence_finish`; original bytes reject duplicate keys (chapter 12). The child-absorption gate is an action gate: while undispositioned direct children remain, the loop HOLDS the candidate (`child_absorption_or_revision_required`) instead of arming the JSON-only control instruction — the hold-vs-arm split exists so the model never receives two contradictory instructions in one round. A typed keep cannot close the gate; after the one bounded reminder it forces the best-effort `children_unabsorbed` rail with a current `id [status] sha256` listing. The absorption digest's `## child` header carries the child's typed custody debt (`delegated_runs_unreconciled`, bounded, with a `get_task_result` pointer) as visibility only — the parent's authority over that patch is exactly the orphan rule, and a child's debt never relabels the root card (DESIGN §4). Finalizing over an UNDISPOSED OWN delegated patch is deliberately NOT gated: the consequence is disclosed where the decision is made (the `integrate_delegated_patch` schema, the apply receipts) and lands as the additive Done-with-warnings custody overlay rather than a hold; a pre-finalization reminder and propagation of child custody debt into the acceptance-subtree snapshot remain disclosed deferred gaps. @@ -68,7 +68,7 @@ A workspace task's completion compares against the captured preflight base — t `promote_chat_to_task`, `route_to_project`, `steer_task` and `ensure_project_scope` ride one receipt rail: an act succeeds only once its token-matched supervisor facts are durable in the task result, queue snapshot, annotation or mailbox authority; among several possible tasks the LLM chooses, code auto-delivers only the unambiguous one-target case, and an unconfirmed or stale receipt fails visibly instead of launching a second root. Receipts are retained per `(owner message, routing token)`: an earlier act's receipt stays readable by its token (`chat_annotation_receipt`) while the message's latest row is the UI projection and the picker's liveness test. A KNOWN rejection returns `rejected` with its reason, never a timeout, so `UNCONFIRMED` keeps its one meaning: no matching receipt exists. A receipt proves admission, not completion; an unread indicator proves a visible revision, not memory isolation. -WHO is speaking is ONE host-minted fact on the event (`control_routing._routing_issuer`; the model has no argument): an OWNER TURN (a direct turn the owner door stamped: `is_direct_chat` and `run_origin.owner_ingress`) or a TASK speaking for itself (a promoted root inherits the stamp as ancestry, not as issuer; a root relaying an owner message it just drained; a consciousness wake-up, a Presence event or the auto-resume template on the direct lane, which nobody typed — a wake's promotes mint consciousness roots inheriting its origin, ledger category and autonomy level). An owner turn's steer travels as owner text: `[Message from my human]`, the owner corpus, the generation bump that supersedes a reviewed answer, the room veto from the registry lane of the issuing chat (a Project room reaches its own roots, Main every host-listed root) and the owner acknowledgement. A task's own words NEVER travel as owner text: they go through the one task-message writer `forward_to_worker` also uses, as `independent_task` provenance, to any host-listed active independent root (hidden roots included, no room veto), render as `[Message from independent task ]`, enter no owner corpus (`owner_source_sha256` and the acceptance premises stay the owner's), carry no attachments, and are confirmed WRITTEN or refused with the host's reason. A relay keys and publishes its acknowledgement on the owner message it drained; other task acts use their own synthetic receipt id without a chat acknowledgement. Neither receipt grants owner authority; author and target ride the receipt and one `task_message_routed` Logs row, and the receiver's `task_message_injected` row names the sender. Independent roots learn the roster from a `[INDEPENDENT_ROOTS]` TAIL note (`peer_roster.py`, `ROSTER_NOTE_CAP` = 40 rows shown, the cut disclosed), appended only when the roster changed and never merged into a sent row. Rows identify live direct conversations without claiming an owner initiator; messaging one is the model's call. A root may publish one bounded `update_focus(text, source_ref)` record, exposed in grouped notes and paginated `live_roots` and retained by `recent_tasks` on dormant results. A `source_ref` names a reader; `update_focus` answers it through that reader under the caller's registry admission (`disabled_tools` included) and retains the exact answer (≤256 KiB) write-once on the canonical root as `focus.source_handle`, a native `task_source` ref peers read from any drive via `get_task_result(include_focus_source=True, focus_source_sha256=…)` against the PHYSICAL author's record: the digest selects the immutable historical file, so no later focus or retry can substitute its evidence (the roster's `retained_source`); a refusing reader or changed snapshot refuses the focus (`FOCUS_SOURCE_UNRESOLVED`), and the roster drops a settled root's focus. Focus has authored time separate from host observation and carries no TTL or owner authority. Explicit authorized project journal/workpad reads return exactly the requested source; foreign scoped writes refuse; children/Presence keep their capability ceiling. +WHO is speaking is ONE host-minted fact on the event (`control_routing._routing_issuer`; the model has no argument): an OWNER TURN (a direct turn the owner door stamped: `is_direct_chat` and `run_origin.owner_ingress`) or a TASK speaking for itself (a promoted root inherits the stamp as ancestry, not as issuer; a root relaying an owner message it just drained; a consciousness wake-up, a Presence event or the auto-resume template on the direct lane, which nobody typed — a wake's promotes mint consciousness roots inheriting its origin, ledger category and autonomy level). An owner turn's steer travels as owner text: `[Message from my human]`, the owner corpus, the generation bump that supersedes a reviewed answer, the room veto from the registry lane of the issuing chat (a Project room reaches its own roots, Main every host-listed root) and the owner acknowledgement. A task's own words NEVER travel as owner text: they go through the one task-message writer `forward_to_worker` also uses, as `independent_task` provenance, to any host-listed active independent root (hidden roots included, no room veto; a Presence sender only to its own binding's work, chapter 12), render as `[Message from independent task ]`, enter no owner corpus (`owner_source_sha256` and the acceptance premises stay the owner's), carry no attachments, and are confirmed WRITTEN or refused with the host's reason. A relay keys and publishes its acknowledgement on the owner message it drained; other task acts use their own synthetic receipt id without a chat acknowledgement. Neither receipt grants owner authority; author and target ride the receipt and one `task_message_routed` Logs row, and the receiver's `task_message_injected` row names the sender. Independent roots learn the roster from a `[INDEPENDENT_ROOTS]` TAIL note (`peer_roster.py`, `ROSTER_NOTE_CAP` = 40 rows shown, the cut disclosed), appended only when the roster changed and never merged into a sent row. Rows identify live direct conversations without claiming an owner initiator; messaging one is the model's call. A root may publish one bounded `update_focus(text, source_ref)` record, exposed in grouped notes and paginated `live_roots` and retained by `recent_tasks` on dormant results. A `source_ref` names a reader; `update_focus` answers it through that reader under the caller's registry admission (`disabled_tools` included) and retains the exact answer (≤256 KiB) write-once on the canonical root as `focus.source_handle`, a native `task_source` ref peers read from any drive via `get_task_result(include_focus_source=True, focus_source_sha256=…)` against the PHYSICAL author's record: the digest selects the immutable historical file, so no later focus or retry can substitute its evidence (the roster's `retained_source`); a refusing reader or changed snapshot refuses the focus (`FOCUS_SOURCE_UNRESOLVED`), and the roster drops a settled root's focus. Focus has authored time separate from host observation and carries no TTL or owner authority. Explicit authorized project journal/workpad reads return exactly the requested source; foreign scoped writes refuse; children/Presence keep their capability ceiling. `steer_task` relays the owner's exact ingress bytes only on the turn's FIRST routing act, while it still acts on the message that started it; the window ends with the latest owner message the turn DRAINED (`ToolContext.last_owner_delivery`, stamped at the loop's mailbox drain; a message only written to the mailbox ends nothing) or with a landed promote/route/steer receipt already on the origin message (a refused or unconfirmed act carried nothing, so the next act still relays). Past either, the turn RELAYS its own words and its receipt is keyed on the message relayed (the drained delivery's own client id, else the synthetic `agent-steer:` id), never again on an origin this turn already routed. An agent-authored steer belonging to no owner message earns its receipt under that synthetic id, confirmable through the same `routing_wait` poll; no chat row carries the id and the owner message's own receipt (what a later decision turn reads) stands. Each steer's mailbox entry is keyed by its routing token, so several instructions under one origin are several deliveries while a retried emit of one steer stays one. @@ -557,9 +557,9 @@ Every IMPLICIT claim — the UI conversion, that admission, the reaper's retry a `context.py` assembles static governance, semi-stable memory, and dynamic task evidence without treating truncation as forgetting; the recent-activity sections are each task's OWN newest rows (progress 50 rendered; tools 20 selected, 10 rendered and 20 scanned for review markers; events 200 counted by type) through the bounded reader `jsonl_tail.py` (`Memory.read_task_recent`: a doubling live tail plus at most three newest archives), never a global tail filtered afterwards (issue #131), and their header's coverage line names the rows, the window and any unopened archives while `read_file` pages the rest; a subagent child gets the same three windows beside its `## Working sources` block, its tools and events read from its own execution drive (its worker rows; host-side rows such as waits stay in the canonical log, as the header says) and progress from the canonical log; the Development context matrix and `context_layout.py` own which reference form is resident. When the rendered scratchpad exceeds `SCRATCHPAD_SECTION_BUDGET_CHARS`, `context.py` keeps the newest whole blocks that fit and drops the oldest behind an in-band gap marker naming `memory/scratchpad.md` as the live source; no block is retired by a context build, and scratchpad replacement keeps its explicit summary and source-journal provenance. -`consolidator.py` publishes a dialogue block only after every part succeeds, retaining raw generations and their cursor; an unfindable generation appends `[MEMORY GAP]`, never silently resets the offset. `context_fit` measures full Light requests against fresh route/account capacity and calibrated density (`llm_local.local_context_limits` owns local output reservation; missing/stale evidence remains unknown). `room_consolidation.py` drafts and corrects each room separately, then deterministically assembles the sections. Episodic text is grounded in that room's source; cumulative knowledge replacements require the complete current note plus the episode in BOTH stages. Corrected entries bind to the corrector's complete delivered read, never the draft's revision credit. A narrow or older episode cannot negate prior facts or later receipts; supported corrections and removals remain model judgment. Range reads, authored views, CAS and old/new history remain the publication path; no new stage or store. Failed, empty or truncated correction withholds the chunk, cursor and nominations. +`consolidator.py` publishes a block and advances its generation-aware cursor only after complete room draft and correction; a missing generation appends `[MEMORY GAP]` instead of resetting. `context_fit` measures Light against fresh route/account capacity and calibrated density; `llm_local` owns the local output reserve, and absent evidence stays unknown. `room_consolidation.py` processes each room separately and assembles sections deterministically. Both knowledge stages receive the entire current note and source episode; the corrector's complete read, not the draft's, binds revised entries. Older episodes cannot negate newer facts; model judgment governs supported corrections. Source range reads and CAS preserve old/new history. Startup compaction is a no-op; earlier digests cannot be reversed. A failed correction withholds the chunk and cursor. -Oversized source splits without clipping, including inside an entry; continuation context stays outside source bytes. A real refusal records its source hash and strictly smaller same-route byte bound in `dialogue_meta.json` (`consolidation_retry`); changed source, route, capacity or output reserve invalidates it. Era compression regroups each recorded room across blocks and reassembles deterministically; legacy untyped blocks remain explicitly unknown provenance. Failed/overflowed eras preserve old blocks. Failures retain `last_consolidation_error`, cleared by an advance without a new failure; incomplete knowledge publication retains `last_unpublished_nominations`. Unknown spend remains nullable and control/resource/unknown model errors retain `propagate_model_error` semantics. +Oversized sources split without clipping, including within an entry. `consolidation_retry` records source hash and a smaller same-route bound, invalidated by source/route/capacity/reserve changes. Era compression regroups rooms deterministically; failed eras retain blocks and legacy provenance stays unknown. `last_consolidation_error` clears after a failure-free advance. `pending_knowledge_nominations` records each source entry BEFORE note publication; an unrelated successful batch cannot remove one. Legacy `last_unpublished_nominations` persists. Health shows three distinct abbreviated source+position IDs and omitted count; full proposals live in `knowledge_history.jsonl`. Unreadable meta preserves debts and warns in Health; Nano pressure records no-progress instead of aborting Main. Invalid legacy receipts warn separately. Old digests and debts need explicit resolution. Spend remains nullable; model-control errors follow `propagate_model_error`. Consolidation labels every chronological source message through `dialogue_provenance.RoomLabelResolver`, using the actual `chat_id`, never lineage `project_id`; one read-only registry snapshot supplies the window. Main is named only for the actual Main id, a resolved project uses its current registry name and stable chat id, and missing, unknown or ambiguous rooms stay explicit. Ephemeral formatter offsets carry the original room/author/direction/transport header into split continuations without parsing message bodies or duplicating their bytes. Room draft and correction prompts require meaningful decisions, approvals, outcomes and unresolved commitments of that room, retaining source distinctions (who decided, what was authorized, what stays owed) and one first-person Ouroboros voice. Length adapts to content within the existing output-token ceiling; no per-room word quota, semantic gate or absent room is imposed. Labels establish provenance, not summary success. The mixed Main recent view opts into the same labels; focused Project rendering, membership and explicit `chat_history` retain their existing behavior and bytes. diff --git a/docs/architecture/12-host-service-companions-and-chat-ids.md b/docs/architecture/12-host-service-companions-and-chat-ids.md index 39716b351..39e466532 100644 --- a/docs/architecture/12-host-service-companions-and-chat-ids.md +++ b/docs/architecture/12-host-service-companions-and-chat-ids.md @@ -12,9 +12,9 @@ Admission is one limiter with two policies (`host_service._RateLimiter`): the WS Operation correlation: a named injected message has `operation_ref=:` on 202, 200, 504 and disconnect responses. `supervisor.message_bus.accept_local_message` serializes check, canonical inbound-row acceptance and enqueue, and the `log_chat` write must succeed before work is queued. Repeated same-id, same-text, same-skill delivery rejoins even before supervisor dequeue; changed content or source is refused with 409. The queue stays in-memory: a crash after acceptance can lose delivery and reads honestly as `lost`, never authorizing a second enqueue. Routing annotations and outbound task ids are discovery hints only — task reads and cancellation require the actual queue/task record's complete `origin_message_ref` to match the authenticated skill's canonical source (`DirectActivityRegistry` carries the same origin), and named response waits poll that exact operation and its retry-aware effective task result, because chat ordering alone never proves a reply. Cancel enters the durable intent and cascade-custody owner (§5) only for work with that origin and the same installation root; a different or unavailable owner root and unaddressable or foreign work are `cancel_unsupported` before any intent is written, and unresolved custody never becomes a false `cancelled`. -Presence (`presence_runner.py`): `POST /presence/turn` admits an exact provider/account/conversation/thread event and skill-state-confined files under the hash-bound `presence` permission and an owner binding (`state/presence_bindings.json`). Agents share one autobiography. Event-derived IDs deduplicate retries; cross-process locks cap concurrency and serialize conversations. Inbound, initiated and receipt paths derive one conversation key and chat id (`presence_bindings.conversation_key`, empty thread = `0`). A host-reconciled placeholder, or a still-running row of a dead attempt, is not a result: the same event id re-runs told what that attempt confirmed sending (unknown once its rows left the live chat generation), and the row shows `superseded_placeholder`. A presence-local liveness set shields an executing turn from orphan reconciliation without making it an owner-addressable direct actor, so update drains do not wait for it and after a restart the transport's retry re-runs it. Each conversation's last executed turn is a rebuildable pointer (`state/presence_turn_gate/last-.json`; canonical sources: task results and receipts; a replay of a settled, authored turn that lost it rebuilds it) shown to the next turn. Turns, receipts and inject have separate per-skill in-flight budgets. Outcomes are message/silent/tool_delivered/deferred; deferred needs a correlated `work_ref`, read via `GET /presence/work/{work_ref}`, not the general task API; an orphan-reconciled child replays silent (nobody re-runs it). Promotion discards requested Project/workspace/source widening and copies the ceiling, cost and return context; unusable folders return `workspace_unusable` with repair detail. `presence_cancel_work` requires the current binding and conversation. Owner chat and consciousness at Act or above may `initiate_presence` on an enabled binding. +Presence (`presence_runner.py`): `POST /presence/turn` admits an exact provider/account/conversation/thread event and skill-state-confined files under the hash-bound `presence` permission and an owner binding (`state/presence_bindings.json`). Agents share one autobiography. Event-derived IDs deduplicate retries; cross-process locks cap concurrency and serialize conversations. Inbound, initiated and receipt paths derive one conversation key and chat id (`presence_bindings.conversation_key`, empty thread = `0`). A host-reconciled placeholder, or a still-running row of a dead attempt, is not a result: the same event id re-runs told what that attempt confirmed sending (unknown once its rows left the live chat generation), and the row shows `superseded_placeholder`. A presence-local liveness set shields an executing turn from orphan reconciliation without making it an owner-addressable direct actor, so update drains do not wait for it and after a restart the transport's retry re-runs it. Each conversation's last executed turn is a rebuildable pointer (`state/presence_turn_gate/last-.json`; canonical sources: task results and receipts; a replay of a settled, authored turn that lost it rebuilds it) shown to the next turn. Turns, receipts and inject have separate per-skill in-flight budgets. Outcomes are message/silent/tool_delivered/deferred; deferred needs a correlated `work_ref`, read via `GET /presence/work/{work_ref}`, not the general task API; an orphan-reconciled child replays silent (nobody re-runs it). Promotion discards requested Project/workspace/source widening and copies the ceiling, cost and return context, and its scheduled row already carries the Presence provenance; unusable folders return `workspace_unusable` with repair detail. A binding's own work is an independent root carrying its nonempty binding id, from any of its conversations (`presence_related_work`); other bindings, owner roots, inline turns, children and rows without that provenance are never attributed. The context lists its first page (canonical root, read gaps stated); new ceilings add `recent_tasks`/`get_task_result` host-bound to `presence_scope=own_binding` plus `steer_task`, and steer, `presence_cancel_work` and a selected cancel or forward reach only that work (cancel/forward also the caller's own tree); a selected global reader stays global. A delegated descendant inherits the ceiling plus only `metadata.presence_binding_authority`, never the speaker's `metadata.presence` (no forced reply): its readers, steer and forward reach that work and its own tree, root included; its promote and that root's follow-ups carry the same carrier (`presence_root_carrier`): related work that never speaks. Steer, like read/cancel, trusts a canonical row over a live one; an unstamped steer is fenced by the sender's live row. Owner chat and consciousness at Act or above may `initiate_presence` on an enabled binding. -The ceiling includes knowledge, scratchpad, identity and chat history, not correspondent tool or owner-command authority. Unselected baseline `chat_history` is unbound; older ceilings keep their digest without gaining baseline tools. `presence_context.py` supplies route facts and frames incoming speech as observation, distinct from initiation or inherited work. Source text stays separate from host attachment declarations; optional provider identity/mention/root facts do not determine the addressee. The model chooses useful, concise participation or silence; early speech follows that choice through selected send tools, never Working forwarding. `presence_finish` enters common completion checks after the tool/control/budget tail; an omitted message/deferred body keeps an answer round. Revised/failed work discards stale completion text. Authored best-effort replies remain speech; host diagnostics stay owner-side. Host-only terminals retain scheduled child custody as deferred with an empty body. Live/cache/work readers share authorship and empty-body rules; unknown-origin legacy text speaks only for completed rows. Synthesis preserves the recorded adapter body, outcome/origin/work reference and internal result, not today's replay policy; preparation is no delivery receipt. +The ceiling includes knowledge, scratchpad, identity and chat history, not correspondent tool or owner-command authority. Unselected baseline `chat_history` is unbound; older ceilings keep their digest without gaining baseline tools. `presence_context.py` supplies route facts and frames incoming speech as observation, distinct from initiation or inherited work. Source text stays separate from host attachment declarations; optional provider identity/mention/root facts do not determine the addressee. The model chooses useful, concise participation or silence; early speech follows that choice through selected send tools, never Working forwarding. `presence_finish` enters common completion checks after the tool/control/budget tail; an omitted message/deferred body keeps an answer round. Revised/failed work discards stale completion text, and the next round is told so with the task's confirmed sends. Ordinary authored best-effort replies remain speech. A forced final is the internal record: only a valid `presence_finish` declared inside that one call speaks (a declared partial even on failure, never a `tool_delivered` note); anything else says nothing new and records `presence_declaration`. Host-authored text stays owner-side; an ordinary final's own wording is not screened. A tool-send finish note stays a separate `finish_note` in the previous-turn pointer even when owed work changes its outcome to deferred; it is never prior speech. Host-only terminals retain scheduled child custody as deferred with an empty body. Live/cache/work readers share authorship and empty-body rules; unknown-origin legacy text speaks only for completed rows. Synthesis preserves the recorded adapter body, outcome/origin/work reference and internal result, not today's replay policy; preparation is no delivery receipt. Receipt reporting is negotiated through `/identity` (`presence_delivery_version: 1`) and optional `delivery_reporting_version: 1` on a turn; mode survives cached/deferred results. Mode1 writes outgoing history on provider receipts; mode0 retains an authored, delivery-unconfirmed row. `presence_delivery.py` accepts authenticated `POST /presence/delivery` observations through the chat writer. Parts retain target, text/format and provider facts; SMTP `accepted` means provider acceptance only. Speech is typed `presence_delivery`, never a task-finalizing untyped row; failed/uncertain attempts are System facts, queued remains in tool/outbox receipts. Memory, history and consolidation retain destination/state/details. A Host-context projection rebuilds once from retained chat generations and updates after required writes; identical report retries deduplicate, changed facts conflict. Transport outboxes own provider receipts and separate report ACK/backoff: a slow or failed report neither resends nor blocks provider delivery. No second store or scheduler; old queues are not imported. Wire fields and procedure: CREATING_SKILLS, “Reporting actual Presence delivery”. diff --git a/docs/development/06-rules-by-change-class.md b/docs/development/06-rules-by-change-class.md index a84ed26a4..413c82c07 100644 --- a/docs/development/06-rules-by-change-class.md +++ b/docs/development/06-rules-by-change-class.md @@ -51,10 +51,10 @@ Enforcement: `tests/test_protected_artifacts_policy.py` and `tests/test_acceptan ### Skill-defined Presence - Keep behavior portable and authority installation-local: a reviewed `presence:` profile declares instructions, context topics, bounded runtime defaults and conceptual tool/script/resource requests — never provider credentials, room ids or one installed tool spelling; `presence_capabilities.py` stores the owner's exact selections outside the payload, fingerprinted by the request semantics that authorize them. Preserve its optional `workspace_root` (an owner-local external folder, validated through the existing workspace admission and copied into each task contract) when editing runtime/capability selections; unset profiles retain their prior serialized state and fingerprint. Presence keeps canonical shared memory without deriving a Project or creating a forked drive from that folder (ARCHITECTURE §6 "Skills and extensions"). -- Presence authority is a positive immutable ceiling, not a denylist or a prompt promise: admission requires the owner-created binding plus an installed, enabled, freshly executable behavior skill and every required selection, then freezes skill/profile/state/selection fingerprints, exact grants (the profile's selections plus the constant cognitive-memory baseline `tool_capabilities.COGNITIVE_MEMORY_TOOL_NAMES`; a selected grant keeps its bindings), argument bindings, runtime slot and round limit into `task_contract.capability_ceiling`. Schema discovery and execution enforce that same ceiling for built-ins, extensions, MCP tools, scripts and resource roots. +- Presence authority is a positive immutable ceiling, not a denylist or a prompt promise: admission requires the owner-created binding plus an installed, enabled, freshly executable behavior skill and every required selection, then freezes skill/profile/state/selection fingerprints, exact grants (the profile's selections plus the constant cognitive-memory baseline `tool_capabilities.COGNITIVE_MEMORY_TOOL_NAMES` and the own-work baseline — both readers host-bound to `presence_scope=own_binding`, `steer_task`; a selected grant keeps its bindings), argument bindings, runtime slot and round limit into `task_contract.capability_ceiling`. Schema discovery and execution enforce that same ceiling for built-ins, extensions, MCP tools, scripts and resource roots. - `state/presence_bindings.json` is host-owned authority: a transport token resolves only bindings naming that exact transport skill, and the submitted provider/account/conversation/thread must match the binding origin — never recover those identities from message text. Staged files stay inside the calling skill's state root before entering the ordinary attachment store (the turn flow: ARCHITECTURE §12). - Run each admitted event with a fresh agent, a deterministic binding-plus-source-event task id, the cross-process installation-wide concurrency gate and per-conversation serialization; the transport's durable provider custody owns arrival FIFO before Host admission. Do not add a transport-specific task scheduler, memory silo, core terminal outbox or resident cross-room agent. -- Completion is exactly `message`, `silent`, `tool_delivered` or `deferred` (deferred requires a successfully promoted `work_ref`; correlated lookup stays behind the same transport token and binding, and `presence_cancel_work` additionally requires the current binding and conversation to match). Promotion and `schedule_followup` copy the Presence metadata, admitted workspace and capability ceiling by value; any new descendant producer preserves this ceiling or refuses the transition — reconstructing authority from mutable current state is forbidden. +- Completion is exactly `message`, `silent`, `tool_delivered` or `deferred` (deferred requires a successfully promoted `work_ref`; correlated lookup stays behind the same transport token and binding). A Presence caller reads, steers and cancels only independent roots of its own nonempty binding (`presence_authority.presence_work_refusal`); a delegated descendant is one through the inherited `metadata.presence_binding_authority` alone, never the speaker's `metadata.presence`; and a forced final speaks only its nested `presence_finish` declaration. Promotion and `schedule_followup` copy one Presence carrier (`presence_root_carrier`: speaker metadata or a descendant's binding), admitted workspace and capability ceiling by value; any new descendant producer preserves this ceiling or refuses the transition — reconstructing authority from mutable current state is forbidden. - Knowledge-topic and scratchpad mutation each use one stable lock, so concurrent owner and Presence turns cannot overwrite a newer projection with an older render. Test the boundary at both layers — strict profile/state/ceiling parsing, stale/missing review admission, schema and direct-execution filtering, argument binding, binding/token/origin checks, event idempotency and conversation ordering, typed outcomes, late-work correlation, promotion/follow-up inheritance; provider adapter E2E is separate evidence. Enforcement: `tests/test_presence_admission.py` plus the both-layer boundary tests this list requires. ### Devtools isolation diff --git a/docs/inventories/DATA_LAYOUT_INVENTORY.md b/docs/inventories/DATA_LAYOUT_INVENTORY.md index 0bd62fd11..5d89f3a6f 100644 --- a/docs/inventories/DATA_LAYOUT_INVENTORY.md +++ b/docs/inventories/DATA_LAYOUT_INVENTORY.md @@ -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 602-691; UTF-8 SHA-256 `011862ab738963b8d97569057d18e717bbe8b08842105757afe692b2bc1b4a2a`. +Source: `docs/architecture/01-high-level-architecture.md`, physical LF lines 603-692; UTF-8 SHA-256 `7954873ca14387e7d615b148fc6a97444eb6ff922837f3a33a3bedc034362b3f`. - entries: **79** (code-ref: 72, repo-dir: 6, repo-path: 1) diff --git a/ouroboros/consolidator.py b/ouroboros/consolidator.py index 8c7d12785..33a2cccb1 100644 --- a/ouroboros/consolidator.py +++ b/ouroboros/consolidator.py @@ -10,7 +10,6 @@ from ouroboros import room_consolidation from ouroboros.utils import ( append_jsonl, atomic_write_json, - read_json_dict, replace_atomic, utc_now_iso, read_text, @@ -388,6 +387,10 @@ def _run_block_consolidation( return total_usage for nominated_block, _entries in pending_knowledge: nominated_block["knowledge_source_ref"] = ref + if knowledge_context is not None: + from ouroboros.memory_nomination_receipts import prepare + pending_ids = prepare(meta, source_id, pending_knowledge) + atomic_write_json(meta_path, meta) # Debt precedes block and note publication. existing_blocks = _load_blocks(blocks_path) all_blocks = existing_blocks + new_blocks @@ -435,21 +438,11 @@ def _run_block_consolidation( "source_ref": block["knowledge_source_ref"], "outcomes": block["knowledge_writes"], }) if pending_knowledge: - # Nominations were durable before mutation. Outcome facts belong to - # the same blocks, so a failed write is available to later learning. _write_locked_json(blocks_path, all_blocks) - # Era compression later replaces these blocks with one object carrying no - # knowledge_writes, so this batch receipt lives in meta, not in a scan of - # dialogue_blocks.json. A fully published batch clears it; a run with no - # nominations at all leaves the older receipt standing. - # Count what was NOMINATED, not only what produced an outcome: an entry - # the writer skipped as malformed was not published either. - nominated = sum(len(entries) for _block, entries in pending_knowledge) - failed = nominated - sum(1 for outcome in published if outcome["ok"]) - meta.pop("last_unpublished_nominations", None) - if failed > 0: - meta["last_unpublished_nominations"] = {"entry_id": ref["entry_id"], - "failed": failed, "total": nominated} + from ouroboros.memory_nomination_receipts import settle + settle(meta, pending_ids, published) + # Legacy batch-only receipts remain open: no positional evidence can + # prove which old entry a later successful nomination resolved. _advance_cursor(meta, segments, segment_sigs, segment_entries, last_offset + processed) if not run_failed: # An advance by a run that recorded no failure retires a stale error. @@ -1019,8 +1012,17 @@ def maintain_memory_pressure(memory: Any, llm_client: Any, context: Any, *, identity_ref = retain_memory_source(context, "maintenance_identity", memory.identity_path().read_bytes()) identity += "\nExact identity source, available through read_file; no identity rewrite is authorized here:\n" + json.dumps(identity_ref) if chat.exists() or blocks.exists(): - usage = consolidate(chat, blocks, meta, llm_client, identity, knowledge_context=context, - force_tail=True, compact_chronicle=True, pressure_fits=fits) + from ouroboros.memory_nomination_receipts import DialogueMetaUnreadable + + try: + usage = consolidate(chat, blocks, meta, llm_client, identity, knowledge_context=context, + force_tail=True, compact_chronicle=True, pressure_fits=fits) + except DialogueMetaUnreadable as exc: + # A damaged existing cursor is neither empty nor permission to rewrite + # memory. Keep the original context available to Main, with a typed + # maintenance gap instead of aborting its first round. + usage = {"_consolidation_errors": [{"kind": "dialogue_meta_unreadable", + "message": str(exc)}]} if usage is not None: usages.append(usage) actions.append({"owner": "dialogue_consolidation", "usage": usage}) @@ -1237,7 +1239,9 @@ def _advance_cursor( def _load_meta(path: pathlib.Path) -> Dict[str, Any]: - return read_json_dict(path) or {} + from ouroboros.memory_nomination_receipts import load_meta + + return load_meta(path) from ouroboros.utils import jsonl_generation_signature as _chat_log_signature @@ -1452,9 +1456,11 @@ def _write_knowledge_entries( outcomes = [] for entry in entries: if not isinstance(entry, dict): + outcomes.append({"topic": "", "ok": False, "reason": "malformed_nomination"}) continue topic, content = entry.get("topic"), entry.get("content") if not isinstance(content, str) or not content.strip(): + outcomes.append({"topic": topic, "ok": False, "reason": "empty_nomination"}) continue try: topic = sanitize_topic(topic) diff --git a/ouroboros/context.py b/ouroboros/context.py index 59be6cef1..8ce98ec8f 100644 --- a/ouroboros/context.py +++ b/ouroboros/context.py @@ -1359,6 +1359,8 @@ def _capture_context_core( presence_section = build_presence_context_section( pathlib.Path(env.drive_root), task_metadata.get("presence"), + str(task.get("id") or ""), + status_root=canonical_root, # a forked promoted root finds its binding's work canonically ) if presence_section: dynamic_parts.append(presence_section) diff --git a/ouroboros/context_health.py b/ouroboros/context_health.py index 9ef599aa7..b8ec6a73e 100644 --- a/ouroboros/context_health.py +++ b/ouroboros/context_health.py @@ -232,26 +232,47 @@ def _memory_health_lines(env: Any) -> List[str]: except Exception: pass + from ouroboros.memory_nomination_receipts import DialogueMetaUnreadable, load_meta + try: - meta = read_json_dict(env.drive_path("memory/dialogue_meta.json")) or {} - receipt = meta.get("last_unpublished_nominations") - if isinstance(receipt, dict) and int(receipt.get("failed") or 0) > 0: - # The recovery route is named because the reader may hold no read_file: - # an external-channel turn has the cognitive memory tools and nothing else. + meta = load_meta(env.drive_path("memory/dialogue_meta.json")) + except DialogueMetaUnreadable: + # A broken existing meta file is not an empty nomination/cursor state. + lines.append("WARNING: DIALOGUE META UNREADABLE — memory/dialogue_meta.json; " + "consolidation withheld to preserve existing bytes") + else: + pending = meta.get("pending_knowledge_nominations") + if pending: + # load_meta already validates the whole list; malformed state raises. + sample = ", ".join(row["id"].split(":")[0][:12] + ":" + + ":".join(row["id"].split(":")[-2:]) + for row in pending[:3]) lines.append( - f"WARNING: LAST DIALOGUE KNOWLEDGE PUBLICATION INCOMPLETE — {receipt.get('failed')} of " - f"{receipt.get('total')} nominations from the latest consolidation batch were not published " - f"(entry_id {receipt.get('entry_id')}); from the main chat, read_file(root='runtime_data', " - "path='memory/knowledge_history.jsonl') and publish what still holds" + f"WARNING: DIALOGUE KNOWLEDGE PUBLICATION OPEN — {len(pending)} source-addressed " + f"nominations (first {min(3, len(pending))}: {sample}; omitted {max(0, len(pending)-3)}). " + "Read memory/dialogue_meta.json and memory/knowledge_history.jsonl for full source. " + "No automatic or tool-level discharge exists yet; later successes cannot retire older entries." ) + receipt = meta.get("last_unpublished_nominations") + if isinstance(receipt, dict): + failed = receipt.get("failed") + if type(failed) is int and failed > 0: + # This old batch receipt cannot be retired by a later new-source success. + lines.append( + f"WARNING: LAST DIALOGUE KNOWLEDGE PUBLICATION INCOMPLETE — {failed} of " + f"{receipt.get('total')} nominations in a legacy consolidation batch remain unresolved " + f"(entry_id {receipt.get('entry_id')}); from the main chat, read_file(root='runtime_data', " + "path='memory/knowledge_history.jsonl') and publish what still holds" + ) + elif failed is not None and failed != 0: + lines.append("WARNING: DIALOGUE LEGACY NOMINATION RECEIPT INVALID — " + "memory/dialogue_meta.json; inspect the original receipt") error = meta.get("last_consolidation_error") if isinstance(error, dict): lines.append( f"WARNING: LAST DIALOGUE CONSOLIDATION FAILED — kind={error.get('kind') or 'unknown'} " f"at cursor {error.get('cursor_offset')}" ) - except Exception: - pass return lines diff --git a/ouroboros/dialogue_provenance.py b/ouroboros/dialogue_provenance.py index 45b19cc69..f28e5d10e 100644 --- a/ouroboros/dialogue_provenance.py +++ b/ouroboros/dialogue_provenance.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from pathlib import Path from typing import Any, Mapping from ouroboros.contracts.chat_id_policy import HIDDEN_CHAT_ID, WEB_UI_CHAT_ID @@ -25,6 +26,168 @@ def is_presence_task(task: Mapping[str, Any]) -> bool: ) +# A Presence binding's own work is reached through this scope (owner Q1/Q2). +PRESENCE_OWN_WORK_SCOPE = "own_binding" +# The one metadata key a DELEGATED descendant of a Presence-bound task carries: +# the binding it acts for, never the speaker's ``metadata.presence`` (whose +# presence arms the forced reply, the parser and the conversation context). +PRESENCE_BINDING_AUTHORITY_KEY = "presence_binding_authority" + + +def presence_record_binding(record: Any) -> str: + """The nonempty host binding id one task/queue record carries, else ``""``. + + The one reader of both carriers: a speaker's ``metadata.presence`` and the + ``metadata.presence_binding_authority`` of work a delegated descendant started. + """ + + metadata = record.get("metadata") if isinstance(record, Mapping) else None + return presence_metadata_binding(metadata) or "" + + +def presence_related_work(binding_id: str, record: Any) -> bool: + """Independent work started from this same nonempty binding (owner Q1). + + Related work is a promoted or follow-up ROOT carrying the host's Presence + provenance for exactly this binding id, whichever of its conversations it + came from. An inline Presence turn, a delegated child, an owner root and a + record without that provenance are never attributed to the binding. + """ + + binding = str(binding_id or "").strip() + return bool( + binding + and isinstance(record, Mapping) + and presence_record_binding(record) == binding + and str(record.get("delegation_role") or "") == "root" + and not str(record.get("parent_task_id") or "").strip() + ) + + +def presence_metadata_binding(metadata: Any) -> str | None: + """``None`` for a non-Presence task's metadata; otherwise the binding it acts for (may be empty). + + A Presence turn, promoted or follow-up root speaks from ``metadata.presence``; + a delegated descendant holds only the host-inherited binding authority. A + malformed authority carrier is still a Presence one: it narrows to nothing. + """ + + if not isinstance(metadata, Mapping): + return None + if "presence" in metadata: + carrier = metadata["presence"] + elif PRESENCE_BINDING_AUTHORITY_KEY in metadata: + carrier = metadata[PRESENCE_BINDING_AUTHORITY_KEY] + else: + return None + value = carrier.get("binding_id") if isinstance(carrier, Mapping) else None + return value.strip() if isinstance(value, str) else "" + + +def presence_caller_binding(ctx: Any) -> str | None: + """``None`` for a non-Presence caller; otherwise its binding id (may be empty).""" + + binding = presence_metadata_binding(getattr(ctx, "task_metadata", None)) + contract = getattr(ctx, "task_contract", None) + return "" if binding is None and isinstance(contract, Mapping) and "capability_ceiling" in contract else binding + + +def presence_binding_authority_metadata(parent_metadata: Any, *, task_contract: Any = None) -> dict[str, Any]: + """What a child delegated by this task inherits: its binding authority only, or nothing.""" + + binding = presence_metadata_binding(parent_metadata) + if binding is None and isinstance(task_contract, Mapping) and "capability_ceiling" in task_contract: + binding = "" # A lost carrier never turns an inherited Presence ceiling into global authority. + return {} if binding is None else {PRESENCE_BINDING_AUTHORITY_KEY: {"binding_id": binding}} + + +def presence_root_carrier(source: Any, *, task_contract: Any = None) -> dict[str, Any]: + """The Presence carrier an independent root started from ``source`` keeps, or ``{}``. + + ``source`` is the starting task's metadata or its promote event. A Presence + turn or root hands on its speaker metadata: the new root answers the same + conversation. A delegated descendant hands on only the binding it acts for: + its root is that binding's related work, never a speaker. A malformed or lost + carrier under a ceiling narrows to an empty binding. Producer and admission + both read this; ``presence_record_binding`` reads what it writes. + """ + + presence = source.get("presence") if isinstance(source, Mapping) else None + if isinstance(presence, Mapping) and presence: + return {"presence": dict(presence)} + return presence_binding_authority_metadata(source, task_contract=task_contract) + + +def presence_sender_origin(ctx: Any) -> dict[str, str]: + """Where a Presence caller's run started (its ``run_origin`` room/event facts). + + It names the sending run's origin only: later arrivals in that turn may have + been written by other people, so it never claims authorship of quoted words. + """ + metadata = getattr(ctx, "task_metadata", None) + return dict(run_origin({"metadata": metadata if isinstance(metadata, Mapping) else {}}).get("presence") or {}) + + +def presence_queue_task(drive_root: Any, task_id: str) -> dict[str, Any] | None: + """The persisted queue row of one pending/running task, if the snapshot lists it.""" + + from ouroboros.utils import read_json_dict + + snapshot = read_json_dict(Path(drive_root) / "state" / "queue_snapshot.json") or {} + for key in ("pending", "running"): + for item in snapshot.get(key) or []: + task = item.get("task") if isinstance(item, Mapping) else None + if isinstance(task, Mapping) and str(item.get("id") or task.get("id") or "") == task_id: + return {**dict(task), "id": task_id} + return None + + +def presence_target_record(drive_root: Any, task_id: str, *, + queue_row: Mapping[str, Any] | None = None) -> Mapping[str, Any] | None: + """The record that decides whose work ``task_id`` is. + + The canonical task record decides, a malformed Presence carrier included (it + narrows to nothing); a legacy row without Presence provenance may be established + only by the queue's own task metadata — ``queue_row`` when the caller holds the + live row (the supervisor), else the persisted snapshot. + """ + + from ouroboros.task_results import load_task_result + + target = str(task_id or "").strip() + try: + stored = load_task_result(Path(drive_root), target) if target else None + except (OSError, ValueError): + stored = None # an unreadable or invalid id is no evidence of relation + record = stored if isinstance(stored, Mapping) and stored else None + contract = record.get("task_contract") if isinstance(record, Mapping) else None + has_ceiling = isinstance(contract, Mapping) and "capability_ceiling" in contract + if record is None or (presence_metadata_binding(record.get("metadata")) is None and not has_ceiling): + queued = {**dict(queue_row), "id": target} if isinstance(queue_row, Mapping) else ( + presence_queue_task(drive_root, target)) + record = queued or record + return record + + +def presence_effective_hops(task_id: str, effective: Any) -> list[str]: + """The OTHER tasks an effective projection of ``task_id`` carries: retry lineage and successor.""" + + if not isinstance(effective, Mapping): + return [] + ids = [value for hop in effective.get("retry_lineage") or [] if isinstance(hop, Mapping) + for value in (hop.get("task_id"), hop.get("retry_task_id"))] + ids.append(effective.get("task_id") or effective.get("id")) + requested = str(task_id or "").strip() + return [hop for hop in dict.fromkeys(str(value or "").strip() for value in ids) if hop and hop != requested] + + +def presence_effective_related(binding: str, task_id: str, effective: Any, *, drive_root: Any) -> bool: + """Whether every task an effective projection of ``task_id`` reaches is this binding's own work.""" + + return all(presence_related_work(binding, presence_target_record(drive_root, hop)) + for hop in presence_effective_hops(task_id, effective)) + + def presence_provenance_from_task(task: Mapping[str, Any]) -> dict[str, str]: """Return the stable, non-secret presence facts carried by one task. diff --git a/ouroboros/domains.toml b/ouroboros/domains.toml index c15f438ce..876ba6283 100644 --- a/ouroboros/domains.toml +++ b/ouroboros/domains.toml @@ -273,6 +273,7 @@ D20 = "Presence" "ouroboros/mcp_client.py" = "D05" "ouroboros/memory.py" = "D15" "ouroboros/memory_journal_compaction.py" = "D15" +"ouroboros/memory_nomination_receipts.py" = "D15" "ouroboros/model_concurrency.py" = "D02" "ouroboros/model_send_seal.py" = "D16" "ouroboros/mutation_attribution.py" = "D01" diff --git a/ouroboros/gateway/task_list_scan.py b/ouroboros/gateway/task_list_scan.py index fc85c9b5e..64119d764 100644 --- a/ouroboros/gateway/task_list_scan.py +++ b/ouroboros/gateway/task_list_scan.py @@ -12,6 +12,7 @@ import logging import pathlib from typing import Dict, List +from ouroboros.presence_authority import presence_metadata_binding, presence_record_binding from ouroboros.task_result_schema import ( quarantine_task_result, task_result_schema_refusal, @@ -78,6 +79,17 @@ def raw_result_facts(results_dir: pathlib.Path, *, reader=None) -> tuple[Dict[st malformed.append(name) continue facts = {field: str(data.get(field) or "") for field in _RESULT_FACT_KEYS} + # One derived scalar selects a Presence binding's own work without a + # second read; the full row still decides once the selection loads it. + facts["presence_binding_id"] = presence_record_binding(data) + # An empty scalar is not proof of absent provenance: a malformed carrier or + # lost metadata under an inherited ceiling must never inherit a queue claim. + metadata = data.get("metadata") + contract = data.get("task_contract") + facts["presence_authority_recorded"] = ( + presence_metadata_binding(metadata) is not None + or isinstance(contract, dict) and "capability_ceiling" in contract + ) facts["schema_refusal"] = task_result_schema_refusal(data) rows[name] = facts _RAW_TS_MEMO[key] = (signature, tuple(facts.items())) diff --git a/ouroboros/loop.py b/ouroboros/loop.py index e2fa80406..9767b15bf 100644 --- a/ouroboros/loop.py +++ b/ouroboros/loop.py @@ -118,6 +118,10 @@ def _finalize_loop_candidate(content, limit_ctx, tools, emit_progress, *, after_ if transcript_growth_signature(limit_ctx.messages) == spoken_before: wait_for_acceptance_feedback(tools, limit_ctx, limit_ctx.llm_trace, limit_ctx.tool_schemas, limit_ctx.owner_msg_seen) + elif isinstance(completion, dict): # that owed round also learns its finish is void + from ouroboros.presence_context import presence_finish_not_accepted_note + + _append_or_merge_user_message(limit_ctx.messages, presence_finish_not_accepted_note(ctx, completion), slot=ctx) return result @@ -351,6 +355,7 @@ def _record_transcript_prefix(ctx, messages, round_idx, accumulated_usage, def _reset_turn_state(ctx: Any) -> None: """Clear the per-turn state this turn owns; nothing durable is touched.""" ctx._presence_completion, ctx._presence_completion_accepted = None, False + ctx._presence_forced_declaration = ctx._presence_forced_pending = None ctx._delivery_candidate, ctx._delivery_candidate_revision, ctx._delivery_control_required = None, 0, False ctx._delivery_evidence_revision, ctx._delivery_evidence_fingerprint = 0, "" ctx.model_turn_state, ctx._authoring_handover, ctx._pending_model_wait_handover = ModelTurnState(), None, None diff --git a/ouroboros/loop_budget.py b/ouroboros/loop_budget.py index 405653aec..18f12f4c1 100644 --- a/ouroboros/loop_budget.py +++ b/ouroboros/loop_budget.py @@ -143,6 +143,9 @@ def _check_budget_limits( # The exact probe confirmed a stop: finalize services and prepare the # candidate that will be dispatched (forced augmentations included). trace = ctx.llm_trace if isinstance(ctx.llm_trace, dict) else {} + tools_ctx = getattr(getattr(ctx, "tools", None), "_ctx", None) + presence_arm = (getattr(tools_ctx, "_presence_forced_declaration", None), + getattr(tools_ctx, "_presence_forced_pending", None)) priced_prompt = _loop()._prepare_forced_prompt(ctx, forced_prompt, trace) prospective_messages = [dict(message) for message in ctx.messages] _loop()._append_or_merge_user_message(prospective_messages, priced_prompt) @@ -173,6 +176,10 @@ def _check_budget_limits( fallback_text=finish_reason, reason_code="budget_exhausted", _initial_messages=send_messages, _admitted_request=wrapup_request, ) + # Repricing admitted ordinary work after all. No forced call is committed, so the + # Presence arm the prepared prompt set is withdrawn: it would silence the ordinary reply. + if tools_ctx is not None: + tools_ctx._presence_forced_declaration, tools_ctx._presence_forced_pending = presence_arm if deciding is not None and ceiling_usd is not None and deciding > ceiling_usd: if spend_basis == task_pacing.SPEND_BASIS_TREE: spent_text = ( diff --git a/ouroboros/loop_delivery.py b/ouroboros/loop_delivery.py index ccd773d86..0e21dbd1a 100644 --- a/ouroboros/loop_delivery.py +++ b/ouroboros/loop_delivery.py @@ -867,9 +867,9 @@ def _parse_delivery_control_object( def _classify_parsed_delivery_control( parsed: Optional[Dict[str, Any]], duplicate_protocol_key: bool, - embedded: bool, + embedded: bool, *, envelope_keys: Tuple[str, ...] = (), ) -> Tuple[str, str, str]: - """Return ``(kind, replacement, error)`` for a parsed control body.""" + """Return ``(kind, replacement, error)``; ``envelope_keys`` are members an armed caller reads itself.""" exact_error = "control must be one exact JSON object" if embedded: @@ -890,7 +890,7 @@ def _classify_parsed_delivery_control( selected = str(parsed.get("delivery_control") or "") if "pending_review" in parsed and str(parsed.get("pending_review") or "").strip().lower() not in {"wait", "finish"}: return "invalid", "", 'pending_review must be "wait" or "finish"' - keys = set(parsed) - {"acceptance_subject", "pending_review"} + keys = set(parsed) - {"acceptance_subject", "pending_review", *envelope_keys} if selected == "keep" and keys == {"delivery_control"}: return "keep", "", "" if selected == "replace" and keys == {"delivery_control", "full_answer"}: @@ -905,7 +905,7 @@ def _resolve_forced_delivery_control_body( raw: str, candidate: Optional[DeliveryCandidate], *, - armed: bool, + armed: bool, envelope_keys: Tuple[str, ...] = (), # members the armed caller reads itself ) -> Tuple[str, bool, bool, bool, bool]: """Return text plus retained/degraded/consumed/replaced facts.""" @@ -913,7 +913,7 @@ def _resolve_forced_delivery_control_body( candidate = None parsed, duplicate_protocol_key, embedded_protocol = _parse_delivery_control_body(raw) control_kind, replacement, _error = _classify_parsed_delivery_control( - parsed, duplicate_protocol_key, embedded_protocol, + parsed, duplicate_protocol_key, embedded_protocol, envelope_keys=envelope_keys, ) historical = bool( not armed @@ -935,6 +935,7 @@ def _resolve_forced_delivery_control_body( control_kind != "none" or (parsed is None and strip_protocol_fence(raw).startswith("{")) or bool(getattr(parsed, "has_duplicate_keys", False)) + or bool(envelope_keys and isinstance(parsed, dict) and set(parsed).intersection(envelope_keys)) ) if not protocol_intent: # Ordinary prose under an armed latch stands (a control object quoted diff --git a/ouroboros/loop_forced_finalization.py b/ouroboros/loop_forced_finalization.py index 5d7582e24..edf678c9c 100644 --- a/ouroboros/loop_forced_finalization.py +++ b/ouroboros/loop_forced_finalization.py @@ -388,8 +388,8 @@ def _maybe_enforce_child_absorption_gate( prompt=( "[FINALIZE_WITH_UNABSORBED_CHILDREN]\n" "You still have child results without exact dispositions and already received one " - "child-absorption reminder. Produce an honest best-effort final answer now; name the " - "unabsorbed or unfinished children explicitly. Current child state: " + "child-absorption reminder. Produce an honest best-effort final answer now that says " + "what remains unabsorbed or unfinished; the exact child state is: " f"{_undecided_children_listing(undecided)}." ), fallback_text="⚠️ Finalized best-effort with undispositioned child results.", @@ -637,10 +637,102 @@ def _prepare_forced_prompt( prompt + _loop()._forced_delegation_note(tools_ctx, llm_trace) + _forced_state_facts(ctx, llm_trace) + + _presence_forced_contract(ctx, tools_ctx) + _forced_subject_prompt(ctx, llm_trace) ) +def _presence_forced_contract(ctx: _RoundLimitContext, tools_ctx: Any) -> str: + """Arm a Presence task's ONE forced call to declare its outward delivery apart from its record. + + The forced answer is the internal record (owner, review, task result); what the + conversation receives is only the nested ``presence_finish`` declaration. Until a + valid declaration arrives with a model-final answer the arm stays ``missing``, so + no untyped internal prose becomes Presence speech (owner Q4). Not a delivery receipt. + Only a context whose final becomes a Presence result is armed: the host ceiling AND + the Presence metadata the pipeline keys that result on. A delegated child inherits + the ceiling with its contract, but it answers its parent, not a conversation. + """ + contract = getattr(tools_ctx, "task_contract", None) + metadata = getattr(tools_ctx, "task_metadata", None) + presence = metadata.get("presence") if isinstance(metadata, dict) else None + if not (isinstance(contract, dict) and isinstance(contract.get("capability_ceiling"), dict) + and isinstance(presence, dict)): + return "" + tools_ctx._presence_forced_declaration = {"status": "missing", "reason": "no presence_finish declaration"} + tools_ctx._presence_forced_pending = None + try: + from ouroboros.presence_context import presence_send_facts + from ouroboros.tool_access import canonical_data_root + + # Receipts live on the canonical root; a forked execution drive holds none. + sent = presence_send_facts(canonical_data_root(tools_ctx), ctx.task_id, presence) + except Exception: + sent = "unknown (receipts unreadable)" + handoff = getattr(tools_ctx, "_swarm_handoff_attempt", None) + scheduled = isinstance(handoff, dict) and str(handoff.get("status") or "") == "scheduled" + return ( + "\n\n[PRESENCE_DELIVERY]\n" + "This task answers a Presence conversation. Your answer is its internal record for the " + "owner and review; the people in the conversation receive only what you declare. Return " + "exactly one JSON object and no other text: " + '{"delivery_control":"replace","full_answer":"",' + '"presence_finish":{"outcome":"message","message":""}}' + + (' ("delivery_control":"keep" without full_answer keeps the current answer as the record)' + if _loop()._live_delivery_candidate(ctx) is not None else "") + + ". Outcomes: message = new useful speech on their subject, an honest partial included; " + "silent = nothing new needs saying; tool_delivered = the substantive result already reached " + "them through a transport tool; deferred = acknowledge work that was actually scheduled" + + (" (it was)" if scheduled else " (none was)") + ". Keep internal facts in full_answer; " + "the host never forwards that record automatically. You decide what, if anything, to say " + "in presence_finish.message, including relevant limitations. " + "An early acknowledgement is not the promised result and an uncertain send may not have " + "landed. Without a valid presence_finish nothing new is sent. Sends confirmed for this task " + f"so far: {sent}." + ) + + +def _read_presence_declaration(tools_ctx: Any, extracted: str) -> None: + """Record the nested ``presence_finish`` of an armed forced body without rewriting that body. + + The resolver keeps reading the original bytes (``envelope_keys`` admits this one + key), so the parser's duplicate-key evidence still reaches every rail, the + acceptance subject included. A declaration is valid only in an envelope that + repeats no key anywhere. Each read records its non-speaking verdict at once; a + valid declaration speaks only once its answer becomes the model final. + """ + from ouroboros.loop_delivery import _parse_delivery_control_object + from ouroboros.observability import strip_protocol_fence + from ouroboros.tools.presence import PRESENCE_OUTCOMES + + tools_ctx._presence_forced_pending = None + tools_ctx._presence_forced_declaration = {"status": "missing", "reason": "no presence_finish declaration"} + parsed, duplicate = _parse_delivery_control_object(strip_protocol_fence(extracted)) + if not duplicate and (not isinstance(parsed, dict) or "presence_finish" not in parsed): + return + value = parsed.get("presence_finish") if isinstance(parsed, dict) else None + reason = "" + if duplicate or getattr(parsed, "has_duplicate_keys", False): + reason = "the forced envelope repeats a key" + elif not isinstance(value, dict) or not set(value) <= {"outcome", "message"} \ + or value.get("outcome") not in PRESENCE_OUTCOMES or not isinstance(value.get("message", ""), str): + reason = "presence_finish must be one {outcome, message} object with a known outcome" + else: + outcome, message = value["outcome"], value.get("message", "").strip() + handoff = getattr(tools_ctx, "_swarm_handoff_attempt", None) + if outcome == "message" and not message: + reason = "message needs nonblank conversational text" + elif outcome == "silent" and message: + reason = "silent carries no text" + elif outcome == "deferred" and not (isinstance(handoff, dict) and handoff.get("status") == "scheduled"): + reason = "deferred needs work that was actually scheduled" + if reason: + tools_ctx._presence_forced_declaration = {"status": "invalid", "reason": reason} + else: + tools_ctx._presence_forced_pending = { + "status": "declared", "outcome": value["outcome"], "message": value.get("message", "").strip()} + + def _forced_subject_prompt(ctx: _RoundLimitContext, llm_trace: Dict[str, Any]) -> str: """Capture the source before pricing/sending, never after a reply arrives.""" from ouroboros.loop_acceptance import capture_acceptance_observation, acceptance_observation_prompt @@ -1012,16 +1104,29 @@ def _resolve_forced_delivery_control( """Resolve forced control; returns text, degradation, retained, replaced.""" if tools_ctx is None or not extracted: return extracted, "", False, False + presence_armed = isinstance(getattr(tools_ctx, "_presence_forced_declaration", None), dict) + if presence_armed: + _read_presence_declaration(tools_ctx, extracted) candidate = getattr(tools_ctx, "_delivery_candidate", None) - armed = bool(getattr(tools_ctx, "_delivery_control_required", False)) or ( + armed = presence_armed or bool(getattr(tools_ctx, "_delivery_control_required", False)) or ( isinstance(candidate, _loop().DeliveryCandidate) and _loop()._delivery_replace_required(candidate) ) resolved, retained, degraded, consumed, replaced = ( _loop()._resolve_forced_delivery_control_body( extracted, candidate, armed=armed, + envelope_keys=("presence_finish",) if presence_armed else (), ) ) + if presence_armed and isinstance(tools_ctx._presence_forced_pending, dict): + from ouroboros.loop_delivery import _parse_delivery_control_body + + parsed, _, _ = _parse_delivery_control_body(extracted) + if degraded or not (isinstance(parsed, dict) and parsed.get("delivery_control") in {"keep", "replace"}): + # A declaration cannot speak unless its outer control positively chose the final record. + tools_ctx._presence_forced_pending = None + tools_ctx._presence_forced_declaration = { + "status": "invalid", "reason": "the delivery-control envelope was rejected"} if consumed: tools_ctx._delivery_control_required = False from ouroboros.loop_delivery import _parse_delivery_control_body, apply_delivery_subject_decision @@ -1194,6 +1299,8 @@ def _forced_final_answer( set_terminal_host_notice(ctx.accumulated_usage, plan_suffix, _loop()._forced_orphan_note(ctx)) full_text = extracted ctx.accumulated_usage["terminal_origin"] = TERMINAL_ORIGIN_MODEL_FINAL + if isinstance(getattr(tools_ctx, "_presence_forced_pending", None), dict): + tools_ctx._presence_forced_declaration = tools_ctx._presence_forced_pending candidate = _publish_model_forced_candidate( ctx, llm_trace, full_text, reason_code, degraded_reason=control_degraded, diff --git a/ouroboros/memory_journal_compaction.py b/ouroboros/memory_journal_compaction.py index 097f0dd0f..57335f2a9 100644 --- a/ouroboros/memory_journal_compaction.py +++ b/ouroboros/memory_journal_compaction.py @@ -1,182 +1,20 @@ -"""Digest-only compaction of old memory-journal snapshots (CPL4-C16, owner 4A). +"""Compatibility entry point for the retired destructive memory-journal sweep. -``memory/identity_journal.jsonl``, ``memory/knowledge_history.jsonl`` and -``memory/knowledge/patterns_history.jsonl`` record the FULL old+new document -text on every write — O(doc×edits) growth, the worst byte offenders in the -memory plane. Owner decision 4A: entries younger than the unified GC -retention keep their full text; older entries become digest-only — the -content keys are replaced by their sha256 + length (existing hashes are -never overwritten) and the row is marked ``content_digested``. - -Strictly fail-closed per line: an unparseable line, a row without a -readable ``ts``, a row with nothing to digest, or a row whose STORED digest -disagrees with the text it claims to describe is carried through -BYTE-IDENTICAL. The scratchpad journal (typed rows, its own eviction -contract) is deliberately NOT in scope. - -This is the only sweep that DESTROYS content rather than whole dead files, -so its three guards are load-bearing (audit #15-11): - -* **Digest truth before deletion.** The digest becomes the only surviving - record of the text, so a stored ``*_sha256``/``*_len`` that does not match - the text is never published over it: the row keeps its full content and the - mismatch is reported as a typed fact (``digest_mismatch``, surfaced on the - ``memory_journal_compaction`` event). -* **A lock nobody can steal.** The append lock is taken ``owner_aware_stale`` - so elapsed time alone can never hand a second writer the same journal. -* **Publish only an unchanged source.** ``append_jsonl`` falls back to an - UNLOCKED append after its own lock timeout, so a concurrent row can still - land while this rewrite streams. The file is re-identified (size + inode) - against the bytes actually consumed immediately before ``os.replace``; any - delta aborts the publish and the journal stays as the appender left it. - -The rewrite streams line by line into the temp sibling — the journals are the -worst byte offenders in the memory plane and must never be loaded whole. +The startup/maintenance caller still invokes this function. Retaining that call +keeps old integrations working while new knowledge, identity and Pattern Register +history stays complete. A previously digested row cannot be reconstructed; +no new row loses its old/new text merely because of its age. """ from __future__ import annotations -import hashlib -import json -import logging -import os -import pathlib -from typing import Any, Dict, Optional, Tuple - -from ouroboros.deadline_utils import parse_deadline_ts -from ouroboros.platform_layer import acquire_exclusive_file_lock, release_exclusive_file_lock -from ouroboros.utils import jsonl_append_lock_path - -log = logging.getLogger(__name__) - -_JOURNAL_RELS = ( - pathlib.Path("memory") / "identity_journal.jsonl", - pathlib.Path("memory") / "knowledge_history.jsonl", - pathlib.Path("memory") / "knowledge" / "patterns_history.jsonl", -) -_CONTENT_KEYS = ("old_content", "new_content") +from pathlib import Path +from typing import Any, Dict, Optional +import stat -def _digest_row(row: Dict[str, Any]) -> str: - """Replace full-text keys with sha256+len. - - Returns ``"digested"`` when text was dropped, ``"mismatch"`` when a STORED - digest or length contradicts the text it describes, ``""`` when there was - nothing to digest. On a mismatch the row is left EXACTLY as found: the - digest is about to become the only surviving record of that content, and - publishing a digest already known to be false while deleting the last - correct copy is unrecoverable. - """ - dropped = [] - for key in _CONTENT_KEYS: - value = row.get(key) - if not isinstance(value, str): - continue - prefix = key[: -len("_content")] - digest = hashlib.sha256(value.encode("utf-8")).hexdigest() if value else "" - stored_digest = row.get(f"{prefix}_sha256") - stored_len = row.get(f"{prefix}_len") - if isinstance(stored_digest, str) and stored_digest != digest: - return "mismatch" - if isinstance(stored_len, int) and not isinstance(stored_len, bool) and stored_len != len(value): - return "mismatch" - dropped.append((key, prefix, digest, len(value))) - if not dropped: - return "" - for key, prefix, digest, length in dropped: - row[f"{prefix}_sha256"] = digest - row[f"{prefix}_len"] = length - del row[key] - row["content_digested"] = True - return "digested" - - -def _digest_line(raw: bytes, cutoff: float) -> Tuple[bytes, str]: - """One journal line, transformed or carried through byte-identical.""" - stripped = raw.strip() - if not stripped: - return raw, "" - try: - row = json.loads(stripped.decode("utf-8")) - except (UnicodeDecodeError, ValueError): - return raw, "" # fail-closed: never rewrite what cannot be read - if not isinstance(row, dict): - return raw, "" - parsed_ts = parse_deadline_ts(str(row.get("ts") or "")) - if parsed_ts is None or parsed_ts.timestamp() >= cutoff: - return raw, "" # fresh, or age unknowable: keep full text - outcome = _digest_row(row) - if outcome != "digested": - return raw, outcome - return json.dumps(row, ensure_ascii=False).encode("utf-8") + b"\n", "digested" - - -def _publish_if_unchanged( - path: pathlib.Path, tmp: pathlib.Path, expected: Tuple[int, int, int], -) -> bool: - """Swap the rewritten journal in ONLY if the source is still what we read. - - ``append_jsonl`` appends WITHOUT the sidecar lock once its own acquisition - times out, so a concurrent row can land while this rewrite streams. The - identity is (bytes consumed, device, inode): a grown file means an append - we did not carry over, a different inode means the journal was replaced - outright. Either way the rewrite is dropped and the appender's file stands. - """ - try: - stat = path.stat() - except OSError: - return False - if (int(stat.st_size), int(stat.st_dev), int(stat.st_ino)) != expected: - return False - os.replace(tmp, path) - return True - - -def _compact_one(path: pathlib.Path, cutoff: float) -> Tuple[int, int, str]: - """Digest one journal in place. - - Returns ``(digested, mismatched, error)``; a nonempty ``error`` - (``lock_unavailable`` / ``source_changed``) means nothing was published and - the journal is byte-identical to what the appenders left. - """ - lock_path = jsonl_append_lock_path(path) - lock_fd = acquire_exclusive_file_lock( - lock_path, timeout_sec=2.0, stale_sec=10.0, owner_aware_stale=True, - ) - if lock_fd is None: - return 0, 0, "lock_unavailable" - tmp = path.with_name(path.name + ".compact.tmp") - published = False - try: - digested = 0 - mismatched = 0 - consumed = 0 - with path.open("rb") as source: - start = os.fstat(source.fileno()) - with tmp.open("wb") as sink: - for raw in source: # streaming: one line in flight, never the file - consumed += len(raw) - out, outcome = _digest_line(raw, cutoff) - sink.write(out) - if outcome == "digested": - digested += 1 - elif outcome == "mismatch": - mismatched += 1 - if not digested: - return 0, mismatched, "" - published = _publish_if_unchanged( - path, tmp, (consumed, int(start.st_dev), int(start.st_ino)), - ) - if not published: - return 0, 0, "source_changed" - return digested, mismatched, "" - finally: - if not published: - try: - tmp.unlink() - except OSError: - log.debug("Failed to drop the journal compaction temp file", exc_info=True) - release_exclusive_file_lock(lock_path, lock_fd) +_JOURNALS = ("memory/identity_journal.jsonl", "memory/knowledge_history.jsonl", + "memory/knowledge/patterns_history.jsonl") def compact_memory_journal_snapshots( @@ -185,33 +23,26 @@ def compact_memory_journal_snapshots( *, now: Optional[float] = None, ) -> Dict[str, Any]: - """Digest old full-text snapshots in the three memory journals.""" - from ouroboros.retention import age_cutoff, get_gc_retention_days + """Preserve every journal byte, including malformed and historical rows. - if retention_days is None: - retention_days = get_gc_retention_days() - cutoff = age_cutoff(retention_days, now) - report: Dict[str, Any] = {"digested": {}, "digest_mismatch": {}, "errors": []} - root = pathlib.Path(drive_root) - for rel in _JOURNAL_RELS: - path = root / rel - if not path.exists(): - continue + The arguments keep the previous call contract. Size facts in the existing + startup report measure growth; a missing journal is not a measured zero. + """ + sizes: Dict[str, Optional[int]] = {} + errors: list[str] = [] + for relative in _JOURNALS: try: - digested, mismatched, error = _compact_one(path, cutoff) - except OSError: - report["errors"].append({"journal": rel.as_posix(), "error": "io_error"}) - continue - if error: - report["errors"].append({"journal": rel.as_posix(), "error": error}) - if digested: - report["digested"][rel.as_posix()] = digested - if mismatched: - # Typed fact, not a silent skip: a stored digest that contradicts - # its own text means one of the two is already corrupt, and the - # content stays in full until a human looks. - report["digest_mismatch"][rel.as_posix()] = mismatched - return report + info = (Path(drive_root) / relative).lstat() + sizes[relative] = info.st_size if stat.S_ISREG(info.st_mode) else None + if sizes[relative] is None: + errors.append(f"{relative}: not_regular") + except FileNotFoundError: + sizes[relative] = None + except OSError as exc: + sizes[relative] = None + errors.append(f"{relative}: {type(exc).__name__}") + return {"digested": {}, "digest_mismatch": {}, "errors": errors, + "journal_bytes": sizes} __all__ = ["compact_memory_journal_snapshots"] diff --git a/ouroboros/memory_nomination_receipts.py b/ouroboros/memory_nomination_receipts.py new file mode 100644 index 000000000..4bcc98f0e --- /dev/null +++ b/ouroboros/memory_nomination_receipts.py @@ -0,0 +1,104 @@ +"""Source-addressed pending knowledge nominations in dialogue_meta.json. + +The history log carries full nomination bytes. This compact index makes an +unpublished nomination visible even when its summary block becomes an era. +A later unrelated success cannot discharge an older source identity. +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +KEY = "pending_knowledge_nominations" + + +class DialogueMetaUnreadable(ValueError): + """Existing cursor or nomination obligations cannot be safely interpreted.""" + + +def load_meta(path: Path) -> dict[str, Any]: + """An absent cursor is new; an unreadable existing cursor is not empty. + + This meta file now owns durable pending obligations. A permissive JSON read + would erase them on the next consolidation. Reject duplicate keys as well: + the second copy of an obligation field cannot silently replace the first. + """ + def unique_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"Duplicate dialogue meta key: {key}") + result[key] = value + return result + + try: + with path.open("r", encoding="utf-8") as source: + value = json.load(source, object_pairs_hook=unique_pairs) + except FileNotFoundError: + # A dangling link is an existing, unreadable source, not a new cursor. + try: + path.lstat() + except FileNotFoundError: + return {} + raise DialogueMetaUnreadable("Dialogue meta exists but cannot be read") from None + except (OSError, UnicodeError, ValueError) as exc: + raise DialogueMetaUnreadable(f"Dialogue meta unreadable: {type(exc).__name__}") from exc + if not isinstance(value, dict): + raise DialogueMetaUnreadable("Dialogue meta must be a JSON object") + _pending(value) # Refuse corrupt obligations before the first paid correction call. + return value + + +def _pending(meta: dict[str, Any]) -> dict[str, dict[str, Any]]: + rows = meta.get(KEY, []) + if not isinstance(rows, list) or any(not isinstance(row, dict) or not isinstance(row.get("id"), str) + for row in rows): + raise DialogueMetaUnreadable("Unreadable nomination obligations; refusing to replace their bytes") + if len({row["id"] for row in rows}) != len(rows): + raise DialogueMetaUnreadable("Duplicate nomination obligation IDs") + return {row["id"]: row for row in rows} + + +def prepare(meta: dict[str, Any], source_id: str, batches: list[tuple[Any, list[Any]]]) -> list[str]: + """Record every proposed entry before publication; return positional IDs.""" + pending = _pending(meta) + ids: list[str] = [] + for block_index, (_block, entries) in enumerate(batches): + for entry_index, entry in enumerate(entries): + identifier = f"{source_id}:{block_index}:{entry_index}" + ids.append(identifier) + if identifier not in pending: + pending[identifier] = { + "id": identifier, + "scope": str(entry.get("scope") or "default") if isinstance(entry, dict) else "invalid", + "topic": str(entry.get("topic") or "") if isinstance(entry, dict) else "", + "reason": "publication_pending", + } + meta[KEY] = list(pending.values()) + return ids + + +def settle(meta: dict[str, Any], ids: list[str], outcomes: list[dict[str, Any]]) -> None: + """Only this exact source's successful entries retire; missing outcomes stay owed. + + A failed or unobserved entry never expires. A future source-grounded + resolution must address its ID explicitly; same-topic later writes cannot. + """ + pending = _pending(meta) + for index, identifier in enumerate(ids): + outcome = outcomes[index] if index < len(outcomes) else {} + if outcome.get("ok") is True: + pending.pop(identifier, None) + elif identifier in pending: + row = pending[identifier] + row["reason"] = str(outcome.get("reason") or "outcome_missing") + if isinstance(outcome.get("scope"), str): + row["scope"] = outcome["scope"] + if isinstance(outcome.get("topic"), str): + row["topic"] = outcome["topic"] + if pending: + meta[KEY] = list(pending.values()) + else: + meta.pop(KEY, None) diff --git a/ouroboros/owner_mailbox.py b/ouroboros/owner_mailbox.py index b66787818..1ba08493c 100644 --- a/ouroboros/owner_mailbox.py +++ b/ouroboros/owner_mailbox.py @@ -252,11 +252,14 @@ def write_task_message( msg_id: Optional[str] = None, review_feedback: Optional[Dict[str, Any]] = None, relation: str = "", + sender_origin: Optional[Dict[str, Any]] = None, ) -> bool: """Write an addressed task-tree message without forging owner provenance. ``relation`` is the peer_task sender's typed place relative to the recipient (``sibling`` / ``parent``); stored only when non-empty. + ``sender_origin`` is the sending run's host-recorded origin (a Presence + room/event), never the author of the words it quotes. """ if provenance not in TASK_MESSAGE_PROVENANCES: @@ -275,6 +278,8 @@ def write_task_message( entry["relayed_from_task_id"] = str(relayed_from_task_id) if str(relation or ""): entry["relation"] = str(relation) + if sender_origin: + entry["sender_origin"] = {str(key): str(value) for key, value in dict(sender_origin).items()} if provenance == "system" and isinstance(review_feedback, dict): entry["review_feedback"] = dict(review_feedback) try: @@ -376,7 +381,10 @@ def deliver_task_message( elif provenance == PROVENANCE_INDEPENDENT_TASK: # A peer root's own words: never the ancestor fallback, which would # place a stranger above the recipient in its tree. - prefix = f"[Message from independent task {source}]" + origin = entry.get("sender_origin") if isinstance(entry.get("sender_origin"), dict) else {} + prefix = f"[Message from independent task {source}" + ( + "; that task's run started from " + json.dumps(origin, ensure_ascii=False, sort_keys=True) + + ", which does not make it the author of any words it quotes]" if origin else "]") elif provenance == PROVENANCE_PEER_TASK: # A contribution from inside the tree without authority over the # recipient: the stamped relation names the sender's place, so a @@ -691,6 +699,8 @@ def drain_owner_entries( # left out of the projection it would never be delivered. if str(entry.get("relation") or ""): drained["relation"] = str(entry["relation"]) + if isinstance(entry.get("sender_origin"), dict) and entry.get("sender_origin"): + drained["sender_origin"] = dict(entry["sender_origin"]) # rendered beside the words entries.append(drained) if _read_status is not None: _read_status["complete"] = complete diff --git a/ouroboros/peer_roster.py b/ouroboros/peer_roster.py index 84a4e0b35..62f35223c 100644 --- a/ouroboros/peer_roster.py +++ b/ouroboros/peer_roster.py @@ -24,7 +24,7 @@ import pathlib import time from typing import Any, Dict, List, Optional -from ouroboros.dialogue_provenance import is_presence_task +from ouroboros.dialogue_provenance import is_presence_task, presence_caller_binding from ouroboros.focus import compact_focus, focus_fingerprint from ouroboros.task_status import _load_queue_snapshot, queue_snapshot_observation from ouroboros.utils import read_json_dict @@ -269,7 +269,7 @@ def maybe_append_roster_note(ctx: Any, messages: List[Dict[str, Any]], drive_roo "_presence_origin": getattr(ctx, "_presence_origin", None)} if (str(metadata.get("parent_task_id") or "").strip() or str(metadata.get("delegation_role") or "") == "subagent" - or is_presence_task(actor_task)): + or is_presence_task(actor_task) or presence_caller_binding(ctx) is not None): return False task_id = str(getattr(ctx, "task_id", "") or "") canonical = pathlib.Path(str( diff --git a/ouroboros/presence_authority.py b/ouroboros/presence_authority.py index a557158a9..80d027b23 100644 --- a/ouroboros/presence_authority.py +++ b/ouroboros/presence_authority.py @@ -8,6 +8,17 @@ from dataclasses import dataclass from pathlib import Path, PurePosixPath from typing import Any, Mapping +from ouroboros.dialogue_provenance import ( # the provenance predicate; authority re-exports it + PRESENCE_OWN_WORK_SCOPE, + presence_caller_binding, + presence_metadata_binding as presence_metadata_binding, + presence_effective_hops, + presence_effective_related, + presence_queue_task, + presence_record_binding, + presence_related_work, + presence_target_record, +) from ouroboros.presence_capabilities import ( PresenceArgumentBinding, PresenceProfileResolution, @@ -19,6 +30,12 @@ from ouroboros.tool_capabilities import COGNITIVE_MEMORY_TOOL_NAMES PRESENCE_CEILING_SCHEMA_VERSION = 1 _SHA256_LEN = 64 +# The own-work baseline (owner Q1/Q2): a Presence mind may find, read and message +# the independent work it started from ANY conversation of its own binding. The two +# readers arrive bound to that scope (the host overwrites the argument); steer_task +# is narrowed by its handler for every Presence caller. A profile that selected one +# of these names keeps that grant, so a selected global reader stays global. +_OWN_WORK_BASELINE = (("get_task_result", True), ("recent_tasks", True), ("steer_task", False)) class PresenceAuthorityError(ValueError): @@ -331,6 +348,12 @@ def build_presence_capability_ceiling( for name in sorted(COGNITIVE_MEMORY_TOOL_NAMES) if name not in selected ) + scope = (PresenceArgumentBinding(("presence_scope",), "static", static_value=PRESENCE_OWN_WORK_SCOPE),) + tools.extend( + PresenceToolGrant(name, scope if scoped else ()) + for name, scoped in _OWN_WORK_BASELINE + if name not in selected + ) provisional = PresenceCapabilityCeiling( skill_name=_text(skill_name, "skill_name"), skill_content_hash=_sha(skill_content_hash, "skill_content_hash"), @@ -518,8 +541,69 @@ def presence_ceiling_allows_binding(ceiling: PresenceCapabilityCeiling, binding: return False +def presence_effective_refusal(ctx: Any, task_id: str, effective: Any, *, drive_root: Any = None, + same_tree: bool = False) -> str: + """Refusal when an admitted ``task_id``'s effective projection reaches work the caller may not address. + + A retry successor replaces the requested record's content, so each hop is judged + by the same rule before anything is projected; the foreign id is not named. + """ + + for hop in presence_effective_hops(task_id, effective): + if presence_work_refusal(ctx, hop, drive_root=drive_root, same_tree=same_tree): + return ( + f"⚠️ PRESENCE_CAPABILITY_BLOCKED: task {task_id}'s effective result continues in work " + "that was not started from this Presence binding; a Presence turn reads only that work." + ) + return "" + + +def presence_work_refusal(ctx: Any, task_id: str, *, drive_root: Any = None, same_tree: bool = False) -> str: + """Refusal text when a Presence caller may not address ``task_id``, else ``""``. + + A non-Presence caller is never narrowed here; a delegated descendant of a + Presence-bound task is one through its inherited binding authority. + ``presence_target_record`` decides, and a record naming another binding refuses. + ``same_tree`` also admits the caller's own task tree, its root included (its + lineage reads). ``drive_root`` defaults to the caller's task-status root. + """ + + binding = presence_caller_binding(ctx) + if binding is None: + return "" + if drive_root is None: + metadata = getattr(ctx, "task_metadata", None) + drive_root = ((metadata.get("budget_drive_root") if isinstance(metadata, Mapping) else "") + or getattr(ctx, "budget_drive_root", "") or ctx.drive_root) + target = str(task_id or "").strip() + record = presence_target_record(drive_root, target) + if isinstance(record, Mapping) and presence_related_work(binding, record): + return "" + if same_tree and isinstance(record, Mapping): + metadata = getattr(ctx, "task_metadata", None) + own_id = str(getattr(ctx, "task_id", "") or "") + own_root = str((metadata or {}).get("root_task_id") or own_id) if isinstance(metadata, Mapping) else own_id + if own_id and (target in {own_id, own_root} or str(record.get("parent_task_id") or "") == own_id + or str(record.get("root_task_id") or "") == own_root): + return "" + return ( + f"⚠️ PRESENCE_CAPABILITY_BLOCKED: task {target or '?'} is not independent work started from " + "this Presence binding (same binding_id, any of its conversations); a Presence turn " + "addresses only that work." + ) + + __all__ = [ "PRESENCE_CEILING_SCHEMA_VERSION", + "PRESENCE_OWN_WORK_SCOPE", + "presence_caller_binding", + "presence_effective_refusal", + "presence_effective_related", + "presence_queue_task", + "presence_record_binding", + "presence_related_work", + "presence_target_record", + "presence_work_refusal", "PresenceAuthorityError", "PresenceCapabilityCeiling", "PresenceResourceGrant", diff --git a/ouroboros/presence_context.py b/ouroboros/presence_context.py index 793181f12..0faed27ce 100644 --- a/ouroboros/presence_context.py +++ b/ouroboros/presence_context.py @@ -58,11 +58,16 @@ def _previous_turn_line(previous: Mapping[str, Any]) -> str: sends = [str(text) for text in sends if str(text or "").strip()] message = str(previous.get("message") or "").strip() said = [json.dumps(text, ensure_ascii=False) for text in sends] - if previous.get("outcome") == "tool_delivered": # its message is the model's note, never speech + note = str(previous.get("finish_note") or "").strip() + if previous.get("outcome") == "tool_delivered": # legacy pointers kept the note in message said = said or ["delivered via transport tool (content unrecorded)"] - said += [f"finish note {json.dumps(message, ensure_ascii=False)}"] if message else [] + note = note or message elif message and message not in sends: said.append(json.dumps(message, ensure_ascii=False)) + if note: + said.append(f"finish note {json.dumps(note, ensure_ascii=False)}") + if previous.get("previous_text_unverified"): + said.append("legacy previous text unverified as speech (source unavailable)") body = " / ".join(said) or "nothing sent" work = "" if previous.get("work_ref"): @@ -83,8 +88,110 @@ def _previous_turn_line(previous: Mapping[str, Any]) -> str: f"outcome {previous.get('outcome')}, delivery {previous.get('delivery') or 'unknown'}): {body}.{work}") -def build_presence_context_section(drive_root: Path, value: Any) -> str: - """Render host-authored presence context, including declared full KB topics.""" +_OWN_WORK_PAGE = 5 + + +def _own_work_section(drive_root: Path, value: Mapping[str, Any], task_id: str) -> str: + """The first page of independent work this binding started, from the scoped reader.""" + from ouroboros.tools.recent_tasks import recent_tasks_page + + binding = str(value.get("binding_id") or "").strip() + if not binding: + return "" + page = recent_tasks_page(Path(drive_root), limit=_OWN_WORK_PAGE, binding=binding, + exclude=str(task_id or ""), restricted=True) + here = str((value.get("event") or {}).get("conversation_key") or "") + lines = [] + for row in page.get("tasks") or []: + origin = row.get("presence_origin") if isinstance(row.get("presence_origin"), Mapping) else {} + key = str(origin.get("conversation_key") or "") + where = ("this conversation" if key and key == here + else f"conversation {key}" if key else "a conversation this row does not name") + preview = " ".join(str(row.get("result_preview") or "").split())[:200] + lines.append( + f"- {row.get('task_id')} [{row.get('status') or 'unknown'}" + + (f", cancel {row['cancel_state']}" if row.get("cancel_state") else "") + + (", its result row is unreadable" if row.get("result_row") == "unreadable" else "") + f"] from {where}: " + + json.dumps(" ".join(str(row.get("description") or "").split())[:200], ensure_ascii=False) + + (f"; result preview {json.dumps(preview, ensure_ascii=False)}" + if preview and row.get("status") in {"completed", "failed", "cancelled"} else "") + + (f"; {row['effective_result']}" if row.get("effective_result") else "") + ) + gap = page.get("read_gap") if isinstance(page.get("read_gap"), Mapping) else {} + unread = [text for key, text in ( + ("result_root", "the result root"), ("queue_snapshot", "the queue snapshot (queued work)"), + ("unattributed_unreadable_rows", f"{gap.get('unattributed_unreadable_rows')} row(s) no record attributes"), + ) if gap.get(key)] + if unread: + lines.append("(some results are unreadable now: " + "; ".join(unread) + + "; this binding's work may be among them)") + if page.get("error"): + lines.append(f"(listing unavailable now: {page['error'].get('code')}; page again with recent_tasks)") + elif page.get("remaining"): + lines.append(f"({page['remaining']} more: recent_tasks(presence_scope=\"own_binding\", " + f"offset={page['offset'] + page['returned']}, snapshot=\"{page['snapshot']}\"))") + if not lines: + return "" + return ( + "## Work started from this binding (host-authored facts)\n\n" + "Independent work this Presence binding started, from this or another of its conversations, " + "newest first (queued work without a result row leads). Being listed says nothing about whether " + "its result reached anyone. Where your tools include them, get_task_result(task_id, " + "presence_scope=\"own_binding\") reads one exactly, steer_task gives it new facts as this " + "task's own message, and presence_cancel_work requests its cancellation. Work of other " + "bindings and the owner's own tasks are not addressable from here.\n\n" + + "\n".join(lines) + ) + + +def presence_send_facts(drive_root: Path, task_id: str, value: Any) -> str: + """Transport sends of this task confirmed so far in its own conversation, or honest unknown.""" + from ouroboros.presence_runner import _live_task_rows, _turn_sends + + value = value if isinstance(value, Mapping) else {} + key = str((value.get("event") or {}).get("conversation_key") or "") + if not key or value.get("delivery_reporting_version") != 1: + return "unknown (this transport reports no delivery receipts)" + rows = _live_task_rows(Path(drive_root), str(task_id or ""), key) + sent, uncertain = _turn_sends(rows) + partial = sent is None + if partial: + # No inbound row of this task in the live log (a promoted root never logs one): its receipts + # there are still observed, but earlier ones may sit in rotated history this read does not cover. + sent, uncertain = _turn_sends(rows, same_generation=True) + said = " / ".join(json.dumps(text, ensure_ascii=False) for text in sent if text) or "none" + if partial: + said += " (live chat log only; receipts rotated into archived history are not covered, so there may be more)" + return said + (f"; {uncertain} more part(s) have an uncertain outcome and may have landed" if uncertain else "") + + +def presence_finish_not_accepted_note(ctx: Any, completion: Mapping[str, Any]) -> str: + """Tell the round that follows an invalidated finish what is void and what is already sent.""" + from ouroboros.tool_access import canonical_data_root + + metadata = getattr(ctx, "task_metadata", None) + try: # the host records receipts on the canonical root, never on a forked execution drive + sent = presence_send_facts(canonical_data_root(ctx), str(getattr(ctx, "task_id", "") or ""), + metadata.get("presence") if isinstance(metadata, Mapping) else None) + except Exception: + sent = "unknown (receipts unreadable)" + return ( + f"[PRESENCE_FINISH_NOT_ACCEPTED]\npresence_finish({completion.get('outcome')}) was not accepted: " + "finalization asked for the work above first, so it no longer decides what the conversation " + f"receives. Sends confirmed for this task so far: {sent}. When the work is done, finish again: " + "tool_delivered or silent when the substantive result already reached the conversation and " + "nothing new needs saying, message only for speech you choose. Internal host notes are " + "not sent automatically; you decide whether any of their facts matter to this conversation." + ) + + +def build_presence_context_section(drive_root: Path, value: Any, task_id: str = "", *, + status_root: Path | None = None) -> str: + """Render host-authored presence context, including declared full KB topics. + + ``status_root`` is the canonical task root the own-work catalogue reads (a forked + execution drive holds only its own worker rows); it defaults to ``drive_root``. + """ if not isinstance(value, Mapping): return "" @@ -170,6 +277,12 @@ def build_presence_context_section(drive_root: Path, value: Any) -> str: f"The host lost an earlier attempt of this event before it finished; {detail}. " "Do not resend what was already delivered." ) + try: + own_work = _own_work_section(Path(status_root or drive_root), value, task_id) + except Exception: + own_work = "## Work started from this binding (host-authored facts)\n\nUnavailable now; page it with recent_tasks." + if own_work: + parts.append(own_work) parts += [ "## Current presence event (host-authored facts)\n\n" + json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True, default=str), @@ -178,4 +291,7 @@ def build_presence_context_section(drive_root: Path, value: Any) -> str: return "\n\n".join(parts) -__all__ = ["build_presence_context_section", "frame_presence_user_content"] +__all__ = [ + "build_presence_context_section", "frame_presence_user_content", + "presence_finish_not_accepted_note", "presence_send_facts", +] diff --git a/ouroboros/presence_runner.py b/ouroboros/presence_runner.py index 79dc8856d..db8c116d2 100644 --- a/ouroboros/presence_runner.py +++ b/ouroboros/presence_runner.py @@ -121,6 +121,15 @@ def build_presence_result_event(task: dict[str, Any], text: str, ctx: Any, *, te completion = completion if ( isinstance(completion, dict) and getattr(ctx, "_presence_completion_accepted", False) ) else {} + # A forced final declares its outward delivery beside the internal record; without + # a valid declaration the record never becomes conversation speech (owner Q4). + declared = getattr(ctx, "_presence_forced_declaration", None) + if not completion and isinstance(declared, dict): + completion = declared if declared.get("status") == "declared" else {"outcome": "silent"} + # Only a message/deferred body is speech; a tool_delivered note stays context even + # when owed work below turns the outcome into deferred. + text = str(completion.get("message") or "") if completion.get("outcome") in {"message", "deferred"} else "" + note = str(completion.get("message") or "") if completion.get("outcome") == "tool_delivered" else "" outcome = str(completion.get("outcome") or "message").strip() handoff = getattr(ctx, "_swarm_handoff_attempt", None) handoff = handoff if isinstance(handoff, dict) else {} @@ -141,14 +150,18 @@ def build_presence_result_event(task: dict[str, Any], text: str, ctx: Any, *, te metadata["presence_result_text"] = result_text if work_ref: metadata["presence_work_ref"] = work_ref + if not getattr(ctx, "_presence_completion_accepted", False) and isinstance(declared, dict): + metadata["presence_declaration"] = {key: declared[key] for key in ("status", "reason") if declared.get(key)} task["metadata"] = metadata return { "type": "presence_result", "task_id": str(task.get("id") or ""), "outcome": outcome, "text": result_text, - # The accepted presence_finish message; a tool_delivered note is context, never speech. - "message": result_text or (str(completion.get("message") or "") if outcome == "tool_delivered" else ""), + # Speech and the internal finish note stay separate even when owed work + # changes tool_delivered into deferred for the polling contract. + "message": result_text, + **({"finish_note": note} if note else {}), "work_ref": work_ref, "ts": utc_now_iso(), } @@ -333,12 +346,35 @@ def _read_previous_turn(drive_root: Path, conversation_key: str) -> dict[str, An return row if row.get("conversation_key") == conversation_key else None +def _previous_turn_source_view(drive_root: Path, pointer: dict[str, Any]) -> dict[str, Any]: + """Read a legacy deferred pointer's speech from its canonical result, without rewriting history. + + Old tool-delivered notes used the same `message` slot as replies. Owed work + changed their outcome to deferred, hiding that provenance. A missing source + proves neither speech nor a note; the context must say it is unverified. + """ + if (pointer.get("outcome") != "deferred" or not pointer.get("message") + or "finish_note" in pointer): + return pointer + row = load_task_result(drive_root, str(pointer.get("task_id") or "")) or {} + metadata = row.get("metadata") if isinstance(row.get("metadata"), dict) else {} + observed = metadata.get("presence_result_text") + if metadata.get("presence_outcome") == "deferred" and isinstance(observed, str): + if observed == pointer["message"]: + return pointer # source proves an authored partial reply, not a note + if not observed: + return {**pointer, "message": "", "finish_note": pointer["message"]} + return {**pointer, "message": "", "previous_text_unverified": True} + + def _write_previous_turn(drive_root: Path, conversation_key: str, task_id: str, *, outcome: str, message: str, - sends: Sequence[str], work_ref: str, finished_at: str, delivery: str) -> None: + sends: Sequence[str], work_ref: str, finished_at: str, delivery: str, + finish_note: str = "") -> None: """Best effort: the pointer is a projection, and a turn that already answered is not failed over it.""" try: atomic_write_json(_previous_turn_path(drive_root, conversation_key), { "conversation_key": conversation_key, "task_id": task_id, "outcome": outcome, "message": message, + **({"finish_note": finish_note} if finish_note else {}), "transport_sends": [text for text in sends if text], "work_ref": work_ref, "finished_at": finished_at, "delivery": delivery, }) @@ -470,7 +506,8 @@ def _build_task( } previous_turn = _read_previous_turn(drive_root, event.conversation_key) if previous_turn: - if previous_turn.get("work_ref"): # the deferred child's fate is read from its canonical row, never stored + previous_turn = _previous_turn_source_view(drive_root, previous_turn) + if previous_turn.get("work_ref"): # the deferred child's fate is read from its canonical row, never stored work_ref = str(previous_turn["work_ref"]) child = load_task_result(drive_root, work_ref) or {} status = str(child.get("status") or "") @@ -688,7 +725,8 @@ def run_presence_turn( _write_previous_turn(Path(drive_root), event.conversation_key, task_id, outcome=result.outcome, message=str(row.get("message") or result.text), sends=sends or [], work_ref=result.work_ref, finished_at=utc_now_iso(), - delivery=_delivery_state(event.delivery_reporting_version, sends, result.text)) + delivery=_delivery_state(event.delivery_reporting_version, sends, result.text), + finish_note=str(row.get("finish_note") or "")) return result return (gate or _configured_gate(Path(drive_root))).run(event.conversation_key, execute) diff --git a/ouroboros/project_facts.py b/ouroboros/project_facts.py index f3be9827d..82afe8301 100644 --- a/ouroboros/project_facts.py +++ b/ouroboros/project_facts.py @@ -155,9 +155,11 @@ def resolve_project_id(task: Dict[str, Any]) -> str: # mismatch the forked seed prepared at schedule time for an unscoped parent. if str(task.get("delegation_role") or "") == "subagent": return "" - metadata = task.get("metadata") - if isinstance(metadata, dict) and isinstance(metadata.get("presence"), dict) and metadata["presence"]: - # Presence changes file/process cwd, not its canonical memory scope. + from ouroboros.dialogue_provenance import presence_metadata_binding + + if presence_metadata_binding(task.get("metadata")) is not None: + # Presence (a speaker, or work acting for its binding) changes file/process cwd, + # not its canonical memory scope. return "" workspace = str(task.get("workspace_root") or "").strip() if workspace: diff --git a/ouroboros/server_maintenance.py b/ouroboros/server_maintenance.py index b1e9366c9..d4b7fc678 100644 --- a/ouroboros/server_maintenance.py +++ b/ouroboros/server_maintenance.py @@ -452,9 +452,8 @@ def _startup_retired_settings_notice(settings: dict) -> None: def _prune_event(event_type: str, keys: tuple, **reports: dict) -> None: - """One ``events.jsonl`` row for a GC/sweep step that did or failed something: - ``keys`` are its own evidence of material work, read across every report it - hands in, so a healthy no-op pass stays silent instead of rowing every boot.""" + """Emit when a report has evidence under ``keys``. GC no-ops stay silent; + an observation report with measured journal sizes intentionally rows at boot.""" from supervisor.state import append_jsonl if any(report.get(key) for report in reports.values() for key in keys): @@ -565,11 +564,11 @@ def _startup_prune_sweeps(*, preserve_task_sources: bool = False) -> None: except Exception: log.debug("Stale cache prune failed", exc_info=True) try: - # CPL4-C16 (owner 4A): memory-journal snapshots older than GC retention - # become digest-only (sha256 + length); fresh entries keep full text. + # TZ-3 11A/B5 supersedes old age-digestion: measure journal growth + # without touching historical or new full-text snapshots. from ouroboros.memory_journal_compaction import compact_memory_journal_snapshots - _prune_event("memory_journal_compaction", ("digested", "digest_mismatch", "errors"), + _prune_event("memory_journal_observation", ("journal_bytes", "errors"), report=compact_memory_journal_snapshots(DATA_DIR)) except Exception: log.debug("Memory journal compaction failed", exc_info=True) diff --git a/ouroboros/tools/control.py b/ouroboros/tools/control.py index 24f527d72..e2d276263 100644 --- a/ouroboros/tools/control.py +++ b/ouroboros/tools/control.py @@ -289,7 +289,8 @@ def get_tools() -> List[ToolEntry]: "owner's steering text; from a task it is written as a message from THIS task (never " "owner text, no file attachments), and the result says written, not read. The task picks " "it up at its next step. If no running task clearly fits, use promote_chat_to_task " - "(new work) or answer inline — never steer a task you are unsure about." + "(new work) or answer inline — never steer a task you are unsure about. " + "A Presence-bound turn can steer only work in its own binding; the host checks it." ), "parameters": {"type": "object", "properties": { "task_id": {"type": "string", "description": "Id of the running task to steer (from current_chat.running_tasks)."}, @@ -415,6 +416,7 @@ def get_tools() -> List[ToolEntry]: "focus_source_sha256": {"type": "string", "default": "", "description": "With include_focus_source: select the retained source by the sha256 the roster row quoted, so a later focus of the same author cannot substitute its evidence."}, "source_start_char": {"type": "integer", "description": "Inclusive character offset for the requested canonical source range."}, "source_end_char": {"type": "integer", "description": "Exclusive character offset for the requested canonical source range. A range outside the source returns no text: the answer names complete_chars and the range received, and is an argument error."}, + "presence_scope": {"type": "string", "enum": ["own_binding"], "description": "Presence tasks only: read just independent work started from this Presence binding (any of its conversations) or this task's own tree."}, }}, }, _get_task_result), ToolEntry("wait_task", { diff --git a/ouroboros/tools/control_events.py b/ouroboros/tools/control_events.py index 9634b0dab..76a2d7214 100644 --- a/ouroboros/tools/control_events.py +++ b/ouroboros/tools/control_events.py @@ -189,12 +189,16 @@ def _record_promotion_admission_stub(ctx: ToolContext, evt: Dict[str, Any], mode duplicate root (#1160). The stub is the negative side only: ``emitted`` is not a scheduled status, positive scheduling authority stays with the supervisor's own receipt, and ``create_only`` initializes ABSENCE alone, so a supervisor - that already answered keeps its row byte-for-byte. + that already answered keeps its row byte-for-byte. A Presence promote's stub + carries the event's host provenance as the would-be root, exactly as the + admission writes it, so its own binding can read the pending reconciliation. """ + from ouroboros.dialogue_provenance import presence_root_carrier from ouroboros.routing_wait import PROMOTION_ADMISSION_EMITTED from ouroboros.task_results import STATUS_REQUESTED, write_task_result task_id = str(evt.get("task_id") or "") + carrier = presence_root_carrier(evt, task_contract=evt.get("task_contract")) try: write_task_result( _routing_status_root(ctx), task_id, STATUS_REQUESTED, @@ -207,6 +211,8 @@ def _record_promotion_admission_stub(ctx: ToolContext, evt: Dict[str, Any], mode "emitted_at": utc_now_iso(), "transport_mode": mode, }, + **({"metadata": carrier, "source": "presence_promote", + "delegation_role": "root", "root_task_id": task_id} if carrier else {}), ) except Exception as exc: # The promote itself proceeds; what is lost is the reconciliation read, so diff --git a/ouroboros/tools/control_routing.py b/ouroboros/tools/control_routing.py index 03aebb65e..4025845a7 100644 --- a/ouroboros/tools/control_routing.py +++ b/ouroboros/tools/control_routing.py @@ -15,6 +15,7 @@ import uuid from pathlib import Path from typing import Any, Dict +from ouroboros.dialogue_provenance import presence_root_carrier from ouroboros.tools.control_events import ( _PROMOTE_CONFIRM_TIMEOUT_SEC, _emit_and_wait_for_routing, @@ -438,18 +439,17 @@ def _promote_chat_to_task( "ts": utc_now_iso(), } metadata = getattr(ctx, "task_metadata", {}) - presence = metadata.get("presence") if isinstance(metadata, dict) else None - if isinstance(presence, dict) and presence: - # A public conversation may promote long work, but it cannot choose a - # new Project/workspace/source authority. The immutable positive ceiling - # and exact return destination follow the promoted root by value. + presence_carrier = presence_root_carrier(metadata, task_contract=getattr(ctx, "task_contract", None)) + if presence_carrier: + # A public conversation cannot choose a new Project/workspace/source authority; the immutable + # ceiling and return destination (a descendant's root: its binding only) follow it by value. evt.update({ "project_id": "", "project_name": "", "workspace_root": "", "workspace": "", "source": "", - "presence": dict(presence), + **presence_carrier, "task_contract": dict(getattr(ctx, "task_contract", {}) or {}), }) repo_root_note = "" # Presence runs in its admitted folder, never over the repo @@ -972,6 +972,7 @@ def _send_task_message( No origin-bytes substitution, attachments or owner client surface. The result says WRITTEN: the target reads it at its next checkpoint. """ + from ouroboros.dialogue_provenance import presence_caller_binding, presence_sender_origin from ouroboros.project_dialogue import AGENT_RECEIPT_ID_PREFIX routing_token = uuid.uuid4().hex @@ -987,6 +988,8 @@ def _send_task_message( "issuer": dict(issuer), "ts": utc_now_iso(), } + if (binding := presence_caller_binding(ctx)) is not None: # admitted only to this binding's own work (owner Q2) + evt.update(presence_binding_id=binding, sender_origin=presence_sender_origin(ctx)) mode, receipt = _emit_and_wait_for_routing(ctx, evt) if str(receipt.get("status") or "") == "delivered": return ( diff --git a/ouroboros/tools/control_scheduling.py b/ouroboros/tools/control_scheduling.py index 1700b5b18..acbfa121c 100644 --- a/ouroboros/tools/control_scheduling.py +++ b/ouroboros/tools/control_scheduling.py @@ -20,9 +20,10 @@ from pathlib import Path from typing import Any, Dict, List, Optional from ouroboros.artifacts import attachment_manifest_projection, resolve_attachment_manifest -from ouroboros.config import get_max_subagent_depth +from ouroboros.config import get_max_subagent_depth, runtime_settings from ouroboros.consciousness_authority import consciousness_origin_metadata from ouroboros.depth_evidence import parse_task_depth +from ouroboros.dialogue_provenance import presence_binding_authority_metadata from ouroboros.contracts.task_contract import ( build_task_contract, effective_acceptance_claims, @@ -60,7 +61,6 @@ from ouroboros.tools.control_subagent_spec import ( from ouroboros.tools.registry import ToolContext, active_repo_dir_for, system_repo_dir_for from ouroboros.utils import append_jsonl, utc_now_iso from ouroboros.tools.tool_result import ToolResult, _publish_tool_result -from ouroboros.config import runtime_settings def _publish_scheduling_refusal(ctx: Any, status: str, code: str, text: str) -> str: @@ -846,8 +846,8 @@ def _schedule_task(ctx: ToolContext, internal: Dict[str, Any] | None = None, /, "required_capabilities": required_caps, **intent_fields, "subagent_envelope": envelope, - # A child of a consciousness turn/tree carries the origin (label, category, level). - "origin_metadata": consciousness_origin_metadata(metadata), + "origin_metadata": consciousness_origin_metadata(metadata), # a consciousness child: label, category, level + **presence_binding_authority_metadata(metadata, task_contract=getattr(ctx, "task_contract", None)), # never speaker } _populate_subagent_event_extras( evt, current_chat_id=current_chat_id, child_drive=child_drive, diff --git a/ouroboros/tools/control_task_results.py b/ouroboros/tools/control_task_results.py index 2b0a22be9..3b37d3972 100644 --- a/ouroboros/tools/control_task_results.py +++ b/ouroboros/tools/control_task_results.py @@ -188,11 +188,33 @@ def _get_task_result( include_work_order_source: bool = False, source_start_char: Any = None, source_end_char: Any = None, include_completion_source: bool = False, known_result_sha256: str = "", include_focus_source: bool = False, focus_source_sha256: str = "", + presence_scope: str = "", ) -> str: """Read a task result, or a bounded canonical work-order/completion source range.""" metadata = getattr(ctx, "task_metadata", {}) if isinstance(getattr(ctx, "task_metadata", {}), dict) else {} status_drive_root = Path(str(metadata.get("budget_drive_root") or getattr(ctx, "budget_drive_root", "") or ctx.drive_root)) + scoped = bool(str(presence_scope or "").strip()) + if scoped: + from ouroboros.presence_authority import PRESENCE_OWN_WORK_SCOPE, presence_caller_binding, presence_work_refusal + + # The scoped read admits exactly the work a scoped page can list, plus this + # task's own tree; everything else refuses before any record is projected. + refusal = ( + "⚠️ PRESENCE_CAPABILITY_BLOCKED: presence_scope=own_binding needs a Presence task with a binding id." + if str(presence_scope).strip() != PRESENCE_OWN_WORK_SCOPE or not presence_caller_binding(ctx) + else presence_work_refusal(ctx, str(task_id or ""), drive_root=status_drive_root, same_tree=True) + ) + if refusal: + return _publish_tool_result(ctx, ToolResult(status="blocked", code="ACCESS_BLOCKED", text=refusal)) data = load_effective_task_result(status_drive_root, task_id) + if scoped: + from ouroboros.presence_authority import presence_effective_refusal + + # The effective read may substitute a retry successor's record: it is judged too. + refusal = presence_effective_refusal(ctx, str(task_id or ""), data, drive_root=status_drive_root, + same_tree=True) + if refusal: + return _publish_tool_result(ctx, ToolResult(status="blocked", code="ACCESS_BLOCKED", text=refusal)) from ouroboros.tools.recent_tasks import _restricted_actor restricted = _restricted_actor(ctx) diff --git a/ouroboros/tools/followup.py b/ouroboros/tools/followup.py index 2e7b83534..a43cb39c5 100644 --- a/ouroboros/tools/followup.py +++ b/ouroboros/tools/followup.py @@ -34,6 +34,7 @@ from typing import Any, Dict, List from ouroboros.consciousness_authority import consciousness_origin_metadata from ouroboros.deadline_utils import parse_deadline_ts +from ouroboros.dialogue_provenance import presence_caller_binding, presence_root_carrier from ouroboros.tools.arg_feedback import ignored_argument_note from ouroboros.tools.registry import ToolContext, ToolEntry @@ -59,10 +60,8 @@ def _manage_schedules( mutate_scheduled_task, schedule_tool_projection, ) - metadata = getattr(ctx, "task_metadata", {}) - metadata = metadata if isinstance(metadata, dict) else {} operation = str(action or "list").strip().lower() - if metadata.get("presence"): + if presence_caller_binding(ctx) is not None: # a speaker, or work acting for its binding return _publish_tool_result(ctx, ToolResult( status="blocked", code="RESOURCE_CONSTRAINT_BLOCKED", text="⚠️ RESOURCE_CONSTRAINT_BLOCKED: a Presence conversation cannot read or change owner schedules.", @@ -439,11 +438,18 @@ def _register_followup(ctx: ToolContext, task_id: str, drive_root: Any, # A follow-up from a consciousness turn/tree starts a consciousness root: the # origin, category and level ride the template; admission derives the rest. record["task"]["metadata"].update(consciousness_origin_metadata(metadata_src)) - presence = metadata_src.get("presence") if isinstance(metadata_src, dict) else None + # The same Presence carrier a promote keeps: a speaker's metadata, or the binding a + # promoted descendant root acts for; the ceiling rides by value and no Project is chosen. + # A new root authors its own objective, context and acceptance premises; + # only the origin's authority and general constraints survive by value. contract = getattr(ctx, "task_contract", None) - if isinstance(presence, dict) and presence and isinstance(contract, dict): - record["task"]["metadata"]["presence"] = dict(presence) - record["task"]["task_contract"] = dict(contract) + carrier = presence_root_carrier(metadata_src, task_contract=contract) + if carrier and isinstance(contract, dict): + record["task"]["metadata"].update(carrier) + record["task"]["task_contract"] = { + key: value for key, value in contract.items() + if key not in {"objective", "context", "expected_output", "acceptance_claims", "success_criteria"}} + record["task"].pop("project_id", None) from supervisor.queue import ScheduleRefused, ScheduleStoreUnreadable try: diff --git a/ouroboros/tools/join_ledger.py b/ouroboros/tools/join_ledger.py index 3053b6cfd..97eced65b 100644 --- a/ouroboros/tools/join_ledger.py +++ b/ouroboros/tools/join_ledger.py @@ -670,6 +670,23 @@ def _cancel_task(ctx: ToolContext, task_id: str, reason: str = "") -> str: if not own and (_is_delegated_task(ctx) or is_observe_origin(getattr(ctx, "task_metadata", {}))): return _publish_tool_result(ctx, ToolResult(status="blocked", code="ACCESS_BLOCKED", text=(f"⚠️ cancel_task: {tid} is not a child of this task — a delegated task may only cancel its own children, and a consciousness wake at the Observe level is held to the same rule."))) + from ouroboros.presence_authority import presence_caller_binding, presence_work_refusal + + if not own and presence_caller_binding(ctx) is not None: + # A Presence caller stops only its own binding's work or its own tree, and + # a redirected retry is judged at its effective target too — before any intent. + from ouroboros.cancel_intents import _validated_single_cancel_target + + refusal = presence_work_refusal(ctx, tid, drive_root=status_drive_root, same_tree=True) + if not refusal: + try: + effective = _validated_single_cancel_target(status_drive_root, tid) + except Exception: + effective = tid + if effective != tid: + refusal = presence_work_refusal(ctx, effective, drive_root=status_drive_root, same_tree=True) + if refusal: + return _publish_tool_result(ctx, ToolResult(status="blocked", code="ACCESS_BLOCKED", text=refusal)) # Durable cancel intent — the ONE ingress (phase A, owner batch-4 1=A). The # canonical status never carries intent: the supervisor's cancellation # custody claims this intent, tears the task down, and settles the terminal diff --git a/ouroboros/tools/presence.py b/ouroboros/tools/presence.py index 59718d1a3..9c922126f 100644 --- a/ouroboros/tools/presence.py +++ b/ouroboros/tools/presence.py @@ -300,9 +300,10 @@ def _initiate_presence( def _cancel_presence_work(ctx: ToolContext, work_ref: str, reason: str = "") -> str: - """Cancel only work correlated to this exact presence binding/conversation.""" + """Cancel work started from this presence binding (any of its conversations) or this turn's own tree.""" - from ouroboros.task_results import load_task_result, validate_task_id + from ouroboros.presence_authority import presence_caller_binding, presence_work_refusal + from ouroboros.task_results import validate_task_id from ouroboros.tool_access import canonical_data_root from ouroboros.tools.join_ledger import _cancel_task @@ -310,21 +311,11 @@ def _cancel_presence_work(ctx: ToolContext, work_ref: str, reason: str = "") -> task_id = validate_task_id(work_ref) except ValueError as exc: return _publish_tool_result(ctx, ToolResult(status="error", code="TOOL_ARG_ERROR", text=(f"ERROR: PRESENCE_WORK_REF_INVALID: {exc}"))) - current_meta = getattr(ctx, "task_metadata", {}) - current = current_meta.get("presence") if isinstance(current_meta, dict) else None - stored = load_task_result(canonical_data_root(ctx), task_id) or {} - target_meta = stored.get("metadata") if isinstance(stored.get("metadata"), dict) else {} - target = target_meta.get("presence") if isinstance(target_meta.get("presence"), dict) else None - if not isinstance(current, dict) or not isinstance(target, dict): - return _publish_tool_result(ctx, ToolResult(status="blocked", code="ACCESS_BLOCKED", text=("ERROR: PRESENCE_WORK_NOT_CORRELATED"))) - current_event = current.get("event") if isinstance(current.get("event"), dict) else {} - target_event = target.get("event") if isinstance(target.get("event"), dict) else {} - if ( - str(current.get("binding_id") or "") != str(target.get("binding_id") or "") - or str(current_event.get("conversation_key") or "") - != str(target_event.get("conversation_key") or "") - ): - return _publish_tool_result(ctx, ToolResult(status="blocked", code="ACCESS_BLOCKED", text=("ERROR: PRESENCE_WORK_NOT_CORRELATED"))) + refusal = presence_work_refusal(ctx, task_id, drive_root=canonical_data_root(ctx), same_tree=True) + # A speaker, or a root acting only for its binding: the binding authority decides, not speaker metadata. + if refusal or presence_caller_binding(ctx) is None: + return _publish_tool_result(ctx, ToolResult(status="blocked", code="ACCESS_BLOCKED", text=( + "ERROR: PRESENCE_WORK_NOT_CORRELATED: " + (refusal.split(": ", 1)[-1] or "this is not a presence task.")))) return _cancel_task(ctx, task_id, reason) @@ -440,9 +431,10 @@ def get_tools() -> List[ToolEntry]: schema={ "name": "presence_cancel_work", "description": ( - "Request cancellation of long work previously deferred from this exact " - "presence binding and conversation. The opaque work_ref is correlation, " - "not general task authority." + "Request cancellation of independent work started from this presence " + "binding, in this or another of its conversations (or of this turn's own " + "children). The result is a request receipt, not proof the work stopped; " + "work of another binding or the owner's own tasks is refused." ), "parameters": { "type": "object", diff --git a/ouroboros/tools/project_journal.py b/ouroboros/tools/project_journal.py index 0f212a69e..b46631a9d 100644 --- a/ouroboros/tools/project_journal.py +++ b/ouroboros/tools/project_journal.py @@ -26,7 +26,7 @@ from ouroboros.project_facts import ( sanitize_project_id, explicit_project_id_ok, ) -from ouroboros.dialogue_provenance import is_presence_task +from ouroboros.dialogue_provenance import is_presence_task, presence_caller_binding from ouroboros.focus import normalize_focus from ouroboros.tools.registry import ToolContext, ToolEntry from ouroboros.utils import ( @@ -55,7 +55,7 @@ def _scope_authority(ctx: ToolContext) -> tuple[str, Dict[str, Any]]: delegation_role = str(lineage.get("delegation_role") or metadata.get("delegation_role") or "").strip() root_task_id = str(lineage.get("root_task_id") or metadata.get("root_task_id") or "").strip() child = bool(parent_task_id) or delegation_role == "subagent" - if is_presence_task(task): + if is_presence_task(task) or presence_caller_binding(ctx) is not None: # a speaker, or acting for its binding return "presence", metadata if child: return "child", metadata diff --git a/ouroboros/tools/recent_tasks.py b/ouroboros/tools/recent_tasks.py index dc3a008fe..71ad090b6 100644 --- a/ouroboros/tools/recent_tasks.py +++ b/ouroboros/tools/recent_tasks.py @@ -10,11 +10,19 @@ from typing import Any, Dict, List from ouroboros.tools.registry import ToolContext, ToolEntry from ouroboros.outcomes import normalize_outcome_axes from ouroboros.task_status import effective_task_result -from ouroboros.dialogue_provenance import is_presence_task +from ouroboros.dialogue_provenance import ( + PRESENCE_OWN_WORK_SCOPE, + is_presence_task, + presence_caller_binding, + presence_effective_related, + presence_provenance_from_task, + presence_related_work, +) _MAX_TASKS = 20 _PREVIEW_CHARS = 800 +_ORIGIN_KEYS = ("provider", "conversation_id", "thread_id", "conversation_key", "source_event_id") def _coerce_limit(value: Any) -> int: @@ -49,11 +57,18 @@ def _task_record( drive_root: pathlib.Path, include_results: bool, include_traces: bool, + binding: str | None = None, ) -> tuple[Dict[str, Any] | None, Dict[str, str] | None]: - data, error = _read_json(path) - if data is None: + raw, error = _read_json(path) + if raw is None: return None, {"path": str(path), "error": error} - data = effective_task_result(drive_root, data) + data = effective_task_result(drive_root, raw) + withheld = False + if binding is not None: + # A scoped page lists this binding's row; a retry successor it redirects to is judged too. + withheld = not presence_effective_related(binding, str(raw.get("task_id") or path.stem), data, + drive_root=drive_root) + data = raw if withheld else data result = str(data.get("result") or "") from ouroboros.cost_projection import cost_projection @@ -94,28 +109,133 @@ def _task_record( record["result"] = result if include_traces: record["trace_summary"] = str(data.get("trace_summary") or "") + if data.get("cancel_state"): + record["cancel_state"] = str(data["cancel_state"]) # requested is not stopped + origin = presence_provenance_from_task(data) + if origin: + # Which of the binding's conversations started it: the source room is a fact + # for the reader, never a reply address or a public disclosure. + record["presence_origin"] = {key: origin[key] for key in _ORIGIN_KEYS if origin.get(key)} + if withheld: + record["effective_result"] = "withheld: it continues in work not started from this binding" return record, None -def _running_tasks(drive_root: pathlib.Path) -> List[Dict[str, Any]]: +def _queue_snapshot(drive_root: pathlib.Path) -> tuple[Dict[str, Any], bool]: + """The persisted queue snapshot, and whether one exists that could not be read. + + Never written means nothing was ever queued; a written snapshot that cannot be + read, or lists its rows in a shape this reader cannot walk, proves no absence. + """ + path = drive_root / "state" / "queue_snapshot.json" + try: + raw = path.read_text(encoding="utf-8") + except FileNotFoundError: + return {}, False + except (OSError, UnicodeDecodeError): + return {}, True + try: + data = json.loads(raw) + except ValueError: + return {}, True + if not isinstance(data, dict) or any(not isinstance(data.get(key, []), list) for key in ("running", "pending")): + return {}, True + return data, False + + +def _owner_record(row: Dict[str, Any] | None, queued: Dict[str, Any]) -> Dict[str, Any]: + """Whose work one task is: a readable result row's binding fact decides, else the queue's own task.""" + if row and (row.get("presence_binding_id") or row.get("presence_authority_recorded")): + # A readable malformed/empty carrier (or a ceiling whose carrier was lost) + # outranks a stale queue claim: the empty binding grants no scoped read. + return {"metadata": {"presence_binding_authority": {"binding_id": row.get("presence_binding_id") or ""}}, + "delegation_role": row.get("delegation_role"), "parent_task_id": row.get("parent_task_id")} + return queued + + +def _running_tasks(drive_root: pathlib.Path, binding: str | None = None) -> List[Dict[str, Any]]: snapshot, _error = _read_json(drive_root / "state" / "queue_snapshot.json") snapshot = snapshot or {} running = snapshot.get("running") if not isinstance(running, list): return [] + facts: Dict[str, Dict[str, Any]] = {} + if binding is not None: + from ouroboros.gateway.task_list_scan import raw_result_facts + + try: + facts, _malformed = raw_result_facts(drive_root / "task_results") + except OSError: + pass # no result row is readable: the queue rows decide, as on the scoped task list rows: List[Dict[str, Any]] = [] for item in running: if not isinstance(item, dict): continue + task = item.get("task") if isinstance(item.get("task"), dict) else {} + task_id = str(item.get("id") or item.get("task_id") or "") + if binding is not None and not presence_related_work( + binding, _owner_record(facts.get(f"{task_id}.json"), task)): + continue # a scoped page lists no foreign running work, whatever a stale queue row claims rows.append({ - "task_id": str(item.get("id") or item.get("task_id") or ""), + "task_id": task_id, "status": "running", - "description": str(item.get("text") or item.get("description") or ""), + "description": str(item.get("text") or item.get("description") + or task.get("description") or task.get("text") or ""), "ts": str(item.get("ts") or snapshot.get("ts") or ""), }) return rows +def _presence_scope_inventory( + drive_root: pathlib.Path, task_dir: pathlib.Path, binding: str, exclude: str, +) -> tuple[List[Dict[str, Any]], Dict[str, Any]]: + """This binding's own work (queue-only active rows first, then result files newest first) and its read gap. + + The memo's scalar binding fact selects files without a second read; a row + without Presence provenance may be established only by the queue's own task + metadata (a legacy pending promotion), and a row naming another binding stays out. + Only a READABLE result row replaces a queue row: an unreadable one leaves the + queued work listed, and unreadable rows nothing attributes are counted, never dropped. + An unreadable queue snapshot is a gap too: its queued work cannot be listed, not absent. + """ + from ouroboros.gateway.task_list_scan import raw_result_facts + + gap: Dict[str, Any] = {} + try: + facts, malformed = raw_result_facts(task_dir) + except OSError: + facts, malformed = {}, [] + gap["result_root"] = "unreadable" # queued rows remain; no result row could be read + snapshot, snapshot_unreadable = _queue_snapshot(drive_root) + if snapshot_unreadable: + gap["queue_snapshot"] = "unreadable" # its queued work cannot be listed; that is not absence + queued: Dict[str, tuple[str, Dict[str, Any]]] = {} + for status in ("running", "pending"): + for item in snapshot.get(status) or []: + task = item.get("task") if isinstance(item, dict) and isinstance(item.get("task"), dict) else {} + task_id = str(item.get("id") or task.get("id") or "") if isinstance(item, dict) else "" + if task_id and task_id not in queued: + queued[task_id] = (status, task) + selected: set[str] = set() + for name, row in facts.items(): + task_id = row.get("task_id") or row.get("id") or name[:-5] + record = _owner_record(row, queued.get(task_id, ("", {}))[1]) + if task_id != exclude and presence_related_work(binding, record): + selected.add(name) + unreadable = set(malformed) + queue_only = [ + {"queue_task_id": task_id, "status": status, + "description": str(task.get("description") or task.get("text") or ""), + **({"result_row": "unreadable"} if f"{task_id}.json" in unreadable else {})} + for task_id, (status, task) in queued.items() + if f"{task_id}.json" not in facts and task_id != exclude and presence_related_work(binding, task) + ] + unattributed = sum(1 for name in unreadable if name[:-5] not in queued) # a queue row attributed the rest + if unattributed: + gap["unattributed_unreadable_rows"] = unattributed # any of them may be this binding's work + return queue_only + [row for row in _task_file_inventory(task_dir) if row["name"] in selected], gap + + def _task_file_inventory(task_dir: pathlib.Path) -> List[Dict[str, Any]]: inventory: List[Dict[str, Any]] = [] if not task_dir.is_dir(): @@ -141,13 +261,17 @@ def _recent_tasks_snapshot( *, include_results: bool, include_traces: bool, + binding: str | None = None, ) -> str: + query: Dict[str, Any] = { + "include_results": bool(include_results), + "include_traces": bool(include_traces), + } + if binding is not None: + query["presence_binding"] = binding # another binding or scope never continues this cursor payload = { "schema_version": 1, - "query": { - "include_results": bool(include_results), - "include_traces": bool(include_traces), - }, + "query": query, "files": inventory, } encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) @@ -161,14 +285,45 @@ def _handle_recent_tasks( snapshot: str = "", include_results: bool = False, include_traces: bool = False, + presence_scope: str = "", **_kwargs: Any, ) -> str: """Return recent completed task summaries from the canonical task root.""" from ouroboros.tool_access import canonical_data_root - drive_root = canonical_data_root(ctx) + binding = None + if str(presence_scope or "").strip(): + binding = presence_caller_binding(ctx) + if str(presence_scope).strip() != PRESENCE_OWN_WORK_SCOPE or not binding: + return json.dumps({"ok": False, "host_code": "TOOL_ARG_ERROR", "error": { + "code": "PRESENCE_SCOPE_UNAVAILABLE", + "message": "presence_scope=own_binding needs a Presence task with a binding id.", + }}, ensure_ascii=False) + page = recent_tasks_page( + canonical_data_root(ctx), limit=limit, offset=offset, snapshot=snapshot, + include_results=bool(include_results), include_traces=bool(include_traces), + restricted=_restricted_actor(ctx), binding=binding, + exclude=str(getattr(ctx, "task_id", "") or "") if binding is not None else "", + ) + if binding is not None: + page["presence_scope"] = {"scope": PRESENCE_OWN_WORK_SCOPE, "binding_id": binding} + return json.dumps(page, ensure_ascii=False, indent=2) + + +def recent_tasks_page( + drive_root: pathlib.Path, + *, + limit: Any = 5, + offset: Any = 0, + snapshot: str = "", + include_results: bool = False, + include_traces: bool = False, + restricted: bool = False, + binding: str | None = None, + exclude: str = "", +) -> Dict[str, Any]: + """One stable page; ``binding`` filters to that Presence binding's own work BEFORE paging.""" task_dir = drive_root / "task_results" - restricted = _restricted_actor(ctx) task_limit = _coerce_limit(limit) try: skip = max(0, int(offset or 0)) @@ -180,23 +335,42 @@ def _handle_recent_tasks( inventory: List[Dict[str, Any]] = [] current_snapshot = "" stable = False + read_gap: Dict[str, Any] = {} + + def _inventory() -> List[Dict[str, Any]]: + if binding is None: + return _task_file_inventory(task_dir) + rows, gap = _presence_scope_inventory(drive_root, task_dir, binding, exclude) + read_gap.clear() + read_gap.update(gap) + return rows + for _attempt in range(2): tasks = [] unreadable_tasks = [] - before = _task_file_inventory(task_dir) + before = _inventory() current_snapshot = _recent_tasks_snapshot( before, include_results=bool(include_results), include_traces=bool(include_traces), + binding=binding, ) selected = before[skip:skip + task_limit] for item in selected: + if item.get("queue_task_id"): + tasks.append({ + "task_id": str(item["queue_task_id"]), "status": str(item.get("status") or ""), + "description": str(item.get("description") or ""), "source": "queue_snapshot", + **({"result_row": item["result_row"]} if item.get("result_row") else {}), + }) + continue path = task_dir / str(item["name"]) record, error = _task_record( path, drive_root=drive_root, include_results=bool(include_results), include_traces=bool(include_traces), + binding=binding, ) if record is not None: if restricted: @@ -206,7 +380,7 @@ def _handle_recent_tasks( tasks.append(record) elif error is not None: unreadable_tasks.append(error) - inventory = _task_file_inventory(task_dir) + inventory = _inventory() stable = before == inventory if stable: break @@ -214,7 +388,7 @@ def _handle_recent_tasks( returned = min(task_limit, max(0, total - skip)) remaining = max(0, total - skip - returned) base = { - "running": _running_tasks(drive_root), + "running": _running_tasks(drive_root, binding), "tasks": tasks, "unreadable_tasks": unreadable_tasks, "source": {"reader": "recent_tasks", "root": "canonical_task_results"}, @@ -229,10 +403,12 @@ def _handle_recent_tasks( "snapshot": current_snapshot, "include_results": bool(include_results), "include_traces": bool(include_traces), + **({"presence_scope": PRESENCE_OWN_WORK_SCOPE} if binding is not None else {}), } if remaining else None), + **({"read_gap": dict(read_gap)} if read_gap else {}), } if not stable: - return json.dumps({ + return { **base, "tasks": [], "unreadable_tasks": [], @@ -243,9 +419,9 @@ def _handle_recent_tasks( "was returned; restart with offset=0 and no snapshot." ), }, - }, ensure_ascii=False, indent=2) + } if requested_snapshot and requested_snapshot != current_snapshot: - return json.dumps({ + return { **base, "tasks": [], "unreadable_tasks": [], @@ -256,17 +432,17 @@ def _handle_recent_tasks( "returned; restart with offset=0 and no snapshot." ), }, - }, ensure_ascii=False, indent=2) - return json.dumps(base, ensure_ascii=False, indent=2) + } + return base def _restricted_actor(ctx: ToolContext) -> bool: - """Children and Presence turns hold no live cross-focus catalogue.""" + """Children and Presence turns, or work acting for a binding, hold no live cross-focus catalogue.""" metadata = getattr(ctx, "task_metadata", {}) metadata = metadata if isinstance(metadata, dict) else {} return bool(str(metadata.get("parent_task_id") or "").strip() or str(metadata.get("delegation_role") or "") == "subagent" - or is_presence_task({"metadata": metadata})) + or is_presence_task({"metadata": metadata}) or presence_caller_binding(ctx) is not None) def _handle_live_roots(ctx: ToolContext, limit: int = 20, offset: int = 0, snapshot: str = "", **_kwargs: Any) -> str: @@ -321,6 +497,14 @@ def get_tools() -> List[ToolEntry]: "description": "Include each task's trace_summary.", "default": False, }, + "presence_scope": { + "type": "string", + "enum": ["own_binding"], + "description": ( + "Presence tasks only: list just the independent work started from this " + "Presence binding in any of its conversations, pending and running included." + ), + }, }, "required": [], }, diff --git a/ouroboros/tools/registry_core.py b/ouroboros/tools/registry_core.py index 23d411b34..56c09d2ab 100644 --- a/ouroboros/tools/registry_core.py +++ b/ouroboros/tools/registry_core.py @@ -191,6 +191,13 @@ def _presence_bound_args(ctx: Any, name: str, args: Any) -> tuple[dict[str, Any] "⚠️ PRESENCE_CAPABILITY_BLOCKED: " f"{name!r} is outside this presence task's positive capability ceiling." ) + if ceiling is not None and name == "forward_to_worker": + # A selected forward keeps its own tree and reaches only this binding's work. + from ouroboros.presence_authority import presence_work_refusal + + refusal = presence_work_refusal(ctx, str(bound.get("task_id") or ""), same_tree=True) + if refusal: + return {}, refusal return bound, "" except Exception as exc: return {}, f"⚠️ PRESENCE_ARGUMENT_BINDING_BLOCKED: {exc}" diff --git a/supervisor/events_project_routing.py b/supervisor/events_project_routing.py index 036714478..dec1337ca 100644 --- a/supervisor/events_project_routing.py +++ b/supervisor/events_project_routing.py @@ -12,6 +12,7 @@ import logging import threading from typing import Any, Dict, Optional +from ouroboros.dialogue_provenance import presence_root_carrier from ouroboros.task_results import STATUS_FAILED, STATUS_SCHEDULED, write_task_result from ouroboros.utils import utc_now_iso @@ -497,6 +498,7 @@ def _promote_chat_to_task_outcome(evt: Dict[str, Any], ctx: Any) -> Dict[str, An else "unconfirmed" ) transfer = outcome.pop("force_plan_transfer", None) + carrier = presence_root_carrier(evt, task_contract=evt.get("task_contract")) stored = write_task_result( ctx.DRIVE_ROOT, str(outcome.get("task_id") or task_id), @@ -527,6 +529,10 @@ def _promote_chat_to_task_outcome(evt: Dict[str, Any], ctx: Any) -> Dict[str, An else "Task is scheduled, but its owner-facing routing receipt was not confirmed." ), attachment_manifest=list(outcome.get("attachment_manifest") or []), + # A Presence promotion's host-carried provenance is canonical from + # admission, so its binding finds, polls and controls the work while + # it is still queued (the worker's running write keeps the same value). + **({"metadata": carrier, "source": "presence_promote"} if carrier else {}), ) admission = stored.get("promotion_admission") if isinstance(stored, dict) else {} if ( diff --git a/supervisor/events_schedule_task.py b/supervisor/events_schedule_task.py index fef2165cb..46bc3af68 100644 --- a/supervisor/events_schedule_task.py +++ b/supervisor/events_schedule_task.py @@ -525,6 +525,8 @@ def _handle_schedule_task(evt: Dict[str, Any], ctx: Any) -> None: "parent_cognitive_route": parent_cognitive_route, "parent_id": parent_id, "origin_metadata": evt.get("origin_metadata"), + **({"presence_binding_authority": evt["presence_binding_authority"]} + if "presence_binding_authority" in evt else {}), }) scheduled_failure_reason = "" scheduled_failure_detail = "" diff --git a/supervisor/queue_schedules.py b/supervisor/queue_schedules.py index e2b014375..f34441e4c 100644 --- a/supervisor/queue_schedules.py +++ b/supervisor/queue_schedules.py @@ -22,6 +22,7 @@ import uuid from typing import Any, Dict, List from ouroboros.consciousness_authority import apply_consciousness_authority from ouroboros.contracts.task_contract import build_task_contract, normalize_allowed_resources +from ouroboros.dialogue_provenance import presence_metadata_binding from ouroboros.schedule_contract import RESERVED_TEMPLATE_FIELDS, schedule_slug from ouroboros.skill_loader import skill_identity_collision_names from ouroboros.utils import atomic_write_json, in_worker_process, read_json_dict, utc_now_iso @@ -684,9 +685,10 @@ def _task_from_schedule(record: Dict[str, Any]) -> Dict[str, Any]: if existing_contract: task["task_contract"] = existing_contract task["task_contract"] = build_task_contract(apply_consciousness_authority(task)) - presence = metadata.get("presence") workspace = task["task_contract"]["workspace"] - if isinstance(presence, dict) and presence and workspace["root"]: + # Presence-bound work (a speaker's follow-up, or one acting for a descendant's binding) + # runs in the folder its inherited contract admitted, with canonical shared memory. + if presence_metadata_binding(metadata) is not None and workspace["root"]: task.update( workspace_root=workspace["root"], workspace_mode=workspace["mode"], memory_mode="shared", diff --git a/supervisor/steering.py b/supervisor/steering.py index 5d1364e00..ceab55937 100644 --- a/supervisor/steering.py +++ b/supervisor/steering.py @@ -53,6 +53,36 @@ def _task_issued(evt: Dict[str, Any]) -> bool: return str(_issuer(evt).get("kind") or "") == "task" +def _presence_target_refused(ctx: Any, evt: Dict[str, Any], task: Dict[str, Any]) -> bool: + """A Presence sender's live target must be independent work of its own binding. + + The sender is Presence by the host's stamp on the event or, failing that, by + its own live queue row (a delegated descendant's inherited binding authority), + so the fence never rests on one producer remembering to stamp the event; a + malformed stamp or carrier narrows to nothing. Whose work the target is follows + the read/cancel precedence: its canonical record decides, and the live row + stands in only for a record without Presence provenance. + """ + from ouroboros.dialogue_provenance import ( + presence_metadata_binding, presence_related_work, presence_target_record, + ) + + if "presence_binding_id" in evt: + stamp = evt.get("presence_binding_id") + binding = stamp.strip() if isinstance(stamp, str) else "" + else: + running = getattr(ctx, "RUNNING", None) + meta = running.get(str(_issuer(evt).get("task_id") or "")) if isinstance(running, dict) else None + row = meta.get("task") if isinstance(meta, dict) else None + binding = presence_metadata_binding(row.get("metadata")) if isinstance(row, dict) else None + if binding is None and isinstance(row, dict) and isinstance(row.get("task_contract"), dict): + binding = "" if "capability_ceiling" in row["task_contract"] else None + if binding is None: + return False + target = str(evt.get("target_task_id") or task.get("id") or "").strip() + return not presence_related_work(binding, presence_target_record(ctx.DRIVE_ROOT, target, queue_row=task)) + + def _refuse_steering_while_cancelling( ctx: Any, evt: Dict[str, Any], @@ -250,6 +280,8 @@ def _handle_steer_task(evt: Dict[str, Any], ctx: Any) -> None: refusal = "target_unknown" elif str(task.get("delegation_role") or "") == "subagent": refusal = "subagent_target" + elif task_issued and _presence_target_refused(ctx, evt, task): + refusal = "presence_work_not_related" elif not task_issued and not _owner_lane_allows(ctx, task, target, chat_id): refusal = "chat_mismatch" else: @@ -373,6 +405,7 @@ def _handle_steer_task(evt: Dict[str, Any], ctx: Any) -> None: if not write_task_message( drive, message, target, source_task_id=issuer_task_id, provenance=PROVENANCE_INDEPENDENT_TASK, msg_id=msg_id, + sender_origin=evt.get("sender_origin") if isinstance(evt.get("sender_origin"), dict) else None, ): raise OSError("task mailbox append was not durable") delivered = True diff --git a/supervisor/task_dispatch.py b/supervisor/task_dispatch.py index 233847570..f7faa8a0e 100644 --- a/supervisor/task_dispatch.py +++ b/supervisor/task_dispatch.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Any, Dict from ouroboros.depth_evidence import parse_task_depth +from ouroboros.dialogue_provenance import PRESENCE_BINDING_AUTHORITY_KEY, presence_binding_authority_metadata def build_scheduled_task_payload(fields: Dict[str, Any]) -> Dict[str, Any]: @@ -50,6 +51,11 @@ def build_scheduled_task_payload(fields: Dict[str, Any]) -> Dict[str, Any]: directory_options = {key: fields[key] for key in ("directory_strategy", "scope_paths") if key in fields} # A child of a consciousness turn/tree inherits its origin label, category and level. origin_metadata = fields.get("origin_metadata") if isinstance(fields.get("origin_metadata"), dict) else {} + # A child of a Presence-bound task inherits only the binding it acts for, never the speaker's + # ``metadata.presence``; a malformed carrier stays a Presence one and narrows to nothing. + carrier = ({PRESENCE_BINDING_AUTHORITY_KEY: fields[PRESENCE_BINDING_AUTHORITY_KEY]} + if PRESENCE_BINDING_AUTHORITY_KEY in fields else {}) + binding_authority = presence_binding_authority_metadata(carrier, task_contract=task_contract) task: Dict[str, Any] = { "id": tid, "type": "task", @@ -118,6 +124,7 @@ def build_scheduled_task_payload(fields: Dict[str, Any]) -> Dict[str, Any]: **directory_options, "root_cost_ceiling_usd": root_cost_ceiling_usd, **origin_metadata, + **binding_authority, }, } if not drive_root: diff --git a/supervisor/worker_promotion.py b/supervisor/worker_promotion.py index 9e3d73704..cc5bb63c1 100644 --- a/supervisor/worker_promotion.py +++ b/supervisor/worker_promotion.py @@ -183,6 +183,13 @@ def _promoted_force_plan_metadata(evt: dict) -> dict: return {"metadata": {"force_plan": True, "force_plan_source": source}} +def _presence_promotion(evt: dict) -> bool: + """A promote carrying Presence authority: a speaker's, or a delegated descendant's binding.""" + from ouroboros.dialogue_provenance import presence_root_carrier + + return bool(presence_root_carrier(evt, task_contract=evt.get("task_contract"))) + + def _promote_project_scope(evt: dict) -> str: """The project an admitted promote lands in: the explicit one the event carries, else the project the OWNER MESSAGE it came from already has. @@ -199,7 +206,7 @@ def _promote_project_scope(evt: dict) -> str: cannot choose a Project. Fail-open: an unreadable store leaves the scope exactly as the event stated it.""" explicit = str(evt.get("project_id") or "") - if explicit or evt.get("presence") or not isinstance(evt.get("source_ref"), dict): + if explicit or _presence_promotion(evt) or not isinstance(evt.get("source_ref"), dict): return explicit try: from ouroboros.projects_registry import origin_claim_lock, project_id_for_origin @@ -427,7 +434,7 @@ def promote_chat_to_task(evt: dict, ctx: Any) -> dict: # assignment below turns that answer into the event's stated scope. implicit_scope = ( not str(evt.get("project_id") or "") - and not evt.get("presence") + and not _presence_promotion(evt) and isinstance(evt.get("source_ref"), dict) ) effective_pid = evt["project_id"] = _promote_project_scope(evt) @@ -666,7 +673,7 @@ def _admit_promoted_workspace(evt: dict, ctx: Any, task: dict, *, pid: str, tid: workspace_repair_hint, ) - if task.get("_presence_origin"): + if _presence_promotion(evt): # Keep the admitted folder from the inherited contract, never a public # event's replacement. Presence retains its canonical shared memory. workspace = task["task_contract"].get("workspace") or {} diff --git a/supervisor/workers.py b/supervisor/workers.py index 87a76133b..e301e6135 100644 --- a/supervisor/workers.py +++ b/supervisor/workers.py @@ -554,15 +554,22 @@ def _reject_promoted_after_attachment_stage( def _apply_presence_promotion_authority( evt: dict, task: dict, *, objective: str, expected_output: str, ) -> list[dict] | dict: - """Preserve inherited Presence authority while rebinding the new root.""" + """Preserve inherited Presence authority while rebinding the new root. + + A speaker's promote makes the root answer its conversation; a delegated + descendant's carries only the binding it acts for, so its root is that + binding's related work under the same ceiling and never a speaker. + """ + from ouroboros.dialogue_provenance import presence_root_carrier - presence = evt.get("presence") if isinstance(evt.get("presence"), dict) else None - if not presence: - return [] - task["_presence_origin"] = True - task["source"] = "presence_promote" - task.setdefault("metadata", {})["presence"] = dict(presence) contract = evt.get("task_contract") if isinstance(evt.get("task_contract"), dict) else {} + carrier = presence_root_carrier(evt, task_contract=contract) + if not carrier: + return [] + if "presence" in carrier: + task["_presence_origin"] = True + task["source"] = "presence_promote" + task.setdefault("metadata", {}).update(carrier) inherited_manifest = [ dict(row) for row in (contract.get("attachment_manifest") or []) if isinstance(row, dict) @@ -573,6 +580,9 @@ def _apply_presence_promotion_authority( "objective": objective, "expected_output": expected_output, "attachment_manifest": [], + # The new root owns its objective: a delegated promoter's claims are not its premise. + "acceptance_claims": [], + "success_criteria": [], }) promoted_contract.pop("lineage", None) promoted_contract.pop("attachment_manifest_ref", None) diff --git a/tests/test_consolidation_honesty.py b/tests/test_consolidation_honesty.py index 966e331f4..e19ca65fe 100644 --- a/tests/test_consolidation_honesty.py +++ b/tests/test_consolidation_honesty.py @@ -198,18 +198,39 @@ def test_partial_publication_records_the_batch_receipt_in_meta(tmp_path, fit, mo lambda *_a, **_k: [{"topic": "people/alex", "ok": False, "reason": "revision_conflict"}]) ctx = ToolContext(repo_dir=tmp_path, drive_root=tmp_path, task_id="partial") c.consolidate(chat, blocks, meta, _Nominating(), knowledge_context=ctx) - receipt = json.loads(meta.read_text())["last_unpublished_nominations"] - assert receipt["failed"] == 1 and receipt["total"] == 1 and receipt["entry_id"] + pending = json.loads(meta.read_text())["pending_knowledge_nominations"] + assert len(pending) == 1 + assert pending[0]["topic"] == "people/alex" and pending[0]["reason"] == "revision_conflict" + assert pending[0]["id"].endswith(":0:0") -def test_a_fully_published_batch_clears_the_receipt(tmp_path, fit): +def test_new_success_does_not_erase_an_old_failed_entry_or_legacy_receipt(tmp_path, fit, monkeypatch): chat, blocks, meta = _paths(tmp_path) _write_chat(chat, count=100, text_size=0) meta.parent.mkdir(parents=True, exist_ok=True) - c.atomic_write_json(meta, {"last_unpublished_nominations": {"entry_id": "old", "failed": 3, "total": 4}}) + legacy = {"entry_id": "old", "failed": 3, "total": 4} + c.atomic_write_json(meta, {"last_unpublished_nominations": legacy}) ctx = ToolContext(repo_dir=tmp_path, drive_root=tmp_path, task_id="clean") + original = c._write_knowledge_entries + calls = 0 + + def fail_once(*args, **kwargs): + nonlocal calls + calls += 1 + if calls == 1: + return [{"topic": "people/alex", "scope": "global", "ok": False, + "reason": "revision_conflict"}] + return original(*args, **kwargs) + + monkeypatch.setattr(c, "_write_knowledge_entries", fail_once) c.consolidate(chat, blocks, meta, _Nominating(), knowledge_context=ctx) - assert "last_unpublished_nominations" not in json.loads(meta.read_text()) + older = json.loads(meta.read_text())["pending_knowledge_nominations"][0] + _write_chat(chat, count=200, text_size=0) + c.consolidate(chat, blocks, meta, _Nominating(), knowledge_context=ctx) + saved = json.loads(meta.read_text()) + assert saved["last_unpublished_nominations"] == legacy + assert saved["pending_knowledge_nominations"] == [older] + assert calls == 2 def test_a_run_without_nominations_leaves_the_receipt_alone(tmp_path, fit): @@ -235,8 +256,9 @@ def test_the_receipt_survives_era_compression(tmp_path, fit, monkeypatch): saved_blocks = json.loads(blocks.read_text()) assert saved_blocks[0]["type"] == "era" assert "knowledge_writes" not in saved_blocks[0] - receipt = json.loads(meta.read_text())["last_unpublished_nominations"] - assert receipt["failed"] == 11 and receipt["total"] == 11 + pending = json.loads(meta.read_text())["pending_knowledge_nominations"] + assert len(pending) == 11 + assert len({row["id"] for row in pending}) == 11 # --- the Health block is where stale memory becomes visible ----------------------- @@ -297,3 +319,99 @@ def test_unreadable_receipts_do_not_raise_or_shout(tmp_path, payload): env = _health_env(tmp_path) c.atomic_write_json(tmp_path / "memory" / "dialogue_meta.json", payload) assert not any("DIALOGUE" in line for line in context_health._memory_health_lines(env)) + + +def test_invalid_legacy_receipt_does_not_impersonate_unreadable_meta(tmp_path): + env = _health_env(tmp_path) + c.atomic_write_json(tmp_path / "memory" / "dialogue_meta.json", + {"last_unpublished_nominations": {"failed": "many", "total": 2}}) + lines = context_health._memory_health_lines(env) + assert any("LEGACY NOMINATION RECEIPT INVALID" in line for line in lines) + assert not any("DIALOGUE META UNREADABLE" in line for line in lines) + + +def test_pending_receipt_precedes_the_note_writer_and_cannot_be_replaced_by_corrupt_meta(tmp_path, fit, monkeypatch): + chat, blocks, meta = _paths(tmp_path) + _write_chat(chat, count=100, text_size=0) + ctx = ToolContext(repo_dir=tmp_path, drive_root=tmp_path, task_id="interrupted") + + def interrupted(*_args, **_kwargs): + saved = json.loads(meta.read_text()) + assert len(saved["pending_knowledge_nominations"]) == 1 + assert saved["pending_knowledge_nominations"][0]["reason"] == "publication_pending" + raise RuntimeError("simulated stop after pending publication") + + monkeypatch.setattr(c, "_write_knowledge_entries", interrupted) + with pytest.raises(RuntimeError, match="simulated stop"): + c.consolidate(chat, blocks, meta, _Nominating(), knowledge_context=ctx) + saved = json.loads(meta.read_text()) + assert saved["pending_knowledge_nominations"][0]["reason"] == "publication_pending" + assert saved.get("last_consolidated_offset", 0) == 0 + + +def test_health_projects_three_owed_addresses_and_omission_count(tmp_path): + env = _health_env(tmp_path) + rows = [{"id": f"source{i}:0:0", "scope": "global", "topic": f"people/{i}", + "reason": "revision_conflict"} for i in range(5)] + c.atomic_write_json(tmp_path / "memory" / "dialogue_meta.json", + {"pending_knowledge_nominations": rows}) + lines = context_health._memory_health_lines(env) + row = next(line for line in lines if "KNOWLEDGE PUBLICATION OPEN" in line) + assert "5 source-addressed" in row and "first 3" in row and "omitted 2" in row + assert "source0" in row and "source2" in row and "source3" not in row + assert "memory/knowledge_history.jsonl" in row + + +def test_malformed_nomination_keeps_its_position_and_cannot_retire_another_entry(tmp_path): + from ouroboros.memory_nomination_receipts import prepare, settle + + meta = {} + ids = prepare(meta, "source", [({}, [None, {"topic": "people/alex", "content": "Valid"}])]) + outcomes = c._write_knowledge_entries(tmp_path / "memory" / "knowledge", [None, + {"topic": "people/alex", "content": "Valid"}]) + assert len(outcomes) == 2 and outcomes[0]["reason"] == "malformed_nomination" + assert outcomes[1]["ok"] + settle(meta, ids, outcomes) + assert [row["id"] for row in meta["pending_knowledge_nominations"]] == ["source:0:0"] + + +def test_corrupt_obligation_index_refuses_replacement(tmp_path, fit): + chat, blocks, meta = _paths(tmp_path) + _write_chat(chat, count=100, text_size=0) + meta.parent.mkdir(parents=True, exist_ok=True) + c.atomic_write_json(meta, {"pending_knowledge_nominations": {"not": "a list"}}) + ctx = ToolContext(repo_dir=tmp_path, drive_root=tmp_path, task_id="corrupt") + with pytest.raises(ValueError, match="refusing to replace"): + c.consolidate(chat, blocks, meta, _Nominating(), knowledge_context=ctx) + assert not blocks.exists() + assert json.loads(meta.read_text())["pending_knowledge_nominations"] == {"not": "a list"} + + +@pytest.mark.parametrize("bad_bytes", [b'{"pending_knowledge_nominations":[{"id":"old"}]', + b'["wrong top-level type"]', + b'{"pending_knowledge_nominations":[],"pending_knowledge_nominations":[]}']) +def test_unreadable_existing_meta_cannot_erase_obligations(tmp_path, fit, bad_bytes): + chat, blocks, meta = _paths(tmp_path) + _write_chat(chat, count=100, text_size=0) + meta.parent.mkdir(parents=True, exist_ok=True) + meta.write_bytes(bad_bytes) + ctx = ToolContext(repo_dir=tmp_path, drive_root=tmp_path, task_id="corrupt") + with pytest.raises(ValueError): + c.should_consolidate(meta, chat) + with pytest.raises(ValueError): + c.consolidate(chat, blocks, meta, _Nominating(), knowledge_context=ctx) + assert meta.read_bytes() == bad_bytes + assert not blocks.exists() + assert any("DIALOGUE META UNREADABLE" in line for line in + context_health._memory_health_lines(_health_env(tmp_path))) + + +def test_pending_health_disambiguates_two_entries_from_one_source(tmp_path): + env = _health_env(tmp_path) + c.atomic_write_json(tmp_path / "memory" / "dialogue_meta.json", { + "pending_knowledge_nominations": [ + {"id": "a" * 64 + f":{index}:0", "reason": "revision_conflict"} + for index in (0, 1)]}) + row = next(line for line in context_health._memory_health_lines(env) + if "KNOWLEDGE PUBLICATION OPEN" in line) + assert "aaaaaaaaaaaa:0:0" in row and "aaaaaaaaaaaa:1:0" in row diff --git a/tests/test_context_memory_preparation.py b/tests/test_context_memory_preparation.py index 3d7e69a15..27519fdf8 100644 --- a/tests/test_context_memory_preparation.py +++ b/tests/test_context_memory_preparation.py @@ -2,6 +2,8 @@ import json +import pytest + from ouroboros import context from ouroboros.context_fit import estimate_context_prompt_tokens from ouroboros.tools.registry import ToolContext @@ -50,6 +52,30 @@ def test_actual_nano_preparation_consolidates_complete_source_before_returning(t assert len(actor.calls) == calls +@pytest.mark.parametrize("broken", [b"{bad", b"[]", b'{"pending_knowledge_nominations":[],"pending_knowledge_nominations":[]}']) +def test_nano_unreadable_dialogue_meta_withholds_maintenance_not_main(tmp_path, fit, monkeypatch, broken): + env, memory, task, ctx, chat_before = _setup(tmp_path) + meta = env.drive_root / "memory/dialogue_meta.json" + meta.write_bytes(broken) + monkeypatch.setattr(context, "get_context_mode", lambda: "nano") + actor = SourceReader(env.drive_root, fit.window) + messages, info = context.build_llm_messages( + env, memory, task, ctx=ctx, llm=actor, tool_schemas=[], + fit_candidate=lambda _messages, _tools: {"accepted": False}, + ) + receipt = info["context_memory_maintenance"] + assert receipt["status"] == "no_progress" + assert receipt["usage"]["_consolidation_errors"][0]["kind"] == "dialogue_meta_unreadable" + assert messages[-1]["content"] == task["text"] + assert meta.read_bytes() == broken + assert (env.drive_root / "logs/chat.jsonl").read_text() == chat_before + assert not actor.calls + events = [json.loads(line) for line in (env.drive_root / "logs/events.jsonl").read_text().splitlines()] + assert any(row.get("type") == "context_memory_maintenance" and + row["usage"]["_consolidation_errors"][0]["kind"] == "dialogue_meta_unreadable" + for row in events) + + def test_max_and_pure_preview_never_start_a_maintenance_model(tmp_path, fit, monkeypatch): env, memory, task, ctx, _raw = _setup(tmp_path) actor = SourceReader(env.drive_root, 50000) diff --git a/tests/test_knowledge_consolidation.py b/tests/test_knowledge_consolidation.py index 36de700ea..12165ca14 100644 --- a/tests/test_knowledge_consolidation.py +++ b/tests/test_knowledge_consolidation.py @@ -229,5 +229,7 @@ def test_era_compression_cannot_erase_unpublished_knowledge_proposals(tmp_path, # The era object carries no knowledge_writes, so the batch receipt lives in meta: # without it the incomplete publication would vanish from every resident surface. assert "knowledge_writes" not in saved[0] - receipt = json.loads(meta.read_text())["last_unpublished_nominations"] - assert receipt == {"entry_id": nominations["entry_id"], "failed": 11, "total": 11} + pending = json.loads(meta.read_text())["pending_knowledge_nominations"] + assert len(pending) == 11 + assert all(row["id"].startswith(nominations["entry_id"] + ":") for row in pending) + assert all(row["reason"] == "revision_required" for row in pending) diff --git a/tests/test_memory_journal_compaction.py b/tests/test_memory_journal_compaction.py index c994c3beb..b44738c25 100644 --- a/tests/test_memory_journal_compaction.py +++ b/tests/test_memory_journal_compaction.py @@ -1,223 +1,92 @@ -"""CPL4-C16 pins (owner batch №8, 4A): old journal snapshots go digest-only. +"""Old memory-journal snapshots remain readable after every maintenance pass. -Fresh entries keep their full old/new text; entries older than GC retention -keep only sha256 + length and gain ``content_digested``. Unparseable lines and -rows without a readable ``ts`` survive byte-identical; the consciousness -observation inbox is out of scope. - -Audit #15-11 corrective lane: this compactor is the one sweep that destroys -CONTENT, so it also pins that a stored digest is verified before the text it -describes is deleted, that the rewrite publishes only a source nothing else -touched, and that the journal is never loaded whole. +Previously this startup sweep digested old knowledge, identity and Pattern +Register old/new contents. A digest cannot restore the complete source after +retention; the compatibility entry point is intentionally non-destructive. """ from __future__ import annotations -import hashlib +import inspect import json -import os -import pathlib import pytest -from ouroboros import memory_journal_compaction as mjc from ouroboros.memory_journal_compaction import compact_memory_journal_snapshots -from ouroboros.utils import utc_now_iso -_OLD_TS = "2020-01-01T00:00:00+00:00" +_JOURNALS = ( + "memory/identity_journal.jsonl", + "memory/knowledge_history.jsonl", + "memory/knowledge/patterns_history.jsonl", + "projects/example/knowledge_history.jsonl", + "memory/scratchpad_journal.jsonl", +) -def _journal(tmp_path, rel): - path = tmp_path / rel +@pytest.mark.parametrize("journal", _JOURNALS) +def test_old_journal_bytes_remain_complete_through_repeated_maintenance(tmp_path, journal): + path = tmp_path / journal path.parent.mkdir(parents=True, exist_ok=True) - return path + row = {"ts": "2020-01-01T00:00:00+00:00", "old_content": "old\nwith Unicode Я", + "new_content": "new\nwith Unicode Ё", "old_sha256": "legacy-mismatch"} + original = (json.dumps(row, ensure_ascii=False) + "\n{legacy broken row\n").encode("utf-8") + path.write_bytes(original) + + for _ in range(2): + report = compact_memory_journal_snapshots(tmp_path, retention_days=0, + now=2_000_000_000.0) + assert report["digested"] == report["digest_mismatch"] == {} + assert report["errors"] == [] + assert report["journal_bytes"].get(journal) == (len(original) if journal in + ("memory/identity_journal.jsonl", "memory/knowledge_history.jsonl", + "memory/knowledge/patterns_history.jsonl") else None) + assert path.read_bytes() == original + # Content digested in an older release cannot be restored, but is not deleted either. + assert b"old_content" in path.read_bytes() and b"new_content" in path.read_bytes() -def test_old_rows_digested_fresh_rows_kept_full(tmp_path): - path = _journal(tmp_path, "memory/identity_journal.jsonl") - old_row = { - "ts": _OLD_TS, "old_content": "I was v1", "new_content": "I am v2", - "old_sha256": hashlib.sha256(b"I was v1").hexdigest(), "old_len": 8, - } - fresh_row = {"ts": utc_now_iso(), "old_content": "I am v2", "new_content": "I am v3"} - broken_line = "{not json at all\n" - path.write_text( - json.dumps(old_row) + "\n" + broken_line + json.dumps(fresh_row) + "\n", - encoding="utf-8", - ) - - report = compact_memory_journal_snapshots(tmp_path) - - lines = path.read_text(encoding="utf-8").splitlines() - digested = json.loads(lines[0]) - assert "old_content" not in digested and "new_content" not in digested - assert digested["content_digested"] is True - assert digested["old_sha256"] == hashlib.sha256(b"I was v1").hexdigest() - assert digested["new_sha256"] == hashlib.sha256(b"I am v2").hexdigest() - assert digested["new_len"] == len("I am v2") - assert lines[1] == broken_line.rstrip("\n") # unreadable: byte-identical - kept = json.loads(lines[2]) - assert kept["old_content"] == "I am v2" and kept["new_content"] == "I am v3" - assert report["digested"] == {"memory/identity_journal.jsonl": 1} - assert not report["digest_mismatch"] and not report["errors"] +def test_maintenance_does_not_create_missing_journals_or_directories(tmp_path): + root = tmp_path / "absent" + compact_memory_journal_snapshots(root) + assert not root.exists() -@pytest.mark.parametrize("false_fact", [ - {"old_sha256": "pinned-old-hash"}, - {"old_len": 999}, -]) -def test_a_false_stored_digest_never_costs_the_text(tmp_path, false_fact): - """Audit #15-11: the compactor used ``setdefault``, so a stored digest that - contradicted its own text was KEPT while the only correct copy of the - content was deleted — the lie became the whole record. The pre-fix pin in - this file asserted exactly that behavior (``old_sha256 == "pinned-old-hash"`` - survives the deletion of ``old_content``); it was cementing the defect and - is reshaped above to a truthful stored digest. +def test_startup_prune_still_reaches_compatibility_entry_point(): + import ouroboros.server_maintenance as maintenance - A row whose stored fact does not match its text now keeps its FULL content - and is reported as a typed fact.""" - path = _journal(tmp_path, "memory/identity_journal.jsonl") - row = {"ts": _OLD_TS, "old_content": "I was v1", "new_content": "I am v2", **false_fact} - original = json.dumps(row) + "\n" - path.write_text(original, encoding="utf-8") - - report = compact_memory_journal_snapshots(tmp_path) - - assert path.read_text(encoding="utf-8") == original # byte-identical - assert not report["digested"] - assert report["digest_mismatch"] == {"memory/identity_journal.jsonl": 1} + assert "compact_memory_journal_snapshots" in inspect.getsource(maintenance._startup_prune_sweeps) -def test_a_concurrent_append_is_never_dropped_by_the_rewrite(tmp_path): - """``append_jsonl`` appends WITHOUT the sidecar lock once its own - acquisition times out, so a row can land mid-rewrite. Whether this pass - carries it over or abandons the rewrite, the row must survive.""" - path = _journal(tmp_path, "memory/knowledge_history.jsonl") - old_row = {"ts": _OLD_TS, "old_content": "a", "new_content": "b"} - path.write_text(json.dumps(old_row) + "\n", encoding="utf-8") - racing = {"ts": utc_now_iso(), "old_content": "b", "new_content": "c"} - real_digest_line = mjc._digest_line - fired = {"done": False} - - def racing_digest_line(raw, cutoff): - result = real_digest_line(raw, cutoff) - if not fired["done"]: - fired["done"] = True - with path.open("ab") as unlocked_appender: - unlocked_appender.write(json.dumps(racing).encode("utf-8") + b"\n") - return result - - mjc._digest_line = racing_digest_line +def test_size_observation_does_not_follow_a_journal_symlink(tmp_path): + target = tmp_path / "elsewhere" + target.write_bytes(b"secret data") + link = tmp_path / "memory" / "knowledge_history.jsonl" + link.parent.mkdir() try: - compact_memory_journal_snapshots(tmp_path) - finally: - mjc._digest_line = real_digest_line - - rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] - assert len(rows) == 2 - assert rows[1] == racing # the concurrent row is intact whichever branch ran - - -@pytest.mark.skipif(os.name == "nt", reason="the reader holds the journal open; Windows refuses to unlink it (no FILE_SHARE_DELETE)") -def test_a_replaced_source_aborts_the_publish(tmp_path): - """Identity, not just size: if the journal is swapped for a different file - under the rewrite, the finished temp must be dropped, not published over - whatever now lives there.""" - path = _journal(tmp_path, "memory/knowledge/patterns_history.jsonl") - path.write_text(json.dumps({"ts": _OLD_TS, "old_content": "a", "new_content": "b"}) + "\n", - encoding="utf-8") - replacement = json.dumps({"ts": _OLD_TS, "topic": "someone else's file"}) + "\n" - real_digest_line = mjc._digest_line - fired = {"done": False} - - def swapping_digest_line(raw, cutoff): - result = real_digest_line(raw, cutoff) - if not fired["done"]: - fired["done"] = True - path.unlink() - path.write_text(replacement, encoding="utf-8") - return result - - mjc._digest_line = swapping_digest_line - try: - report = compact_memory_journal_snapshots(tmp_path) - finally: - mjc._digest_line = real_digest_line - - assert path.read_text(encoding="utf-8") == replacement - assert not report["digested"] - assert report["errors"] == [ - {"journal": "memory/knowledge/patterns_history.jsonl", "error": "source_changed"}, - ] - assert not list(path.parent.glob("*.compact.tmp")) - - -def test_the_journal_is_never_loaded_whole(tmp_path, monkeypatch): - """Bounded/streaming (audit #15-11c): these journals are the worst byte - offenders in the memory plane; a whole-file read is the thing being fixed. - Poison the whole-file readers and the compaction must still work.""" - path = _journal(tmp_path, "memory/identity_journal.jsonl") - rows = [{"ts": _OLD_TS, "old_content": f"o{i}", "new_content": f"n{i}"} for i in range(50)] - path.write_text("".join(json.dumps(row) + "\n" for row in rows), encoding="utf-8") - - def _boom(self, *args, **kwargs): - raise AssertionError(f"whole-file read of {self}") - - monkeypatch.setattr(pathlib.Path, "read_bytes", _boom) + link.symlink_to(target) + except (OSError, NotImplementedError): + pytest.skip("symlink creation unavailable") report = compact_memory_journal_snapshots(tmp_path) - - assert report["digested"] == {"memory/identity_journal.jsonl": 50} + assert report["journal_bytes"]["memory/knowledge_history.jsonl"] is None + assert "memory/knowledge_history.jsonl: not_regular" in report["errors"] + assert target.read_bytes() == b"secret data" -def test_the_rewrite_takes_an_unstealable_lock(): - """Owner-aware stale: elapsed time alone must never hand a second writer - the journal this destructive rewrite is holding.""" - import inspect - - src = inspect.getsource(mjc._compact_one) - assert "owner_aware_stale=True" in src - - -def test_patterns_history_gains_derived_digests(tmp_path): - path = _journal(tmp_path, "memory/knowledge/patterns_history.jsonl") - path.write_text(json.dumps({ - "ts": _OLD_TS, "task_id": "t", "markers": ["m"], - "old_content": "old body", "new_content": "new body\n", - }) + "\n", encoding="utf-8") - - compact_memory_journal_snapshots(tmp_path) - - row = json.loads(path.read_text(encoding="utf-8")) - assert row["old_sha256"] == hashlib.sha256(b"old body").hexdigest() - assert row["new_len"] == len("new body\n") - assert "old_content" not in row and row["content_digested"] is True - - -def test_row_without_readable_ts_keeps_full_text(tmp_path): - path = _journal(tmp_path, "memory/knowledge_history.jsonl") - original = json.dumps({"topic": "x", "old_content": "a", "new_content": "b"}) + "\n" - path.write_text(original, encoding="utf-8") +def test_startup_event_publishes_normal_journal_sizes(tmp_path, monkeypatch): + import ouroboros.server_maintenance as maintenance + import supervisor.state as state + journal = tmp_path / "memory" / "knowledge_history.jsonl" + journal.parent.mkdir() + journal.write_bytes(b"full historical text\n") + rows = [] + monkeypatch.setattr(maintenance, "DATA_DIR", tmp_path) + monkeypatch.setattr(state, "append_jsonl", lambda _path, row: rows.append(row)) report = compact_memory_journal_snapshots(tmp_path) - - assert path.read_text(encoding="utf-8") == original - assert not report["digested"] and not report["errors"] - - -def test_observation_inbox_is_out_of_scope(tmp_path): - inbox = tmp_path / "state" / "consciousness_observations.jsonl" - inbox.parent.mkdir(parents=True) - original = json.dumps({"ts": _OLD_TS, "op": "enqueue", "payload": "keep me"}) + "\n" - inbox.write_text(original, encoding="utf-8") - - compact_memory_journal_snapshots(tmp_path) - - assert inbox.read_text(encoding="utf-8") == original - - -def test_startup_prune_sweeps_run_the_compaction(): - import inspect - - import ouroboros.server_maintenance as sm - - assert "compact_memory_journal_snapshots" in inspect.getsource(sm._startup_prune_sweeps) + maintenance._prune_event("memory_journal_observation", ("journal_bytes", "errors"), report=report) + assert len(rows) == 1 + assert rows[0]["report"]["journal_bytes"]["memory/knowledge_history.jsonl"] == len(b"full historical text\n") + assert journal.read_bytes() == b"full historical text\n" + # Pin the real startup caller, not only this unit invocation. + source = inspect.getsource(maintenance._startup_prune_sweeps) + assert '_prune_event("memory_journal_observation", ("journal_bytes", "errors")' in source diff --git a/tests/test_persistence_inventory.py b/tests/test_persistence_inventory.py index 2c65cf0b5..02c6700f0 100644 --- a/tests/test_persistence_inventory.py +++ b/tests/test_persistence_inventory.py @@ -571,7 +571,10 @@ def scan_data_paths(root: pathlib.Path = REPO) -> frozenset[str]: # one rebuildable projection per conversation written by presence_runner at the end of an executed # turn; it has its own row in section 2. # 293 -> 295: the disposable test-environment caches (``cache/pip``, ``cache/uv``; test root only). -EXPECTED_SCAN_PATHS = 295 +# 295 -> 294: TZ-3 removed the destructive memory journal rewrite and its +# ``.compact.tmp`` sibling path; PERSISTENCE.md keeps the journals, now +# read-only observed and never age-digested. +EXPECTED_SCAN_PATHS = 294 # Scanned paths that must always be present — guards the scanner itself # against a silent regression that would shrink coverage while keeping counts diff --git a/tests/test_presence_admission.py b/tests/test_presence_admission.py index 3e4d2f8f6..7984b590b 100644 --- a/tests/test_presence_admission.py +++ b/tests/test_presence_admission.py @@ -135,12 +135,15 @@ def test_admission_freezes_reviewed_behavior_runtime_digests_and_authority(tmp_p assert admission.destination == binding.destination assert admission.capability_ceiling.skill_name == "community-helper" # The reviewed profile selected chat_history; the rest is the constant - # cognitive baseline every admitted conversation carries. + # cognitive baseline plus the own-work baseline every new ceiling carries. assert [grant.name for grant in admission.capability_ceiling.tool_grants] == [ "chat_history", + "get_task_result", "knowledge_list", "knowledge_read", "knowledge_write", + "recent_tasks", + "steer_task", "update_identity", "update_scratchpad", ] diff --git a/tests/test_presence_authority.py b/tests/test_presence_authority.py index 2afd40ca1..e0b4548c2 100644 --- a/tests/test_presence_authority.py +++ b/tests/test_presence_authority.py @@ -59,16 +59,27 @@ def test_ceiling_compiles_exact_tools_scripts_resources_and_digest(): ) # The profile selected chat_history and one script; the cognitive baseline - # (own memory, no new authority) is compiled in beside them. + # (own memory, no new authority) and the own-work baseline (this binding's + # readers, host-bound to its scope, and steer_task) are compiled in beside them. assert [grant.name for grant in ceiling.tool_grants] == [ "chat_history", + "get_task_result", "knowledge_list", "knowledge_read", "knowledge_write", + "recent_tasks", "skill_exec", + "steer_task", "update_identity", "update_scratchpad", ] + scoped = {grant.name: [(item.argument_path, item.static_value) for item in grant.bindings] + for grant in ceiling.tool_grants if grant.name in {"get_task_result", "recent_tasks", "steer_task"}} + assert scoped == { + "get_task_result": [(("presence_scope",), "own_binding")], + "recent_tasks": [(("presence_scope",), "own_binding")], + "steer_task": [], + } script = next(grant for grant in ceiling.tool_grants if grant.name == "skill_exec") assert [(item.argument_path, item.static_value) for item in script.bindings] == [ (("skill",), "calendar"), @@ -230,9 +241,12 @@ def test_registry_filters_schema_dispatch_and_resolved_targets(tmp_path): "presence_cancel_work", "read_file", "chat_history", + "get_task_result", "knowledge_list", "knowledge_read", "knowledge_write", + "recent_tasks", + "steer_task", "update_identity", "update_scratchpad", } diff --git a/tests/test_presence_cognitive_baseline.py b/tests/test_presence_cognitive_baseline.py index da5ad531a..4f973528a 100644 --- a/tests/test_presence_cognitive_baseline.py +++ b/tests/test_presence_cognitive_baseline.py @@ -54,11 +54,14 @@ def _ceiling(*selections): ) +_OWN_WORK = {"get_task_result", "recent_tasks", "steer_task"} + + def test_profile_without_tool_selections_still_carries_its_own_memory(): ceiling = _ceiling() - assert [grant.name for grant in ceiling.tool_grants] == sorted(COGNITIVE_MEMORY_TOOL_NAMES) - assert all(grant.bindings == () for grant in ceiling.tool_grants) + assert [grant.name for grant in ceiling.tool_grants] == sorted(COGNITIVE_MEMORY_TOOL_NAMES | _OWN_WORK) + assert all(grant.bindings == () for grant in ceiling.tool_grants if grant.name in COGNITIVE_MEMORY_TOOL_NAMES) for name in COGNITIVE_MEMORY_TOOL_NAMES: assert presence_ceiling_allows_tool(ceiling, name) @@ -84,7 +87,7 @@ def test_a_selected_baseline_tool_keeps_the_profile_authored_bindings(): assert [(item.argument_path, item.static_value) for item in grant.bindings] == [ (("scope",), "global"), ] - assert [item.name for item in ceiling.tool_grants] == sorted(COGNITIVE_MEMORY_TOOL_NAMES) + assert [item.name for item in ceiling.tool_grants] == sorted(COGNITIVE_MEMORY_TOOL_NAMES | _OWN_WORK) ctx = ToolContext( repo_dir=None, @@ -137,7 +140,7 @@ def test_admitted_external_turn_writes_global_knowledge_and_nothing_else(tmp_pat _select_history(data, skill_dir) admission = _admit(data, _binding(data)) assert [grant.name for grant in admission.capability_ceiling.tool_grants] == sorted( - COGNITIVE_MEMORY_TOOL_NAMES + COGNITIVE_MEMORY_TOOL_NAMES | _OWN_WORK ) seen: dict[str, object] = {} diff --git a/tests/test_presence_completion.py b/tests/test_presence_completion.py index fa6a67f72..334483b2f 100644 --- a/tests/test_presence_completion.py +++ b/tests/test_presence_completion.py @@ -210,18 +210,35 @@ def test_ordinary_empty_and_failed_silent_outcomes_remain_failed(): assert outcome["outcome_axes"]["execution"]["status"] in {"failed", "infra_failed"} -def test_pending_children_still_require_absorption(turn, tmp_path): +_DECLARED = json.dumps({"delivery_control": "replace", "full_answer": "Best available; child1 still running", + "presence_finish": {"outcome": "message", "message": "Here is what I have so far."}}) + + +@pytest.mark.parametrize("forced,outcome,spoken", [ + # Owner Q4: the forced answer is the internal record; undeclared prose never becomes speech. + ("Best available; child1 still running", "silent", ""), + (_DECLARED, "message", "Here is what I have so far."), +]) +def test_pending_children_still_require_absorption(turn, tmp_path, forced, outcome, spoken): from ouroboros.task_results import write_task_result, STATUS_RUNNING _registry, calls, run = turn + # A real turn carries its Presence metadata; the ceiling alone does not arm the protocol. + _registry._ctx.task_metadata = {"presence": {"binding_id": "a" * 32, "event": {"conversation_key": "k"}}} write_task_result(tmp_path, "child1", STATUS_RUNNING, parent_task_id="parent1", root_task_id="parent1", delegation_role="subagent", role="reviewer", result="Still running") text, usage, _trace = run([ _call("silent", "Premature"), {"content": '{"delivery_control":"keep"}'}, - {"content": "Best available"}, {"content": "Best available"}, + {"content": "Best available"}, {"content": forced}, ]) assert len(calls) > 1 assert usage["reason_code"] == "children_unabsorbed" assert "presence_completion_outcome" not in usage - assert build_presence_result_event({"id": "parent1"}, text, _registry._ctx, terminal_origin=usage.get("terminal_origin", ""))["outcome"] == "message" + assert text == "Best available; child1 still running" # the internal record keeps the child facts + assert "[PRESENCE_DELIVERY]" in str(calls[-1][-1]["content"]) + assert "name the unabsorbed" not in str(calls[-1][-1]["content"]) + task = {"id": "parent1"} + result = build_presence_result_event(task, text, _registry._ctx, terminal_origin=usage.get("terminal_origin", "")) + assert (result["outcome"], result["text"]) == (outcome, spoken) + assert task["metadata"]["presence_declaration"]["status"] == ("declared" if spoken else "missing") diff --git a/tests/test_presence_forced_delivery.py b/tests/test_presence_forced_delivery.py new file mode 100644 index 000000000..32e6d409f --- /dev/null +++ b/tests/test_presence_forced_delivery.py @@ -0,0 +1,437 @@ +"""A Presence forced final keeps its internal record apart from what the conversation receives. + +Owner Q4: host diagnostics are not sent automatically. The model may choose what +to say, including relevant limitations. The ONE forced model call declares its +outward delivery beside the internal record; without a valid declaration nothing +new is spoken, and a declared useful reply is spoken even when the run itself +failed. Deterministic fake-model replay; no transport sends anything. +""" + +from __future__ import annotations + +import json +import queue +from types import SimpleNamespace + +import pytest +from starlette.testclient import TestClient + +from ouroboros import agent_task_pipeline as pipeline, loop +from ouroboros.gateway.host_service import create_host_service_app +from ouroboros.presence_authority import presence_ceiling_payload +from ouroboros.presence_runner import _cached_result +from ouroboros.task_results import load_task_result +from ouroboros.tools.registry import ToolRegistry +from ouroboros.utils import append_jsonl +from tests.test_host_service_api import _seed_presence_behavior, _seed_token +from tests.test_presence_completion import _call +from tests.test_presence_runner import _admission + +KEY = "telegram:bot-1:room-1:0" +RECORD = "Internal record: helper child-7 failed with provider 400; review not run; figures verified for Q1 only." + + +def _presence(binding="a" * 32, version=1): + return {"binding_id": binding, "delivery_reporting_version": version, + "event": {"conversation_key": KEY, "conversation_id": "room-1"}} + + +def _forced(outcome=None, message=None, **extra): + body = {"delivery_control": "replace", "full_answer": RECORD, **extra} + if outcome is not None: + body["presence_finish"] = {"outcome": outcome, **({"message": message} if message is not None else {})} + return json.dumps(body) + + +def _read(): + return {"role": "assistant", "content": None, "tool_calls": [{ + "id": "read", "type": "function", "function": {"name": "chat_history", "arguments": "{}"}, + }]} + + +def _run(root, monkeypatch, forced, *, task=None, presence=True, handoff=None, first=None, ceiling=None, + metadata=None, traces=None, rounds=1): + """Round limit after one tool round, then the ONE forced call; real pipeline after it.""" + monkeypatch.setenv("OUROBOROS_TASK_REVIEW_MODE", "off") + monkeypatch.setenv("OUROBOROS_MAX_ROUNDS", str(rounds)) + registry = ToolRegistry(repo_dir=root, drive_root=root) + ctx = registry._ctx + ctx.is_direct_chat = True + ctx.task_metadata = {"inline_max_rounds": rounds, **({"presence": _presence()} if presence else {}), + **(metadata or {})} + if presence if ceiling is None else ceiling: + ctx.task_contract = {"capability_ceiling": presence_ceiling_payload(_admission().capability_ceiling)} + if handoff: + ctx._swarm_handoff_attempt = handoff + registry.override_handler("chat_history", lambda *_a, **_kw: "Synthetic history") + calls, replies = [], iter([first or _read(), {"content": forced}]) + + def respond(_llm, messages, *_a, **_k): + calls.append([dict(row) for row in messages]) + return next(replies), 0.0 + + monkeypatch.setattr(loop, "call_llm_with_retry", respond) + task = task or {"id": "presence-loop", "type": "presence", "_presence_turn": True, "chat_id": 7, + "text": "Please help", "metadata": {"presence": _presence()}} + task["_skip_post_task_synthesis"] = True + text, usage, trace = loop.run_llm_loop( + [{"role": "user", "content": "Please help"}], registry, + SimpleNamespace(default_model=lambda: "test-model"), root / "logs", + lambda *_a, **_kw: None, queue.Queue(), task_id=task["id"], drive_root=root, + ) + events = [] + pipeline.emit_task_results(SimpleNamespace(drive_root=root, repo_dir=root), None, None, + events, task, text, usage, trace, 0.0, root / "logs", ctx=ctx) + result = next((row for row in events if row["type"] == "presence_result"), None) + if traces is not None: + traces.append((trace, events)) + return result, load_task_result(root, task["id"]), calls, text + + +@pytest.mark.parametrize("forced,outcome,spoken,status", [ + (_forced("message", "Q1 figures are ready; Q2 is still coming."), "message", + "Q1 figures are ready; Q2 is still coming.", "declared"), + (_forced("message", "The review is delayed; Q1 figures are ready."), "message", + "The review is delayed; Q1 figures are ready.", "declared"), + (_forced("silent", ""), "silent", "", "declared"), + (_forced("tool_delivered", "sent the table via the transport tool"), "tool_delivered", "", "declared"), + (RECORD, "silent", "", "missing"), # untyped internal prose is never speech + (_forced("message", " "), "silent", "", "invalid"), + (_forced("maybe", "hi"), "silent", "", "invalid"), + (_forced("silent", "but also this"), "silent", "", "invalid"), + (_forced("deferred", "on it"), "silent", "", "invalid"), # nothing was scheduled + (json.dumps({"delivery_control": "replace", "full_answer": RECORD, + "presence_finish": {"outcome": "message", "message": "x", "to": "y"}}), "silent", "", "invalid"), +], ids=["message", "chosen_limitation", "silent", "tool_delivered", "prose", "blank_message", "unknown_outcome", "silent_with_text", + "unscheduled_deferred", "extra_key"]) +def test_forced_final_speaks_only_what_it_declares(tmp_path, monkeypatch, forced, outcome, spoken, status): + result, stored, calls, text = _run(tmp_path, monkeypatch, forced) + + assert len(calls) == 2 # the one forced call; no repair or polishing round + assert "[PRESENCE_DELIVERY]" in str(calls[-1][-1]["content"]) + assert "You decide what, if anything, to say" in str(calls[-1][-1]["content"]) + assert (result["outcome"], result["text"]) == (outcome, spoken) + assert stored["reason_code"] == "round_limit" + assert stored["outcome_axes"]["execution"]["status"] != "ok" # speech is declared, not read off status + assert stored["metadata"]["presence_declaration"]["status"] == status + assert stored["metadata"]["presence_result_text"] == spoken + assert _cached_result(tmp_path, "presence-loop").text == spoken + assert text == RECORD and stored["result"].startswith(RECORD) # the record survives beside it + if outcome == "tool_delivered": + assert result["message"] == "" and result["finish_note"] == "sent the table via the transport tool" + + +def test_a_malformed_control_body_speaks_nothing_and_keeps_the_host_fallback(tmp_path, monkeypatch): + duplicate = ('{"delivery_control": "replace", "full_answer": "%s", "presence_finish": {"outcome": "silent"}, ' + '"presence_finish": {"outcome": "message", "message": "dup"}}' % RECORD) + result, stored, calls, _text = _run(tmp_path, monkeypatch, duplicate) + assert len(calls) == 2 + assert (result["outcome"], result["text"]) == ("silent", "") + assert stored["terminal_origin"] != "model_final" # the host fallback is never spoken + assert stored["metadata"]["presence_declaration"] == {"status": "invalid", + "reason": "the forced envelope repeats a key"} + + +def test_early_acknowledgement_is_shown_and_does_not_suppress_the_result(tmp_path, monkeypatch): + logs = tmp_path / "logs" + append_jsonl(logs / "chat.jsonl", {"task_id": "presence-loop", "direction": "in", "text": "Please help"}) + append_jsonl(logs / "chat.jsonl", { + "task_id": "presence-loop", "type": "presence_delivery", "text": "Looking into it now.", + "transport": {"conversation_key": KEY, "delivery": {"state": "delivered", "delivery_id": "d1", "part_id": "0"}}, + }) + append_jsonl(logs / "chat.jsonl", { + "task_id": "presence-loop", "type": "presence_delivery", "text": "Partial table", + "transport": {"conversation_key": KEY, "delivery": {"state": "uncertain", "delivery_id": "d2", "part_id": "0"}}, + }) + result, _stored, calls, _text = _run(tmp_path, monkeypatch, _forced("message", "Here are the Q1 figures.")) + + prompt = str(calls[-1][-1]["content"]) + assert '"Looking into it now."' in prompt and "1 more part(s) have an uncertain outcome" in prompt + assert "An early acknowledgement is not the promised result" in prompt + assert (result["outcome"], result["text"]) == ("message", "Here are the Q1 figures.") + + +def test_declared_useful_partial_survives_failure_and_owed_child_stays_pollable(tmp_path, monkeypatch): + handoff = {"status": "scheduled", "task_id": "later-work"} + result, stored, calls, _text = _run(tmp_path, monkeypatch, _forced("deferred", "Started the full audit."), + handoff=handoff) + assert "deferred = acknowledge work that was actually scheduled (it was)" in str(calls[-1][-1]["content"]) + assert (result["outcome"], result["text"], result["work_ref"]) == ("deferred", "Started the full audit.", "later-work") + silent, _stored, _calls, _text = _run(tmp_path / "silent", monkeypatch, RECORD, handoff=handoff) + # No declaration: nothing new is said, but the admitted child is still owed. + assert (silent["outcome"], silent["text"], silent["work_ref"]) == ("deferred", "", "later-work") + + +def test_ordinary_forced_final_is_unchanged(tmp_path, monkeypatch): + task = {"id": "owner-task", "type": "task", "chat_id": 1, "text": "Please help"} + _result, stored, calls, text = _run(tmp_path, monkeypatch, RECORD, task=task, presence=False) + assert "[PRESENCE_DELIVERY]" not in str(calls[-1][-1]["content"]) + assert text == RECORD and stored["result"].startswith(RECORD) + assert "presence_declaration" not in (stored.get("metadata") or {}) + + +def test_promoted_work_result_reaches_the_work_endpoint_as_declared(tmp_path, monkeypatch): + _seed_token(tmp_path, skill="telegram-bot", token="presence-token", + permissions=["presence"], manifest_permissions=["presence"]) + binding = _seed_presence_behavior(tmp_path) + task = {"id": "promoted-9", "type": "task", "chat_id": 7, "text": "Compile", "delegation_role": "root", + "root_task_id": "promoted-9", "metadata": {"presence": _presence(binding)}} + _run(tmp_path, monkeypatch, _forced("message", "The figures you asked for: 41 and 43."), task=task) + with TestClient(create_host_service_app(tmp_path)) as client: + body = client.get("/presence/work/promoted-9", params={"binding_id": binding}, + headers={"X-Skill-Token": "presence-token"}).json() + assert (body["outcome"], body["text"]) == ("message", "The figures you asked for: 41 and 43.") + assert RECORD not in json.dumps(body) + + +@pytest.mark.parametrize("feedback", [True, False]) +def test_invalidated_finish_is_named_void_with_this_tasks_confirmed_sends(tmp_path, monkeypatch, feedback): + monkeypatch.setenv("OUROBOROS_TASK_REVIEW_MODE", "off") + registry = ToolRegistry(repo_dir=tmp_path, drive_root=tmp_path) + registry._ctx.task_contract = {"capability_ceiling": presence_ceiling_payload(_admission().capability_ceiling)} + registry._ctx.task_metadata = {"presence": _presence()} + registry._ctx.is_direct_chat = True + logs = tmp_path / "logs" + append_jsonl(logs / "chat.jsonl", {"task_id": "parent1", "direction": "in", "text": "Please help"}) + append_jsonl(logs / "chat.jsonl", { + "task_id": "parent1", "type": "presence_delivery", "text": "The full answer, sent by tool.", + "transport": {"conversation_key": KEY, "delivery": {"state": "delivered", "delivery_id": "d1", "part_id": "0"}}, + }) + reviews, calls = [], [] + + def review(**kwargs): + reviews.append(kwargs["content"]) + if feedback and len(reviews) == 1: # the panel's feedback is new transcript content + kwargs["messages"].append({"role": "user", "content": "[REVIEW] Also confirm the Q2 figures."}) + return len(reviews) == 1 # the first finish is held for another pass + + def respond(_llm, messages, *_a, **_k): + calls.append([dict(row) for row in messages]) + return ([_call("tool_delivered", ""), _call("tool_delivered", "")][len(calls) - 1]), 0.0 + + monkeypatch.setattr(loop, "_run_task_acceptance_review_once", review) + monkeypatch.setattr(loop, "call_llm_with_retry", respond) + _text, usage, _trace = loop.run_llm_loop( + [{"role": "user", "content": "Please help"}], registry, + SimpleNamespace(default_model=lambda: "test-model"), logs, + lambda *_a, **_kw: None, queue.Queue(), task_id="parent1", drive_root=tmp_path, + ) + + assert len(calls) == 2 + notes = [row for row in calls[1] if "[PRESENCE_FINISH_NOT_ACCEPTED]" in str(row.get("content"))] + assert "[PRESENCE_FINISH_NOT_ACCEPTED]" not in json.dumps(calls[0]) + assert usage["presence_completion_outcome"] == "tool_delivered" # the fresh finish is accepted + if not feedback: # nothing new was said: the turn parks as before, with no added reminder + assert notes == [] + return + assert len(notes) == 1 + assert '"The full answer, sent by tool."' in str(notes[0]["content"]) + assert "you decide whether any of their facts matter" in str(notes[0]["content"]) + + +# --- repair pass: arming identity, duplicate evidence, internal notes -------------- + +def test_a_child_inheriting_only_the_ceiling_keeps_its_ordinary_forced_final(tmp_path, monkeypatch): + task = {"id": "child-3", "type": "task", "chat_id": 7, "text": "Check Q1", "delegation_role": "subagent", + "parent_task_id": "promoted-9", "root_task_id": "promoted-9"} + traces = [] + result, stored, calls, text = _run(tmp_path, monkeypatch, RECORD, task=task, presence=False, ceiling=True, + metadata={"delegation_role": "subagent", "parent_task_id": "promoted-9"}, + traces=traces) + assert "[PRESENCE_DELIVERY]" not in str(calls[-1][-1]["content"]) # a child answers its parent + assert result is None and text == RECORD and stored["result"].startswith(RECORD) + assert "presence_declaration" not in (stored.get("metadata") or {}) + assert [event["type"] for event in traces[0][1]].count("presence_result") == 0 + + +def test_a_child_holding_the_inherited_binding_authority_still_answers_only_its_parent(tmp_path, monkeypatch): + authority = {"presence_binding_authority": {"binding_id": "a" * 32}} # it acts for the binding, never speaks + task = {"id": "child-4", "type": "task", "chat_id": 7, "text": "Check Q1", "delegation_role": "subagent", + "parent_task_id": "presence-loop", "root_task_id": "presence-loop", "metadata": dict(authority)} + traces = [] + result, stored, calls, text = _run(tmp_path, monkeypatch, _forced("message", "Hello room"), task=task, + presence=False, ceiling=True, traces=traces, + metadata={"delegation_role": "subagent", **authority}) + assert "[PRESENCE_DELIVERY]" not in str(calls[-1][-1]["content"]) + assert result is None and "presence_declaration" not in (stored.get("metadata") or {}) + assert [event["type"] for event in traces[0][1]].count("presence_result") == 0 + + +def test_duplicate_subject_evidence_survives_the_presence_arm_and_declares_nothing(tmp_path, monkeypatch): + duplicated = ('{"delivery_control": "replace", "full_answer": "%s", "acceptance_subject": ' + '{"owner_source_sha256": "aaa", "owner_source_sha256": "bbb"}, ' + '"presence_finish": {"outcome": "message", "message": "Here are the figures."}}' % RECORD) + traces = [] + result, stored, calls, text = _run(tmp_path, monkeypatch, duplicated, traces=traces) + + assert len(calls) == 2 + # The resolver saw the original bytes: the ambiguous subject is refused as a duplicate, + # not silently collapsed to its last value and judged as a different source. + assert traces[0][0]["forced_acceptance_subject"] == { + "applied": False, "reason": "acceptance_subject requires an exact owner source and optional criteria/tool indices"} + assert (result["outcome"], result["text"]) == ("silent", "") + assert stored["metadata"]["presence_declaration"] == {"status": "invalid", + "reason": "the forced envelope repeats a key"} + assert text == RECORD # the record keeps its ordinary degraded-subject handling + + +def test_a_duplicate_inside_the_declaration_voids_only_the_declaration(tmp_path, monkeypatch): + body = ('{"delivery_control": "replace", "full_answer": "%s", ' + '"presence_finish": {"outcome": "message", "message": "hi", "outcome": "silent"}}' % RECORD) + result, stored, _calls, text = _run(tmp_path, monkeypatch, body) + assert (result["outcome"], result["text"]) == ("silent", "") + assert stored["metadata"]["presence_declaration"]["status"] == "invalid" + assert text == RECORD and stored["terminal_origin"] == "model_final" + + +def test_a_tool_delivered_note_is_never_speech_even_when_owed_work_defers_the_turn(tmp_path, monkeypatch): + note = "sent the table via the transport tool; helper child-7 failed" + handoff = {"status": "scheduled", "task_id": "later-work"} + result, stored, _calls, _text = _run(tmp_path, monkeypatch, _forced("tool_delivered", note), handoff=handoff) + + assert (result["outcome"], result["text"], result["work_ref"]) == ("deferred", "", "later-work") + assert result["message"] == "" and result["finish_note"] == note # context, never speech + assert stored["metadata"]["presence_result_text"] == "" + replay = _cached_result(tmp_path, "presence-loop") + assert (replay.outcome, replay.text, replay.work_ref) == ("deferred", "", "later-work") + assert note not in json.dumps(stored["metadata"]) + + +@pytest.mark.parametrize("prepared_fits,reply,outcome,spoken,declaration", [ + # The priced candidate still fits twice: ordinary work continues and its reply is speech. + ((True, True), "Here are the Q1 figures.", "message", "Here are the Q1 figures.", None), + # The priced candidate confirms the last-fit stop: the committed forced call stays armed. + ((True, False), RECORD, "silent", "", "missing"), +], ids=["repriced_fall_through", "confirmed_stop"]) +def test_a_budget_repricing_arms_presence_only_for_the_forced_call_it_commits( + tmp_path, monkeypatch, prepared_fits, reply, outcome, spoken, declaration): + from ouroboros import task_pacing + from ouroboros.contracts.task_contract import normalize_budget_profile + + ceiling = task_pacing.resolve_cost_ceiling(None, normalize_budget_profile(None), root_cap_usd=50.0) + monkeypatch.setattr(loop, "_resolve_task_cost_ceiling", lambda *_a: ceiling) + monkeypatch.setattr(loop, "_loop_tree_accounting", lambda **_k: {"accounted_usd": 20.0}) + # proxy last-fit -> exact probe last-fit -> the prepared candidate decides + answers = iter((True, False, True, False, *prepared_fits)) + monkeypatch.setattr(task_pacing, "wrapup_reservation_fits", lambda **_k: next(answers, True)) + monkeypatch.setattr(task_pacing, "prospective_wrapup_attempt_request", lambda **_k: object()) + monkeypatch.setattr(task_pacing, "prepared_wrapup_candidate", + lambda _ctx, messages, **_k: (object(), messages)) + prepared, prepare = [], loop._prepare_forced_prompt + monkeypatch.setattr(loop, "_prepare_forced_prompt", lambda *args: prepared.append(1) or prepare(*args)) + post_tool = loop._prepare_post_tool_budget_context + + def measured(tools, limit_ctx, *args): # the fake route records no context-fit measurement + limit_ctx.accumulated_usage["_context_prompt_estimate"] = 4_000 + return post_tool(tools, limit_ctx, *args) + + monkeypatch.setattr(loop, "_prepare_post_tool_budget_context", measured) + + result, stored, calls, text = _run(tmp_path, monkeypatch, reply, rounds=3) + + assert prepared == [1] and len(calls) == 2 # the real preparer priced the forced prompt once + assert (result["outcome"], result["text"]) == (outcome, spoken) + assert (stored["metadata"].get("presence_declaration") or {}).get("status") == declaration + assert _cached_result(tmp_path, "presence-loop").text == spoken + if declaration is None: + assert "[PRESENCE_DELIVERY]" not in json.dumps(calls[-1]) # priced on a copy, never sent + assert text == reply and stored["terminal_origin"] == "model_final" + else: + assert "[PRESENCE_DELIVERY]" in str(calls[-1][-1]["content"]) + assert stored["reason_code"] == "budget_exhausted" and text == RECORD + + +def _receipt(task_id, text, delivery_id, *, state="delivered", key=KEY): + return {"task_id": task_id, "type": "presence_delivery", "text": text, "transport": { + "conversation_key": key, "delivery": {"state": state, "delivery_id": delivery_id, "part_id": "0"}}} + + +def test_a_promoted_roots_observed_sends_reach_its_forced_final_without_an_inbound_row(tmp_path, monkeypatch): + # A promoted root logs no inbound row of its own: only its tool sends carry its id. + for row in (_receipt("promoted-9", "First table sent.", "d1"), + _receipt("promoted-9", "Second part", "d2", state="uncertain"), + _receipt("promoted-9", "Sent to another room", "d3", key="telegram:bot-1:room-2:0"), + _receipt("presence-turn", "The turn's own early reply", "d4")): + append_jsonl(tmp_path / "logs" / "chat.jsonl", row) + task = {"id": "promoted-9", "type": "task", "chat_id": 7, "text": "Compile", "delegation_role": "root", + "root_task_id": "promoted-9", "metadata": {"presence": _presence()}} + + result, _stored, calls, _text = _run(tmp_path, monkeypatch, _forced("tool_delivered", ""), task=task) + + prompt = str(calls[-1][-1]["content"]) + assert 'Sends confirmed for this task so far: "First table sent." (live chat log only;' in prompt + assert "so there may be more); 1 more part(s) have an uncertain outcome" in prompt + assert "another room" not in prompt and "early reply" not in prompt + assert (result["outcome"], result["text"]) == ("tool_delivered", "") + + +def test_send_facts_mark_only_an_uncovered_task_as_partial(tmp_path): + from ouroboros.presence_context import presence_send_facts + + append_jsonl(tmp_path / "logs" / "chat.jsonl", {"task_id": "turn", "direction": "in", "text": "x"}) + for task_id in ("turn", "promoted"): + append_jsonl(tmp_path / "logs" / "chat.jsonl", _receipt(task_id, f"sent by {task_id}", task_id)) + + assert presence_send_facts(tmp_path, "turn", _presence()) == '"sent by turn"' # its inbound row covers it + assert presence_send_facts(tmp_path, "promoted", _presence()).startswith('"sent by promoted" (live chat log only;') + assert presence_send_facts(tmp_path, "silent-root", _presence()).startswith("none (live chat log only;") + assert presence_send_facts(tmp_path, "promoted", _presence(version=0)).startswith("unknown (this transport") + + +def test_forced_prompt_and_facts_read_the_canonical_root_on_a_forked_drive(tmp_path, monkeypatch): + canonical = tmp_path / "canonical" + append_jsonl(canonical / "logs" / "chat.jsonl", {"task_id": "presence-loop", "direction": "in", "text": "x"}) + append_jsonl(canonical / "logs" / "chat.jsonl", { + "task_id": "presence-loop", "type": "presence_delivery", "text": "Canonical receipt", + "transport": {"conversation_key": KEY, "delivery": {"state": "delivered", "delivery_id": "d1", "part_id": "0"}}, + }) + append_jsonl(tmp_path / "logs" / "chat.jsonl", {"task_id": "presence-loop", "direction": "in", "text": "x"}) + _result, _stored, calls, _text = _run(tmp_path, monkeypatch, _forced("silent", ""), + metadata={"budget_drive_root": str(canonical)}) + prompt = str(calls[-1][-1]["content"]) + assert 'Sends confirmed for this task so far: "Canonical receipt"' in prompt + + +EARLIER = "Earlier record: Q1 figures verified; Q2 not yet checked." + + +@pytest.mark.parametrize("body,record,outcome,spoken,declaration", [ + # A valid keep retains the earlier answer as the record and still speaks its declaration. + ({"delivery_control": "keep"}, EARLIER, "message", "Fresh reply", {"status": "declared"}), + ({"delivery_control": "replace", "full_answer": RECORD}, RECORD, "message", "Fresh reply", {"status": "declared"}), + # A rejected envelope keeps the earlier answer as the record: its fresh reply is never spoken beside it. + ({"delivery_control": "replace", "full_answer": ""}, EARLIER, "silent", "", + {"status": "invalid", "reason": "the delivery-control envelope was rejected"}), + ({"delivery_control": "revise", "full_answer": RECORD}, EARLIER, "silent", "", + {"status": "invalid", "reason": "the delivery-control envelope was rejected"}), + ({"full_answer": RECORD}, EARLIER, "silent", "", + {"status": "invalid", "reason": "the delivery-control envelope was rejected"}), +], ids=["keep", "replace", "empty_replace_rejected", "unknown_verb_rejected", "missing_verb_rejected"]) +def test_a_declaration_speaks_only_beside_the_answer_its_own_envelope_authorized( + tmp_path, monkeypatch, body, record, outcome, spoken, declaration): + from tests.test_delivery_forced_finalization import _arm_latch_with_candidate, _forced_test_context + + monkeypatch.setenv("OUROBOROS_TASK_REVIEW_MODE", "off") + forced_loop, registry, limit_ctx, trace = _forced_test_context(tmp_path) + ctx = registry._ctx + ctx.task_contract = {"capability_ceiling": presence_ceiling_payload(_admission().capability_ceiling)} + ctx.task_metadata = {**ctx.task_metadata, "presence": _presence()} + _arm_latch_with_candidate(forced_loop, registry, limit_ctx, trace, text=EARLIER) # a live earlier answer + reply = json.dumps({**body, "presence_finish": {"outcome": "message", "message": "Fresh reply"}}) + monkeypatch.setattr(forced_loop, "call_llm_with_retry", + lambda *_a, **_k: ({"role": "assistant", "content": reply}, 0.0)) + + text, usage, trace = forced_loop._forced_final_answer( + limit_ctx, prompt="finalize", fallback_text="fallback", reason_code="round_limit") + task = {"id": "parent1", "type": "presence", "_presence_turn": True, "chat_id": 7, "text": "Please help", + "metadata": {"presence": _presence()}, "_skip_post_task_synthesis": True} + events = [] + pipeline.emit_task_results(SimpleNamespace(drive_root=tmp_path, repo_dir=tmp_path), None, None, + events, task, text, usage, trace, 0.0, tmp_path / "logs", ctx=ctx) + result = next(row for row in events if row["type"] == "presence_result") + stored = load_task_result(tmp_path, "parent1") + + assert text.startswith(record) and stored["result"].startswith(record) + assert (result["outcome"], result["text"]) == (outcome, spoken) + assert stored["metadata"]["presence_declaration"] == declaration + assert stored["metadata"]["presence_result_text"] == spoken diff --git a/tests/test_presence_own_work.py b/tests/test_presence_own_work.py new file mode 100644 index 000000000..694ab89f8 --- /dev/null +++ b/tests/test_presence_own_work.py @@ -0,0 +1,886 @@ +"""A Presence binding's own work: scoped discovery, exact reads and control (owner Q1-Q3). + +Related work is independent work started from the same nonempty binding id, from +any of its conversations or threads. Other bindings, owner roots, inline turns, +delegated children and rows without Presence provenance are never attributed. +""" + +from __future__ import annotations + +import json +import types + +import pytest + +from ouroboros.presence_authority import ( + PresenceAuthorityError, + build_presence_capability_ceiling, + presence_ceiling_from_payload, + presence_ceiling_payload, +) +from ouroboros.presence_capabilities import PresenceToolTarget +from ouroboros.task_results import load_task_result, write_task_result +from ouroboros.tools.registry import ToolContext, ToolRegistry +from ouroboros.utils import atomic_write_json +from tests.test_presence_authority import _resolution + +BINDING = "a" * 32 +OTHER = "b" * 32 +HERE = "slack:T1:D1:0" +THREAD = "slack:T1:D1:1712.5" +ROOM = "slack:T1:C9:0" + + +def _presence(binding=BINDING, key=HERE): + provider, account, conversation, thread = key.split(":") + return {"binding_id": binding, "event": { + "conversation_key": key, "provider": provider, "account_id": account, + "conversation_id": conversation, "thread_id": "" if thread == "0" else thread, + "source_event_id": f"evt-{conversation}-{thread}", + }} + + +def _work(root, task_id, status, *, binding=BINDING, key=HERE, **fields): + metadata = {"presence": _presence(binding, key)} if binding else {} + fields.setdefault("delegation_role", "root") + write_task_result(root, task_id, status, metadata=metadata, description=f"goal of {task_id}", **fields) + + +def _ceiling(*targets): + return build_presence_capability_ceiling( + skill_name="community-helper", skill_content_hash="c" * 64, + state_fingerprint="d" * 64, resolution=_resolution(*targets), + ) + + +def _registry(root, ceiling=None, *, binding=BINDING, key=HERE, task_id="presence-turn-1"): + ctx = ToolContext( + repo_dir=root, drive_root=root, task_id=task_id, + task_contract={"capability_ceiling": presence_ceiling_payload(ceiling or _ceiling())}, + task_metadata={"presence": _presence(binding, key)}, + ) + registry = ToolRegistry(repo_dir=root, drive_root=root) + registry.set_context(ctx) + return registry, ctx + + +def _queue(root, *, pending=(), running=()): + atomic_write_json(root / "state" / "queue_snapshot.json", { + "pending": [{"id": task["id"], "task": task} for task in pending], + "running": [{"id": task["id"], "task": task} for task in running], + }) + + +def _page_ids(registry, **args): + """Every id reachable by following ``next`` from the first page.""" + seen, page = [], json.loads(registry.execute("recent_tasks", {"limit": 2, **args})) + while True: + assert "error" not in page, page + seen += [row["task_id"] for row in page["tasks"]] + if not page["next"]: + return seen, page + page = json.loads(registry.execute("recent_tasks", page["next"])) + + +def test_same_binding_work_from_other_threads_is_paged_and_nothing_else(tmp_path): + _work(tmp_path, "queued-here", "scheduled") + _work(tmp_path, "running-thread", "running", key=THREAD) + _work(tmp_path, "done-room", "completed", key=ROOM, result="The report is ready.") + _work(tmp_path, "done-here", "completed", result="Earlier answer") + _work(tmp_path, "failed-thread", "failed", key=THREAD) + # Never attributed: another binding, the owner's root, an inline turn, + # a delegated child, a row with no provenance at all. + _work(tmp_path, "foreign", "running", binding=OTHER) + _work(tmp_path, "owner-root", "running", binding="") + _work(tmp_path, "presence-inline", "completed", delegation_role=None) + _work(tmp_path, "child", "running", delegation_role="subagent", parent_task_id="running-thread") + write_task_result(tmp_path, "unknown", "completed", description="no provenance") + # A legacy row whose canonical record predates provenance is established by + # the queue's own task metadata; a queue claim never overrides another binding. + write_task_result(tmp_path, "legacy-queued", "scheduled", delegation_role="root") + _work(tmp_path, "conflict", "scheduled", binding=OTHER) + queue_rows = [ + {"id": "legacy-queued", "delegation_role": "root", "metadata": {"presence": _presence(key=ROOM)}}, + {"id": "conflict", "delegation_role": "root", "metadata": {"presence": _presence()}}, + {"id": "queue-only", "delegation_role": "root", "description": "not yet recorded", + "metadata": {"presence": _presence(key=THREAD)}}, + ] + running_rows = [ + {"id": "running-thread", "delegation_role": "root", "description": "thread work", + "metadata": {"presence": _presence(key=THREAD)}}, + {"id": "foreign", "delegation_role": "root", "metadata": {"presence": _presence(OTHER)}}, + ] + _queue(tmp_path, pending=queue_rows, running=running_rows) + registry, _ctx = _registry(tmp_path) + + ids, last = _page_ids(registry) # the model supplies no scope: the host binds it + + assert sorted(ids) == sorted([ + "queued-here", "running-thread", "done-room", "done-here", "failed-thread", + "legacy-queued", "queue-only", + ]) + assert len(ids) == len(set(ids)) and ids[0] == "queue-only" # queued work without a row leads + assert last["presence_scope"] == {"scope": "own_binding", "binding_id": BINDING} + assert [row["task_id"] for row in last["running"]] == ["running-thread"] + first = json.loads(registry.execute("recent_tasks", {"limit": 20})) + by_id = {row["task_id"]: row for row in first["tasks"]} + assert by_id["done-room"]["presence_origin"]["conversation_key"] == ROOM + assert by_id["done-room"]["result_preview"] == "The report is ready." + assert by_id["queue-only"] == {"task_id": "queue-only", "status": "pending", + "description": "not yet recorded", "source": "queue_snapshot"} + + # A changed inventory never continues an old cursor into a mixed page. + stale = json.loads(registry.execute("recent_tasks", {"limit": 2})) + _work(tmp_path, "new-work", "scheduled", key=ROOM) + moved = json.loads(registry.execute("recent_tasks", stale["next"])) + assert moved["error"]["code"] == "RECENT_TASKS_SNAPSHOT_CHANGED" and moved["tasks"] == [] + + +def test_running_work_is_listed_by_its_canonical_binding_not_its_queue_claim(tmp_path): + _work(tmp_path, "running-conflict", "running", binding=OTHER) # the canonical row: another binding + _work(tmp_path, "running-mine", "running", key=ROOM) + _work(tmp_path, "running-requeued", "running", key=THREAD) # mine, whatever its queue row says + # Rows that do not decide leave the queue's own task metadata deciding: a row that + # predates provenance, and a row that cannot be read right now. + write_task_result(tmp_path, "running-legacy", "running", delegation_role="root") + (tmp_path / "task_results" / "running-torn.json").write_text("{", encoding="utf-8") + mine = {"delegation_role": "root", "metadata": {"presence": _presence()}} + _queue(tmp_path, running=[ + {"id": "running-conflict", "description": "their private goal", **mine}, + {"id": "running-mine", "description": "my goal", **mine}, + {"id": "running-requeued", "description": "requeued goal", "delegation_role": "root", + "metadata": {"presence": _presence(OTHER)}}, + {"id": "running-legacy", "description": "legacy goal", **mine}, + {"id": "running-torn", "description": "torn goal", **mine}, + {"id": "queue-foreign", "description": "queue says theirs", "delegation_role": "root", + "metadata": {"presence": _presence(OTHER)}}, + ]) + registry, _ctx = _registry(tmp_path) + + page = json.loads(registry.execute("recent_tasks", {"limit": 20})) + + assert {row["task_id"]: row["description"] for row in page["running"]} == { + "running-mine": "my goal", "running-requeued": "requeued goal", "running-legacy": "legacy goal", + "running-torn": "torn goal"} + assert "running-conflict" not in json.dumps(page) and "their private goal" not in json.dumps(page) + owner = ToolRegistry(repo_dir=tmp_path, drive_root=tmp_path) + owner.set_context(ToolContext(repo_dir=tmp_path, drive_root=tmp_path, task_id="owner-turn")) + unscoped = json.loads(owner.execute("recent_tasks", {"limit": 20})) + assert "running-conflict" in {row["task_id"] for row in unscoped["running"]} # owner reads stay whole + + +def test_an_empty_or_foreign_binding_attributes_nothing_and_owner_reads_stay_whole(tmp_path): + _work(tmp_path, "mine", "completed") + _work(tmp_path, "theirs", "completed", binding=OTHER) + registry, _ctx = _registry(tmp_path, binding="") + refused = json.loads(registry.execute("recent_tasks", {})) + assert refused["error"]["code"] == "PRESENCE_SCOPE_UNAVAILABLE" + + owner = ToolRegistry(repo_dir=tmp_path, drive_root=tmp_path) + owner.set_context(ToolContext(repo_dir=tmp_path, drive_root=tmp_path, task_id="owner-turn")) + everything = json.loads(owner.execute("recent_tasks", {"limit": 20})) + assert {row["task_id"] for row in everything["tasks"]} == {"mine", "theirs"} + assert "presence_scope" not in everything + + +def test_exact_read_admits_own_work_and_tree_and_refuses_the_rest(tmp_path): + _work(tmp_path, "done-room", "completed", key=ROOM, result="Full report text") + _work(tmp_path, "foreign", "completed", binding=OTHER, result="Not yours") + _work(tmp_path, "owner-root", "completed", binding="", result="Owner work") + write_task_result(tmp_path, "my-child", "completed", parent_task_id="presence-turn-1", + root_task_id="presence-turn-1", delegation_role="subagent", result="Child result") + registry, _ctx = _registry(tmp_path) + + assert "Full report text" in registry.execute("get_task_result", {"task_id": "done-room"}) + assert "Child result" in registry.execute("get_task_result", {"task_id": "my-child"}) + for task_id in ("foreign", "owner-root", "never-existed"): + refused = registry.execute("get_task_result", {"task_id": task_id}) + assert "is not independent work started from this Presence binding" in refused and "Not yours" not in refused + + # A profile that explicitly selected the global readers keeps that grant. + selected = _ceiling(PresenceToolTarget("builtin", "get_task_result"), + PresenceToolTarget("builtin", "recent_tasks")) + global_reader, _ctx = _registry(tmp_path, selected) + assert "Not yours" in global_reader.execute("get_task_result", {"task_id": "foreign"}) + listed = json.loads(global_reader.execute("recent_tasks", {"limit": 20})) + assert {"foreign", "owner-root"} <= {row["task_id"] for row in listed["tasks"]} + # ...and the model may still narrow it on purpose. + narrowed = json.loads(global_reader.execute("recent_tasks", {"limit": 20, "presence_scope": "own_binding"})) + assert [row["task_id"] for row in narrowed["tasks"]] == ["done-room"] + + +def test_old_frozen_ceilings_keep_their_digest_and_gain_nothing(tmp_path): + payload = presence_ceiling_payload(_ceiling()) + old = json.loads(json.dumps(payload)) + old["tools"] = [tool for tool in old["tools"] + if tool["name"] not in {"get_task_result", "recent_tasks", "steer_task"}] + with pytest.raises(PresenceAuthorityError): + presence_ceiling_from_payload(old) # stripping the grants is not a valid frozen ceiling + from ouroboros.presence_authority import _digest + + old["digest"] = _digest(old) # a genuinely older ceiling, compiled before the baseline + ctx = ToolContext(repo_dir=tmp_path, drive_root=tmp_path, task_id="presence-old", + task_contract={"capability_ceiling": old}, + task_metadata={"presence": _presence()}) + registry = ToolRegistry(repo_dir=tmp_path, drive_root=tmp_path) + registry.set_context(ctx) + names = {schema["function"]["name"] for schema in registry.schemas()} + assert not names & {"get_task_result", "recent_tasks", "steer_task"} + assert "PRESENCE_CAPABILITY_BLOCKED" in registry.execute("steer_task", {"task_id": "x", "message": "y"}) + assert "presence_cancel_work" in names # the intrinsic control is unchanged + + +# --- steering: task-authored, own binding only, pending included ------------------ + +def _supervisor(root, *, pending=(), running=()): + return types.SimpleNamespace( + DRIVE_ROOT=root, PENDING=list(pending), bridge=None, + RUNNING={task["id"]: {"task": task, "started_at": 1.0} for task in running}, + send_with_budget=lambda *_a, **_k: None, + ) + + +def _steering_turn(root, supervisor_ctx, emitted): + from supervisor.events import _handle_steer_task + + def _dispatch(event): + emitted.append(event) + _handle_steer_task(event, supervisor_ctx) + + return types.SimpleNamespace( + pending_events=[], event_queue=types.SimpleNamespace(put_nowait=_dispatch), + current_chat_id=4242, drive_root=root, task_id="presence-turn-1", is_direct_chat=True, + task_metadata={"presence": _presence(), "source": "presence", "client_message_id": "evt-D1-0"}, + last_owner_delivery=None, + ) + + +def test_presence_steer_reaches_pending_and_running_own_work_as_task_authored_text(tmp_path, monkeypatch): + import supervisor.queue as queue + from ouroboros.owner_mailbox import deliver_task_message, drain_owner_entries + from ouroboros.tools.control import _steer_task + + monkeypatch.setattr(queue, "DRIVE_ROOT", str(tmp_path)) + queued = {"id": "queued-work", "delegation_role": "root", "chat_id": 77, + "metadata": {"presence": _presence(key=THREAD)}} + running = {"id": "running-work", "delegation_role": "root", "chat_id": 78, + "metadata": {"presence": _presence(key=ROOM)}} + foreign = {"id": "foreign-work", "delegation_role": "root", "chat_id": 79, + "metadata": {"presence": _presence(OTHER)}} + owner = {"id": "owner-work", "delegation_role": "root", "chat_id": 1, "metadata": {}} + fence = {"root_task_id": "running-work", "status": "active", "owner_message_generation": 3} + monkeypatch.setitem(queue.ACCEPTANCE_FENCES, "running-work", fence) + emitted = [] + turn = _steering_turn(tmp_path, _supervisor(tmp_path, pending=[queued, foreign], running=[running, owner]), + emitted) + + exact = "Alex says: use the March figures, not February." + for target in ("queued-work", "running-work"): + out = _steer_task(turn, target, exact) + assert "written to its mailbox" in out and "not as owner text" in out + [entry] = drain_owner_entries(tmp_path, target) + assert entry["text"] == exact + assert (entry["provenance"], entry["source_task_id"]) == ("independent_task", "presence-turn-1") + # The run's origin rides beside the words; it is never offered as their author. + assert entry["sender_origin"] == {"provider": "slack", "account_id": "T1", "conversation_id": "D1", + "source_event_id": "evt-D1-0"} + rendered = [] + deliver_task_message(entry, target, None, rendered.append) + assert rendered[0].startswith("[Message from independent task presence-turn-1; that task's run started from ") + assert "does not make it the author of any words it quotes]" in rendered[0] + assert rendered[0].endswith("\n" + exact) and "[Message from my human]" not in rendered[0] + assert fence["owner_message_generation"] == 3 # a task's words supersede no reviewed answer + assert all(evt["presence_binding_id"] == BINDING for evt in emitted) + assert all(evt["issuer"]["kind"] == "task" for evt in emitted) + + for target in ("foreign-work", "owner-work"): + refused = _steer_task(turn, target, "stop") + assert "STEER_REJECTED" in refused and "presence_work_not_related" in refused + assert drain_owner_entries(tmp_path, target) == [] + + +def test_an_ordinary_task_still_messages_any_listed_root(tmp_path, monkeypatch): + import supervisor.queue as queue + from ouroboros.owner_mailbox import drain_owner_entries + from ouroboros.tools.control import _steer_task + + monkeypatch.setattr(queue, "DRIVE_ROOT", str(tmp_path)) + owner = {"id": "owner-work", "delegation_role": "root", "chat_id": 1, "metadata": {}} + emitted = [] + turn = _steering_turn(tmp_path, _supervisor(tmp_path, running=[owner]), emitted) + turn.task_metadata = {} + turn.task_id = "managed-root" + + assert "written to its mailbox" in _steer_task(turn, "owner-work", "status please") + assert "presence_binding_id" not in emitted[0] and "sender_origin" not in emitted[0] + [entry] = drain_owner_entries(tmp_path, "owner-work") + assert entry["provenance"] == "independent_task" and "sender_origin" not in entry + + +# --- cancellation: request receipts, own binding only, before any intent ----------- + +def _cancel_ctx(root, *, binding=BINDING, key=HERE): + return types.SimpleNamespace( + pending_events=[], event_queue=None, drive_root=root, task_id="presence-turn-1", + task_metadata={"presence": _presence(binding, key)}, current_chat_id=4242, + task_contract={"capability_ceiling": presence_ceiling_payload(_ceiling())}, + ) + + +def _intents(root): + path = root / "state" / "cancel_intents.json" + return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} + + +def test_presence_cancel_requests_own_pending_work_from_another_thread(tmp_path): + from ouroboros.tools.presence import get_tools + + _work(tmp_path, "queued-thread", "scheduled", key=THREAD, root_task_id="queued-thread") + _queue(tmp_path, pending=[{"id": "queued-thread", "delegation_role": "root", + "metadata": {"presence": _presence(key=THREAD)}}]) + cancel = next(item for item in get_tools() if item.name == "presence_cancel_work").handler + + out = cancel(_cancel_ctx(tmp_path), "queued-thread", "the person withdrew the request") + + assert out.startswith("Cancel requested: queued-thread") + assert "cancel_state=pending" in out # a request receipt, never a claim that it stopped + assert "queued-thread" in json.dumps(_intents(tmp_path)) + assert load_task_result(tmp_path, "queued-thread")["status"] == "scheduled" + + +def test_selected_cancel_and_forward_refuse_foreign_work_before_any_effect(tmp_path): + from ouroboros.tools.join_ledger import _cancel_task + from ouroboros.owner_mailbox import drain_owner_entries + + _work(tmp_path, "foreign", "running", binding=OTHER) + _work(tmp_path, "owner-root", "running", binding="") + _work(tmp_path, "mine", "running", key=ROOM) + _queue(tmp_path, running=[{"id": "mine", "delegation_role": "root", + "metadata": {"presence": _presence(key=ROOM)}}]) + ctx = _cancel_ctx(tmp_path) + for target in ("foreign", "owner-root"): + refused = _cancel_task(ctx, target, "stop") + assert "is not independent work started from this Presence binding" in refused + assert _intents(tmp_path) == {} + + selected = _ceiling(PresenceToolTarget("builtin", "forward_to_worker")) + registry, _ctx = _registry(tmp_path, selected) + assert "is not independent work started from this Presence binding" in registry.execute( + "forward_to_worker", {"task_id": "foreign", "message": "stop"}) + sent = registry.execute("forward_to_worker", {"task_id": "mine", "message": "new fact"}) + assert "written to its mailbox as a message from this task" in sent + [entry] = drain_owner_entries(tmp_path, "mine") + assert (entry["text"], entry["provenance"], entry["source_task_id"]) == ( + "new fact", "independent_task", "presence-turn-1") + + +def test_a_root_acting_only_for_its_binding_cancels_that_bindings_work_and_nothing_foreign(tmp_path): + from ouroboros.tools.presence import get_tools + + _work(tmp_path, "queued-thread", "scheduled", key=THREAD, root_task_id="queued-thread") + _queue(tmp_path, pending=[{"id": "queued-thread", "delegation_role": "root", + "metadata": {"presence": _presence(key=THREAD)}}]) + _work(tmp_path, "foreign", "running", binding=OTHER) + _work(tmp_path, "owner-root", "running", binding="") + cancel = next(item for item in get_tools() if item.name == "presence_cancel_work").handler + # A root a delegated descendant promoted holds the binding authority, never speaker metadata. + ctx = _cancel_ctx(tmp_path) + ctx.task_id = "descendant-root" + ctx.task_metadata = {"presence_binding_authority": {"binding_id": BINDING}, + "delegation_role": "root", "root_task_id": "descendant-root"} + + for target in ("foreign", "owner-root"): + assert cancel(ctx, target, "stop").startswith("ERROR: PRESENCE_WORK_NOT_CORRELATED") + ordinary = types.SimpleNamespace(**{**vars(ctx), "task_metadata": {}, "task_contract": {}}) + assert cancel(ordinary, "queued-thread").startswith("ERROR: PRESENCE_WORK_NOT_CORRELATED") + assert _intents(tmp_path) == {} + out = cancel(ctx, "queued-thread", "superseded by the new audit") + assert out.startswith("Cancel requested: queued-thread"), out + assert "queued-thread" in json.dumps(_intents(tmp_path)) + + +# --- canonical provenance from admission; the work endpoint and context read it ---- + +def test_scheduled_presence_promotion_is_canonical_and_pollable_while_queued(tmp_path, monkeypatch): + from starlette.testclient import TestClient + + import supervisor.workers as workers + from ouroboros.gateway.host_service import create_host_service_app + from supervisor.events import _handle_promote_chat_to_task + from tests.test_host_service_api import _seed_presence_behavior, _seed_token + + monkeypatch.setattr(workers, "DRIVE_ROOT", tmp_path) + _seed_token(tmp_path, skill="telegram-bot", token="presence-token", + permissions=["presence"], manifest_permissions=["presence"]) + binding = _seed_presence_behavior(tmp_path) + pending = [] + handler_ctx = types.SimpleNamespace( + DRIVE_ROOT=tmp_path, WORKERS={0: types.SimpleNamespace()}, PENDING=pending, bridge=None, + append_jsonl=lambda *_a, **_k: None, persist_queue_snapshot=lambda **_k: True, + enqueue_task=lambda task: pending.append(dict(task)) or pending[-1], + load_state=lambda: {"owner_chat_id": 1}, + ) + presence = _presence(binding, THREAD) + event = {"type": "promote_chat_to_task", "task_id": "promoted-1", "routing_token": "tok-1", + "objective": "Compile the figures", "chat_id": 4242, "client_message_id": "evt-D1-1712.5", + "project_id": "", "workspace_root": "", "source": "", "presence": presence, + "task_contract": {"capability_ceiling": presence_ceiling_payload(_ceiling())}} + + outcome = _handle_promote_chat_to_task(event, handler_ctx) + + assert outcome["status"] == "scheduled", outcome + stored = load_task_result(tmp_path, "promoted-1") + assert stored["status"] == "scheduled" and stored["metadata"]["presence"] == presence + assert pending[0]["metadata"]["presence"] == presence # the queue and the record agree + with TestClient(create_host_service_app(tmp_path)) as client: + polled = client.get("/presence/work/promoted-1", params={"binding_id": binding}, + headers={"X-Skill-Token": "presence-token"}) + foreign = client.get("/presence/work/promoted-1", params={"binding_id": OTHER}, + headers={"X-Skill-Token": "presence-token"}) + assert polled.status_code == 202 and polled.json()["status"] == "pending" + assert foreign.status_code in {403, 404} + + +def test_context_lists_own_work_from_other_conversations_after_the_pointer_moved(tmp_path): + from ouroboros.presence_context import build_presence_context_section + + from ouroboros.cancel_intents import request_cancel + + _work(tmp_path, "done-room", "completed", key=ROOM, result="The report is ready.") + _work(tmp_path, "queued-here", "scheduled") + _work(tmp_path, "running-thread", "running", key=THREAD) + request_cancel(tmp_path, "running-thread", reason="withdrawn", source="agent_tool") + _work(tmp_path, "foreign", "completed", binding=OTHER, result="Other binding") + _work(tmp_path, "promoted-self", "running") + value = {**_presence(), "instructions": "Be useful.", "previous_turn": { + "task_id": "presence-later", "outcome": "silent", "finished_at": "2026-09-24T12:00:00+00:00", + "work_ref": "", # a later turn replaced the pointer; the work is still found + }} + + section = build_presence_context_section(tmp_path, value, "promoted-self") + + own = section.split("## Work started from this binding (host-authored facts)", 1)[1] + assert "done-room [completed] from conversation slack:T1:C9:0" in own + assert "The report is ready." in own + assert "queued-here [scheduled] from this conversation" in own + assert "running-thread [running, cancel pending] from conversation slack:T1:D1:1712.5" in own + assert "foreign" not in own and "promoted-self" not in own + assert "says nothing about whether its result reached anyone" in own + + +# --- repair pass: read gaps, effective redirects, unconfirmed promotion, forked roots ---- + +def test_an_unreadable_result_row_leaves_queued_own_work_listed_and_the_gap_counted(tmp_path): + from ouroboros.presence_context import build_presence_context_section + + task_dir = tmp_path / "task_results" + task_dir.mkdir(parents=True) + # A torn row of own queued work, a torn row nothing attributes, and a torn row + # the queue says is another binding's: only the first is this binding's work. + for name in ("queued-torn", "loose-torn", "foreign-torn"): + (task_dir / f"{name}.json").write_text("{not json", encoding="utf-8") + _work(tmp_path, "readable-queued", "scheduled") + _queue(tmp_path, pending=[ + {"id": "queued-torn", "delegation_role": "root", "description": "compile the figures", + "metadata": {"presence": _presence(key=THREAD)}}, + {"id": "foreign-torn", "delegation_role": "root", "metadata": {"presence": _presence(OTHER)}}, + {"id": "readable-queued", "delegation_role": "root", "metadata": {"presence": _presence()}}, + ]) + registry, _ctx = _registry(tmp_path) + + page = json.loads(registry.execute("recent_tasks", {"limit": 20})) + + rows = {row["task_id"]: row for row in page["tasks"]} + assert set(rows) == {"queued-torn", "readable-queued"} # the readable row replaced its queue row once + assert rows["queued-torn"] == {"task_id": "queued-torn", "status": "pending", "description": "compile the figures", + "source": "queue_snapshot", "result_row": "unreadable"} + assert rows["readable-queued"]["status"] == "scheduled" and "result_row" not in rows["readable-queued"] + assert page["read_gap"] == {"unattributed_unreadable_rows": 1} # foreign-torn is attributed by its queue row + section = build_presence_context_section(tmp_path, {**_presence(), "instructions": "Be useful."}, "turn-x") + assert "queued-torn [pending, its result row is unreadable]" in section + assert "1 row(s) no record attributes; this binding's work may be among them" in section + + # Nothing torn: no gap is claimed. + for name in ("queued-torn", "loose-torn", "foreign-torn"): + (task_dir / f"{name}.json").unlink() + clean = json.loads(registry.execute("recent_tasks", {"limit": 20})) + assert "read_gap" not in clean and {row["task_id"] for row in clean["tasks"]} == {"queued-torn", "readable-queued"} + + +def test_an_effective_redirect_is_judged_before_projection_and_own_retries_still_read(tmp_path): + # Own work whose retry successor is another binding's: neither reader projects it. + _work(tmp_path, "mine-redirected", "interrupted", superseded_by="theirs-retry", result="own interrupted row") + _work(tmp_path, "theirs-retry", "completed", binding=OTHER, result="FOREIGN SUCCESSOR BODY") + # A real same-binding retry (the reaper copies metadata, role and lineage): read through. + _work(tmp_path, "mine-timed-out", "interrupted", superseded_by="mine-retry", result="timed out") + _work(tmp_path, "mine-retry", "completed", root_task_id="mine-timed-out", original_task_id="mine-timed-out", + supersedes_task_id="mine-timed-out", result="Retry finished the report.") + registry, _ctx = _registry(tmp_path) + + refused = registry.execute("get_task_result", {"task_id": "mine-redirected"}) + assert "effective result continues in work that was not started from this Presence binding" in refused + assert "FOREIGN SUCCESSOR BODY" not in refused and "theirs-retry" not in refused + assert "Retry finished the report." in registry.execute("get_task_result", {"task_id": "mine-timed-out"}) + + page = json.loads(registry.execute("recent_tasks", {"limit": 20, "include_results": True})) + rows = {row["task_id"]: row for row in page["tasks"]} + assert "FOREIGN SUCCESSOR BODY" not in json.dumps(page) and "theirs-retry" not in rows + assert rows["mine-redirected"]["status"] == "interrupted" + assert rows["mine-redirected"]["effective_result"].startswith("withheld") + # The own retry reads through (the effective row names its successor, as it always has). + retried = [row for row in page["tasks"] if row["task_id"] == "mine-retry"] + assert len(retried) == 2 and all(row["result"] == "Retry finished the report." for row in retried) + assert not any("effective_result" in row for row in retried) + + # The owner's unscoped reader keeps the ordinary effective projection. + owner = ToolRegistry(repo_dir=tmp_path, drive_root=tmp_path) + owner.set_context(ToolContext(repo_dir=tmp_path, drive_root=tmp_path, task_id="owner-turn")) + assert "FOREIGN SUCCESSOR BODY" in owner.execute("get_task_result", {"task_id": "mine-redirected"}) + + +def test_an_unconfirmed_presence_promote_is_readable_as_pending_by_its_own_binding(tmp_path, monkeypatch): + from ouroboros.tools import control_events + from ouroboros.tools.control_routing import _promote_chat_to_task + from ouroboros.tools.control_task_results import _get_task_result + + monkeypatch.setattr(control_events, "_PROMOTE_CONFIRM_TIMEOUT_SEC", 0.05) + monkeypatch.setattr(control_events, "_PROMOTE_CONFIRM_POLL_SEC", 0.005) + monkeypatch.setattr("ouroboros.config.DATA_DIR", tmp_path) + emitted = [] + presence = _presence(key=THREAD) + ctx = types.SimpleNamespace( + pending_events=[], event_queue=types.SimpleNamespace(put_nowait=emitted.append), current_chat_id=4242, + drive_root=tmp_path, budget_drive_root=str(tmp_path), project_id="", task_id="presence-turn-1", + task_metadata={"presence": presence}, is_direct_chat=True, + task_contract={"capability_ceiling": presence_ceiling_payload(_ceiling())}, + ) + + out = _promote_chat_to_task(ctx, "Compile the figures", predecessor_task_id="") + + assert out.startswith("⚠️ PROMOTE_UNCONFIRMED") + task_id = emitted[0]["task_id"] + stub = load_task_result(tmp_path, task_id) + # Emitted, not scheduled: the supervisor alone grants that; the provenance is the event's own. + assert (stub["status"], stub["promotion_admission"]["status"]) == ("requested", "emitted") + assert stub["metadata"]["presence"] == presence and stub["delegation_role"] == "root" + read = _get_task_result(ctx, task_id, presence_scope="own_binding") + assert "admission pending since" in read and "PRESENCE_CAPABILITY_BLOCKED" not in read + registry, _registry_ctx = _registry(tmp_path) + listed = json.loads(registry.execute("recent_tasks", {"limit": 20})) + assert [(row["task_id"], row["status"]) for row in listed["tasks"]] == [(task_id, "requested")] + stranger, _stranger_ctx = _registry(tmp_path, binding=OTHER) + assert "PRESENCE_CAPABILITY_BLOCKED" in stranger.execute("get_task_result", {"task_id": task_id}) + + # An ordinary promote's stub is unchanged: no Presence provenance is invented. + owner_emitted = [] + owner_ctx = types.SimpleNamespace( + pending_events=[], event_queue=types.SimpleNamespace(put_nowait=owner_emitted.append), current_chat_id=1, + drive_root=tmp_path, budget_drive_root=str(tmp_path), project_id="", task_metadata={}, task_id="", + ) + _promote_chat_to_task(owner_ctx, "Owner work", workspace="none", predecessor_task_id="") + owner_stub = load_task_result(tmp_path, owner_emitted[0]["task_id"]) + assert "presence" not in (owner_stub.get("metadata") or {}) and "delegation_role" not in owner_stub + + +def test_a_forked_promoted_root_reads_its_bindings_work_and_sends_from_the_canonical_root(tmp_path): + from ouroboros.agent import Env + from ouroboros.context import build_llm_messages + from ouroboros.memory import Memory + from ouroboros.presence_context import presence_finish_not_accepted_note + from ouroboros.utils import append_jsonl + from tests.test_doc_context import _make_env_and_memory + + canonical_env, _memory = _make_env_and_memory(tmp_path) + canonical, child = canonical_env.drive_root, tmp_path / "child-drive" + for sub in ("memory/knowledge", "logs", "state"): + (child / sub).mkdir(parents=True, exist_ok=True) + _work(canonical, "done-room", "completed", key=ROOM, result="The canonical report.") + _work(child, "child-drive-decoy", "completed", key=ROOM, result="decoy") # worker-local rows only + presence = {**_presence(), "instructions": "Be useful.", "delivery_reporting_version": 1} + for root, text in ((canonical, "Canonical sent reply"), (child, "Child drive decoy send")): + # A promoted root logs no inbound row of its own; its sends are observed all the same. + append_jsonl(root / "logs" / "chat.jsonl", { + "task_id": "promoted-self", "type": "presence_delivery", "text": text, + "transport": {"conversation_key": HERE, "delivery": {"state": "delivered", "delivery_id": "d", "part_id": "0"}}, + }) + env = Env(repo_dir=canonical_env.repo_dir, drive_root=child, budget_drive_root=canonical) + task = {"id": "promoted-self", "type": "task", "text": "Compile", "delegation_role": "root", + "_presence_origin": True, "budget_drive_root": str(canonical), "metadata": {"presence": presence}} + + messages, _ = build_llm_messages(env=env, memory=Memory(child, repo_dir=env.repo_dir), task=task) + + rendered = json.dumps(messages, ensure_ascii=False) + assert "done-room [completed] from conversation slack:T1:C9:0" in rendered + assert "child-drive-decoy" not in rendered + ctx = types.SimpleNamespace(drive_root=child, budget_drive_root=str(canonical), task_id="promoted-self", + task_metadata={"presence": presence, "budget_drive_root": str(canonical)}) + note = presence_finish_not_accepted_note(ctx, {"outcome": "tool_delivered"}) + assert '"Canonical sent reply" (live chat log only;' in note and "decoy" not in note + + +# --- delegated descendants: the binding authority, never the speaker metadata ------- + +def test_the_binding_authority_is_its_own_carrier_and_fails_closed(): + from ouroboros.dialogue_provenance import presence_binding_authority_metadata, presence_metadata_binding + + assert presence_metadata_binding({}) is None and presence_metadata_binding(None) is None + assert presence_metadata_binding({"presence": _presence()}) == BINDING + assert presence_metadata_binding({"presence_binding_authority": {"binding_id": BINDING}}) == BINDING + # A malformed authority is still a Presence one: it narrows to nothing, never to everything. + for malformed in ({}, {"binding_id": 7}, "not-a-mapping", [], None): + assert presence_metadata_binding({"presence_binding_authority": malformed}) == "" + assert presence_metadata_binding({"presence": None}) == "" + assert presence_binding_authority_metadata({}, task_contract={"capability_ceiling": {}}) == { + "presence_binding_authority": {"binding_id": ""}} + # The speaker metadata decides for a Presence turn or root; its child gets the binding only. + assert presence_binding_authority_metadata({"presence": _presence(OTHER)}) == { + "presence_binding_authority": {"binding_id": OTHER}} + assert presence_binding_authority_metadata({"source": "owner"}) == {} + + +def _parent(root, metadata, *, task_id="presence-turn-1", ceiling=True): + return types.SimpleNamespace( + task_depth=0, pending_events=[], drive_root=root, task_id=task_id, task_metadata=metadata, + task_contract={"capability_ceiling": presence_ceiling_payload(_ceiling())} if ceiling else {}, + current_chat_id=4242, is_direct_chat=ceiling, is_workspace_mode=lambda: False, + ) + + +def _admitted_child(root, monkeypatch, parent): + """The real schedule tool, then the real supervisor admission: the queued child and its event.""" + from ouroboros.tools.control import _schedule_task + from supervisor import events + from tests.test_nested_rights_depth import _fake_ctx + from tests.test_task_status_flow import _configure_test_subagent, _FakeEventQueue + + _configure_test_subagent(monkeypatch) + parent.event_queue = _FakeEventQueue() + queued = _schedule_task(parent, subagent_id="api-scout", objective="Check the figures", expected_output="Findings") + assert "Subagent request queued" in queued, queued + [evt] = parent.event_queue.events + enqueued = [] + events._handle_schedule_task(evt, _fake_ctx(root, enqueued)) + [row] = enqueued + return evt, row + + +def _worker_metadata(row): + """What the worker hands its tools: the queued metadata plus the row's lineage facts.""" + lineage = ("parent_task_id", "root_task_id", "delegation_role", "budget_drive_root") + return {**row["metadata"], **{key: row[key] for key in lineage if row.get(key)}} + + +def test_a_presence_child_inherits_only_the_binding_through_real_admission(tmp_path, monkeypatch): + turn = _parent(tmp_path, {"presence": _presence(), "source": "presence"}) + evt, child = _admitted_child(tmp_path, monkeypatch, turn) + + authority = {"binding_id": BINDING} + assert evt["presence_binding_authority"] == authority and "presence" not in evt + assert child["metadata"]["presence_binding_authority"] == authority + assert "presence" not in child["metadata"] # no speaker: no forced reply, parser or room context + assert child["task_contract"]["capability_ceiling"] == turn.task_contract["capability_ceiling"] + + # A grandchild inherits the same binding from its parent's authority, still without the speaker. + grand_evt, grandchild = _admitted_child(tmp_path, monkeypatch, _parent( + tmp_path, _worker_metadata(child), task_id=child["id"])) + assert grand_evt["presence_binding_authority"] == authority + assert grandchild["metadata"]["presence_binding_authority"] == authority + assert "presence" not in grandchild["metadata"] and grandchild["root_task_id"] == "presence-turn-1" + + # An empty binding narrows its children to nothing; an ordinary parent's child is unchanged. + _evt, empty = _admitted_child(tmp_path, monkeypatch, _parent(tmp_path, {"presence": _presence("")}, + task_id="presence-turn-2")) + assert empty["metadata"]["presence_binding_authority"] == {"binding_id": ""} + plain_evt, plain = _admitted_child(tmp_path, monkeypatch, _parent(tmp_path, {}, task_id="owner-root", + ceiling=False)) + assert "presence_binding_authority" not in plain_evt + assert not {"presence", "presence_binding_authority"} & set(plain["metadata"]) + + +def test_a_lost_or_null_carrier_cannot_widen_an_inherited_ceiling(tmp_path, monkeypatch): + from ouroboros.dialogue_provenance import presence_caller_binding + from ouroboros.presence_authority import presence_work_refusal + from supervisor.task_dispatch import build_scheduled_task_payload + + _evt, child = _admitted_child(tmp_path, monkeypatch, _parent(tmp_path, {"presence": _presence()})) + _work(tmp_path, "foreign", "completed", binding=OTHER, result="Not yours") + for carrier in ({}, {"presence_binding_authority": None}, {"presence_binding_authority": "bad"}): + fields = {"tid": "child-lost", "delegation_role": "subagent", "task_contract": child["task_contract"], + **carrier} + row = build_scheduled_task_payload(fields) + assert row["metadata"]["presence_binding_authority"] == {"binding_id": ""} + ctx = types.SimpleNamespace(task_metadata=row["metadata"], task_contract=child["task_contract"], + task_id="child-lost", drive_root=tmp_path) + assert presence_caller_binding(ctx) == "" + assert presence_work_refusal(ctx, "foreign", drive_root=tmp_path) + # The read-side gate also fails closed before queue payload construction. + lost = types.SimpleNamespace(task_metadata={}, task_contract=child["task_contract"], + task_id="child-lost", drive_root=tmp_path) + assert presence_caller_binding(lost) == "" + assert presence_work_refusal(lost, "foreign", drive_root=tmp_path) + + +def _child_turn(root, row, supervisor_ctx, emitted): + turn = _steering_turn(root, supervisor_ctx, emitted) + turn.task_id, turn.is_direct_chat, turn.task_metadata = row["id"], False, _worker_metadata(row) + return turn + + +def test_a_presence_child_steers_only_its_bindings_work_through_the_supervisor(tmp_path, monkeypatch): + import supervisor.queue as queue + from ouroboros.owner_mailbox import deliver_task_message, drain_owner_entries + from ouroboros.project_dialogue import AGENT_RECEIPT_ID_PREFIX + from ouroboros.tools.control import _steer_task + from supervisor.events import _handle_steer_task + + monkeypatch.setattr(queue, "DRIVE_ROOT", str(tmp_path)) + _evt, child = _admitted_child(tmp_path, monkeypatch, _parent(tmp_path, {"presence": _presence()})) + _evt, plain = _admitted_child(tmp_path, monkeypatch, _parent(tmp_path, {}, task_id="owner-root", ceiling=False)) + queued = {"id": "queued-work", "delegation_role": "root", "chat_id": 77, + "metadata": {"presence": _presence(key=THREAD)}} + running = {"id": "running-work", "delegation_role": "root", "chat_id": 78, + "metadata": {"presence": _presence(key=ROOM)}} + foreign = {"id": "foreign-work", "delegation_role": "root", "chat_id": 79, + "metadata": {"presence": _presence(OTHER)}} + owner = {"id": "owner-work", "delegation_role": "root", "chat_id": 1, "metadata": {}} + supervisor_ctx = _supervisor(tmp_path, pending=[queued, foreign], running=[running, owner, child, plain]) + emitted = [] + turn = _child_turn(tmp_path, child, supervisor_ctx, emitted) + + for target in ("queued-work", "running-work"): # own binding, pending and live (owner Q2) + out = _steer_task(turn, target, "The March figures are confirmed.") + assert "written to its mailbox" in out and "not as owner text" in out + [entry] = drain_owner_entries(tmp_path, target) + assert (entry["provenance"], entry["source_task_id"]) == ("independent_task", child["id"]) + assert "sender_origin" not in entry # the child's run started from its parent, not a room + rendered = [] + deliver_task_message(entry, target, None, rendered.append) + assert rendered[0].startswith(f"[Message from independent task {child['id']}]") + for target in ("foreign-work", "owner-work"): + refused = _steer_task(turn, target, "stop") + assert "STEER_REJECTED" in refused and "presence_work_not_related" in refused + assert drain_owner_entries(tmp_path, target) == [] + assert all(evt["presence_binding_id"] == BINDING and evt["issuer"]["kind"] == "task" for evt in emitted) + + # The supervisor fences the child by its own live row even when an event carries no stamp. + def unstamped(issuer, target): + _handle_steer_task({ + "type": "steer_task", "routing_token": f"tok-{issuer}", "target_task_id": target, + "message": "unstamped", "chat_id": 4242, "client_message_id": f"{AGENT_RECEIPT_ID_PREFIX}{issuer}", + "issuer": {"kind": "task", "task_id": issuer, "root_task_id": issuer}, + }, supervisor_ctx) + return drain_owner_entries(tmp_path, target) + + assert unstamped(child["id"], "foreign-work") == [] + # A legacy/torn live row without its carrier must not widen the Presence ceiling. + supervisor_ctx.RUNNING[child["id"]]["task"] = {**child, "metadata": {}} + assert unstamped(child["id"], "foreign-work") == [] + supervisor_ctx.RUNNING[child["id"]]["task"] = child + assert [entry["text"] for entry in unstamped(plain["id"], "foreign-work")] == ["unstamped"] + + # An ordinary child still messages any listed root, with no Presence stamp at all. + plain_emitted = [] + plain_turn = _child_turn(tmp_path, plain, supervisor_ctx, plain_emitted) + assert "written to its mailbox" in _steer_task(plain_turn, "owner-work", "status please") + assert "presence_binding_id" not in plain_emitted[0] + + +def test_a_presence_child_reads_its_own_tree_and_bindings_work_and_nothing_else(tmp_path, monkeypatch): + _evt, child = _admitted_child(tmp_path, monkeypatch, _parent(tmp_path, {"presence": _presence()})) + write_task_result(tmp_path, "presence-turn-1", "running", metadata={"presence": _presence()}, + result="The turn that started this child") + write_task_result(tmp_path, "sibling", "completed", parent_task_id="presence-turn-1", + root_task_id="presence-turn-1", delegation_role="subagent", result="Sibling result") + _work(tmp_path, "done-room", "completed", key=ROOM, result="Full report text") + _work(tmp_path, "foreign", "completed", binding=OTHER, result="Not yours") + _work(tmp_path, "owner-root", "completed", binding="", result="Owner work") + ctx = ToolContext(repo_dir=tmp_path, drive_root=tmp_path, task_id=child["id"], + task_contract=child["task_contract"], task_metadata=_worker_metadata(child)) + registry = ToolRegistry(repo_dir=tmp_path, drive_root=tmp_path) + registry.set_context(ctx) + + # The ceiling binds presence_scope; the inherited binding admits the tree and the binding's work. + for task_id, text in (("presence-turn-1", "The turn that started this child"), ("sibling", "Sibling result"), + (child["id"], "Subagent"), ("done-room", "Full report text")): + read = registry.execute("get_task_result", {"task_id": task_id}) + assert text in read and "PRESENCE_CAPABILITY_BLOCKED" not in read, read + for task_id in ("foreign", "owner-root"): + refused = registry.execute("get_task_result", {"task_id": task_id}) + assert "is not independent work started from this Presence binding" in refused and "Not yours" not in refused + listed = json.loads(registry.execute("recent_tasks", {"limit": 20})) + assert [row["task_id"] for row in listed["tasks"]] == ["done-room"] + assert listed["presence_scope"] == {"scope": "own_binding", "binding_id": BINDING} + + +def test_a_cyber_acting_presence_child_steers_and_answers_its_parent_through_the_real_loop( + tmp_path, tmp_path_factory, monkeypatch): + """Fake-model replay: under Cyber Pro an acting child holds the whole catalog, so the + inherited ceiling's steer_task reaches the real supervisor consumer through the registry; + the child then finishes as an ordinary child. Nothing is sent to any transport.""" + import queue as stdlib_queue + + import supervisor.queue as queue + from ouroboros import agent_task_pipeline as pipeline + from ouroboros import loop + from ouroboros.owner_mailbox import drain_owner_entries + from ouroboros.tools.registry import TaskConstraint + from supervisor.events import _handle_steer_task + + monkeypatch.setattr(queue, "DRIVE_ROOT", str(tmp_path)) + monkeypatch.setenv("OUROBOROS_TASK_REVIEW_MODE", "off") + monkeypatch.setenv("OUROBOROS_RUNTIME_MODE", "cyber_pro") + worktree = tmp_path_factory.mktemp("shared-tree") # disjoint from the repo and data roots + _evt, child = _admitted_child(tmp_path, monkeypatch, _parent(tmp_path, {"presence": _presence()})) + mine = {"id": "running-work", "delegation_role": "root", "chat_id": 78, + "metadata": {"presence": _presence(key=ROOM)}} + foreign = {"id": "foreign-work", "delegation_role": "root", "chat_id": 79, + "metadata": {"presence": _presence(OTHER)}} + supervisor_ctx = _supervisor(tmp_path, running=[mine, foreign, child]) + registry = ToolRegistry(repo_dir=tmp_path, drive_root=tmp_path) + ctx = registry._ctx + ctx.task_id, ctx.current_chat_id = child["id"], 4242 + ctx.task_contract, ctx.task_metadata = child["task_contract"], _worker_metadata(child) + ctx.task_constraint = TaskConstraint(mode="acting_subagent", surface="external_workspace", write_root=str(worktree)) + ctx.workspace_root, ctx.workspace_mode = str(worktree), "external_workspace" + loop_events = [] + + def supervisor_consumer(event, *_a, **_k): + # Routing reaches the real supervisor handler; the loop's other events are only kept. + if event.get("type") == "steer_task": + _handle_steer_task(event, supervisor_ctx) + else: + loop_events.append(event) + + event_queue = types.SimpleNamespace(put_nowait=supervisor_consumer, put=supervisor_consumer) + steer = [{"id": f"steer-{target}", "type": "function", "function": { + "name": "steer_task", "arguments": json.dumps({"task_id": target, "message": "Figures confirmed."})}} + for target in ("foreign-work", "running-work")] + calls, replies = [], iter([{"role": "assistant", "content": None, "tool_calls": steer}, + {"content": "Findings: the figures are confirmed."}]) + + def respond(_llm, messages, *_a, **_k): + calls.append([dict(row) for row in messages]) + return next(replies), 0.0 + + monkeypatch.setattr(loop, "call_llm_with_retry", respond) + task = {"id": child["id"], "type": "task", "chat_id": 4242, "text": "Check the figures", + "delegation_role": "subagent", "parent_task_id": "presence-turn-1", "root_task_id": "presence-turn-1", + "metadata": child["metadata"], "_skip_post_task_synthesis": True} + text, usage, trace = loop.run_llm_loop( + [{"role": "user", "content": "Check the figures"}], registry, + types.SimpleNamespace(default_model=lambda: "test-model"), tmp_path / "logs", + lambda *_a, **_kw: None, stdlib_queue.Queue(), task_id=child["id"], drive_root=tmp_path, + event_queue=event_queue, + ) + events = [] + pipeline.emit_task_results(types.SimpleNamespace(drive_root=tmp_path, repo_dir=tmp_path), None, None, + events, task, text, usage, trace, 0.0, tmp_path / "logs", ctx=ctx) + + results = {row["tool_call_id"]: row["content"] for row in calls[-1] if row.get("role") == "tool"} + assert "presence_work_not_related" in results["steer-foreign-work"] + assert "written to its mailbox" in results["steer-running-work"] + assert drain_owner_entries(tmp_path, "foreign-work") == [] + assert [entry["source_task_id"] for entry in drain_owner_entries(tmp_path, "running-work")] == [child["id"]] + assert text == "Findings: the figures are confirmed." + assert "[PRESENCE_DELIVERY]" not in json.dumps(calls) + assert not [event for event in events if event["type"] == "presence_result"] # it answers its parent diff --git a/tests/test_presence_root_carrier.py b/tests/test_presence_root_carrier.py new file mode 100644 index 000000000..72fefe460 --- /dev/null +++ b/tests/test_presence_root_carrier.py @@ -0,0 +1,248 @@ +"""One Presence carrier from producer to admission to reader, and the precedence steer shares. + +A delegated descendant's promote and that root's follow-ups keep the binding the +descendant acts for (never the speaker's metadata) and the inherited ceiling. Steer +judges its target by the canonical record first, exactly as read and cancel do, and +the scoped reader states an unreadable queue snapshot as a gap. Deterministic; no +transport sends anything. +""" + +from __future__ import annotations + +import json +import types + +from ouroboros.presence_authority import presence_ceiling_payload +from ouroboros.task_results import load_task_result, write_task_result +from ouroboros.tools.registry import ToolContext +from tests.test_presence_own_work import ( + BINDING, + OTHER, + THREAD, + _admitted_child, + _ceiling, + _parent, + _presence, + _queue, + _registry, + _steering_turn, + _supervisor, + _work, + _worker_metadata, +) + + +def test_one_carrier_is_produced_for_new_roots_and_read_back_everywhere(tmp_path): + from ouroboros.dialogue_provenance import presence_record_binding, presence_root_carrier + from ouroboros.project_facts import resolve_project_id + + speaker, authority = {"presence": _presence()}, {"presence_binding_authority": {"binding_id": BINDING}} + ceiling = {"capability_ceiling": presence_ceiling_payload(_ceiling())} + assert presence_root_carrier(speaker) == speaker # a speaker's root answers its conversation + assert presence_root_carrier({**authority, "source": "x"}) == authority # a descendant's: the binding only + for lost in ({"presence": {}}, {"presence_binding_authority": "bad"}): + assert presence_root_carrier(lost) == {"presence_binding_authority": {"binding_id": ""}} + assert presence_root_carrier({}, task_contract=ceiling) == {"presence_binding_authority": {"binding_id": ""}} + assert presence_root_carrier({"source": "owner"}) == {} and presence_root_carrier(None) == {} + for carrier in (speaker, authority): + assert presence_record_binding({"metadata": carrier}) == BINDING + # Presence moves cwd, never the canonical memory scope into a workspace-derived Project. + assert resolve_project_id({"workspace_root": str(tmp_path), "metadata": carrier}) == "" + assert resolve_project_id({"workspace_root": str(tmp_path), "metadata": {}}).startswith("proj_") + + +def test_a_presence_childs_promote_and_follow_up_stay_its_bindings_work_under_the_ceiling(tmp_path, monkeypatch): + """A delegated descendant carries the binding, not the speaker. The real promote tool, + the real supervisor admission, the readers, steer and a follow-up of that root keep + that one carrier: the root is this binding's own work under the inherited ceiling, + speaks to no conversation and chooses no Project, workspace or source.""" + import supervisor.queue as queue + import supervisor.workers as workers + from ouroboros.dialogue_provenance import is_presence_task, presence_related_work + from ouroboros.owner_mailbox import drain_owner_entries + from ouroboros.peer_roster import maybe_append_roster_note + from ouroboros.project_facts import resolve_project_id + from ouroboros.tools.control import _steer_task + from ouroboros.tools.control_routing import _promote_chat_to_task + from ouroboros.tools.followup import _handle_schedule_followup, _manage_schedules + from ouroboros.tools.project_journal import _scope_authority + from ouroboros.tools.recent_tasks import _restricted_actor + from supervisor.events import _handle_promote_chat_to_task + from tests.test_promote_chat_flow import _confirm_promote + + monkeypatch.setattr(workers, "DRIVE_ROOT", tmp_path) + monkeypatch.setattr(queue, "DRIVE_ROOT", str(tmp_path)) + _confirm_promote(monkeypatch) + _evt, child = _admitted_child(tmp_path, monkeypatch, _parent(tmp_path, {"presence": _presence()})) + ceiling = child["task_contract"]["capability_ceiling"] + pending, emitted = [], [] + handler_ctx = types.SimpleNamespace( + DRIVE_ROOT=tmp_path, WORKERS={0: types.SimpleNamespace()}, PENDING=pending, bridge=None, + append_jsonl=lambda *_a, **_k: None, persist_queue_snapshot=lambda **_k: True, + enqueue_task=lambda task: pending.append(dict(task)) or pending[-1], + load_state=lambda: {"owner_chat_id": 1}, + ) + child_ctx = types.SimpleNamespace( + pending_events=[], current_chat_id=4242, drive_root=tmp_path, budget_drive_root=str(tmp_path), + project_id="", task_id=child["id"], task_metadata=_worker_metadata(child), is_direct_chat=False, + # The promoter's own claim must not become the new root's acceptance premise. + task_contract={**child["task_contract"], "acceptance_claims": [{"claim": "The child's figures are checked."}]}, + event_queue=types.SimpleNamespace( + put_nowait=lambda event: emitted.append(event) or _handle_promote_chat_to_task(event, handler_ctx)), + ) + + out = _promote_chat_to_task(child_ctx, "Compile the full audit", project_name="Widened", + workspace_root=str(tmp_path / "elsewhere"), source="api", predecessor_task_id="") + + authority = {"binding_id": BINDING} + [evt], [root] = emitted, pending + assert out.startswith("OK: task") and "Widened" not in out, out + assert evt["presence_binding_authority"] == authority and "presence" not in evt # binding, never speaker + assert [evt[key] for key in ("project_id", "project_name", "workspace_root", "source")] == ["", "", "", ""] + assert evt["task_contract"]["capability_ceiling"] == ceiling + assert (root["id"], root["delegation_role"], root.get("parent_task_id")) == (evt["task_id"], "root", None) + assert root["metadata"]["presence_binding_authority"] == authority + assert "presence" not in root["metadata"] and not is_presence_task(root) # no forced reply or room context + assert root["task_contract"]["capability_ceiling"] == ceiling and root["task_contract"]["acceptance_claims"] == [] + assert not root.get("project_id") and resolve_project_id(root) == "" + stored = load_task_result(tmp_path, root["id"]) + assert stored["status"] == "scheduled" and stored["metadata"]["presence_binding_authority"] == authority + + # Readers and steer of the binding reach it from another conversation; another binding does not. + registry, _turn = _registry(tmp_path, key=THREAD) + assert root["id"] in [row["task_id"] for row in json.loads(registry.execute("recent_tasks", {"limit": 20}))["tasks"]] + assert "PRESENCE_CAPABILITY_BLOCKED" not in registry.execute("get_task_result", {"task_id": root["id"]}) + stranger, _stranger = _registry(tmp_path, binding=OTHER) + assert "PRESENCE_CAPABILITY_BLOCKED" in stranger.execute("get_task_result", {"task_id": root["id"]}) + steered = _steer_task(_steering_turn(tmp_path, _supervisor(tmp_path, pending=[root]), []), root["id"], "Q2 is in.") + assert "written to its mailbox" in steered and len(drain_owner_entries(tmp_path, root["id"])) == 1 + + # A follow-up of that root keeps the same carrier and ceiling, never a Project. + root_ctx = ToolContext(repo_dir=tmp_path, drive_root=tmp_path, task_id=root["id"], current_chat_id=4242, + task_contract=root["task_contract"], project_id="widened", + task_metadata={**root["metadata"], "root_task_id": root["id"], "delegation_role": "root"}) + scheduled_text = _handle_schedule_followup(root_ctx, objective="Revisit the audit", run_at="2030-01-01T00:00:00Z") + assert scheduled_text.startswith("FOLLOWUP_SCHEDULED"), scheduled_text + [record] = queue.list_scheduled_tasks(tmp_path)["tasks"] + followup = queue._task_from_schedule(record) + assert followup["metadata"]["presence_binding_authority"] == authority and "presence" not in followup["metadata"] + assert followup["task_contract"]["capability_ceiling"] == ceiling + assert not followup.get("project_id") and presence_related_work(BINDING, followup) + # Work acting for a binding reads no owner schedule table, exactly as its speaker cannot. + assert "RESOURCE_CONSTRAINT_BLOCKED" in _manage_schedules(root_ctx, "list") + child_tools = types.SimpleNamespace(task_metadata=_worker_metadata(child), task_contract=child["task_contract"]) + assert "RESOURCE_CONSTRAINT_BLOCKED" in _manage_schedules(child_tools, "list") + + # An ordinary child's promote is unchanged: no carrier, and its project request stands. + _plain_evt, plain = _admitted_child(tmp_path, monkeypatch, _parent(tmp_path, {}, task_id="owner-root", ceiling=False)) + plain_events = [] + plain_ctx = types.SimpleNamespace(**{**vars(child_ctx), "task_id": plain["id"], "task_metadata": _worker_metadata(plain), + "task_contract": plain["task_contract"], + "event_queue": types.SimpleNamespace(put_nowait=plain_events.append)}) + _promote_chat_to_task(plain_ctx, "Owner-side work", project_name="Chosen", predecessor_task_id="") + assert not {"presence", "presence_binding_authority"} & set(plain_events[0]) + assert plain_events[0]["project_name"] == "Chosen" + + # Work acting for a binding holds no other owner cross-focus view a speaker's promoted root is denied. + _queue(tmp_path, running=[{"id": "owner-work", "delegation_role": "root", "description": "Owner audit"}]) + owner_root = ToolContext(repo_dir=tmp_path, drive_root=tmp_path, task_id="owner-root-2", + task_metadata={"delegation_role": "root", "root_task_id": "owner-root-2"}) + assert maybe_append_roster_note(owner_root, [], tmp_path) is True # an owner root sees its peers + assert maybe_append_roster_note(root_ctx, [], tmp_path) is False + assert _restricted_actor(root_ctx) and not _restricted_actor(owner_root) + assert (_scope_authority(root_ctx)[0], _scope_authority(owner_root)[0]) == ("presence", "root") + + +def test_supervisor_steering_follows_the_canonical_binding_over_a_stale_queue_row(tmp_path, monkeypatch): + """Steer judges whose work the target is like read and cancel: the canonical record + decides (a malformed carrier narrows to nothing); the live row speaks only for a + record without Presence provenance. A malformed sender stamp narrows to nothing.""" + import supervisor.queue as queue + from ouroboros.owner_mailbox import drain_owner_entries + from ouroboros.project_dialogue import AGENT_RECEIPT_ID_PREFIX + from ouroboros.tools.control import _steer_task + from supervisor.events import _handle_steer_task + + monkeypatch.setattr(queue, "DRIVE_ROOT", str(tmp_path)) + names = ("theirs-pending", "theirs-running", "mine-pending", "legacy-pending", "torn-carrier") + live = {name: {"id": name, "delegation_role": "root", "chat_id": 70 + index, + "metadata": {"presence": _presence()}} # every live row claims this binding + for index, name in enumerate(names)} + _work(tmp_path, "theirs-pending", "scheduled", binding=OTHER) + _work(tmp_path, "theirs-running", "running", binding=OTHER) + _work(tmp_path, "mine-pending", "scheduled", key=THREAD) # the canonical record agrees + write_task_result(tmp_path, "legacy-pending", "scheduled", delegation_role="root") # no provenance + write_task_result(tmp_path, "torn-carrier", "scheduled", delegation_role="root", + metadata={"presence": {"binding_id": 7}}) + supervisor_ctx = _supervisor(tmp_path, running=[live["theirs-running"]], + pending=[live[name] for name in names if name != "theirs-running"]) + turn = _steering_turn(tmp_path, supervisor_ctx, []) + + for target in ("mine-pending", "legacy-pending"): # own pending work stays steerable (owner Q2) + assert "written to its mailbox" in _steer_task(turn, target, "New figures."), target + assert len(drain_owner_entries(tmp_path, target)) == 1 + for target in ("theirs-pending", "theirs-running", "torn-carrier"): + refused = _steer_task(turn, target, "stop") + assert "STEER_REJECTED" in refused and "presence_work_not_related" in refused, target + assert drain_owner_entries(tmp_path, target) == [] + + def stamped(stamp, token): # the entries this one event adds + before = len(drain_owner_entries(tmp_path, "mine-pending")) + _handle_steer_task({ + "type": "steer_task", "routing_token": token, "target_task_id": "mine-pending", + "message": "stamped", "chat_id": 4242, "client_message_id": f"{AGENT_RECEIPT_ID_PREFIX}{token}", + "issuer": {"kind": "task", "task_id": "presence-turn-1", "root_task_id": "presence-turn-1"}, + "presence_binding_id": stamp, + }, supervisor_ctx) + return drain_owner_entries(tmp_path, "mine-pending")[before:] + + assert [entry["text"] for entry in stamped(BINDING, "tok-own")] == ["stamped"] + for index, malformed in enumerate((None, 7, {"binding_id": BINDING}, [BINDING], "")): + assert stamped(malformed, f"tok-bad-{index}") == [], malformed + + +def test_an_unreadable_queue_snapshot_is_a_stated_gap_not_absent_queued_work(tmp_path): + from ouroboros.presence_context import build_presence_context_section + + _work(tmp_path, "done-here", "completed", result="Earlier answer") + registry, _ctx = _registry(tmp_path) + page = json.loads(registry.execute("recent_tasks", {"limit": 20})) + assert "read_gap" not in page # never written: nothing was ever queued + snapshot = tmp_path / "state" / "queue_snapshot.json" + snapshot.parent.mkdir(parents=True, exist_ok=True) + for torn in ("{not json", json.dumps(["not", "an", "object"]), + json.dumps({"pending": {"queued-only": {}}, "running": []})): + snapshot.write_text(torn, encoding="utf-8") + page = json.loads(registry.execute("recent_tasks", {"limit": 20})) + assert page["read_gap"] == {"queue_snapshot": "unreadable"}, torn + assert [row["task_id"] for row in page["tasks"]] == ["done-here"] + section = build_presence_context_section(tmp_path, {**_presence(), "instructions": "Be useful."}, "turn-x") + assert "unreadable now: the queue snapshot (queued work); this binding's work may be among them" in section + + _queue(tmp_path, pending=[{"id": "queued-only", "delegation_role": "root", + "metadata": {"presence": _presence(key=THREAD)}}]) + page = json.loads(registry.execute("recent_tasks", {"limit": 20})) + assert "read_gap" not in page and {row["task_id"] for row in page["tasks"]} == {"queued-only", "done-here"} + + +def test_malformed_or_lost_canonical_binding_never_borrows_a_queued_claim(tmp_path): + """A parseable result with Presence authority but no valid binding outranks stale queue A.""" + from ouroboros.dialogue_provenance import presence_target_record + tasks = [ + {"id": name, "delegation_role": "root", "metadata": {"presence": _presence()}} + for name in ("malformed-result", "lost-carrier") + ] + _queue(tmp_path, pending=tasks[:1], running=tasks[1:]) + write_task_result(tmp_path, "malformed-result", "scheduled", delegation_role="root", + metadata={"presence": {"binding_id": 7}}, description="secret other task") + write_task_result(tmp_path, "lost-carrier", "running", delegation_role="root", metadata={}, + task_contract={"capability_ceiling": presence_ceiling_payload(_ceiling())}, + description="lost binding task") + registry, _ctx = _registry(tmp_path) + page = json.loads(registry.execute("recent_tasks", {"limit": 20})) + assert not {"malformed-result", "lost-carrier"} & {row["task_id"] for row in page["tasks"]} + assert "lost-carrier" not in {row["task_id"] for row in page["running"]} + for row in tasks: + record = presence_target_record(tmp_path, row["id"], queue_row=row) + assert record["metadata"] != row["metadata"] + assert "PRESENCE_CAPABILITY_BLOCKED" in registry.execute("get_task_result", {"task_id": row["id"]}) diff --git a/tests/test_presence_runner.py b/tests/test_presence_runner.py index 84c7168d9..d7606e7ef 100644 --- a/tests/test_presence_runner.py +++ b/tests/test_presence_runner.py @@ -431,6 +431,46 @@ def test_previous_turn_shows_what_a_transport_tool_delivered(tmp_path): tmp_path, captured[-1]["metadata"]["presence"]) +def test_deferred_handoff_keeps_internal_finish_note_out_of_prior_speech(tmp_path): + """A tool-send note stays context even when owed work makes the turn deferred.""" + note = "helper failed; the table was already sent" + captured = [] + first = _pointer_turn(tmp_path, "note-e1", {"outcome": "deferred", "text": "", "message": "", + "finish_note": note, "work_ref": "owed-task"}) + assert (first.outcome, first.text, first.work_ref) == ("deferred", "", "owed-task") + _pointer_turn(tmp_path, "note-e2", {"outcome": "silent", "text": ""}, captured=captured) + pointer = captured[-1]["metadata"]["presence"]["previous_turn"] + assert pointer["message"] == "" and pointer["finish_note"] == note + section = build_presence_context_section(tmp_path, captured[-1]["metadata"]["presence"]) + assert 'finish note "helper failed; the table was already sent"' in section + assert '): "helper failed; the table was already sent"' not in section + + +def test_legacy_deferred_pointer_is_checked_against_canonical_reply_before_quoting(tmp_path): + """Old deferred tool-send notes shared `message` with speech; source separates them.""" + from ouroboros.presence_bindings import conversation_key + from ouroboros.presence_runner import _previous_turn_path + + captured = [] + note = "helper failed, result already sent" + _pointer_turn(tmp_path, "old-note", {"outcome": "deferred", "text": "", "message": note, + "work_ref": "owed"}) + path = _previous_turn_path(tmp_path, conversation_key("telegram", "bot-1", "room-1", "topic-1")) + historical = path.read_bytes() + _pointer_turn(tmp_path, "after-note", {"outcome": "silent", "text": ""}, captured=captured) + previous = captured[-1]["metadata"]["presence"]["previous_turn"] + assert (previous["message"], previous["finish_note"]) == ("", note) + assert b'"finish_note"' not in historical # source remains unchanged; this is a read projection + assert 'finish note "helper failed, result already sent"' in build_presence_context_section( + tmp_path, captured[-1]["metadata"]["presence"]) + + _pointer_turn(tmp_path, "old-speech", {"outcome": "deferred", "text": "Still working", "message": "Still working", + "work_ref": "owed"}) + _pointer_turn(tmp_path, "after-speech", {"outcome": "silent", "text": ""}, captured=captured) + spoken = captured[-1]["metadata"]["presence"]["previous_turn"] + assert spoken["message"] == "Still working" and "finish_note" not in spoken + + def test_previous_turn_reports_the_fate_of_its_deferred_work(tmp_path): """"Work continues" only while the child runs; a finished child's answer or failure is stated instead.""" from ouroboros.task_results import write_task_result diff --git a/tests/test_presence_terminal_authorship.py b/tests/test_presence_terminal_authorship.py index db0b81986..5cfd4cb19 100644 --- a/tests/test_presence_terminal_authorship.py +++ b/tests/test_presence_terminal_authorship.py @@ -1,5 +1,6 @@ """External speech follows terminal authorship through execution and replay.""" +import json import queue from types import SimpleNamespace @@ -19,12 +20,12 @@ from tests.test_presence_failed_handoff import _failed_parent from tests.test_presence_runner import _admission -def _run_loop(root, monkeypatch, responses, *, held=False): +def _run_loop(root, monkeypatch, responses, *, held=False, presence=None): monkeypatch.setenv("OUROBOROS_TASK_REVIEW_MODE", "off") monkeypatch.setenv("OUROBOROS_MAX_ROUNDS", "1") registry = ToolRegistry(repo_dir=root, drive_root=root) registry._ctx.is_direct_chat = True - registry._ctx.task_metadata = {"inline_max_rounds": 1} + registry._ctx.task_metadata = {"inline_max_rounds": 1, **({"presence": presence} if presence else {})} registry._ctx.task_contract = {"capability_ceiling": presence_ceiling_payload(_admission().capability_ceiling)} registry.override_handler("chat_history", lambda *_a, **_kw: "Synthetic history") calls, held_origins = [], [] @@ -65,7 +66,12 @@ def _read_response(): @pytest.mark.parametrize("authored", [False, True]) def test_real_round_limit_delivers_only_the_current_authored_final(tmp_path, monkeypatch, authored): reply = "I found the record; the remaining check is incomplete." if authored else "" - result, stored, calls, _held = _run_loop(tmp_path, monkeypatch, [_read_response(), {"content": reply}]) + forced = json.dumps({"delivery_control": "replace", "full_answer": reply, + "presence_finish": {"outcome": "message", "message": reply}}) if authored else "" + # A real turn's context carries its Presence metadata; that, with the ceiling, arms the forced call. + presence = {"binding_id": "1" * 32, "event": {"conversation_key": "telegram:bot-1:room-1:topic-1"}} + result, stored, calls, _held = _run_loop(tmp_path, monkeypatch, [_read_response(), {"content": forced}], + presence=presence) assert len(calls) == 2 and stored["reason_code"] == "round_limit" assert stored["terminal_origin"] == ("model_final" if authored else "host_notice") assert result["outcome"] == ("message" if authored else "silent") @@ -78,6 +84,12 @@ def test_real_round_limit_delivers_only_the_current_authored_final(tmp_path, mon def test_exact_host_diagnostic_is_deliverable_when_the_model_authors_it(tmp_path, monkeypatch): + """Owner Q4 keeps host bytes internal by default without forbidding model speech. + + Host-authored terminal bytes never speak, and a forced final speaks only its typed + declaration; an ordinary final is the model's own text in one channel, so a model + that chooses to restate a diagnostic there is heard. The host adds no text filter. + """ _result, host, _calls, _held = _run_loop(tmp_path / "host", monkeypatch, [_read_response(), {"content": ""}]) result, authored, calls, _held = _run_loop(tmp_path / "author", monkeypatch, [{"content": host["result"]}]) assert len(calls) == 1 # ordinary implicit final, no presence_finish required diff --git a/tests/test_presence_tools.py b/tests/test_presence_tools.py index 320432c20..17fa82139 100644 --- a/tests/test_presence_tools.py +++ b/tests/test_presence_tools.py @@ -177,7 +177,7 @@ def test_initiate_presence_resolves_binding_and_reports_actual_delivery(monkeypa assert captured["event"].actor["kind"] == "proactive_initiation" -def test_presence_cancel_work_accepts_only_same_binding_and_conversation(monkeypatch, tmp_path) -> None: +def test_presence_cancel_work_accepts_own_binding_work_from_any_of_its_conversations(monkeypatch, tmp_path) -> None: ctx = _ctx(tmp_path) ctx.task_metadata = { "presence": { @@ -185,28 +185,33 @@ def test_presence_cancel_work_accepts_only_same_binding_and_conversation(monkeyp "event": {"conversation_key": "telegram:bot-1:room-1"}, } } - atomic_write_json( - tmp_path / "task_results" / "presence-work-1.json", - { - "_schema_version": 1, - "task_id": "presence-work-1", - "status": "running", - "metadata": { - "presence": { - "binding_id": "1" * 32, - "event": {"conversation_key": "telegram:bot-1:room-1"}, - } - }, - }, - ) + + def row(task_id, *, binding="1" * 32, key="telegram:bot-1:room-1", **fields): + atomic_write_json(tmp_path / "task_results" / f"{task_id}.json", { + "_schema_version": 1, "task_id": task_id, "status": "running", + "metadata": {"presence": {"binding_id": binding, "event": {"conversation_key": key}}} if binding else {}, + **fields, + }) + + row("promoted-work-1", delegation_role="root", root_task_id="promoted-work-1") + row("other-thread-work", key="telegram:bot-1:room-1:thread-9", delegation_role="root") + row("foreign-binding-work", binding="2" * 32, delegation_role="root") + row("presence-inline-turn") # an inline turn is not deferred work + row("owner-root", binding="", delegation_role="root") monkeypatch.setattr( "ouroboros.tools.join_ledger._cancel_task", lambda _ctx, task_id, reason="": f"cancel:{task_id}:{reason}", ) entry = next(item for item in get_tools() if item.name == "presence_cancel_work") - assert entry.handler(ctx, "presence-work-1", "no longer needed") == ( - "cancel:presence-work-1:no longer needed" + assert entry.handler(ctx, "promoted-work-1", "no longer needed") == ( + "cancel:promoted-work-1:no longer needed" ) + # Owner Q1: the same nonempty binding, a different thread or room of it. + assert entry.handler(ctx, "other-thread-work") == "cancel:other-thread-work:" ctx.task_metadata["presence"]["event"]["conversation_key"] = "telegram:bot-1:other" - assert entry.handler(ctx, "presence-work-1") == "ERROR: PRESENCE_WORK_NOT_CORRELATED" + assert entry.handler(ctx, "promoted-work-1") == "cancel:promoted-work-1:" + for foreign in ("foreign-binding-work", "presence-inline-turn", "owner-root", "never-existed"): + assert entry.handler(ctx, foreign).startswith("ERROR: PRESENCE_WORK_NOT_CORRELATED") + ctx.task_metadata["presence"]["binding_id"] = "" # an empty binding never compares equal + assert entry.handler(ctx, "promoted-work-1").startswith("ERROR: PRESENCE_WORK_NOT_CORRELATED") diff --git a/tests/test_presence_transport_consumer.py b/tests/test_presence_transport_consumer.py new file mode 100644 index 000000000..9626923f3 --- /dev/null +++ b/tests/test_presence_transport_consumer.py @@ -0,0 +1,163 @@ +"""A forced Presence final under the Host contract the installed transport actually honours. + +The fake transport below mirrors the installed Telegram adapter without importing +Hub code: ``submit`` is ``custody.record_submission`` (a message/deferred turn body +is queued, a deferred turn registers its work_ref) and ``poll`` is +``runtime.process_one_work`` over ``host.poll`` (pending keeps the work; a terminal +result must echo the polled work_ref, closes the work, and only a ``message`` body +is sent). The real /presence/turn and /presence/work endpoints, loop and pipeline +run with a scripted model; nothing leaves the process. + +Boundary, not covered here: a polled late result that is itself ``deferred`` (owed +work scheduling further work) is stored truthfully (body and nested work_ref), but +that consumer sends no deferred late body and closes the work, and the poll +response must echo the polled work_ref, so the nested obligation cannot reach it +without a transport protocol change. +""" + +from __future__ import annotations + +import queue +from types import SimpleNamespace + +import pytest +from starlette.testclient import TestClient + +from ouroboros import agent_task_pipeline as pipeline, loop +from ouroboros.gateway.host_service import create_host_service_app +from ouroboros.presence_runner import PresenceTurnGate, run_presence_turn +from ouroboros.task_results import write_task_result +from ouroboros.tools.registry import ToolRegistry +from tests.test_host_service_api import _seed_presence_behavior, _seed_token +from tests.test_presence_forced_delivery import RECORD, _forced, _read + +_TOKEN = "presence-token" +_OUTCOMES = {"message", "silent", "tool_delivered", "deferred"} +PARTIAL = "Q1 is ready: 41. Q2 is still being checked." +RESULT = "Q2 is ready too: 43." +NOTE = "sent the table via the transport tool; helper child-7 failed with provider 400" + + +class _InstalledTransportContract: + def __init__(self, client: TestClient, binding: str) -> None: + self.client, self.binding = client, binding + self.outbox: list[str] = [] + self.open_work: list[str] = [] + + def submit(self, body: dict) -> None: + assert body["ok"] is True and body["status"] == "completed" and body["outcome"] in _OUTCOMES + if body["text"] and body["outcome"] in {"message", "deferred"}: + self.outbox.append(body["text"]) + if body["outcome"] == "deferred": + assert body["work_ref"], "deferred submission requires work_ref" + self.open_work.append(body["work_ref"]) + + def poll(self) -> None: + for work_ref in list(self.open_work): + body = self.client.get(f"/presence/work/{work_ref}", params={"binding_id": self.binding}, + headers={"X-Skill-Token": _TOKEN}).json() + if body["status"] == "pending": + continue + assert body["status"] in {"completed", "failed", "cancelled"} and body["outcome"] in _OUTCOMES + assert body.get("work_ref") in {"", work_ref}, "presence Host returned a different work_ref" + self.open_work.remove(work_ref) + if body["outcome"] == "message" and body["text"]: + self.outbox.append(body["text"]) + + +def _scripted_agent(root, monkeypatch, forced, *, handoff=None): + """The real loop and pipeline for the task it is handed: one tool round, then the ONE forced call.""" + + class Agent: + def handle_task(self, task): + monkeypatch.setenv("OUROBOROS_TASK_REVIEW_MODE", "off") + monkeypatch.setenv("OUROBOROS_MAX_ROUNDS", "1") + registry = ToolRegistry(repo_dir=root, drive_root=root) + ctx = registry._ctx + ctx.is_direct_chat = bool(task.get("_is_direct_chat")) + ctx.task_metadata = {**task["metadata"], "inline_max_rounds": 1} + ctx.task_contract = dict(task["task_contract"]) + if handoff: + ctx._swarm_handoff_attempt = dict(handoff) + registry.override_handler("chat_history", lambda *_a, **_kw: "Synthetic history") + replies = iter([_read(), {"content": forced}]) + monkeypatch.setattr(loop, "call_llm_with_retry", lambda *_a, **_k: (next(replies), 0.0)) + task["_skip_post_task_synthesis"] = True + text, usage, trace = loop.run_llm_loop( + [{"role": "user", "content": task["text"]}], registry, + SimpleNamespace(default_model=lambda: "test-model"), root / "logs", + lambda *_a, **_kw: None, queue.Queue(), task_id=task["id"], drive_root=root, + ) + events = [] + pipeline.emit_task_results(SimpleNamespace(drive_root=root, repo_dir=root), None, None, + events, task, text, usage, trace, 0.0, root / "logs", ctx=ctx) + return events + + return Agent() + + +def _turn(client, binding): + return client.post("/presence/turn", headers={"X-Skill-Token": _TOKEN}, json={ + "binding_id": binding, + "event": { + "source_event_id": "telegram:bot-1:42", "provider": "telegram", "account_id": "bot-1", + "conversation_id": "room-1", "thread_id": "topic-1", "conversation_key": "ignored", + "actor": {"platform_actor_id": "user-7"}, "conversation": {"title": "Community"}, + "message": {"message_id": "42"}, "text": "Please compile Q1 and Q2", + }, + }).json() + + +@pytest.mark.parametrize("turn_forced,spoken_now", [ + (_forced("message", PARTIAL), [PARTIAL]), # a declared partial beside owed work + (_forced("deferred", PARTIAL), [PARTIAL]), + (_forced("tool_delivered", NOTE), []), # the note is context, never a reply + (RECORD, []), # an undeclared internal record says nothing new +], ids=["message", "deferred", "tool_delivered", "undeclared"]) +def test_the_installed_transport_gets_the_partial_now_and_the_owed_result_later( + tmp_path, monkeypatch, turn_forced, spoken_now): + _seed_token(tmp_path, skill="telegram-bot", token=_TOKEN, permissions=["presence"], + manifest_permissions=["presence"]) + binding = _seed_presence_behavior(tmp_path) + repo = tmp_path / "repo" + repo.mkdir() + captured = {} + + def runner(**kwargs): + def factory(**_kwargs): + agent = _scripted_agent(tmp_path, monkeypatch, turn_forced, + handoff={"status": "scheduled", "task_id": "work-9"}) + original = agent.handle_task + + def handle(task): + captured["turn"] = task + # The admitted promotion the handoff names, canonical from admission. + write_task_result(tmp_path, "work-9", "scheduled", delegation_role="root", + root_task_id="work-9", description="Compile Q2", + metadata={"presence": dict(task["metadata"]["presence"])}) + return original(task) + + agent.handle_task = handle + return agent + + return run_presence_turn(repo_dir=repo, drive_root=tmp_path, agent_factory=factory, + gate=PresenceTurnGate(1), **kwargs) + + with TestClient(create_host_service_app(tmp_path, presence_runner=runner)) as client: + transport = _InstalledTransportContract(client, binding) + transport.submit(_turn(client, binding)) + assert transport.outbox == spoken_now and transport.open_work == ["work-9"] # speech AND custody + + transport.poll() # the owed work has not run: nothing is sent and it stays owed + assert transport.outbox == spoken_now and transport.open_work == ["work-9"] + + turn = captured["turn"] + work = {"id": "work-9", "type": "task", "chat_id": turn["chat_id"], "text": "Compile Q2", + "delegation_role": "root", "root_task_id": "work-9", "_presence_origin": True, + "metadata": {"presence": dict(turn["metadata"]["presence"])}, + "task_contract": dict(turn["task_contract"])} + _scripted_agent(tmp_path, monkeypatch, _forced("message", RESULT)).handle_task(work) + transport.poll() + + assert transport.outbox == spoken_now + [RESULT] and transport.open_work == [] + assert not any(RECORD in text or NOTE in text for text in transport.outbox) diff --git a/tests/test_reference_book_budgets.py b/tests/test_reference_book_budgets.py index 3d8b83b38..a8b4cb3dd 100644 --- a/tests/test_reference_book_budgets.py +++ b/tests/test_reference_book_budgets.py @@ -156,7 +156,10 @@ CHAPTER_BYTE_BUDGETS: dict[str, int] = { # 308900 -> 309800 (#1262): the one name-miss answer, every-mode discovery and the MCP # lookup-before-safety facts are mechanisms no older text held; the "Not found" # sentence they sit in was compressed rather than appended to (measured 309712). - "docs/architecture/06-agent-core.md": 309800, + # 309800 -> 310100 (TZ2 + #1262 merge, measured 309985): the Presence task-message + # own-binding boundary and forced declaration remain beside #1262's name-miss + # contract; both are independent rules in the same chapter, not duplicate prose. + "docs/architecture/06-agent-core.md": 310100, # 36991 -> 37300: the facade paragraph names the three loop constants runtime_limits.py # gained (events batch bound, budget-projection retry interval); no older text to displace. # 37300 -> 38400 (PR #1207): the Z.ai (`zai::`) direct provider gets its own route @@ -201,7 +204,18 @@ CHAPTER_BYTE_BUDGETS: dict[str, int] = { # conversation key, placeholder re-run and its lost-attempt facts, presence-local liveness, # previous-turn pointer and its replay repair, split in-flight budgets, silent orphaned work, # presence room label); the base sat 2 bytes under. - "docs/architecture/12-host-service-companions-and-chat-ids.md": 12500, + # 12500 -> 13400 (TZ2 own work): the Presence paragraph gains two contracts it had + # no text for — what a binding's own work is and which readers/controls reach it + # (replacing the conversation-exact cancel sentence), and the forced-final split + # between the internal record and the declared reply; the base sat 10 bytes under. + # 13400 -> 13700 (TZ2 descendant authority): one sentence the chapter lacked — a + # delegated descendant's inherited binding authority, apart from the speaker metadata. + # 13700 -> 13950 (TZ2 repair, measured 13918): that sentence now names what the + # descendant's promote/follow-up roots carry and the canonical-first steer precedence + # (replacing the live-row clause), and "host diagnostics" states its ordinary-final limit. + # 13950 -> 14100 (TZ2 review): a deferred tool-delivery finish note is + # carried separately from prior speech in the same previous-turn pointer. + "docs/architecture/12-host-service-companions-and-chat-ids.md": 14100, # 7764 -> 8600 (#1195): the fresh selected-subject + immutable peer projection # execution check (`skill_peer_inventory.py`, `skill_conflicts.py`) replaces # whole-inventory hashing; the chapter had no description of that seam to swap out. @@ -227,7 +241,13 @@ CHAPTER_BYTE_BUDGETS: dict[str, int] = { # chapter had 5 bytes left. Sized to the text: 5 bytes of margin. # 94520 -> 94900: the delegated-lane bullet names the worktree ops lock rule # (issue #1241: no tree walk or per-file git process under the lock). - "docs/development/06-rules-by-change-class.md": 94900, + # 94900 -> 95000 (TZ2 own work): the Presence bullets replace the conversation-exact + # cancel clause with the own-binding rule and name the forced declaration. + # 95000 -> 95150 (TZ2 descendant authority): the own-binding bullet names how a delegated + # descendant is a Presence caller (inherited binding authority, never speaker metadata). + # 95150 -> 95200 (TZ2 repair, measured 95191): the promotion/follow-up clause names the + # one carrier it copies instead of "the Presence metadata". + "docs/development/06-rules-by-change-class.md": 95200, "docs/development/07-managed-update-rule.md": 4166, "docs/development/08-mutation-attribution-rule.md": 2899, "docs/development/09-process-custody-rule.md": 10028, diff --git a/tests/test_room_knowledge_correction.py b/tests/test_room_knowledge_correction.py index 69664e507..b99660364 100644 --- a/tests/test_room_knowledge_correction.py +++ b/tests/test_room_knowledge_correction.py @@ -162,5 +162,6 @@ def test_unread_correction_failure_remains_visible_after_dialogue_publication(tm assert stored["knowledge_writes"][0]["reason"] == "revision_required" state = json.loads(meta.read_text(encoding="utf-8")) assert state["last_consolidated_offset"] == 100 - assert state["last_unpublished_nominations"]["failed"] == 1 + assert len(state["pending_knowledge_nominations"]) == 1 + assert state["pending_knowledge_nominations"][0]["reason"] == "revision_required" assert k.read_knowledge_note(address).raw == original.raw diff --git a/tests/test_schedule_followup.py b/tests/test_schedule_followup.py index 6d1279f67..3867d917b 100644 --- a/tests/test_schedule_followup.py +++ b/tests/test_schedule_followup.py @@ -262,10 +262,43 @@ def test_presence_recurring_followup_uses_existing_cron_and_preserves_authority( assert record["timezone"] == "Europe/Moscow" assert record["next_run_at"] assert record["task"]["metadata"]["presence"] == ctx.task_metadata["presence"] - assert record["task"]["task_contract"] == ctx.task_contract + assert record["task"]["task_contract"] == {"capability_ceiling": payload} # the turn's objective is not the follow-up's scheduled = queue._task_from_schedule(record) assert scheduled["metadata"]["presence"] == ctx.task_metadata["presence"] assert scheduled["task_contract"]["capability_ceiling"] == ctx.task_contract["capability_ceiling"] + assert scheduled["task_contract"]["objective"] == "Re-run the plan panel once the reviewer window resets." + + +def test_a_bindings_root_follow_up_runs_its_own_objective_under_the_inherited_authority(tmp_path): + from ouroboros.presence_authority import presence_ceiling_payload + from supervisor import queue + from tests.test_presence_own_work import _ceiling + + ctx = _ctx(tmp_path) + # A root a delegated descendant promoted: binding authority only, a full contract of its own. + ctx.task_metadata["presence_binding_authority"] = {"binding_id": "b" * 32} + ceiling = presence_ceiling_payload(_ceiling()) + ctx.task_contract = {"objective": "Compile the full audit", "context": "old audit ids 1-9", + "expected_output": "Nine figures", "acceptance_claims": ["Q1 is closed"], + "success_criteria": ["All nine figures reviewed"], + "allowed_resources": {"network": True}, "capability_ceiling": ceiling} + + assert _followup(ctx, objective="Revisit the Q2 figures", context="Q2 closes Friday").startswith( + "FOLLOWUP_SCHEDULED") + assert _followup(ctx, objective="Check the Q3 draft").startswith("FOLLOWUP_SCHEDULED") + + records = queue.list_scheduled_tasks(tmp_path / "data")["tasks"] + scheduled = {row["task_contract"]["objective"]: row for row in map(queue._task_from_schedule, records)} + assert set(scheduled) == {"Revisit the Q2 figures", "Check the Q3 draft"} + assert scheduled["Revisit the Q2 figures"]["task_contract"]["context"] == "Q2 closes Friday" + assert scheduled["Check the Q3 draft"]["task_contract"]["context"] == "" # no inherited context + for task in scheduled.values(): + assert task["task_contract"]["expected_output"] == "" + assert task["task_contract"]["acceptance_claims"] == [] + assert task["task_contract"]["success_criteria"] == [] + assert task["metadata"]["presence_binding_authority"] == {"binding_id": "b" * 32} + assert task["task_contract"]["capability_ceiling"] == ceiling + assert task["task_contract"]["allowed_resources"] == {"network": True} def test_schedule_followup_requires_exactly_one_valid_trigger(tmp_path):