Merge remote-tracking branch 'origin/master' into paperclip/pr-5732

This commit is contained in:
Frank Song 2026-07-09 10:42:48 +08:00
commit 60eacbef34
4 changed files with 46 additions and 15 deletions

View file

@ -5,6 +5,10 @@
### Fixed
- **A background child stream no longer bumps its parent session to the top of the sidebar.** When a child (branched/background) stream was active, its activity re-sorted the parent session's sidebar time-bucket, yanking the parent around while you were working elsewhere. The sidebar sort no longer keys the parent's bucket off child-stream activity, so parents stay put. Thanks @rodboev. (#5729, #5699)
- **The embedded terminal now recovers from a dropped connection instead of silently freezing.** A transport-level failure on the terminal's output stream (session expired, gateway restarted, network drop) fired an EventSource `error` with no handler, so the terminal froze with no feedback. It now shows a "connection lost, reconnecting…" / "disconnected" line, lets the browser auto-reconnect a recoverable connection, and tears down a permanently-closed one so a restart can reconnect — notifying once per outage so a flapping connection can't flood the pane. Thanks @ai-ag2026. (#5786)
- **The OpenCode Go model picker now lists the current flat-rate catalog.** The static `opencode-go` model snapshot was stale; it's been re-synced from the public opencode.ai/go docs + models endpoint (adds MiniMax M3, Kimi K2.7 Code, GLM-5.2, Qwen 3.7 Max/Plus, and more), keeping preview/free-only Zen models out of the Go picker. Thanks @ai-ag2026. (#5761)
- **The background-task completion drain thread can no longer spin at 100% CPU when the process registry is (re)initializing.** If the registry didn't yet expose its `completion_queue`, the drain loop's broad `except Exception: continue` swallowed the `AttributeError` and immediately retried with no delay — a hot loop. The loop now reads the queue defensively (`getattr(..., None)`) and backs off on the stop event when it's missing, catches `queue.Empty` separately as the normal idle path, and logs (no longer silently swallows) any unexpected queue error before backing off. No delivery latency is added — real completions are still drained on the 1 Hz blocking `get`. Thanks @ai-ag2026. (#5782, #2476, #4633)

View file

@ -6214,7 +6214,6 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere
const childActivitySec=Number(childActivityRaw);
if(Number.isFinite(childActivitySec)&&childActivitySec>Number(parentRow._child_session_latest_at||0)){
parentRow._child_session_latest_at=childActivitySec;
parentRow._sidebar_activity_at=Math.max(Number(parentRow.last_message_at||parentRow.updated_at||parentRow.created_at||0), childActivitySec);
}
const childAttention=childRow&&childRow.attention&&typeof childRow.attention==='object'?childRow.attention:null;
if(!childAttention||!childAttention.kind||!Number.isFinite(Number(childAttention.count))||Number(childAttention.count)<=0) return;

View file

@ -431,6 +431,34 @@ function _connectTerminalOutput(){
try{if(source&&source.readyState!==2)source.close();}catch(_){}
TERMINAL_UI.source=null;
});
// A successful (re)connect clears the notify latch so the NEXT outage notifies
// again — "once per outage", not "once per source lifetime". Without this, a
// source that errors, auto-reconnects, then drops a second time would stay
// silent (guard already true, not CLOSED) — the exact freeze this handler
// prevents.
source.addEventListener('open',()=>{
if(TERMINAL_UI.source!==source)return;
source._terminalErrNotified=false;
});
// Transport-level failures (session expired, gateway killed, network drop)
// fire 'error' rather than a terminal_* event; without this the terminal froze
// with no feedback and no telemetry. Let the browser auto-reconnect a merely
// CONNECTING source (no manual loop/backoff), but surface a permanently CLOSED
// one and tear it down so a restart can reconnect. Notify once per outage so a
// flapping connection can't flood the pane or telemetry.
source.addEventListener('error',()=>{
if(TERMINAL_UI.source!==source)return;
const closed=source.readyState===EventSource.CLOSED;
if(closed||!source._terminalErrNotified){
source._terminalErrNotified=true;
if(typeof recordClientSSEError==='function')recordClientSSEError('terminal',{ready_state:source?source.readyState:null,reason:'terminal EventSource.onerror'});
if(TERMINAL_UI.term)TERMINAL_UI.term.writeln('\r\n[terminal '+(closed?'disconnected':'connection lost, reconnecting…')+']\r\n');
}
if(closed){
try{if(source&&source.readyState!==2)source.close();}catch(_){}
TERMINAL_UI.source=null;
}
});
}
async function _startComposerTerminal(restart=false){

View file

@ -1082,7 +1082,7 @@ console.log(JSON.stringify(rows));
assert "_child_sessions" not in rows[0]
def test_nested_fork_bubbles_latest_child_timestamp_for_sorting():
def test_nested_fork_tracks_latest_child_without_changing_parent_bucket_timestamp():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = f"""
const src = {js!r};
@ -1112,15 +1112,15 @@ console.log(JSON.stringify({{row: rows[0], timestampMs: _sessionTimestampMs(rows
result = json.loads(_run_node(source))
row = result["row"]
assert row["session_id"] == "parent"
# Keep the parent row's persisted timestamp intact, but use newest attached
# child/subagent activity for sidebar sorting/date grouping.
# Keep the parent row's persisted timestamp intact while still recording
# the newest attached child/subagent metadata.
assert row["last_message_at"] == 10
assert row["_child_session_latest_at"] == 20
assert row["_sidebar_activity_at"] == 20
assert result["timestampMs"] == 20_000
assert "_sidebar_activity_at" not in row
assert result["timestampMs"] == 10_000
def test_hidden_archived_lineage_child_bubbles_running_state_and_activity_to_visible_parent():
def test_hidden_archived_lineage_child_bubbles_running_state_and_latest_child_timestamp_without_changing_parent_bucket():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = f"""
const src = {js!r};
@ -1179,11 +1179,11 @@ console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _ses
assert "_child_sessions" not in row
assert row["_child_session_streaming"] is True
assert row["_child_session_latest_at"] == 30
assert row["_sidebar_activity_at"] == 30
assert result["timestampMs"] == 30_000
assert "_sidebar_activity_at" not in row
assert result["timestampMs"] == 10_000
def test_archived_lineage_child_without_root_id_falls_back_to_parent_for_bubbling():
def test_archived_lineage_child_without_root_id_preserves_parent_bucket_timestamp():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = f"""
const src = {js!r};
@ -1241,8 +1241,8 @@ console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _ses
assert "_child_sessions" not in row
assert row["_child_session_streaming"] is True
assert row["_child_session_latest_at"] == 30
assert row["_sidebar_activity_at"] == 30
assert result["timestampMs"] == 30_000
assert "_sidebar_activity_at" not in row
assert result["timestampMs"] == 10_000
def test_non_archived_reference_only_lineage_row_does_not_bubble_to_visible_parent():
@ -1308,7 +1308,7 @@ console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _ses
assert result["timestampMs"] == 10_000
def test_stale_active_stream_id_on_hidden_archived_child_does_not_bubble_streaming_state():
def test_stale_active_stream_id_on_hidden_archived_child_keeps_parent_bucket_and_latest_child_timestamp():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = f"""
const src = {js!r};
@ -1367,8 +1367,8 @@ console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _ses
assert "_child_sessions" not in row
assert "_child_session_streaming" not in row
assert row["_child_session_latest_at"] == 30
assert row["_sidebar_activity_at"] == 30
assert result["timestampMs"] == 30_000
assert "_sidebar_activity_at" not in row
assert result["timestampMs"] == 10_000
def test_collapsed_lineage_segments_do_not_render_as_child_sessions():