Skip to content

Commit 6efaa0f

Browse files
committed
feat(craft): outputs reconciler wired into every terminal branch
Diffs the sandbox outputs manifest against the artifact rows at turn end, while the turn still holds its prompt slot, so the next turn never starts against a half-written index. Upserts only rows whose content or type moved, keeping turn_index the turn that last changed each artifact, flags vanished paths in one statement, and announces changed rows over Redis so an attached stream can render artifact packets promptly. Incomplete manifests, truncated or partially unreadable, are never reduced: the rows stay the baseline and the next turn end self-heals. Wired into the interactive executor's owned terminal branches and every owned scheduled-task terminal status, with the docker manifest exec bounded by timeout(1) to match the kubernetes RPC bound.
1 parent 7c972f3 commit 6efaa0f

8 files changed

Lines changed: 720 additions & 8 deletions

File tree

backend/onyx/server/features/build/db/artifact.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from uuid import UUID
1111

12-
from sqlalchemy import case, desc, func, select
12+
from sqlalchemy import case, desc, func, select, update
1313
from sqlalchemy.dialects.postgresql import insert as pg_insert
1414
from sqlalchemy.orm import Session
1515

@@ -94,6 +94,35 @@ def mark_artifact_deleted(
9494
return artifact
9595

9696

97+
def mark_artifacts_deleted(
98+
db_session: Session,
99+
*,
100+
session_id: UUID,
101+
paths: list[str],
102+
) -> list[Artifact]:
103+
"""Flag every live row whose path vanished from the manifest, in one
104+
statement. Returns the rows that actually flipped."""
105+
if not paths:
106+
return []
107+
stmt = (
108+
update(Artifact)
109+
.where(
110+
Artifact.session_id == session_id,
111+
Artifact.path.in_(paths),
112+
Artifact.deleted.is_(False),
113+
)
114+
.values(deleted=True, updated_at=func.now())
115+
.returning(Artifact)
116+
)
117+
return list(
118+
db_session.execute(
119+
select(Artifact)
120+
.from_statement(stmt)
121+
.execution_options(populate_existing=True)
122+
).scalars()
123+
)
124+
125+
97126
def get_session_artifacts(
98127
db_session: Session,
99128
*,

backend/onyx/server/features/build/interactive_turns/executor.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333
get_active_turn,
3434
touch_turn,
3535
)
36+
from onyx.server.features.build.outputs_reconciler import (
37+
announce_artifacts,
38+
reconcile_session_outputs,
39+
)
3640
from onyx.server.features.build.sandbox.event_schema import (
3741
ActivityTimeoutError,
3842
PromptResponse,
@@ -292,6 +296,24 @@ def persist_turn_error(message: str) -> None:
292296
"Failed to persist turn error message for turn %s", turn_id
293297
)
294298

299+
def reconcile_outputs() -> None:
300+
"""Best-effort outputs index update on every owned terminal branch,
301+
while the prompt slot is still held. Failures never block
302+
finish_turn."""
303+
try:
304+
packets = reconcile_session_outputs(
305+
db_session,
306+
get_sandbox_manager(),
307+
sandbox_id=sandbox.id,
308+
session_id=session_id,
309+
turn_index=turn_index,
310+
)
311+
db_session.commit()
312+
announce_artifacts(session_id, packets, cache)
313+
except Exception:
314+
logger.exception("Outputs reconcile failed for turn %s", turn_id)
315+
db_session.rollback()
316+
295317
prompt_slot_cm = session_manager.prompt_slot(
296318
sandbox.id,
297319
session_id,
@@ -359,6 +381,7 @@ def persist_turn_error(message: str) -> None:
359381
if interrupt_requested():
360382
session_manager.finalize_persist(session_id, state)
361383
db_session.commit()
384+
reconcile_outputs()
362385
finish_turn(
363386
cache=cache,
364387
turn_id=turn_id,
@@ -414,6 +437,8 @@ def drive_one_prompt(
414437
persist_turn_error(
415438
"This turn was interrupted and could not finish."
416439
)
440+
# No reconcile here: the lease is lost, so the slot
441+
# holder owns the index now.
417442
finish_turn(
418443
cache=cache,
419444
turn_id=turn_id,
@@ -441,6 +466,7 @@ def drive_one_prompt(
441466
session_manager.finalize_persist(session_id, state)
442467
db_session.commit()
443468
persist_turn_error(sandbox_event.message)
469+
reconcile_outputs()
444470
finish_turn(
445471
cache=cache,
446472
turn_id=turn_id,
@@ -491,6 +517,7 @@ def drive_one_prompt(
491517

492518
session_manager.finalize_persist(session_id, state)
493519
db_session.commit()
520+
reconcile_outputs()
494521

495522
if deadline_exceeded:
496523
persist_turn_error(
@@ -543,6 +570,8 @@ def drive_one_prompt(
543570
except Exception:
544571
logger.exception("Failed to finalize persistence for turn %s", turn_id)
545572
persist_turn_error("This turn failed unexpectedly.")
573+
if not slot.lost:
574+
reconcile_outputs()
546575
finish_turn(
547576
cache=cache,
548577
turn_id=turn_id,
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""Reconciles a session's outputs tree into artifact rows at turn end.
2+
3+
One call per owned terminal branch, while the turn still holds its prompt
4+
slot, so the next turn never starts against a half-written index. Idempotent
5+
because the rows are the baseline: a run skipped by a hard crash self-heals
6+
at the next turn end. An incomplete manifest is skipped outright, missing
7+
entries would flap rows deleted and directory hashes stale.
8+
"""
9+
10+
from uuid import UUID
11+
12+
from pydantic import ValidationError
13+
from sqlalchemy.orm import Session
14+
15+
from onyx.cache.interface import CacheBackend
16+
from onyx.db.models import Artifact
17+
from onyx.server.features.build.artifact_classifier import (
18+
OutputEntry,
19+
derive_artifacts,
20+
)
21+
from onyx.server.features.build.db.artifact import (
22+
get_session_artifacts,
23+
mark_artifacts_deleted,
24+
upsert_artifact,
25+
)
26+
from onyx.server.features.build.packets import ArtifactPacket
27+
from onyx.server.features.build.sandbox.base import SandboxManager
28+
from onyx.utils.logger import setup_logger
29+
30+
logger = setup_logger()
31+
32+
_ANNOUNCE_TTL_S = 60
33+
# The announce exists so an attached stream can render cards promptly. The
34+
# index refetch at turn end is the completeness guarantee, so a huge first
35+
# reconcile does not need every row on the wire.
36+
_MAX_ANNOUNCED = 50
37+
38+
39+
def _announce_key(session_id: UUID) -> str:
40+
return f"craft:artifact:announce:{session_id}"
41+
42+
43+
def announce_artifacts(
44+
session_id: UUID, packets: list[ArtifactPacket], cache: CacheBackend
45+
) -> None:
46+
"""Hand changed rows to the SSE stream attached to this session.
47+
48+
Called after commit, so a card never announces a row a reader cannot
49+
fetch.
50+
"""
51+
if not packets:
52+
return
53+
key = _announce_key(session_id)
54+
for packet in packets[:_MAX_ANNOUNCED]:
55+
cache.rpush(key, packet.model_dump_json())
56+
cache.expire(key, _ANNOUNCE_TTL_S)
57+
58+
59+
def pop_artifact_announcement(
60+
session_id: UUID, timeout_s: int, cache: CacheBackend
61+
) -> ArtifactPacket | None:
62+
"""BLPOP one announced artifact. None on timeout or unparseable payload."""
63+
result = cache.blpop([_announce_key(session_id)], timeout_s)
64+
if result is None:
65+
return None
66+
_key, value = result
67+
if isinstance(value, bytes):
68+
value = value.decode()
69+
try:
70+
return ArtifactPacket.model_validate_json(value)
71+
except ValidationError:
72+
logger.warning("artifact: unparseable announce %r for %s", value, session_id)
73+
return None
74+
75+
76+
def reconcile_session_outputs(
77+
db_session: Session,
78+
sandbox_manager: SandboxManager,
79+
*,
80+
sandbox_id: UUID,
81+
session_id: UUID,
82+
turn_index: int | None,
83+
) -> list[ArtifactPacket]:
84+
"""Diff the sandbox outputs manifest against the artifact rows.
85+
86+
Upserts rows whose content or type moved or whose path came back, flags
87+
rows whose path vanished, and leaves untouched rows alone so
88+
``turn_index`` keeps naming the turn that last changed each artifact.
89+
Flushes only, the caller owns the commit and announces the returned
90+
packets after it.
91+
92+
Returns an empty list without touching rows when the manifest is
93+
unavailable or incomplete: the rows stay the baseline and the next turn
94+
end self-heals. Unreadable entries count as incomplete because the
95+
delete pass would otherwise flag rows the walk merely failed to see.
96+
"""
97+
try:
98+
manifest = sandbox_manager.get_outputs_manifest(
99+
sandbox_id=sandbox_id, session_id=session_id
100+
)
101+
except (RuntimeError, ValidationError):
102+
logger.warning(
103+
"Outputs manifest unavailable for session %s; skipping reconcile",
104+
session_id,
105+
exc_info=True,
106+
)
107+
return []
108+
if manifest.truncated or manifest.skipped_unreadable:
109+
logger.warning(
110+
"Outputs manifest incomplete for session %s "
111+
"(truncated=%s skipped_unreadable=%d); skipping reconcile",
112+
session_id,
113+
manifest.truncated,
114+
manifest.skipped_unreadable,
115+
)
116+
return []
117+
118+
derived = derive_artifacts(
119+
[
120+
OutputEntry(
121+
path=entry.path,
122+
is_directory=entry.is_directory,
123+
size=entry.size,
124+
mtime_ns=entry.mtime_ns,
125+
sha256=entry.sha256,
126+
)
127+
for entry in manifest.entries
128+
]
129+
)
130+
rows_by_path = {
131+
row.path: row
132+
for row in get_session_artifacts(
133+
db_session, session_id=session_id, include_deleted=True
134+
)
135+
}
136+
137+
packets: list[ArtifactPacket] = []
138+
for artifact in derived:
139+
row = rows_by_path.get(artifact.path)
140+
unchanged = (
141+
row is not None
142+
and not row.deleted
143+
and row.content_hash == artifact.content_hash
144+
and row.type == artifact.type
145+
)
146+
if unchanged:
147+
continue
148+
updated = upsert_artifact(
149+
db_session,
150+
session_id=session_id,
151+
artifact_type=artifact.type,
152+
path=artifact.path,
153+
name=artifact.name,
154+
turn_index=turn_index,
155+
size_bytes=artifact.size_bytes,
156+
content_hash=artifact.content_hash,
157+
)
158+
packets.append(_packet_for(updated))
159+
160+
derived_paths = {artifact.path for artifact in derived}
161+
vanished = [
162+
path
163+
for path, row in rows_by_path.items()
164+
if path not in derived_paths and not row.deleted
165+
]
166+
packets.extend(
167+
_packet_for(row)
168+
for row in mark_artifacts_deleted(
169+
db_session, session_id=session_id, paths=vanished
170+
)
171+
)
172+
return packets
173+
174+
175+
def _packet_for(row: Artifact) -> ArtifactPacket:
176+
return ArtifactPacket(
177+
artifact_id=row.id,
178+
session_id=row.session_id,
179+
path=row.path,
180+
name=row.name,
181+
artifact_type=row.type,
182+
version=row.version,
183+
turn_index=row.turn_index,
184+
size_bytes=row.size_bytes,
185+
deleted=row.deleted,
186+
)

backend/onyx/server/features/build/packets.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,8 @@
1818
- prompt_response: Agent finished processing
1919
- error: An error occurred
2020
21-
Custom Onyx packets (defined here):
22-
- error: Onyx-specific errors (e.g., session not found)
23-
- subagent_started: A child opencode session was created under a parent turn
21+
The custom Onyx packet classes below are the authoritative list; the
22+
``BuildPacket`` union at the bottom names them all.
2423
"""
2524

2625
from datetime import datetime, timezone
@@ -29,6 +28,8 @@
2928

3029
from pydantic import BaseModel, Field
3130

31+
from onyx.db.enums import ArtifactType
32+
3233
# =============================================================================
3334
# Base Packet Type
3435
# =============================================================================
@@ -89,6 +90,26 @@ class ConnectAppRequestPacket(BasePacket):
8990
reason: str | None = None
9091

9192

93+
class ArtifactPacket(BasePacket):
94+
"""An artifact row was produced, changed, or deleted at turn end.
95+
96+
Carries the full row, unlike the ids-only approval packet, so a consumer
97+
can render a card with no round trip. The version is pinned at announce
98+
time and the index refetch at turn end is the completeness guarantee.
99+
"""
100+
101+
type: Literal["artifact"] = "artifact"
102+
artifact_id: UUID
103+
session_id: UUID
104+
path: str
105+
name: str
106+
artifact_type: ArtifactType
107+
version: int
108+
turn_index: int | None
109+
size_bytes: int | None
110+
deleted: bool
111+
112+
92113
class ContextUsagePacket(BasePacket):
93114
type: Literal["context_usage"] = "context_usage"
94115
used_tokens: int
@@ -109,6 +130,7 @@ class CompactionPacket(BasePacket):
109130
| ApprovalRequestedPacket
110131
| SubagentStartedPacket
111132
| ConnectAppRequestPacket
133+
| ArtifactPacket
112134
| ContextUsagePacket
113135
| CompactionPacket
114136
)

backend/onyx/server/features/build/sandbox/docker/docker_sandbox_manager.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1637,6 +1637,11 @@ def get_outputs_manifest(
16371637
result = _run_in_container_as_sandbox_user(
16381638
container,
16391639
[
1640+
# exec_run has no timeout, so a pathological walk must
1641+
# not hang the turn's terminal handling. 30s matches the
1642+
# kubernetes RPC bound.
1643+
"/usr/bin/timeout",
1644+
"30",
16401645
"/usr/local/bin/python3",
16411646
"-E",
16421647
"-s",

0 commit comments

Comments
 (0)