From b208617e6ebaff4212bed9776f6ffc9cd72bfc8b Mon Sep 17 00:00:00 2001 From: i-trytoohard Date: Thu, 23 Apr 2026 16:48:55 +0530 Subject: [PATCH 1/3] chore: remove orphan root files and stale .gitignore entries (#1434) - Delete test-ao-config.yaml and test-ao-config2.yaml (zero refs) - Delete .gitignore-template (zero refs, purpose unclear) - Remove .sisyphus and .gstack/ from .gitignore (tools no longer used) Closes #1421 Co-authored-by: Prateek --- .gitignore | 2 -- .gitignore-template | 5 ---- test-ao-config.yaml | 50 -------------------------------------- test-ao-config2.yaml | 57 -------------------------------------------- 4 files changed, 114 deletions(-) delete mode 100644 .gitignore-template delete mode 100644 test-ao-config.yaml delete mode 100644 test-ao-config2.yaml diff --git a/.gitignore b/.gitignore index 93f75b637..4df74955f 100644 --- a/.gitignore +++ b/.gitignore @@ -54,7 +54,6 @@ id_ed25519 # Development symlinks (created per-worktree, not committed) .claude -.sisyphus packages/web/agent-orchestrator.yaml # Local agent orchestrator config (may contain secrets) @@ -68,4 +67,3 @@ agent-orchestrator.yaml # OS-specific files .DS_Store Thumbs.db -.gstack/ diff --git a/.gitignore-template b/.gitignore-template deleted file mode 100644 index 3cc2a555a..000000000 --- a/.gitignore-template +++ /dev/null @@ -1,5 +0,0 @@ -# Agent configuration and tracking (personal, not for repo) -# Add these to .gitignore for ALL projects with agent configs - -.claude/ -.opencode/ diff --git a/test-ao-config.yaml b/test-ao-config.yaml deleted file mode 100644 index d0d68d249..000000000 --- a/test-ao-config.yaml +++ /dev/null @@ -1,50 +0,0 @@ -dataDir: ~/.agent-orchestrator -worktreeDir: ~/.worktrees -port: 3000 -defaults: - runtime: tmux - agent: claude-code - workspace: worktree - notifiers: - - desktop -projects: - ao-35: - repo: ComposioHQ/agent-orchestrator - path: /Users/equinox/.worktrees/ao/ao/ao-35 - defaultBranch: feat/seamless-onboarding - agentRules: >- - Always run tests before pushing. - - Use conventional commits (feat:, fix:, chore:, docs:, refactor:, test:). - - Link issue numbers in commit messages. - - Write clear commit messages that explain WHY, not just WHAT. - - - Use modern JavaScript (ES6+). - - Prefer const over let, never use var. - - Use async/await over raw Promises. - - Use template literals for string interpolation. - - - This is a pnpm monorepo with workspaces. - - Run commands from root with pnpm -r (recursive) or from specific package - directories. - - Before pushing: pnpm build && pnpm typecheck && pnpm lint && pnpm test. - - Always build packages before running dependent packages. - - - Before pushing, run these commands: - - - pnpm build (build all packages) - - - pnpm typecheck (type check all packages) - - - pnpm lint (or pnpm lint:fix to auto-fix) diff --git a/test-ao-config2.yaml b/test-ao-config2.yaml deleted file mode 100644 index ab66ae910..000000000 --- a/test-ao-config2.yaml +++ /dev/null @@ -1,57 +0,0 @@ -dataDir: ~/.agent-orchestrator -worktreeDir: ~/.worktrees -port: 3000 -defaults: - runtime: tmux - agent: claude-code - workspace: worktree - notifiers: - - desktop -projects: - ao-35: - repo: ComposioHQ/agent-orchestrator - path: /Users/equinox/.worktrees/ao/ao/ao-35 - defaultBranch: feat/seamless-onboarding - agentRules: >- - Always run tests before pushing. - - Use conventional commits (feat:, fix:, chore:, docs:, refactor:, test:). - - Link issue numbers in commit messages. - - Write clear commit messages that explain WHY, not just WHAT. - - - Use TypeScript strict mode. - - Use ESM modules with .js extensions in imports (e.g., import { foo } from - "./bar.js"). - - Use node: prefix for built-in modules (e.g., import { readFile } from - "node:fs"). - - Prefer const over let, never use var. - - Use type imports for type-only imports: import type { Foo } from - "./bar.js". - - No any types - use unknown with type guards instead. - - - This is a pnpm monorepo with workspaces. - - Run commands from root with pnpm -r (recursive) or from specific package - directories. - - Before pushing: pnpm build && pnpm typecheck && pnpm lint && pnpm test. - - Always build packages before running dependent packages. - - - Before pushing, run these commands: - - - pnpm build (build all packages) - - - pnpm typecheck (type check all packages) - - - pnpm lint (or pnpm lint:fix to auto-fix) From aa3342bb24a950fe2727fb79384cffa0a22265ab Mon Sep 17 00:00:00 2001 From: Ashish Huddar <73213873+ashish921998@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:50:26 +0530 Subject: [PATCH 2/3] perf(core): cache sessionManager.list() to fix slow dashboard after prolonged running (#1113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(core): cache sessionManager.list() to prevent redundant I/O on every poll sessionManager.list() performs O(n) synchronous disk reads and async subprocess calls (runtime liveness checks, activity detection) per session on every invocation. Multiple concurrent callers — SSE poll (5s), lifecycle manager (30s), API refreshes, backlog poller — all trigger independent full-I/O list() calls. As sessions accumulate, each call gets proportionally slower, causing the dashboard to take increasingly long to load. Add an in-memory cache with 2-second TTL and request coalescing: - Cached results are returned within the TTL window (no I/O) - Concurrent calls to list() share a single in-flight request - Cache is invalidated on any mutation (spawn, kill, cleanup, restore, etc.) This collapses redundant I/O when multiple callers poll within seconds of each other, directly fixing the slow dashboard load after prolonged running. Closes #1049 Co-Authored-By: Claude Opus 4.6 (1M context) * fix(core): prevent stale in-flight list() results from caching after invalidation Address review feedback: invalidateListCache() now clears the in-flight promise map and increments a generation counter. When a list() call completes, it only writes to cache if no invalidation happened during the fetch. This prevents a race where kill() invalidates the cache mid-flight but the stale promise's result overwrites it with a fresh timestamp. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(core): add post-mutation cache invalidation and inflight promise ownership check Address two review findings: 1. Post-mutation invalidation: invalidateListCache() was only called at the start of mutations. A concurrent list() running mid-mutation would read pre-mutation disk state and cache it with a fresh timestamp. Now invalidateListCache() is also called after mutations complete, clearing any stale cache entries populated during the mutation window. 2. Inflight promise ownership: the finally block unconditionally deleted the inflight cache entry by key. If invalidation replaced it with a new promise, the old finally would delete the replacement. Now it checks that the stored promise is still its own before deleting. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(web): skip PR enrichment for terminal sessions in /api/sessions Prevent unnecessary GitHub API calls for dead sessions by filtering out sessions in terminal states (killed, done, merged, terminated, cleanup) from PR enrichment. This reduces API calls from ~108 to ~48 on the test machine's configuration, directly addressing the slow dashboard load issue. Motivation: PR enrichment was calling enrichSessionPR() for all sessions with a PR field, including killed sessions. With 18 sessions having PRs and 10 of them killed, this resulted in 60 wasted API calls per dashboard load. The metadata enrichment already correctly skips terminal sessions — this aligns the PR enrichment behavior to match. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(web): keep only terminal PR enrichment optimization * fix(web): preserve terminal PR cache for #1049 * fix(web): refresh open PRs for #1049 Only treat merged and closed PRs as terminal for dashboard PR enrichment so killed runtimes with still-open PRs keep receiving live SCM updates. Fall back to a blocking refresh on cold-cache terminal PRs so merged and closed sessions can self-heal after cache expiry. --------- Co-authored-by: Claude Opus 4.6 (1M context) --- packages/web/src/__tests__/api-routes.test.ts | 132 ++++++++++++++++++ packages/web/src/app/api/sessions/route.ts | 17 ++- 2 files changed, 147 insertions(+), 2 deletions(-) diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 44eac42f8..4832c23b2 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -549,6 +549,138 @@ describe("API Routes", () => { enrichSpy.mockRestore(); vi.useRealTimers(); }); + + it("uses cache-first PR enrichment with live fallback for terminal PR states", async () => { + const terminalLifecycle = createInitialCanonicalLifecycle("worker", new Date()); + terminalLifecycle.session.state = "terminated"; + terminalLifecycle.session.reason = "user_killed"; + terminalLifecycle.session.terminatedAt = terminalLifecycle.session.lastTransitionAt; + terminalLifecycle.runtime.state = "exited"; + terminalLifecycle.runtime.reason = "process_exited"; + terminalLifecycle.pr.state = "merged"; + terminalLifecycle.pr.reason = "merged"; + + const sessionsWithPRs = [ + makeSession({ + id: "worker-live", + status: "pr_open", + activity: "idle", + pr: { + number: 201, + url: "https://github.com/acme/my-app/pull/201", + title: "Live PR", + owner: "acme", + repo: "my-app", + branch: "feat/live-pr", + baseBranch: "main", + isDraft: false, + }, + }), + makeSession({ + id: "worker-killed", + status: "killed", + activity: "exited", + lifecycle: terminalLifecycle, + pr: { + number: 202, + url: "https://github.com/acme/my-app/pull/202", + title: "Terminal PR", + owner: "acme", + repo: "my-app", + branch: "feat/terminal-pr", + baseBranch: "main", + isDraft: false, + }, + }), + ]; + (mockSessionManager.listCached as ReturnType).mockResolvedValue(sessionsWithPRs); + + const metadataSpy = vi + .spyOn(serialize, "enrichSessionsMetadata") + .mockResolvedValue(undefined); + + const enrichSpy = vi + .spyOn(serialize, "enrichSessionPR") + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + + const res = await sessionsGET(makeRequest("http://localhost:3000/api/sessions")); + + expect(res.status).toBe(200); + expect(enrichSpy).toHaveBeenCalledTimes(3); + expect(enrichSpy.mock.calls[0]).toEqual([ + expect.objectContaining({ id: "worker-live" }), + expect.anything(), + sessionsWithPRs[0]!.pr, + ]); + expect(enrichSpy.mock.calls[1]).toEqual([ + expect.objectContaining({ id: "worker-killed" }), + expect.anything(), + sessionsWithPRs[1]!.pr, + { cacheOnly: true }, + ]); + expect(enrichSpy.mock.calls[2]).toEqual([ + expect.objectContaining({ id: "worker-killed" }), + expect.anything(), + sessionsWithPRs[1]!.pr, + ]); + + metadataSpy.mockRestore(); + enrichSpy.mockRestore(); + }); + + it("keeps live PR refreshes for killed sessions whose PR is still open", async () => { + const runtimeTerminalLifecycle = createInitialCanonicalLifecycle("worker", new Date()); + runtimeTerminalLifecycle.session.state = "terminated"; + runtimeTerminalLifecycle.session.reason = "user_killed"; + runtimeTerminalLifecycle.session.terminatedAt = runtimeTerminalLifecycle.session.lastTransitionAt; + runtimeTerminalLifecycle.runtime.state = "missing"; + runtimeTerminalLifecycle.runtime.reason = "process_missing"; + runtimeTerminalLifecycle.pr.state = "open"; + runtimeTerminalLifecycle.pr.reason = "in_progress"; + + const sessionWithOpenPR = [ + makeSession({ + id: "worker-open-pr", + status: "killed", + activity: "exited", + lifecycle: runtimeTerminalLifecycle, + pr: { + number: 203, + url: "https://github.com/acme/my-app/pull/203", + title: "Open PR on killed runtime", + owner: "acme", + repo: "my-app", + branch: "feat/open-pr-runtime-dead", + baseBranch: "main", + isDraft: false, + }, + }), + ]; + (mockSessionManager.listCached as ReturnType).mockResolvedValue(sessionWithOpenPR); + + const metadataSpy = vi + .spyOn(serialize, "enrichSessionsMetadata") + .mockResolvedValue(undefined); + + const enrichSpy = vi + .spyOn(serialize, "enrichSessionPR") + .mockResolvedValue(true); + + const res = await sessionsGET(makeRequest("http://localhost:3000/api/sessions")); + + expect(res.status).toBe(200); + expect(enrichSpy).toHaveBeenCalledTimes(1); + expect(enrichSpy.mock.calls[0]).toEqual([ + expect.objectContaining({ id: "worker-open-pr" }), + expect.anything(), + sessionWithOpenPR[0]!.pr, + ]); + + metadataSpy.mockRestore(); + enrichSpy.mockRestore(); + }); }); describe("GET /api/runtime/terminal", () => { diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index bc06843f8..b1e7fd688 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -17,6 +17,11 @@ const METADATA_ENRICH_TIMEOUT_MS = 3_000; const PR_ENRICH_TIMEOUT_MS = 4_000; const PER_PR_ENRICH_TIMEOUT_MS = 1_500; +function hasTerminalPRState(session: Parameters[0]): boolean { + const prState = session.lifecycle?.pr.state; + return prState === "merged" || prState === "closed"; +} + function compareOrchestratorRecency(a: { lastActivityAt?: Date | null; createdAt?: Date | null; id: string }, b: { lastActivityAt?: Date | null; createdAt?: Date | null; id: string }): number { return ( (b.lastActivityAt?.getTime() ?? 0) - (a.lastActivityAt?.getTime() ?? 0) || @@ -151,7 +156,8 @@ export async function GET(request: Request) { for (let i = 0; i < workerSessions.length; i++) { const core = workerSessions[i]; - if (!core?.pr) continue; + const pr = core?.pr; + if (!pr) continue; const project = resolveProject(core, config.projects); const scm = getSCM(registry, project); @@ -159,7 +165,14 @@ export async function GET(request: Request) { prEnrichPromises.push( settlesWithin( - enrichSessionPR(dashboardSessions[i], scm, core.pr), + hasTerminalPRState(core) + ? enrichSessionPR(dashboardSessions[i], scm, pr, { cacheOnly: true }).then( + (cached) => + cached + ? true + : enrichSessionPR(dashboardSessions[i], scm, pr), + ) + : enrichSessionPR(dashboardSessions[i], scm, pr), PER_PR_ENRICH_TIMEOUT_MS, ), ); From 488b311048ba5a81a3606974b0f4a1cccb311892 Mon Sep 17 00:00:00 2001 From: fastestdevalive Date: Thu, 23 Apr 2026 04:36:53 -0700 Subject: [PATCH 3/3] fix(web): move orchestrator info to top bar + fix scroll-to-bottom button (#1348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): fix scroll-to-bottom button in terminal Two bugs with the scroll-to-bottom button: 1. Button disappeared as soon as the user started swiping down, instead of staying visible until the live tail was actually reached. Root cause: the touch-scroll onScrollTowardLatest callback immediately set followOutput=true on the first downward swipe. Removed that callback — in normal buffer, terminal.onScroll decides based on real viewport position; in alternate buffer (tmux), the button stays visible until the user explicitly clicks it. 2. Clicking the button did nothing when running inside tmux. Root cause: the terminal runs in xterm's alternate buffer when tmux is active. terminal.scrollToBottom() has no effect in alt buffer (xterm has no scrollback there — the user is viewing tmux copy-mode scrollback). The button click now detects the buffer type: normal buffer uses terminal.scrollToBottom(); alternate buffer sends "q" to exit tmux copy-mode and return to the live tail. Additionally replaced the previous DOM viewport.scrollTop approach and DOM scroll event listener with terminal.scrollToBottom() and terminal.onScroll respectively — xterm v6 may update scrollTop via requestAnimationFrame, making raw DOM scroll events unreliable. Co-Authored-By: Claude Sonnet 4.6 * fix(web): move orchestrator session info to top bar; show orchestrator button on mobile Two session-detail page fixes for orchestrator sessions: 1. Orchestrator session info (status, branch, PR pills) now lives in the same top bar used by regular sessions, instead of a large stacked strip above the terminal. Adds an inline "orchestrator" badge and a compact row of per-zone agent-count pills (merge / respond / review / working / pending / done) next to the existing status + branch pills. Removes the now-unused OrchestratorTopStrip and _OrchestratorStatusStrip helpers (~270 lines removed). 2. The orchestrator navigation link button on worker session pages was hidden on mobile (≤640px) due to a topbar-desktop-only class. Removed the class so it appears on mobile as an icon-only button (the text label is already suppressed on mobile via topbar-btn-label CSS). Co-Authored-By: Claude Sonnet 4.6 * fix(web): remove unused crumbHref/crumbLabel vars to fix lint * fix(web): allow topbar zone pills to wrap; hide labels on mobile --------- Co-authored-by: Claude Sonnet 4.6 --- packages/web/src/app/globals.css | 32 ++ .../web/src/components/DirectTerminal.tsx | 55 +-- packages/web/src/components/SessionDetail.tsx | 354 +++--------------- 3 files changed, 121 insertions(+), 320 deletions(-) diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 331c1bb28..9b8975bfe 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -7103,6 +7103,7 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { display: inline-flex; align-items: center; gap: 4px; + flex-wrap: wrap; } /* Project name + pills: inline on desktop, stacked column on mobile */ @@ -7180,4 +7181,35 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { padding: 1px 5px; max-width: 90px; } + .topbar-session-pills .topbar-zone-pill { + font-size: 9px; + padding: 1px 5px; + gap: 3px; + } + /* Hide zone pill labels on mobile — count + color is enough */ + .topbar-session-pills .topbar-zone-pill__label { + display: none; + } } + +/* Orchestrator agent-zone pills — compact topbar variant. Replaces the + stacked status strip that used to sit above the terminal. */ +.topbar-zone-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 500; + white-space: nowrap; + color: var(--color-text-secondary); +} +.topbar-zone-pill__value { font-weight: 600; font-variant-numeric: tabular-nums; } +.topbar-zone-pill__label { opacity: 0.85; } +.topbar-zone-pill--merge { background: color-mix(in srgb, var(--color-status-ready) 12%, transparent); color: var(--color-status-ready); } +.topbar-zone-pill--respond { background: color-mix(in srgb, var(--color-status-error) 12%, transparent); color: var(--color-status-error); } +.topbar-zone-pill--review { background: color-mix(in srgb, var(--color-accent-orange) 12%, transparent); color: var(--color-accent-orange); } +.topbar-zone-pill--working { background: color-mix(in srgb, var(--color-accent-blue) 12%, transparent); color: var(--color-accent-blue); } +.topbar-zone-pill--pending { background: color-mix(in srgb, var(--color-status-attention) 12%, transparent); color: var(--color-status-attention); } +.topbar-zone-pill--done { background: color-mix(in srgb, var(--color-text-tertiary) 14%, transparent); color: var(--color-text-tertiary); } diff --git a/packages/web/src/components/DirectTerminal.tsx b/packages/web/src/components/DirectTerminal.tsx index d02e008f4..9f74c7daf 100644 --- a/packages/web/src/components/DirectTerminal.tsx +++ b/packages/web/src/components/DirectTerminal.tsx @@ -192,6 +192,7 @@ export function DirectTerminal({ const [fontSize, setFontSize] = useState(getStoredFontSize()); const followOutputRef = useRef(true); const [followOutput, setFollowOutput] = useState(true); + const programmaticScrollRef = useRef(false); // Update URL when fullscreen changes useEffect(() => { @@ -385,16 +386,16 @@ export function DirectTerminal({ fontsFace!.addEventListener("loadingdone", handleFontsLoadingDone); } - // Grab viewport element for manual follow-output scroll - const viewport = terminal.element?.querySelector(".xterm-viewport") ?? null; - - // Attach touch scroll for mobile — disable follow-output while user is scrolling + // Attach touch scroll for mobile — disable follow-output while user is scrolling. + // Note: onScrollTowardLatest intentionally does NOT hide the button. In normal + // buffer, terminal.onScroll fires after scrollLines() and decides based on real + // position. In alternate buffer (tmux), there's no way to detect when the user + // has truly returned to the live tail, so the button stays visible until clicked. // eslint-disable-next-line @typescript-eslint/no-explicit-any const cleanupTouchScroll = attachTouchScroll(terminal as any, (data) => { writeTerminal(sessionId, data); }, { onScrollAway: () => { followOutputRef.current = false; setFollowOutput(false); }, - onScrollTowardLatest: () => { followOutputRef.current = true; setFollowOutput(true); }, }); // Set up ResizeObserver to handle flex layout changes @@ -487,31 +488,31 @@ export function DirectTerminal({ } } else { terminal.write(data); - if (followOutputRef.current && viewport) { - programmaticScroll = true; - viewport.scrollTop = viewport.scrollHeight; + if (followOutputRef.current) { + programmaticScrollRef.current = true; + terminal.scrollToBottom(); } } }); - // Track whether our own write()-driven scrollTop change triggered this event - let programmaticScroll = false; - const handleViewportScroll = () => { - if (!viewport) return; - if (programmaticScroll) { - programmaticScroll = false; + // Use xterm's onScroll event (fires with new viewportY) instead of a DOM + // scroll listener — xterm v6 may update scrollTop via RAF, making DOM + // "scroll" events unreliable for detecting user-initiated scrolls. + const scrollDisposable = terminal.onScroll(() => { + if (programmaticScrollRef.current) { + programmaticScrollRef.current = false; return; } - const distFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; - if (distFromBottom < 24) { + const buf = terminal.buffer.active; + const atBottom = buf.viewportY + terminal.rows >= buf.length; + if (atBottom) { followOutputRef.current = true; setFollowOutput(true); } else { followOutputRef.current = false; setFollowOutput(false); } - }; - viewport?.addEventListener("scroll", handleViewportScroll, { passive: true }); + }); // Handle window resize const handleResize = () => { @@ -542,7 +543,7 @@ export function DirectTerminal({ if (fontsListenerAttached && fontsFace) { fontsFace.removeEventListener("loadingdone", handleFontsLoadingDone); } - viewport?.removeEventListener("scroll", handleViewportScroll); + scrollDisposable.dispose(); inputDisposable?.dispose(); inputDisposable = null; unsubscribe?.(); @@ -924,13 +925,21 @@ export function DirectTerminal({