ouroboros/supervisor/worker_promotion.py
Ouroboros 3837e384fd Let a refused or lost promote stop reading as pending, and pin what the mutation pass found loose
The pre-push panel's third reviewer ran 56 mutations and drove the real
supervisor handler. One defect and several unpinned branches:

- A refusal that writes no result of its own (rejected attachments, and the
  duplicate-id exit) left this promote's emitted stub on disk, so the
  reconciliation read answered 'admission pending, do not promote the same
  work a second time' for ever and the owner's work never started. Both exits
  now replace the stub of their OWN routing token; with no stub on disk they
  still write nothing. This corrects the claim in 8d57aabe3 that every refused
  promote already overwrote the stub.
- The pending sentence states only what the row proves (emitted, no receipt,
  the time now) and hands the decision back: an event can be lost with a
  restarted supervisor, so a standing ban was a lie waiting to happen.
- The admission gates use a strict form, is_own_admission_stub: no token is
  no claim, so a tokenless admission meets any stub as a row that owns the id,
  and _promote_duplicate_reason requires its token. This closes the disclosed
  tokenless residual structurally.
- An emitted stub is neither the project's last result nor a steerable
  predecessor: the lookup skips it like a child, and the door says what it is.
- prompts/SYSTEM.md, the same one sentence: a read-only subagent reads
  deliverables, every subagent its parent's and root's task files (the earlier
  'never a sibling's' is false on a single-drive geometry).
- The light-mode refusal says 'runtime control data', since it lists the task
  drive under the writable roots in the same text.

New pins, each re-run against the reviewer's surviving mutation and now red
without its line: the stub's token and status conditions, the token passed
by the promote handler, the retry window of the compaction floor, the
read-only gate of the lineage redirect, the project filter and omission count
of a room's live roots, the secret-name filter of a Deliverables listing, and
the host-issued authority source of a listed row.
2026-09-21 14:28:56 +03:00

950 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Turning a chat turn — or a project scope — into a queued task.
Resolves where the work came from, binds it to a Project when one is named,
refuses a duplicate of something already live, admits an external workspace only
after proving the tree is a real checkout, and fails the promoted task LOUDLY
rather than leaving a half-admitted row in the queue.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any, Optional
from supervisor.state import append_jsonl
from ouroboros.utils import utc_now_iso
from supervisor.queue import _queue_lock
log = logging.getLogger(__name__)
def _pool():
"""The parent module, read at call time.
The parent owns the rebindable module state and the members tests
monkeypatch there; reading them through the module at each call keeps
one binding, where a from-import would freeze the value this leaf saw
at import time (the owner-approved D18/D33 mechanical exception).
"""
from supervisor import workers
return workers
def _origin_from_mapping(mapping: Any, *, absent: str) -> dict:
"""Typed binding origin from an event/metadata mapping (ref passed BY VALUE
from chat ingress; ``absent`` is the closed-enum reason when none rode along)."""
source = mapping if isinstance(mapping, dict) else {}
ref = source.get("origin_message_ref") or source.get("source_ref")
if isinstance(ref, dict) and ref:
text = source.get("origin_message_text") or source.get("source_text")
origin = {"ref": dict(ref)}
if isinstance(text, str) and text:
origin["text"] = text
return origin
return {"absent": absent}
def _origin_from_task_record(task_id: str) -> Optional[dict]:
"""Ingress-captured origin from the persisted task record.
A QUEUED task's ctx.task_metadata does not carry the origin (only the task
dict/record does), so the mid-run ensure_project_scope bind falls back to
the durable record — mirroring the UI convert path's _owner_task_origin."""
try:
# Child-merging reader: a forked/workspace root persists its RUNNING
# record on its CHILD drive; the effective-status SSOT merges it (same
# reason gateway/projects.py::_owner_task_origin uses it).
from ouroboros.task_status import load_effective_task_result
record = load_effective_task_result(_pool().DRIVE_ROOT, task_id) or {}
ref = record.get("origin_message_ref")
text = record.get("origin_message_text")
if isinstance(ref, dict) and ref and isinstance(text, str) and text.strip():
return {"ref": dict(ref), "text": text}
except Exception:
log.debug("origin task-record lookup failed for %s", task_id, exc_info=True)
return None
def _report_binding_failure(
task_id: str, project_id: str, exc: Exception, *, path: str, reason: str = "",
drive_root: Any = None,
) -> None:
"""A failed durable bind is LOUD (BIBLE P1: silent linkage loss is memory
loss): warning log + typed events.jsonl row; the task itself keeps running.
``reason`` names a REFUSAL that never reached the bind (today
``project_scope_conflict``: the task is already bound elsewhere, so no second
project is created; ``project_binding_unreadable``: the store could not be
read at all); the row is otherwise the same shape a raising bind writes.
``drive_root`` names the store the failure belongs to for a caller that does
not own the pool root — the reaper passes the queue's drive.
"""
# A refusal carries no live traceback, so only a real bind failure logs one.
log.warning("%s for %s/%s (%s)", reason or "bind_task_to_project failed",
task_id, project_id, path, exc_info=not reason)
try:
root = _pool().DRIVE_ROOT if drive_root is None else drive_root
append_jsonl(root / "logs" / "events.jsonl", {
"ts": utc_now_iso(),
"type": "project_binding_failed",
"task_id": str(task_id or ""),
"project_id": str(project_id or ""),
"bind_path": path,
"error": f"{type(exc).__name__}: {exc}",
**({"reason": reason} if reason else {}),
})
except Exception:
log.debug("project_binding_failed event write failed", exc_info=True)
def _canonical_promoted_repair_constraint(value: Any) -> tuple[Optional[dict], str]:
"""Pin and validate the authority envelope for a promoted skill repair."""
from ouroboros.contracts.skill_payload_policy import resolve_constrained_payload_path
from ouroboros.contracts.task_constraint import TaskConstraint, normalize_task_constraint
constraint = normalize_task_constraint(value)
if constraint is None or not (
constraint.mode == "skill_repair"
or (constraint.mode == "normal" and (constraint.skill_name or constraint.payload_root))
):
return None, ""
canonical = TaskConstraint(
mode="normal",
skill_name=constraint.skill_name,
payload_root=constraint.payload_root,
# Selection is not enable authority. The task resolves its real owner
# source when it chooses to enable the repaired installation.
allow_enable=False,
allow_review=True,
)
try:
payload_dir = resolve_constrained_payload_path(_pool().DRIVE_ROOT, canonical, ".")
except (TypeError, ValueError):
return None, "invalid_skill_repair_constraint"
if not payload_dir.is_dir():
return None, "skill_repair_payload_missing"
# X3 (owner 11=B): the repair is admitted against ONE exact payload state.
# An unreadable payload cannot anchor a hash chain — fail closed here, not
# after the task has already spent rounds.
try:
from ouroboros.skill_loader import compute_content_hash
base_content_hash = compute_content_hash(payload_dir)
except Exception:
return None, "skill_repair_payload_unreadable"
return {
"mode": canonical.mode,
"skill_name": canonical.skill_name,
"payload_root": canonical.payload_root,
"allow_enable": canonical.allow_enable,
"allow_review": True,
"_base_content_hash": base_content_hash,
}, ""
def _promote_duplicate_reason(task_id: str, ctx: Any, *, admission_token: str) -> str:
"""Fail closed if a promoted id is already live, durable, or uncheckable.
A row that is only THIS admission's emitted stub (#1160) is its own
pre-receipt, not a second owner of the id, so it is read around here.
"""
pending = getattr(ctx, "PENDING", _pool().PENDING)
running = getattr(ctx, "RUNNING", _pool().RUNNING)
with _queue_lock:
live_duplicate = any(
isinstance(row, dict) and str(row.get("id") or "") == task_id
for row in list(pending or [])
) or task_id in (running or {})
try:
from ouroboros.routing_wait import is_own_admission_stub
from ouroboros.task_results import load_task_result
stored = load_task_result(
getattr(ctx, "DRIVE_ROOT", _pool().DRIVE_ROOT), task_id, strict=True,
)
stored_duplicate = bool(stored) and not is_own_admission_stub(
stored, admission_token,
)
except Exception:
log.warning("promote: duplicate-id lookup failed for %s", task_id, exc_info=True)
return "task_id_lookup_failed"
return "duplicate_task_id" if live_duplicate or stored_duplicate else ""
def _promoted_force_plan_metadata(evt: dict) -> dict:
if evt.get("force_plan") is not True:
return {}
source = str(evt.get("force_plan_source") or "operator").strip() or "operator"
return {"metadata": {"force_plan": True, "force_plan_source": source}}
def _promote_project_scope(evt: dict) -> str:
"""The project an admitted promote lands in: the explicit one the event carries,
else the project the OWNER MESSAGE it came from already has.
The tool already inherits that scope when it emits the event, but the emit →
admission window is real: a sibling card of the same message can be turned into a
Project in between, and the root would then arrive in Main as a second convertible
unit for one piece of work. An IMPLICIT event therefore calls this TWICE: once
when the handler starts, so the early admission decisions have a scope, and again
under the claim lock that also holds the durable bind — only the second answer can
still be true when the bind lands. An event whose scope is already settled returns
it unchanged, so the second call re-reads nothing. Presence promotion keeps its
empty scope — the tool-side override already decided that a public conversation
cannot choose a Project. Fail-open: an unreadable store leaves the scope exactly
as the event stated it."""
explicit = str(evt.get("project_id") or "")
if explicit or evt.get("presence") or not isinstance(evt.get("source_ref"), dict):
return explicit
try:
from ouroboros.projects_registry import origin_claim_lock, project_id_for_origin
with origin_claim_lock():
return str(project_id_for_origin(_pool().DRIVE_ROOT, evt.get("source_ref")) or "")
except Exception:
log.debug("promote: origin project lookup failed", exc_info=True)
return ""
def _admit_project_scope(
evt: dict, task: dict, tid: str, pid: str, attachment_manifest: list,
) -> Optional[dict]:
"""Admit the promoted task into project ``pid``: lifecycle fence, scope on the
task record, project row, durable bind and project-thread routing; the project
to announce is stored on the task record and ``promote_chat_to_task`` announces
it only after ``enqueue_task`` succeeds. Returns the caller's rejection dict, or
``None`` when the task is admitted (and when there is no project to admit it into).
Held by the caller under ``origin_claim_lock``: for an implicit promote the
origin re-read and this bind are ONE transaction against the UI conversion's
claim, so a sibling card cannot bind the owner message in between and leave this
root in Main as a second convertible unit for one piece of work."""
if not pid:
return None
# Deletion closes admission before cancellation/quiescence begins. Check
# the durable lifecycle before creating projects or child drives;
# enqueue_task repeats this check atomically under the queue lock.
try:
from ouroboros.projects_registry import get_reserved_project
existing_project = get_reserved_project(_pool().DRIVE_ROOT, pid)
existing_lifecycle = str((existing_project or {}).get("lifecycle") or "active")
if existing_project is not None and existing_lifecycle != "active":
return _pool()._reject_promoted_after_attachment_stage({
"status": "needs_manual_target",
"reason": "project_routing_fence",
"project_lifecycle": existing_lifecycle,
"detail": f"project lifecycle: {existing_lifecycle}",
"task_id": tid,
}, attachment_manifest)
except Exception:
log.warning("promote: project admission lookup failed for %s", pid, exc_info=True)
return _pool()._reject_promoted_after_attachment_stage({
"status": "needs_manual_target",
"reason": "project_routing_fence_lookup_failed",
"task_id": tid,
}, attachment_manifest)
task["project_id"] = pid
# When the model is CREATING a named project (project_name set), pass the
# human display name so the project isn't named after its bare id (v6.33.0).
project_display_name = str(evt.get("project_name") or "").strip()
try:
from ouroboros.projects_registry import bind_task_to_project, create_project, touch_project
project = create_project(
_pool().DRIVE_ROOT, pid, name=project_display_name, origin="promote_chat_to_task",
)
touch_project(_pool().DRIVE_ROOT, pid)
# Bind the task to its project (durable task->project map). Without this
# the task is project-scoped only in its own metadata; the frontend (via
# all_task_bindings in /api/state) and the mailbox follow-up router
# (project_chat_for_task) can't recognise it as a project task, so it
# surfaces in the main chat with a stray "turn into project" button (P2).
try:
# Absence semantics by PROVENANCE (structural, never keyword):
# a chat-born event carries client_message_id, so a missing ref
# there is a producer BUG (grep-able producer_missing_ref); an
# event from a context with no owner message (headless/scheduled/
# consciousness promote) is a DESIGNED absence.
absent_reason = (
"producer_missing_ref"
if str(evt.get("client_message_id") or "").strip()
and not evt.get("origin_suppressed")
else "mid_task_no_origin"
)
bind_task_to_project(
_pool().DRIVE_ROOT,
tid,
pid,
(project or {}).get("chat_id"),
origin=_origin_from_mapping(evt, absent=absent_reason),
)
except Exception as exc:
_report_binding_failure(tid, pid, exc, path="promote_chat_to_task")
return _pool()._reject_promoted_after_attachment_stage({
"status": "needs_manual_target",
"reason": "project_binding_failed",
"task_id": tid,
}, attachment_manifest)
# The promoted task runs in the PROJECT thread: route its live card +
# owner mailbox to the project's chat_id (not the main chat it was
# promoted from) so follow-ups steer to it via
# _route_project_chat_to_running_task and its progress is visible in
# the project panel.
try:
proj_chat = int((project or {}).get("chat_id") or 0)
except (TypeError, ValueError):
proj_chat = 0
if proj_chat:
task["chat_id"] = proj_chat
# The agent just created/bound this project server-side (no client
# round-trip, unlike the UI "Turn into project" flow). Tell the
# frontend so it refreshes projectChatIds NOW — otherwise this new
# project's live frames render in the main chat until the periodic
# /api/state poll catches up (≤20s) and isMyThread misclassifies them.
try:
from supervisor.message_bus import get_bridge
get_bridge().broadcast({"type": "projects_changed", "project_id": pid, "chat_id": proj_chat})
except Exception:
log.debug("promote: projects_changed broadcast failed for %s", pid, exc_info=True)
if evt.get("_source_created") and not (project or {}).get("created"):
# The source-resolution half of THIS promote registered the
# project off-loop (_prepare_promote_source_off_loop) — same
# agent-initiated creation, so the announce gate honors it.
project = {**(project or {}), "created": True}
# The "Project · Started" row is owed only once the task is REALLY in the
# queue: promote_chat_to_task announces it after enqueue_task succeeded,
# so a workspace refusal after project creation announces nothing.
task["_announce_project"] = project
except Exception:
log.warning("promote: project registration failed for %s", pid, exc_info=True)
return _pool()._reject_promoted_after_attachment_stage({
"status": "needs_manual_target",
"reason": "project_registration_failed",
"task_id": tid,
}, attachment_manifest)
return None
def bind_retry_to_origin_project(
drive_root: Any, task: dict, task_id: str, retry_task_id: str,
) -> str:
"""Carry a timed-out root's Project onto the retry that replaces it.
A retry is the SAME work under a new physical id, so it belongs to the room
the work already has (DEVELOPMENT "the captured reference is the identity of
the work"). Before this, the new id copied the origin ref but carried no
binding, and the retried work painted a Main card offering "Turn into
project" until some later implicit act adopted it. The predecessor's own
binding answers first — it is the SSOT for that task's project — and the
origin-keyed lookup covers a root that was never bound itself while another
task id of the same owner message was. The new row reuses the predecessor's
stored origin BY VALUE, so the retry joins that message's one convertible
unit instead of starting a second.
The reaper calls this INSIDE its retry admission transaction, under the same
``origin_claim_lock`` every implicit claim holds, and only once cancellation
can no longer win the boundary: ``bind_task_to_project`` is immutable, so a
bound-but-never-admitted retry id would answer ``project_id_for_task``
forever. Creates no project and never raises — a refused bind (a project that
stopped accepting them) or an unreadable store leaves the retry unbound and
is disclosed as ``project_binding_failed``. Returns the project the retry was
bound to, "" when there was nothing to inherit.
"""
tid = str(retry_task_id or "").strip()
origin_id = str(task_id or "").strip()
if not tid or not origin_id or tid == origin_id:
return ""
from ouroboros.projects_registry import (
bind_task_to_project,
project_binding_for_task,
project_id_for_origin,
)
origin = _origin_from_mapping(task, absent="mid_task_no_origin")
try:
predecessor = project_binding_for_task(drive_root, origin_id) or {}
pid = str(predecessor.get("project_id") or "") or str(
project_id_for_origin(drive_root, origin.get("ref"), strict=True) or ""
)
except Exception as exc:
_report_binding_failure(tid, "", exc, path="timeout_retry_admission",
reason="project_binding_unreadable", drive_root=drive_root)
return ""
if not pid:
return ""
if isinstance(predecessor.get("source_ref"), dict):
origin = {"ref": dict(predecessor["source_ref"])}
if isinstance(predecessor.get("source_text"), str):
origin["text"] = predecessor["source_text"]
elif predecessor.get("origin_absent"):
origin = {"absent": str(predecessor["origin_absent"])}
try:
bind_task_to_project(drive_root, tid, pid, origin=origin)
except Exception as exc:
_report_binding_failure(tid, pid, exc, path="timeout_retry_admission",
drive_root=drive_root)
return ""
return pid
def promote_chat_to_task(evt: dict, ctx: Any) -> dict:
"""Enqueue a first-class pooled owner task from a conversation-lane promote.
The task carries the originating ``chat_id`` (its live card and replies
land in that thread) and the optional ``project_id`` scope; it competes for
the project writer lease like any other top-level project task.
"""
from ouroboros.consciousness_authority import apply_consciousness_authority, consciousness_origin_metadata
from ouroboros.contracts.task_contract import attach_task_contract
from ouroboros.project_naming import admission_names
tid = str(evt.get("task_id") or uuid.uuid4().hex[:16])
admission_token = str(evt.get("routing_token") or "").strip()
objective = str(evt.get("objective") or "")
if not objective.strip():
return {"status": "needs_manual_target", "reason": "empty_objective", "task_id": tid}
# Reject before project/source/workspace side effects. enqueue_task repeats
# the check atomically for the tiny race before queue insertion.
duplicate_reason = _promote_duplicate_reason(tid, ctx, admission_token=admission_token)
if duplicate_reason:
return {
"status": "needs_manual_target",
"reason": duplicate_reason,
"task_id": tid,
}
evt = dict(evt)
source_note = str(evt.get("_source_note") or "")
# IMPLICIT scope: the event named no project, so the answer comes from the owner
# message's own Project and can still change before the bind (a sibling card
# converting in the emit -> admission window). Remembered HERE, before the
# assignment below turns that answer into the event's stated scope.
implicit_scope = (
not str(evt.get("project_id") or "")
and not evt.get("presence")
and isinstance(evt.get("source_ref"), dict)
)
effective_pid = evt["project_id"] = _promote_project_scope(evt)
repair_constraint, constraint_error = _canonical_promoted_repair_constraint(
evt.get("task_constraint")
)
if constraint_error:
return {
"status": "needs_manual_target",
"reason": constraint_error,
"task_id": tid,
}
try:
chat_id = int(evt.get("chat_id") or 0)
except (TypeError, ValueError):
chat_id = 0
if not chat_id:
st = ctx.load_state()
try:
chat_id = int(st.get("owner_chat_id") or 0)
except (TypeError, ValueError):
chat_id = 0
expected_output = str(evt.get("expected_output") or "").strip()
text = objective if not expected_output else f"{objective}\n\nExpected output: {expected_output}"
title, suggested_name = admission_names(evt, objective)
task = {
"id": tid,
"root_task_id": tid,
"delegation_role": "root",
"type": "task",
"chat_id": chat_id,
"text": text,
"description": objective,
"objective": objective,
"expected_output": expected_output,
"title": title,
"suggested_name": suggested_name,
"source": "promote_chat_to_task",
"_require_unique_task_id": True,
"_require_worker_pool": True,
"_admission_token": admission_token,
"promotion_admission_token": admission_token,
**_promoted_force_plan_metadata(evt),
}
origin = consciousness_origin_metadata(evt)
if origin:
# A root consciousness started keeps its origin/category/level by value (В9':
# ordinary Main flow, no presence-style project/workspace/source stripping).
task["actor_id"] = "consciousness"
task.setdefault("metadata", {}).update(origin)
apply_consciousness_authority(task)
inherited_attachment_manifest = _pool()._apply_presence_promotion_authority(
evt, task, objective=objective, expected_output=expected_output,
)
attachment_manifest, attachment_rejection = _pool()._stage_promoted_initial_attachments(
evt, task, tid, inherited_manifest=inherited_attachment_manifest,
)
if attachment_rejection is not None:
return attachment_rejection
if repair_constraint is not None:
# X3: bind the admission hash to the REAL task id, durably, before the
# task exists anywhere else — every payload write CAS-checks this chain.
# FAIL CLOSED, like the unreadable-payload branch above: a repair admitted
# without its binding CAS-checks nothing (every later check no-ops), which
# is precisely the drift-blind repair this mechanism replaces.
_base_content_hash = str(repair_constraint.pop("_base_content_hash", "") or "")
try:
from ouroboros.skill_repair_admission import record_repair_admission
record_repair_admission(
_pool().DRIVE_ROOT, str(repair_constraint.get("skill_name") or ""),
task_id=tid, base_content_hash=_base_content_hash)
except Exception:
log.warning("Failed to record skill repair admission for %s", tid, exc_info=True)
return _pool()._reject_promoted_after_attachment_stage({
"status": "needs_manual_target",
"reason": "skill_repair_admission_unwritable",
"task_id": tid,
}, attachment_manifest)
# Bind the selected resource before attach_task_contract. This remains
# an ordinary managed task; the selection is not a reduced tool profile.
task["task_constraint"] = repair_constraint
# Ingress-captured origin identity rides the task record (post-hoc UI convert
# reads it from the persisted result — never re-derived from content).
if isinstance(evt.get("source_ref"), dict) and evt.get("source_ref"):
task["origin_message_ref"] = dict(evt["source_ref"])
if isinstance(evt.get("source_text"), str) and evt.get("source_text"):
task["origin_message_text"] = evt["source_text"]
if isinstance(evt.get("predecessor_authority_source"), dict):
task["predecessor_authority_source"] = dict(evt["predecessor_authority_source"])
# Owner Surface Fact: the promoting turn's sending-surface fact lands in
# METADATA (the renderer reads task["metadata"]["client_surface"]), never a
# top-level key — and metadata may not exist yet (only force_plan creates it).
if isinstance(evt.get("client_surface"), dict) and evt.get("client_surface"):
task.setdefault("metadata", {})["client_surface"] = dict(evt["client_surface"])
pid = str(evt.get("project_id") or "").strip()
from ouroboros.projects_registry import origin_claim_lock
with origin_claim_lock():
if implicit_scope:
# Re-resolved under the SAME lock that holds the bind below (an RLock, so
# the helper's own acquire is free). A sibling card of this owner message
# may have created its Project since this handler started; reading the
# origin once, a hundred lines before the bind, is what let the promoted
# root arrive in Main as a second convertible unit for one message.
pid = evt["project_id"] = _promote_project_scope(evt)
rejection = _admit_project_scope(evt, task, tid, pid, attachment_manifest)
if rejection is not None:
return rejection
# The answer the task was actually admitted with, so the promote's own receipt
# names the room the binding names.
effective_pid = pid
# Workspace admission (v6.58.0 SSOT + the Q10=A auto-provision) lives in one
# helper so this entry point stays readable and under the method gate.
workspace_outcome = _admit_promoted_workspace(evt, ctx, task, pid=pid, tid=tid)
if workspace_outcome is not None:
return _pool()._reject_promoted_after_attachment_stage(
workspace_outcome, attachment_manifest,
)
if not _pool()._relocate_promoted_attachments(task, tid, attachment_manifest):
return _pool()._reject_promoted_after_attachment_stage({
"status": "needs_manual_target",
"reason": "attachment_admission_rejected",
"detail": "Attachment staging could not be finalized (reason=staging_unavailable).",
"attachment_manifest": [
{
"ordinal": row.get("ordinal", index),
"status": "rejected",
"reason": "staging_unavailable",
"label": str(row.get("label") or f"attachment {index + 1}"),
}
for index, row in enumerate(attachment_manifest)
],
"task_id": tid,
}, attachment_manifest)
if attachment_manifest:
from ouroboros.gateway.tasks import _render_attachment_lines
from ouroboros.artifacts import attachment_manifest_projection
authority = attachment_manifest_projection(task.get("drive_root") or _pool().DRIVE_ROOT, tid, attachment_manifest)
task.update(authority)
task["text"] = f"{task['text']}\n\n[ATTACHMENTS]\n{_render_attachment_lines(authority)}\n[END_ATTACHMENTS]"
task["attachments"] = authority["attachment_manifest"]
task["attachment_images"] = [row for row in attachment_manifest if row.get("is_image")]
if isinstance(task.get("task_contract"), dict):
task["task_contract"].update(authority)
# Popped BEFORE the row is built/queued so the announce fact never rides it.
announce_project = task.pop("_announce_project", None)
attach_task_contract(task)
admitted = ctx.enqueue_task(task)
if isinstance(admitted, dict) and admitted.get("_admission_blocked"):
return _pool()._reject_promoted_after_attachment_stage({
"status": "needs_manual_target",
"reason": str(admitted.get("_admission_blocked") or "admission_fence"),
"detail": str(admitted.get("_admission_detail") or ""),
"project_lifecycle": str(admitted.get("_project_lifecycle") or ""),
"task_id": tid,
}, attachment_manifest)
if announce_project is not None:
_pool()._announce_created_project(announce_project, tid, task=task)
# Owner 3=A: the promoter's unmet planning obligation now belongs to this root
# (stamped by _promoted_force_plan_metadata above); release it on the promoter's
# live row BEFORE the snapshot persist below, so one persist shows both facts.
from supervisor.plan_obligation import transfer_promoter_obligation
obligation_transfer = transfer_promoter_obligation(ctx, evt, tid)
# A positive promote confirmation is allowed only after the durable queue
# projection exists. The event handler writes the scheduled task result
# after the routing receipt; keeping that last step outside this function
# makes the result itself the cross-process admission receipt.
persist_snapshot = getattr(ctx, "persist_queue_snapshot", None)
if not callable(persist_snapshot):
return _pool()._reject_promoted_after_attachment_stage({
"status": "needs_manual_target",
"reason": "queue_snapshot_persist_unavailable",
"task_id": tid,
"admission_started": True,
}, attachment_manifest)
try:
persisted = persist_snapshot(reason="promote_chat_to_task") is not False
except Exception:
log.warning("promote: queue snapshot persist failed for %s", tid, exc_info=True)
persisted = False
if not persisted:
return _pool()._reject_promoted_after_attachment_stage({
"status": "needs_manual_target",
"reason": "queue_snapshot_persist_failed",
"task_id": tid,
"admission_started": True,
}, attachment_manifest)
# v6.82 (P5) disclosed residual: a PROMOTED root carries the host-attested
# `cancelable` marker from its first RUNNING relay, not from enqueue — the
# promote path emits no owner-facing progress frame of its own, and minting a
# marker-only bubble would either add chat noise or bypass the canonical
# message seam (tests/test_heartbeat_presentation.py). While it is still
# PENDING the Dashboard Activity row cancels it; the card action appears once
# it starts.
# A project root may execute from a forked child drive. Its budget-root
# result therefore receives this admitted contract before worker startup.
outcome = _pool()._promoted_scheduled_outcome(task, admitted, tid)
outcome["_admitted_suggested_name"] = task["suggested_name"]
if attachment_manifest:
outcome.update(authority)
if effective_pid:
outcome["project_id"] = effective_pid
if source_note:
outcome["source_note"] = source_note
if obligation_transfer:
outcome["force_plan_transfer"] = obligation_transfer
return outcome
def _admit_promoted_workspace(evt: dict, ctx: Any, task: dict, *, pid: str, tid: str) -> Optional[dict]:
"""Bind the promoted task's active workspace, or return a failure outcome.
Extracted verbatim from ``promote_chat_to_task`` (v6.90.x submarine unwind) to
keep that function under the hard method gate; the admission SEQUENCE is
unchanged. Returns ``None`` when the task was bound (or legitimately has no
workspace) and mutates ``task`` in place; returns a ``needs_manual_target``
outcome dict when admission must fail LOUDLY.
"""
# v6.58.0 (slice 1) — the promote path admits a workspace through the SAME SSOT
# as /api/tasks. A task born in a project ROOM defaults to the room's registered
# working_dir (workspace="none" on the event opts out); a SET-but-broken
# working_dir fails LOUDLY here — never a silent workspace-less task that would
# resolve to the self_modification profile over the system repo.
from ouroboros.workspace_admission import (
WORKSPACE_NONE,
bounded_workspace_preflight,
compose_workspace_block,
resolve_room_workspace,
workspace_repair_hint,
)
if task.get("_presence_origin"):
# Keep the admitted folder from the inherited contract, never a public
# event's replacement. Presence retains its canonical shared memory.
workspace = task["task_contract"].get("workspace") or {}
resolved_ws, ws_error = resolve_room_workspace(
drive_root=_pool().DRIVE_ROOT, system_repo_dir=_pool().REPO_DIR,
project_id="", explicit_workspace=str(workspace.get("root") or ""),
)
if ws_error:
return {
"status": "needs_manual_target", "reason": "workspace_unusable", "task_id": tid,
"detail": workspace_repair_hint(ws_error=ws_error, presence=True),
}
if resolved_ws:
task.update(workspace_root=resolved_ws, workspace_mode="external", memory_mode="shared")
return None
# Q10=A (owner, 2026-08-08): a project promoted with NO working folder gets one
# AUTO-PROVISIONED via the existing ensure_project_workspace seam (an idempotent
# standalone git repo under the durable subagent_projects root — passes the same
# validate_workspace_root SSOT below). This binds the task's real tree as its
# active workspace, fixing path/cwd confinement, the external tool profile and
# the one-writer lease for the file-less project class (the submarine shape).
# STRICTLY empty-only: a NON-EMPTY working_dir — valid or broken — is never
# blind-ensured over (a broken one must LOUD-FAIL through resolve_room_workspace,
# the v6.58.0 invariant, not be papered over with a fresh empty repo). The
# workspace="none" sentinel still opts out entirely. Docs are NOT part of this
# decision: since D-ARCH (2026-08-08) the doc matrix keys on project membership
# and the owner mode, so binding a workspace here never drags ARCHITECTURE.md
# out of a max context.
if (
pid
and not str(evt.get("workspace_root") or "").strip()
and str(evt.get("workspace") or "").strip().lower() != WORKSPACE_NONE
):
provisioned_now = ""
try:
from ouroboros.projects_registry import get_project as _get_project_entry
_existing_wd = str((_get_project_entry(_pool().DRIVE_ROOT, pid) or {}).get("working_dir") or "").strip()
except Exception:
# Registry read failure: do NOT provision (a blind ensure here could
# mint a fresh empty repo over a project whose working_dir merely
# failed to load). resolve_room_workspace re-reads and decides.
_existing_wd = "unreadable"
log.warning("promote: project working_dir lookup failed for %s", pid, exc_info=True)
if not _existing_wd:
try:
from ouroboros.projects_registry import ensure_project_workspace
provisioned_now = str(ensure_project_workspace(_pool().DRIVE_ROOT, pid, _pool().REPO_DIR) or "")
except Exception:
provisioned_now = ""
log.warning("promote: workspace auto-provisioning raised for %s", pid, exc_info=True)
if not provisioned_now:
# Bind-or-fail (v6.58.0): falling through to a workspace-less
# self_modification-profile task over the system repo is exactly
# the silent degradation the admission SSOT exists to kill.
return {
"status": "needs_manual_target",
"reason": "workspace_provisioning_failed",
"task_id": tid,
"detail": workspace_repair_hint(
ws_error=f"project {pid!r} has no working folder and auto-provisioning "
"one failed; see the supervisor log (ensure_project_workspace)",
),
}
task.setdefault("metadata", {})["workspace_autoprovisioned"] = True
resolved_ws, ws_error = resolve_room_workspace(
drive_root=_pool().DRIVE_ROOT,
system_repo_dir=_pool().REPO_DIR,
project_id=pid,
explicit_workspace=str(evt.get("workspace_root") or "").strip(),
workspace_sentinel=str(evt.get("workspace") or ""),
)
if ws_error:
return {
"status": "needs_manual_target", "reason": "workspace_unusable", "task_id": tid,
"detail": workspace_repair_hint(
ws_error=ws_error, explicit_workspace=str(evt.get("workspace_root") or "").strip(),
project_id=pid, drive_root=_pool().DRIVE_ROOT, system_repo_dir=_pool().REPO_DIR,
),
}
if resolved_ws:
task["workspace_root"] = resolved_ws
task["workspace_mode"] = "external"
task["memory_mode"] = "forked"
if pid and str(evt.get("workspace_root") or "").strip():
# An explicit folder for a room that has none yet becomes the room's
# folder: the same validated canonical path this task runs in, written
# only while `working_dir` is still empty (compare-and-set under the
# registry lock), so a set value is never overwritten and the room's
# later direct turns are not blind to where the work went.
try:
from ouroboros.projects_registry import update_project
update_project(_pool().DRIVE_ROOT, pid, working_dir=resolved_ws, only_if_empty=("working_dir",))
except Exception:
log.warning("promote: could not record working_dir for project %s", pid, exc_info=True)
# The lease lane keys off task["project_id"]: for a project room it is already
# set; for a bare workspace promote, resolve it (registry-first → derived hash)
# so one folder is one serialized lane on EVERY entry path (slice 0 invariant).
if not str(task.get("project_id") or "").strip():
try:
from ouroboros.project_facts import resolve_project_id as _resolve_pid
derived_pid = _resolve_pid({"workspace_root": resolved_ws})
if derived_pid:
task["project_id"] = derived_pid
except Exception:
log.debug("promote: project_id derivation failed for %s", tid, exc_info=True)
# Memory-fork parity with /api/tasks: the room task runs on an ISOLATED child
# drive (forked seed), with the canonical root kept for budget/status.
try:
from ouroboros.headless import prepare_task_drive
child_drive = prepare_task_drive(
_pool().DRIVE_ROOT, tid, "forked", project_id=str(task.get("project_id") or "")
)
if child_drive is not None:
task["drive_root"] = str(child_drive)
task["budget_drive_root"] = str(_pool().DRIVE_ROOT)
except Exception:
log.warning("promote: child drive fork failed for %s", tid, exc_info=True)
# Preflight parity, HARD-CAPPED: this runs on the supervisor event-drain
# thread, so the git/toolchain snapshot gets a bounded window and degrades
# to a disclosed skip note instead of stalling event delivery.
preflight_summary = bounded_workspace_preflight(resolved_ws)
metadata = task.setdefault("metadata", {})
metadata["workspace_root"] = resolved_ws
metadata["workspace_preflight"] = preflight_summary
task["text"] = (
f"{task['text']}\n\n[HEADLESS_WORKSPACE]\n"
+ compose_workspace_block(
workspace_root=resolved_ws,
workspace_mode="external",
memory_mode="forked",
workspace_preflight=preflight_summary,
)
+ "[END_HEADLESS_WORKSPACE]"
)
return None
def ensure_project_scope(evt: dict, ctx: Any) -> dict:
"""Create/attach the registry project for an in-task ensure_project_scope call
and bind the CURRENT (already-running) task to it, then broadcast so the UI moves
the card into the project thread. Mirrors the project-registration half of
promote_chat_to_task, but for a task that already exists (the worker has already
set ctx.project_id locally; this makes it durable + visible). Returns the typed
outcome the receipt rail reports: ``delivered`` with the bound project, or
``rejected`` with the reason (bound elsewhere, a refused bind, a registration
failure) -- the tool never says "OK" for a bind that did not land."""
tid = str(evt.get("task_id") or "").strip()
pid = str(evt.get("project_id") or "").strip()
if not tid or not pid:
return {"status": "rejected", "reason": "missing_task_or_project"}
name = str(evt.get("project_name") or "").strip()
try:
from ouroboros.projects_registry import (
bind_task_to_project,
create_project,
origin_claim_lock,
project_id_for_origin,
project_id_for_task,
touch_project,
update_project,
)
# The task's ingress-captured origin, resolved BEFORE the authority read (it
# answers both questions): event metadata, then the live RUNNING task dict
# (queued tasks carry no origin in ctx.task_metadata, and this also covers
# forked/workspace roots whose running record lives on a CHILD drive), then
# the durable task record on the canonical drive — the mid-run "make this a
# project named X" path must keep the start message.
origin = _origin_from_mapping(evt, absent="mid_task_no_origin")
if "absent" in origin:
running = getattr(ctx, "RUNNING", None)
running_row = running.get(tid) if isinstance(running, dict) else None
task_row = running_row.get("task") if isinstance(running_row, dict) else None
candidate = _origin_from_mapping(task_row, absent="mid_task_no_origin")
if "ref" in candidate and "text" in candidate:
origin = candidate
if "absent" in origin:
origin = _origin_from_task_record(tid) or origin
# The claim lock is the one the UI conversion and the promote handler hold,
# so a card of the SAME owner message cannot claim a project between this
# read and this bind.
with origin_claim_lock():
# This task's EXACT binding is the AUTHORITY, read BEFORE any side effect
# (owner decision B4=A). create_project runs before bind_task_to_project here,
# so a task already bound elsewhere used to mint a second project, mark the
# lease, broadcast it and announce it in chat before the immutable bind
# refused. An UNREADABLE store is disclosed once and treated as "no binding":
# the work continues as it did before this read existed.
try:
bound = str(project_id_for_task(_pool().DRIVE_ROOT, tid, strict=True) or "")
# The project the owner MESSAGE already has answers for a SIBLING task id
# of the same message — but only when this act does not name a different
# room: an explicit name/id is the model's own choice (P13) and forks.
adopted = "" if bound else str(
project_id_for_origin(_pool().DRIVE_ROOT, origin.get("ref"), strict=True) or ""
)
except Exception:
bound, adopted = "", ""
log.warning("project_binding_unreadable: ensure_project_scope for task %s continues "
"as unbound", tid, exc_info=True)
if bound and bound != pid:
# Bound elsewhere: the request is a RENAME of the project this task already
# belongs to, never a second project. Nothing else happens - no create, no
# lease mark, no broadcast, no announcement.
rename = "name_unchanged"
if name:
try:
from ouroboros.projects_registry import get_project
row = get_project(_pool().DRIVE_ROOT, bound) or {}
if str(row.get("name") or "") != name:
update_project(_pool().DRIVE_ROOT, bound, name=name)
rename = "renamed"
except Exception:
rename = "rename_failed"
log.warning("ensure_project_scope: rename of %s to %r failed", bound, name, exc_info=True)
_report_binding_failure(
tid, pid,
ValueError(f"task is already bound to project {bound!r}; it stays there"),
path="ensure_project_scope", reason="project_scope_conflict",
)
return {"status": "rejected", "reason": "project_scope_conflict",
"project_id": bound, "detail": rename}
project = create_project(_pool().DRIVE_ROOT, pid, name=name, origin="ensure_project_scope")
touch_project(_pool().DRIVE_ROOT, pid)
try:
proj_chat = int((project or {}).get("chat_id") or 0)
except (TypeError, ValueError):
proj_chat = 0
try:
bind_task_to_project(_pool().DRIVE_ROOT, tid, pid, proj_chat or None, origin=origin)
except Exception as exc:
# A refused bind leaves the task where it was: stop before the lease
# mark, the broadcast and the announcement (the promote path already
# rejects this way), instead of publishing a project the task is not in.
_report_binding_failure(tid, pid, exc, path="ensure_project_scope")
return {"status": "rejected", "reason": "project_binding_failed",
"project_id": pid, "detail": f"{type(exc).__name__}: {exc}"}
# Make the one-writer-per-project lease recognize THIS already-running task
# as a lane occupant: project_lease reads task["project_id"] from the
# supervisor RUNNING map, which (unlike the promote path that sets it at
# build time) is NOT set for a mid-flight self-scope. Without this, a task
# that self-scopes to project X would not hold X's lane and a concurrent
# X task could be assigned and write the same project. SSOT helper shared
# with the UI api_project_from_task convert path so the two cannot drift.
# A task ADOPTING the project its owner message already has owns the binding
# it just wrote, so its lane FOLLOWS that truth (authority="binding") even
# when the row still carries a derived id from a bare-workspace promote.
try:
from ouroboros.project_lease import mark_task_project
running = getattr(ctx, "RUNNING", None)
pending = getattr(ctx, "PENDING", None)
if isinstance(running, dict):
with _queue_lock:
mark_task_project(running, pending, tid, pid,
authority="binding" if adopted == pid else "")
except Exception:
log.debug("ensure_project_scope: RUNNING project_id update failed for %s", tid, exc_info=True)
if proj_chat:
try:
from supervisor.message_bus import get_bridge
get_bridge().broadcast({"type": "projects_changed", "project_id": pid, "chat_id": proj_chat})
except Exception:
log.debug("ensure_project_scope: projects_changed broadcast failed for %s", pid, exc_info=True)
running = getattr(ctx, "RUNNING", None)
row = running.get(tid) if isinstance(running, dict) else None
_pool()._announce_created_project(
project, tid, task=row.get("task") if isinstance(row, dict) else None,
)
return {"status": "delivered", "project_id": pid, "chat_id": proj_chat,
"reason": "created" if (project or {}).get("created") else ("adopted" if adopted == pid else "attached")}
except Exception as exc:
log.warning("ensure_project_scope: project registration failed for %s", pid, exc_info=True)
return {"status": "rejected", "reason": "project_registration_failed",
"project_id": pid, "detail": f"{type(exc).__name__}: {exc}"}