Skip to content

Commit d1f62b0

Browse files
jgnagyclaude
andauthored
fix: don't pin dashboard to a non-orchestrator session (#12)
* fix: don't pin dashboard to a non-orchestrator session The dashboard discovers delegates by scanning the *parent* (orchestrator) session's messages for "Task <id> started in background" and never re-resolves that parent once pinned. Two paths could pin the wrong session, leaving the UI stuck on "Spawning delegates..." with 0 delegates forever — even while delegate messages stream onto the wall (the wall is keyed off GOOSE_GTWALL_FILE, so it keeps working independently): 1. dashboard `resolve_session()` trusted `AGENT_SESSION_ID` unconditionally and passed it as `--session`, bypassing goosetown-ui's own discovery. goose exports a *terminal* session id into shell subprocesses, so launching the dashboard from a goose shell pinned that terminal session — which has no delegates. 2. `sessions_tree_watcher()` reads `parent_id` once and never re-resolves, so any wrong/early pin is permanent. Fix: - resolve_session(): only trust AGENT_SESSION_ID when it actually has delegate activity; otherwise prefer SQL discovery of a delegate-bearing session, and fall back to AGENT_SESSION_ID / most-recent user session only as a last resort. Adds a `session_has_delegates()` helper. - sessions_tree_watcher(): when the pinned parent yields 0 children, re-resolve via find_parent_session() and adopt a candidate only if it actually has children — self-healing without thrashing between empty sessions. Verified against a real sessions.db that exhibited the bug: with AGENT_SESSION_ID set to a terminal session, resolve_session now returns the orchestrator session, and the watcher re-resolves a wrongly-pinned parent to the orchestrator (6 delegates) while leaving a correct parent untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: scope dashboard session resolution to the instance's wall All goosetowns on a machine share one sessions.db, and sessions carry no instance/wall identifier. The previous resolution ("most recent delegate-bearing user session") and the self-heal added in the prior commit would, with multiple concurrent instances, resolve or re-pin to *another* instance's orchestrator — e.g. instance A's dashboard adopting instance B's delegates whenever A's orchestrator momentarily had 0 children. Use the wall as the per-instance anchor. An instance's wall lists its delegates' gtwall IDs (senders), and the orchestrator's delegate-spawn messages declare those same IDs (already parsed by build_sender_session_map). Matching the two uniquely identifies the orchestrator for this instance: - goosetown-ui: add scoped_parent_session(cur, senders) — pick the candidate whose spawned delegates overlap most with this wall's senders (ties → most recent), else None. find_parent_session() now prefers it; main() lets a positive wall-scoped match override an explicit --session (the launcher may pass a terminal/other-instance id); the self-heal re-resolves on 0 children OR when the current parent's delegates don't match the wall's senders (wrong instance), using only the scoped lookup — so it can adopt this instance's orchestrator and never another's. - dashboard: resolve_session() prefers a delegate-bearing user session that references this wall (WALL_ID, already sanitized to [A-Za-z0-9_-]) before the generic most-recent query. Best-effort hint; goosetown-ui is authoritative. Verified with a synthetic two-instance sessions.db: wall-A senders resolve to orchestrator A and wall-B senders to orchestrator B even though B is more recently updated; a wall with no delegate senders resolves to None (never a cross-instance guess); and bash resolve_session honors WALL_ID over a conflicting AGENT_SESSION_ID. Single-instance behavior and tests/test_dashboard.sh (4/4) unchanged. Known limitation: if two concurrent instances spawn delegates with identical gtwall IDs, the most-overlap-then-most-recent tiebreak may still pick the wrong one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b5ddb5f commit d1f62b0

2 files changed

Lines changed: 159 additions & 27 deletions

File tree

dashboard

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,22 +31,77 @@ SCREEN_NAME="goosetown-ui-${WALL_ID}"
3131
PORTFILE="${WALLS_DIR}/dashboard-${WALL_ID}.port"
3232

3333
# ── Session resolution ──────────────────────────────────────────────────────
34+
# True if the given session has delegate activity (a "started in background"
35+
# message), i.e. it looks like an orchestrator rather than a bare terminal/chat
36+
# session. Returns non-zero on empty input, no DB, or no delegate activity.
37+
session_has_delegates() {
38+
local sid="$1"
39+
[[ -n "$sid" ]] || return 1
40+
command -v sqlite3 &>/dev/null && [[ -f "$DB_PATH" ]] || return 1
41+
# Escape single quotes to keep the literal safe (session ids are YYYYMMDD_N).
42+
local esc="${sid//\'/\'\'}"
43+
local n
44+
n=$(sqlite3 "$DB_PATH" "
45+
SELECT COUNT(*) FROM messages
46+
WHERE session_id = '$esc'
47+
AND content_json LIKE '%started in background%';
48+
" 2>/dev/null || echo 0)
49+
[[ "$n" =~ ^[0-9]+$ && "$n" -gt 0 ]]
50+
}
51+
3452
resolve_session() {
3553
local sid="${AGENT_SESSION_ID:-}"
3654

37-
if [[ -z "$sid" ]] && command -v sqlite3 &>/dev/null && [[ -f "$DB_PATH" ]]; then
38-
# Prefer orchestrator session (has delegates)
39-
sid=$(sqlite3 "$DB_PATH" "
40-
SELECT DISTINCT s.id FROM sessions s
41-
JOIN messages m ON m.session_id = s.id
42-
WHERE s.session_type = 'user'
43-
AND m.role = 'user'
44-
AND m.content_json LIKE '%started in background%'
45-
AND s.updated_at > datetime('now', '-24 hours')
46-
ORDER BY s.updated_at DESC LIMIT 1;
47-
" 2>/dev/null || true)
48-
49-
# Fallback: any recent user session
55+
if command -v sqlite3 &>/dev/null && [[ -f "$DB_PATH" ]]; then
56+
# Prefer the orchestrator bound to THIS instance's wall. Every goosetown on the
57+
# machine shares one sessions.db with no instance/wall column, so without scoping
58+
# we'd risk pinning a *different* instance's orchestrator. The wall is the per-
59+
# instance anchor; an orchestrator launched with this wall records its path in its
60+
# messages. WALL_ID is already sanitized to [A-Za-z0-9_-], so it's injection-safe.
61+
# (goosetown-ui does the authoritative wall-scoped match; this is a best-effort hint.)
62+
if [[ -n "${WALL_ID:-}" ]]; then
63+
local wall_scoped
64+
wall_scoped=$(sqlite3 "$DB_PATH" "
65+
SELECT DISTINCT s.id FROM sessions s
66+
JOIN messages m ON m.session_id = s.id
67+
WHERE s.session_type = 'user'
68+
AND m.role = 'user'
69+
AND m.content_json LIKE '%started in background%'
70+
AND s.updated_at > datetime('now', '-24 hours')
71+
AND EXISTS (SELECT 1 FROM messages w
72+
WHERE w.session_id = s.id AND w.content_json LIKE '%${WALL_ID}%')
73+
ORDER BY s.updated_at DESC LIMIT 1;
74+
" 2>/dev/null || true)
75+
[[ -n "$wall_scoped" ]] && sid="$wall_scoped"
76+
fi
77+
78+
# Otherwise don't trust AGENT_SESSION_ID if it points at a session with no delegates
79+
# (e.g. goose exports a *terminal* session id into shell subprocesses). Pinning such
80+
# a session leaves the dashboard stuck on "Spawning delegates..." forever because
81+
# find_children() never matches. Fall through to discovery instead.
82+
if [[ -z "${wall_scoped:-}" && -n "$sid" ]] && ! session_has_delegates "$sid"; then
83+
sid=""
84+
fi
85+
86+
# Prefer the most recent orchestrator session (one that has delegates).
87+
if [[ -z "$sid" ]]; then
88+
sid=$(sqlite3 "$DB_PATH" "
89+
SELECT DISTINCT s.id FROM sessions s
90+
JOIN messages m ON m.session_id = s.id
91+
WHERE s.session_type = 'user'
92+
AND m.role = 'user'
93+
AND m.content_json LIKE '%started in background%'
94+
AND s.updated_at > datetime('now', '-24 hours')
95+
ORDER BY s.updated_at DESC LIMIT 1;
96+
" 2>/dev/null || true)
97+
fi
98+
99+
# Fall back to AGENT_SESSION_ID even without delegate activity — covers a fresh
100+
# orchestrator that hasn't spawned delegates yet (goosetown-ui self-heals the
101+
# pin once delegates appear). Then to any recent user session.
102+
if [[ -z "$sid" ]]; then
103+
sid="${AGENT_SESSION_ID:-}"
104+
fi
50105
if [[ -z "$sid" ]]; then
51106
sid=$(sqlite3 "$DB_PATH" "
52107
SELECT id FROM sessions WHERE session_type = 'user'

scripts/goosetown-ui

Lines changed: 91 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -94,18 +94,64 @@ def normalize_epoch(ts) -> float:
9494

9595

9696
# ── Session Discovery ──────────────────────────────────────────────────────
97-
def find_parent_session(cur) -> str | None:
97+
def wall_sender_ids_from_file(wall_file: str | None) -> set[str]:
98+
"""IDs that have posted to a wall file (column 2 of each line)."""
99+
if not wall_file or not os.path.isfile(wall_file):
100+
return set()
101+
senders: set[str] = set()
102+
try:
103+
with open(wall_file, "r", encoding="utf-8", errors="replace") as f:
104+
for line in f:
105+
parts = line.split("|", 2)
106+
if len(parts) >= 2 and parts[1].strip():
107+
senders.add(parts[1].strip())
108+
except OSError:
109+
pass
110+
return senders
111+
112+
113+
def _orchestrator_candidates(cur) -> list[str]:
114+
"""User sessions with delegate activity in the last 24h, most recent first."""
98115
cur.execute("""
99116
SELECT DISTINCT s.id FROM sessions s
100117
JOIN messages m ON m.session_id = s.id
101118
WHERE s.session_type = 'user'
102119
AND m.role = 'user'
103120
AND m.content_json LIKE '%started in background%'
104121
AND s.updated_at > datetime('now', '-24 hours')
105-
ORDER BY s.updated_at DESC LIMIT 1
122+
ORDER BY s.updated_at DESC
106123
""")
107-
if row := cur.fetchone():
108-
return row[0]
124+
return [row[0] for row in cur.fetchall()]
125+
126+
127+
def scoped_parent_session(cur, senders: set[str]) -> str | None:
128+
"""The orchestrator bound to THIS instance, found by matching the gtwall IDs it
129+
spawned against the IDs that have posted to this instance's wall.
130+
131+
All goosetowns on a machine share one sessions.db, and sessions carry no
132+
instance/wall identifier. The wall file is the per-instance anchor: matching its
133+
senders to a candidate's spawned delegates is what stops concurrent instances from
134+
resolving to each other's orchestrator. Picks the candidate with the most overlap
135+
(ties → most recent); returns None if nothing matches this wall's senders.
136+
"""
137+
if not senders:
138+
return None
139+
best, best_score = None, 0
140+
for cid in _orchestrator_candidates(cur):
141+
spawned = set(build_sender_session_map(cur, cid).keys())
142+
score = len(spawned & senders)
143+
if score > best_score:
144+
best, best_score = cid, score
145+
return best
146+
147+
148+
def find_parent_session(cur, wall_file: str | None = None) -> str | None:
149+
# Prefer the orchestrator bound to THIS instance's wall (multi-instance safe).
150+
if scoped := scoped_parent_session(cur, wall_sender_ids_from_file(wall_file)):
151+
return scoped
152+
# Fall back: most recent delegate-bearing user session, then any recent user session.
153+
if candidates := _orchestrator_candidates(cur):
154+
return candidates[0]
109155
cur.execute("SELECT id FROM sessions WHERE session_type = 'user' ORDER BY updated_at DESC LIMIT 1")
110156
return row[0] if (row := cur.fetchone()) else None
111157

@@ -362,6 +408,31 @@ async def sessions_tree_watcher():
362408
# Tree
363409
child_ids = find_children(cur, parent_id)
364410
sender_map = build_sender_session_map(cur, parent_id)
411+
412+
# Self-heal (multi-instance safe). Re-pin when the current parent has no
413+
# delegates (e.g. AGENT_SESSION_ID pointed at a terminal session, or the
414+
# dashboard launched before the orchestrator existed), OR when the current
415+
# parent's delegates don't match the IDs posting to our wall — meaning we're
416+
# pinned to a *different* concurrent goosetown's orchestrator. Re-resolution
417+
# is scoped to THIS wall's senders, so we can only ever adopt our own
418+
# instance, and only a candidate that actually has children (no thrashing).
419+
wall_senders = {ln.get("sender_id", "") for ln in latest_wall_lines if ln.get("sender_id")}
420+
parent_gtwall_ids = set(sender_map.keys())
421+
wrong_instance = bool(wall_senders) and bool(parent_gtwall_ids) and not (parent_gtwall_ids & wall_senders)
422+
if not child_ids or wrong_instance:
423+
candidate = scoped_parent_session(cur, wall_senders)
424+
if candidate and candidate != parent_id:
425+
candidate_children = find_children(cur, candidate)
426+
if candidate_children:
427+
print(
428+
f"🔄 Re-resolved parent session {parent_id}{candidate} "
429+
f"({len(candidate_children)} delegate(s), wall-scoped)",
430+
file=sys.stderr,
431+
)
432+
parent_id = candidate
433+
config["parent_session_id"] = parent_id
434+
child_ids = candidate_children
435+
sender_map = build_sender_session_map(cur, parent_id)
365436
session_to_gtwall = {sid: gid for gid, sid in sender_map.items()}
366437
now = time.time()
367438
children = []
@@ -577,16 +648,6 @@ def main():
577648
parser.add_argument("--wall", help="Wall file path (auto-detect if omitted)")
578649
args = parser.parse_args()
579650

580-
parent_id = args.session
581-
if not parent_id:
582-
try:
583-
with db() as cur:
584-
parent_id = find_parent_session(cur)
585-
except Exception as e:
586-
print(f"⚠️ Could not auto-detect session: {e}", file=sys.stderr)
587-
588-
print(f"📋 Parent session: {parent_id}" if parent_id else "⚠️ No parent session found — will retry in background", file=sys.stderr)
589-
590651
wall_file = args.wall or find_wall_file()
591652
wall_id = ""
592653
if wall_file:
@@ -595,6 +656,22 @@ def main():
595656
else:
596657
print("⚠️ No wall file found — will poll for it", file=sys.stderr)
597658

659+
# Resolve the parent (orchestrator) session. Prefer the one bound to THIS instance's
660+
# wall — it overrides an explicit --session, since the launcher may have passed a
661+
# terminal or another instance's session id. Keep --session only when the wall can't
662+
# yet identify an orchestrator (no delegates have posted), then fall back to discovery.
663+
parent_id = args.session
664+
try:
665+
with db() as cur:
666+
if scoped := scoped_parent_session(cur, wall_sender_ids_from_file(wall_file)):
667+
parent_id = scoped
668+
elif not parent_id:
669+
parent_id = find_parent_session(cur, wall_file)
670+
except Exception as e:
671+
print(f"⚠️ Could not auto-detect session: {e}", file=sys.stderr)
672+
673+
print(f"📋 Parent session: {parent_id}" if parent_id else "⚠️ No parent session found — will retry in background", file=sys.stderr)
674+
598675
config.update({
599676
"parent_session_id": parent_id,
600677
"wall_file": wall_file,

0 commit comments

Comments
 (0)