release 6.95.0: forced-exit lifecycle honesty — delivery control, provider death, child absorption (sprint phase B)

Squashed landing of sprint/slime-b-lifecycle (e3184c6f + 9df9368d + 2912e634)
onto the phase A mainline (cccc758f, v6.94.0).

Forced delivery-control resolution is pure and no-retry: under the armed
latch, any parsed object carrying the delivery_control key (unknown verbs
included) and any JSON-looking non-parse degrade to the retained candidate
with the typed delivery_control_degraded reason — protocol JSON can no
longer ship as the owner's answer; unarmed JSON passes through untouched.
Both resolvers carry the hardened protocol-intent rule.

Batch child disposition: tree_note(kind='decision') payloads accept a
children array expanded into the same per-child authoritative ledger rows
(exact child_result_sha256 per entry, per-entry rejection); the absorption
reminder and tool schema advertise the batch form.

Acceptance review runs on the forced children_unabsorbed rail (owner Q2A)
through the ordinary panel entry point, with the undispositioned children in
the evidence packet (explicit omission marker past 20); a requested
improvement pass the rail cannot grant terminalizes finalized_unaccepted
with the typed revision_unavailable_on_forced_rail reason.

Provider-death honesty: a provider-killed task terminalizes
failed/provider_unavailable (left BEST_EFFORT_REASON_CODES; salvage text
still rides the result body), and the owner gets an immediate single-shot
"NOT completed" notification with no false resume promise. The
reaper-delivered notification suppression bug is fixed and pinned by
regression test: registration happens only after a successful send, after
task-done cleanup, below the ephemeral-turn return.

A child's settled result stamps the parent's own progress (including
reaper-delivered terminals via the final_task_result fallback), so a
coordinator waiting on children is not idle-killed when its last child
delivers.

The forced-path nanny note closes the 6.94.0 disclosed residual: grounded in
durable custody evidence from the canonical custody root via the one
task_execution_evidence reducer shared with phase A's ordinary nudge
(delegated_runs_succeeded / failure states / evidence_read_failed).

Merge resolution: kept phase A's richer task_execution_evidence reducer
(SUCCEEDED_STATES set, delegated_run_failure_states, evidence_read_failed)
as the single reducer both nanny paths read; phase B's simpler succeeded
count was subsumed by the same delegated_runs_succeeded key.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
Ouroboros 2026-08-11 01:17:23 +03:00
parent cccc758f6b
commit 6c53eee45f
21 changed files with 1390 additions and 103 deletions

View file

@ -9,7 +9,7 @@
[![Linux](https://img.shields.io/badge/Linux-x86__64-orange.svg)](https://github.com/razzant/ouroboros/releases)
[![Windows](https://img.shields.io/badge/Windows-x64-blue.svg)](https://github.com/razzant/ouroboros/releases)
[![OuroborosHub](https://img.shields.io/badge/OuroborosHub-skills%20marketplace-8A2BE2.svg)](https://github.com/razzant/OuroborosHub)
[![Version 6.94.0](https://img.shields.io/badge/version-6.94.0-green.svg)](VERSION)
[![Version 6.95.0](https://img.shields.io/badge/version-6.95.0-green.svg)](VERSION)
Ouroboros is an open-source, general-purpose AI agent whose identity, durable memory, and history continue across tasks and restarts. It works on external projects, coordinates a live swarm of specialist agents, and can rewrite the implementation it runs on, including its code, architecture, prompts, tools, and dependencies. Reflection can also change how it understands itself without severing that continuity.
@ -327,6 +327,7 @@ and integration work.
| Version | Date | Description |
|---------|------|-------------|
| 6.95.0 | 2026-08-11 | **fix: the task lifecycle tells the truth at every forced exit — delivery-control protocol JSON, provider death, unabsorbed children, and the reaper's silence (sprint phase B).** Forced delivery-control resolution is PURE and no-retry (`_resolve_forced_delivery_control`): under the armed latch, ANY parsed object carrying the `delivery_control` key (unknown verbs included) and any JSON-looking text that fails to parse degrade to the retained candidate with the typed `delivery_control_degraded` reason — protocol JSON can no longer ship as the owner's answer, while unarmed JSON passes through untouched (armed prose stands; disclosed residual). Parents disposition children in batch: a `tree_note(kind='decision')` payload accepts a `children` array expanded into the same per-child authoritative ledger rows — exact `child_result_sha256` binding and per-entry rejection, single form unchanged. Acceptance review now runs on the forced `children_unabsorbed` rail (owner Q2A) through the ordinary panel entry point before the answer is sealed, with the undispositioned children (ids/statuses/hashes; explicit omission marker past 20) in the evidence packet; a requested improvement pass the rail cannot grant terminalizes as `finalized_unaccepted` with the typed `revision_unavailable_on_forced_rail` reason. Provider-death honesty: a provider-killed task terminalizes `failed`/`provider_unavailable` instead of "completed (best effort)" (the code left `BEST_EFFORT_REASON_CODES`; salvage text still rides the result body), and the owner gets an immediate "NOT completed" notification with no false resume promise — re-run is the honest verb. The reaper-delivered notification suppression bug is fixed and proven by regression test: the single-shot registry is registered only after a successful send, runs after task-done cleanup and below the ephemeral-turn return, so a raising send is retried later and an ephemeral decision turn gets no duplicate outage ping. A child's settled result stamps the PARENT's own progress (including reaper-delivered terminals via the `final_task_result` fallback), so a coordinator waiting in `wait_tasks` is no longer idle-killed the moment its last child delivers. The forced-path nanny note closes the 6.94.0 disclosed residual: it is grounded in durable custody evidence from the canonical custody root (the same `task_execution_evidence` reducer as the ordinary nudge, which now also reports `delegated_runs_succeeded`) — succeeded runs silence it, unsettled runs get pending wording, settled-without-success gets truthful failure wording, and unreadable evidence never accuses. |
| 6.94.0 | 2026-08-10 | **feat: external-project tasks can finally delegate onto the subscription substrate, and the ordinary finalization nudge tells the truth about delegated runs.** The Slime Lab Escape saga (4 attempts, ~$215, the game built twice) exposed that the workspace tool envelope predates delegation: tasks in external project workspaces — and their read-only children — were resolved onto the harness route and told to be nannies while `delegate_start`/`delegate_wait`/`delegate_cancel` were filtered out of their toolset, so every "nanny" burned metered opus tokens and the subscription paid $0. The envelope now carries the delegate verbs plus `switch_model` and the `send_photo`/`send_video`/`send_file` media family (a task contract demanded send_photo while the filter hid it), with an invariant test pinning both child profiles as strict subsets so a future tool cannot silently diverge. `enable_tools` distinguishes "hidden by policy: <reason>" from "not found" through the new read-only `policy_hidden_reason` (drift-pinned to `get_schema_by_name` across six context variants); the nanny finalization nudge consults durable delegate-custody evidence — suppressed when the verbs are structurally invisible, and speaking `NANNY_DELEGATED_RUN_FAILED` with the terminal states when a child delegated and the run crashed, so a killed run can never again be misread as a judgment call ("0 delegate_start calls" accused children whose runs died in 6 seconds). The dispatch note tells a nanny to decide delegation FIRST with typed cost classes (known-zero when the route settled $0; estimated/undisclosed is never zero; metered is real money), and the swarm-router promote turn sees the built-in tool-name envelope so a contract can no longer demand a built-in the runtime does not expose. The landed amendments close the honesty loop end to end: a capability preflight before the first paid round proves a harness-dispatched child can actually see all three delegate verbs — an explicit harness pin blocks typed with zero spend (`delegate_tools_invisible`; `delegate_visibility_unverified` when toolset introspection itself failed), while `auto` falls back LOUDLY to native with a typed capability delta; execution reporting speaks the purely factual `actual_substrate` vocabulary (`harness_used`/`harness_attempted`/`native_only`, derived from custody evidence alone, raw attested counts riding beside the enum, and the claim omitted entirely when evidence is unreadable); the nanny evidence distinguishes PENDING from FAILED — `NANNY_DELEGATED_RUN_PENDING` says wait or cancel over an in-flight run instead of accusing it; the promoted-task toolset is resolved from LIVE registry availability (typed `unavailable_builtin_tools` reasons) instead of a static allowlist union; and `evidence_read_failed` renders as typed unknown ("evidence unavailable"), never as a false no-run receipt — the `wait_tasks` projection then omits the run counts and the substrate claim entirely (an unread log yields no numeric facts). Disclosed residual: the FORCED-finalization note remains trace-only — its honesty upgrade lands with the lifecycle release. This release also integrates community PR #176: native Linux `.deb`/`.rpm` packages join the release pipeline (contributed by Fgeeha). |
| 6.93.1 | 2026-08-10 | **fix: the three v6.93.0 tag-matrix failures.** chmod-based unreadability is injected instead (Windows chmod cannot revoke reads); two `read_text()` calls gain explicit utf-8 (server.py carries non-ASCII bytes that cp1252 rejects); and the overlapping dismiss/start smoke now pins the merges transition-queue semantics — the overlapped start lands after the dismissal settles instead of being dropped — asserting the preserved C7 invariant as order. Test-only changes. |
| 6.93.0 | 2026-08-09 | **feat: onboarding connects agent plans, Settings gets an Agents tab, and the account surfaces stop stating more than they know.** Two sprints integrated on one backbone. The wizard is served by the live gateway on every host and gains a skippable Connect-your-agents step with sign-in through the same login cards Settings uses; completion is one atomic request, and install-time defaults point reviewers and subagents at connected subscriptions without ever overwriting an owner choice. A new Agents tab holds the account cards per family, engine-contract removal, one service banner, and the delegation controls; docs/DESIGN.md becomes the visual authority, and the ratified BIBLE P3 amendment admits owner-declared retrieving scope reviewers as an alternate authoritative delivery mode. Underneath, `GET /api/claudexor/status` carries per-facet read provenance — reads:{catalog, accounts, quota}, each ok\|not_read\|failed — so a lazily-started daemons silence stops rendering as "no account connected": a facet is ok only when the full promised envelope arrived, refused per-harness model reads are disclosed, and POST /api/claudexor/wake is the owners start button behind Refresh. The client reads it all through ONE store (single writer, visibility-gated polling, dispose): the status line asks the facets before the aggregate, a partial refusal names what went unread, a wake refusal expires only on proven recovery, saved reviewer pins are labelled from their own facet ("not checked", never "not in discovery", until the facet was read), and a login seen online is monotone evidence. |
@ -336,8 +337,7 @@ and integration work.
| 6.91.0 | 2026-08-08 | **feat: the Linux binary boots into the browser when no desktop webview backend exists.** Instead of dying with `WebViewException` on every GTK/QT-less Linux box, the launcher probes the pywebview backend AND whether a display actually exists (Linux-only probe; zero new code runs on macOS/Windows) — importing GTK is not the same as having a display, so an ssh session on a box carrying the system gi bindings gets browser mode instead of the crash (a Qt-selected backend is judged on the session environment alone — probing Qt would itself crash a display-less process; disclosed residual, documented in ARCHITECTURE), installs SIGINT/SIGTERM shutdown handlers BEFORE the lifecycle thread spawns the server (via the new `platform_layer.install_shutdown_signal_handlers` — the signal surface stays behind the platform layer), prints the URL, opens the default browser without blocking, and keeps the process alive while the server lifecycle runs — first-run onboarding included, served by the existing web overlay; a shutdown signal during startup aborts the readiness wait into the same clean teardown. Headless boxes get a clear message instead of a crash. README now leads its integrations story with subscription-powered delegation: use existing Codex, Claude Code, or Cursor subscriptions through the bundled [Claudexor](https://github.com/razzant/claudexor) engine. |
| 6.90.3 | 2026-08-08 | **fix: every native browser dialog is replaced by the in-app dialog, and the harness-accounts panel stops polling a dead daemon forever.** All 21 `window.prompt`/`confirm`/`alert` sites across `web/modules` migrate to the in-app `openConfirmDialog` (a new single-button alert mode included), fixing the two dead `window.prompt` sites on the macOS desktop shell — marketplace version updates and evolution campaign objectives silently returned null there — while preserving each site's contract (empty update version = latest, evolution cancel = do NOT start, `/panic` confirm keeps panic working — its complete confirm-and-send flow is node-tested through the injectable `confirmAndSendPanic`). A static CI gate bans the native dialog trio from `web/modules` so the class stays closed. Harness Accounts now fetches immediately on page show (no 5-second «Checking daemon…»), re-checks on tab/page activation, and login polling backs off (6→30s cap) and gives up honestly into the existing unconfirmed verdict after 10 consecutive failures instead of polling a dead daemon forever. Deleting every reviewer-slot row and saving now surfaces the backend's 400 («needs at least one slot») instead of pretending success while saving nothing. |
| 6.90.2 | 2026-08-08 | **fix: the commit gate asks whether the advisory lane is actually available — route- and slot-aware — not whether an Anthropic key exists.** The bypass test is now route- and slot-aware: a DISABLED advisory slot with a key present used to silently drop the compensating hermetic pytest (neither advisory nor tests ran before triad+scope), and a keyless DELEGATED advisory route — which does run, on the owner's subscription — used to be mislabelled `bypassed` and pay a duplicate full test run every commit. The gate reads the CURRENT advisory availability, not the stored advisory record's status — an owner-accepted residual disclosed at the gate. A malformed reviewer-slot configuration fails CLOSED into that preflight, and the parser now raises its documented `ValueError` for a non-dict advisory route instead of an `AttributeError` that escaped every fail-closed handler. Skill review's optional advisory pre-review now asks the same availability question (still fail-open), so a DISABLED advisory slot no longer dispatches — and no longer spends — review budget the owner switched off. A malformed structured reviewer-slot config also stops being silent where it mattered: plan review returns a typed unavailable result and skill review returns `pending` with the row-precise parse error, instead of quietly running the shipped default panel; task acceptance keeps reading the projected legacy/default panel by owner decision, and the architecture map now says so rather than claiming the refusal is universal. Delegated review-session custody rows carry root/parent task lineage, so a run recovered by restart reconciliation settles its spend on the real root instead of charging the reviewing task as its own. Also unblocks the commit gate on any machine running Ouroboros: the managed-update writer-fence ORDER test reached the operator's LIVE process ledger (the fence's custody step reads an import-time `DRIVE_ROOT` that an isolated `OUROBOROS_DATA_DIR` does not rebind), so it failed on a stale ledger entry and could have killed a ledgered task/session service that happened to be running. |
| 6.90.0 | 2026-08-07 | **fix: the subscription substrate actually carries the work — quota exhaustion is judged against the route's own model, and every silent seam found by the integration audit is closed.** The route-health predicate now scopes quota windows by `applies_to_models` (a Fable-only weekly window no longer takes an opus-pinned route offline for days — the live incident that burned ~$83 of metered API in 43 minutes), treats a spent window with no reset instant as spent rather than healthy, and demands POSITIVE evidence for the whole route: a profile whose quota endpoint answered 429 is unknown-not-dead, so the daemon's own rotation gets to try. A nanny dispatched onto the delegated substrate that reaches finalization with zero `delegate_start` calls gets one structural reminder (the wave-0 child burned $8.89 of metered opus under a subscription dispatch and only its prose admitted it); the decision stays the child's. The blocking scope reviewer no longer silently runs at `medium` on stock installs (the legacy slot path now honours `OUROBOROS_EFFORT_SCOPE_REVIEW`); scope and advisory session schemas grow a one-finding floor so the one answer their contracts reject by design cannot arrive schema-blessed; Panic Stop kills the owned Claudexor daemon's process group (subscription runs no longer outlive the red button); startup reconciliation of orphaned delegated runs goes through the ENSURE path instead of handshaking the corpse it just reaped — which also adopts a staged runtime update on restart; the production poll retries the engine's transient Git atomic-object ENOENT exactly once (CI had learned this tolerance while the live `delegate_wait` kept failing); `harness:effort` parses per the documented grammar and an unparseable configured route warns instead of silently disabling delegation. Web: "Add account…" replaces `window.prompt` (dead in the desktop WKWebView) with the standard input dialog and shows the normalized profile name before login; the account quota summary applies the same model-scope rule as the resolver; an empty Heavy slot visibly says it inherits Main. The Repo Commit Checklist gains item 23 (`delegated_transport`): the delegation invariants — capability_delta to three destinations, hash-bound EOF reads, typed blockers over silent re-routes, positive-evidence quota, custody lineage, no vendor branching — are now a named review surface. |
Older releases are preserved in Git tags and GitHub releases. Older 6.x rows (including 6.87.5, 6.87.4, 6.87.3, 6.87.2, 6.84.0, 6.87.1, 6.83.0, 6.86.1, 6.81.1, 6.76.0, 6.75.0, 6.74.5, 6.74.4, 6.74.1, 6.74.0, 6.73.2, 6.73.1, 6.73.0, 6.72.0, 6.71.2, 6.71.1, 6.71.0, 6.70.0, 6.69.0, 6.68.0, 6.67.0, 6.66.0, 6.65.4, 6.65.3, 6.65.2, 6.65.1, 6.65.0, 6.64.3, 6.64.2, 6.64.1, 6.64.0, 6.63.0, 6.62.0, 6.61.4, 6.61.3, 6.61.1, 6.61.0, 6.60.0, 6.59.0, 6.58.0, 6.57.0, 6.56.0, 6.55.0, 6.54.4, 6.54.2, 6.54.1, 6.54.0, 6.53.4, 6.53.0, 6.51.0), the 5.2.0 through 5.33.0-rc.6 rows, and former `4.0.0` rows are rolled off to respect the P9 changelog cap; their full bodies remain at their git tags.
Older releases are preserved in Git tags and GitHub releases. Older 6.x rows (including 6.90.0, 6.87.5, 6.87.4, 6.87.3, 6.87.2, 6.84.0, 6.87.1, 6.83.0, 6.86.1, 6.81.1, 6.76.0, 6.75.0, 6.74.5, 6.74.4, 6.74.1, 6.74.0, 6.73.2, 6.73.1, 6.73.0, 6.72.0, 6.71.2, 6.71.1, 6.71.0, 6.70.0, 6.69.0, 6.68.0, 6.67.0, 6.66.0, 6.65.4, 6.65.3, 6.65.2, 6.65.1, 6.65.0, 6.64.3, 6.64.2, 6.64.1, 6.64.0, 6.63.0, 6.62.0, 6.61.4, 6.61.3, 6.61.1, 6.61.0, 6.60.0, 6.59.0, 6.58.0, 6.57.0, 6.56.0, 6.55.0, 6.54.4, 6.54.2, 6.54.1, 6.54.0, 6.53.4, 6.53.0, 6.51.0), the 5.2.0 through 5.33.0-rc.6 rows, and former `4.0.0` rows are rolled off to respect the P9 changelog cap; their full bodies remain at their git tags.
---

View file

@ -1 +1 @@
6.94.0
6.95.0

View file

@ -31,22 +31,26 @@ def append_result_index(run_dir: pathlib.Path, row: dict[str, Any]) -> None:
# guard: every literal `reason_code` in `ouroboros/` must have a recorded decision here.
#
# Per-code decision for `BEST_EFFORT_REASON_CODES` ("forced finalization may yield a
# best-effort outcome"). All six are also "must not be read as a capability result", because
# best-effort outcome"). All are also "must not be read as a capability result", because
# forced finalization means the attempt was cut short by a rail rather than ended by the
# agent, so the set is taken whole with no subtraction:
# budget_exhausted loop.py:287 per-task USD reservation rail
# round_limit loop.py:3128 round cap (_handle_round_limit)
# finalization_grace loop.py:3146 supervisor finalize_now grace
# deadline_local loop.py:3220 loop-local deadline
# provider_unavailable loop.py:3185 same-model reroute + fallback exhausted
# children_unabsorbed loop.py:4071 forced terminal with child results unabsorbed
_TRUNCATION_CODES_NOT_BEST_EFFORT = frozenset({
# ADDITIVE DELTA, one code. `llm_api_error` (loop_llm_call.py:630) is not a best-effort
# code — it terminates without an extracted answer — but it is the same class for an
# AUDITOR as provider_unavailable: a transport death, never a fair shot at the task.
# Adapters that lack a separate infra channel (the GAIA/OSWorld result rows) would
# otherwise publish an affirmative `truncated: false` for it.
# ADDITIVE DELTA, two codes that terminate as infra failures rather than
# best-effort completions but are the same class for an AUDITOR: the attempt
# was cut short by transport/provider death, never a fair shot at the task.
# Adapters that lack a separate infra channel (the GAIA/OSWorld result rows)
# would otherwise publish an affirmative `truncated: false` for them.
# `llm_api_error` (loop_llm_call.py:630) ends without an extracted answer;
# `provider_unavailable` (loop.py _handle_provider_unavailable) left
# BEST_EFFORT_REASON_CODES in the slime-saga honesty fix — a provider outage
# now terminalizes as infra_failed instead of "completed (best effort)".
"llm_api_error",
"provider_unavailable",
})
RUNTIME_TRUNCATION_REASON_CODES = BEST_EFFORT_REASON_CODES | _TRUNCATION_CODES_NOT_BEST_EFFORT

View file

@ -1,4 +1,4 @@
# Ouroboros v6.94.0 — Architecture & Reference
# Ouroboros v6.95.0 — Architecture & Reference
This file is NOT a changelog. Version history lives in README.md, git tags, and commit log.
@ -238,7 +238,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
│ ├── skill_preflight.py ← Heal-safe read-only payload preflight: manifest/syntax checks plus registration-aware literal UI-schema resolution; unresolved dynamic schemas are explicit degraded skips, with runtime validation still fail-closed
│ ├── project_journal.py ← Thin per-project journal/workpad tools (v6.32.0): journal_write/read (durable milestone memory), workpad_read/write (scratch page), journal_tail_digest (context injection); over-limit writes are rejected, never silently sliced
│ ├── task_tree.py ← (v6.38.0) Task-tree coordination tools tree_note/tree_read (the swarm blackboard + child→parent beacons; storage/kind SSOT in ouroboros/task_tree_ledger.py)
│ ├── join_ledger.py ← Soft-join decision authority: validates direct lineage and exact current child-result hashes for tagged `tree_note(kind="decision")` dispositions (`integrated`, `irrelevant`, `deferred`), appends the sole authoritative task-tree row, rejects stale hashes as `CHILD_RESULT_STALE`, and keeps `peek_task`, `discard_child_result`, constraint override, cancellation, and shared child-decision helpers. The hash covers status, full result, trace summary, artifact status, and stable artifact identities, not cost/timestamps/queue diagnostics/parent decisions; task-result fields are derived read projections only.
│ ├── join_ledger.py ← Soft-join decision authority: validates direct lineage and exact current child-result hashes for tagged `tree_note(kind="decision")` dispositions (`integrated`, `irrelevant`, `deferred`) — single-child or batch `children` array form, each batch entry validated individually — appends the sole authoritative task-tree row, rejects stale hashes as `CHILD_RESULT_STALE`, and keeps `peek_task`, `discard_child_result`, constraint override, cancellation, and shared child-decision helpers. The hash covers status, full result, trace summary, artifact status, and stable artifact identities, not cost/timestamps/queue diagnostics/parent decisions; task-result fields are derived read projections only.
│ ├── delegate.py ← Nanny verbs for DELEGATED subagent cognition on the owner's already-paid Claudexor session: `delegate_start` (host-derived authority — access profile, run mode, isolation and the `delegated` scoped-HOME marker all follow the calling task's own authority via `subagents.delegated_run_shape`; no argument can widen them), a time-bounded `delegate_wait` (progress cursor, containment verification against the run's OWN artifacts, terminal payload bounded inside `tool_result_limit` with the full result staged to the task drive and read back to EOF), and `delegate_cancel` (four typed outcomes — nothing claims terminal without a receipt). Run LIFECYCLE, custody, settlement and reconciliation are `ouroboros/delegate_custody.py`; transport is `gateways/claudexor.py`; route policy is `ouroboros/subagents.py`. This module is nanny BEHAVIOUR only
│ └── subagent_integration.py ← integrate_subagent_patch: parent's manifest-first integration of an acting subagent's workspace.patch. For self_worktree children it applies into ctx.active_repo_dir() (sha256-verified, 3-way --index, protected-path gated, top-only lineage check, genesis refused), stages but never commits. For external_workspace children it verifies the child wrote in the same active external workspace and records an audited verdict without re-applying the patch; (v6.58.0) a NON-workspace parent integrating a COOP child (write_root = a host-minted tree under the subagent-projects root) gets a read-only verification + a SUCCESSFUL `coop_already_in_tree` no-op verdict instead of a parent-missing error — the work is already in the shared tree, which `coop_checkpoint.checkpoint_commit_coop_roots` checkpoint-commits at root finalization. Also compare_subagent_patches: read-only best-of-N helper that shows several children's candidate patches side by side for LLM-first synthesis
├── process_containment.py ← `ProcessContainer` for the hermetic gate: env-token membership (`OURO_PROC_CONTAINER_*`, /proc environ on Linux, `ps -E` on macOS, kill-on-close Job Object on Windows), read from LIVE kernel state at reap time; an alive or undeterminable member is an honest hard-block answer, never a kill guarantee. Policy layer over platform_layer's OS primitives
@ -834,7 +834,7 @@ The bridge recognizes `/panic`, `/restart`, `/review`, `/evolve [on|off]`, `/bg
A user message enters through a reviewed transport, is admitted by the supervisor queue, and runs in `OuroborosAgent`. The root pipeline captures the task contract and immutable context core, executes the LLM/tool loop, preserves a delivery candidate, stores the result and artifacts, emits lifecycle and usage evidence, performs the root-only post-task work, and publishes the typed outcome. Queue admission proves only that asynchronous work was durably accepted; completion, objective satisfaction, artifact finality, verification, and review acceptance remain separate facts.
`DeliveryCandidate` is retained before verification or review so a later notice, reviewer failure, deadline, or provider outage cannot erase a useful answer. `outcomes.py` combines execution, objective, review, artifact, and child-absorption axes without converting one axis into another. Verify-before-done receipts and exact artifact references are host-attested evidence; declarations and answer prose are not substitutes. A forced exit may publish the best current candidate only with its typed rail and evidence-freshness disclosure, and lifecycle may remain `completed` while the objective or review axis records a best-effort or unaccepted result.
`DeliveryCandidate` is retained before verification or review so a later notice, reviewer failure, deadline, or provider outage cannot erase a useful answer. `outcomes.py` combines execution, objective, review, artifact, and child-absorption axes without converting one axis into another. Verify-before-done receipts and exact artifact references are host-attested evidence; declarations and answer prose are not substitutes. A forced exit may publish the best current candidate only with its typed rail and evidence-freshness disclosure, and lifecycle may remain `completed` while the objective or review axis records a best-effort or unaccepted result. When a forced exit fires while the delivery-control latch is armed, the model's one forced answer may legitimately be the protocol object: `loop._resolve_forced_delivery_control` resolves it purely (valid `keep` → retained candidate, valid `replace``full_answer`, malformed → retained candidate with the typed `delivery_control_degraded` reason) before suffixes and publication, never re-loops, and passes JSON through untouched when the latch is off — raw `{"delivery_control": ...}` never reaches the chat or the durable result. Provider death is the one forced rail that is NOT a best-effort completion: `_handle_provider_unavailable` still salvages the best available text into the result body, but stamps `infra_failed`, so the task terminalizes `failed` with the typed `provider_unavailable` reason and the supervisor sends the owner an immediate "provider outage — NOT completed" chat notification on the root's terminal dispatch.
Host-enforced task acceptance is a root-owned completion coach, not the P3 commit gate. `off` disables it. In `auto` and `required`, substantive queued, headless, and scheduled roots are eligible; direct chat becomes eligible only after an observable reviewable effect or a typed deliverable/criterion. Pure conversation, ordinary read-only exploration, routing turns, and cognitive-memory updates do not create eligibility. Child reviews are advisory evidence and are superseded by the root decision.
@ -846,7 +846,7 @@ A clean criterion is evidence-resolved, not merely well argued. Reviewer `eviden
Actionable findings enter the durable obligation dialogue with stable identity. The agent can fix, rebut with an evidence-bearing disposition, or ask the reviewers to declare the issue unreachable here or a stable disagreement. Re-raises must name an existing obligation id or are disclosed as new findings; a valid rebuttal retires the row and an invalid one reopens it with both positions preserved. Required+Blocking continues until clean acceptance or a real deadline, budget, round, lifecycle, or configured improvement-pass rail. Required+Advisory may publish an honest non-clean result. Pacing reserves time for the first review and sizes later passes from observed duration; the improvement capsule reports the actual verdict, open obligation ids, remaining rails, and the concrete next moves rather than inventing a timer-based give-up.
Every forced deadline, budget, or round rail uses the common terminal recorder. If the task was eligible but no panel ran, the review axis records an eligible bypass with zero runs, the rail-specific trigger and acceptance reason; a pure eligibility probe does not run the panel, quiescence, or another model. This keeps a forced delivery distinct from both clean acceptance and a task for which no panel was warranted. The root's post-task phases use the minimal `root_phase_checkpoint`: startup retries only a durable `pending_once` phase, while an indeterminate `running` phase is disclosed as degraded rather than replaying paid work.
Every forced deadline, budget, or round rail uses the common terminal recorder. If the task was eligible but no panel ran, the review axis records an eligible bypass with zero runs, the rail-specific trigger and acceptance reason; a pure eligibility probe does not run the panel, quiescence, or another model. The forced `children_unabsorbed` rail is the one exception (owner decision Q2A, 2026-08-10): for an acceptance-eligible root with a quiescent subtree it still runs the acceptance panel, with the undispositioned-children debt in its evidence, and a requested revision terminalizes as `finalized_unaccepted` (`revision_unavailable_on_forced_rail`) because the forced rail cannot loop. This keeps a forced delivery distinct from both clean acceptance and a task for which no panel was warranted. The root's post-task phases use the minimal `root_phase_checkpoint`: startup retries only a durable `pending_once` phase, while an indeterminate `running` phase is disclosed as degraded rather than replaying paid work.
The agent-callable `task_acceptance_review` does not call the panel for an eligible root. It validates and stores claims, checklist items, evidence references, and the optional agent disposition, then returns `deferred_to_host_acceptance` and `authoritative=false`. Structural eligibility is unchanged. Child-task review and `off` mode keep their separate behavior.
@ -1901,7 +1901,7 @@ Runtime floors:
| OUROBOROS_REASONING_SUMMARY | auto | Narration display switch. `auto` (default) narrates an otherwise-empty tool-round bubble with readable reasoning the provider already returned (`LLMClient.extract_display_reasoning`, shape-based: flat `reasoning` / `reasoning_details` of readable types / Anthropic `thinking` / Gemini `part.thought`; opaque/encrypted skipped). `off` disables the fallback. DISPLAY-ONLY — never added to the transcript or sent back to a provider, so it cannot affect round-trip. Verified against live gpt-5.5, which returns a readable `reasoning.summary` alongside the encrypted block. |
| OUROBOROS_SOFT_TIMEOUT_SEC | 600 | One-minor deprecated no-op retained for settings/env compatibility; a non-default legacy value emits a deprecation event. No user heartbeat/status control is rendered from it. |
| OUROBOROS_HARD_TIMEOUT_SEC | 1800 | One-minor deprecated no-op retained for settings/env compatibility; a non-default legacy value emits a deprecation event. Task termination is governed by idle/absolute-ceiling/deadline/budget rails. |
| OUROBOROS_TASK_IDLE_TIMEOUT_SEC | 900 | (v6.38.0) Activity-based idle window: a task is stopped only after it has made NO real progress (`llm_usage`/progress events — NOT the unconditional 30s liveness heartbeat) AND has no progressing/queued subtree for this long. Effective value is floored to the per-call timeout ceiling (`max(idle, per_call_ceiling+120)`) so a single legitimate long tool/LLM call is never idle-killed mid-work. |
| OUROBOROS_TASK_IDLE_TIMEOUT_SEC | 900 | (v6.38.0) Activity-based idle window: a task is stopped only after it has made NO real progress (`llm_usage`/progress events — NOT the unconditional 30s liveness heartbeat) AND has no progressing/queued subtree for this long. Effective value is floored to the per-call timeout ceiling (`max(idle, per_call_ceiling+120)`) so a single legitimate long tool/LLM call is never idle-killed mid-work. A child's settled terminal result stamps the PARENT's own progress at task_done dispatch (`events._finish_task_done_dispatch`): delivery is the cue to integrate, so a coordinator is never idle-killed exactly when its last child delivers, and an outstanding finalization-grace episode is withdrawn by the existing own-progress spare machinery. |
| OUROBOROS_TASK_ABS_CEILING_SEC | 21600 | (v6.38.0) Absolute per-task wall-clock backstop (6h), independent of activity — the unconditional safety ceiling. Together with an explicit `deadline_at` (a deliberate cap, honored promptly even while progressing) and the budget axis, these are the ONLY hard task-termination axes. |
| OUROBOROS_SUPERVISOR_LIVENESS_DEADLINE_SEC | 90 | (v6.34.0, WS3) Dedicated-thread liveness watchdog deadline. If the supervisor loop tick OR an in-process direct-chat turn's heartbeat goes silent for longer than this, the watchdog surfaces the stall to the owner (detect + alert + `/restart` recommendation). It does NOT free the chat-agent lock / lane admission in-process (the wedged turn holds the lock; out-of-process kill deferred). Must exceed the ~0.5s tick / 30s healthy heartbeat cadence. |
| OUROBOROS_PACING_INTERVAL_SEC | 600 | (v6.34.0, CW9) Pacing interval (seconds) registered in the settings/env SSOT with the other numeric timeouts, per the DEVELOPMENT.md numeric-timeout-SSOT rule (no inline literals). |

View file

@ -1169,11 +1169,11 @@ Before every commit, verify the following:
#### Loop / State-Machine Changes
- [ ] Changes to `loop.py` or other task state-machine logic include adversarial tests for malformed output, false-completion prevention, replay/log durability, and failure modes — not just the happy path.
- [ ] Audit/checkpoint rounds must not silently reuse the normal final-answer path unless that invariant is explicitly tested and documented.
- [ ] Keep a complete loop-local `DeliveryCandidate` once a substantive answer exists. A service round may return `keep`, or `replace` plus the complete replacement answer; allow one repair for malformed control, then preserve the prior complete answer and mark finalization degraded. A service notice alone does not change evidence. Owner messages, tool effects, child results, and verification receipts advance the evidence revision and require fresh delivery/acceptance binding. Finalize task-scoped service outputs/errors before host acceptance and require a complete replacement when that evidence changes; keep the `finally` path as idempotent cleanup only. This control must not bypass verification, acceptance, safety, skill-finalization, deadline, child-handoff, the unconditional `FINAL ANSWER:` latch, or the task-level answer protocol.
- [ ] Every direct child result needs an exact-hash disposition through the existing `tree_note(kind="decision")` tagged payload (`type=child_result_disposition`, child id, `integrated | irrelevant | deferred`, complete-result SHA-256; note text is rationale). The typed task-tree row is the sole authority; task-result disposition fields are derived reads, never a mirrored write. The join-ledger helper alone validates lineage and current content. Stale or malformed payloads change nothing. `deferred` suppresses only the unchanged reminder and forces an honest degraded/best-effort terminal answer until the item is resolved. Explicit cancellation wins a late-completion race and bounded child scratch is removed without preserving another copy.
- [ ] Keep a complete loop-local `DeliveryCandidate` once a substantive answer exists. A service round may return `keep`, or `replace` plus the complete replacement answer; allow one repair for malformed control, then preserve the prior complete answer and mark finalization degraded. A FORCED finalization (budget/round/deadline/provider/children rails) resolves an armed control purely and without retry instead: valid keep/replace is honored, anything malformed preserves the retained candidate with a typed degraded reason, and the protocol JSON itself never reaches chat or the durable result. A service notice alone does not change evidence. Owner messages, tool effects, child results, and verification receipts advance the evidence revision and require fresh delivery/acceptance binding. Finalize task-scoped service outputs/errors before host acceptance and require a complete replacement when that evidence changes; keep the `finally` path as idempotent cleanup only. This control must not bypass verification, acceptance, safety, skill-finalization, deadline, child-handoff, the unconditional `FINAL ANSWER:` latch, or the task-level answer protocol.
- [ ] Every direct child result needs an exact-hash disposition through the existing `tree_note(kind="decision")` tagged payload (`type=child_result_disposition`, child id, `integrated | irrelevant | deferred`, complete-result SHA-256; note text is rationale). One call may instead carry a `children` array of such entries (batch form): each entry is validated exactly like the single form, invalid entries are rejected individually by index while valid ones record. The typed task-tree row is the sole authority; task-result disposition fields are derived reads, never a mirrored write. The join-ledger helper alone validates lineage and current content. Stale or malformed payloads change nothing. `deferred` suppresses only the unchanged reminder and forces an honest degraded/best-effort terminal answer until the item is resolved. Explicit cancellation wins a late-completion race and bounded child scratch is removed without preserving another copy.
- [ ] Host task acceptance is root-only. Queued/headless/scheduled roots are reviewed in `auto` and `required`; direct eligibility is the union of `outcomes.turn_has_reviewable_effects` and a typed deliverable/criterion. Ordinary read-only tool activity, pure conversation, and meta/routing controls are not reviewed, and child reviews remain advisory. Eligibility must use structured facts, never keywords (Bible P3/P5). For an eligible root under `auto|required`, agent-callable `task_acceptance_review` validates/stores evidence and optional agent disposition but makes zero reviewer calls; it returns `deferred_to_host_acceptance`, `authoritative=false`, and the evidence revision. The call itself never widens eligibility; child and `off` behavior remain unchanged.
- [ ] Before root acceptance, atomically fence new descendants under the queue lock and prove recursive subtree quiescence from the existing task-status SSOT. Split-drive ACK, subtree, and acceptance-timing reads/writes use canonical `budget_drive_root`. Preserve the prior verdict until the replacement is recorded. A revision must explicitly reopen the fence; terminal/degraded outcomes seal it.
- [ ] The host runs the authoritative acceptance panel once per unchanged candidate-hash/evidence-revision/fence binding. Task-acceptance actors receive one substantive call and at most two physical attempts total. Record transport status, parse status, and valid-response semantic verdict separately, with actor model/provider, role, coverage, panel id, quorum contribution, reason, enforcement impact, and binding hashes. Public task/event/UI records receive only the compact projection; full model payloads remain in private audit storage. `adaptive_quorum` applies; any contributing FAIL fails, DEGRADED abstains (the reviewer verdict vocabulary `PASS|FAIL|DEGRADED` is NOT narrowable — `_contract_valid_actors`, the deliberate-DEGRADED capsule rail and the host's core-overflow DEGRADED all depend on it), and no quorum is a terminal HOST decision. The host acceptance decision itself is written ONLY by `loop._set_acceptance_decision` and has exactly three owner-facing states — `accepted | revision_requested | finalized_unaccepted` — each with a typed `reason` from an existing structured fact; an unknown status fails closed to `finalized_unaccepted` keeping its raw token as the reason. When you add a writer, add its reason to the closed set AND check every value-keyed reader: `outcomes.derive_loop_outcome` keys the eligible-but-skipped degradation on the status+reason PAIR (`review_skipped_deadline_reserve` plus the closed forced-rail `ACCEPTANCE_BYPASS_REASONS`), and breaking that pairing is a silent false green. Forced exits stamp their typed bypass record in the common terminal recorder (`_record_forced_acceptance_bypass`) as a pure ledger write — never a fence, panel, extra round, or prompt text on a forced path, and never overwriting an existing host decision. The agent may write only `agent_disposition`/`agent_rationale`, merged into the host decision, never replacing it. Clean requires PASS + solved + supported criterion evidence. Chat and Logs must use the same severity reducer, and degraded review or best-effort/degraded objective must never render as green solved. Do not add task scope review or reuse the commit gate.
- [ ] The host runs the authoritative acceptance panel once per unchanged candidate-hash/evidence-revision/fence binding. Task-acceptance actors receive one substantive call and at most two physical attempts total. Record transport status, parse status, and valid-response semantic verdict separately, with actor model/provider, role, coverage, panel id, quorum contribution, reason, enforcement impact, and binding hashes. Public task/event/UI records receive only the compact projection; full model payloads remain in private audit storage. `adaptive_quorum` applies; any contributing FAIL fails, DEGRADED abstains (the reviewer verdict vocabulary `PASS|FAIL|DEGRADED` is NOT narrowable — `_contract_valid_actors`, the deliberate-DEGRADED capsule rail and the host's core-overflow DEGRADED all depend on it), and no quorum is a terminal HOST decision. The host acceptance decision itself is written ONLY by `loop._set_acceptance_decision` and has exactly three owner-facing states — `accepted | revision_requested | finalized_unaccepted` — each with a typed `reason` from an existing structured fact; an unknown status fails closed to `finalized_unaccepted` keeping its raw token as the reason. When you add a writer, add its reason to the closed set AND check every value-keyed reader: `outcomes.derive_loop_outcome` keys the eligible-but-skipped degradation on the status+reason PAIR (`review_skipped_deadline_reserve` plus the closed forced-rail `ACCEPTANCE_BYPASS_REASONS`), and breaking that pairing is a silent false green. Forced exits stamp their typed bypass record in the common terminal recorder (`_record_forced_acceptance_bypass`) as a pure ledger write — never a fence, panel, extra round, or prompt text on a forced path, and never overwriting an existing host decision — with ONE exception (owner decision Q2A, 2026-08-10): the forced `children_unabsorbed` rail still runs the acceptance panel for an acceptance-eligible root when the subtree is quiescent, with the undispositioned-children debt included in the evidence packet; because that rail cannot take another round, a requested revision terminalizes as `finalized_unaccepted` with the typed `revision_unavailable_on_forced_rail` reason, while the process outcome stays best-effort `children_unabsorbed`. The agent may write only `agent_disposition`/`agent_rationale`, merged into the host decision, never replacing it. Clean requires PASS + solved + supported criterion evidence. Chat and Logs must use the same severity reducer, and degraded review or best-effort/degraded objective must never render as green solved. Do not add task scope review or reuse the commit gate.
- [ ] The acceptance improvement loop is a reviewer-authored DIALOGUE (v6.74.0): obligation identity comes from the reviewer's typed `disposition_kind`/`obligation_id` (an unknown re-raise id fails closed to `new`, disclosed — never a silent fresh hash id); a re-raise reopens the row WITHOUT wiping the agent's argument (`previous_disposition`/`previous_reason`/`reopened_count` survive into the evidence catalog and the obligations clause); termination beyond a clean PASS/accepted rebuttal happens ONLY via the reviewers' quorum `dialogue_status` judgement reduced over ALL contract-valid actors (`aggregate_dialogue_status` — never `_contributing_actors`, which drops a DEGRADED slot's vote) or a real rail — no host counters, no answer/verdict hashes, no keyword gates (P5). Changes here must cover: malformed reviewer output, unknown/stale `obligation_id` on a re_raise, partial panel failure, multi-slot dialogue-status disagreement (the reducer's precedence), replay/restart durability of obligation rows, false completion, and the backward-compatible default when the new fields are absent.
- [ ] An explicit `max_improvement_passes` binds under every legacy policy. Required+Blocking without one has no local count cap, but real deadline/budget/lifecycle rails remain. The first acceptance review reserves at least 200s; later passes use the canonical event-derived `max(floor, 1.5×EWMA)` (`alpha=0.5`). Only the root runs global post-task synthesis once and persists one phase checkpoint in the canonical `budget_drive_root`. Recovery is startup-only: replay `pending_once`, degrade indeterminate `running` without another paid call, and let the normal supervisor copy-back/artifact path materialize child results without overwriting a terminal canonical phase.

View file

@ -410,8 +410,10 @@ def task_execution_evidence(drive_root: Any, task_id: str) -> Dict[str, Any]:
"delegated_runs_settled": len(settled),
# The terminal-state axis (F4, 2026-08-10 saga): a run that STARTED and
# FAILED is an ATTEMPTED route, not a refusal to delegate. Readers (the
# nanny nudge, the completion seam) must be able to tell "never tried"
# from "tried and the run died" without re-parsing the event log.
# nanny nudge, the forced-path note, the completion seam) must be able to
# tell "never tried" from "tried and the run died" without re-parsing the
# event log; the forced-path nanny note keys on the succeeded count to
# stop nagging over finished work.
"delegated_runs_succeeded": len(succeeded),
"delegated_run_failure_states": sorted(set(failure_states)),
# True only when the canonical log EXISTS but could not be opened —

View file

@ -16,7 +16,7 @@ import logging
from ouroboros.llm import LLMClient, normalize_reasoning_effort, add_usage
from ouroboros import task_pacing
from ouroboros.config import adaptive_quorum, get_context_mode, get_light_model, get_review_enforcement, get_task_review_mode, resolve_effort
from ouroboros.outcomes import ACCEPTANCE_ACCEPTED, ACCEPTANCE_BYPASS_REASON_BY_RAIL, ACCEPTANCE_BYPASS_REASONS, ACCEPTANCE_DECISION_STATUSES, ACCEPTANCE_FINALIZED_UNACCEPTED, ACCEPTANCE_REVISION_REQUESTED, REASON_ACCEPTANCE_REVIEW_SKIPPED_DEADLINE_RESERVE, extract_final_answer, latest_agent_defined_verification, latest_unreconciled_failed_verification, latest_unreconciled_masked_verification, reviewable_effect_projection, should_nudge_verification, turn_has_reviewable_effects
from ouroboros.outcomes import ACCEPTANCE_ACCEPTED, ACCEPTANCE_BYPASS_REASON_BY_RAIL, ACCEPTANCE_BYPASS_REASONS, ACCEPTANCE_DECISION_STATUSES, ACCEPTANCE_FINALIZED_UNACCEPTED, ACCEPTANCE_REVISION_REQUESTED, REASON_ACCEPTANCE_REVIEW_SKIPPED_DEADLINE_RESERVE, REASON_DELIVERY_CONTROL_DEGRADED, RESULT_INFRA_FAILED, extract_final_answer, latest_agent_defined_verification, latest_unreconciled_failed_verification, latest_unreconciled_masked_verification, reviewable_effect_projection, should_nudge_verification, turn_has_reviewable_effects
from ouroboros.observability import new_call_id, persist_call
from ouroboros.tool_policy import CAPABILITY_OMISSION_HEADER, format_capability_omissions, initial_tool_schemas, list_non_core_tools, swarm_router_turn
from ouroboros.tools.registry import ToolRegistry
@ -1378,6 +1378,9 @@ ACCEPTANCE_DECISION_REASONS = (
"review_degraded",
"fence_reopen_failed",
"infra_failure",
# Owner Q2A: the forced children_unabsorbed rail runs the panel but cannot
# grant a requested improvement pass; the dangling revision terminalizes.
"revision_unavailable_on_forced_rail",
REASON_ACCEPTANCE_REVIEW_SKIPPED_DEADLINE_RESERVE,
# Forced-rail acceptance bypass (closed set, outcomes.py SSOT): stamped by
# `_record_forced_acceptance_bypass` when the panel was owed but a rail fired.
@ -1739,7 +1742,7 @@ def _build_host_acceptance_evidence(ctx: _TaskAcceptanceContext) -> Dict[str, An
and str(call.get("status") or "") == "ok"
for call in (ctx.llm_trace.get("tool_calls") or [])
)
return build_task_acceptance_evidence(
evidence = build_task_acceptance_evidence(
ctx.tools._ctx,
llm_trace=ctx.llm_trace,
drive_root=ctx.drive_root,
@ -1750,6 +1753,12 @@ def _build_host_acceptance_evidence(ctx: _TaskAcceptanceContext) -> Dict[str, An
canonical_subject=str(ctx.content or ""),
subtree_statuses=ctx.subtree_statuses,
)
# Owner Q2A: the forced children_unabsorbed rail stashes the process debt
# (undispositioned children) so the panel sees it; part of the binding hash.
undecided = getattr(ctx.tools._ctx, "_forced_undispositioned_children", None)
if isinstance(undecided, list) and undecided:
evidence["undispositioned_children"] = undecided
return evidence
def _execute_task_acceptance_panel(ctx: _TaskAcceptanceContext) -> Any:
@ -3692,13 +3701,14 @@ def _handle_forced_finalization(ctx: _RoundLimitContext, reason: str) -> Tuple[s
def _handle_provider_unavailable(ctx: _RoundLimitContext) -> Tuple[str, Dict[str, Any], Dict[str, Any]]:
"""Provider-death terminalization (P2 unified best-effort shelf): the model
returned no usable response after the transport same-model reroute + retries
(+ any configured cross-model fallback). Join the SAME honest best-effort
shelf as deadline/budget/round-limit instead of discarding workspace state
with a bare error string one tool-less final answer (which itself benefits
from the same-model reroute) and, failing that, the last assistant text
already produced."""
"""Provider-death terminalization: the model returned no usable response
after the transport same-model reroute + retries (+ any configured
cross-model fallback). SALVAGE like the other forced rails one tool-less
final answer (which itself benefits from the same-model reroute) and,
failing that, the last assistant text already produced but terminalize as
an INFRA FAILURE, never as a completion: an outage interrupts the task with
the objective unmet, and calling that "completed (best effort)" was a lie
that hid a real outage from the owner (95 minutes of silence)."""
# A stale DeliveryCandidate is still the best complete text available when
# the provider is dead. _forced_fallback_result preserves its original
# evidence provenance and adds a host-owned resume disclosure rather than
@ -3724,10 +3734,23 @@ def _handle_provider_unavailable(ctx: _RoundLimitContext) -> Tuple[str, Dict[str
)
prompt = (
"[PROVIDER_UNAVAILABLE] The model provider failed to return a usable response. "
"Produce your best final answer NOW from the verified work so far; clearly mark "
"anything unverified or incomplete. An honest best-effort result is expected here, not a failure."
"The task is being INTERRUPTED by this outage, not completed. Summarize the "
"verified work so far and state plainly what remains undone."
)
return _forced_final_answer(ctx, prompt=prompt, fallback_text=fallback, reason_code="provider_unavailable")
text, usage, llm_trace = _forced_final_answer(
ctx, prompt=prompt, fallback_text=fallback, reason_code="provider_unavailable",
)
# Honesty (P1): a provider outage interrupts the task — it never "completes"
# it. Stamp the infra-failure execution status so the outcome reducer lands
# on infra_failed/provider (terminal task status: failed) instead of the old
# best-effort promotion to "completed". The salvage text above still rides
# the result body; only the claimed status changes. Skipped when a swarm
# routing handoff already cleared the rail (the admitted task owns its own
# lifecycle). NOTE: "interrupted" is deliberately NOT used here — in this
# codebase STATUS_INTERRUPTED is a pre-requeue, non-terminal state.
if str(usage.get("reason_code") or "") == "provider_unavailable":
usage["execution_status"] = RESULT_INFRA_FAILED
return text, usage, llm_trace
def _maybe_deadline_local_finalize(
@ -4471,9 +4494,11 @@ def _resolve_delivery_control(
return "fresh", _extract_plain_text_from_content(content)
raw = _extract_plain_text_from_content(content).strip()
parsed, duplicate_protocol_key = _parse_delivery_control_object(raw)
# ANY parsed object carrying the protocol key is control intent, regardless of
# verb/value — an unknown verb is a mangled protocol attempt, never prose (raw
# JSON leaked to chat). Verb/shape validity is judged below (repair path).
is_control_intent = duplicate_protocol_key or (
isinstance(parsed, dict)
and str(parsed.get("delivery_control") or "") in {"keep", "replace"}
isinstance(parsed, dict) and "delivery_control" in parsed
)
if not required:
if _delivery_replace_required(candidate):
@ -4754,6 +4779,9 @@ def _maybe_enforce_child_absorption_gate(
f"{listed}. Before a clean final answer, inspect unfinished children or record a "
"tree_note(kind='decision') payload with type=child_result_disposition, child_task_id, "
"disposition=integrated|irrelevant|deferred, and the shown child_result_sha256. "
"To disposition several children in ONE call, pass a children array instead: "
"payload={'type': 'child_result_disposition', 'children': [{'child_task_id': ..., "
"'disposition': ..., 'child_result_sha256': ...}, ...]}. "
"discard_child_result remains the shorthand for irrelevant. This is a bounded reminder; "
"ignoring it will finalize best_effort, not clean."
)
@ -4773,9 +4801,88 @@ def _maybe_enforce_child_absorption_gate(
reason_code="children_unabsorbed",
)
_merge_finalization_trace(llm_trace, forced_trace)
_run_forced_children_acceptance(
tools, limit_ctx, undecided, text, messages, emit_progress, llm_trace,
)
return text, usage, llm_trace
def _run_forced_children_acceptance(
tools: ToolRegistry,
limit_ctx: _RoundLimitContext,
undecided: list[Dict[str, Any]],
text: str,
messages: List[Dict[str, Any]],
emit_progress: Callable[[str], None],
llm_trace: Dict[str, Any],
) -> None:
"""Content acceptance still runs on the forced children_unabsorbed rail (owner Q2A).
The panel goes through the ORDINARY entry point (`_run_task_acceptance_review_once`)
after the forced answer text exists but BEFORE the loop seals it; the evidence packet
carries the undispositioned children via the ctx stash. The forced rail can never take
another model round, so a ``True`` return terminalizes here instead of looping: a
requested improvement pass is downgraded to ``finalized_unaccepted``, while a WAIT
shape that never ran the panel keeps the typed acceptance-bypass verdict already
stamped by `_record_forced_finalization`. Never raises salvage outranks review.
"""
if not str(text or "").strip():
return
tools_ctx = tools._ctx
try:
from ouroboros.tools.join_ledger import _child_result_sha256
debt = [
{
"task_id": str(c.get("task_id") or c.get("id") or ""),
"status": str(c.get("status") or "unknown"),
"child_result_sha256": _child_result_sha256(c),
}
for c in undecided[:20]
if isinstance(c, dict)
]
if len(undecided) > 20:
# Explicit omission marker: a >20-child debt list must not read as complete.
debt.append({"omitted": len(undecided) - 20, "total": len(undecided)})
tools_ctx._forced_undispositioned_children = debt
another_round = _run_task_acceptance_review_once(
tools=tools,
content=str(text),
task_id=limit_ctx.task_id,
task_type=limit_ctx.task_type,
llm_trace=llm_trace,
drive_root=limit_ctx.drive_root,
messages=messages,
emit_progress=emit_progress,
)
if not another_round:
return
tools_ctx._task_acceptance_reviewed = True
_end_task_acceptance_fence(tools_ctx, outcome="terminal")
decision = llm_trace.get("acceptance_decision")
status = str(decision.get("status") or "") if isinstance(decision, dict) else ""
if status == ACCEPTANCE_REVISION_REQUESTED:
# A panel DID run and asked for an improvement pass; record the honest
# terminal state instead of leaving a dangling revision request.
_set_acceptance_decision(llm_trace, {
"status": ACCEPTANCE_FINALIZED_UNACCEPTED,
"reason": "revision_unavailable_on_forced_rail",
"source": "forced_finalization",
"rationale": (
"The acceptance panel requested an improvement pass, but the "
"forced children_unabsorbed rail cannot take another model round."
),
})
emit_progress(
"Task acceptance ran on the forced rail; the requested improvement "
"pass is unavailable, finalizing unaccepted."
)
except Exception:
log.debug("Forced children_unabsorbed acceptance run failed", exc_info=True)
finally:
tools_ctx._forced_undispositioned_children = None
def _enforce_swarm_actions(
content: str,
messages: List[Dict[str, Any]],
@ -5445,6 +5552,123 @@ def _forced_swarm_router_result(
return candidate.full_text, ctx.accumulated_usage, llm_trace
def _resolve_forced_delivery_control(
tools_ctx: Any,
extracted: str,
) -> Tuple[str, str]:
"""PURE, no-retry delivery-control resolution for the forced rail.
While the delivery-control latch is armed, the model's one forced answer is
legitimately allowed to be the protocol object ``{"delivery_control": ...}``
instead of prose shipping it raw leaked protocol JSON into the owner's
chat and the durable result. Resolve it here, before suffix composition and
publication, without ever re-looping (``_resolve_delivery_control`` can
inject a repair round, which a hard forced stop must never do): a valid
``keep`` uses the retained candidate's full text, a valid ``replace`` uses
``full_answer``, and a malformed/duplicate/invalid control falls back to the
retained candidate with the typed degraded reason. Protocol intent under the
armed latch is ANY parsed object carrying the ``delivery_control`` key
(regardless of verb/value) AND any JSON-LOOKING text (stripped text starting
with ``{``) that fails to parse the model was explicitly instructed to
answer with the protocol object, so a JSON-looking non-parse is a mangled
protocol attempt, never the answer. JSON while NOT armed passes through
untouched legitimate user-facing JSON is never eaten. Disclosed residual:
armed PROSE (text not starting with ``{``) is genuinely indistinguishable
from an intentional fresh answer and stands as-is, even if the model meant
it as a control acknowledgement. Clears the latch. Returns
``(resolved_text, degraded_reason)``.
"""
if tools_ctx is None or not extracted:
return extracted, ""
candidate = getattr(tools_ctx, "_delivery_candidate", None)
candidate = candidate if isinstance(candidate, DeliveryCandidate) else None
armed = bool(getattr(tools_ctx, "_delivery_control_required", False)) or (
candidate is not None and _delivery_replace_required(candidate)
)
if not armed:
return extracted, ""
tools_ctx._delivery_control_required = False
parsed, duplicate_protocol_key = _parse_delivery_control_object(extracted)
# Protocol intent: any parsed object with the protocol key (unknown verb =
# broken control, never prose), or JSON-looking text that fails to parse (a
# mangled protocol attempt under the armed latch — the candidate is the answer).
protocol_intent = duplicate_protocol_key or (
("delivery_control" in parsed)
if isinstance(parsed, dict)
else extracted.lstrip().startswith("{")
)
if not protocol_intent:
# An ordinary prose answer under an armed latch: the fresh text stands.
return extracted, ""
selected = str(parsed.get("delivery_control") or "") if isinstance(parsed, dict) else ""
if selected == "replace" and set(parsed) == {"delivery_control", "full_answer"}:
replacement = parsed.get("full_answer")
if isinstance(replacement, str) and replacement.strip():
return replacement, ""
elif selected == "keep" and set(parsed) == {"delivery_control"} and candidate is not None:
return candidate.full_text, ""
# Malformed/duplicate/invalid control: preserve the retained candidate (or,
# with none retained, let the caller's fallback text stand) and say so.
return (
candidate.full_text if candidate is not None else "",
REASON_DELIVERY_CONTROL_DEGRADED,
)
def _forced_delegation_note(tools_ctx: Any, llm_trace: Dict[str, Any]) -> str:
"""The nanny postcondition's forced-path half, grounded in DURABLE custody.
A forced finalization may not re-loop, so the substrate fact rides the one final
prompt. `delegate_custody.task_execution_evidence` on the custody root (the
canonical/budget root the same split-root rule Phase A fixed in the ordinary
path) decides, not just the current execution's trace: succeeded → no note;
started-but-unsettled pending wording (no retry pressure); settled-without-
success truthful failure wording; zero started with readable evidence the
no-delegation wording; unreadable evidence no accusation."""
if not getattr(tools_ctx, "_nanny_route_dispatched", False):
return ""
try:
from ouroboros import delegate_custody
root = delegate_custody.custody_root(tools_ctx)
log_path = delegate_custody.event_log_path(root)
if log_path.exists():
# _iter_rows swallows OSError, which would misread an unreadable log
# as "zero runs" — probe readability so absence of rows is a fact.
log_path.open("rb").close()
evidence = delegate_custody.task_execution_evidence(
root, str(getattr(tools_ctx, "task_id", "") or ""),
)
except Exception:
log.debug("Forced-path custody evidence unreadable; nanny note skipped", exc_info=True)
return ""
started = int(evidence.get("delegated_runs_started") or 0)
settled = int(evidence.get("delegated_runs_settled") or 0)
if int(evidence.get("delegated_runs_succeeded") or 0):
return ""
if started > settled:
return (
"\nNOTE: this task dispatched delegated run(s) that have not settled "
f"yet ({started - settled} of {started} pending). State their status "
"in your answer; do not claim the delegated work finished."
)
if settled:
return (
f"\nNOTE: this task's delegated run(s) settled WITHOUT success ({settled} "
"run(s)). State that failure and its impact honestly in your answer."
)
if any(str(c.get("tool") or "") == "delegate_start"
for c in (llm_trace.get("tool_calls") or []) if isinstance(c, dict)):
# The trace shows a dispatch the durable rows have not recorded — never
# accuse over evidence that is behind the task's own actions.
return ""
return (
"\nNOTE: this task was dispatched onto the delegated substrate "
"(executor=harness) and made no delegate_start calls — the work ran on "
"metered API tokens. State why in your answer."
)
def _forced_final_answer(
ctx: _RoundLimitContext,
*,
@ -5461,19 +5685,7 @@ def _forced_final_answer(
if router_result is not None:
return router_result
tools_ctx = getattr(getattr(ctx, "tools", None), "_ctx", None)
if (getattr(tools_ctx, "_nanny_route_dispatched", False)
and not any(str(c.get("tool") or "") == "delegate_start"
for c in (llm_trace.get("tool_calls") or [])
if isinstance(c, dict))):
# The nanny postcondition's forced-path half: a forced finalization may not
# re-loop (that is its whole point), so the substrate fact rides the one
# final prompt instead — the child can still SAY why delegation never
# happened, and the parent still sees the decision instead of silence.
prompt += (
"\nNOTE: this task was dispatched onto the delegated substrate "
"(executor=harness) and made no delegate_start calls — the work ran on "
"metered API tokens. State why in your answer."
)
prompt += _forced_delegation_note(tools_ctx, llm_trace)
_append_or_merge_user_message(ctx.messages, prompt)
extracted = ""
for attempt in range(2):
@ -5509,6 +5721,9 @@ def _forced_final_answer(
"new complete answer bound to every owner directive now present.",
)
extracted, control_degraded = _resolve_forced_delivery_control(
getattr(getattr(ctx, "tools", None), "_ctx", None), extracted,
)
if extracted:
# Typed fact for the best_effort outcome gate: a REAL model answer
# was extracted (host fallback strings never set this).
@ -5524,6 +5739,14 @@ def _forced_final_answer(
candidate = _publish_model_forced_candidate(
ctx, llm_trace, full_text, reason_code,
)
if control_degraded and candidate is not None:
candidate.degraded_reason = control_degraded
llm_trace.setdefault("reasoning_notes", []).append(
"Forced finalization received an invalid delivery-control object; "
"preserved the retained complete answer."
)
if getattr(ctx, "tools", None) is not None:
_publish_delivery_candidate(ctx.tools, candidate, llm_trace)
_record_forced_finalization(
ctx,
llm_trace,
@ -6807,9 +7030,9 @@ def run_llm_loop(
emit_progress=emit_progress, context_fit_plan=context_fit_plan,
active_context_mode=active_context_mode)
if msg is None:
# Provider-death: join the unified honest best-effort shelf
# (deadline/budget/round-limit) instead of discarding useful
# workspace state with a bare error string.
# Provider-death: salvage the useful workspace state like the
# forced rails do, but terminalize as an infra failure — an
# outage interrupts the task, it never completes it.
text, accumulated_usage, forced_trace = _handle_provider_unavailable(limit_ctx)
_merge_finalization_trace(llm_trace, forced_trace)
return text, accumulated_usage, llm_trace

View file

@ -95,15 +95,16 @@ OBJECTIVE_BEST_EFFORT = "best_effort"
# deadline_local is the loop-local sibling of finalization_grace (v6.33.0 WS2): a
# genuinely-extracted answer at a real deadline must land as best_effort, not an
# agent failure — same as the supervisor finalize_now path.
# provider_unavailable is deliberately NOT here (it was, until the slime-saga
# audit): a provider outage interrupts a task with the objective unmet, and the
# best-effort promotion turned that into "completed" — a lie that hid a real
# outage from the owner. The rail stamps infra_failed instead (loop.py
# _handle_provider_unavailable); salvage text still rides the result body.
BEST_EFFORT_REASON_CODES = frozenset({
"budget_exhausted",
"round_limit",
"finalization_grace",
"deadline_local",
# provider-death terminalization (WA2): a genuinely-extracted final answer
# after the same-model reroute + fallback exhausted must land as best_effort,
# not a flat failure — the same honest-shelf semantics as deadline/budget.
"provider_unavailable",
"children_unabsorbed",
})
@ -844,8 +845,16 @@ def derive_loop_outcome(final_text: str, usage: Dict[str, Any], llm_trace: Dict[
)
forced_best_effort_with_deferred_child = bool(
deferred_child_count
and str(delivery_candidate.get("degraded_reason") or "")
in BEST_EFFORT_REASON_CODES
and (
str(delivery_candidate.get("degraded_reason") or "")
in BEST_EFFORT_REASON_CODES
# provider_unavailable left the best-effort set (2026-08-10 saga:
# a provider-killed task is failed, not best-effort), but a forced
# provider rail must still not erase the more specific
# deferred-child objective below.
or str(delivery_candidate.get("degraded_reason") or "")
== "provider_unavailable"
)
)
verification_failures: List[Dict[str, Any]] = []
for event in llm_trace.get("verification_events") or []:
@ -931,7 +940,8 @@ def derive_loop_outcome(final_text: str, usage: Dict[str, Any], llm_trace: Dict[
# Honest reachability (measured, not asserted): the FORCED-rail bypass reasons
# cannot arrive here on an OK execution — a bypass is stamped only when the rail
# already wrote `usage.reason_code`, and every writer of that key also writes
# `execution_status='failed'`, so those runs land on the STRONGER failed /
# `execution_status='failed'` (the provider rail upgrades it to 'infra_failed'),
# so those runs land on the STRONGER failed/infra_failed /
# best_effort branches above and the owner-visible bypass rides the review axis
# (see test_forced_rail_axes_are_the_production_shape). What this branch actually
# decides is the pacing skip (REASON_ACCEPTANCE_REVIEW_SKIPPED_DEADLINE_RESERVE).

View file

@ -272,6 +272,59 @@ def _record_child_result_disposition(
return f"OK: child {tid} marked {disposition} for result {expected[:12]}."
def _record_child_result_disposition_batch(
ctx: ToolContext,
payload: Dict[str, Any],
rationale: str,
) -> str:
"""Record dispositions for MANY children in ONE tree_note call.
Each ``children`` entry is validated and recorded exactly like the single
form (same exact-hash binding, lineage gates, and idempotency the batch
expands into the same individual authoritative ledger rows, so every
existing reader is unchanged). Entries are independent: an invalid entry is
rejected with a clear per-entry error naming it, while valid entries still
record. The shared tree_note text is the rationale for every entry.
"""
envelope_extra = sorted(set(payload) - {"type", "children"})
children = payload.get("children")
if envelope_extra or not isinstance(children, list) or not children:
return (
"⚠️ CHILD_RESULT_DISPOSITION_INVALID: the batch form is exactly "
"{'type': 'child_result_disposition', 'children': [{'child_task_id', "
"'disposition', 'child_result_sha256'}, ...]} with a non-empty array"
+ (f" (unknown key(s): {', '.join(envelope_extra)})" if envelope_extra else "")
+ ". Nothing was recorded (atomic no-op)."
)
lines: list[str] = []
recorded = 0
for index, entry in enumerate(children):
if not isinstance(entry, dict):
lines.append(f"[entry {index}] ⚠️ CHILD_RESULT_DISPOSITION_INVALID: entry must be a JSON object.")
continue
single = dict(entry)
single.setdefault("type", CHILD_RESULT_DISPOSITION_TYPE)
label = str(entry.get("child_task_id") or f"entry {index}")
outcome = _record_child_result_disposition(ctx, single, rationale)
if outcome.startswith("OK:"):
recorded += 1
lines.append(f"[{label}] {outcome}")
total = len(children)
if recorded == total:
header = f"OK: batch child disposition recorded for {recorded} child(ren)."
elif recorded:
header = (
f"⚠️ CHILD_RESULT_DISPOSITION_PARTIAL: {recorded}/{total} entries recorded; "
"the failed entries below were rejected individually and must be corrected."
)
else:
header = (
f"⚠️ CHILD_RESULT_DISPOSITION_INVALID: 0/{total} batch entries were recorded."
)
return header + "\n" + "\n".join(lines)
def _record_current_child_result_disposition(
ctx: ToolContext,
child_task_id: str,

View file

@ -32,6 +32,12 @@ def _tree_note(
)
# The join ledger is the sole lineage/hash/write authority. Invalid tagged
# payloads return here and are never downgraded into ordinary text notes.
# A `children` array is the batch form: one call, one shared rationale,
# expanded into the same per-child authoritative rows as the single form.
if "children" in payload:
from ouroboros.tools.join_ledger import _record_child_result_disposition_batch
return _record_child_result_disposition_batch(ctx, payload, text)
from ouroboros.tools.join_ledger import _record_child_result_disposition
return _record_child_result_disposition(ctx, payload, text)
@ -96,7 +102,10 @@ def get_tools() -> List[ToolEntry]:
"Structured payload. Required for delegation_constraint. For a parent "
"decision about a direct child result, set type=child_result_disposition, "
"child_task_id, disposition, and the exact SHA-256 shown by child evidence; "
"tree_note text is the rationale."
"tree_note text is the rationale. To disposition MANY children in one call, "
"set type=child_result_disposition and a children array of "
"{child_task_id, disposition, child_result_sha256} entries (one shared "
"rationale; each entry is validated exactly like the single form)."
),
"properties": {
"type": {"type": "string", "enum": ["child_result_disposition"]},
@ -106,6 +115,27 @@ def get_tools() -> List[ToolEntry]:
"enum": ["integrated", "irrelevant", "deferred"],
},
"child_result_sha256": {"type": "string"},
"children": {
"type": "array",
"description": (
"Batch child dispositions: entries of {child_task_id, "
"disposition, child_result_sha256}."
),
"items": {
"type": "object",
"properties": {
"child_task_id": {"type": "string"},
"disposition": {
"type": "string",
"enum": ["integrated", "irrelevant", "deferred"],
},
"child_result_sha256": {"type": "string"},
},
"required": [
"child_task_id", "disposition", "child_result_sha256",
],
},
},
"constraint_id": {"type": "string"},
"directive": {"type": "string", "enum": list(DELEGATION_CONSTRAINT_DIRECTIVES)},
"scope": {},

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ouroboros"
version = "6.94.0"
version = "6.95.0"
description = "Self-creating AI agent with constitution, background consciousness, and persistent identity"
readme = "README.md"
license = {text = "MIT"}

View file

@ -1393,6 +1393,58 @@ def _handle_evolution_task_done(
log.debug("Post-task evolution autostop failed", exc_info=True)
# Single-shot registry for the provider-death owner notification. The old gate
# (`and task`, a live RUNNING row) also swallowed every reaper-delivered terminal:
# the reaper loop pops RUNNING before its task_done dispatches (regression tests:
# test_supervisor_reaper_notification.py). Process-local: after a restart the
# worst case is one repeated notification, never a lost one.
_PROVIDER_DEATH_NOTIFIED: set[str] = set()
def _maybe_notify_provider_death(
ctx: Any,
task_id: Any,
task: Dict[str, Any],
final_task_result: Dict[str, Any],
task_done_event: Dict[str, Any],
) -> None:
"""Provider-death honesty (P1): tell the owner a root task terminalized by a
provider outage was NOT completed the historical shape was 95 minutes of
silence behind a result claiming "completed". Runs AFTER the task-done
bookkeeping (cleanup never depends on chat delivery) and registers the id in
the single-shot registry only after a SUCCESSFUL send, so a raising send is
retried by a later dispatch instead of being lost. Never raises."""
if not (
task_id
and str(task_id) not in _PROVIDER_DEATH_NOTIFIED
and str(
task.get("delegation_role") or final_task_result.get("delegation_role") or ""
) != "subagent"
and str(task_done_event.get("reason_code") or "") == "provider_unavailable"
and str(task_done_event.get("status") or "") == STATUS_FAILED
):
return
notify_chat = int(task_done_event.get("chat_id") or 0)
if not notify_chat:
return
try:
# Promise only what works: the resume endpoint serves budget-paused
# PENDING tasks (task_lifecycle.resume_budget_paused_task), never a
# failed terminal — "resume" here was a false owner promise.
ctx.send_with_budget(
notify_chat,
f"🔌 Task {task_id} was stopped by a model-provider outage and was "
"NOT completed. Partial work and workspace files are preserved; "
"re-run the task once the provider recovers.",
)
except Exception:
log.warning(
"Provider-death owner notification failed for %s", task_id, exc_info=True,
)
return
_PROVIDER_DEATH_NOTIFIED.add(str(task_id))
def _finish_task_done_dispatch(
evt: Dict[str, Any],
ctx: Any,
@ -1486,6 +1538,25 @@ def _finish_task_done_dispatch(
with _queue_lock:
if task_id:
ctx.RUNNING.pop(str(task_id), None)
# A child's settled result is the parent's cue to START integrating,
# so settlement counts as the PARENT's own progress. Without this
# stamp a coordinator blocked in wait_tasks was idle-killed exactly
# when its last child delivered (the completed child instantly left
# RUNNING, so _subtree_progressing went dark and only the grace
# window remained). Own progress also lets the existing spare
# machinery (resolve_grace_episode_for_spared_task) withdraw an
# outstanding finalization-grace episode on the next enforce tick.
# A one-shot event per child terminal — unlike subtree narration,
# it cannot re-arm/flicker episodes. `task` is {} for reaper-delivered
# terminals (RUNNING popped before dispatch), so fall back to the
# durable result for the parent id — same shape the notification
# gate handles.
parent_meta = ctx.RUNNING.get(str(
task.get("parent_task_id")
or final_task_result.get("parent_task_id") or ""
))
if isinstance(parent_meta, dict):
parent_meta["last_progress_at"] = time.time()
if worker_id in ctx.WORKERS and ctx.WORKERS[worker_id].busy_task_id == task_id:
# A `reaping` slot is OWNED — by the reaper or by an in-flight
# cancellation custody. Its owner confirms process death and then
@ -1512,7 +1583,10 @@ def _finish_task_done_dispatch(
)
if bool(evt.get("_ephemeral")):
# An ephemeral direct-chat decision turn shows its failure inline —
# no duplicate provider-outage owner ping.
return
_maybe_notify_provider_death(ctx, task_id, task, final_task_result, task_done_event)
try:
results_dir = pathlib.Path(ctx.DRIVE_ROOT) / "task_results"
results_dir.mkdir(parents=True, exist_ok=True)

View file

@ -312,6 +312,120 @@ def test_disposition_violations_helper_is_the_normalizer_authority():
assert normalize_child_result_disposition_payload(bad) is None
def test_batch_disposition_records_one_authoritative_row_per_child(tmp_path):
"""Q2A (slime saga): a fan-out parent needed one bureaucratic tree_note per
child (six calls in the incident). One call with a children array now
expands into the SAME per-child authoritative rows as the single form, so
every existing reader (projection, absorption gate) is unchanged."""
from ouroboros.task_status import load_effective_task_result
from ouroboros.task_tree_ledger import tree_ledger_rows
from ouroboros.tools.join_ledger import _child_result_sha256
from ouroboros.tools.task_tree import _tree_note
entries = []
for child_id, disposition in (
("child1", "integrated"), ("child2", "irrelevant"), ("child3", "deferred"),
):
_write_child(tmp_path, child_id=child_id)
entries.append({
"child_task_id": child_id,
"disposition": disposition,
"child_result_sha256": _child_result_sha256(
load_effective_task_result(tmp_path, child_id)
),
})
result = _tree_note(
_parent_ctx(tmp_path),
"decision",
"batch disposition after absorbing all three",
payload={"type": "child_result_disposition", "children": entries},
)
assert result.startswith("OK: batch child disposition recorded for 3 child(ren).")
rows = tree_ledger_rows("parent1", data_root=tmp_path)
assert len(rows) == 3
recorded = {row["payload"]["child_task_id"]: row["payload"] for row in rows}
for entry in entries:
payload = recorded[entry["child_task_id"]]
assert payload["disposition"] == entry["disposition"]
assert payload["child_result_sha256"] == entry["child_result_sha256"]
effective = load_effective_task_result(tmp_path, entry["child_task_id"])
assert effective["child_result_disposition"] == entry["disposition"]
assert effective["child_result_disposition_source"] == "task_tree_ledger"
def test_batch_disposition_rejects_invalid_entries_individually(tmp_path):
"""Exact-hash binding is preserved PER CHILD: a stale hash or a foreign task
rejects only its own entry the clear error names which entries failed
while valid entries still record."""
from ouroboros.task_status import load_effective_task_result
from ouroboros.task_tree_ledger import tree_ledger_rows
from ouroboros.tools.join_ledger import _child_result_sha256
from ouroboros.tools.task_tree import _tree_note
_write_child(tmp_path)
_write_child(tmp_path, child_id="child2")
good_hash = _child_result_sha256(load_effective_task_result(tmp_path, "child1"))
result = _tree_note(
_parent_ctx(tmp_path),
"decision",
"partial batch",
payload={
"type": "child_result_disposition",
"children": [
{"child_task_id": "child1", "disposition": "integrated",
"child_result_sha256": good_hash},
{"child_task_id": "child2", "disposition": "integrated",
"child_result_sha256": "0" * 64}, # stale hash
{"child_task_id": "stranger9", "disposition": "irrelevant",
"child_result_sha256": "1" * 64}, # not our child
{"child_task_id": "child2", "disposition": "absorbed",
"child_result_sha256": "bad"}, # enum + sha violations
"not-an-object",
],
},
)
assert result.startswith("⚠️ CHILD_RESULT_DISPOSITION_PARTIAL: 1/5")
assert "[child1] OK:" in result
assert "[child2] ⚠️ CHILD_RESULT_STALE" in result
assert "[stranger9] ⚠️ CHILD_RESULT_LINEAGE_FORBIDDEN" in result
assert "disposition must be one of" in result
assert "[entry 4] ⚠️ CHILD_RESULT_DISPOSITION_INVALID: entry must be a JSON object." in result
rows = tree_ledger_rows("parent1", data_root=tmp_path)
assert [row["payload"]["child_task_id"] for row in rows] == ["child1"]
def test_batch_disposition_envelope_is_validated_atomically(tmp_path):
"""A malformed batch ENVELOPE (empty/non-array children, stray keys mixing
the single and batch forms) records nothing."""
from ouroboros.task_tree_ledger import tree_ledger_rows
from ouroboros.tools.task_tree import _tree_note
_write_child(tmp_path)
for payload in (
{"type": "child_result_disposition", "children": []},
{"type": "child_result_disposition", "children": "child1"},
{"type": "child_result_disposition", "children": [], "child_task_id": "child1"},
):
result = _tree_note(_parent_ctx(tmp_path), "decision", "why", payload=payload)
assert "CHILD_RESULT_DISPOSITION_INVALID" in result
assert "atomic no-op" in result
wrong_kind = _tree_note(
_parent_ctx(tmp_path),
"note",
"why",
payload={"type": "child_result_disposition", "children": [
{"child_task_id": "child1", "disposition": "integrated",
"child_result_sha256": "a" * 64},
]},
)
assert "require kind='decision'" in wrong_kind
assert tree_ledger_rows("parent1", data_root=tmp_path) == []
def test_orphan_note_claim_detail_is_scoped_to_undecided_children(monkeypatch):
"""The blackboard-derived claim detail belongs ONLY to children the exact-hash
disposition projection left UNDECIDED. A deferred child IS carried by that

View file

@ -1180,6 +1180,416 @@ def test_child_result_change_after_host_panel_requires_replacement_and_fresh_pan
assert binding["acceptance_status"] == "pass"
# ---------------------------------------------------------------------------
# F1 (slime saga): a forced finalization while the delivery-control latch is
# armed must RESOLVE the protocol object purely (no repair round — a hard stop
# may not re-loop), never ship raw {"delivery_control": ...} JSON to the chat
# or the durable result, and never eat legitimate JSON when the latch is off.
def _arm_latch_with_candidate(loop, registry, limit_ctx, trace, text="Retained complete answer."):
candidate = loop._replace_delivery_candidate(
registry, limit_ctx, trace, text, control="awaiting_control",
)
registry._ctx._delivery_control_required = True # replace() resets the latch
return candidate
def test_forced_round_limit_resolves_armed_replace_control(tmp_path, monkeypatch):
loop, registry, limit_ctx, trace = _forced_test_context(tmp_path)
_arm_latch_with_candidate(loop, registry, limit_ctx, trace)
control = json.dumps({
"delivery_control": "replace",
"full_answer": "Complete replacement answer for the owner.",
})
monkeypatch.setattr(
loop, "call_llm_with_retry",
lambda *_a, **_k: ({"role": "assistant", "content": control}, 0.0),
)
text, usage, _returned_trace = loop._handle_round_limit(limit_ctx)
assert text.startswith("Complete replacement answer for the owner.")
assert "delivery_control" not in text
assert registry._ctx._delivery_control_required is False
assert registry._ctx._delivery_candidate.full_text == text
assert usage["reason_code"] == "round_limit"
def test_forced_finalization_resolves_armed_keep_to_retained_candidate(tmp_path, monkeypatch):
loop, registry, limit_ctx, trace = _forced_test_context(tmp_path)
_arm_latch_with_candidate(loop, registry, limit_ctx, trace)
monkeypatch.setattr(
loop, "call_llm_with_retry",
lambda *_a, **_k: (
{"role": "assistant", "content": '{"delivery_control":"keep"}'}, 0.0,
),
)
text, _usage, _returned_trace = loop._forced_final_answer(
limit_ctx, prompt="finalize", fallback_text="fallback", reason_code="finalization_grace",
)
assert text.startswith("Retained complete answer.")
assert "delivery_control" not in text
assert registry._ctx._delivery_control_required is False
def test_forced_finalization_degrades_malformed_control_to_retained_candidate(
tmp_path, monkeypatch,
):
loop, registry, limit_ctx, trace = _forced_test_context(tmp_path)
_arm_latch_with_candidate(loop, registry, limit_ctx, trace)
# Duplicate protocol key -> invalid control object with control intent.
malformed = '{"delivery_control":"keep","delivery_control":"replace"}'
monkeypatch.setattr(
loop, "call_llm_with_retry",
lambda *_a, **_k: ({"role": "assistant", "content": malformed}, 0.0),
)
text, _usage, returned_trace = loop._forced_final_answer(
limit_ctx, prompt="finalize", fallback_text="fallback", reason_code="round_limit",
)
assert text.startswith("Retained complete answer.")
assert "delivery_control" not in text
candidate = registry._ctx._delivery_candidate
assert candidate.degraded is True
assert candidate.degraded_reason == "delivery_control_degraded"
assert returned_trace["delivery_candidate"]["degraded_reason"] == "delivery_control_degraded"
def test_forced_finalization_passes_json_through_when_latch_not_armed(tmp_path, monkeypatch):
"""Legitimate user-facing JSON is never eaten while no control round is open."""
loop, registry, limit_ctx, _trace = _forced_test_context(tmp_path)
legitimate = json.dumps({"delivery_control": "keep"})
monkeypatch.setattr(
loop, "call_llm_with_retry",
lambda *_a, **_k: ({"role": "assistant", "content": legitimate}, 0.0),
)
text, _usage, _returned_trace = loop._forced_final_answer(
limit_ctx, prompt="finalize", fallback_text="fallback", reason_code="round_limit",
)
assert text.startswith(legitimate)
def test_forced_finalization_degrades_unknown_verb_control_to_retained_candidate(
tmp_path, monkeypatch,
):
"""An armed latch treats ANY parsed object carrying the protocol key as
protocol an unknown verb is a mangled control, never the owner's answer."""
loop, registry, limit_ctx, trace = _forced_test_context(tmp_path)
_arm_latch_with_candidate(loop, registry, limit_ctx, trace)
unknown_verb = json.dumps({
"delivery_control": "publish",
"full_answer": "text behind an unknown verb",
})
monkeypatch.setattr(
loop, "call_llm_with_retry",
lambda *_a, **_k: ({"role": "assistant", "content": unknown_verb}, 0.0),
)
text, _usage, _returned_trace = loop._forced_final_answer(
limit_ctx, prompt="finalize", fallback_text="fallback", reason_code="round_limit",
)
assert text.startswith("Retained complete answer.")
assert "delivery_control" not in text
assert "publish" not in text
candidate = registry._ctx._delivery_candidate
assert candidate.degraded is True
assert candidate.degraded_reason == "delivery_control_degraded"
def test_forced_finalization_degrades_broken_json_looking_text_to_retained_candidate(
tmp_path, monkeypatch,
):
"""Armed latch + JSON-looking text that FAILS to parse: the model was
explicitly instructed to answer with the protocol object, so a broken
brace-blob is a mangled protocol attempt resolve to the retained
candidate with the typed degraded reason; never ship the broken JSON raw."""
loop, registry, limit_ctx, trace = _forced_test_context(tmp_path)
_arm_latch_with_candidate(loop, registry, limit_ctx, trace)
broken = '{"delivery_control": "replace", "full_answer": "truncated mid-'
monkeypatch.setattr(
loop, "call_llm_with_retry",
lambda *_a, **_k: ({"role": "assistant", "content": broken}, 0.0),
)
text, _usage, _returned_trace = loop._forced_final_answer(
limit_ctx, prompt="finalize", fallback_text="fallback", reason_code="round_limit",
)
assert text.startswith("Retained complete answer.")
assert '{"delivery_control"' not in text
candidate = registry._ctx._delivery_candidate
assert candidate.degraded is True
assert candidate.degraded_reason == "delivery_control_degraded"
def test_forced_finalization_keeps_armed_prose_as_the_answer(tmp_path, monkeypatch):
"""Armed latch + plain prose (not starting with '{'): the fresh text stands
the disclosed residual is prose, never anything JSON-looking."""
loop, registry, limit_ctx, trace = _forced_test_context(tmp_path)
_arm_latch_with_candidate(loop, registry, limit_ctx, trace)
prose = "A reconsidered complete prose answer for the owner."
monkeypatch.setattr(
loop, "call_llm_with_retry",
lambda *_a, **_k: ({"role": "assistant", "content": prose}, 0.0),
)
text, _usage, _returned_trace = loop._forced_final_answer(
limit_ctx, prompt="finalize", fallback_text="fallback", reason_code="round_limit",
)
assert text.startswith(prose)
assert registry._ctx._delivery_control_required is False
def test_forced_finalization_passes_broken_json_through_when_latch_not_armed(
tmp_path, monkeypatch,
):
"""Unarmed: broken JSON-looking output is an ordinary (bad) answer, not a
protocol attempt it passes through untouched."""
loop, registry, limit_ctx, _trace = _forced_test_context(tmp_path)
broken = '{"some_json_like": "output that never closes'
monkeypatch.setattr(
loop, "call_llm_with_retry",
lambda *_a, **_k: ({"role": "assistant", "content": broken}, 0.0),
)
text, _usage, _returned_trace = loop._forced_final_answer(
limit_ctx, prompt="finalize", fallback_text="fallback", reason_code="round_limit",
)
assert text.startswith(broken)
def test_nonforced_resolver_treats_unknown_verb_object_as_protocol_not_prose(tmp_path):
"""The non-forced resolver's gap: an owner-revision round answered with an
unknown-verb protocol object previously returned it as FRESH prose (raw JSON
to the owner). It is control intent: the resolver keeps its repair semantics
(one repair round), never adopting the raw object as the answer."""
loop, registry, limit_ctx, trace = _forced_test_context(tmp_path)
candidate = loop._replace_delivery_candidate(
registry, limit_ctx, trace, "Retained complete answer.", control="candidate",
)
candidate.finalization_control = "owner_revision_required"
registry._ctx._delivery_control_required = False
unknown_verb = json.dumps({"delivery_control": "finalize"})
status, text = loop._resolve_delivery_control(
unknown_verb, registry, limit_ctx, trace,
)
assert status == "retry"
assert text == ""
assert candidate.repair_attempted is True
assert "DELIVERY_CONTROL_REPAIR" in str(limit_ctx.messages[-1]["content"])
# Second failure after the one repair round degrades to the retained answer.
status2, text2 = loop._resolve_delivery_control(
unknown_verb, registry, limit_ctx, trace,
)
assert status2 == "degraded"
assert text2 == candidate.full_text
assert "delivery_control" not in text2
def test_children_unabsorbed_forced_path_never_leaks_protocol_json(tmp_path, monkeypatch):
"""The saga leak: children_unabsorbed fired while the latch was armed and the
model's protocol JSON went RAW into the owner's chat and the durable result."""
_write_child(tmp_path, status="running")
loop, registry, limit_ctx, trace = _forced_test_context(tmp_path)
_arm_latch_with_candidate(loop, registry, limit_ctx, trace)
registry._ctx._child_absorption_reminded = True
control = json.dumps({
"delivery_control": "replace",
"full_answer": "Integrated summary naming the unabsorbed child explicitly.",
})
monkeypatch.setattr(
loop, "call_llm_with_retry",
lambda *_a, **_k: ({"role": "assistant", "content": control}, 0.0),
)
result = loop._maybe_enforce_child_absorption_gate(
registry, limit_ctx, "", limit_ctx.messages, lambda _t: None, trace,
)
assert result is not None and result != "continue"
text, usage, _returned_trace = result
assert text.startswith("Integrated summary naming the unabsorbed child explicitly.")
assert "delivery_control" not in text
assert usage["reason_code"] == "children_unabsorbed"
assert registry._ctx._delivery_candidate.full_text == text
# ---------------------------------------------------------------------------
# Owner Q2A (slime saga): the forced children_unabsorbed rail must still run the
# CONTENT acceptance review through the ordinary entry point (the incident task
# finalized with zero review), the panel must see the undispositioned-children
# process debt, and a requested improvement pass (which the forced rail cannot
# grant) terminalizes honestly. The process outcome stays
# best_effort/children_unabsorbed in every branch.
def _acceptance_panel_result(*, aggregate, actors, findings=()):
import ouroboros.review_substrate as rs
return rs.ReviewRunResult(
request={"surface": "task_acceptance", "policy": {"min_successful_slots": 1}},
actors=list(actors),
parsed_findings=list(findings),
aggregate_signal=aggregate,
)
def _forced_absorption_acceptance_context(tmp_path, monkeypatch, panel_result):
loop, registry, limit_ctx, trace = _forced_test_context(tmp_path)
registry._ctx.is_direct_chat = False
registry._ctx._child_absorption_reminded = True
seen_evidence: dict = {}
panel_calls = {"count": 0}
def panel_probe(review_ctx):
panel_calls["count"] += 1
seen_evidence.update(review_ctx.evidence or {})
return panel_result
monkeypatch.setattr(loop, "get_task_review_mode", lambda: "auto")
monkeypatch.setattr(loop, "_execute_task_acceptance_panel", panel_probe)
monkeypatch.setattr(
loop, "call_llm_with_retry",
lambda *_a, **_k: (
{"role": "assistant", "content": "Best-effort final answer naming child1."},
0.0,
),
)
return loop, registry, limit_ctx, trace, seen_evidence, panel_calls
def test_forced_children_unabsorbed_rail_runs_acceptance_with_debt_evidence(
tmp_path, monkeypatch,
):
"""A quiescent-but-undispositioned subtree: the panel RUNS on the forced rail,
sees the undispositioned children (ids/statuses/hashes) in its evidence, and a
clean PASS lands as `accepted` while the process outcome stays
best_effort/children_unabsorbed."""
from ouroboros.outcomes import derive_loop_outcome
from ouroboros.tools.join_ledger import _child_result_sha256
from ouroboros.task_status import load_effective_task_result
_write_child(tmp_path)
panel = _acceptance_panel_result(
aggregate="PASS",
actors=[{
"slot_id": "s0", "signal": "PASS",
"parsed": {
"verdict": "PASS", "outcome_tier": "solved",
"criteria_used": [{
"criterion": "owner request", "status": "supported",
"evidence_refs": ["artifact:1"],
}],
},
}],
)
loop, registry, limit_ctx, trace, seen_evidence, panel_calls = (
_forced_absorption_acceptance_context(tmp_path, monkeypatch, panel)
)
result = loop._maybe_enforce_child_absorption_gate(
registry, limit_ctx, "", limit_ctx.messages, lambda _t: None, trace,
)
assert result is not None and result != "continue"
text, usage, returned_trace = result
assert usage["reason_code"] == "children_unabsorbed"
assert panel_calls["count"] == 1
debt = seen_evidence["undispositioned_children"]
assert [row["task_id"] for row in debt] == ["child1"]
assert debt[0]["status"] == "completed"
child = load_effective_task_result(tmp_path, "child1")
assert debt[0]["child_result_sha256"] == _child_result_sha256(child)
decision = returned_trace["acceptance_decision"]
assert decision["status"] == "accepted"
assert decision["reason"] == "clean_pass"
# The ctx stash is scoped to the forced run only.
assert registry._ctx._forced_undispositioned_children is None
outcome = derive_loop_outcome(text, usage, returned_trace)
assert outcome["outcome_axes"]["execution"]["status"] == "best_effort"
assert outcome["outcome_axes"]["execution"]["reason_code"] == "children_unabsorbed"
def test_forced_rail_terminalizes_a_requested_improvement_pass(tmp_path, monkeypatch):
"""The panel asks for a revision pass, but the forced rail can never take
another model round: the dangling `revision_requested` is downgraded to the
honest terminal `finalized_unaccepted` with a typed reason."""
import ouroboros.task_pacing as task_pacing
_write_child(tmp_path)
panel = _acceptance_panel_result(
aggregate="FAIL",
actors=[{
"slot_id": "s0", "signal": "FAIL",
"parsed": {
"verdict": "FAIL", "outcome_tier": "blocked_with_evidence",
"completion_coach": "fix it", "dialogue_status": "continue_actionable",
},
}],
findings=[{
"slot_id": "s0", "severity": "critical", "item": "broken",
"recommendation": "fix the header",
}],
)
loop, registry, limit_ctx, trace, _seen_evidence, panel_calls = (
_forced_absorption_acceptance_context(tmp_path, monkeypatch, panel)
)
monkeypatch.setattr(
task_pacing, "improvement_pass_allowed", lambda *_a, **_k: (True, ""),
)
result = loop._maybe_enforce_child_absorption_gate(
registry, limit_ctx, "", limit_ctx.messages, lambda _t: None, trace,
)
assert result is not None and result != "continue"
_text, usage, returned_trace = result
assert usage["reason_code"] == "children_unabsorbed"
assert panel_calls["count"] == 1
decision = returned_trace["acceptance_decision"]
assert decision["status"] == "finalized_unaccepted"
assert decision["reason"] == "revision_unavailable_on_forced_rail"
assert registry._ctx._task_acceptance_reviewed is True
def test_forced_rail_keeps_bypass_verdict_when_subtree_is_not_quiescent(
tmp_path, monkeypatch,
):
"""A still-RUNNING child means the panel structurally cannot bind stable
evidence (the voluntary path would WAIT, which the forced rail cannot):
the panel never runs and the typed acceptance-bypass verdict stamped by
the forced-finalization recorder stays as the terminal truth."""
_write_child(tmp_path, status="running")
panel = _acceptance_panel_result(aggregate="PASS", actors=[])
loop, registry, limit_ctx, trace, _seen_evidence, panel_calls = (
_forced_absorption_acceptance_context(tmp_path, monkeypatch, panel)
)
result = loop._maybe_enforce_child_absorption_gate(
registry, limit_ctx, "", limit_ctx.messages, lambda _t: None, trace,
)
assert result is not None and result != "continue"
_text, usage, returned_trace = result
assert usage["reason_code"] == "children_unabsorbed"
assert panel_calls["count"] == 0
decision = returned_trace["acceptance_decision"]
assert decision["status"] == "finalized_unaccepted"
assert decision["reason"] == "acceptance_bypassed_children_unabsorbed"
def test_orphan_label_keeps_cancelled_lifecycle_and_terminal_result(monkeypatch, tmp_path):
import ouroboros.loop as loop

View file

@ -299,8 +299,10 @@ def test_every_host_acceptance_writer_emits_a_canonical_status_and_typed_reason(
i for i, line in enumerate(src)
if "_set_acceptance_decision(" in line and not line.lstrip().startswith("def ")
]
# 17th writer: the forced-rail acceptance-bypass recorder (typed, closed-enum reason).
assert len(starts) == 17, f"writer inventory changed: {len(starts)} call sites"
# 17th writer: the forced-rail acceptance-bypass recorder (typed, closed-enum
# reason). 18th: the forced children_unabsorbed rail terminalizing a requested
# improvement pass it cannot grant (owner Q2A, revision_unavailable_on_forced_rail).
assert len(starts) == 18, f"writer inventory changed: {len(starts)} call sites"
allowed_status = {
"ACCEPTANCE_ACCEPTED", "ACCEPTANCE_REVISION_REQUESTED",
"ACCEPTANCE_FINALIZED_UNACCEPTED",

View file

@ -282,10 +282,7 @@ def test_a_delegating_nanny_and_a_native_child_are_not_nudged():
assert _run(undispatched, [], []) is False
def test_forced_finalization_carries_the_nanny_note_instead_of_relooping():
"""Forced finalization may not re-loop (that is its whole point), so the
substrate fact rides the one final prompt instead: a harness-dispatched child
that made zero delegate_start calls sees the note and can state why."""
def _forced_run(tmp_path, nanny, tool_calls):
import pathlib
from unittest.mock import patch
@ -294,29 +291,91 @@ def test_forced_finalization_carries_the_nanny_note_instead_of_relooping():
class _Ctx:
pass
class _Tools:
def __init__(self, nanny):
self._ctx = _Ctx()
self._ctx._nanny_route_dispatched = nanny
tools = SimpleNamespace(_ctx=_Ctx())
tools._ctx._nanny_route_dispatched = nanny
tools._ctx.drive_root = tmp_path
tools._ctx.task_id = "t"
messages: list = []
ctx = _RoundLimitContext(
messages=messages, llm=None, active_model="m", active_effort="low",
max_retries=0, drive_logs=pathlib.Path("."), task_id="t", round_idx=1,
event_queue=None, accumulated_usage={}, task_type="task",
active_use_local=False, max_rounds=1,
)
ctx.tools = tools
ctx.llm_trace = {"reasoning_notes": [], "tool_calls": tool_calls}
with patch("ouroboros.loop._call_forced_model_once", return_value="done"), \
patch("ouroboros.loop._finalize_forced_services"), \
patch("ouroboros.loop._forced_swarm_router_result", return_value=None), \
patch("ouroboros.loop._drain_forced_owner_directives", return_value=False):
_forced_final_answer(ctx, prompt="wrap up", fallback_text="fb",
reason_code="round_limit")
return "\n".join(m.get("content", "") for m in messages)
def run(nanny, tool_calls):
messages = []
ctx = _RoundLimitContext(
messages=messages, llm=None, active_model="m", active_effort="low",
max_retries=0, drive_logs=pathlib.Path("."), task_id="t", round_idx=1,
event_queue=None, accumulated_usage={}, task_type="task",
active_use_local=False, max_rounds=1,
)
ctx.tools = _Tools(nanny)
ctx.llm_trace = {"reasoning_notes": [], "tool_calls": tool_calls}
with patch("ouroboros.loop._call_forced_model_once", return_value="done"), \
patch("ouroboros.loop._finalize_forced_services"), \
patch("ouroboros.loop._forced_swarm_router_result", return_value=None), \
patch("ouroboros.loop._drain_forced_owner_directives", return_value=False):
_forced_final_answer(ctx, prompt="wrap up", fallback_text="fb",
reason_code="round_limit")
return "\n".join(m.get("content", "") for m in messages)
assert "delegated substrate" in run(True, [])
assert "delegated substrate" not in run(False, [])
assert "delegated substrate" not in run(True, [{"tool": "delegate_start"}])
def test_forced_finalization_carries_the_nanny_note_instead_of_relooping(tmp_path):
"""Forced finalization may not re-loop (that is its whole point), so the
substrate fact rides the one final prompt instead: a harness-dispatched child
that made zero delegate_start calls sees the note and can state why."""
assert "delegated substrate" in _forced_run(tmp_path, True, [])
assert "delegated substrate" not in _forced_run(tmp_path, False, [])
assert "delegated substrate" not in _forced_run(
tmp_path, True, [{"tool": "delegate_start"}],
)
def _emit_custody(tmp_path, kind, **payload):
from ouroboros import delegate_custody as dc
assert dc.emit(tmp_path, kind, {"task_id": "t", **payload})
def test_forced_note_consults_durable_custody_evidence(tmp_path):
"""The forced-path note is grounded in delegate_custody evidence on the
custody root, not just the current trace: succeeded runs silence the note,
unsettled runs get pending wording (no retry pressure), settled-without-
success gets truthful failure wording."""
from ouroboros import delegate_custody as dc
# A SUCCEEDED run from an earlier execution: no note, no nag.
_emit_custody(tmp_path, dc.STARTED, run_id="r1")
_emit_custody(tmp_path, dc.SETTLED, run_id="r1", state="succeeded")
out = _forced_run(tmp_path, True, [])
assert "delegated substrate" not in out and "NOTE:" not in out
# A started-but-unsettled run: pending wording, never "made no calls".
pending_root = tmp_path / "pending"
_emit_custody(pending_root, dc.STARTED, run_id="r2")
out = _forced_run(pending_root, True, [])
assert "not settled yet" in out and "made no delegate_start" not in out
# A run that settled WITHOUT success (crashed in an earlier execution):
# truthful failure wording instead of the false "made no calls" accusation.
failed_root = tmp_path / "failed"
_emit_custody(failed_root, dc.STARTED, run_id="r3")
_emit_custody(failed_root, dc.SETTLED, run_id="r3", state="failed")
out = _forced_run(failed_root, True, [])
assert "settled WITHOUT success" in out and "made no delegate_start" not in out
def test_forced_note_never_accuses_over_unreadable_evidence(tmp_path):
"""An unreadable custody log must not be misread as 'zero runs': no note."""
import os
import platform
import pytest
from ouroboros import delegate_custody as dc
if platform.system() == "Windows":
pytest.skip("chmod-based permission test not portable to Windows")
log_path = dc.event_log_path(tmp_path)
log_path.parent.mkdir(parents=True, exist_ok=True)
log_path.write_text("", encoding="utf-8")
os.chmod(log_path, 0)
if os.geteuid() == 0: # pragma: no cover — only hit in root CI
pytest.skip("root user bypasses 0o000 chmod, cannot trigger OSError")
try:
assert "NOTE:" not in _forced_run(tmp_path, True, [])
finally:
os.chmod(log_path, 0o644)

View file

@ -537,6 +537,112 @@ def test_a_spared_task_still_gets_its_whole_grace_window(monkeypatch, tmp_path):
assert len(_live_finalize_controls(tmp_path, orch)) == 1
def test_child_settlement_stamps_parent_activity_and_withdraws_grace(
monkeypatch, tmp_path,
):
"""Q5 (slime saga): a coordinator waiting on children was idle-killed 120s
after its last child DELIVERED its result delivery did not count as parent
activity, so the parent died exactly when integration should start. The
child's terminal dispatch now stamps the PARENT's own progress, so a parent
inside a finalization-grace episode is spared and the episode is withdrawn
whole by the EXISTING spare machinery (own progress answers the request)."""
from supervisor import events as events_mod
from supervisor import queue as queue_mod
orch, child = "orch3", "child3"
orch_meta = {
"task": {"id": orch, "chat_id": 7},
"started_at": 1000.0, "last_progress_at": 1000.0, "worker_id": 0,
}
child_task = {
"id": child, "chat_id": 7, "parent_task_id": orch, "root_task_id": orch,
"delegation_role": "subagent",
}
child_meta = {
"task": child_task,
"started_at": 1000.0, "last_progress_at": 1000.0, "worker_id": 1,
}
running = {orch: orch_meta, child: child_meta}
tick = _enforce_harness(monkeypatch, tmp_path, running, grace=120)
tick(2000.0) # both idle: the orchestrator's grace episode opens
assert orch_meta["finalization_requested_at"] == 2000.0
assert len(_live_finalize_controls(tmp_path, orch)) == 1
tick(2050.0) # inside the grace window; also moves the clock the stamp reads
# The child's terminal result is DELIVERED — the settled task_done dispatch.
ctx = types.SimpleNamespace(
DRIVE_ROOT=tmp_path, RUNNING=running, PENDING=[], WORKERS={},
send_with_budget=lambda _cid, _text, **_k: None,
append_jsonl=lambda *_a, **_k: None,
persist_queue_snapshot=lambda **_k: True,
bridge=types.SimpleNamespace(push_log=lambda _e: None),
)
events_mod._finish_task_done_dispatch(
{}, ctx, task_id=child, worker_id=1, task=child_task,
final_task_result={}, task_done_event={"type": "task_done", "task_id": child},
)
assert child not in running
assert orch_meta["last_progress_at"] == 2050.0, "settlement did not stamp the parent"
tick(2055.0) # own progress: the episode is withdrawn whole, parent spared
assert orch in queue_mod.RUNNING
assert "finalization_requested_at" not in orch_meta
assert _live_finalize_controls(tmp_path, orch) == []
tick(3000.0) # the stamp is one-shot: a genuinely idle parent still reaches
assert orch_meta.get("finalization_requested_at") == 3000.0 # a fresh episode
def test_provider_outage_root_terminal_notifies_owner_chat(tmp_path):
"""Q7 (slime saga): a root task terminalized by a provider outage must tell
the owner immediately that it was NOT completed the historical shape was
95 minutes of silence behind a result claiming "completed (best effort)"."""
from supervisor import events as events_mod
sent = []
def _ctx(running):
return types.SimpleNamespace(
DRIVE_ROOT=tmp_path, RUNNING=running, PENDING=[], WORKERS={},
send_with_budget=lambda cid, text, **_k: sent.append((cid, str(text))),
append_jsonl=lambda *_a, **_k: None,
persist_queue_snapshot=lambda **_k: True,
bridge=types.SimpleNamespace(push_log=lambda _e: None),
)
root_task = {"id": "root9", "chat_id": 7}
events_mod._finish_task_done_dispatch(
{}, _ctx({"root9": {"task": root_task, "worker_id": 0}}),
task_id="root9", worker_id=0, task=root_task, final_task_result={},
task_done_event={
"type": "task_done", "task_id": "root9", "chat_id": 7,
"status": "failed", "reason_code": "provider_unavailable",
},
)
outage_lines = [t for _c, t in sent if "provider outage" in t]
assert outage_lines and "NOT completed" in outage_lines[0]
# A CHILD's provider death keeps the ordinary subagent toast only — the
# parent absorbs child failures; no second owner ping per child.
sent.clear()
child_task = {
"id": "kid9", "chat_id": 7, "parent_task_id": "root9",
"root_task_id": "root9", "delegation_role": "subagent",
}
events_mod._finish_task_done_dispatch(
{"status": "failed"}, _ctx({"kid9": {"task": child_task, "worker_id": 1}}),
task_id="kid9", worker_id=1, task=child_task, final_task_result={},
task_done_event={
"type": "task_done", "task_id": "kid9", "chat_id": 7,
"status": "failed", "reason_code": "provider_unavailable",
},
)
assert not [t for _c, t in sent if "provider outage" in t]
assert [t for _c, t in sent if "Subagent kid9 failed" in t]
def test_every_host_authored_progress_frame_declares_itself():
"""The gate only works if host emitters declare themselves, so make that
structural rather than a habit: any supervisor-side event-bus frame that

View file

@ -0,0 +1,187 @@
"""Regression tests for the provider-death owner-notification single-shot gate.
Slime-saga TASK 3 settled a disputed claim by test: the old ``and task`` gate in
``_finish_task_done_dispatch`` claimed reaper-only delivery still notifies, but
the reaper LOOP pops RUNNING BEFORE ``reap_timed_out_task`` emits its task_done
(that function's docstring: "The loop already popped RUNNING/cleared
busy_task_id"), so a reaper-delivered provider-death terminal arrived with
``task={}`` and the notification was silently swallowed the external
reviewer's claim was CORRECT. The fix keys single-shot on the process-local
``_PROVIDER_DEATH_NOTIFIED`` registry: a duplicate ``already_done`` terminal
stays silent, a reaper-delivered terminal fires.
"""
from __future__ import annotations
import types
import pytest
from supervisor import events as events_mod
@pytest.fixture()
def sent_and_ctx(tmp_path, monkeypatch):
monkeypatch.setattr(events_mod, "_PROVIDER_DEATH_NOTIFIED", set())
sent: list[tuple[int, str]] = []
def make_ctx(running):
return types.SimpleNamespace(
DRIVE_ROOT=tmp_path, RUNNING=running, PENDING=[], WORKERS={},
send_with_budget=lambda cid, text, **_k: sent.append((cid, str(text))),
append_jsonl=lambda *_a, **_k: None,
persist_queue_snapshot=lambda **_k: True,
bridge=types.SimpleNamespace(push_log=lambda _e: None),
)
return sent, make_ctx
def _provider_death_event(task_id: str) -> dict:
return {
"type": "task_done", "task_id": task_id, "chat_id": 7,
"status": "failed", "reason_code": "provider_unavailable",
}
def _outage_lines(sent):
return [text for _cid, text in sent if "provider outage" in text]
def test_duplicate_already_done_after_normal_delivery_notifies_exactly_once(
sent_and_ctx,
):
"""Path (a): the worker delivered its own task_done (RUNNING row present,
notification fires and the dispatch releases the row), died, and the crash
detector emitted a second already_done terminal whose dispatch sees
task={} the owner is notified exactly once."""
sent, make_ctx = sent_and_ctx
root_task = {"id": "rootA", "chat_id": 7}
running = {"rootA": {"task": root_task, "worker_id": 0}}
events_mod._finish_task_done_dispatch(
{}, make_ctx(running),
task_id="rootA", worker_id=0, task=root_task, final_task_result={},
task_done_event=_provider_death_event("rootA"),
)
assert len(_outage_lines(sent)) == 1
assert "rootA" not in running
# The duplicate already_done terminal: RUNNING no longer holds the row.
events_mod._finish_task_done_dispatch(
{}, make_ctx({}),
task_id="rootA", worker_id=0, task={}, final_task_result={},
task_done_event=_provider_death_event("rootA"),
)
assert len(_outage_lines(sent)) == 1, "duplicate terminal must stay silent"
def test_reaper_delivered_terminal_with_popped_running_row_still_notifies(
sent_and_ctx,
):
"""Path (b), the proven bug: the reaper loop pops RUNNING before the reap
job's task_done dispatches, so the FIRST and only delivery arrives with
task={} the old `and task` gate swallowed the notification entirely.
Also pins the wording: the resume endpoint only serves budget-paused
PENDING tasks, so the message promises re-run and never resume."""
sent, make_ctx = sent_and_ctx
events_mod._finish_task_done_dispatch(
{}, make_ctx({}),
task_id="rootB", worker_id=0, task={}, final_task_result={},
task_done_event=_provider_death_event("rootB"),
)
lines = _outage_lines(sent)
assert lines, "reaper-delivered provider-death terminal must notify the owner"
assert "NOT completed" in lines[0]
assert "re-run" in lines[0]
assert "resume" not in lines[0]
def test_raising_send_keeps_cleanup_and_allows_a_later_retry(sent_and_ctx):
"""A raising ``send_with_budget`` must not abort the task-done bookkeeping,
and the id must NOT enter the single-shot registry (a later dispatch may
retry the notification); the success path stays single-shot."""
sent, make_ctx = sent_and_ctx
root_task = {"id": "rootF", "chat_id": 7}
running = {"rootF": {"task": root_task, "worker_id": 0}}
ctx = make_ctx(running)
def _boom(_cid, _text, **_k):
raise RuntimeError("chat transport down")
ctx.send_with_budget = _boom
events_mod._finish_task_done_dispatch(
{}, ctx,
task_id="rootF", worker_id=0, task=root_task, final_task_result={},
task_done_event=_provider_death_event("rootF"),
)
assert "rootF" not in running, "cleanup must run despite the failed send"
assert "rootF" not in events_mod._PROVIDER_DEATH_NOTIFIED
# A later dispatch (e.g. the duplicate already_done terminal) retries and
# registers the id only now, on the successful send.
events_mod._finish_task_done_dispatch(
{}, make_ctx({}),
task_id="rootF", worker_id=0, task={}, final_task_result={},
task_done_event=_provider_death_event("rootF"),
)
assert len(_outage_lines(sent)) == 1
assert "rootF" in events_mod._PROVIDER_DEATH_NOTIFIED
def test_reaper_delivered_child_terminal_stamps_parent_activity(sent_and_ctx):
"""The parent activity stamp must land even when ``task`` is {} (the
reaper-delivered popped-RUNNING shape): ``parent_task_id`` falls back to
the durable ``final_task_result``, same as the notification gate."""
_sent, make_ctx = sent_and_ctx
parent_meta = {"task": {"id": "rootG"}, "worker_id": 0}
running = {"rootG": parent_meta}
events_mod._finish_task_done_dispatch(
{}, make_ctx(running),
task_id="kidG", worker_id=1, task={},
final_task_result={"parent_task_id": "rootG", "delegation_role": "subagent"},
task_done_event=_provider_death_event("kidG"),
)
assert "last_progress_at" in parent_meta, (
"a reaper-delivered child terminal must count as the parent's progress"
)
def test_ephemeral_decision_turn_gets_no_duplicate_outage_ping(sent_and_ctx):
"""An ephemeral direct-chat decision turn already shows its failure inline;
the provider-outage owner ping must stay silent and leave the registry
untouched."""
sent, make_ctx = sent_and_ctx
root_task = {"id": "rootH", "chat_id": 7}
events_mod._finish_task_done_dispatch(
{"_ephemeral": True}, make_ctx({"rootH": {"task": root_task, "worker_id": 0}}),
task_id="rootH", worker_id=0, task=root_task, final_task_result={},
task_done_event=_provider_death_event("rootH"),
)
assert not _outage_lines(sent)
assert "rootH" not in events_mod._PROVIDER_DEATH_NOTIFIED
def test_subagent_provider_death_never_pings_the_owner(sent_and_ctx):
"""A child's provider death keeps the ordinary subagent toast only — the
parent absorbs child failures; the registry gate must not change that."""
sent, make_ctx = sent_and_ctx
child_task = {
"id": "kidE", "chat_id": 7, "parent_task_id": "rootE",
"root_task_id": "rootE", "delegation_role": "subagent",
}
events_mod._finish_task_done_dispatch(
{"status": "failed"}, make_ctx({"kidE": {"task": child_task, "worker_id": 1}}),
task_id="kidE", worker_id=1, task=child_task, final_task_result={},
task_done_event=_provider_death_event("kidE"),
)
assert not _outage_lines(sent)
assert [text for _cid, text in sent if "Subagent kidE failed" in text]

View file

@ -80,27 +80,37 @@ def test_create_with_retries_reroutes_once_on_transient_body_error(tmp_path, mon
assert "allow_fallbacks" not in calls[1].get("extra_body", {}).get("provider", {})
# --- WA2: provider-death -> best-effort shelf / salvage -----------------------
def test_provider_unavailable_salvages_then_best_effort(monkeypatch):
# --- WA2 (amended by the slime-saga honesty fix): provider-death -> salvage the
# text, but terminalize as an INFRA FAILURE, never a completion ------------------
def test_provider_unavailable_salvages_then_terminalizes_as_infra_failure(monkeypatch):
import ouroboros.loop as loop
from ouroboros.outcomes import derive_loop_outcome
ctx = SimpleNamespace(
messages=[{"role": "user", "content": "do"}, {"role": "assistant", "content": "partial A"}],
llm=None, active_model="m", active_effort="medium", max_retries=1,
drive_logs=pathlib.Path("/tmp"), task_id="t", round_idx=1, event_queue=None,
accumulated_usage={}, task_type="", active_use_local=False, max_rounds=10, deadline_ts=None,
)
# provider stays dead -> final call yields nothing -> salvage last assistant text (NOT best_effort)
# provider stays dead -> final call yields nothing -> salvage last assistant text
monkeypatch.setattr(loop, "call_llm_with_retry", lambda *a, **k: (None, 0.0))
text, usage, _ = loop._handle_provider_unavailable(ctx)
assert text == "partial A"
assert usage.get("reason_code") == "provider_unavailable"
assert usage.get("execution_status") == "infra_failed"
assert not usage.get("_best_effort_extracted")
# provider recovers (reroute) -> fresh final answer -> best_effort
# provider recovers (reroute) -> the fresh final answer still rides the result
# BODY, but the task remains an interruption: infra_failed execution (terminal
# status failed), never the old "completed (best effort)" promotion.
ctx.accumulated_usage = {}
monkeypatch.setattr(loop, "call_llm_with_retry", lambda *a, **k: ({"content": "FINAL"}, 0.01))
text2, usage2, _ = loop._handle_provider_unavailable(ctx)
assert text2 == "FINAL"
assert usage2.get("_best_effort_extracted") is True
assert usage2.get("execution_status") == "infra_failed"
outcome = derive_loop_outcome(text2, usage2, {"tool_calls": [], "reasoning_notes": []})
execution = outcome["outcome_axes"]["execution"]
assert execution["status"] == "infra_failed"
assert execution["reason_code"] == "provider_unavailable"
# --- WA3: reviewer-slot SSOT / adaptive quorum --------------------------------
@ -356,13 +366,16 @@ def test_provider_unavailable_no_salvage_path_does_not_raise(monkeypatch):
assert isinstance(text, str)
def test_provider_unavailable_recovered_answer_lifts_to_best_effort():
"""claudexor confirm-round finding (WA2): a genuinely-extracted final answer on
the provider-death path (reason_code provider_unavailable + _best_effort_extracted)
must reduce to best_effort, not a flat failure provider_unavailable must be in
the best-effort reason-code allowlist."""
def test_provider_unavailable_is_never_a_best_effort_completion():
"""Slime-saga honesty fix (reverses the WA2 confirm-round decision): a task
killed by provider unavailability with an unmet objective must terminalize as
an infra failure, never be promoted to "completed (best effort)" so
provider_unavailable must NOT be in the best-effort reason-code allowlist,
while benchmark truncation disclosure still covers it explicitly."""
from ouroboros.outcomes import BEST_EFFORT_REASON_CODES
assert "provider_unavailable" in BEST_EFFORT_REASON_CODES
from devtools.benchmarks.common.result_index import RUNTIME_TRUNCATION_REASON_CODES
assert "provider_unavailable" not in BEST_EFFORT_REASON_CODES
assert "provider_unavailable" in RUNTIME_TRUNCATION_REASON_CODES
def test_auto_acceptance_capsule_wrapped_review_is_still_ingested():

View file

@ -713,4 +713,4 @@
* @property {?boolean} check_ok
*/
export const GATEWAY_CONTRACT_VERSION = '6.94.0';
export const GATEWAY_CONTRACT_VERSION = '6.95.0';

View file

@ -1,6 +1,6 @@
{
"name": "ouroboros-web",
"version": "6.94.0",
"version": "6.95.0",
"private": true,
"type": "module",
"description": "Ouroboros browser UI package boundary",