mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-21 14:12:28 +00:00
Merge pull request #1867 from razzant/claude/phantom-paused-cards-20261014
Keep answered roots with a paused late phase out of the chat page end
This commit is contained in:
commit
7cb7eabe7d
12 changed files with 147 additions and 14 deletions
|
|
@ -135,7 +135,7 @@ Interactive Chat history (`GET /api/chat/history`) pages a room in its own rows,
|
|||
|
||||
`POST /api/tasks/{id}/events` is the read-only SSE v2 transport over the task's progress, chat, events, tools and supervisor sources (`TaskEventsRequest`, checked by `validate_ingress` before cursor semantics): JSON `{v:2, wait, cursor}`, the cursor holding one byte offset per root/source across the complete archive-plus-live chain. Events follow physical root/source order, never timestamp ranks (ranks mis-order retroactive inserts across reconnects); each row carries a stable `event_id` and the cursor after that row. Frames: `cursor_replay` on a filter change, `cursor_checkpoint` at wait expiry (skipped bytes advance, no progress claimed), `history_gap` for an immutable malformed line, `cursor_unavailable` for an unreadable required segment or a shorter chain, never a reset. The offset guarantee needs archives immutable and retained; only an explicit creation fact (`created_at` stamped before preparation) may skip older archives. The GET form and `iter_task_events` keep timestamp-sorted integer ranks; the CLI uses POST, falling back to GET only on a first-connection HTTP 405 (`gateway/task_events.py`, `cli.py`). The agent-facing `chat_history` tool reads the same live-plus-rotated timeline with provider, account, conversation, thread, actor and date filters.
|
||||
|
||||
History adds an inert `task_evidence` carrier for represented or addressed tasks, in the task's own room only; it spends no conversation quota and supplies no row identity or liveness, yet keeps the whole terminal truth, so reload settles the card from it when it is the task's only terminal row in the window. Quiz-bound `owner_wait` facts project independently of Project question details, so Main and Project waits end on the same recorded answer; confirmed pauses leave task-keyed System rows through `pause_notices` (§5).
|
||||
History adds an inert `task_evidence` carrier for represented tasks or unanswered current work, only in the task's own room; it spends no conversation quota, supplies no row identity or liveness, keeps all terminal truth, so reload settles the card if it is the task's only terminal row in the window. Quiz-bound `owner_wait` facts project apart from Project question details, so Main and Project waits end on one recorded answer; confirmed pauses leave task-keyed System rows through `pause_notices` (§5).
|
||||
|
||||
### Files
|
||||
|
||||
|
|
|
|||
|
|
@ -735,6 +735,7 @@ class ActiveChatActivity(ActiveDirectTurn):
|
|||
project_admission_hold: NotRequired[Dict[str, Any]]
|
||||
pause_cause: NotRequired[str] # budget | owner | restart | sleep | unknown; display only
|
||||
finishing_reviews: NotRequired[bool] # review work an owner Pause lets finish runs on; display only, no count
|
||||
late_work: NotRequired[Dict[str, Any]] # answered root with an open late phase (D10): title, answered_at; display only
|
||||
|
||||
class EffortRange(TypedDict): # min ≤ recommended ≤ max, each an EFFORT_SCALE tier (the tolerant read)
|
||||
min: str
|
||||
|
|
|
|||
|
|
@ -1491,12 +1491,10 @@ def _assemble_history_response(
|
|||
# Annotate only emitted rows; an absent summary must not strand a card.
|
||||
deferred_lineage: set = set()
|
||||
evidence_anchors = {}
|
||||
if recent:
|
||||
from ouroboros.gateway.state import _chat_activities_snapshot_safe
|
||||
evidence_anchors = {str(row["activity_id"]): str(row.get("ts") or "")
|
||||
for row in _chat_activities_snapshot_safe(data_dir, bindings_by_task)
|
||||
if row.get("activity_id") and row.get("chat_id") is not None
|
||||
and row_matches_thread.evidence_matches(row["chat_id"], {**row, "task_id": row["activity_id"]})}
|
||||
if recent: # current work has no history position: its evidence lands at the page end
|
||||
from ouroboros.gateway.state import current_work_in_room
|
||||
evidence_anchors = dict.fromkeys(current_work_in_room(
|
||||
data_dir, bindings_by_task, row_matches_thread.evidence_matches), "")
|
||||
represented = {str(row.get("task_id") or "") for row in messages} | evidence_anchors.keys()
|
||||
for row in messages:
|
||||
parent = str(row.get("parent_task_id") or "")
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from starlette.responses import JSONResponse
|
|||
from ouroboros import get_version
|
||||
from ouroboros.settings_scales import effort_range
|
||||
from ouroboros.gateway._helpers import json_exception, request_drive_root
|
||||
from ouroboros.post_task_checkpoint import post_task_synthesis_is_open
|
||||
from ouroboros.post_task_checkpoint import post_task_synthesis_is_open, post_task_synthesis_is_terminal
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -301,6 +301,11 @@ def _epoch_or_zero(value: Any) -> float:
|
|||
return 0.0
|
||||
|
||||
|
||||
def _answer_stored(synthesis: Any) -> bool:
|
||||
"""A post-answer checkpoint state; the pre-answer cost ``refresh`` pseudo-status is not one."""
|
||||
return post_task_synthesis_is_open(synthesis) or post_task_synthesis_is_terminal(synthesis)
|
||||
|
||||
|
||||
# Exact root/task path -> stat-keyed outcome, finalizing and question display facts.
|
||||
_FINALIZING_MEMO: Dict[tuple, tuple] = {}
|
||||
_FINALIZING_MEMO_MAX = 64
|
||||
|
|
@ -342,6 +347,15 @@ def _task_activity_facts(drive_root: Any, task_id: str) -> dict:
|
|||
display["outcome_axes"] = normalize_outcome_axes(data)
|
||||
if synthesis:
|
||||
display["root_phase_checkpoint"] = {"post_task_synthesis": synthesis}
|
||||
if _answer_stored(synthesis):
|
||||
# An answered root whose late phase remains (D10) names its work where no
|
||||
# card does: Activity shows it beside the saved remainder's controls.
|
||||
from ouroboros.projects_registry import task_presentation_name
|
||||
from ouroboros.terminal_time import terminal_time_fact
|
||||
|
||||
# The executor's own terminal fact or nothing: generic ts is never an answer time.
|
||||
display["late_work"] = {"title": task_presentation_name(data),
|
||||
"answered_at": terminal_time_fact(data).get("occurred_at") or ""}
|
||||
facts = {"finalizing": post_task_synthesis_is_open(synthesis), "late_phase": synthesis,
|
||||
"display": display,
|
||||
# An answered root's census row (D10) carries only its own routing identity.
|
||||
|
|
@ -653,6 +667,19 @@ def _chat_activities_snapshot_safe(drive_root: Any, task_bindings: Any = None, *
|
|||
return activities
|
||||
|
||||
|
||||
def current_work_in_room(drive_root: Any, task_bindings: Any, matches: Callable[[Any, dict], bool]) -> list:
|
||||
"""Census roots a recent history page addresses as current work of its room.
|
||||
|
||||
An answered root whose late phase alone remains (D10: the answer stays, the owner
|
||||
paused only its post-task work) is not current conversation: its card stays beside
|
||||
its delivered answer and Activity offers the saved remainder, so it is never
|
||||
re-addressed at the end of a page that no longer holds its own rows."""
|
||||
return [str(row["activity_id"]) for row in _chat_activities_snapshot_safe(drive_root, task_bindings)
|
||||
if row.get("activity_id") and row.get("chat_id") is not None
|
||||
and not _answer_stored((row.get("root_phase_checkpoint") or {}).get("post_task_synthesis"))
|
||||
and matches(row["chat_id"], {**row, "task_id": row["activity_id"]})]
|
||||
|
||||
|
||||
async def api_state(request: Request) -> JSONResponse:
|
||||
try:
|
||||
from ouroboros.config import (
|
||||
|
|
|
|||
|
|
@ -416,12 +416,12 @@ def test_gateway_contract_endpoint_index_matches_router_and_types(tmp_path):
|
|||
assert _notrequired_fields(ActiveChatActivity) == {
|
||||
"model_waits", "model_activity", "task_attempt", "required_question", "required_question_unavailable", "project_admission_hold", "pause_cause", "owner_wait",
|
||||
"status", "outcome_axes", "reason_code", "root_phase_checkpoint", "timeout_retry_from", "original_task_id",
|
||||
"finishing_reviews",
|
||||
"finishing_reviews", "late_work",
|
||||
}, "ActiveChatActivity adds optional outcome/retry/review evidence without changing required activity facts"
|
||||
activity_fields = get_type_hints(ActiveChatActivity, include_extras=True)
|
||||
question_keys = {"required_question", "required_question_unavailable", "project_admission_hold", "pause_cause", "owner_wait"}
|
||||
question_keys |= {"status", "outcome_axes", "reason_code", "root_phase_checkpoint", "timeout_retry_from", "original_task_id"}
|
||||
question_keys |= {"finishing_reviews"}
|
||||
question_keys |= {"finishing_reviews", "late_work"}
|
||||
assert {key: value for key, value in activity_fields.items() if key not in question_keys} == get_type_hints(ActiveDirectTurn, include_extras=True), (
|
||||
"ActiveChatActivity must mirror ActiveDirectTurn's field shape so one client reducer hydrates both"
|
||||
)
|
||||
|
|
@ -435,7 +435,7 @@ def test_gateway_contract_endpoint_index_matches_router_and_types(tmp_path):
|
|||
assert activity_schema["properties"].pop("owner_wait")["type"] == "object"
|
||||
for field in ("status", "reason_code", "timeout_retry_from", "original_task_id"):
|
||||
assert activity_schema["properties"].pop(field)["type"] == "string"
|
||||
for field in ("outcome_axes", "root_phase_checkpoint"):
|
||||
for field in ("outcome_axes", "root_phase_checkpoint", "late_work"):
|
||||
assert activity_schema["properties"].pop(field)["type"] == "object"
|
||||
assert activity_schema["properties"].pop("finishing_reviews")["type"] == "boolean"
|
||||
assert not question_keys & set(activity_schema["required"])
|
||||
|
|
|
|||
|
|
@ -371,3 +371,27 @@ def test_a_main_rooted_tasks_own_main_notice_still_carries_its_tools(tmp_path):
|
|||
_write(tmp_path / "logs/tools.jsonl", [_tool("root")])
|
||||
rows = _room_read(tmp_path, 1)["messages"]
|
||||
assert [(row["task_id"], row["system_type"]) for row in rows] == [("root", "main_notice"), ("root", "task_evidence")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["completed", "failed"])
|
||||
def test_answered_root_with_a_paused_late_phase_is_not_readdressed_at_the_page_end(tmp_path, monkeypatch, status):
|
||||
# D10: the answer was delivered; only its post-task work waits under the owner's Pause.
|
||||
# Its own rows left the page, so a census anchor would mint a timestamp-free card below
|
||||
# the newest message (14.10 phantom "Done/Failed · Paused · owner pause" cards).
|
||||
_write(tmp_path / "task_results/root.json", [{"_schema_version": 1, "task_id": "root", "status": status,
|
||||
"chat_id": 1, "_is_direct_chat": True, "root_phase_checkpoint": {"post_task_synthesis": "paused"},
|
||||
"completion_observations": {"delivery_counts": {"send_user_message": {"calls": 2}}}}])
|
||||
ensure_current_store(tmp_path)
|
||||
_write(tmp_path / "logs/chat.jsonl", [{"direction": "in", "text": "Latest question", "ts": TS, "chat_id": 1}])
|
||||
paused = {"activity_id": "root", "chat_id": 1, "kind": "direct_chat", "phase": "budget_paused", "pause_cause": "owner",
|
||||
"status": status, "root_phase_checkpoint": {"post_task_synthesis": "paused"}}
|
||||
census = [paused]
|
||||
monkeypatch.setattr("ouroboros.gateway.state._chat_activities_snapshot_safe", lambda *_: census)
|
||||
assert [row["text"] for row in _read(tmp_path)["messages"]] == ["Latest question"]
|
||||
# Positive control: unanswered current work of the room keeps its evidence at the page end,
|
||||
# including a turn whose checkpoint holds only the pre-answer cost "refresh" pseudo-status.
|
||||
census[:] = [{"activity_id": "root", "chat_id": 1, "kind": "direct_chat", "phase": "working",
|
||||
"root_phase_checkpoint": {"post_task_synthesis": "refresh"}}]
|
||||
_write(tmp_path / "logs/tools.jsonl", [_tool("root")])
|
||||
carrier = _read(tmp_path)["messages"][-1]
|
||||
assert carrier["system_type"] == "task_evidence" and carrier["task_id"] == "root" and carrier["ts"] == ""
|
||||
|
|
|
|||
|
|
@ -250,6 +250,25 @@ def test_pause_after_delivery_saves_the_remainder_and_soft_resume_reassesses_cur
|
|||
assert not again.get("ok") and f.light.kinds() == kinds
|
||||
|
||||
|
||||
def test_paused_remainder_is_named_in_activity_and_never_readdressed_as_current_chat_work(late):
|
||||
"""The census keeps the answered root (Resume/Stop stay reachable) and names its work and
|
||||
answer time for Activity; history no longer addresses it as current conversation, which
|
||||
minted a timestamp-free card below the newest message once its own rows left the page."""
|
||||
f = late
|
||||
from ouroboros.gateway.state import _chat_activities_snapshot_safe, current_work_in_room
|
||||
from ouroboros.projects_registry import task_presentation_name
|
||||
from ouroboros.terminal_time import terminal_time_fact
|
||||
|
||||
_pause_during_read(f)
|
||||
[row] = [r for r in _chat_activities_snapshot_safe(f.root, {}, direct_turns=[]) if r["activity_id"] == ROOT]
|
||||
assert row["phase"] == "budget_paused" and row["pause_cause"] == "owner"
|
||||
result = _row(f)
|
||||
assert row["late_work"]["title"] == task_presentation_name(result)
|
||||
# Only the executor's own terminal fact dates the answer; generic ts never does.
|
||||
assert row["late_work"]["answered_at"] == (terminal_time_fact(result).get("occurred_at") or "")
|
||||
assert ROOT not in current_work_in_room(f.root, {}, lambda *_: True)
|
||||
|
||||
|
||||
def test_fully_finished_root_reports_pause_inapplicable_and_buys_nothing(late):
|
||||
"""Positive no-extra-work control: a genuinely finished root is not paused."""
|
||||
f = late
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ def test_active_chat_activity_contract_mirrors_direct_turn_shape():
|
|||
additional_keys = {
|
||||
"required_question", "required_question_unavailable", "project_admission_hold", "pause_cause", "owner_wait",
|
||||
"status", "outcome_axes", "reason_code", "root_phase_checkpoint", "timeout_retry_from", "original_task_id",
|
||||
"finishing_reviews",
|
||||
"finishing_reviews", "late_work",
|
||||
}
|
||||
assert {key: value for key, value in fields.items() if key not in additional_keys} == ActiveDirectTurn.__annotations__
|
||||
assert set(fields) - set(ActiveDirectTurn.__annotations__) == additional_keys
|
||||
|
|
|
|||
|
|
@ -38,3 +38,36 @@ def test_census_carries_typed_pause_cause_for_a_parked_direct_root(tmp_path, mon
|
|||
row = next(row for row in rows if row["activity_id"] == "paused")
|
||||
assert row["kind"] == "direct_chat" and row["phase"] == "budget_paused"
|
||||
assert row["pause_cause"] == cause
|
||||
|
||||
|
||||
@pytest.mark.parametrize("synthesis,named", [("paused", True), ("pending_once", True), ("refresh", False), ("", False)])
|
||||
def test_only_a_stored_answer_names_its_late_work(tmp_path, synthesis, named):
|
||||
"""Activity names an answered root's remaining work; a working turn whose checkpoint holds
|
||||
only the pre-answer cost "refresh" pseudo-status is not answered and is never so labelled."""
|
||||
from ouroboros.gateway.state import _task_activity_facts
|
||||
from ouroboros.task_results import write_task_result
|
||||
|
||||
write_task_result(tmp_path, "root", "running" if synthesis in {"refresh", ""} else "completed",
|
||||
suggested_name="Audit the release", chat_id=1,
|
||||
**({"root_phase_checkpoint": {"post_task_synthesis": synthesis}} if synthesis else {}))
|
||||
display = _task_activity_facts(tmp_path, "root")["display"]
|
||||
assert ("late_work" in display) is named
|
||||
if named:
|
||||
assert display["late_work"]["title"] == "Audit the release"
|
||||
|
||||
|
||||
def test_late_work_dates_the_answer_only_by_the_executor_terminal_fact(tmp_path):
|
||||
from ouroboros.gateway.state import _task_activity_facts
|
||||
from ouroboros.task_results import write_task_result
|
||||
from ouroboros.terminal_time import terminal_time_fact
|
||||
|
||||
paused = {"root_phase_checkpoint": {"post_task_synthesis": "paused"}}
|
||||
observed = write_task_result(tmp_path, "observed", "completed", _terminal_observed=True,
|
||||
suggested_name="Audit the release", **paused)
|
||||
occurred = terminal_time_fact(observed)["occurred_at"]
|
||||
assert occurred, "the executor observed this terminal"
|
||||
assert _task_activity_facts(tmp_path, "observed")["display"]["late_work"] == {
|
||||
"title": "Audit the release", "answered_at": occurred}
|
||||
# A record without the executor's own observation keeps an unknown time, never its row ts.
|
||||
write_task_result(tmp_path, "legacy", "completed", suggested_name="Legacy", **paused)
|
||||
assert _task_activity_facts(tmp_path, "legacy")["display"]["late_work"]["answered_at"] == ""
|
||||
|
|
|
|||
|
|
@ -32,16 +32,29 @@ function esc(value) {
|
|||
|
||||
const getJson = (url) => fetchJson(url, { cache: 'no-store' });
|
||||
|
||||
/** An answered root whose late work remains (D10) names its own work, when it answered,
|
||||
* and that only the work left after the delivered answer waits — never a generic turn. */
|
||||
function lateWorkHtml(a, late) {
|
||||
const at = new Date(String(late.answered_at || ''));
|
||||
const iso = Number.isNaN(at.getTime()) ? '' : at.toISOString();
|
||||
const when = iso ? ` <time datetime="${esc(iso)}" title="${esc(iso)}">${esc(at.toLocaleString([], {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }))}</time>` : '';
|
||||
const state = { budget_paused: 'paused', budget_pausing: 'pausing', unknown: 'status unknown' }[a.phase] || 'running';
|
||||
// The state leads: a narrow row's ellipsis may cut the time, never what waits.
|
||||
return { name: esc(late.title || 'Answered task'), sub: `Remaining work ${state} · answer delivered${when}` };
|
||||
}
|
||||
|
||||
/** A census-only live root (a direct turn, or an answered root whose late work the owner
|
||||
* paused, D10): its phase, and the shared control — Resume once the census says Paused. */
|
||||
export function liveActivityRowHtml(a, nowMs = Date.now()) {
|
||||
const names = { direct_chat: 'Direct turn', managed_task: 'Managed task' };
|
||||
const started = Number(a.started_at) || 0;
|
||||
const elapsed = started > 0 ? ` · ${Math.max(0, Math.round(nowMs / 1000 - started))}s` : '';
|
||||
const late = a.late_work && typeof a.late_work === 'object' ? lateWorkHtml(a, a.late_work) : null;
|
||||
return `<div class="activity-row">
|
||||
<div class="activity-row-main">
|
||||
<span class="activity-name">${esc(names[a.kind] || 'Live turn')}</span>
|
||||
<span class="activity-sub">${esc(a.phase || '')}${elapsed}</span>
|
||||
<span class="activity-name">${late ? late.name : esc(names[a.kind] || 'Live turn')}</span>
|
||||
<span class="activity-sub">${late ? late.sub : `${esc(a.phase || '')}${elapsed}`}</span>
|
||||
</div>
|
||||
<div class="activity-row-actions">
|
||||
<button type="button" class="btn btn-xs btn-danger" data-act="task-control" data-id="${esc(a.activity_id || '')}" data-root="1"${a.phase === 'budget_paused' ? ' data-budget-paused="1"' : ''}>${esc(TASK_CONTROL_TRIGGER_LABEL)}</button>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
* @property {Object=} project_admission_hold // accepted unstarted work waiting for original Project authority
|
||||
* @property {string=} pause_cause // budget | owner | restart | sleep | unknown; display only
|
||||
* @property {boolean=} finishing_reviews // review work an owner Pause lets finish runs on; display only, no count
|
||||
* @property {Object=} late_work // answered root with an open late phase (D10): title, answered_at; display only
|
||||
* @property {Object=} required_question // read-only pointer to the current required Project quiz
|
||||
* @property {boolean=} required_question_unavailable // a recorded owner-question wait whose detail could not be read: possibly blocked, never "no question"
|
||||
* @property {Object.<string,Object>=} model_waits
|
||||
|
|
|
|||
|
|
@ -48,6 +48,23 @@ test('the census-only Activity row offers Resume only for a saved remainder', ()
|
|||
assert.match(pausing, /data-root="1"/);
|
||||
});
|
||||
|
||||
test('an answered root names its work and that only the remainder waits, not a generic turn', () => {
|
||||
const row = (phase, late_work) => liveActivityRowHtml({ activity_id: 'late-root', kind: 'direct_chat', phase,
|
||||
started_at: 0, late_work }, 1_700_000_100_000);
|
||||
const paused = row('budget_paused', { title: 'Audit <the> release', answered_at: '2026-10-13T21:11:55Z' });
|
||||
assert.match(paused, /<span class="activity-name">Audit <the> release<\/span>/);
|
||||
assert.match(paused, /Remaining work paused · answer delivered <time datetime="2026-10-13T21:11:55.000Z"[^>]*>[^<]+<\/time>/);
|
||||
assert.doesNotMatch(paused, /Direct turn|budget_paused/);
|
||||
assert.match(paused, /data-budget-paused="1"/, 'its saved remainder keeps the shared Resume');
|
||||
const pausing = row('budget_pausing', { title: 'Audit', answered_at: 'not a time' });
|
||||
assert.match(pausing, /Remaining work pausing · answer delivered<\/span>/, 'an unknown answer time is omitted');
|
||||
assert.doesNotMatch(pausing, /data-budget-paused/);
|
||||
assert.match(row('budget_paused', {}), /<span class="activity-name">Answered task<\/span>/);
|
||||
// Positive control: a live direct turn without a late phase keeps its plain census line.
|
||||
assert.match(liveActivityRowHtml({ activity_id: 'turn', kind: 'direct_chat', phase: 'working', started_at: 0 }),
|
||||
/<span class="activity-name">Direct turn<\/span>\s*<span class="activity-sub">working<\/span>/);
|
||||
});
|
||||
|
||||
test('Resume of late work says it continues in place, never that it returns to the queue', async () => {
|
||||
const { resumeTaskAction } = await import('../modules/task_control_menu.js');
|
||||
const toasts = [];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue