feat(orchestration): honest runtime self-knowledge, activity-based timeouts, off-loop reaping, task-tree coordination

Make long-horizon multi-agent orchestration honest, non-blocking, observable, and
timeout-resilient — the agent reasons from live runtime state, not half-remembered rules.

Runtime honesty (1.1): every turn's context carries a `capabilities` digest
(allow_mutative_subagents from the live MASTER gate — light blocks only self-repo/
control-plane, NOT user deliverables or acting children) and a live `queue` digest
(running/pending/free worker slots), both derived from the same getters the runtime
enforces. Reword the misleading control.py "turned off in this runtime mode" error.

Activity-based timeouts (1.2): replace the flat blanket wall-clock kill (HARD_TIMEOUT_SEC)
with an ACTIVITY model — a task is stopped only when it makes no REAL progress
(llm_usage/progress, not the 30s liveness heartbeat) AND has no progressing/queued subtree
for OUROBOROS_TASK_IDLE_TIMEOUT_SEC (floored to the per-call ceiling). The only HARD axes
are an explicit deadline_at, OUROBOROS_TASK_ABS_CEILING_SEC (6h), and budget. An
orchestrator with live children is never blind-retried (descendant detection survives a
missing intermediate parent via root_task_id).

Off-loop reaping — Variant A (1.3): the heavy worker teardown (kill/join/archive/respawn)
moves to a single-owner background reaper (supervisor/task_reaper.py) off the
supervisor-loop critical path; the slot is marked `reaping` under _queue_lock (assign +
crash-detector skip it; restart-on-death) so a burst of timeouts no longer wedges the loop.

Task-tree coordination (1.4): a domain-agnostic task-tree ledger
(data/task_trees/<root>/blackboard.jsonl) is the swarm blackboard + typed child->parent
beacons via the tree_note/tree_read tools (contract/decision/fact/note +
milestone/partial_finding/blocker/question), injected into context each turn; a
blocker/question beacon early-returns a parent's sliced wait. The "shared frame before
interdependent fan-out" doctrine lands in SYSTEM.md.

A reaped worker that self-finalized a workspace child also completes artifact finalization
(a single SSOT task_is_readonly_subagent gate shared by the task_done path and the reaper)
so artifact_status can't strand at 'finalizing'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ouroboros 2026-06-19 07:02:22 +03:00
parent c8b0ffb913
commit b662500030
25 changed files with 1570 additions and 232 deletions

3
.gitignore vendored
View file

@ -70,3 +70,6 @@ MagicMock/
# Local adversarial review evidence packets (not shipped)
.adversarial-review/
# Claudexor external-review tooling run artifacts (operator-side, never tracked)
.claudexor/

View file

@ -24,7 +24,8 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
│ ├── message_bus.py ← Queue-based local message bus (Web UI + reviewed transport skills)
│ ├── workers.py ← Multiprocessing worker pool (fork/spawn by platform)
│ ├── state.py ← Persistent state (state.json) with file locking
│ ├── queue.py ← Task queue management (PENDING/RUNNING lists)
│ ├── queue.py ← Task queue management (PENDING/RUNNING lists) + activity-based timeout enforcement
│ ├── task_reaper.py ← (v6.38.0) Variant A off-loop worker reaper (extracted from queue.py): kill/join/archive/respawn a timed-out worker on a single-owner background thread, off the loop critical path
│ ├── schedule_time.py ← Cron/timezone schedule time parsing helpers
│ ├── evolution_lifecycle.py ← Evolution campaign state + transaction lifecycle (moved from queue.py in v6.30.0): campaign file IO, start/pause, begin/update transaction, cycle-outcome recording, deterministic no_op/abandoned worktree cleanup, owner cycle reports, supervisor auto-restart request
│ ├── events.py ← Event dispatcher (worker→supervisor events)
@ -60,6 +61,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
├── consolidator.py ← Block-wise dialogue consolidation (dialogue_blocks.json)
├── memory.py ← Scratchpad, identity, chat history
├── project_facts.py ← Thin per-project facts store (Phase 3b): project_id resolution (explicit `--project-id` or stable workspace-path hash) + a per-project knowledge dir under the canonical data dir (`projects/<id>/knowledge`), isolated from `memory/knowledge` and from the forked seed; v6.32.0 adds per-project journal/workpad path helpers
├── task_tree_ledger.py ← (v6.38.0) Task-tree coordination ledger keyed by `root_task_id` — the domain-agnostic swarm blackboard + typed child→parent beacons. Append-only `data/task_trees/<root>/blackboard.jsonl` (size-capped, validated, GC-eligible with the tree); kinds: contract/decision/fact/note (coordination) + milestone/partial_finding/blocker/question (beacon). EPHEMERAL swarm coordination — distinct from the DURABLE project journal. Exposed via the `tree_note`/`tree_read` tools (`ouroboros/tools/task_tree.py`); the tail is injected into context each turn; a blocker/question beacon early-returns a parent's sliced `wait`; aged out by `headless.prune_task_trees` once the root task is terminal
├── projects_registry.py ← Multi-project registry (v6.32.0; v6.33.0 removed the status/sleep/wake lifecycle): durable `data/state/projects.json` (id/name/chat_id/folder/last_active_at), create/update, boot reconcile (never age-prunes), recency-sorted, `registered_project_chat_ids` membership SSOT, `ensure_project_workspace`
├── project_lease.py ← One-writer-per-project lease (v6.32.0): `assign_tasks` serializes top-level tasks of the same STORED `project_id`; same-project subagent swarm exempt; `project_id==""` is no lane
├── context.py ← LLM context builder (public API for consciousness)
@ -181,6 +183,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
│ ├── skill_publish.py ← Agent-callable `submit_skill_to_hub` tool: validates a fresh no-blocker review — `clean` or advisory-only `warnings` (v6.27.1; advisory findings are disclosed in the PR body under `## Known advisory findings`; blockers/pending/stale still refuse) — for a local skill (sources `external`/`self_authored`/`user_repo`/`ouroboroshub`/`clawhub`; `native` only when no `.seed-origin` marker), infers OuroborosHub from `OUROBOROS_HUB_CATALOG_URL`, commits payload + catalog update to the user's fork via GitHub GraphQL, and opens a PR without mutating the local Ouroboros repo. For marketplace-managed sources the generated PR body is force-prefixed with a `## Provenance` block read from the local sidecar (`.ouroboroshub.json` slug / `.clawhub.json` clawhub_slug); when no sidecar exists the source is reclassified as `external` by skill_loader and submit proceeds without the block.
│ ├── skill_preflight.py ← v5.7.0 heal-safe, read-only skill payload preflight validator (manifest parse + Python compile() / node --check / bash -n; no review-state mutation)
│ ├── 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 in ouroboros/task_tree_ledger.py)
│ └── 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. Also compare_subagent_patches: read-only best-of-N helper that shows several children's candidate patches side by side for LLM-first synthesis
└── platform_layer.py ← Cross-platform process/path/locking helpers
@ -303,7 +306,9 @@ data drive. Workspace mode uses an explicit allowlist for contextual repo/data,
search, shell, git status/diff, browser, log/history, planning, and parent-owned
delegation tools. Workspace children run as local-readonly subagents: local
writes, commits, review mutation, runtime control, tool expansion, shell, and
skill lifecycle stay blocked. Nested readonly delegation is allowed only within
skill lifecycle stay blocked — except bounded task-tree coordination via
`tree_note`/`tree_read` (the one permitted local-write path: swarm beacons and
shared-frame reads, which is coordination, not state mutation). Nested readonly delegation is allowed only within
configured depth/cap limits, and descendants deeper than the first child level
are forced onto the light model lane. Enabled/reviewed extension and
MCP tools remain callable by owner policy, subject to `task_contract`
@ -433,7 +438,9 @@ Live subagents default to deterministic
visible first-party tool schemas to repo/data/history reads plus web/browser
inspection and also blocks forbidden first-party calls at execute time,
including local writes, commits, review mutation, runtime control, tool
expansion, skills lifecycle, and shell. Nested readonly `schedule_subagent`
expansion, skills lifecycle, and shell — except bounded task-tree coordination
via `tree_note`/`tree_read` (the one permitted local-write path: swarm beacons
and shared-frame reads, not state mutation). Nested readonly `schedule_subagent`
recursion is visible only within configured depth/cap limits, and depth > 1 is
coerced to the light lane.
@ -568,6 +575,7 @@ finalization states.
│ │ │ └── <artifact files> ← Canonical task artifacts, including workspace patches, verification ledgers, and copied external deliverables
│ │ └── artifact_versions/<task_id>/ ← Non-manifest recovery history for overwritten user-visible deliverables (last 5 versions per artifact name)
│ ├── task_drives/<task_id>/ ← Task-scoped scratch for direct tasks and light-mode run_script defaults; startup prunes terminal tasks after the headless retention window
│ ├── task_trees/<root_task_id>/blackboard.jsonl ← (v6.38.0) Task-tree coordination ledger: append-only swarm blackboard + child→parent beacons (tree_note/tree_read), scoped to the whole tree; EPHEMERAL coordination (distinct from the durable project journal)
│ ├── state/
│ │ ├── state.json ← Runtime state (spent_usd, session_id, branch, etc.)
│ │ ├── server_port ← Active HTTP port used by the launcher/browser handoff
@ -991,7 +999,7 @@ Each iteration (0.5s sleep):
1. `rotate_chat_log_if_needed()` — archive chat.jsonl if > 800KB
2. `ensure_workers_healthy()` — respawn dead workers, detect crash storms
3. Drain event queue (worker→supervisor events via multiprocessing.Queue)
4. `enforce_task_timeouts()`soft/hard timeout handling
4. `enforce_task_timeouts()`activity-based stop (v6.38.0): a task is stopped only when it makes no REAL progress (`llm_usage`/progress events, NOT the 30s liveness heartbeat) AND has no progressing/queued subtree, beyond `OUROBOROS_TASK_IDLE_TIMEOUT_SEC` (floored to the per-call ceiling); the only HARD axes are an explicit `deadline_at`, `OUROBOROS_TASK_ABS_CEILING_SEC`, and budget. The heavy teardown (kill/join/archive/respawn) is handed OFF the loop to a single-owner background reaper (`supervisor/task_reaper.py`); the slot is marked `reaping` under `_queue_lock` (assign + crash-detector skip it) and the terminal write + retry happen only AFTER the kill, so a still-alive worker never races a concurrently-assigned retry
5. Periodic custody reap (every 600s) and periodic zombie reconcile (every 300s,
`server.py::_periodic_zombie_reconcile`): heals `review_job.json` files and
`task_results/<id>.json` records stuck at `running` after a worker died
@ -1482,7 +1490,9 @@ Runtime floors:
| OUROBOROS_EFFORT_CONSCIOUSNESS | high | Reasoning effort for background consciousness |
| OUROBOROS_RETURN_REASONING | true | OpenRouter reasoning continuity switch. Unset means return reasoning payloads by default; false-like values or an explicit empty string opt out. Direct/local routes strip OpenRouter-only reasoning fields on copied payloads. |
| OUROBOROS_SOFT_TIMEOUT_SEC | 600 | Soft timeout warning (10 min) |
| OUROBOROS_HARD_TIMEOUT_SEC | 1800 | Hard timeout kill (30 min) |
| OUROBOROS_HARD_TIMEOUT_SEC | 1800 | (v6.38.0) NO LONGER a kill axis. The flat blanket wall-clock kill was replaced by the activity model below; this value now only feeds the soft-warning/status display. Task termination is governed by the idle/ceiling/deadline axes. |
| 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_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). |
| LOCAL_MODEL_SOURCE | "" | HuggingFace repo for local model |

View file

@ -525,7 +525,9 @@ Before every commit, verify the following:
- `task_constraint` boolean parsing must be strict; strings such as `"false"`
are false, never truthy through Python's `bool("false")`.
- Subagent changes must keep writes, commits, review mutation, runtime control,
tool expansion, skills lifecycle, and shell blocked. Nested readonly
tool expansion, skills lifecycle, and shell blocked — except bounded task-tree
coordination via `tree_note`/`tree_read` (the one permitted local-write path for
swarm beacons/shared-frame reads, not state mutation). Nested readonly
`schedule_subagent` recursion is allowed only within configured depth/cap
limits; descendants deeper than the first child level are coerced to the light
lane. Enabled/reviewed extension tools and enabled MCP tools may remain

View file

@ -121,7 +121,16 @@ SETTINGS_DEFAULTS = {
# Skill lifecycle lane deadline (wedged-job loud-failure bound).
"OUROBOROS_SKILL_LIFECYCLE_TIMEOUT_SEC": 1800,
"OUROBOROS_SOFT_TIMEOUT_SEC": 600,
# NOTE: OUROBOROS_HARD_TIMEOUT_SEC no longer terminates tasks — the flat wall-clock
# kill was replaced by the activity model below (idle + subtree-liveness, abs ceiling).
# It survives only as a soft-warning/status display input; runtime is governed by
# OUROBOROS_TASK_IDLE_TIMEOUT_SEC and OUROBOROS_TASK_ABS_CEILING_SEC.
"OUROBOROS_HARD_TIMEOUT_SEC": 1800,
# Activity-based liveness (replaces flat wall-clock as the primary stop):
# idle window = no real progress AND no progressing subtree; abs ceiling = the
# unconditional per-task backstop (budget/cost stays a separate hard axis).
"OUROBOROS_TASK_IDLE_TIMEOUT_SEC": 900,
"OUROBOROS_TASK_ABS_CEILING_SEC": 21600,
"OUROBOROS_PER_CALL_TIMEOUT_CEILING_SEC": 1800,
"OUROBOROS_FINALIZATION_GRACE_SEC": FINALIZATION_GRACE_DEFAULT_SEC,
"OUROBOROS_SUPERVISOR_LIVENESS_DEADLINE_SEC": SUPERVISOR_LIVENESS_DEADLINE_DEFAULT_SEC,
@ -456,6 +465,32 @@ def get_max_workers() -> int:
return max(1, parsed)
def get_task_idle_timeout_sec() -> int:
"""Idle window before a task is eligible for an activity-based stop: it has made
no REAL progress (its own last_progress_at) AND has no progressing subtree for
this long. The periodic 30s process heartbeat is liveness, NOT progress."""
raw = os.environ.get(
"OUROBOROS_TASK_IDLE_TIMEOUT_SEC", SETTINGS_DEFAULTS["OUROBOROS_TASK_IDLE_TIMEOUT_SEC"]
)
try:
return max(60, int(raw))
except (TypeError, ValueError):
return int(SETTINGS_DEFAULTS["OUROBOROS_TASK_IDLE_TIMEOUT_SEC"])
def get_task_abs_ceiling_sec() -> int:
"""Absolute wall-clock backstop per task, independent of activity — the only hard
time axis (budget/cost is the other, separate hard axis). A productively-waiting
orchestrator survives to this ceiling instead of a flat 1800s wall-clock kill."""
raw = os.environ.get(
"OUROBOROS_TASK_ABS_CEILING_SEC", SETTINGS_DEFAULTS["OUROBOROS_TASK_ABS_CEILING_SEC"]
)
try:
return max(300, int(raw))
except (TypeError, ValueError):
return int(SETTINGS_DEFAULTS["OUROBOROS_TASK_ABS_CEILING_SEC"])
def get_per_call_timeout_ceiling_sec() -> int:
"""SSOT ceiling for an explicit per-call run_command/run_script timeout_sec
(and the outer tool-execution cap that accommodates it)."""
@ -1123,6 +1158,7 @@ def apply_settings_to_env(settings: dict) -> None:
"OUROBOROS_PLAN_TASK_SWARM_HEARTBEAT_STALE_SEC",
"TOTAL_BUDGET", "OUROBOROS_PER_TASK_COST_USD", "GITHUB_TOKEN", "GITHUB_REPO",
"OUROBOROS_TOOL_TIMEOUT_SEC", "OUROBOROS_PER_CALL_TIMEOUT_CEILING_SEC", "OUROBOROS_FINALIZATION_GRACE_SEC",
"OUROBOROS_TASK_IDLE_TIMEOUT_SEC", "OUROBOROS_TASK_ABS_CEILING_SEC",
"OUROBOROS_PACING_INTERVAL_SEC", "OUROBOROS_SUPERVISOR_LIVENESS_DEADLINE_SEC",
"OUROBOROS_MAX_ROUNDS", "OUROBOROS_TRANSIENT_RETRY_MAX",
"OUROBOROS_BG_MAX_ROUNDS", "OUROBOROS_BG_WAKEUP_MIN", "OUROBOROS_BG_WAKEUP_MAX",

View file

@ -232,6 +232,68 @@ def build_runtime_section(env: Any, task: Dict[str, Any]) -> str:
"skill_payload only for explicit scoped skill-payload work/repair, not generic "
"artifact transport; do not use runtime_data/uploads as artifact transport"
)
# Capability SSOT (honesty): surface the SAME live gate the runtime enforces so
# the agent reasons FORWARD from real state instead of backward from a half-remembered
# rule. Structural facts only — the model still chooses by judgment (BIBLE P5); this is
# not a string gate, it is the truth the gate is derived from.
try:
from ouroboros.config import get_allow_mutative_subagents
from ouroboros.contracts.task_constraint import VALID_WRITE_SURFACES
runtime_data["capabilities"] = {
"allow_mutative_subagents": bool(get_allow_mutative_subagents()),
"write_surfaces": sorted(VALID_WRITE_SURFACES),
"note": (
"allow_mutative_subagents is the MASTER gate (the owner toggle overrides the "
"runtime-mode default; runtime mode only sets the default when the toggle is "
"empty). light blocks ONLY Ouroboros self-repo/control-plane mutation "
"(write_surface=self_worktree), NOT user/task/project deliverables: acting "
"subagents with write_surface=external_workspace or genesis remain valid in "
"light. Read THIS value before declaring you cannot spawn acting subagents."
),
}
except Exception:
log.debug("Failed to build capability digest for context", exc_info=True)
# Live worker/queue load (honesty): derive resource facts from the real snapshot,
# never guess "starved"/"saturated".
try:
from ouroboros.config import DATA_DIR, get_max_active_subagents_per_root, get_max_workers
from ouroboros.task_status import _load_queue_snapshot
# The supervisor persists the snapshot at the canonical data root, NOT a forked
# child drive — so read it from budget_drive_root (the main root for a subagent)
# or DATA_DIR. Reading env.drive_root would leave subagents (the actors most likely
# to mis-reason about "starved" siblings) with no live-queue honesty signal.
_snap_root = str(task.get("budget_drive_root") or "").strip() or str(DATA_DIR)
_snap = _load_queue_snapshot(_snap_root)
if not (_snap.get("_snapshot_missing") or _snap.get("_snapshot_invalid")):
_running = [r for r in (_snap.get("running") or []) if isinstance(r, dict)]
_pending = [r for r in (_snap.get("pending") or []) if isinstance(r, dict)]
_maxw = int(get_max_workers())
_reaping = int(_snap.get("reaping_count") or 0)
# Prefer the ACTUAL assignable-idle worker count persisted from the live pool
# (the real pool can be smaller than the configured max, and a mid-reap slot is
# unavailable); fall back to a derived estimate for older snapshots.
_assignable = _snap.get("assignable_idle_workers")
if _assignable is not None:
_free = max(0, int(_assignable))
else:
_free = max(0, _maxw - len(_running) - _reaping)
runtime_data["queue"] = {
"running_count": len(_running),
"pending_count": len(_pending),
"reaping_count": _reaping,
"max_workers": _maxw,
"worker_total": int(_snap.get("worker_total") or _maxw),
"free_worker_slots": _free,
"max_active_subagents_per_root": int(get_max_active_subagents_per_root()),
"note": (
"live worker/queue load. Read THIS before claiming children are 'starved' "
"or the queue is 'saturated' — derive resource facts from here, not guesses."
),
}
except Exception:
log.debug("Failed to build queue digest for context", exc_info=True)
if budget_info:
runtime_data["budget"] = budget_info
schedule_digest = _scheduled_tasks_digest(env)
@ -253,7 +315,26 @@ def build_runtime_section(env: Any, task: Dict[str, Any]) -> str:
"message in a project room defaults to that project unless it clearly says otherwise."
)
runtime_ctx = json.dumps(runtime_data, ensure_ascii=False, indent=2)
return "## Runtime context\n\n" + runtime_ctx
out = "## Runtime context\n\n" + runtime_ctx
# Shared task-tree coordination ledger (swarm blackboard): inject the tail so EVERY
# member of the tree reads the shared frame / sibling beacons forward, instead of
# re-deriving or duplicating work (domain-agnostic; tree_note/tree_read).
try:
from ouroboros.task_tree_ledger import tree_ledger_tail_digest
_root_id = str(task.get("root_task_id") or task.get("id") or "")
_tree_digest = tree_ledger_tail_digest(_root_id, limit=40) if _root_id else ""
if _tree_digest:
out += (
"\n\n## Task-tree coordination ledger (shared swarm blackboard)\n\n"
"Shared across this task tree via tree_note/tree_read. Before fanning out "
"INTERDEPENDENT children, publish the shared frame (contract/decision/fact); "
"children build against it and raise blocker/question beacons for attention.\n\n"
+ _tree_digest
)
except Exception:
log.debug("Failed to inject task-tree ledger digest", exc_info=True)
return out
def build_knowledge_sections(

View file

@ -49,6 +49,12 @@ ARTIFACT_TERMINAL_STATUSES = {
# headless → task_status → outcomes → headless cycle, and the smoke test below
# pins equality so the literal cannot drift from the SSOT.
_FINAL_STATUSES = frozenset({"completed", "failed", "cancelled", "rejected_duplicate"})
# Mirrors tool_capabilities.LOCAL_READONLY_SUBAGENT_MODE; a module-level import would risk
# an import cycle (same rationale as _FINAL_STATUSES above), and the smoke test pins equality
# so the literal cannot drift from this SSOT — the kind of re-derivation drift that stranded
# the reaper's artifact finalization before task_is_readonly_subagent consolidated the gate.
_LOCAL_READONLY_SUBAGENT_MODE = "local_readonly_subagent"
_ARTIFACT_LIFECYCLE_FIELDS = {
"artifact_status",
"artifact_error",
@ -303,6 +309,53 @@ def prune_task_drives(
return report
def prune_task_trees(
parent_drive_root: pathlib.Path,
*,
retention_days: Optional[int] = None,
now: Optional[float] = None,
) -> Dict[str, Any]:
"""Best-effort startup prune for ephemeral task-tree coordination ledgers
(``data/task_trees/<root_task_id>/blackboard.jsonl``). A tree's ledger is removed once
its ROOT task is terminal (or has no surviving result) and older than the GC retention
window swarm-run coordination is transient, distinct from durable project memory."""
from ouroboros.retention import age_cutoff
parent = pathlib.Path(parent_drive_root)
base = parent / "task_trees"
days = _resolve_retention_days(retention_days)
cutoff = age_cutoff(days, now)
report: Dict[str, Any] = {"retention_days": days, "scanned": 0, "pruned": [], "skipped": [], "errors": []}
if not base.is_dir():
return report
for tree_dir in sorted(base.iterdir()):
if not tree_dir.is_dir():
continue
root_id = tree_dir.name
report["scanned"] += 1
try:
dir_mtime = tree_dir.stat().st_mtime
try:
from ouroboros.task_status import load_effective_task_result
result = load_effective_task_result(parent, root_id) or {}
except Exception:
result = load_task_result(parent, root_id) or {}
status = str(result.get("status") or "").lower()
if status and status not in _FINAL_STATUSES:
report["skipped"].append({"root_task_id": root_id, "reason": "root_not_terminal", "status": status})
continue
if _timestamp_from_result(result, dir_mtime) > cutoff:
report["skipped"].append({"root_task_id": root_id, "reason": "younger_than_retention"})
continue
shutil.rmtree(tree_dir)
report["pruned"].append({"root_task_id": root_id, "path": str(tree_dir)})
except Exception as exc:
report["errors"].append({"root_task_id": root_id, "error": f"{type(exc).__name__}: {exc}"})
return report
def remove_subagent_task_drive(parent_drive_root: pathlib.Path, task_id: str) -> bool:
"""Immediately remove a subagent's child drive (used on cancel/timeout).
@ -344,7 +397,7 @@ def copy_child_task_result(parent_drive_root: pathlib.Path, task: Dict[str, Any]
task_constraint = metadata.get("task_constraint") or {}
readonly_subagent = (
str(task.get("delegation_role") or metadata.get("delegation_role") or "") == "subagent"
and str(task_constraint.get("mode") or "") == "local_readonly_subagent"
and str(task_constraint.get("mode") or "") == _LOCAL_READONLY_SUBAGENT_MODE
)
workspace_task = _workspace_root_from_task(task) is not None and not readonly_subagent
child_status = str(child_result.get("status") or "completed")
@ -445,6 +498,23 @@ def _copy_child_artifacts_to_parent(
return rebased
def task_is_readonly_subagent(task: Dict[str, Any]) -> bool:
"""A local-readonly live subagent produces no durable owner-facing artifacts, so the
``task_done`` finalize path (and the reaper that honors a self-finalized result) skip
artifact finalization for it. Single SSOT gate so every call site reads the same rule
instead of re-deriving it (a re-derivation drift is what stranded the reaper path)."""
if not isinstance(task, dict):
return False
metadata = task.get("metadata") if isinstance(task.get("metadata"), dict) else {}
task_constraint = task.get("task_constraint") if isinstance(task.get("task_constraint"), dict) else {}
if not task_constraint and isinstance(metadata.get("task_constraint"), dict):
task_constraint = metadata.get("task_constraint") or {}
return (
str(task.get("delegation_role") or metadata.get("delegation_role") or "") == "subagent"
and str(task_constraint.get("mode") or "") == _LOCAL_READONLY_SUBAGENT_MODE
)
def finalize_task_artifacts(parent_drive_root: pathlib.Path, task: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Write patch/memory-export artifacts for a completed headless task."""
@ -1297,6 +1367,7 @@ __all__ = [
"build_workspace_patch",
"copy_child_task_result",
"finalize_task_artifacts",
"task_is_readonly_subagent",
"prepare_task_drive",
"prune_headless_task_drives",
"prune_task_drives",

View file

@ -78,6 +78,10 @@ TOOL_POLICY: Dict[str, str] = {
"knowledge_write": POLICY_SKIP,
"journal_write": POLICY_SKIP,
"workpad_write": POLICY_SKIP,
# Bounded local task-tree coordination ledger (append-only, size-capped, tree-scoped):
# same trust class as journal/workpad — no external effect.
"tree_note": POLICY_SKIP,
"tree_read": POLICY_SKIP,
"promote_chat_to_task": POLICY_SKIP,
"ensure_project_scope": POLICY_SKIP,
"route_to_project": POLICY_SKIP,

View file

@ -7,7 +7,7 @@ import pathlib
import time
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Any, Dict, Iterable, List
from typing import Any, Callable, Dict, Iterable, List, Optional
from ouroboros.headless import (
ARTIFACT_STATUS_FAILED,
@ -548,6 +548,7 @@ def wait_for_effective_tasks(
timeout_sec: float,
mode: str = "all_terminal",
poll_interval_sec: float = 0.5,
on_poll: Optional[Callable[[Dict[str, Any], Dict[str, bool]], Any]] = None,
) -> Dict[str, Any]:
ids = []
for item in task_ids:
@ -561,6 +562,7 @@ def wait_for_effective_tasks(
deadline = start + max(0.0, float(timeout_sec or 0))
results: Dict[str, Dict[str, Any]] = {}
timed_out = False
early: Any = None
while True:
results = {tid: load_effective_task_result(pathlib.Path(drive_root), tid) for tid in ids}
terminal = {tid: str(data.get("status") or "").strip().lower() in FINAL_STATUSES for tid, data in results.items()}
@ -568,11 +570,22 @@ def wait_for_effective_tasks(
break
if mode != "any_terminal" and all(terminal.values()):
break
# Sliced wait hook: a child->parent attention beacon (blocker/question) can break
# the wait early so a productively-waiting parent reacts mid-flight instead of only
# at terminal. Never raises into the wait; a faulty hook just keeps polling.
if callable(on_poll):
try:
signal = on_poll(results, terminal)
except Exception:
signal = None
if signal is not None:
early = signal
break
if time.monotonic() >= deadline:
timed_out = True
break
time.sleep(max(0.05, min(2.0, float(poll_interval_sec or 0.5))))
return {
out: Dict[str, Any] = {
"mode": mode,
"timeout_sec": float(timeout_sec or 0),
"elapsed_sec": max(0.0, time.monotonic() - start),
@ -580,6 +593,20 @@ def wait_for_effective_tasks(
"all_terminal": all(str(data.get("status") or "").strip().lower() in FINAL_STATUSES for data in results.values()) if ids else True,
"tasks": results,
}
if early is not None:
out["early_return"] = early
# Live per-child status from the queue snapshot — kills the false "starved"/"dead"
# claim: the parent sees which children are actually RUNNING/SCHEDULED vs terminal.
try:
_snap = _load_queue_snapshot(pathlib.Path(drive_root))
live: Dict[str, str] = {}
for tid in ids:
_st, _ = _queue_task_status(_snap, tid)
live[tid] = _st or ("terminal" if str((results.get(tid) or {}).get("status") or "").strip().lower() in FINAL_STATUSES else "unknown")
out["live_child_status"] = live
except Exception:
pass
return out
def find_child_tasks(

View file

@ -0,0 +1,149 @@
"""Task-tree coordination ledger — the swarm blackboard + typed child->parent beacons.
Scoped by ROOT_TASK_ID (the whole task tree), so it works for ANY swarm project or
not (email triage, research, a presentation, an OS from scratch). One append-only JSONL
holds both coordination artifacts and beacons; durable project milestones still belong in
the project journal (this ledger is EPHEMERAL coordination for one swarm run).
Domain-agnostic by design: a 'contract' is code-module APIs OR presentation
section-ownership+style OR a research claim/source schema OR an email-triage category
schema whatever the integration seam is for THIS task. Deterministic code enforces only
form (scope, kinds, append-only, size caps); the LLM interprets meaning (BIBLE P5).
"""
from __future__ import annotations
import logging
import pathlib
from typing import Any, Dict, List
from ouroboros.config import DATA_DIR
from ouroboros.utils import append_jsonl, iter_jsonl_objects, utc_now_iso
log = logging.getLogger(__name__)
# Coordination artifacts + typed child->parent beacons, in one append-only ledger.
COORDINATION_KINDS = ("contract", "decision", "fact", "note")
BEACON_KINDS = ("milestone", "partial_finding", "blocker", "question")
LEDGER_KINDS = COORDINATION_KINDS + BEACON_KINDS
# Beacons that ask the parent to look NOW (surface an early return from a sliced wait).
ATTENTION_KINDS = ("blocker", "question")
_MAX_TEXT_CHARS = 4000
# Bound runaway growth — this is a coordination ledger, not a bulk-data store.
_MAX_LEDGER_BYTES = 2 * 1024 * 1024
def _sanitize_root_id(root_id: Any) -> str:
s = str(root_id or "").strip()
safe = "".join(ch for ch in s if ch.isalnum() or ch in ("-", "_"))
return safe[:64]
def tree_ledger_path(root_id: str) -> pathlib.Path:
return pathlib.Path(DATA_DIR) / "task_trees" / _sanitize_root_id(root_id) / "blackboard.jsonl"
def tree_ledger_append(
root_id: str,
kind: str,
text: str,
*,
task_id: str = "",
role: str = "",
needs_parent_attention: bool = False,
) -> str:
rid = _sanitize_root_id(root_id)
if not rid:
return "⚠️ TOOL_ARG_ERROR (tree_note): no task-tree scope (root_task_id missing)."
kind_norm = str(kind or "note").strip().lower()
if kind_norm not in LEDGER_KINDS:
return f"⚠️ TOOL_ARG_ERROR (tree_note): kind must be one of {LEDGER_KINDS}"
body = str(text or "").strip()
if not body:
return "⚠️ TOOL_ARG_ERROR (tree_note): text is required"
if len(body) > _MAX_TEXT_CHARS:
return (
f"⚠️ TOOL_ARG_ERROR (tree_note): entry exceeds {_MAX_TEXT_CHARS} chars "
f"({len(body)}) — a ledger entry is a short coordination note; keep it terse "
"and move bulk detail to an artifact."
)
path = tree_ledger_path(rid)
try:
if path.is_file() and path.stat().st_size > _MAX_LEDGER_BYTES:
return (
"⚠️ TOOL_ARG_ERROR (tree_note): the task-tree ledger is full (>2MB) — it is for "
"coordination artifacts, not bulk data; summarize or move detail to artifacts."
)
except OSError:
pass
path.parent.mkdir(parents=True, exist_ok=True)
attention = bool(needs_parent_attention) or kind_norm in ATTENTION_KINDS
append_jsonl(
path,
{
"ts": utc_now_iso(),
"kind": kind_norm,
"text": body,
"task_id": str(task_id or ""),
"role": str(role or ""),
"needs_parent_attention": attention,
},
)
return f"OK: task-tree ledger[{rid}] += {kind_norm} entry ({len(body)} chars)."
def tree_ledger_rows(root_id: str) -> List[Dict[str, Any]]:
path = tree_ledger_path(root_id)
if not path.is_file():
return []
return [r for r in iter_jsonl_objects(path) if isinstance(r, dict)]
def tree_ledger_tail_digest(root_id: str, *, limit: int = 40) -> str:
"""Recent ledger entries for context injection (no ctx needed). Each entry shown in
full; older entries beyond the tail represented by a visible pointer to tree_read."""
rows = tree_ledger_rows(root_id)
if not rows:
return ""
take = rows[-max(1, int(limit)):]
omitted = len(rows) - len(take)
lines: List[str] = []
if omitted:
lines.append(f"- …[{omitted} earlier ledger entries via tree_read]")
for r in take:
flag = " ⚠needs_parent_attention" if r.get("needs_parent_attention") else ""
who = str(r.get("role") or "") or str(r.get("task_id") or "")[:8]
lines.append(
f"- [{str(r.get('ts') or '')[:16]}] {str(r.get('kind') or 'note')}{flag} "
f"({who}): {str(r.get('text') or '')}"
)
return "\n".join(lines)
def tree_ledger_attention_after(root_id: str, after_ts: str) -> List[Dict[str, Any]]:
"""Attention-beacons (blocker/question) strictly after after_ts — drives the sliced
wait's early return so a parent reacts to a child's blocker without waiting for it to
terminate."""
out: List[Dict[str, Any]] = []
for r in tree_ledger_rows(root_id):
if not r.get("needs_parent_attention"):
continue
ts = str(r.get("ts") or "")
if after_ts and ts <= after_ts:
continue
out.append(r)
return out
__all__ = [
"LEDGER_KINDS",
"COORDINATION_KINDS",
"BEACON_KINDS",
"ATTENTION_KINDS",
"tree_ledger_path",
"tree_ledger_append",
"tree_ledger_rows",
"tree_ledger_tail_digest",
"tree_ledger_attention_after",
]

View file

@ -11,6 +11,9 @@ CORE_TOOL_NAMES: frozenset[str] = frozenset({
"vcs_restore", "vcs_revert", "vcs_pull_ff", "vcs_rollback",
"schedule_subagent", "integrate_subagent_patch", "compare_subagent_patches",
"wait_task", "wait_tasks", "get_task_result",
# Task-tree coordination must be in the round-one envelope so a parent can publish the
# shared frame BEFORE fanning out interdependent children (no enable_tools detour).
"tree_note", "tree_read",
# Main-chat routing capabilities the SYSTEM.md decision turn relies on
# (kept in the core envelope so the anti-freeze ephemeral turn never needs an
# enable_tools detour to route — though initial_tool_schemas exposes the full
@ -46,6 +49,10 @@ LOCAL_READONLY_SUBAGENT_TOOL_NAMES: frozenset[str] = frozenset({
"vcs_status", "vcs_diff",
"chat_history", "recent_tasks", "get_task_result", "wait_task", "wait_tasks",
"schedule_subagent",
# Task-tree coordination: a child reads the shared frame and raises beacons. tree_note
# is a bounded local coordination write (no repo/control-plane mutation), so it is
# allowed even for read-only subagents — same class as emitting progress.
"tree_note", "tree_read",
"web_search", "browse_page", "browser_action", "analyze_screenshot", "vlm_query",
})
@ -68,6 +75,7 @@ ACTING_SUBAGENT_TOOL_NAMES: frozenset[str] = frozenset({
"integrate_subagent_patch", "compare_subagent_patches",
"schedule_subagent", "wait_task", "wait_tasks", "get_task_result",
"knowledge_read", "knowledge_list",
"tree_note", "tree_read",
"web_search", "browse_page", "browser_action", "analyze_screenshot", "vlm_query",
"list_available_tools",
})
@ -132,6 +140,9 @@ TOOL_RESULT_LIMITS: dict[str, int] = {
"compare_subagent_patches": 80_000,
# skill_exec wraps stdout/stderr; keep the full capped payload visible.
"skill_exec": 300_000,
# tree_read returns the shared task-tree coordination tail (up to 200 entries); the 15k
# default would truncate the swarm blackboard and defeat the coordination contract.
"tree_read": 80_000,
}
DEFAULT_TOOL_RESULT_LIMIT: int = 15_000

View file

@ -12,7 +12,7 @@ import time
import uuid
from hashlib import sha256
from pathlib import Path
from typing import Any, Dict, List
from typing import Any, Callable, Dict, List
from ouroboros.config import apply_settings_to_env, get_max_subagent_depth, load_settings, save_settings
from ouroboros.headless import prepare_task_drive, task_state_dir
@ -504,10 +504,13 @@ def _build_acting_constraint(
)
if not get_allow_mutative_subagents():
return (
"⚠️ MUTATIVE_SUBAGENTS_DISABLED: acting (mutative) subagents are turned "
"off in this runtime mode. Schedule a read-only subagent (omit "
"write_surface), or have the owner enable OUROBOROS_ALLOW_MUTATIVE_SUBAGENTS "
"(default ON in advanced/pro, OFF in light)."
"⚠️ MUTATIVE_SUBAGENTS_DISABLED: the OUROBOROS_ALLOW_MUTATIVE_SUBAGENTS toggle "
"is not enabled. That toggle is the master gate: an explicit owner true/false "
"overrides the runtime-mode default, and runtime mode only sets the default when "
"the toggle is empty (default ON in advanced/pro, OFF in light). Schedule a "
"read-only subagent (omit write_surface), or have the owner enable "
"OUROBOROS_ALLOW_MUTATIVE_SUBAGENTS. Note: light blocks only self-repo/control-plane "
"writes (write_surface=self_worktree), not user/task/project deliverables."
)
grants: List[str] = []
if isinstance(external_tool_grants, (list, tuple)):
@ -1194,6 +1197,28 @@ def _get_task_result(ctx: ToolContext, task_id: str) -> str:
return output
def _wait_attention_poll(ctx: ToolContext, after_ts: str) -> Callable[..., Any]:
"""on_poll hook: break a sliced wait early when a child appends an attention beacon
(blocker/question) after the wait started, so a waiting parent reacts mid-flight."""
# tree_note/tree_read live in ouroboros/tools/task_tree.py (extracted for module size).
from ouroboros.tools.task_tree import tree_root_id
rid = tree_root_id(ctx)
def _hook(_results: Dict[str, Any], _terminal: Dict[str, bool]) -> Any:
if not rid:
return None
try:
from ouroboros.task_tree_ledger import tree_ledger_attention_after
att = tree_ledger_attention_after(rid, after_ts)
except Exception:
return None
return {"reason": "child_attention_beacon", "beacons": att[-5:]} if att else None
return _hook
def _wait_for_task(ctx: ToolContext, task_id: str, timeout_sec: int = 180) -> str:
"""Wait for a subtask to reach a terminal status."""
try:
@ -1206,9 +1231,18 @@ def _wait_for_task(ctx: ToolContext, task_id: str, timeout_sec: int = 180) -> st
timeout = 180
metadata = getattr(ctx, "task_metadata", {}) if isinstance(getattr(ctx, "task_metadata", {}), dict) else {}
status_drive_root = Path(str(metadata.get("budget_drive_root") or getattr(ctx, "budget_drive_root", "") or ctx.drive_root))
waited = wait_for_effective_tasks(status_drive_root, [tid], timeout_sec=timeout)
header = "Task wait completed" if waited.get("all_terminal") else "Task wait timed out"
return f"{header} after {waited.get('elapsed_sec', 0):.1f}s.\n\n{_get_task_result(ctx, tid)}"
waited = wait_for_effective_tasks(
status_drive_root, [tid], timeout_sec=timeout,
on_poll=_wait_attention_poll(ctx, utc_now_iso()), poll_interval_sec=2.0,
)
early = waited.get("early_return")
if early:
header = "Task wait interrupted by a child attention beacon"
extra = f"\n\n[CHILD_BEACONS]\n{json.dumps(early, ensure_ascii=False, indent=2)}\n[/CHILD_BEACONS]"
else:
header = "Task wait completed" if waited.get("all_terminal") else "Task wait timed out"
extra = ""
return f"{header} after {waited.get('elapsed_sec', 0):.1f}s.{extra}\n\n{_get_task_result(ctx, tid)}"
def _wait_for_tasks(
@ -1239,7 +1273,10 @@ def _wait_for_tasks(
return "⚠️ TOOL_ARG_ERROR (wait_tasks): mode must be all_terminal or any_terminal."
metadata = getattr(ctx, "task_metadata", {}) if isinstance(getattr(ctx, "task_metadata", {}), dict) else {}
status_drive_root = Path(str(metadata.get("budget_drive_root") or getattr(ctx, "budget_drive_root", "") or ctx.drive_root))
waited = wait_for_effective_tasks(status_drive_root, normalized_ids, timeout_sec=timeout, mode=normalized_mode)
waited = wait_for_effective_tasks(
status_drive_root, normalized_ids, timeout_sec=timeout, mode=normalized_mode,
on_poll=_wait_attention_poll(ctx, utc_now_iso()), poll_interval_sec=2.0,
)
tasks = waited.get("tasks")
if isinstance(tasks, dict):
public_tasks: Dict[str, Any] = {}
@ -1509,7 +1546,7 @@ def get_tools() -> List[ToolEntry]:
}, _get_task_result),
ToolEntry("wait_task", {
"name": "wait_task",
"description": "Wait for a subtask to reach a terminal status and return its effective result.",
"description": "Wait for a subtask to reach a terminal status and return its effective result. May return EARLY (before terminal) if the child raises a tree_note blocker/question beacon — the result then carries a [CHILD_BEACONS] block so you can steer it.",
"parameters": {"type": "object", "required": ["task_id"], "properties": {
"task_id": {"type": "string", "description": "Task ID to check"},
"timeout_sec": {"type": "integer", "default": 180, "description": "Maximum seconds to wait (default 180)."},
@ -1517,7 +1554,7 @@ def get_tools() -> List[ToolEntry]:
}, _wait_for_task, timeout_sec=7200),
ToolEntry("wait_tasks", {
"name": "wait_tasks",
"description": "Wait for multiple subtasks and return full effective results for each child.",
"description": "Wait for multiple subtasks and return full effective results for each child. The JSON also includes live_child_status (running/scheduled/terminal per child) and may early_return (before all terminal) on a child tree_note blocker/question beacon so you can steer mid-flight.",
"parameters": {"type": "object", "required": ["task_ids"], "properties": {
"task_ids": {"type": "array", "items": {"type": "string"}, "description": "Task IDs returned by schedule_subagent."},
"timeout_sec": {"type": "integer", "default": 600, "description": "Maximum seconds to wait (default 600)."},

View file

@ -423,6 +423,10 @@ _WORKSPACE_ALLOWED_TOOLS = frozenset({
"journal_write",
"workpad_read",
"workpad_write",
# Task-tree coordination: a workspace parent must publish/read the shared frame and a
# workspace child must raise beacons (bounded, append-only local coordination).
"tree_note",
"tree_read",
"web_search",
"browse_page",
"browser_action",
@ -773,7 +777,7 @@ class ToolRegistry:
"health", "knowledge", "memory_tools", "plan_review", "project_journal",
"recent_tasks",
"query_code", "review", "search", "services", "shell", "skill_exec", "skill_publish",
"skill_preflight", "subagent_integration", "tool_discovery", "vision",
"skill_preflight", "subagent_integration", "task_tree", "tool_discovery", "vision",
]
def _load_modules(self) -> None:

View file

@ -0,0 +1,89 @@
"""Task-tree coordination tools: tree_note / tree_read (the swarm blackboard + typed
child->parent beacons). Extracted from control.py for module size; storage lives in
``ouroboros.task_tree_ledger`` and is scoped by ``root_task_id`` (the whole task tree)."""
from __future__ import annotations
from typing import List
from ouroboros.tools.registry import ToolContext, ToolEntry
def tree_root_id(ctx: ToolContext) -> str:
"""Resolve the task-tree root (root_task_id), falling back to this task's own id (the
root has no parent). Scopes the coordination ledger to the WHOLE swarm/tree."""
md = getattr(ctx, "task_metadata", {})
rid = str(md.get("root_task_id") or "").strip() if isinstance(md, dict) else ""
return rid or str(getattr(ctx, "task_id", "") or "").strip()
def _tree_note(ctx: ToolContext, kind: str, text: str, needs_parent_attention: bool = False) -> str:
from ouroboros.task_tree_ledger import tree_ledger_append
md = getattr(ctx, "task_metadata", {})
role = str(md.get("role") or md.get("subagent_role") or "") if isinstance(md, dict) else ""
return tree_ledger_append(
tree_root_id(ctx),
kind,
text,
task_id=str(getattr(ctx, "task_id", "") or ""),
role=role,
needs_parent_attention=bool(needs_parent_attention),
)
def _tree_read(ctx: ToolContext, limit: int = 40) -> str:
from ouroboros.task_tree_ledger import tree_ledger_tail_digest
rid = tree_root_id(ctx)
if not rid:
return "⚠️ TOOL_ARG_ERROR (tree_read): no task-tree scope."
try:
lim = max(1, min(int(limit), 200))
except (TypeError, ValueError):
lim = 40
digest = tree_ledger_tail_digest(rid, limit=lim)
if not digest:
return f"(task-tree coordination ledger [{rid}] is empty)"
return f"## Task-tree coordination ledger ({rid})\n\n{digest}"
def get_tools() -> List[ToolEntry]:
return [
ToolEntry("tree_note", {
"name": "tree_note",
"description": (
"Append a coordination entry to the SHARED task-tree ledger (the swarm "
"blackboard, scoped to root_task_id — visible to the parent and all "
"siblings/descendants of THIS task tree). Use it to publish the shared "
"frame BEFORE fanning out interdependent children and to coordinate while "
"they run. kind: contract|decision|fact|note (coordination) or "
"milestone|partial_finding|blocker|question (child->parent beacon). "
"blocker/question (or needs_parent_attention=true) surface an early return "
"in the parent's wait. Domain-agnostic: 'contract' = code APIs OR "
"presentation section-ownership OR a research claim schema — the seam for "
"THIS task. Keep entries short; bulk detail belongs in artifacts."
),
"parameters": {"type": "object", "required": ["kind", "text"], "properties": {
"kind": {"type": "string", "enum": [
"contract", "decision", "fact", "note",
"milestone", "partial_finding", "blocker", "question",
]},
"text": {"type": "string", "description": "Short coordination text (<=4000 chars)."},
"needs_parent_attention": {"type": "boolean", "default": False, "description": "Force a parent early-wait return (implied by blocker/question)."},
}},
}, lambda ctx, kind, text, needs_parent_attention=False: _tree_note(ctx, kind, text, needs_parent_attention), timeout_sec=15),
ToolEntry("tree_read", {
"name": "tree_read",
"description": (
"Read the tail of the shared task-tree coordination ledger (newest last) — "
"the shared frame, decisions, facts, and sibling beacons for THIS task tree."
),
"parameters": {"type": "object", "properties": {
"limit": {"type": "integer", "default": 40, "description": "Max entries (<=200)."},
}},
}, lambda ctx, limit=40: _tree_read(ctx, limit), timeout_sec=15),
]
__all__ = ["get_tools", "tree_root_id"]

View file

@ -60,8 +60,11 @@ schema: `objective`, `expected_output`, optional `role`, `context`,
safe light lane unless I deliberately choose another lane. `review`/`scope`
may fan out across configured reviewer slots and return a task group. `shared`
is disabled for live subagents. `context` is reference material only. A read-only
child cannot write local state, enable tools, commit, review, change runtime
settings, run shell/skills lifecycle tools, or bypass owner resources.
child cannot write local repo/data/memory state, enable tools, commit, review, change
runtime settings, run shell/skills lifecycle tools, or bypass owner resources — but it
MAY still coordinate via the bounded append-only task-tree ledger (`tree_note`/`tree_read`:
raise beacons, read the shared frame), which is the one permitted local-write path because
it is swarm coordination, not state mutation.
To delegate work that CHANGES things, pass `write_surface` to spawn a mutative
("acting") child (when `OUROBOROS_ALLOW_MUTATIVE_SUBAGENTS` is on — default in
@ -133,6 +136,35 @@ While a task runs, a new main-chat message never freezes the chat: it is its own
short turn where I make this same answer/route/spawn/steer decision. I steer the
running task only when the message is explicitly about it.
## Swarm Coordination: shared frame, beacons, honest capability
When I fan out children whose outputs will be INTEGRATED together, I first publish the
shared frame to the task-tree ledger with `tree_note`: the ownership map, the shared
contract/schema/format/standard at the seams, the integration order, and the open
questions. Children build AGAINST that frame and raise a beacon (`tree_note`
kind=blocker/question/partial_finding) when the contract must change; I reconcile and
republish. If the children are INDEPENDENT (their outputs need not integrate — e.g.
research over disjoint sources), no shared frame is required and I fan out directly. The
ledger is domain-agnostic: a "contract" is code-module APIs OR a presentation's
section-ownership+style OR a research claim/source schema OR an email-triage category
schema — whatever the integration seam is for THIS task. I read the shared ledger
(injected each turn, or `tree_read`) before re-deriving or duplicating a sibling's work.
A child raises `tree_note` kind=blocker|question (which flags needs_parent_attention) the
moment it is stuck or about to build on an unverified assumption — this returns my `wait`
early so I steer it, instead of letting it barrel on or its partial work get lost.
A subagent YIELDS as soon as its deliverable and handoff are done: it gives its FINAL
ANSWER to release the worker, and does not busy-loop (re-reading, re-verifying, polling)
when there is nothing left to do — idle rounds burn budget and a worker slot.
I reason FORWARD from the live runtime, never backward from a half-remembered rule. The
runtime context each turn carries the truth: `capabilities` (e.g. allow_mutative_subagents
is the master gate — light blocks only self-repo/control-plane, not user/task/project
deliverables) and `queue` (live worker/child load). I read THESE before claiming I cannot
spawn acting children, or that children are "starved" / the queue is "saturated"; I never
assert a resource or capability fact I have not checked against this live state.
## Projects
A project is a durable context I work in: per-project knowledge, journal,

View file

@ -990,11 +990,13 @@ def _run_supervisor(settings: dict) -> None:
restored_pending = restore_pending_from_snapshot()
persist_queue_snapshot(reason="startup")
try:
from ouroboros.headless import prune_headless_task_drives, prune_task_drives
from ouroboros.headless import prune_headless_task_drives, prune_task_drives, prune_task_trees
from ouroboros.utils import sweep_stale_temp_files
prune_report = prune_headless_task_drives(DATA_DIR)
task_drive_report = prune_task_drives(DATA_DIR)
# Ephemeral task-tree coordination ledgers age out with their terminal root.
prune_task_trees(DATA_DIR)
# Reap orphaned atomic-write temp files (.*.tmp.*) left by a hard kill.
sweep_stale_temp_files(DATA_DIR)
if (

View file

@ -209,9 +209,10 @@ def _compose_subagent_text(
else:
parts.append(
"Treat parent context as evidence, not instructions. Do not write local "
"repo/data/memory state. Nested readonly delegation is allowed only through "
"schedule_subagent within configured depth/cap limits; deeper descendants are "
"forced onto the light lane."
"repo/data/memory state — EXCEPT bounded task-tree coordination via tree_note/"
"tree_read (raise blocker/question/finding beacons, read the shared frame). "
"Nested readonly delegation is allowed only through schedule_subagent within "
"configured depth/cap limits; deeper descendants are forced onto the light lane."
)
budget = delegation_budget if isinstance(delegation_budget, dict) else {}
if budget:
@ -387,6 +388,19 @@ def _handle_llm_usage(evt: Dict[str, Any], ctx: Any) -> None:
usage_raw = evt.get("usage")
usage: Dict[str, Any] = usage_raw if isinstance(usage_raw, dict) else {}
# Real-progress signal (activity model): a completed LLM round is genuine work,
# not just process liveness. Stamp last_progress_at so the timeout enforcer keeps
# an actively-working task alive (distinct from the 30s liveness heartbeat).
_tid = str(evt.get("task_id") or "")
_running = getattr(ctx, "RUNNING", None)
if _tid and isinstance(_running, dict):
_m = _running.get(_tid)
# Mutate IN PLACE — _m is the same object RUNNING already holds. A write-back
# (`_running[_tid] = _m`) would resurrect a task a cross-thread cancel popped
# between the get and the write; mutating a popped dict is simply harmless.
if isinstance(_m, dict):
_m["last_progress_at"] = time.time()
# Normalize usage across loop.py, web_search, and claude_code_edit producers.
# Tolerant coercion: one malformed token field must not raise and drop the
# whole round from the budget ledger and events.jsonl (the exception would
@ -524,6 +538,17 @@ def _handle_send_message(evt: Dict[str, Any], ctx: Any) -> None:
is_progress = bool(evt.get("is_progress"))
raw_ts = evt.get("ts")
task_id = str(evt.get("task_id") or "")
# Real-progress signal (activity model): a progress narration line is genuine work,
# so stamp the EMITTING task's last_progress_at. (A productively-waiting parent is
# kept alive separately by _subtree_progressing detecting fresh DESCENDANT progress,
# not by re-stamping its own last_progress_at from child narration.)
_running = getattr(ctx, "RUNNING", None)
if is_progress and task_id and isinstance(_running, dict):
_m = _running.get(task_id)
# Mutate in place (see _handle_llm_usage): no write-back, so a cross-thread
# cancel that popped this task is never resurrected.
if isinstance(_m, dict):
_m["last_progress_at"] = time.time()
bound_chat = _bound_project_chat_id(ctx, task_id, evt.get("parent_task_id"), evt.get("root_task_id"))
chat_id = bound_chat or int(evt["chat_id"])
ctx.send_with_budget(
@ -556,19 +581,15 @@ def _handle_task_done(evt: Dict[str, Any], ctx: Any) -> None:
final_task_result: Dict[str, Any] = {}
if task_id:
try:
from ouroboros.headless import copy_child_task_result, finalize_task_artifacts
from ouroboros.headless import (
copy_child_task_result,
finalize_task_artifacts,
task_is_readonly_subagent,
)
if task:
copy_child_task_result(ctx.DRIVE_ROOT, task)
task_constraint = task.get("task_constraint") if isinstance(task.get("task_constraint"), dict) else {}
task_metadata = task.get("metadata") if isinstance(task.get("metadata"), dict) else {}
if not task_constraint and isinstance(task_metadata.get("task_constraint"), dict):
task_constraint = task_metadata.get("task_constraint") or {}
real_live_subagent = (
str(task.get("delegation_role") or task_metadata.get("delegation_role") or "") == "subagent"
and str(task_constraint.get("mode") or "") == LOCAL_READONLY_SUBAGENT_MODE
)
if not real_live_subagent:
if not task_is_readonly_subagent(task):
finalize_task_artifacts(ctx.DRIVE_ROOT, task)
except Exception as exc:
try:

View file

@ -7,6 +7,7 @@ import json
import logging
import math
import pathlib
import queue as _stdqueue # noqa: F401 — re-exported for the test suite's reap-queue isolation
import threading
import time
import uuid
@ -17,11 +18,17 @@ from supervisor.state import (
QUEUE_SNAPSHOT_PATH, budget_remaining, EVOLUTION_BUDGET_RESERVE, reconstruct_task_cost,
)
from supervisor.message_bus import send_with_budget
from ouroboros.config import FINALIZATION_GRACE_DEFAULT_SEC, get_finalization_grace_sec
from ouroboros.config import (
FINALIZATION_GRACE_DEFAULT_SEC,
get_finalization_grace_sec,
get_per_call_timeout_ceiling_sec,
get_task_abs_ceiling_sec,
get_task_idle_timeout_sec,
)
from ouroboros.contracts.task_contract import attach_task_contract, build_task_contract, normalize_allowed_resources
from ouroboros.schedule_contract import RESERVED_TEMPLATE_FIELDS, schedule_slug
from ouroboros.outcomes import EXECUTION_INFRA_FAILED, normalize_outcome_axes, terminal_outcome_axes
from ouroboros.utils import atomic_write_json, read_json_dict, truncate_review_artifact, utc_now_iso
from ouroboros.outcomes import normalize_outcome_axes, terminal_outcome_axes
from ouroboros.utils import atomic_write_json, read_json_dict, utc_now_iso
from supervisor.evolution_lifecycle import (
_read_evolution_campaign,
_write_evolution_campaign,
@ -100,6 +107,15 @@ _queue_lock = threading.RLock()
_last_skill_schedule_sync: float = 0.0
_SKILL_SCHEDULE_SYNC_INTERVAL_SEC: float = 60.0
# Variant A off-loop worker reaper lives in supervisor/task_reaper.py (extracted for
# module size). Re-export the thin names the enforce path and tests use; monkeypatching
# these queue-module names still works because the enforce path references them here.
from supervisor.task_reaper import ( # noqa: E402,F401 — re-exported for enforce path + tests
ensure_reaper_started as _ensure_reaper_started,
reap_queue as _reap_queue,
reap_timed_out_task as _reap_timed_out_task,
)
def init_queue_refs(pending: List[Dict[str, Any]], running: Dict[str, Dict[str, Any]],
seq_counter_ref: Dict[str, int]) -> None:
@ -532,6 +548,24 @@ def persist_queue_snapshot(reason: str = "") -> None:
(task_id, dict(meta) if isinstance(meta, dict) else {})
for task_id, meta in RUNNING.items()
]
# Honest worker-pool counts from the ACTUAL pool (not the configured max): the live
# pool can be smaller (a crash-storm/direct-chat fallback clears WORKERS) and a slot
# mid-reap is popped from RUNNING but NOT assignable. Surface the real assignable-idle
# count so the context queue digest never falsely advertises a free worker slot.
try:
from supervisor import workers as _workers_mod
_ws = list(_workers_mod.WORKERS.values())
worker_total = len(_ws)
reaping_count = sum(1 for _w in _ws if getattr(_w, "reaping", False))
assignable_idle_workers = sum(
1 for _w in _ws
if getattr(_w, "busy_task_id", None) is None and not getattr(_w, "reaping", False)
)
except Exception:
worker_total = 0
reaping_count = 0
assignable_idle_workers = 0
pending_rows = []
for t in pending_items:
pending_rows.append({
@ -586,6 +620,9 @@ def persist_queue_snapshot(reason: str = "") -> None:
"ts": utc_now_iso(),
"reason": reason,
"pending_count": len(pending_items), "running_count": len(running_items),
"reaping_count": reaping_count,
"worker_total": worker_total,
"assignable_idle_workers": assignable_idle_workers,
"pending": pending_rows, "running": running_rows,
}
try:
@ -901,6 +938,91 @@ def enforce_task_timeouts() -> None:
_enforce_task_timeouts_locked(workers, now, owner_chat_id, st)
def _is_descendant_of(task: Dict[str, Any], ancestor_id: str) -> bool:
"""True if `task` is in the subtree rooted at ancestor_id. Cheap in-memory (no I/O):
root_task_id == ancestor_id (covers the common root-orchestrator case even when an
INTERMEDIATE parent has already left RUNNING a grandchild whose parent finished is
still a descendant of the root), OR the parent_task_id chain (via RUNNING metas)
reaches ancestor_id (covers a mid-tree ancestor while the chain is intact).
"""
if not isinstance(task, dict) or not ancestor_id:
return False
if str(task.get("root_task_id") or "") == ancestor_id:
return True
cur = task
hops = 0
while isinstance(cur, dict) and hops < 25:
pid = str(cur.get("parent_task_id") or "")
if not pid:
return False
if pid == ancestor_id:
return True
nxt = RUNNING.get(pid)
cur = nxt.get("task") if isinstance(nxt, dict) and isinstance(nxt.get("task"), dict) else None
hops += 1
return False
def _subtree_progressing(task_id: str, now: float, idle_timeout: float) -> bool:
"""True if any RUNNING descendant of task_id made real progress within idle_timeout.
In-memory walk over RUNNING only (NO I/O this runs under the queue lock): keeps a
productively-waiting orchestrator alive while its children work, instead of a flat
wall-clock kill. Descendant freshness uses last_progress_at (real progress), not the
bare liveness heartbeat.
"""
if not task_id:
return False
for tid, m in list(RUNNING.items()):
if tid == task_id or not isinstance(m, dict):
continue
if not _is_descendant_of(m.get("task") if isinstance(m.get("task"), dict) else {}, task_id):
continue
# Real progress only (NOT the bare 30s liveness heartbeat): a child that merely
# pings but makes no progress must not keep its ancestor alive.
lp = float(m.get("last_progress_at") or m.get("started_at") or 0.0)
if lp and (now - lp) < idle_timeout:
return True
return False
def _has_live_descendant(task_id: str) -> bool:
"""True if any LIVE (RUNNING or PENDING) task is a descendant of task_id (in-memory, no
I/O). Used to recognise an orchestrator at kill time so it is NOT blind-retried a
blind retry would replay the plan and re-spawn the whole subtree (the timeout storm).
PENDING is included: a parent can time out while its children are merely QUEUED (worker
saturation / project lease), and those queued children are still its live subtree.
"""
if not task_id:
return False
for tid, m in list(RUNNING.items()):
if tid == task_id or not isinstance(m, dict):
continue
if _is_descendant_of(m.get("task") if isinstance(m.get("task"), dict) else {}, task_id):
return True
for t in list(PENDING):
if not isinstance(t, dict) or str(t.get("id") or "") == task_id:
continue
if _is_descendant_of(t, task_id):
return True
return False
def _has_pending_descendant(task_id: str) -> bool:
"""True if any PENDING (queued, not yet assigned) task is a descendant of task_id. A
parent whose children are merely WAITING for worker capacity (saturation / project lease)
is not idle/stuck keep it alive (bounded by the absolute ceiling) so it can integrate
them once they run, instead of killing it and orphaning the queued subtree."""
if not task_id:
return False
for t in list(PENDING):
if not isinstance(t, dict) or str(t.get("id") or "") == task_id:
continue
if _is_descendant_of(t, task_id):
return True
return False
def _enforce_task_timeouts_locked(
workers: Any, now: float, owner_chat_id: int, st: Dict[str, Any]
) -> None:
@ -924,10 +1046,44 @@ def _enforce_task_timeouts_locked(
attempt = int(_att) if _att is not None else 1
effective_soft = 3000 if task_type == "deep_self_review" else SOFT_TIMEOUT_SEC
effective_hard = 3600 if task_type == "deep_self_review" else HARD_TIMEOUT_SEC
deadline_ts = _task_deadline_ts(task)
deadline_reached = bool(deadline_ts and now >= deadline_ts)
hard_reached = runtime_sec >= effective_hard
# Activity-based liveness (owner decision + BIBLE P5): keep a task alive while it
# makes REAL progress (its own last_progress_at) OR has a progressing subtree —
# NOT merely while its 30s process heartbeat ticks. The flat BLANKET wall-clock
# (HARD_TIMEOUT_SEC) is gone so a productively-waiting orchestrator is never killed
# mid-flight. The HARD (unconditional, activity-independent) axes are: an explicit
# deadline_at (a deliberate cap — often a caller's timeout_sec, honored promptly even
# while progressing), the absolute ceiling, and the budget axis (enforced elsewhere).
# INVARIANT: idle_timeout MUST stay >= the per-call timeout ceiling. A single
# legitimate tool/LLM call can run up to that ceiling without emitting a
# between-rounds progress event (heartbeats are NOT progress, by design), so if
# idle fired below the ceiling a leaf making one long-but-real call would be killed
# mid-work. Keep this max() coupling on any future change to either knob.
idle_timeout = max(
float(get_task_idle_timeout_sec()),
float(get_per_call_timeout_ceiling_sec()) + 120.0,
)
# deep_self_review runs a single long 1M-context LLM call with NO intermediate
# progress events (no tool loop), so the idle timer governs it from started_at.
# Preserve its prior ~60min tolerance (the retired effective_hard=3600) so a
# legitimately long review is not idle-killed mid-call.
if task_type == "deep_self_review":
idle_timeout = max(idle_timeout, 3600.0)
abs_ceiling = float(get_task_abs_ceiling_sec())
# "Real progress" only — NOT the unconditional 30s liveness heartbeat (which would
# keep a wedged-before-first-round task alive). A task that has never made real
# progress is measured from started_at.
last_progress_at = float(meta.get("last_progress_at") or started_at)
idle_sec = max(0.0, now - last_progress_at)
subtree_progressing = _subtree_progressing(task_id, now, idle_timeout)
# Keep an orchestrator alive while it (a) makes own progress, (b) has a freshly
# progressing RUNNING descendant, OR (c) has a QUEUED descendant still waiting for a
# worker — killing it then would orphan the queued subtree. Only the abs ceiling /
# explicit deadline / budget are unconditional.
progressing = idle_sec < idle_timeout or subtree_progressing or _has_pending_descendant(task_id)
ceiling_reached = runtime_sec >= abs_ceiling
if runtime_sec >= effective_soft and not bool(meta.get("soft_sent")):
meta["soft_sent"] = True
@ -935,13 +1091,22 @@ def _enforce_task_timeouts_locked(
send_with_budget(
owner_chat_id,
f"⏱️ Task {task_id} running for {int(runtime_sec)}s. "
f"type={task_type}, heartbeat_lag={int(hb_lag_sec)}s. Continuing.",
f"type={task_type}, heartbeat_lag={int(hb_lag_sec)}s, idle={int(idle_sec)}s. Continuing.",
)
if not deadline_reached and not hard_reached:
# Hard axes (deadline_at, abs ceiling) stop the task regardless of activity; the
# idle/subtree gate only spares a task that has NO explicit deadline and is still
# progressing. This honors an explicit/caller deadline promptly while never letting
# the removed blanket wall-clock kill a productively-waiting orchestrator.
if not ceiling_reached and not deadline_reached and progressing:
continue
terminal_reason = "deadline" if deadline_reached else "hard_timeout"
if ceiling_reached:
terminal_reason = "absolute_ceiling"
elif deadline_reached:
terminal_reason = "deadline"
else:
terminal_reason = "idle_timeout"
finalization_requested_at = float(meta.get("finalization_requested_at") or 0.0)
if finalization_requested_at <= 0 and FINALIZATION_GRACE_SEC > 0:
meta["finalization_requested_at"] = now
@ -981,193 +1146,71 @@ def _enforce_task_timeouts_locked(
if finalization_requested_at > 0 and now - finalization_requested_at < FINALIZATION_GRACE_SEC:
continue
RUNNING.pop(task_id, None)
if worker_id in workers.WORKERS and workers.WORKERS[worker_id].busy_task_id == task_id:
workers.WORKERS[worker_id].busy_task_id = None
# NOTE: the "worker self-finalized at the idle boundary" case is handled by the
# reaper's POST-KILL terminal re-check (which kills+joins the process FIRST, then
# honors an on-disk terminal result and emits an idempotent task_done). We do NOT
# short-circuit here: freeing the slot inline without killing the still-possibly-
# running process would let assign_tasks reuse it mid-flight and could drop the
# terminal event, leaving the live card unresolved.
# Variant A: hand the ENTIRE teardown to the background reaper so the loop tick
# stays fast AND — critically — the terminal result write + retry enqueue happen
# only AFTER the reaper has killed/joined the old process (a still-alive worker can
# no longer race a concurrently-assigned retry; for a subagent the retry reuses the
# same id/drive). Decisions that need live RUNNING state (orchestrator -> no blind
# retry; the retry id) are frozen HERE under the lock and passed in the job.
RUNNING.pop(task_id, None)
proc_handle = None
if worker_id in workers.WORKERS:
w = workers.WORKERS[worker_id]
try:
from ouroboros.platform_layer import kill_pid_tree
# Spare deliberately-kept services (this task's + earlier pooled
# tasks') so a hard-timeout kill leaves verifier-facing services
# alive; they reparent to init and the custody reaper governs them.
_keep = _kept_service_pids()
if w.proc.pid:
kill_pid_tree(w.proc.pid, exclude_pids=_keep)
elif w.proc.is_alive():
w.proc.terminate()
w.proc.join(timeout=5)
if w.proc.is_alive() and w.proc.pid:
kill_pid_tree(w.proc.pid, exclude_pids=_keep)
w.proc.join(timeout=2)
except Exception:
log.warning("Failed to terminate worker %d during hard timeout", worker_id, exc_info=True)
try:
from ouroboros.tools.services import archive_task_service_logs
archive_task_service_logs(pathlib.Path(DRIVE_ROOT), str(task_id), task)
except Exception:
log.debug("Failed to archive service logs for timed-out task %s", task_id, exc_info=True)
workers.respawn_worker(worker_id)
if w.busy_task_id == task_id:
w.busy_task_id = None
# Mark reaping under the lock so assign_tasks and the crash detector both skip
# this slot until the reaper installs a fresh worker.
w.reaping = True
proc_handle = w.proc
# Reconstruct real cost/rounds from durable llm_usage before writing the
# rollup/terminal event: the killed worker never finalized, so the event
# would otherwise carry zeros and understate per-task + campaign metrics.
recon_cost, recon_rounds, recon_prompt, recon_completion = reconstruct_task_cost(str(task_id))
# Salvage the last persisted assistant text (read-only, from the task's
# ACTIVE drive) so a hard kill surfaces real progress, not emptiness.
salvage_note = ""
try:
from ouroboros.observability import latest_llm_response_text
salvaged = latest_llm_response_text(_task_drive_for_task(task, str(task_id)), str(task_id))
if salvaged:
salvage_note = ("\n\nLast agent output (salvaged best-effort, unreviewed):\n"
+ truncate_review_artifact(salvaged, 4000))
except Exception:
log.debug("Failed to salvage last LLM response for %s", task_id, exc_info=True)
# A hard-killed worker never reaches the loop's mailbox cleanup, leaking
# the finalize_now control file. Remove it unconditionally: a subagent
# retry reuses the same task id and drive, and a stale finalize_now
# would instantly force-finalize the fresh attempt.
try:
from ouroboros.owner_mailbox import cleanup_task_mailbox
cleanup_task_mailbox(_task_drive_for_task(task, str(task_id)), str(task_id))
except Exception:
log.debug("Failed to clean owner mailbox for killed task %s", task_id, exc_info=True)
will_retry = attempt <= QUEUE_MAX_RETRIES and isinstance(task, dict) and not deadline_reached
# A stopped evolution campaign breaks the auto-retry chain: a hard-timeout
# kill of an evolution task must not silently re-enqueue another cycle
# after /evolve stop. `st` is the live state loaded at the top of this tick
# (not cached), so this reflects the current owner decision.
# NOTE: the "no blind retry of an orchestrator with live descendants" guarantee is
# TIMEOUT-REAPING-specific (this path). The worker-CRASH path
# (workers._ensure_workers_healthy_locked) has its own signal-vs-attempt retry
# semantics and is intentionally not gated here; a crashed-orchestrator storm is a
# separate, rarer concern than the flat-wall-clock timeout storm this batch targets.
orchestrator = _has_live_descendant(task_id)
will_retry = (
attempt <= QUEUE_MAX_RETRIES
and isinstance(task, dict)
and not deadline_reached
and not ceiling_reached
and not orchestrator
)
# A stopped evolution campaign breaks the auto-retry chain. `st` is the live state
# loaded this tick, so this reflects the current owner decision.
if will_retry and task_type == "evolution" and not bool(st.get("evolution_mode_enabled")):
will_retry = False
retry_task_id = ""
if will_retry:
retry_task_id = task_id if str(task.get("delegation_role") or "") == "subagent" else uuid.uuid4().hex[:8]
try:
from ouroboros.task_results import STATUS_FAILED, STATUS_INTERRUPTED, STATUS_SCHEDULED, write_task_result
write_task_result(
DRIVE_ROOT,
task_id,
STATUS_INTERRUPTED if will_retry else STATUS_FAILED,
reason_code=f"{terminal_reason}_retry" if will_retry else terminal_reason,
outcome_axes=terminal_outcome_axes(
lifecycle=STATUS_INTERRUPTED if will_retry else STATUS_FAILED,
execution=EXECUTION_INFRA_FAILED,
reason_code=f"{terminal_reason}_retry" if will_retry else terminal_reason,
review_trigger="supervisor_terminal",
),
superseded_by=retry_task_id if retry_task_id and retry_task_id != task_id else "",
retry_task_id=retry_task_id if retry_task_id else "",
cost_usd=recon_cost,
total_rounds=recon_rounds,
prompt_tokens=recon_prompt,
completion_tokens=recon_completion,
result=(
f"Task killed by {terminal_reason} after {int(runtime_sec)}s. Retrying."
if will_retry
else f"Task killed by {terminal_reason} after {int(runtime_sec)}s.{salvage_note}"
),
)
if will_retry and retry_task_id and retry_task_id != task_id:
write_task_result(
DRIVE_ROOT,
retry_task_id,
STATUS_SCHEDULED,
reason_code=f"{terminal_reason}_retry_scheduled",
outcome_axes=terminal_outcome_axes(
lifecycle=STATUS_SCHEDULED,
execution="pending",
reason_code=f"{terminal_reason}_retry_scheduled",
review_trigger="supervisor_terminal",
),
supersedes_task_id=task_id,
original_task_id=task_id,
result=f"Retry scheduled after {terminal_reason}.",
parent_task_id=task.get("parent_task_id"),
root_task_id=task.get("root_task_id") or task_id,
description=task.get("description"),
context=task.get("context"),
workspace_root=task.get("workspace_root"),
workspace_mode=task.get("workspace_mode"),
memory_mode=task.get("memory_mode"),
metadata=task.get("metadata") if isinstance(task.get("metadata"), dict) else {},
)
except Exception:
pass
requeued = False
new_attempt = attempt
if will_retry:
retried = dict(task)
retried["original_task_id"] = task_id
retried["id"] = retry_task_id or task_id
retried["_attempt"] = attempt + 1
retried["timeout_retry_from"] = task_id
retried["timeout_retry_at"] = utc_now_iso()
enqueue_task(retried, front=True)
requeued = True
new_attempt = attempt + 1
append_jsonl(
DRIVE_ROOT / "logs" / "supervisor.jsonl",
{
"ts": utc_now_iso(),
"type": "task_terminal_timeout",
"task_id": task_id, "task_type": task_type,
"reason": terminal_reason,
"worker_id": worker_id, "runtime_sec": round(runtime_sec, 2),
"heartbeat_lag_sec": round(hb_lag_sec, 2), "heartbeat_stale": hb_stale,
"attempt": attempt, "requeued": requeued, "new_attempt": new_attempt,
"max_retries": QUEUE_MAX_RETRIES,
},
)
if owner_chat_id:
if requeued:
send_with_budget(owner_chat_id, (
f"🛑 {terminal_reason}: task {task_id} killed after {int(runtime_sec)}s.\n"
f"Worker {worker_id} restarted. Task queued for retry attempt={new_attempt}."
))
else:
stop_detail = (
"Absolute deadline reached; task stopped."
if deadline_reached
else "Retry limit exhausted, task stopped."
)
send_with_budget(owner_chat_id, (
f"🛑 {terminal_reason}: task {task_id} killed after {int(runtime_sec)}s.\n"
f"Worker {worker_id} restarted. {stop_detail}"
))
# When the task is terminally stopped (no retry), emit task_done so the
# UI live card resolves instead of spinning forever. A retry keeps the
# card active under the same (subagent) id or a superseding id.
if not requeued:
try:
done_chat_id = int(task.get("chat_id") or 0) if isinstance(task, dict) else 0
if done_chat_id:
workers.get_event_q().put({
"type": "task_done",
"task_id": str(task_id),
"task_type": task_type,
"chat_id": done_chat_id,
"status": "failed",
"reason_code": terminal_reason,
"outcome_axes": terminal_outcome_axes(lifecycle="failed", execution=EXECUTION_INFRA_FAILED, reason_code=terminal_reason, review_trigger="supervisor_terminal"),
"cost_usd": recon_cost,
"total_rounds": recon_rounds,
"prompt_tokens": recon_prompt,
"completion_tokens": recon_completion,
"metadata": task.get("metadata") if isinstance(task.get("metadata"), dict) else {},
})
except Exception:
log.debug("Failed to emit task_done for hard-timeout task %s", task_id, exc_info=True)
persist_queue_snapshot(reason="task_hard_timeout")
_ensure_reaper_started()
_reap_queue.put({
"worker_id": worker_id,
"proc": proc_handle,
"task_id": str(task_id),
"task": task,
"task_type": task_type,
"terminal_reason": terminal_reason,
"attempt": attempt,
"owner_chat_id": owner_chat_id,
"runtime_sec": runtime_sec,
"hb_lag_sec": hb_lag_sec,
"hb_stale": hb_stale,
"deadline_reached": deadline_reached,
"ceiling_reached": ceiling_reached,
"orchestrator": orchestrator,
"will_retry": will_retry,
"retry_task_id": retry_task_id,
})
persist_queue_snapshot(reason="task_timeout_reap_queued")

377
supervisor/task_reaper.py Normal file
View file

@ -0,0 +1,377 @@
"""Variant A: off-loop worker reaper (extracted from supervisor/queue.py for module size).
The supervisor loop must stay responsive (<100ms ticks), so a timed-out task's heaviest
teardown process kill + join (up to ~5s) + service-log archive + worker respawn (process
spawn) runs on a single-owner background reaper thread instead of inline under the queue
lock. The loop only marks the worker ``reaping`` (so assign_tasks/crash-detector skip the
slot) and hands a fully-decided job here; ``supervisor.queue`` re-exports the thin names
(``_reap_queue`` / ``_ensure_reaper_started`` / ``_reap_timed_out_task``).
"""
from __future__ import annotations
import logging
import pathlib
import queue as _stdqueue
import threading
from typing import Any, Dict, Optional
from ouroboros.outcomes import EXECUTION_INFRA_FAILED, terminal_outcome_axes
from ouroboros.utils import append_jsonl, truncate_review_artifact, utc_now_iso
from supervisor.message_bus import send_with_budget
log = logging.getLogger(__name__)
reap_queue: "_stdqueue.Queue[Dict[str, Any]]" = _stdqueue.Queue()
_reaper_thread: "Optional[threading.Thread]" = None
_reaper_start_lock = threading.Lock()
def reaper_loop() -> None:
while True:
try:
job = reap_queue.get()
except Exception:
continue
try:
reap_timed_out_task(job)
except Exception:
log.error("Reaper failed for task %s", (job or {}).get("task_id"), exc_info=True)
# Self-heal: an escape BEFORE the guarded teardown (e.g. the top-of-function
# imports / variable extraction) must not strand the slot at reaping=True forever —
# the crash detector skips reaping slots, so it would be unrecoverable until restart.
# Clear reaping (the same conservative recovery step 5 uses) so a later tick reclaims
# it; do NOT respawn here (an early escape may have left the original worker alive).
try:
from supervisor import workers as _w_mod
from supervisor.queue import _queue_lock as _ql
_wid_raw = (job or {}).get("worker_id")
if _wid_raw is not None:
with _ql:
_w = _w_mod.WORKERS.get(int(_wid_raw))
if _w is not None:
_w.reaping = False
except Exception:
log.debug("Reaper: self-heal reaping-clear failed", exc_info=True)
finally:
try:
reap_queue.task_done()
except Exception:
pass
def ensure_reaper_started() -> None:
"""Start the reaper thread, or RESTART it if it ever died — otherwise a dead reaper
would strand every ``reaping=True`` slot forever (assign skips it, no one respawns it)."""
global _reaper_thread
t = _reaper_thread
if t is not None and t.is_alive():
return
with _reaper_start_lock:
t = _reaper_thread
if t is not None and t.is_alive():
return
if t is not None:
log.warning("Task reaper thread had died; restarting it.")
_reaper_thread = threading.Thread(target=reaper_loop, name="task-reaper", daemon=True)
_reaper_thread.start()
def reap_timed_out_task(job: Dict[str, Any]) -> None:
"""Full teardown for a timed-out task, run OFF the supervisor loop (Variant A).
Order is load-bearing for correctness: kill+join the worker process FIRST, then decide
the terminal write + retry. Because the original process is provably dead before the
retry is enqueued, a still-alive worker can never race a concurrently-assigned retry
(which, for a subagent, reuses the same task id/drive). A POST-KILL already-terminal
re-check honors a worker that self-finalized at the idle boundary instead of clobbering
its result or running a duplicate. The loop already popped RUNNING/cleared busy_task_id
and marked the slot ``reaping`` under the lock; respawn_worker installs a fresh
reaping=False Worker, re-opening the slot.
"""
from supervisor import queue as _q
from supervisor import workers as workers_mod
worker_id = int(job.get("worker_id")) if job.get("worker_id") is not None else -1
proc = job.get("proc")
task_id = str(job.get("task_id") or "")
task = job.get("task") if isinstance(job.get("task"), dict) else {}
task_type = str(job.get("task_type") or "")
terminal_reason = str(job.get("terminal_reason") or "idle_timeout")
attempt = int(job.get("attempt") or 1)
owner_chat_id = int(job.get("owner_chat_id") or 0)
runtime_sec = float(job.get("runtime_sec") or 0.0)
hb_lag_sec = float(job.get("hb_lag_sec") or 0.0)
hb_stale = bool(job.get("hb_stale"))
deadline_reached = bool(job.get("deadline_reached"))
ceiling_reached = bool(job.get("ceiling_reached"))
orchestrator = bool(job.get("orchestrator"))
will_retry = bool(job.get("will_retry"))
retry_task_id = str(job.get("retry_task_id") or "")
# 1. Kill + join the worker process FIRST (off-lock).
try:
from ouroboros.platform_layer import kill_pid_tree
# Spare deliberately-kept services so a timeout kill leaves verifier-facing
# services alive; they reparent to init and the custody reaper governs them.
_keep = _q._kept_service_pids()
if proc is not None:
if getattr(proc, "pid", None):
kill_pid_tree(proc.pid, exclude_pids=_keep)
elif proc.is_alive():
proc.terminate()
proc.join(timeout=5)
if proc.is_alive() and getattr(proc, "pid", None):
kill_pid_tree(proc.pid, exclude_pids=_keep)
proc.join(timeout=2)
except Exception:
log.warning("Reaper: failed to terminate worker %d for task %s", worker_id, task_id, exc_info=True)
try:
from ouroboros.tools.services import archive_task_service_logs
archive_task_service_logs(pathlib.Path(_q.DRIVE_ROOT), task_id, task)
except Exception:
log.debug("Reaper: failed to archive service logs for %s", task_id, exc_info=True)
from ouroboros.task_results import (
STATUS_FAILED,
STATUS_INTERRUPTED,
STATUS_SCHEDULED,
_TRULY_TERMINAL_STATUSES,
load_task_result,
write_task_result,
)
# 2. POST-KILL already-terminal re-check: the worker may have self-finalized right at
# the boundary. The process is dead now, so this decision is final.
self_status = ""
_existing = None
try:
_existing = load_task_result(_q.DRIVE_ROOT, task_id)
if _existing and str(_existing.get("status") or "") in _TRULY_TERMINAL_STATUSES:
self_status = str(_existing.get("status") or "")
except Exception:
log.debug("Reaper: post-kill terminal re-check failed for %s", task_id, exc_info=True)
# Forked/workspace/subagent tasks self-finalize on the CHILD drive and are copied back
# only on task_done; a worker that died after writing its child result but before
# copy-back would be missed by the parent-drive check above. Mirror the child result
# back and honor it (no interrupted/failed clobber, no duplicate retry).
if not self_status:
try:
from ouroboros.headless import copy_child_task_result
_child = copy_child_task_result(pathlib.Path(_q.DRIVE_ROOT), task)
if _child and str(_child.get("status") or "") in _TRULY_TERMINAL_STATUSES:
_existing = _child
self_status = str(_child.get("status") or "")
except Exception:
log.debug("Reaper: child-drive terminal re-check failed for %s", task_id, exc_info=True)
if self_status:
# A mirrored child result (copy_child_task_result above sets artifact_status to
# 'finalizing' for workspace tasks) still needs the artifact finalization the normal
# task_done path runs in _handle_task_done. The reaper already terminalized the task,
# so it is no longer in RUNNING and that path finds no task to finalize — complete it
# here. Rescue ONLY a stuck non-terminal artifact state: re-running finalize on an
# already-terminal result can regress it to FAILED (e.g. the workspace was cleaned
# up). Readonly subagents have no durable artifacts and are skipped (shared gate).
try:
from ouroboros.headless import (
ARTIFACT_STATUS_FINALIZING,
ARTIFACT_STATUS_PENDING,
finalize_task_artifacts,
task_is_readonly_subagent,
)
_art = str((_existing or {}).get("artifact_status") or "").strip().lower()
if _art in {ARTIFACT_STATUS_PENDING, ARTIFACT_STATUS_FINALIZING} and not task_is_readonly_subagent(task):
finalize_task_artifacts(pathlib.Path(_q.DRIVE_ROOT), task)
except Exception:
log.debug("Reaper: artifact finalize for self-finalized %s failed", task_id, exc_info=True)
# Honor the worker's own terminal result — do NOT clobber it or enqueue a retry.
# The worker may have died before emitting its task_done (and the crash detector
# now skips reaping slots), so emit an idempotent task_done so the UI card resolves.
try:
done_chat_id = int(task.get("chat_id") or 0) if isinstance(task, dict) else 0
if done_chat_id:
workers_mod.get_event_q().put({
"type": "task_done", "task_id": task_id, "task_type": task_type,
"chat_id": done_chat_id, "status": self_status,
"reason_code": str((_existing or {}).get("reason_code") or ""),
})
except Exception:
log.debug("Reaper: failed to emit task_done for self-finalized %s", task_id, exc_info=True)
else:
# 3. Reconstruct real cost/rounds from durable llm_usage (the killed worker never
# finalized; the event would otherwise carry zeros and understate metrics).
# Guarded like every other sub-step so a failure here can never abort the reaper
# before step 5 (respawn) and strand the slot at reaping=True.
try:
recon_cost, recon_rounds, recon_prompt, recon_completion = _q.reconstruct_task_cost(task_id)
except Exception:
log.debug("Reaper: reconstruct_task_cost failed for %s", task_id, exc_info=True)
recon_cost, recon_rounds, recon_prompt, recon_completion = 0.0, 0, 0, 0
# Salvage the last persisted assistant text so a hard kill surfaces real progress.
salvage_note = ""
try:
from ouroboros.observability import latest_llm_response_text
salvaged = latest_llm_response_text(_q._task_drive_for_task(task, task_id), task_id)
if salvaged:
salvage_note = ("\n\nLast agent output (salvaged best-effort, unreviewed):\n"
+ truncate_review_artifact(salvaged, 4000))
except Exception:
log.debug("Reaper: failed to salvage last LLM response for %s", task_id, exc_info=True)
# A killed worker never reaches the loop's mailbox cleanup — remove the finalize_now
# control so a subagent retry (same id/drive) is not instantly force-finalized.
try:
from ouroboros.owner_mailbox import cleanup_task_mailbox
cleanup_task_mailbox(_q._task_drive_for_task(task, task_id), task_id)
except Exception:
log.debug("Reaper: failed to clean owner mailbox for killed task %s", task_id, exc_info=True)
try:
write_task_result(
_q.DRIVE_ROOT, task_id,
STATUS_INTERRUPTED if will_retry else STATUS_FAILED,
reason_code=f"{terminal_reason}_retry" if will_retry else terminal_reason,
outcome_axes=terminal_outcome_axes(
lifecycle=STATUS_INTERRUPTED if will_retry else STATUS_FAILED,
execution=EXECUTION_INFRA_FAILED,
reason_code=f"{terminal_reason}_retry" if will_retry else terminal_reason,
review_trigger="supervisor_terminal",
),
superseded_by=retry_task_id if retry_task_id and retry_task_id != task_id else "",
retry_task_id=retry_task_id if retry_task_id else "",
cost_usd=recon_cost, total_rounds=recon_rounds,
prompt_tokens=recon_prompt, completion_tokens=recon_completion,
result=(
f"Task killed by {terminal_reason} after {int(runtime_sec)}s. Retrying."
if will_retry
else f"Task killed by {terminal_reason} after {int(runtime_sec)}s.{salvage_note}"
),
)
if will_retry and retry_task_id and retry_task_id != task_id:
write_task_result(
_q.DRIVE_ROOT, retry_task_id, STATUS_SCHEDULED,
reason_code=f"{terminal_reason}_retry_scheduled",
outcome_axes=terminal_outcome_axes(
lifecycle=STATUS_SCHEDULED, execution="pending",
reason_code=f"{terminal_reason}_retry_scheduled",
review_trigger="supervisor_terminal",
),
supersedes_task_id=task_id, original_task_id=task_id,
result=f"Retry scheduled after {terminal_reason}.",
parent_task_id=task.get("parent_task_id"),
root_task_id=task.get("root_task_id") or task_id,
description=task.get("description"), context=task.get("context"),
workspace_root=task.get("workspace_root"), workspace_mode=task.get("workspace_mode"),
memory_mode=task.get("memory_mode"),
metadata=task.get("metadata") if isinstance(task.get("metadata"), dict) else {},
)
except Exception:
log.debug("Reaper: failed to write terminal result for %s", task_id, exc_info=True)
# 4. Enqueue the retry ONLY now (original is dead) — no concurrent execution.
# Guarded so an enqueue failure cannot abort the reaper before respawn.
requeued = False
new_attempt = attempt
if will_retry:
try:
retried = dict(task)
retried["original_task_id"] = task_id
retried["id"] = retry_task_id or task_id
retried["_attempt"] = attempt + 1
retried["timeout_retry_from"] = task_id
retried["timeout_retry_at"] = utc_now_iso()
_q.enqueue_task(retried, front=True)
requeued = True
new_attempt = attempt + 1
except Exception:
log.warning("Reaper: failed to enqueue retry for %s", task_id, exc_info=True)
try:
append_jsonl(
_q.DRIVE_ROOT / "logs" / "supervisor.jsonl",
{
"ts": utc_now_iso(), "type": "task_terminal_timeout",
"task_id": task_id, "task_type": task_type, "reason": terminal_reason,
"worker_id": worker_id, "runtime_sec": round(runtime_sec, 2),
"heartbeat_lag_sec": round(hb_lag_sec, 2), "heartbeat_stale": hb_stale,
"attempt": attempt, "requeued": requeued, "new_attempt": new_attempt,
"max_retries": _q.QUEUE_MAX_RETRIES, "reaped_off_loop": True,
},
)
except Exception:
log.debug("Reaper: failed to log task_terminal_timeout for %s", task_id, exc_info=True)
# Guarded: a notification failure (e.g. a torn-down bus during shutdown) must NOT
# abort the reaper before respawn, or the slot would stay reaping=True forever.
if owner_chat_id:
try:
if requeued:
send_with_budget(owner_chat_id, (
f"🛑 {terminal_reason}: task {task_id} killed after {int(runtime_sec)}s.\n"
f"Worker {worker_id} restarted. Task queued for retry attempt={new_attempt}."
))
else:
if ceiling_reached:
stop_detail = "Absolute ceiling reached; task stopped."
elif deadline_reached:
stop_detail = "Absolute deadline reached; task stopped."
elif orchestrator:
stop_detail = ("Idle with live children (orchestrator); stopped without a "
"blind retry to avoid replaying the subtree.")
else:
stop_detail = "Retry limit exhausted, task stopped."
send_with_budget(owner_chat_id, (
f"🛑 {terminal_reason}: task {task_id} killed after {int(runtime_sec)}s.\n"
f"Worker {worker_id} restarted. {stop_detail}"
))
except Exception:
log.debug("Reaper: failed to send owner notification for %s", task_id, exc_info=True)
if not requeued:
try:
done_chat_id = int(task.get("chat_id") or 0) if isinstance(task, dict) else 0
if done_chat_id:
workers_mod.get_event_q().put({
"type": "task_done", "task_id": task_id, "task_type": task_type,
"chat_id": done_chat_id, "status": "failed", "reason_code": terminal_reason,
"outcome_axes": terminal_outcome_axes(lifecycle="failed", execution=EXECUTION_INFRA_FAILED, reason_code=terminal_reason, review_trigger="supervisor_terminal"),
"cost_usd": recon_cost, "total_rounds": recon_rounds,
"prompt_tokens": recon_prompt, "completion_tokens": recon_completion,
"metadata": task.get("metadata") if isinstance(task.get("metadata"), dict) else {},
})
except Exception:
log.debug("Reaper: failed to emit task_done for %s", task_id, exc_info=True)
# 5. Respawn a fresh worker for the slot; on failure, CLEAR reaping so the crash detector
# can recover the slot on a later tick instead of stranding it permanently.
# Hold _queue_lock across the membership check AND the respawn so it is mutually
# exclusive with kill_workers (which clears WORKERS under the same lock at shutdown).
# Otherwise the reaper could pass the check, start a replacement process, and insert it
# into WORKERS only AFTER shutdown cleanup already cleared the pool — an orphan worker
# surviving shutdown. _queue_lock is an RLock and respawn_worker re-acquires it
# internally, so taking it here is safe (and a cleared pool makes the check fail closed).
try:
with _q._queue_lock:
if worker_id in workers_mod.WORKERS:
workers_mod.respawn_worker(worker_id)
except Exception:
log.warning("Reaper: respawn failed for worker %d; clearing reaping for recovery", worker_id, exc_info=True)
try:
with _q._queue_lock:
_w = workers_mod.WORKERS.get(worker_id)
if _w is not None:
_w.reaping = False
except Exception:
pass
try:
_q.persist_queue_snapshot(reason="worker_respawn_after_reap")
except Exception:
log.debug("Reaper: failed to persist queue snapshot after respawn", exc_info=True)

View file

@ -85,6 +85,10 @@ class Worker:
proc: mp.Process
in_q: Any
busy_task_id: Optional[str] = None
# Variant A (off-loop reaping): set under _queue_lock when a timed-out task's heavy
# teardown (kill/join/archive/respawn) is handed to the background reaper. The slot
# is unavailable for assignment until respawn_worker() installs a fresh Worker.
reaping: bool = False
_EVENT_Q = None
@ -1199,7 +1203,7 @@ def assign_tasks() -> None:
from ouroboros.project_lease import candidate_is_leasable, running_project_ids
for w in WORKERS.values():
if w.busy_task_id is None and PENDING:
if w.busy_task_id is None and not getattr(w, "reaping", False) and PENDING:
# One-writer-per-project lease: recompute per assignment so a
# task assigned in THIS loop pass immediately occupies its lane.
leased = running_project_ids(RUNNING.values())
@ -1301,6 +1305,12 @@ def _ensure_workers_healthy_locked(queue: Any) -> None:
dead_detections = 0
crashed_tasks = []
for wid, w in list(WORKERS.items()):
# Variant A: a slot marked `reaping` is owned end-to-end by the background reaper
# (kill -> join -> archive -> respawn). Its proc is expected to die mid-reap, so the
# crash detector must NOT also respawn it — that double-respawn would orphan a live
# worker process. The reaper installs a fresh Worker (reaping=False) when done.
if getattr(w, "reaping", False):
continue
if not w.proc.is_alive():
dead_detections += 1
if w.busy_task_id is not None:

View file

@ -63,6 +63,8 @@ def test_cancel_running_evolution_tasks_cancels_only_evolution(monkeypatch):
def _drive_hard_timeout(tmp_path, monkeypatch, *, evolution_enabled):
"""Drive enforce_task_timeouts for a single overdue evolution task and return
(enqueued, emitted_events, written_result)."""
import time
import supervisor.queue as q
import supervisor.state as state
from supervisor import workers as workers_mod
@ -71,6 +73,13 @@ def _drive_hard_timeout(tmp_path, monkeypatch, *, evolution_enabled):
monkeypatch.setattr(q, "DRIVE_ROOT", tmp_path)
monkeypatch.setattr(state, "DRIVE_ROOT", tmp_path)
monkeypatch.setattr(q, "FINALIZATION_GRACE_SEC", 0)
# Activity model: drive an IDLE kill (no progress for the idle window), not a flat
# wall-clock/ceiling kill — small idle/ceiling getters + a recent started_at keep
# terminal_reason == "idle_timeout" so the evolution retry path is exercised.
monkeypatch.setattr(q, "get_task_idle_timeout_sec", lambda: 1)
monkeypatch.setattr(q, "get_per_call_timeout_ceiling_sec", lambda: 1)
monkeypatch.setattr(q, "_ensure_reaper_started", lambda: None)
monkeypatch.setattr(q, "_reap_queue", q._stdqueue.Queue())
# 3 llm_usage rounds totalling $1.50 are already durable before the kill.
_write_events(tmp_path, [
{"type": "llm_usage", "task_id": "evo1", "cost": 0.5, "prompt_tokens": 10, "completion_tokens": 2}
@ -79,7 +88,7 @@ def _drive_hard_timeout(tmp_path, monkeypatch, *, evolution_enabled):
task = {"id": "evo1", "type": "evolution", "chat_id": 7, "_attempt": 1, "metadata": {}}
monkeypatch.setattr(q, "RUNNING", {
"evo1": {"task": task, "started_at": 1.0, "worker_id": 0, "attempt": 1},
"evo1": {"task": task, "started_at": time.time() - 1000, "worker_id": 0, "attempt": 1},
})
class _FakeProc:
@ -104,6 +113,9 @@ def _drive_hard_timeout(tmp_path, monkeypatch, *, evolution_enabled):
monkeypatch.setattr(q, "load_state", lambda: {"evolution_mode_enabled": evolution_enabled, "owner_chat_id": 0})
q.enforce_task_timeouts()
# Variant A: terminal write + retry happen in the off-loop reaper; drain it here.
while not q._reap_queue.empty():
q._reap_timed_out_task(q._reap_queue.get_nowait())
from ouroboros.task_results import load_task_result
written = load_task_result(tmp_path, "evo1") or {}

View file

@ -191,3 +191,12 @@ class TestStatusSetSSOT:
from ouroboros.task_status import SETTLED_STATUSES
assert _FINAL_STATUSES == SETTLED_STATUSES
def test_headless_readonly_subagent_mode_mirrors_capability_ssot(self):
# Same anti-drift pin: the literal headless uses to detect a read-only subagent
# (it cannot import tool_capabilities at module level) must equal the SSOT constant
# that the registry/supervisor enforce.
from ouroboros.headless import _LOCAL_READONLY_SUBAGENT_MODE
from ouroboros.tool_capabilities import LOCAL_READONLY_SUBAGENT_MODE
assert _LOCAL_READONLY_SUBAGENT_MODE == LOCAL_READONLY_SUBAGENT_MODE

View file

@ -0,0 +1,284 @@
"""Phase 1 golden coverage: honest runtime digest, activity-based timeout model,
no-blind-retry of orchestrators, and the task-tree coordination ledger."""
import time
from types import SimpleNamespace
def _patch_queue(queue_module, workers_module, monkeypatch, tmp_path, workers):
monkeypatch.setattr(queue_module, "DRIVE_ROOT", tmp_path)
monkeypatch.setattr(queue_module, "PENDING", [])
monkeypatch.setattr(queue_module, "RUNNING", {})
monkeypatch.setattr(queue_module, "FINALIZATION_GRACE_SEC", 0)
monkeypatch.setattr(queue_module, "QUEUE_MAX_RETRIES", 1)
monkeypatch.setattr(queue_module, "load_state", lambda: {})
monkeypatch.setattr(queue_module, "append_jsonl", lambda *a, **k: None)
monkeypatch.setattr(queue_module, "persist_queue_snapshot", lambda reason="": None)
monkeypatch.setattr(queue_module, "_ensure_reaper_started", lambda: None)
monkeypatch.setattr(queue_module, "_reap_queue", queue_module._stdqueue.Queue())
monkeypatch.setattr(workers_module, "WORKERS", workers)
monkeypatch.setattr(workers_module, "respawn_worker", lambda worker_id: None)
class _FakeProc:
pid = 0
def is_alive(self):
return False
def join(self, timeout=None):
return None
def test_progressing_subtree_keeps_idle_parent_alive(tmp_path, monkeypatch):
"""The core fix: a parent that is idle ITSELF but whose subtree is progressing must
NOT be killed (no flat wall-clock guillotine on a productively-waiting orchestrator)."""
from supervisor import queue as q
from supervisor import workers as w
workers = {1: SimpleNamespace(busy_task_id="root1", proc=_FakeProc(), reaping=False),
2: SimpleNamespace(busy_task_id="child1", proc=_FakeProc(), reaping=False)}
_patch_queue(q, w, monkeypatch, tmp_path, workers)
now = time.time()
q.RUNNING["root1"] = {
"task": {"id": "root1", "type": "task"},
"started_at": now - 5000, "last_heartbeat_at": now - 5000, # idle itself
"worker_id": 1, "attempt": 1,
}
q.RUNNING["child1"] = {
"task": {"id": "child1", "type": "task", "parent_task_id": "root1", "delegation_role": "subagent"},
"started_at": now - 60, "last_progress_at": now - 2, # fresh real progress
"last_heartbeat_at": now - 2, "worker_id": 2, "attempt": 1,
}
q.enforce_task_timeouts()
assert "root1" in q.RUNNING, "idle parent with a progressing subtree must survive"
assert "child1" in q.RUNNING
assert q.PENDING == []
def test_idle_parent_with_pending_descendant_survives(tmp_path, monkeypatch):
"""A parent that is idle ITSELF but has a QUEUED (PENDING, worker-saturated) descendant
must NOT be killed killing it would orphan the queued subtree before it ever ran."""
from supervisor import queue as q
from supervisor import workers as w
workers = {1: SimpleNamespace(busy_task_id="root1", proc=_FakeProc(), reaping=False)}
_patch_queue(q, w, monkeypatch, tmp_path, workers)
monkeypatch.setattr(q, "get_task_idle_timeout_sec", lambda: 1)
monkeypatch.setattr(q, "get_per_call_timeout_ceiling_sec", lambda: 1)
monkeypatch.setattr(q, "PENDING", [{"id": "child1", "parent_task_id": "root1", "root_task_id": "root1"}])
now = time.time()
q.RUNNING["root1"] = {
"task": {"id": "root1", "type": "task"},
"started_at": now - 5000, "last_heartbeat_at": now - 5000, # idle itself
"worker_id": 1, "attempt": 1,
}
q.enforce_task_timeouts()
assert "root1" in q.RUNNING, "idle parent with a queued descendant must survive"
assert q.PENDING and q.PENDING[0]["id"] == "child1"
def test_explicit_deadline_is_hard_even_while_progressing(tmp_path, monkeypatch):
"""Owner decision (Option A): an explicit deadline_at is HARD — honored promptly even
while the task is actively progressing. Only the removed BLANKET wall-clock was
activity-gated; a deliberate/caller deadline is not."""
from supervisor import queue as q
from supervisor import workers as w
from ouroboros.task_results import STATUS_FAILED, load_task_result
workers = {3: SimpleNamespace(busy_task_id="dl1", proc=_FakeProc(), reaping=False)}
_patch_queue(q, w, monkeypatch, tmp_path, workers)
now = time.time()
q.RUNNING["dl1"] = {
"task": {"id": "dl1", "type": "task", "deadline_at": "2000-01-01T00:00:00Z"},
"started_at": now - 30, "last_progress_at": now - 1, # actively progressing
"worker_id": 3, "attempt": 1,
}
q.enforce_task_timeouts()
while not q._reap_queue.empty():
q._reap_timed_out_task(q._reap_queue.get_nowait())
assert "dl1" not in q.RUNNING, "a past-deadline task must be stopped even while progressing"
assert q.PENDING == [] # deadline => no retry
res = load_task_result(tmp_path, "dl1")
assert res["status"] == STATUS_FAILED
assert res["reason_code"] == "deadline"
def test_has_live_descendant_detects_orchestrator(tmp_path, monkeypatch):
from supervisor import queue as q
monkeypatch.setattr(q, "RUNNING", {
"root": {"task": {"id": "root"}},
"c": {"task": {"id": "c", "parent_task_id": "root"}},
"gc": {"task": {"id": "gc", "parent_task_id": "c"}},
"other": {"task": {"id": "other"}},
})
monkeypatch.setattr(q, "PENDING", [])
assert q._has_live_descendant("root") is True # via c and gc
assert q._has_live_descendant("c") is True # via gc
assert q._has_live_descendant("gc") is False # leaf
assert q._has_live_descendant("other") is False
# A parent whose only child is still QUEUED (PENDING, not yet assigned) is still an
# orchestrator and must not be blind-retried.
monkeypatch.setattr(q, "RUNNING", {"p": {"task": {"id": "p"}}})
monkeypatch.setattr(q, "PENDING", [{"id": "pc", "parent_task_id": "p", "root_task_id": "p"}])
assert q._has_live_descendant("p") is True
assert q._has_live_descendant("nope") is False
def test_descendant_detection_survives_missing_intermediate_parent(tmp_path, monkeypatch):
"""A grandchild whose intermediate parent already left RUNNING is still a descendant of
the root (via root_task_id), so the root orchestrator is recognised (not blind-retried)
and a progressing grandchild keeps it alive."""
from supervisor import queue as q
now = time.time()
monkeypatch.setattr(q, "RUNNING", {
"root": {"task": {"id": "root"}},
# intermediate parent 'c' is GONE from RUNNING; grandchild remains (root_task_id=root)
"gc": {"task": {"id": "gc", "parent_task_id": "c", "root_task_id": "root"},
"last_progress_at": now - 1, "started_at": now - 30},
})
assert q._has_live_descendant("root") is True
assert q._subtree_progressing("root", now, 100.0) is True
# a stale grandchild does NOT keep the root alive
q.RUNNING["gc"]["last_progress_at"] = now - 10_000
assert q._subtree_progressing("root", now, 100.0) is False
def test_capability_and_queue_digest_in_runtime_context(tmp_path, monkeypatch):
"""The honesty SSOT: the live capability gate is surfaced into context each turn."""
import ouroboros.context as ctx_mod
monkeypatch.setattr(ctx_mod, "get_git_info", lambda *a, **k: ("ouroboros", "abc1234"), raising=False)
monkeypatch.setenv("OUROBOROS_ALLOW_MUTATIVE_SUBAGENTS", "true")
env = SimpleNamespace(repo_dir=tmp_path, drive_root=tmp_path,
drive_path=lambda p: tmp_path / p)
section = ctx_mod.build_runtime_section(env, {"id": "t1", "type": "task"})
assert '"allow_mutative_subagents": true' in section
assert "MASTER gate" in section # the honest note teaching forward reasoning
monkeypatch.setenv("OUROBOROS_ALLOW_MUTATIVE_SUBAGENTS", "false")
section_off = ctx_mod.build_runtime_section(env, {"id": "t1", "type": "task"})
assert '"allow_mutative_subagents": false' in section_off
def test_tree_tools_in_core_and_initial_schemas():
"""tree_note/tree_read must be in the round-one envelope so a NORMAL parent can publish
the shared frame before fan-out (no enable_tools detour)."""
import pathlib
import tempfile
from ouroboros.tool_capabilities import CORE_TOOL_NAMES
from ouroboros.tool_policy import initial_tool_schemas
from ouroboros.tools.registry import ToolRegistry
assert "tree_note" in CORE_TOOL_NAMES and "tree_read" in CORE_TOOL_NAMES
with tempfile.TemporaryDirectory() as d:
reg = ToolRegistry(repo_dir=pathlib.Path(d), drive_root=pathlib.Path(d))
names = {s["function"]["name"] for s in initial_tool_schemas(reg)}
assert "tree_note" in names and "tree_read" in names
def test_tree_tools_available_to_subagents_but_writes_stay_blocked():
"""Both-paths isolation contract: a subagent (read-only AND acting) CAN coordinate via
the task-tree ledger (tree_note/tree_read), while the repo/data/runtime write-escalation
surface stays blocked. The capability sets are the SSOT the registry filter reads."""
from ouroboros.tool_capabilities import (
ACTING_SUBAGENT_TOOL_NAMES,
LOCAL_READONLY_SUBAGENT_TOOL_NAMES,
)
for name in ("tree_note", "tree_read"):
assert name in LOCAL_READONLY_SUBAGENT_TOOL_NAMES
assert name in ACTING_SUBAGENT_TOOL_NAMES
# a read-only subagent that can tree_note must STILL NOT escalate to real writes
for blocked in ("write_file", "edit_text", "run_command", "commit_reviewed", "knowledge_write"):
assert blocked not in LOCAL_READONLY_SUBAGENT_TOOL_NAMES
def test_tree_ledger_scope_and_attention(monkeypatch, tmp_path):
# Point the ledger at tmp_path WITHOUT a global config reload (which would leave
# ouroboros.config.DATA_DIR stuck on this tmp_path and pollute later tests): the ledger
# reads its module-level DATA_DIR, so monkeypatch THAT (auto-restored after the test).
import ouroboros.task_tree_ledger as L
monkeypatch.setattr(L, "DATA_DIR", str(tmp_path))
assert L.tree_ledger_append("rootA", "contract", "API: f()->g", task_id="rootA", role="lead").startswith("OK")
assert L.tree_ledger_append("rootA", "question", "lib X or Y?", task_id="c1", role="scout").startswith("OK")
# blocker/question imply parent attention
att = L.tree_ledger_attention_after("rootA", "")
assert len(att) == 1 and att[0]["kind"] == "question"
# bad kind rejected; empty text rejected
assert "TOOL_ARG_ERROR" in L.tree_ledger_append("rootA", "bogus", "x")
assert "TOOL_ARG_ERROR" in L.tree_ledger_append("rootA", "note", "")
# scope isolation: a different tree has its own ledger
assert L.tree_ledger_rows("rootB") == []
digest = L.tree_ledger_tail_digest("rootA")
assert "contract" in digest and "needs_parent_attention" in digest
def test_reaper_finalizes_stuck_artifact_on_self_finalized_result(tmp_path, monkeypatch):
"""Round-10 crit#2: a worker that self-finalized a workspace child but died before the
parent ran artifact finalization leaves artifact_status stuck at 'finalizing'. The reaper
terminalized the task (it is no longer in RUNNING), so the normal task_done finalize path
finds nothing the reaper must complete it. It must rescue ONLY a stuck non-terminal
artifact state (re-finalizing a terminal result could regress it to FAILED) and skip
readonly subagents (no durable artifacts), via the shared task_is_readonly_subagent gate."""
from supervisor import queue as q
from supervisor import workers as w
from ouroboros import headless
from ouroboros.task_results import write_task_result
workers = {4: SimpleNamespace(busy_task_id=None, proc=_FakeProc(), reaping=True)}
_patch_queue(q, w, monkeypatch, tmp_path, workers)
monkeypatch.setattr(q, "_kept_service_pids", lambda: set(), raising=False)
calls = []
monkeypatch.setattr(headless, "finalize_task_artifacts",
lambda root, task: (calls.append(str(task.get("id"))), [])[1])
def _run(task, artifact_status):
# Pre-write the worker's own terminal result so the reaper's post-kill re-check honors
# it (self_status set) instead of clobbering it — the branch crit#2 lives in.
write_task_result(tmp_path, str(task["id"]), "completed", artifact_status=artifact_status)
q._reap_timed_out_task({"worker_id": 4, "proc": None, "task_id": task["id"],
"task": task, "task_type": "task",
"terminal_reason": "idle_timeout", "attempt": 1})
# 1. stuck 'finalizing' → reaper completes finalization
_run({"id": "wt1", "type": "task"}, "finalizing")
assert calls == ["wt1"], "reaper must finalize a self-finalized result stuck at 'finalizing'"
# 2. already-terminal artifact_status → NOT re-finalized (no regression)
calls.clear()
_run({"id": "wt2", "type": "task"}, "ready")
assert calls == [], "an already-terminal artifact result must not be re-finalized"
# 3. readonly subagent → skipped even when stuck (no durable owner-facing artifacts)
calls.clear()
_run({"id": "wt3", "type": "task", "delegation_role": "subagent",
"task_constraint": {"mode": "local_readonly_subagent"}}, "finalizing")
assert calls == [], "a readonly subagent has no durable artifacts to finalize"
def test_task_is_readonly_subagent_gate():
"""The single SSOT gate the task_done path and the reaper both read (a re-derivation
drift of this rule is what stranded the reaper's artifact finalization)."""
from ouroboros.headless import task_is_readonly_subagent
assert task_is_readonly_subagent(
{"delegation_role": "subagent", "task_constraint": {"mode": "local_readonly_subagent"}}) is True
# constraint nested under metadata is honored too
assert task_is_readonly_subagent(
{"delegation_role": "subagent", "metadata": {"task_constraint": {"mode": "local_readonly_subagent"}}}) is True
# acting subagents and plain tasks are NOT readonly → they DO finalize artifacts
assert task_is_readonly_subagent(
{"delegation_role": "subagent", "task_constraint": {"mode": "acting_subagent"}}) is False
assert task_is_readonly_subagent({"id": "root", "type": "task"}) is False
assert task_is_readonly_subagent(None) is False

View file

@ -106,7 +106,7 @@ EXPECTED_TOOLS = [
"request_deep_self_review", "chat_history", "update_scratchpad",
"send_user_message", "update_identity", "toggle_evolution",
"toggle_consciousness", "switch_model", "get_task_result",
"wait_task", "wait_tasks",
"wait_task", "wait_tasks", "tree_note", "tree_read",
"read_file", "list_files", "write_file", "edit_text",
"send_photo", "send_video", "search_code", "query_code", "forward_to_worker",
"generate_evolution_stats",

View file

@ -1741,7 +1741,14 @@ def test_subagent_hard_timeout_retry_preserves_task_id(tmp_path, monkeypatch):
monkeypatch.setattr(queue_module, "load_state", lambda: {})
monkeypatch.setattr(queue_module, "append_jsonl", lambda *args, **kwargs: None)
monkeypatch.setattr(queue_module, "persist_queue_snapshot", lambda reason="": None)
worker = SimpleNamespace(busy_task_id="childtimeout", proc=FakeProc())
# Activity model: a "timed out" task is one with no real progress for the idle
# window AND no progressing subtree (heartbeat alone is not progress). Variant A:
# run the heavy teardown reaper synchronously (no daemon) for a deterministic test.
monkeypatch.setattr(queue_module, "_ensure_reaper_started", lambda: None)
monkeypatch.setattr(queue_module, "_reap_queue", queue_module._stdqueue.Queue())
monkeypatch.setattr(queue_module, "get_task_idle_timeout_sec", lambda: 1)
monkeypatch.setattr(queue_module, "get_per_call_timeout_ceiling_sec", lambda: 1)
worker = SimpleNamespace(busy_task_id="childtimeout", proc=FakeProc(), reaping=False)
monkeypatch.setattr(workers_module, "WORKERS", {9: worker})
monkeypatch.setattr(workers_module, "respawn_worker", lambda worker_id: None)
child_drive = tmp_path / "child-drive"
@ -1759,13 +1766,18 @@ def test_subagent_hard_timeout_retry_preserves_task_id(tmp_path, monkeypatch):
"child_drive_root": str(child_drive),
"_attempt": 1,
},
"started_at": time.time() - 10,
"last_heartbeat_at": time.time() - 10,
# idle for ~1000s, far beyond the monkeypatched idle window max(1, 1+120)=121s,
# with no progressing subtree -> activity-based stop.
"started_at": time.time() - 1000,
"last_heartbeat_at": time.time() - 1000,
"worker_id": 9,
"attempt": 1,
}
queue_module.enforce_task_timeouts()
# Drain the off-loop reaper synchronously (kill/archive/respawn).
while not queue_module._reap_queue.empty():
queue_module._reap_timed_out_task(queue_module._reap_queue.get_nowait())
assert queue_module.PENDING
retried = queue_module.PENDING[0]
@ -1805,7 +1817,11 @@ def test_absolute_deadline_does_not_retry_expired_task(tmp_path, monkeypatch):
monkeypatch.setattr(queue_module, "load_state", lambda: {})
monkeypatch.setattr(queue_module, "append_jsonl", lambda *args, **kwargs: None)
monkeypatch.setattr(queue_module, "persist_queue_snapshot", lambda reason="": None)
worker = SimpleNamespace(busy_task_id="deadline1", proc=FakeProc())
monkeypatch.setattr(queue_module, "_ensure_reaper_started", lambda: None)
monkeypatch.setattr(queue_module, "_reap_queue", queue_module._stdqueue.Queue())
monkeypatch.setattr(queue_module, "get_task_idle_timeout_sec", lambda: 1)
monkeypatch.setattr(queue_module, "get_per_call_timeout_ceiling_sec", lambda: 1)
worker = SimpleNamespace(busy_task_id="deadline1", proc=FakeProc(), reaping=False)
monkeypatch.setattr(workers_module, "WORKERS", {9: worker})
monkeypatch.setattr(workers_module, "respawn_worker", lambda worker_id: None)
@ -1817,13 +1833,18 @@ def test_absolute_deadline_does_not_retry_expired_task(tmp_path, monkeypatch):
"deadline_at": "2000-01-01T00:00:00Z",
"_attempt": 1,
},
"started_at": time.time() - 10,
"last_heartbeat_at": time.time() - 10,
# Past deadline AND idle (no progress for ~1000s): the deadline is gated through
# idle/subtree-liveness, so an expired-but-idle task is stopped without retry.
"started_at": time.time() - 1000,
"last_heartbeat_at": time.time() - 1000,
"worker_id": 9,
"attempt": 1,
}
queue_module.enforce_task_timeouts()
# Variant A: the terminal write + retry decision now happen in the off-loop reaper.
while not queue_module._reap_queue.empty():
queue_module._reap_timed_out_task(queue_module._reap_queue.get_nowait())
assert queue_module.PENDING == []
result = load_task_result(tmp_path, "deadline1")

View file

@ -37,6 +37,9 @@ def _make_worker(wid=0, alive=False, busy_task_id="abc123", exitcode=-11):
w.wid = wid
w.proc = proc
w.busy_task_id = busy_task_id
# Real Worker defaults reaping=False; without this the MagicMock auto-attr is truthy
# and the new crash-detector reaping guard would skip the worker.
w.reaping = False
return w