diff --git a/.changeset/launch-orchestrator-clean.md b/.changeset/launch-orchestrator-clean.md new file mode 100644 index 000000000..a13c2f708 --- /dev/null +++ b/.changeset/launch-orchestrator-clean.md @@ -0,0 +1,8 @@ +--- +"@aoagents/ao-core": minor +"@aoagents/ao-web": minor +--- + +feat: "Launch Orchestrator (clean context)" action on the orchestrator session page + +Adds a `Relaunch (clean)` action on the orchestrator session page that replaces the project's canonical orchestrator with a fresh one — killing the existing orchestrator, deleting its metadata, and spawning a new session with no carryover state. Backed by a new `SessionManager.relaunchOrchestrator(config)` method that ignores `orchestratorSessionStrategy`. Removes the now-redundant Orchestrator Selector page (`/orchestrators?project=X`) — there is only ever one orchestrator per project, so a selector page is no longer meaningful. Closes #1900 and #1080. diff --git a/CLAUDE.md b/CLAUDE.md index 42c583e49..731ee041f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,7 +111,7 @@ spawning -> working -> pr_open -> ci_failed / review_pending +-> mergeable -> merged -> cleanup -> done ``` -**Stale runtime reconciliation:** `sm.list()` detects dead runtimes (tmux/process gone) during enrichment and persists `runtime_lost` reason to disk. This maps to legacy status `killed`. Without this, sessions with dead runtimes would show stale "active" status indefinitely. +**Stale runtime reconciliation:** `sm.list()` detects dead runtimes (tmux/process gone) during enrichment and persists `detecting` state with `runtime_lost` reason to disk. The lifecycle manager's `resolveProbeDecision` pipeline is the single authority on terminal decisions — `sm.list()` never writes `terminated` directly (#1735). ### Data Flow @@ -224,7 +224,7 @@ Strong success criteria let you loop independently. Weak criteria ("make it work - Kanban board filters client-side via `projectSessions` memo ### Key invariants -- `sm.list()` persists `runtime_lost` lifecycle to disk when enrichment detects dead runtimes — this is the only place stale runtime state gets reconciled +- `sm.list()` persists `detecting` state (not `terminated`) to disk when enrichment detects dead runtimes — terminal decisions are made only by the lifecycle manager's probe pipeline (#1735) - `deriveLegacyStatus()` maps canonical lifecycle to legacy status — new terminal reasons must be added here - Tab completions merge local config + global config to show all projects @@ -450,8 +450,8 @@ import { validateUrl, // Webhook URL validation readLastJsonlEntry, // Efficient JSONL log tail (native agent JSONL) readLastActivityEntry, // Read last AO activity JSONL entry - checkActivityLogState, // Extract waiting_input/blocked from AO JSONL (with staleness cap) - getActivityFallbackState, // Last-resort fallback: entry state + age-based decay + checkActivityLogState, // Extract sticky waiting_input/blocked from AO JSONL + getActivityFallbackState, // Last-resort fallback: actionable states + liveness age decay recordTerminalActivity, // Shared recordActivity impl (classify + dedup + append) classifyTerminalActivity, // Classify terminal output via detectActivity appendActivityEntry, // Low-level JSONL append @@ -460,7 +460,7 @@ import { normalizeAgentPermissionMode, // Normalize permission mode strings DEFAULT_READY_THRESHOLD_MS, // 5 min — ready→idle threshold DEFAULT_ACTIVE_WINDOW_MS, // 30s — active→ready window - ACTIVITY_INPUT_STALENESS_MS, // 5 min — waiting_input/blocked expiry + ACTIVITY_INPUT_STALENESS_MS, // Deprecated compatibility export; actionable states no longer expire by wallclock PREFERRED_GH_PATH, // /usr/local/bin/gh CI_STATUS, ACTIVITY_STATE, SESSION_STATUS, // Constants type Session, type ProjectConfig, type RuntimeHandle, diff --git a/packages/core/src/__tests__/activity-log.test.ts b/packages/core/src/__tests__/activity-log.test.ts index 858cfea74..6d8288b8d 100644 --- a/packages/core/src/__tests__/activity-log.test.ts +++ b/packages/core/src/__tests__/activity-log.test.ts @@ -9,9 +9,26 @@ import { appendActivityEntry, recordTerminalActivity, getActivityLogPath, - ACTIVITY_INPUT_STALENESS_MS, + getActivityFallbackState, } from "../activity-log.js"; -import type { ActivityState } from "../types.js"; +import type { ActivityDetection, ActivityLogEntry, ActivityState } from "../types.js"; + +const minutesAgo = (minutes: number): string => new Date(Date.now() - minutes * 60_000).toISOString(); + +const toActivityResult = ( + entry: ActivityLogEntry, +): { entry: ActivityLogEntry; modifiedAt: Date } => ({ + entry, + modifiedAt: new Date(entry.ts), +}); + +const detectWithProcessCheck = ( + isProcessRunning: boolean, + activityResult: { entry: ActivityLogEntry; modifiedAt: Date } | null, +): ActivityDetection | null => { + if (!isProcessRunning) return { state: "exited", timestamp: new Date() }; + return checkActivityLogState(activityResult) ?? getActivityFallbackState(activityResult, 30_000, 5 * 60_000); +}; describe("classifyTerminalActivity", () => { it("returns active state with no trigger", () => { @@ -56,13 +73,20 @@ describe("checkActivityLogState", () => { expect(result?.state).toBe("blocked"); }); - it("returns null for stale waiting_input entry", () => { - const staleTs = new Date(Date.now() - ACTIVITY_INPUT_STALENESS_MS - 1000).toISOString(); + it("returns waiting_input even when older than the former wallclock cap", () => { const result = checkActivityLogState({ - entry: { ts: staleTs, state: "waiting_input", source: "terminal" }, + entry: { ts: minutesAgo(10), state: "waiting_input", source: "terminal" }, modifiedAt: new Date(), }); - expect(result).toBeNull(); + expect(result?.state).toBe("waiting_input"); + }); + + it("returns blocked even when older than the former wallclock cap", () => { + const result = checkActivityLogState({ + entry: { ts: minutesAgo(6), state: "blocked", source: "terminal" }, + modifiedAt: new Date(), + }); + expect(result?.state).toBe("blocked"); }); it("returns null for non-critical states", () => { @@ -82,6 +106,77 @@ describe("checkActivityLogState", () => { }); }); +describe("getActivityFallbackState", () => { + it("returns waiting_input for a 10-minute-old entry instead of decaying to idle", () => { + const result = getActivityFallbackState( + toActivityResult({ ts: minutesAgo(10), state: "waiting_input", source: "terminal" }), + 30_000, + 5 * 60_000, + ); + + expect(result?.state).toBe("waiting_input"); + }); + + it("returns blocked for a 6-minute-old entry instead of decaying to idle", () => { + const result = getActivityFallbackState( + toActivityResult({ ts: minutesAgo(6), state: "blocked", source: "terminal" }), + 30_000, + 5 * 60_000, + ); + + expect(result?.state).toBe("blocked"); + }); + + it("returns blocked for a 1-minute-old entry with unchanged behavior", () => { + const result = getActivityFallbackState( + toActivityResult({ ts: minutesAgo(1), state: "blocked", source: "terminal" }), + 30_000, + 5 * 60_000, + ); + + expect(result?.state).toBe("blocked"); + }); + + it("lets a newer active entry override an older waiting_input entry", async () => { + const tmpDir = await mkdtemp(join(tmpdir(), "ao-test-")); + try { + await mkdir(join(tmpDir, ".ao"), { recursive: true }); + const waitingEntry: ActivityLogEntry = { + ts: minutesAgo(6), + state: "waiting_input", + source: "terminal", + }; + const activeEntry: ActivityLogEntry = { + ts: new Date(Date.now() - 1000).toISOString(), + state: "active", + source: "terminal", + }; + await writeFile( + getActivityLogPath(tmpDir), + `${JSON.stringify(waitingEntry)}\n${JSON.stringify(activeEntry)}\n`, + "utf-8", + ); + + const activityResult = await readLastActivityEntry(tmpDir); + const result = getActivityFallbackState(activityResult, 30_000, 5 * 60_000); + + expect(activityResult?.entry.state).toBe("active"); + expect(result?.state).toBe("active"); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("returns exited when the process check fails before a stale waiting_input can fall through", () => { + const result = detectWithProcessCheck( + false, + toActivityResult({ ts: minutesAgo(6), state: "waiting_input", source: "terminal" }), + ); + + expect(result?.state).toBe("exited"); + }); +}); + describe("readLastActivityEntry", () => { let tmpDir: string; @@ -144,6 +239,25 @@ describe("readLastActivityEntry", () => { const result = await readLastActivityEntry(tmpDir); expect(result).toBeNull(); }); + + it("falls back to the previous complete line when a read races a truncated tail", async () => { + await mkdir(join(tmpDir, ".ao"), { recursive: true }); + const completeEntry: ActivityLogEntry = { + ts: minutesAgo(10), + state: "waiting_input", + source: "terminal", + trigger: "approve?", + }; + await writeFile( + getActivityLogPath(tmpDir), + `${JSON.stringify(completeEntry)}\n{"ts":"${new Date().toISOString()}","state":`, + "utf-8", + ); + + const result = await readLastActivityEntry(tmpDir); + + expect(result?.entry).toEqual(completeEntry); + }); }); describe("recordTerminalActivity", () => { diff --git a/packages/core/src/__tests__/session-manager/query.test.ts b/packages/core/src/__tests__/session-manager/query.test.ts index e8acb988d..17300fca6 100644 --- a/packages/core/src/__tests__/session-manager/query.test.ts +++ b/packages/core/src/__tests__/session-manager/query.test.ts @@ -8,6 +8,7 @@ import { createSessionManager } from "../../session-manager.js"; import { writeMetadata, readMetadataRaw, + updateMetadata, } from "../../metadata.js"; import { createInitialCanonicalLifecycle } from "../../lifecycle-state.js"; import type { @@ -63,6 +64,47 @@ describe("list", () => { expect(sessions.map((s) => s.id).sort()).toEqual(["app-1", "app-2"]); }); + it("skips dead-runtime agent metadata discovery when native restore metadata is already persisted", async () => { + writeMetadata(sessionsDir, "app-1", { + worktree: config.projects["my-app"]!.path, + branch: "feat/a", + status: "killed", + project: "my-app", + runtimeHandle: makeHandle("rt-old"), + }); + updateMetadata(sessionsDir, "app-1", { codexThreadId: "thread-1" }); + + const deadRuntime: Runtime = { + ...mockRuntime, + isAlive: vi.fn().mockResolvedValue(false), + }; + const agentWithSessionInfo: Agent = { + ...mockAgent, + name: "codex", + getSessionInfo: vi.fn().mockResolvedValue({ + summary: null, + agentSessionId: "rollout-1", + metadata: { codexThreadId: "thread-1" }, + }), + }; + const registryWithDeadRuntime: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return deadRuntime; + if (slot === "agent") return agentWithSessionInfo; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + const sm = createSessionManager({ config, registry: registryWithDeadRuntime }); + await sm.list("my-app"); + await sm.list("my-app"); + + expect(agentWithSessionInfo.getSessionInfo).not.toHaveBeenCalled(); + expect(readMetadataRaw(sessionsDir, "app-1")!["codexThreadId"]).toBe("thread-1"); + }); + it("does not backfill role onto foreign bare-id orchestrator records (issue #1048)", async () => { // Regression guard for PR #1075 review comment: a legacy record whose id // is `{projectId}-orchestrator` (pre-numbered scheme, wrong prefix) must @@ -220,7 +262,9 @@ describe("list", () => { const sm = createSessionManager({ config, registry: registryWithDead }); const sessions = await sm.list(); - expect(sessions[0].status).toBe("killed"); + // sm.list() persists "detecting" (not "terminated") so the lifecycle + // manager's probe pipeline makes the final terminal decision (#1735). + expect(sessions[0].status).toBe("detecting"); expect(sessions[0].activity).toBe("exited"); }); @@ -336,7 +380,8 @@ describe("list", () => { expect(sessions).toHaveLength(1); expect(sessions[0].runtimeHandle?.id).toBe(expectedTmuxName); - expect(sessions[0].status).toBe("killed"); + // sm.list() persists "detecting" so the lifecycle manager decides (#1735). + expect(sessions[0].status).toBe("detecting"); expect(sessions[0].activity).toBe("exited"); expect(agentWithSpy.getActivityState).not.toHaveBeenCalled(); }); diff --git a/packages/core/src/__tests__/session-manager/restore.test.ts b/packages/core/src/__tests__/session-manager/restore.test.ts index 9445d16f3..3ba13d03c 100644 --- a/packages/core/src/__tests__/session-manager/restore.test.ts +++ b/packages/core/src/__tests__/session-manager/restore.test.ts @@ -575,7 +575,7 @@ describe("restore", () => { expect(meta!["restoreFallbackReason"]).toBe("mock-agent.getRestoreCommand returned null"); }); - it("does not launch a fresh chat when a native-restore agent cannot build restore command", async () => { + it("falls back to a fresh launch when a native-restore agent cannot build restore command", async () => { const wsPath = join(tmpDir, "ws-app-native-restore-missing"); mkdirSync(wsPath, { recursive: true }); @@ -605,12 +605,119 @@ describe("restore", () => { const sm = createSessionManager({ config, registry: registryWithNativeRestoreAgent }); - await expect(sm.restore("app-1")).rejects.toThrow(SessionNotRestorableError); - expect(mockRuntime.create).not.toHaveBeenCalled(); + await sm.restore("app-1"); + + expect(mockRuntime.create).toHaveBeenCalled(); + const createCall = (mockRuntime.create as ReturnType).mock.calls[0][0]; + expect(createCall.launchCommand).toBe("mock-agent --start"); const meta = readMetadataRaw(sessionsDir, "app-1"); expect(meta!["restoreFallbackReason"]).toBe("codex.getRestoreCommand returned null"); }); + it("persists native restore metadata even when runtime is already dead", async () => { + const wsPath = join(tmpDir, "ws-app-dead-runtime-metadata"); + mkdirSync(wsPath, { recursive: true }); + + const deadRuntime: Runtime = { + ...mockRuntime, + isAlive: vi.fn().mockResolvedValue(false), + }; + const agentWithDiscoverableThread: Agent = { + ...mockAgent, + name: "codex", + getSessionInfo: vi.fn().mockResolvedValue({ + summary: null, + agentSessionId: "rollout-1", + metadata: { codexThreadId: "thread-1" }, + }), + getRestoreCommand: vi + .fn() + .mockImplementation(async (session) => + session.metadata?.codexThreadId ? `codex resume ${session.metadata.codexThreadId}` : null, + ), + }; + + const registryWithDeadRuntime: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return deadRuntime; + if (slot === "agent") return agentWithDiscoverableThread; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + writeMetadata(sessionsDir, "app-1", { + worktree: wsPath, + branch: "feat/TEST-1", + status: "killed", + project: "my-app", + runtimeHandle: makeHandle("rt-old"), + }); + + const sm = createSessionManager({ config, registry: registryWithDeadRuntime }); + const restored = await sm.restore("app-1"); + + expect(agentWithDiscoverableThread.getSessionInfo).toHaveBeenCalled(); + expect(agentWithDiscoverableThread.getRestoreCommand).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ codexThreadId: "thread-1" }), + }), + expect.any(Object), + ); + expect(restored.metadata["codexThreadId"]).toBe("thread-1"); + const createCall = (deadRuntime.create as ReturnType).mock.calls[0][0]; + expect(createCall.launchCommand).toBe("codex resume thread-1"); + const meta = readMetadataRaw(sessionsDir, "app-1"); + expect(meta!["codexThreadId"]).toBe("thread-1"); + }); + + it("uses project path as restore workspace when worktree metadata is missing", async () => { + mkdirSync(config.projects["my-app"]!.path, { recursive: true }); + + const agentWithWorkspaceAssertion: Agent = { + ...mockAgent, + name: "codex", + getRestoreCommand: vi + .fn() + .mockImplementation(async (session) => + session.workspacePath === config.projects["my-app"]!.path + ? "codex resume thread-1" + : null, + ), + }; + + const registryWithWorkspaceAssertion: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return agentWithWorkspaceAssertion; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + writeMetadata(sessionsDir, "app-1", { + worktree: "", + branch: "feat/TEST-1", + status: "killed", + project: "my-app", + runtimeHandle: makeHandle("rt-old"), + }); + + const sm = createSessionManager({ config, registry: registryWithWorkspaceAssertion }); + const restored = await sm.restore("app-1"); + + expect(restored.workspacePath).toBe(config.projects["my-app"]!.path); + expect(agentWithWorkspaceAssertion.getRestoreCommand).toHaveBeenCalledWith( + expect.objectContaining({ workspacePath: config.projects["my-app"]!.path }), + expect.any(Object), + ); + const createCall = (mockRuntime.create as ReturnType).mock.calls[0][0]; + expect(createCall.workspacePath).toBe(config.projects["my-app"]!.path); + expect(createCall.launchCommand).toBe("codex resume thread-1"); + }); + it("clears restore fallback reason when getRestoreCommand succeeds", async () => { const wsPath = join(tmpDir, "ws-app-restore-clears-fallback"); mkdirSync(wsPath, { recursive: true }); diff --git a/packages/core/src/__tests__/session-manager/spawn.test.ts b/packages/core/src/__tests__/session-manager/spawn.test.ts index 8f2057e27..d92c1a4f1 100644 --- a/packages/core/src/__tests__/session-manager/spawn.test.ts +++ b/packages/core/src/__tests__/session-manager/spawn.test.ts @@ -13,7 +13,7 @@ import { readMetadata, readMetadataRaw, } from "../../metadata.js"; -import { getProjectWorktreesDir } from "../../paths.js"; +import { getProjectDir, getProjectWorktreesDir } from "../../paths.js"; import type { OrchestratorConfig, PluginRegistry, @@ -2489,4 +2489,183 @@ describe("spawn", () => { }); }); + + describe("relaunchOrchestrator", () => { + it("spawns a fresh orchestrator when none exists", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + + const session = await sm.relaunchOrchestrator({ projectId: "my-app" }); + + expect(session.id).toBe("app-orchestrator"); + expect(session.status).toBe("working"); + expect(mockWorkspace.create).toHaveBeenCalledTimes(1); + expect(mockRuntime.create).toHaveBeenCalledTimes(1); + }); + + it("kills and replaces an existing orchestrator regardless of strategy", async () => { + writeMetadata(sessionsDir, "app-orchestrator", { + role: "orchestrator", + project: "my-app", + status: "working", + branch: "orchestrator/app-orchestrator", + worktree: join(tmpDir, "old-orchestrator-ws"), + runtimeHandle: makeHandle("old-rt"), + }); + const sm = createSessionManager({ config, registry: mockRegistry }); + + const session = await sm.relaunchOrchestrator({ projectId: "my-app" }); + + expect(session.id).toBe("app-orchestrator"); + expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("old-rt")); + expect(mockWorkspace.create).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "app-orchestrator" }), + ); + const meta = readMetadataRaw(sessionsDir, "app-orchestrator"); + expect(meta?.["status"]).toBe("working"); + expect(meta?.["runtimeHandle"]).not.toContain("old-rt"); + }); + + it("ignores project orchestratorSessionStrategy: ignore and still replaces", async () => { + const configWithIgnore: OrchestratorConfig = { + ...config, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"]!, + orchestratorSessionStrategy: "ignore", + }, + }, + }; + writeMetadata(sessionsDir, "app-orchestrator", { + role: "orchestrator", + project: "my-app", + status: "working", + branch: "orchestrator/app-orchestrator", + worktree: join(tmpDir, "old-orchestrator-ws"), + runtimeHandle: makeHandle("old-rt"), + }); + const sm = createSessionManager({ config: configWithIgnore, registry: mockRegistry }); + + await sm.relaunchOrchestrator({ projectId: "my-app" }); + + expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("old-rt")); + expect(mockWorkspace.create).toHaveBeenCalled(); + }); + + it("coalesces concurrent relaunch calls into a single replacement", async () => { + writeMetadata(sessionsDir, "app-orchestrator", { + role: "orchestrator", + project: "my-app", + status: "working", + branch: "orchestrator/app-orchestrator", + worktree: join(tmpDir, "old-orchestrator-ws"), + runtimeHandle: makeHandle("old-rt"), + }); + const sm = createSessionManager({ config, registry: mockRegistry }); + + const [s1, s2] = await Promise.all([ + sm.relaunchOrchestrator({ projectId: "my-app" }), + sm.relaunchOrchestrator({ projectId: "my-app" }), + ]); + + expect(s1.id).toBe("app-orchestrator"); + expect(s2.id).toBe("app-orchestrator"); + expect(mockRuntime.destroy).toHaveBeenCalledTimes(1); + expect(mockWorkspace.create).toHaveBeenCalledTimes(1); + expect(mockRuntime.create).toHaveBeenCalledTimes(1); + }); + + it("ensureOrchestrator waits for an in-flight relaunch before returning", async () => { + writeMetadata(sessionsDir, "app-orchestrator", { + role: "orchestrator", + project: "my-app", + status: "working", + branch: "orchestrator/app-orchestrator", + worktree: join(tmpDir, "old-orchestrator-ws"), + runtimeHandle: makeHandle("old-rt"), + }); + let releaseWorkspace: () => void = () => {}; + const blockingWorkspace = new Promise((resolve) => { + releaseWorkspace = resolve; + }); + (mockWorkspace.create as ReturnType).mockImplementationOnce(async (cfg) => { + await blockingWorkspace; + return { + path: join(tmpDir, "ws-relaunch"), + branch: cfg.branch, + sessionId: cfg.sessionId, + projectId: cfg.projectId, + }; + }); + const sm = createSessionManager({ config, registry: mockRegistry }); + + const relaunchPromise = sm.relaunchOrchestrator({ projectId: "my-app" }); + await Promise.resolve(); + const ensurePromise = sm.ensureOrchestrator({ projectId: "my-app" }); + + releaseWorkspace(); + + const [relaunched, ensured] = await Promise.all([relaunchPromise, ensurePromise]); + expect(relaunched.id).toBe("app-orchestrator"); + expect(ensured.id).toBe("app-orchestrator"); + // Both should resolve to the *post-relaunch* session, not the killed one. + expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("old-rt")); + }); + + it("waits for an in-flight ensureOrchestrator before replacing", async () => { + let releaseWorkspace: () => void = () => {}; + const blockingWorkspace = new Promise((resolve) => { + releaseWorkspace = resolve; + }); + const ensureWorkspacePath = join(tmpDir, "ws-ensure"); + (mockWorkspace.create as ReturnType).mockImplementationOnce(async (cfg) => { + await blockingWorkspace; + return { + path: ensureWorkspacePath, + branch: cfg.branch, + sessionId: cfg.sessionId, + projectId: cfg.projectId, + }; + }); + const sm = createSessionManager({ config, registry: mockRegistry }); + + const ensurePromise = sm.ensureOrchestrator({ projectId: "my-app" }); + // Yield so ensure can register itself in the in-flight map. + await Promise.resolve(); + const relaunchPromise = sm.relaunchOrchestrator({ projectId: "my-app" }); + + releaseWorkspace(); + + const [ensured, relaunched] = await Promise.all([ensurePromise, relaunchPromise]); + + expect(ensured.id).toBe("app-orchestrator"); + expect(relaunched.id).toBe("app-orchestrator"); + // Relaunch's kill must run, destroying the runtime ensure just spawned. + expect(mockRuntime.destroy).toHaveBeenCalled(); + }); + + it("rewrites the orchestrator-prompt file with the new system prompt", async () => { + writeMetadata(sessionsDir, "app-orchestrator", { + role: "orchestrator", + project: "my-app", + status: "working", + branch: "orchestrator/app-orchestrator", + worktree: join(tmpDir, "old-orchestrator-ws"), + runtimeHandle: makeHandle("old-rt"), + }); + const sm = createSessionManager({ config, registry: mockRegistry }); + + await sm.relaunchOrchestrator({ + projectId: "my-app", + systemPrompt: "FRESH ORCHESTRATOR PROMPT", + }); + + const promptPath = join( + getProjectDir("my-app"), + "orchestrator-prompt-app-orchestrator.md", + ); + expect(existsSync(promptPath)).toBe(true); + expect(readFileSync(promptPath, "utf-8")).toBe("FRESH ORCHESTRATOR PROMPT"); + }); + }); }); diff --git a/packages/core/src/__tests__/test-utils.ts b/packages/core/src/__tests__/test-utils.ts index e7a51e9df..b20f05ffa 100644 --- a/packages/core/src/__tests__/test-utils.ts +++ b/packages/core/src/__tests__/test-utils.ts @@ -499,6 +499,11 @@ export function createMockSessionManager(): OpenCodeSessionManager { .mockResolvedValue( makeSession({ id: "app-orchestrator", metadata: { role: "orchestrator" } }), ), + relaunchOrchestrator: vi + .fn() + .mockResolvedValue( + makeSession({ id: "app-orchestrator", metadata: { role: "orchestrator" } }), + ), restore: vi.fn().mockResolvedValue(makeSession()), list: vi.fn().mockResolvedValue([]), listCached: vi.fn().mockResolvedValue([]), diff --git a/packages/core/src/activity-log.ts b/packages/core/src/activity-log.ts index 1334bfb2e..56a1e02c3 100644 --- a/packages/core/src/activity-log.ts +++ b/packages/core/src/activity-log.ts @@ -15,10 +15,8 @@ import { join, dirname } from "node:path"; import type { ActivityState, ActivityLogEntry, ActivityDetection } from "./types.js"; /** - * Maximum age (ms) for `waiting_input`/`blocked` entries before they're - * considered stale. If no new terminal output overwrites the entry within - * this window, the state falls through to downstream fallbacks instead of - * keeping the session stuck in `needs_input` on the dashboard forever. + * @deprecated Actionable states no longer decay on wallclock. Retained until + * the activity-reducer cleanup removes the old activity-log module. */ export const ACTIVITY_INPUT_STALENESS_MS = 5 * 60 * 1000; // 5 minutes @@ -129,7 +127,7 @@ export async function readLastActivityEntry( /** * Check the AO activity JSONL for actionable states only. * - * Only returns `waiting_input`/`blocked` (with a staleness cap). + * Only returns `waiting_input`/`blocked`. * Non-critical states (`active`, `ready`, `idle`) always return `null` so * callers fall through to their native signals (git commits, chat history, * API queries, native JSONL). This prevents the lifecycle manager's @@ -144,18 +142,12 @@ export function checkActivityLogState( const { entry } = activityResult; if (entry.state === "waiting_input" || entry.state === "blocked") { - // Use the entry's own timestamp for staleness — not file mtime, which - // gets refreshed every poll cycle by recordActivity and would prevent - // stale entries from ever being detected. const entryTs = new Date(entry.ts); if (Number.isNaN(entryTs.getTime())) return null; - const ageMs = Date.now() - entryTs.getTime(); - if (ageMs <= ACTIVITY_INPUT_STALENESS_MS) { - return { state: entry.state, timestamp: entryTs }; - } + return { state: entry.state, timestamp: entryTs }; } - // Non-critical states and stale entries — fall through to native signals + // Non-critical states fall through to native signals return null; } @@ -178,16 +170,8 @@ export function getActivityFallbackState( const entryTs = new Date(entry.ts); if (Number.isNaN(entryTs.getTime())) return null; - // Actionable states use the same staleness cap as checkActivityLogState. - // If the entry is stale, fall through to age-based decay instead of - // re-surfacing a waiting_input/blocked that checkActivityLogState already filtered. if (entry.state === "waiting_input" || entry.state === "blocked") { - const ageMs = Date.now() - entryTs.getTime(); - if (ageMs <= ACTIVITY_INPUT_STALENESS_MS) { - return { state: entry.state, timestamp: entryTs }; - } - // Stale actionable entry — treat as idle - return { state: "idle", timestamp: entryTs }; + return { state: entry.state, timestamp: entryTs }; } // Age-based decay: active→ready→idle, but never promote past the diff --git a/packages/core/src/portfolio-session-service.ts b/packages/core/src/portfolio-session-service.ts index 0d1ce5472..4be551226 100644 --- a/packages/core/src/portfolio-session-service.ts +++ b/packages/core/src/portfolio-session-service.ts @@ -141,6 +141,7 @@ function metadataToSession(sessionId: string, project: PortfolioProject, metadat return sessionFromMetadata(sessionId, metadataToRecord(metadata), { projectId: project.id, + workspacePathFallback: project.repoPath, status: (metadata.status as Session["status"]) || "spawning", activity: null, runtimeHandle: metadata.runtimeHandle ?? null, diff --git a/packages/core/src/recovery/actions.ts b/packages/core/src/recovery/actions.ts index 14e315b08..43a9eec87 100644 --- a/packages/core/src/recovery/actions.ts +++ b/packages/core/src/recovery/actions.ts @@ -132,6 +132,7 @@ export async function recoverSession( const session = sessionFromMetadata(sessionId, updatedMetadata, { projectId: assessment.projectId, + workspacePathFallback: assessment.workspacePath ?? undefined, status: preservedStatus, runtimeHandle: assessment.runtimeHandle, lastActivityAt: new Date(), diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index be8238e10..6f51650bc 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -338,27 +338,33 @@ function parseLifecycleFromRaw( } } +interface MetadataToSessionOptions { + projectId: string; + sessionPrefix?: string; + createdAt?: Date; + modifiedAt?: Date; + workspacePathFallback?: string; +} + /** Reconstruct a Session object from raw metadata key=value pairs. */ function metadataToSession( sessionId: SessionId, meta: Record, - projectId: string, - sessionPrefix?: string, - createdAt?: Date, - modifiedAt?: Date, + options: MetadataToSessionOptions, ): Session { const sessionKind = meta["role"] === "orchestrator" || - (sessionPrefix - ? new RegExp(`^${escapeRegex(sessionPrefix)}-orchestrator-\\d+$`).test(sessionId) + (options.sessionPrefix + ? new RegExp(`^${escapeRegex(options.sessionPrefix)}-orchestrator-\\d+$`).test(sessionId) : false) ? "orchestrator" : "worker"; return sessionFromMetadata(sessionId, meta, { - projectId, + projectId: options.projectId, + workspacePathFallback: options.workspacePathFallback, sessionKind, - createdAt, - lastActivityAt: modifiedAt ?? new Date(), + createdAt: options.createdAt, + lastActivityAt: options.modifiedAt ?? new Date(), }); } @@ -453,18 +459,6 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM return isOrchestratorSessionRecord(sessionId, metadata ?? {}, project.sessionPrefix); } - function requiresNativeRestore(agentName: string): boolean { - // kimicode is intentionally excluded: kimi's session dir only exists if the - // previous launch ran far enough to write to ~/.kimi/sessions. A failed - // launch (e.g. the --agent-file YAML crash) leaves no session to resume, - // and falling back to a fresh getLaunchCommand is the only sensible choice. - return ( - agentName === "claude-code" || - agentName === "codex" || - agentName === "opencode" - ); - } - function applyMetadataUpdatesToRaw( raw: Record, updates: Partial>, @@ -538,6 +532,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM expiresAt: number; } | null = null; const ensureOrchestratorPromises = new Map>(); + const relaunchOrchestratorPromises = new Map>(); function invalidateCache(): void { sessionCache = null; @@ -1019,12 +1014,68 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM */ const TERMINAL_SESSION_STATUSES = new Set(["killed", "done", "merged", "terminated", "cleanup"]); + function hasPersistedNativeRestoreMetadata(session: Session, agent: Agent): boolean { + const metadata = session.metadata ?? {}; + + switch (agent.name) { + case "claude-code": + return typeof metadata["claudeSessionUuid"] === "string" && metadata["claudeSessionUuid"].trim().length > 0; + case "codex": + return typeof metadata["codexThreadId"] === "string" && metadata["codexThreadId"].trim().length > 0; + case "opencode": + return asValidOpenCodeSessionId(metadata["opencodeSessionId"]) !== null; + default: + return false; + } + } + + function canDiscoverSessionInfoAfterRuntimeExit(agent: Agent): boolean { + return agent.name === "claude-code" || agent.name === "codex"; + } + async function enrichSessionWithRuntimeState( session: Session, plugins: ReturnType, handleFromMetadata: boolean, sessionsDir: string, ): Promise { + async function persistAgentSessionInfo(options?: { skipIfNativeRestoreMetadataPresent?: boolean }): Promise { + if (!plugins.agent) return; + if ( + options?.skipIfNativeRestoreMetadataPresent && + hasPersistedNativeRestoreMetadata(session, plugins.agent) + ) { + return; + } + + let info: Awaited>; + try { + info = await plugins.agent.getSessionInfo(session); + } catch { + // Can't get session info — keep existing values + info = null; + } + + if (!info) return; + + session.agentInfo = info; + const metadataUpdates = info.metadata ?? {}; + const allAlreadyPersisted = Object.keys(metadataUpdates).every( + (key) => session.metadata?.[key] === metadataUpdates[key], + ); + if (allAlreadyPersisted) return; + + if (Object.keys(metadataUpdates).length > 0) { + try { + updateMetadata(sessionsDir, session.id, metadataUpdates); + session.metadata = applyMetadataUpdates(session.metadata, metadataUpdates); + invalidateCache(); + } catch { + // Persisting agent metadata is best-effort; keep live agent info. + } + } + } + // Check runtime liveness first — for all statuses except "spawning". // Skip spawning sessions because tmux may not be fully initialized yet, // and a false-negative from isAlive() would permanently mark the session @@ -1065,6 +1116,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM activity: "exited", source: "runtime", }); + // Dead-runtime session info discovery is intentionally limited to + // agents that recover restore metadata from persisted local files. + if (plugins.agent && canDiscoverSessionInfoAfterRuntimeExit(plugins.agent)) { + await persistAgentSessionInfo({ skipIfNativeRestoreMetadataPresent: true }); + } return; } } catch { @@ -1101,28 +1157,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM session.activitySignal = createActivitySignal("probe_failure", { source: "native" }); } - // Enrich with live agent session info (summary, cost). - let info: Awaited>; - try { - info = await plugins.agent.getSessionInfo(session); - } catch { - // Can't get session info — keep existing values - info = null; - } - - if (info) { - session.agentInfo = info; - const metadataUpdates = info.metadata ?? {}; - if (Object.keys(metadataUpdates).length > 0) { - try { - updateMetadata(sessionsDir, session.id, metadataUpdates); - session.metadata = applyMetadataUpdates(session.metadata, metadataUpdates); - invalidateCache(); - } catch { - // Persisting agent metadata is best-effort; keep live agent info. - } - } - } + // Enrich with agent session info (summary, cost, native restore metadata). + await persistAgentSessionInfo(); } } @@ -1815,6 +1851,20 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } const sessionId = getOrchestratorSessionId(project); + + // If a relaunch is mid-flight for this sessionId, wait it out — otherwise + // we could return a session that relaunch is about to kill, or race the + // relaunch's spawnOrchestrator on the same reservation. + const pendingRelaunch = relaunchOrchestratorPromises.get(sessionId); + if (pendingRelaunch) { + await pendingRelaunch.catch((err) => { + console.warn( + `[ensureOrchestrator] in-flight relaunch for ${sessionId} failed before ensure proceeded:`, + err, + ); + }); + } + const existing = await get(sessionId); if (existing) { const orchestratorSessionStrategy = normalizeOrchestratorSessionStrategy( @@ -1876,6 +1926,61 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM return promise; } + async function relaunchOrchestratorInternal( + orchestratorConfig: OrchestratorSpawnConfig, + ): Promise { + const project = config.projects[orchestratorConfig.projectId]; + if (!project) { + throw new Error(`Unknown project: ${orchestratorConfig.projectId}`); + } + const sessionId = getOrchestratorSessionId(project); + const sessionsDir = getProjectSessionsDir(orchestratorConfig.projectId); + + // If ensureOrchestrator is mid-flight for this sessionId, wait it out. + // Otherwise get() would return null (metadata not yet written) and we'd + // skip the kill, then race the in-flight spawnOrchestrator on the same + // reservation — surfacing "session already exists" instead of replacing. + const pendingEnsure = ensureOrchestratorPromises.get(sessionId); + if (pendingEnsure) { + await pendingEnsure.catch((err) => { + console.warn( + `[relaunchOrchestrator] in-flight ensure for ${sessionId} failed before relaunch proceeded:`, + err, + ); + }); + } + + const existing = await get(sessionId); + if (existing) { + const existingAgent = resolveSelectionForSession( + project, + sessionId, + readMetadataRaw(sessionsDir, sessionId) ?? {}, + ).agentName; + await kill(sessionId, { purgeOpenCode: existingAgent === "opencode" }); + deleteMetadata(sessionsDir, sessionId); + } + return spawnOrchestrator(orchestratorConfig); + } + + async function relaunchOrchestrator( + orchestratorConfig: OrchestratorSpawnConfig, + ): Promise { + const project = config.projects[orchestratorConfig.projectId]; + if (!project) { + throw new Error(`Unknown project: ${orchestratorConfig.projectId}`); + } + const sessionId = getOrchestratorSessionId(project); + const existingPromise = relaunchOrchestratorPromises.get(sessionId); + if (existingPromise) return existingPromise; + + const promise = relaunchOrchestratorInternal(orchestratorConfig).finally(() => { + relaunchOrchestratorPromises.delete(sessionId); + }); + relaunchOrchestratorPromises.set(sessionId, promise); + return promise; + } + async function list(projectId?: string): Promise { const allSessions = Object.entries(config.projects).flatMap(([entryProjectId, project]) => { if (projectId && entryProjectId !== projectId) return []; @@ -1907,10 +2012,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM const session = metadataToSession( sessionName, raw, - sessionProjectId, - project.sessionPrefix, - createdAt, - modifiedAt, + { + projectId: sessionProjectId, + sessionPrefix: project.sessionPrefix, + createdAt, + modifiedAt, + workspacePathFallback: project.path, + }, ); const selection = resolveSelectionForSession(project, sessionName, raw); const effectiveAgentName = selection.agentName; @@ -1941,23 +2049,30 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } } - // Persist lifecycle to disk when enrichment detected a dead runtime. - // enrichSessionWithRuntimeState updates the in-memory lifecycle but - // doesn't write to disk — without this, the stale "alive" state persists - // and the dashboard shows terminated sessions on the active sidebar. + // Persist runtime probe result to disk so the lifecycle manager sees it + // on next poll. We only persist the runtime signal and detecting state — + // the lifecycle manager's resolveProbeDecision pipeline is the single + // authority on terminal decisions (terminated/done). See #1735. + // Check the on-disk state (raw) to avoid re-writing when already + // detecting — enrichment sets detecting in-memory, but we only need + // to persist the transition once to avoid resetting lastTransitionAt. + const onDiskLifecycle = parseCanonicalLifecycle(raw, { + sessionId: sessionName, + status: validateStatus(raw["status"]), + }); if ( session.lifecycle && (session.lifecycle.runtime.state === "missing" || session.lifecycle.runtime.state === "exited") && - session.lifecycle.session.state !== "terminated" && - session.lifecycle.session.state !== "done" + onDiskLifecycle.session.state !== "terminated" && + onDiskLifecycle.session.state !== "done" && + onDiskLifecycle.session.state !== "detecting" ) { try { const persisted = buildUpdatedLifecycle(sessionName, raw, (next) => { - next.session.state = "terminated"; + next.session.state = "detecting"; next.session.reason = "runtime_lost"; - next.session.terminatedAt = new Date().toISOString(); - next.session.lastTransitionAt = next.session.terminatedAt; + next.session.lastTransitionAt = new Date().toISOString(); next.runtime.state = session.lifecycle!.runtime.state; next.runtime.reason = session.lifecycle!.runtime.reason; next.runtime.lastObservedAt = new Date().toISOString(); @@ -2021,10 +2136,13 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM const session = metadataToSession( sessionId, repaired.raw, - projectId, - project.sessionPrefix, - createdAt, - modifiedAt, + { + projectId, + sessionPrefix: project.sessionPrefix, + createdAt, + modifiedAt, + workspacePathFallback: project.path, + }, ); const selection = resolveSelectionForSession(project, sessionId, repaired.raw); @@ -2795,7 +2913,15 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // metadataToSession sets activity: null, so without enrichment a crashed // session (status "working", agent exited) would not be detected as terminal // and isRestorable would reject it. - const session = metadataToSession(sessionId, raw, projectId, project.sessionPrefix); + const session = metadataToSession( + sessionId, + raw, + { + projectId, + sessionPrefix: project.sessionPrefix, + workspacePathFallback: project.path, + }, + ); const plugins = resolvePlugins(project, selection.agentName); await enrichSessionWithRuntimeState(session, plugins, true, sessionsDir); @@ -2931,13 +3057,12 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM launchCommand = restoreCmd; updateMetadata(sessionsDir, sessionId, { restoreFallbackReason: "" }); } else { + // Agents with native restore can still launch fresh when no resumable + // session metadata exists; this keeps restore from becoming a hard stop. const reason = `${plugins.agent.name}.getRestoreCommand returned null`; updateMetadata(sessionsDir, sessionId, { restoreFallbackReason: reason, }); - if (requiresNativeRestore(plugins.agent.name)) { - throw new SessionNotRestorableError(sessionId, reason); - } launchCommand = plugins.agent.getLaunchCommand(agentLaunchConfig); } } else { @@ -3047,6 +3172,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM spawn, spawnOrchestrator, ensureOrchestrator, + relaunchOrchestrator, restore, list, listCached, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d759c7769..1d5af5b3e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1845,6 +1845,13 @@ export interface SessionManager { spawn(config: SessionSpawnConfig): Promise; spawnOrchestrator(config: OrchestratorSpawnConfig): Promise; ensureOrchestrator(config: OrchestratorSpawnConfig): Promise; + /** + * Replace the canonical orchestrator with a fresh one. If an orchestrator + * already exists for the project, it is killed, its metadata deleted, and a + * new orchestrator spawned with no carryover state. Ignores + * `orchestratorSessionStrategy` — replacement is the whole point. + */ + relaunchOrchestrator(config: OrchestratorSpawnConfig): Promise; restore(sessionId: SessionId): Promise; list(projectId?: string): Promise; get(sessionId: SessionId): Promise; diff --git a/packages/core/src/utils/session-from-metadata.ts b/packages/core/src/utils/session-from-metadata.ts index aeda52d79..9230ff18b 100644 --- a/packages/core/src/utils/session-from-metadata.ts +++ b/packages/core/src/utils/session-from-metadata.ts @@ -14,6 +14,7 @@ import { safeJsonParse, validateStatus } from "./validation.js"; interface SessionFromMetadataOptions { projectId?: string; + workspacePathFallback?: string; status?: SessionStatus; sessionKind?: SessionKind; activity?: Session["activity"]; @@ -86,7 +87,7 @@ export function sessionFromMetadata( }; })() : null, - workspacePath: meta["worktree"] || null, + workspacePath: meta["worktree"] || options.workspacePathFallback || null, runtimeHandle: lifecycle.runtime.handle ?? runtimeHandle, agentInfo: meta["summary"] ? { summary: meta["summary"], agentSessionId: null } : null, createdAt: meta["createdAt"] ? new Date(meta["createdAt"]) : (options.createdAt ?? new Date()), diff --git a/packages/integration-tests/src/tracker-linear.integration.test.ts b/packages/integration-tests/src/tracker-linear.integration.test.ts index a2144cdd7..f9c1bd543 100644 --- a/packages/integration-tests/src/tracker-linear.integration.test.ts +++ b/packages/integration-tests/src/tracker-linear.integration.test.ts @@ -269,10 +269,10 @@ describe.skipIf(!canRun)("tracker-linear (integration)", () => { // Linear API has eventual consistency — poll until the issue appears in list results const found = await pollUntil( async () => { - const issues = await tracker.listIssues!({ state: "open", limit: 50 }, project); + const issues = await tracker.listIssues!({ state: "open", limit: 100 }, project); return issues.find((i: { id: string }) => i.id === issueIdentifier); }, - { timeoutMs: 5_000, intervalMs: 500 }, + { timeoutMs: 15_000, intervalMs: 1_000 }, ); expect(found).toBeDefined(); diff --git a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts index ff9a78bcb..6199b3b19 100644 --- a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts +++ b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts @@ -1,9 +1,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { toClaudeProjectPath, create } from "../index.js"; -import { createActivitySignal, type Session, type RuntimeHandle } from "@aoagents/ao-core"; +import { + createActivitySignal, + readLastActivityEntry, + type ActivityState, + type Session, + type RuntimeHandle, +} from "@aoagents/ao-core"; // Mock homedir() so getActivityState looks in our temp dir vi.mock("node:os", async (importOriginal) => { @@ -57,6 +63,16 @@ function writeJsonl( } } +function writeActivityLog(state: ActivityState, ageMs = 0): void { + const ts = new Date(Date.now() - ageMs).toISOString(); + const aoDir = join(workspacePath, ".ao"); + mkdirSync(aoDir, { recursive: true }); + writeFileSync( + join(aoDir, "activity.jsonl"), + JSON.stringify({ ts, state, source: "terminal" }) + "\n", + ); +} + // ============================================================================= // toClaudeProjectPath // ============================================================================= @@ -141,23 +157,74 @@ describe("Claude Code Activity Detection", () => { // Fallback cases (no JSONL data available) // ----------------------------------------------------------------------- - it("returns 'idle' when no session file exists yet", async () => { - // projectDir exists but is empty — no .jsonl files yet (freshly spawned session) - const session = makeSession(); - const result = await agent.getActivityState(session); - expect(result?.state).toBe("idle"); - // timestamp must be session.createdAt so stuck-detection can fire eventually - expect(result?.timestamp).toBe(session.createdAt); + it("returns null when no session file or AO activity entry exists yet", async () => { + // projectDir exists but is empty, and the AO safety-net log is absent. + expect(await agent.getActivityState(makeSession())).toBeNull(); }); it("returns null when no workspacePath", async () => { expect(await agent.getActivityState(makeSession({ workspacePath: null }))).toBeNull(); }); - it("returns 'idle' when project directory does not exist", async () => { - // Process is running but no Claude project dir yet — treat as idle + it("returns null when project directory does not exist and AO activity is unavailable", async () => { const badPath = join(fakeHome, "nonexistent-workspace"); - expect((await agent.getActivityState(makeSession({ workspacePath: badPath })))?.state).toBe("idle"); + expect(await agent.getActivityState(makeSession({ workspacePath: badPath }))).toBeNull(); + }); + + it("recordActivity writes to .ao/activity.jsonl when workspacePath is set", async () => { + await agent.recordActivity?.(makeSession(), "Do you want to proceed?\n(Y)es / (N)o"); + + const result = await readLastActivityEntry(workspacePath); + expect(result?.entry.state).toBe("waiting_input"); + expect(result?.entry.source).toBe("terminal"); + expect(result?.entry.trigger).toContain("Do you want to proceed?"); + }); + + it("recordActivity is a no-op when workspacePath is null", async () => { + await agent.recordActivity?.( + makeSession({ workspacePath: null }), + "Do you want to proceed?\n(Y)es / (N)o", + ); + + expect(existsSync(join(workspacePath, ".ao", "activity.jsonl"))).toBe(false); + }); + + it("keeps native JSONL as primary when AO activity JSONL also exists", async () => { + writeJsonl([{ type: "assistant", message: { content: "Done!" } }]); + writeActivityLog("waiting_input"); + + expect((await agent.getActivityState(makeSession()))?.state).toBe("ready"); + }); + + it("falls back to AO JSONL waiting_input when native session lookup is unavailable", async () => { + await agent.recordActivity?.(makeSession(), "Do you want to proceed?\n(Y)es / (N)o"); + + expect((await agent.getActivityState(makeSession()))?.state).toBe("waiting_input"); + }); + + it("falls back to AO JSONL waiting_input when native session entry predates this session", async () => { + writeJsonl([{ type: "assistant", message: { content: "Previous session done" } }], 120_000); + const session = makeSession({ createdAt: new Date() }); + + await agent.recordActivity?.(session, "Do you want to proceed?\n(Y)es / (N)o"); + + expect((await agent.getActivityState(session))?.state).toBe("waiting_input"); + }); + + it("returns idle for stale native session entry when AO JSONL is unavailable", async () => { + writeJsonl([{ type: "assistant", message: { content: "Previous session done" } }], 120_000); + const session = makeSession({ createdAt: new Date() }); + + const result = await agent.getActivityState(session); + + expect(result?.state).toBe("idle"); + expect(result?.timestamp).toBe(session.createdAt); + }); + + it("falls back to AO JSONL age-decay when native session lookup is unavailable", async () => { + writeActivityLog("active", 400_000); + + expect((await agent.getActivityState(makeSession()))?.state).toBe("idle"); }); // ----------------------------------------------------------------------- @@ -306,8 +373,8 @@ describe("Claude Code Activity Detection", () => { it("ignores agent- prefixed JSONL files", async () => { writeJsonl([{ type: "user" }], 0, "agent-toolkit.jsonl"); - // No real session file → process is running, treat as idle - expect((await agent.getActivityState(makeSession()))?.state).toBe("idle"); + // No real session file and no AO activity fallback. + expect(await agent.getActivityState(makeSession())).toBeNull(); }); it("reads last entry from multi-entry JSONL (not first)", async () => { diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index 1e5a933ac..7235b05b4 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -6,6 +6,10 @@ import { PROCESS_PROBE_INDETERMINATE, DEFAULT_READY_THRESHOLD_MS, DEFAULT_ACTIVE_WINDOW_MS, + readLastActivityEntry, + checkActivityLogState, + getActivityFallbackState, + recordTerminalActivity, type Agent, type AgentSessionInfo, type AgentLaunchConfig, @@ -947,6 +951,13 @@ function createClaudeCodeAgent(): Agent { return classifyTerminalOutput(terminalOutput); }, + async recordActivity(session: Session, terminalOutput: string): Promise { + if (!session.workspacePath) return; + await recordTerminalActivity(session.workspacePath, terminalOutput, (output) => + this.detectActivity(output), + ); + }, + async isProcessRunning(handle: RuntimeHandle): Promise { const pid = await findClaudeProcess(handle); if (pid === PROCESS_PROBE_INDETERMINATE) return PROCESS_PROBE_INDETERMINATE; @@ -976,51 +987,67 @@ function createClaudeCodeAgent(): Agent { const projectDir = join(homedir(), ".claude", "projects", projectPath); const sessionFile = await findLatestSessionFile(projectDir); - if (!sessionFile) { - // No session file yet — process is running but no conversation started. - // Treat as idle (waiting for first task). - return { state: "idle", timestamp: session.createdAt }; + let staleNativeState: ActivityDetection | null = null; + if (sessionFile) { + const entry = await readLastJsonlEntry(sessionFile); + if (entry) { + // If the JSONL entry predates this session, it's from a previous session + // in the same worktree. Fall through to the AO safety net first: the + // terminal may have already surfaced waiting_input/blocked before + // Claude writes this session's first native JSONL entry. + if (session.createdAt && entry.modifiedAt < session.createdAt) { + staleNativeState = { state: "idle", timestamp: session.createdAt }; + } else { + const ageMs = Date.now() - entry.modifiedAt.getTime(); + const timestamp = entry.modifiedAt; + + const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold); + switch (entry.lastType) { + case "user": + case "tool_use": + case "progress": + if (ageMs <= activeWindowMs) return { state: "active", timestamp }; + return { state: ageMs > threshold ? "idle" : "ready", timestamp }; + + case "assistant": + case "system": + case "summary": + case "result": + return { state: ageMs > threshold ? "idle" : "ready", timestamp }; + + case "permission_request": + return { state: "waiting_input", timestamp }; + + case "error": + return { state: "blocked", timestamp }; + + default: + if (ageMs <= activeWindowMs) return { state: "active", timestamp }; + return { state: ageMs > threshold ? "idle" : "ready", timestamp }; + } + } + } + + // Session file exists but no parseable entry — fall through to AO JSONL + // checks below instead of returning early, so terminal-derived + // waiting_input/blocked can still be detected. } - const entry = await readLastJsonlEntry(sessionFile); - if (!entry) { - // Empty file or read error — cannot determine activity - return null; - } - - // If the JSONL entry predates this session, it's from a previous session - // in the same worktree. Treat as no data (agent hasn't written yet). - if (session.createdAt && entry.modifiedAt < session.createdAt) { - return { state: "idle", timestamp: session.createdAt }; - } - - const ageMs = Date.now() - entry.modifiedAt.getTime(); - const timestamp = entry.modifiedAt; + // Fallback: check AO activity JSONL (terminal-derived) for + // waiting_input/blocked when Claude's native JSONL is unavailable. + const activityResult = await readLastActivityEntry(session.workspacePath); + const activityState = checkActivityLogState(activityResult); + if (activityState) return activityState; + // Last fallback: use the AO entry with age-based decay when native + // session lookup is missing or unparseable (e.g. Claude project slug drift). const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold); - switch (entry.lastType) { - case "user": - case "tool_use": - case "progress": - if (ageMs <= activeWindowMs) return { state: "active", timestamp }; - return { state: ageMs > threshold ? "idle" : "ready", timestamp }; + const fallback = getActivityFallbackState(activityResult, activeWindowMs, threshold); + if (fallback) return fallback; - case "assistant": - case "system": - case "summary": - case "result": - return { state: ageMs > threshold ? "idle" : "ready", timestamp }; + if (staleNativeState) return staleNativeState; - case "permission_request": - return { state: "waiting_input", timestamp }; - - case "error": - return { state: "blocked", timestamp }; - - default: - if (ageMs <= activeWindowMs) return { state: "active", timestamp }; - return { state: ageMs > threshold ? "idle" : "ready", timestamp }; - } + return null; }, async getSessionInfo(session: Session): Promise { diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 84f769e97..0a32bf0dc 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -129,6 +129,7 @@ const mockSessionManager: SessionManager = { cleanup: vi.fn(async () => ({ killed: [], skipped: [], errors: [] })), spawnOrchestrator: vi.fn(), ensureOrchestrator: vi.fn(), + relaunchOrchestrator: vi.fn(), remap: vi.fn(async () => "ses_mock"), restore: vi.fn(async (id: string) => { const session = testSessions.find((s) => s.id === id); @@ -232,7 +233,7 @@ import { GET as sessionDetailGET, PATCH as sessionDetailPATCH, } from "@/app/api/sessions/[id]/route"; -import { POST as orchestratorsPOST, GET as orchestratorsGET } from "@/app/api/orchestrators/route"; +import { POST as orchestratorsPOST } from "@/app/api/orchestrators/route"; import { POST as spawnPOST } from "@/app/api/spawn/route"; import { POST as sendPOST } from "@/app/api/sessions/[id]/send/route"; import { POST as messagePOST } from "@/app/api/sessions/[id]/message/route"; @@ -1173,53 +1174,49 @@ describe("API Routes", () => { expect(data.recovery).toBe("reuse-or-recreate-workspace"); expect(data.error).toContain('AO found an older orchestrator workspace for "my-app"'); }); - }); - describe("GET /api/orchestrators", () => { - it("returns orchestrators for a project", async () => { - const orchestrator = makeSession({ - id: "my-app-orchestrator", - projectId: "my-app", - metadata: { role: "orchestrator" }, + it("calls relaunchOrchestrator instead of spawnOrchestrator when clean is true", async () => { + (mockSessionManager.relaunchOrchestrator as ReturnType).mockResolvedValueOnce( + makeSession({ + id: "my-app-orchestrator", + projectId: "my-app", + metadata: { role: "orchestrator" }, + }), + ); + + const req = makeRequest("/api/orchestrators", { + method: "POST", + body: JSON.stringify({ projectId: "my-app", clean: true }), + headers: { "Content-Type": "application/json" }, }); - (mockSessionManager.list as ReturnType).mockResolvedValueOnce([orchestrator]); + const res = await orchestratorsPOST(req); - const res = await orchestratorsGET( - makeRequest("http://localhost:3000/api/orchestrators?project=my-app"), - ); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.orchestrators).toHaveLength(1); - expect(data.orchestrators[0].id).toBe("my-app-orchestrator"); - expect(data.projectName).toBe("My App"); + expect(res.status).toBe(201); + expect(mockSessionManager.relaunchOrchestrator).toHaveBeenCalledWith({ + projectId: "my-app", + systemPrompt: expect.stringContaining("# My App Orchestrator"), + }); + expect(mockSessionManager.spawnOrchestrator).not.toHaveBeenCalled(); }); - it("returns 400 when project parameter is missing", async () => { - const res = await orchestratorsGET(makeRequest("http://localhost:3000/api/orchestrators")); - expect(res.status).toBe(400); - const data = await res.json(); - expect(data.error).toMatch(/Missing project query parameter/); - }); + it("uses spawnOrchestrator when clean is false or omitted", async () => { + (mockSessionManager.spawnOrchestrator as ReturnType).mockResolvedValueOnce( + makeSession({ + id: "my-app-orchestrator", + projectId: "my-app", + metadata: { role: "orchestrator" }, + }), + ); - it("returns 404 for unknown project", async () => { - const res = await orchestratorsGET( - makeRequest("http://localhost:3000/api/orchestrators?project=unknown-app"), - ); - expect(res.status).toBe(404); - const data = await res.json(); - expect(data.error).toMatch(/Unknown project/); - }); + const req = makeRequest("/api/orchestrators", { + method: "POST", + body: JSON.stringify({ projectId: "my-app", clean: false }), + headers: { "Content-Type": "application/json" }, + }); + await orchestratorsPOST(req); - it("returns 500 when list fails", async () => { - (mockSessionManager.list as ReturnType).mockRejectedValueOnce( - new Error("boom"), - ); - const res = await orchestratorsGET( - makeRequest("http://localhost:3000/api/orchestrators?project=my-app"), - ); - expect(res.status).toBe(500); - const data = await res.json(); - expect(data.error).toBe("boom"); + expect(mockSessionManager.spawnOrchestrator).toHaveBeenCalled(); + expect(mockSessionManager.relaunchOrchestrator).not.toHaveBeenCalled(); }); }); diff --git a/packages/web/src/__tests__/orchestrators.test.tsx b/packages/web/src/__tests__/orchestrators.test.tsx deleted file mode 100644 index 8aaba6d00..000000000 --- a/packages/web/src/__tests__/orchestrators.test.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; -import OrchestratorsRoute from "@/app/orchestrators/page"; -import { getServices } from "@/lib/services"; -import { getAllProjects } from "@/lib/project-name"; - -// ── Mocks ───────────────────────────────────────────────────────────── - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ - push: vi.fn(), - }), -})); - -vi.mock("next/link", () => ({ - default: ({ children, href }: { children: React.ReactNode; href: string }) => ( - {children} - ), -})); - -vi.mock("@/lib/services", () => ({ - getServices: vi.fn(), -})); - -vi.mock("@/lib/project-name", () => ({ - getAllProjects: vi.fn(), -})); - -global.fetch = vi.fn(); - -// ── Tests ───────────────────────────────────────────────────────────── - -describe("Orchestrators Page (OrchestratorsRoute)", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("renders the page with searchParams and listed orchestrators", async () => { - const mockSessionManager = { - list: vi.fn().mockResolvedValue([ - { - id: "app-orchestrator", - projectId: "my-app", - status: "working", - activity: "active", - metadata: { role: "orchestrator" }, - createdAt: new Date(), - lastActivityAt: new Date(), - }, - ]), - }; - - (getServices as any).mockResolvedValue({ - config: { - projects: { - "my-app": { name: "My App", sessionPrefix: "app" }, - }, - }, - sessionManager: mockSessionManager, - }); - - (getAllProjects as any).mockReturnValue([{ id: "my-app", name: "My App" }]); - - const searchParams = Promise.resolve({ project: "my-app" }); - const jsx = await OrchestratorsRoute({ searchParams }); - render(jsx); - - expect(screen.getByText("My App")).toBeInTheDocument(); - expect(screen.getByText("app-orchestrator")).toBeInTheDocument(); - }); - - it("shows error when project is missing in searchParams", async () => { - const searchParams = Promise.resolve({}); - const jsx = await OrchestratorsRoute({ searchParams }); - render(jsx); - - expect(screen.getByText("Missing Project")).toBeInTheDocument(); - }); - - it("shows error when project is not found in config", async () => { - (getServices as any).mockResolvedValue({ - config: { projects: {} }, - sessionManager: { list: vi.fn() }, - }); - (getAllProjects as any).mockReturnValue([]); - - const searchParams = Promise.resolve({ project: "ghost" }); - const jsx = await OrchestratorsRoute({ searchParams }); - render(jsx); - - expect(screen.getByText('Project "ghost" not found')).toBeInTheDocument(); - }); - - it("handles service errors gracefully", async () => { - (getServices as any).mockRejectedValue(new Error("Database down")); - - const searchParams = Promise.resolve({ project: "my-app" }); - const jsx = await OrchestratorsRoute({ searchParams }); - render(jsx); - - expect(screen.getByText("Database down")).toBeInTheDocument(); - }); -}); diff --git a/packages/web/src/__tests__/setup.ts b/packages/web/src/__tests__/setup.ts index b3258b10f..1ccc65a90 100644 --- a/packages/web/src/__tests__/setup.ts +++ b/packages/web/src/__tests__/setup.ts @@ -2,6 +2,22 @@ import { cleanup } from "@testing-library/react"; import * as matchers from "@testing-library/jest-dom/matchers"; import { afterEach, expect } from "vitest"; +// Node.js 25 exposes a native localStorage stub via --localstorage-file that +// lacks .clear()/.key()/.length. Replace it with a complete in-memory mock so +// tests that call window.localStorage.clear() work on any Node version. +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => { store[k] = String(v); }, + removeItem: (k: string) => { Reflect.deleteProperty(store, k); }, + clear: () => { store = {}; }, + get length() { return Object.keys(store).length; }, + key: (i: number) => Object.keys(store)[i] ?? null, + }; +})(); +Object.defineProperty(window, "localStorage", { value: localStorageMock, writable: true, configurable: true }); + expect.extend(matchers); afterEach(() => { cleanup(); diff --git a/packages/web/src/app/api/orchestrators/route.ts b/packages/web/src/app/api/orchestrators/route.ts index 886761693..54890fa85 100644 --- a/packages/web/src/app/api/orchestrators/route.ts +++ b/packages/web/src/app/api/orchestrators/route.ts @@ -1,8 +1,7 @@ import { type NextRequest, NextResponse } from "next/server"; -import { generateOrchestratorPrompt, generateSessionPrefix } from "@aoagents/ao-core"; +import { generateOrchestratorPrompt } from "@aoagents/ao-core"; import { getServices } from "@/lib/services"; import { validateIdentifier, validateConfiguredProject } from "@/lib/validation"; -import { mapSessionsToOrchestrators } from "@/lib/orchestrator-utils"; function classifySpawnError(projectId: string, error: unknown): { status: number; @@ -35,46 +34,6 @@ function classifySpawnError(projectId: string, error: unknown): { }; } -/** - * GET /api/orchestrators?project= - * List existing orchestrator sessions for a project. - */ -export async function GET(request: NextRequest) { - const projectId = request.nextUrl.searchParams.get("project"); - - if (!projectId) { - return NextResponse.json({ error: "Missing project query parameter" }, { status: 400 }); - } - - const projectErr = validateIdentifier(projectId, "projectId"); - if (projectErr) { - return NextResponse.json({ error: projectErr }, { status: 400 }); - } - - try { - const { config, sessionManager } = await getServices(); - const configProjectErr = validateConfiguredProject(config.projects, projectId); - if (configProjectErr) { - return NextResponse.json({ error: configProjectErr }, { status: 404 }); - } - const project = config.projects[projectId]; - const sessionPrefix = project.sessionPrefix ?? projectId; - - const allSessions = await sessionManager.list(projectId); - const allSessionPrefixes = Object.entries(config.projects).map( - ([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""), - ); - const orchestrators = mapSessionsToOrchestrators(allSessions, sessionPrefix, project.name, allSessionPrefixes); - - return NextResponse.json({ orchestrators, projectName: project.name }); - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "Failed to list orchestrators" }, - { status: 500 }, - ); - } -} - export async function POST(request: NextRequest) { const body = (await request.json().catch(() => null)) as Record | null; if (!body) { @@ -86,6 +45,8 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: projectErr }, { status: 400 }); } + const clean = body.clean === true; + try { const { config, sessionManager } = await getServices(); const projectId = body.projectId as string; @@ -96,7 +57,9 @@ export async function POST(request: NextRequest) { const project = config.projects[projectId]; const systemPrompt = generateOrchestratorPrompt({ config, projectId, project }); - const session = await sessionManager.spawnOrchestrator({ projectId, systemPrompt }); + const session = clean + ? await sessionManager.relaunchOrchestrator({ projectId, systemPrompt }) + : await sessionManager.spawnOrchestrator({ projectId, systemPrompt }); return NextResponse.json( { diff --git a/packages/web/src/app/api/projects/[id]/route.ts b/packages/web/src/app/api/projects/[id]/route.ts index f940aefae..c98964402 100644 --- a/packages/web/src/app/api/projects/[id]/route.ts +++ b/packages/web/src/app/api/projects/[id]/route.ts @@ -31,7 +31,7 @@ function sanitizeString(value: unknown): string | undefined { } function revalidateProjectPaths(projectId: string): void { - for (const route of ["/", "/orchestrators", "/prs", `/projects/${projectId}`]) { + for (const route of ["/", "/prs", `/projects/${projectId}`]) { try { revalidatePath(route); } catch { diff --git a/packages/web/src/app/api/projects/route.ts b/packages/web/src/app/api/projects/route.ts index 0947558a6..2bafa5212 100644 --- a/packages/web/src/app/api/projects/route.ts +++ b/packages/web/src/app/api/projects/route.ts @@ -33,7 +33,7 @@ function isGitRepository(projectPath: string): boolean { } function revalidatePortfolioPaths(projectId: string): void { - for (const route of ["/", "/orchestrators", "/prs", `/projects/${projectId}`]) { + for (const route of ["/", "/prs", `/projects/${projectId}`]) { try { revalidatePath(route); } catch { diff --git a/packages/web/src/app/api/sessions/patches/route.ts b/packages/web/src/app/api/sessions/patches/route.ts index 1ba9fa9de..0aa9df61b 100644 --- a/packages/web/src/app/api/sessions/patches/route.ts +++ b/packages/web/src/app/api/sessions/patches/route.ts @@ -16,7 +16,7 @@ export async function GET(request: Request) { ? projectFilter : undefined; - const coreSessions = await sessionManager.list(requestedProjectId); + const coreSessions = await sessionManager.listCached(requestedProjectId); const visibleSessions = filterWorkerSessions(coreSessions, projectFilter, config.projects); // Convert to dashboard format diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 7f1145392..86d813198 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -1230,6 +1230,11 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { color 100ms ease, transform 120ms cubic-bezier(0.23, 1, 0.32, 1); } +.dashboard-app-btn--icon { + width: 27px; + padding: 0; + justify-content: center; +} .dashboard-app-btn:hover { background: var(--color-bg-subtle); @@ -1328,6 +1333,12 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { .topbar-status-pill__label { color: inherit; } +.topbar-status-pill__dot--working { + background: var(--color-status-working); +} +.topbar-status-pill__dot--attention { + background: var(--color-status-attention); +} /* Branch pill — compact topbar variant */ .topbar-branch-pill { @@ -4265,7 +4276,8 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { display: flex; align-items: center; justify-content: space-between; - padding: 10px 12px 8px; + height: 48px; + padding: 0 12px; border-bottom: 1px solid var(--sidebar-border); flex-shrink: 0; } @@ -4902,6 +4914,8 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { .project-sidebar__sess-row--active { background: transparent; + border-left: 2px solid var(--color-accent-amber); + padding-left: 24px; } /* Session label */ @@ -5046,6 +5060,13 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { font-size: 11px; } +.project-sidebar__empty-hint { + display: block; + font-size: 10px; + margin-top: 3px; + color: var(--color-text-muted); +} + .project-sidebar__footer { margin-top: auto; } diff --git a/packages/web/src/app/orchestrators/page.tsx b/packages/web/src/app/orchestrators/page.tsx deleted file mode 100644 index 63444c915..000000000 --- a/packages/web/src/app/orchestrators/page.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import type { Metadata } from "next"; -import { OrchestratorSelector, type Orchestrator } from "@/components/OrchestratorSelector"; -import { getServices } from "@/lib/services"; -import { getAllProjects } from "@/lib/project-name"; -import { generateSessionPrefix } from "@aoagents/ao-core"; -import { mapSessionsToOrchestrators } from "@/lib/orchestrator-utils"; - -export const dynamic = "force-dynamic"; - -export async function generateMetadata(props: { - searchParams: Promise<{ project?: string }>; -}): Promise { - const searchParams = await props.searchParams; - const projectId = searchParams.project; - let projectName = "Orchestrator"; - if (projectId) { - const projects = getAllProjects(); - const project = projects.find((p) => p.id === projectId); - if (project) { - projectName = project.name; - } - } - return { title: { absolute: `ao | ${projectName} - Orchestrator` } }; -} - -export default async function OrchestratorsRoute(props: { - searchParams: Promise<{ project?: string }>; -}) { - const searchParams = await props.searchParams; - const projectId = searchParams.project; - - if (!projectId) { - return ( -
-
-

- Missing Project -

-

- No project specified. Please provide a project parameter. -

-
-
- ); - } - - let orchestrators: Orchestrator[] = []; - let projectName = projectId; - let error: string | null = null; - - try { - const { config, sessionManager } = await getServices(); - const project = config.projects[projectId]; - - if (!project) { - error = `Project "${projectId}" not found`; - } else { - projectName = project.name; - const sessionPrefix = project.sessionPrefix ?? projectId; - const allSessions = await sessionManager.list(projectId); - const allSessionPrefixes = Object.entries(config.projects).map( - ([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""), - ); - orchestrators = mapSessionsToOrchestrators( - allSessions, - sessionPrefix, - project.name, - allSessionPrefixes, - ); - } - } catch (err) { - error = err instanceof Error ? err.message : "Failed to load orchestrators"; - } - - return ( - - ); -} diff --git a/packages/web/src/app/projects/[projectId]/__tests__/project-layout-client.test.tsx b/packages/web/src/app/projects/[projectId]/__tests__/project-layout-client.test.tsx new file mode 100644 index 000000000..e94256838 --- /dev/null +++ b/packages/web/src/app/projects/[projectId]/__tests__/project-layout-client.test.tsx @@ -0,0 +1,122 @@ +import { render, screen, act } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +let mockPathname = "/projects/proj-1"; +let mockParams: Record = { projectId: "proj-1" }; + +vi.mock("next/navigation", () => ({ + useParams: () => mockParams, + usePathname: () => mockPathname, +})); + +vi.mock("next-themes", () => ({ + useTheme: () => ({ resolvedTheme: "light", setTheme: vi.fn() }), +})); + +vi.mock("@/providers/MuxProvider", () => ({ + useMuxOptional: () => ({ status: "connecting", sessions: [], lastError: null }), +})); + +vi.mock("@/hooks/useSessionEvents", () => ({ + useSessionEvents: ({ initialSessions }: { initialSessions: unknown[] }) => ({ + sessions: initialSessions, + liveSessionsResolved: true, + attentionLevels: {}, + loadError: null, + }), +})); + +vi.mock("@/components/ProjectSidebar", () => ({ + ProjectSidebar: (props: { activeProjectId?: string; orchestrators?: unknown[] }) => ( +
+ ), +})); + +import { ProjectLayoutClient } from "../project-layout-client"; + +const projects = [{ id: "proj-1", name: "Project One", sessionPrefix: "proj-1" }]; +const orchestrators = [{ id: "proj-1-orchestrator", projectId: "proj-1" }]; + +beforeEach(() => { + mockPathname = "/projects/proj-1"; + mockParams = { projectId: "proj-1" }; +}); + +describe("ProjectLayoutClient", () => { + it("renders children and sidebar", () => { + render( + +
Page
+
, + ); + + expect(screen.getByTestId("sidebar")).toBeInTheDocument(); + expect(screen.getByTestId("page-content")).toBeInTheDocument(); + }); + + it("passes activeProjectId from route params to sidebar", () => { + mockParams = { projectId: "proj-1" }; + + render( + +
+ , + ); + + expect(screen.getByTestId("sidebar").dataset.project).toBe("proj-1"); + }); + + it("passes initialOrchestrators directly to sidebar", () => { + render( + +
+ , + ); + + const sidebar = screen.getByTestId("sidebar"); + expect(JSON.parse(sidebar.dataset.orchestrators ?? "[]")).toEqual(orchestrators); + }); + + it("resets mobile sidebar when pathname changes", async () => { + const { rerender } = render( + +
+ , + ); + + // Simulate pathname change + mockPathname = "/projects/proj-1/sessions/sess-1"; + + await act(async () => { + rerender( + +
+ , + ); + }); + + // Sidebar wrapper should not have the mobile-open class + const wrapper = document.querySelector(".sidebar-wrapper"); + expect(wrapper?.classList.contains("sidebar-wrapper--mobile-open")).toBe(false); + }); +}); diff --git a/packages/web/src/app/projects/[projectId]/layout.tsx b/packages/web/src/app/projects/[projectId]/layout.tsx new file mode 100644 index 000000000..330afda9c --- /dev/null +++ b/packages/web/src/app/projects/[projectId]/layout.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from "react"; + +export default function ProjectIdLayout({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/packages/web/src/app/projects/[projectId]/loading.test.tsx b/packages/web/src/app/projects/[projectId]/loading.test.tsx index 54d978a5b..61726031e 100644 --- a/packages/web/src/app/projects/[projectId]/loading.test.tsx +++ b/packages/web/src/app/projects/[projectId]/loading.test.tsx @@ -8,7 +8,8 @@ describe("ProjectRouteLoading", () => { expect(screen.getByText("Agent Orchestrator")).toBeInTheDocument(); expect(screen.getByText("Loading project…")).toBeInTheDocument(); - expect(screen.getByText("Projects")).toBeInTheDocument(); - expect(screen.getByLabelText("Loading project dashboard")).toBeInTheDocument(); + // Sidebar is owned by ProjectLayoutClient — no duplicate skeleton sidebar here + expect(screen.queryByText("Projects")).not.toBeInTheDocument(); + expect(screen.getByText("Working")).toBeInTheDocument(); }); }); diff --git a/packages/web/src/app/projects/[projectId]/loading.tsx b/packages/web/src/app/projects/[projectId]/loading.tsx index cc385e2fd..ffeee105c 100644 --- a/packages/web/src/app/projects/[projectId]/loading.tsx +++ b/packages/web/src/app/projects/[projectId]/loading.tsx @@ -1,101 +1,40 @@ -function ProjectLoadingSidebar() { - return ( - - ); -} - export default function ProjectRouteLoading() { return ( -
-
- - -
- +
+ -
-
-
-
-
- -
); } diff --git a/packages/web/src/app/projects/[projectId]/page.tsx b/packages/web/src/app/projects/[projectId]/page.tsx index 2c677fb1f..39d317391 100644 --- a/packages/web/src/app/projects/[projectId]/page.tsx +++ b/packages/web/src/app/projects/[projectId]/page.tsx @@ -29,7 +29,7 @@ export default async function ProjectPage(props: { const pageData = await getDashboardPageData(projectId); return ( -
+
{ + setMobileSidebarOpen(false); + }, [pathname]); + + const mux = useMuxOptional(); + const { sessions, liveSessionsResolved } = useSessionEvents({ + initialSessions, + muxSessions: mux?.status === "connected" ? mux.sessions : undefined, + muxLastError: mux?.lastError, + attentionZones: "simple", + }); + + const handleToggleSidebar = useCallback(() => { + if (isMobile) { + setMobileSidebarOpen((v) => !v); + } else { + setSidebarCollapsed((v) => !v); + } + }, [isMobile]); + + return ( + +
+
+
+ setSidebarCollapsed((v) => !v)} + onMobileClose={() => setMobileSidebarOpen(false)} + /> +
+ {mobileSidebarOpen && ( +
setMobileSidebarOpen(false)} /> + )} + {children} +
+
+ + ); +} diff --git a/packages/web/src/app/projects/[projectId]/sessions/[id]/page.tsx b/packages/web/src/app/projects/[projectId]/sessions/[id]/page.tsx index 3e7be8541..39c70fcab 100644 --- a/packages/web/src/app/projects/[projectId]/sessions/[id]/page.tsx +++ b/packages/web/src/app/projects/[projectId]/sessions/[id]/page.tsx @@ -1 +1,459 @@ -export { default } from "../../../../sessions/[id]/page"; +"use client"; + +import { useEffect, useState, useCallback, useRef } from "react"; +import { useParams, usePathname, useRouter } from "next/navigation"; +import { isOrchestratorSession } from "@aoagents/ao-core/types"; +import { SessionDetail } from "@/components/SessionDetail"; +import { ErrorDisplay } from "@/components/ErrorDisplay"; +import { + type DashboardSession, + type ActivityState, + getAttentionLevel, +} from "@/lib/types"; +import { activityIcon } from "@/lib/activity-icons"; +import type { ProjectInfo } from "@/lib/project-name"; +import { getSessionTitle } from "@/lib/format"; +import { useMuxSessionActivity } from "@/hooks/useMuxSessionActivity"; +import { projectSessionPath } from "@/lib/routes"; +import { fetchJsonWithTimeout } from "@/lib/client-fetch"; + +function truncate(s: string, max: number): string { + const codePoints = Array.from(s); + return codePoints.length > max ? codePoints.slice(0, max).join("") + "..." : s; +} + +function buildSessionTitle( + session: DashboardSession, + prefixByProject: Map, + activityOverride?: ActivityState | null, +): string { + const id = session.id; + const activity = activityOverride !== undefined ? activityOverride : session.activity; + const emoji = activity ? (activityIcon[activity] ?? "") : ""; + const allPrefixes = [...prefixByProject.values()]; + const isOrchestrator = isOrchestratorSession( + session, + prefixByProject.get(session.projectId), + allPrefixes, + ); + const detail = isOrchestrator ? "Orchestrator Terminal" : truncate(getSessionTitle(session), 40); + return emoji ? `${emoji} ${id} | ${detail}` : `${id} | ${detail}`; +} + +interface ZoneCounts { + merge: number; + respond: number; + review: number; + pending: number; + working: number; + done: number; +} + +interface ProjectSessionsBody { + sessions?: DashboardSession[]; + orchestratorId?: string | null; + orchestrators?: Array<{ id: string; projectId: string; projectName: string }>; +} + +let cachedProjects: ProjectInfo[] | null = null; + +const SESSION_PAGE_REFRESH_INTERVAL_MS = 2000; +const SESSION_FETCH_TIMEOUT_MS = 8000; +const PROJECT_SESSIONS_FETCH_TIMEOUT_MS = 5000; +const PROJECTS_FETCH_TIMEOUT_MS = 5000; +function areProjectsEqual(previous: ProjectInfo[] | null, next: ProjectInfo[]): boolean { + if (!previous || previous.length !== next.length) return false; + return previous.every((p, i) => JSON.stringify(p) === JSON.stringify(next[i])); +} + +function isAbortLikeError(error: unknown): boolean { + if (error instanceof DOMException) return error.name === "AbortError"; + if (error instanceof Error) { + const msg = error.message.toLowerCase(); + return msg.includes("aborted") || msg.includes("aborterror"); + } + return false; +} + +function getSessionLoadErrorMessage(error: Error): string { + const normalized = error.message.toLowerCase(); + if (normalized.includes("timed out")) + return "The session request is taking too long. You can retry, or return to the project and reopen a different session."; + if (normalized.includes("network")) + return "The session request failed before the dashboard got a response. Check the local server connection and try again."; + if (normalized.includes("404")) + return "This session is no longer available. It may have been removed while the page was open."; + if (normalized.includes("500")) + return "The server returned an internal error while loading this session. Try again to re-fetch the latest state."; + return "The dashboard could not load this session cleanly. Try again to re-fetch the latest state."; +} + +function LoadingContent() { + return ( +
+
+ + + +
Loading session…
+
+
+ ); +} + +export default function ProjectSessionPage() { + const params = useParams(); + const pathname = usePathname(); + const router = useRouter(); + const id = params.id as string; + const expectedProjectId = typeof params.projectId === "string" ? params.projectId : undefined; + + // Read optimistic session data written by sidebar navigation + const cachedSession = (() => { + if (typeof sessionStorage === "undefined") return null; + try { + const raw = sessionStorage.getItem(`ao-session-nav:${id}`); + if (raw) { + sessionStorage.removeItem(`ao-session-nav:${id}`); + return JSON.parse(raw) as DashboardSession; + } + } catch { + /* ignore */ + } + return null; + })(); + + const [session, setSession] = useState(cachedSession); + const [zoneCounts, setZoneCounts] = useState(null); + const [projectOrchestratorId, setProjectOrchestratorId] = useState( + undefined, + ); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(cachedSession === null); + const [routeError, setRouteError] = useState(null); + const [sessionMissing, setSessionMissing] = useState(false); + const [prefixByProject, setPrefixByProject] = useState>(new Map()); + + const sessionProjectId = session?.projectId ?? null; + const allPrefixes = [...prefixByProject.values()]; + const sessionIsOrchestrator = session + ? isOrchestratorSession(session, prefixByProject.get(session.projectId), allPrefixes) + : false; + + const sessionProjectIdRef = useRef(null); + const sessionIsOrchestratorRef = useRef(false); + const resolvedProjectSessionsKeyRef = useRef(null); + const prefixByProjectRef = useRef>(new Map()); + const hasLoadedSessionRef = useRef(cachedSession !== null); + const fetchingSessionRef = useRef(false); + const fetchingProjectSessionsRef = useRef(false); + const sessionFetchControllerRef = useRef(null); + const projectSessionsFetchControllerRef = useRef(null); + const pageUnloadingRef = useRef(false); + const mountedRef = useRef(true); + + const sseActivity = useMuxSessionActivity(id); + + useEffect(() => { + prefixByProjectRef.current = prefixByProject; + }, [prefixByProject]); + + useEffect(() => { + if (session) { + document.title = buildSessionTitle(session, prefixByProject, sseActivity?.activity); + } else { + document.title = `${id} | Session Detail`; + } + }, [session, id, prefixByProject, sseActivity]); + + useEffect(() => { + sessionProjectIdRef.current = sessionProjectId; + }, [sessionProjectId]); + + useEffect(() => { + sessionIsOrchestratorRef.current = sessionIsOrchestrator; + }, [sessionIsOrchestrator]); + + useEffect(() => { + if (!session || !projects.some((p) => p.id === session.projectId)) return; + if ( + pathname?.startsWith("/projects/") && + expectedProjectId && + session.projectId !== expectedProjectId + ) { + router.replace(projectSessionPath(session.projectId, session.id)); + } + }, [expectedProjectId, pathname, projects, router, session]); + + useEffect(() => { + if (!sessionIsOrchestrator) setZoneCounts(null); + }, [sessionIsOrchestrator]); + + const fetchProjects = useCallback(async () => { + if (cachedProjects) { + setProjects(cachedProjects); + setPrefixByProject(new Map(cachedProjects.map((p) => [p.id, p.sessionPrefix ?? p.id]))); + } + try { + const data = await fetchJsonWithTimeout<{ projects?: ProjectInfo[] } | null>( + "/api/projects", + { + timeoutMs: PROJECTS_FETCH_TIMEOUT_MS, + timeoutMessage: `Projects request timed out after ${PROJECTS_FETCH_TIMEOUT_MS}ms`, + }, + ); + if (!data?.projects) return; + if (!areProjectsEqual(cachedProjects, data.projects)) { + cachedProjects = data.projects; + setProjects(data.projects); + setPrefixByProject(new Map(data.projects.map((p) => [p.id, p.sessionPrefix ?? p.id]))); + } + } catch (err) { + console.error("Failed to fetch projects:", err); + } + }, []); + + const fetchSession = useCallback(async () => { + if (fetchingSessionRef.current) return; + fetchingSessionRef.current = true; + const controller = new AbortController(); + sessionFetchControllerRef.current = controller; + try { + const data = await fetchJsonWithTimeout( + `/api/sessions/${encodeURIComponent(id)}`, + { + signal: controller.signal, + timeoutMs: SESSION_FETCH_TIMEOUT_MS, + timeoutMessage: `Session request timed out after ${SESSION_FETCH_TIMEOUT_MS}ms`, + }, + ); + setSession(data as DashboardSession); + setRouteError(null); + setSessionMissing(false); + hasLoadedSessionRef.current = true; + } catch (err) { + if (pageUnloadingRef.current || controller.signal.aborted || isAbortLikeError(err)) return; + const message = err instanceof Error ? err.message : "Failed to load session"; + const normalized = message.toLowerCase(); + if (normalized.includes("session not found") || normalized.includes("http 404")) { + if (!hasLoadedSessionRef.current) setSessionMissing(true); + setLoading(false); + return; + } + console.error("Failed to fetch session:", err); + if (!hasLoadedSessionRef.current) { + setRouteError(err instanceof Error ? err : new Error("Failed to load session")); + } + } finally { + fetchingSessionRef.current = false; + if (sessionFetchControllerRef.current === controller) + sessionFetchControllerRef.current = null; + if (!controller.signal.aborted || hasLoadedSessionRef.current) { + setLoading(false); + } else if (mountedRef.current) { + // Aborted before any session was loaded and the component is still + // mounted — React Strict Mode fired the cleanup between mount 1 and + // mount 2, aborting the first fetch. Mount 2's fetchSession() was + // blocked by fetchingSessionRef (not yet reset). Retry immediately + // now that the ref is clear. mountedRef guards against the navigation- + // away case where the component is genuinely unmounted and we should + // not start a new request. + void fetchSession(); + } + } + }, [id]); + + const fetchProjectSessions = useCallback(async () => { + if (fetchingProjectSessionsRef.current) return; + const projectId = sessionProjectIdRef.current; + if (!projectId) return; + const isOrchestrator = sessionIsOrchestratorRef.current; + const projectSessionsKey = `${projectId}:${isOrchestrator ? "orchestrator" : "worker"}`; + if (!isOrchestrator && resolvedProjectSessionsKeyRef.current === projectSessionsKey) return; + fetchingProjectSessionsRef.current = true; + const controller = new AbortController(); + projectSessionsFetchControllerRef.current = controller; + try { + const query = isOrchestrator + ? `/api/sessions?project=${encodeURIComponent(projectId)}&fresh=true` + : `/api/sessions?project=${encodeURIComponent(projectId)}&orchestratorOnly=true&fresh=true`; + const body = await fetchJsonWithTimeout(query, { + signal: controller.signal, + timeoutMs: PROJECT_SESSIONS_FETCH_TIMEOUT_MS, + timeoutMessage: `Project sessions request timed out after ${PROJECT_SESSIONS_FETCH_TIMEOUT_MS}ms`, + }); + const sessions = body.sessions ?? []; + const orchestratorId = + body.orchestratorId ?? + body.orchestrators?.find((o) => o.projectId === projectId)?.id ?? + null; + setProjectOrchestratorId((current) => + current === orchestratorId ? current : orchestratorId, + ); + + if (!isOrchestrator) { + resolvedProjectSessionsKeyRef.current = projectSessionsKey; + return; + } + + const counts: ZoneCounts = { + merge: 0, + respond: 0, + review: 0, + pending: 0, + working: 0, + done: 0, + }; + const allPfxs = [...prefixByProjectRef.current.values()]; + for (const s of sessions) { + if (!isOrchestratorSession(s, prefixByProjectRef.current.get(s.projectId), allPfxs)) { + const level = getAttentionLevel(s); + if (level === "action") continue; + counts[level]++; + } + } + setZoneCounts(counts); + } catch (err) { + if (pageUnloadingRef.current || controller.signal.aborted || isAbortLikeError(err)) return; + console.error("Failed to fetch project sessions:", err); + } finally { + fetchingProjectSessionsRef.current = false; + if (projectSessionsFetchControllerRef.current === controller) + projectSessionsFetchControllerRef.current = null; + } + }, []); + + useEffect(() => { + void Promise.all([fetchProjects(), fetchSession()]); + }, [fetchProjects, fetchSession]); + + useEffect(() => { + if (!sessionProjectId) return; + void fetchProjectSessions(); + }, [fetchProjectSessions, sessionIsOrchestrator, sessionProjectId]); + + useEffect(() => { + const interval = setInterval(() => { + void fetchSession(); + void fetchProjectSessions(); + }, SESSION_PAGE_REFRESH_INTERVAL_MS); + return () => clearInterval(interval); + }, [fetchSession, fetchProjectSessions]); + + useEffect(() => { + pageUnloadingRef.current = false; + const mark = () => { + pageUnloadingRef.current = true; + }; + window.addEventListener("pagehide", mark); + window.addEventListener("beforeunload", mark); + return () => { + window.removeEventListener("pagehide", mark); + window.removeEventListener("beforeunload", mark); + }; + }, []); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + return () => { + sessionFetchControllerRef.current?.abort(); + projectSessionsFetchControllerRef.current?.abort(); + }; + }, []); + + if (loading) return
; + + if (sessionMissing) { + return ( +
+
+ void fetchSession() }} + compact + chrome="card" + /> +
+
+ ); + } + + if (routeError) { + return ( +
+
+ { + setRouteError(null); + setSessionMissing(false); + setLoading(true); + void Promise.all([fetchProjects(), fetchSession()]); + }, + }} + secondaryAction={{ + label: "Back to dashboard", + href: session?.projectId ? `/projects/${session.projectId}` : "/", + }} + error={routeError} + compact + chrome="card" + /> +
+
+ ); + } + + if (!session) { + return ( +
+
+ void fetchSession() }} + secondaryAction={{ + label: "Back to dashboard", + href: expectedProjectId ? `/projects/${expectedProjectId}` : "/", + }} + compact + chrome="card" + /> +
+
+ ); + } + + return ( + + ); +} diff --git a/packages/web/src/app/projects/layout.tsx b/packages/web/src/app/projects/layout.tsx new file mode 100644 index 000000000..91140bcd1 --- /dev/null +++ b/packages/web/src/app/projects/layout.tsx @@ -0,0 +1,19 @@ +import { getDashboardPageData } from "@/lib/dashboard-page-data"; +import { ProjectLayoutClient } from "./[projectId]/project-layout-client"; +import type { ReactNode } from "react"; + +export const dynamic = "force-dynamic"; + +export default async function ProjectLayout({ children }: { children: ReactNode }) { + const pageData = await getDashboardPageData("all"); + + return ( + + {children} + + ); +} diff --git a/packages/web/src/app/sessions/[id]/page.test.tsx b/packages/web/src/app/sessions/[id]/page.test.tsx index 1f3158e8d..6fa1990e9 100644 --- a/packages/web/src/app/sessions/[id]/page.test.tsx +++ b/packages/web/src/app/sessions/[id]/page.test.tsx @@ -454,77 +454,6 @@ describe("SessionPage project polling", () => { expect(screen.getAllByText("Failed to load session").length).toBeGreaterThan(0); }); - it("marks sidebar data as loading until the sessions list resolves", async () => { - const workerSession = makeWorkerSession(); - let resolveSidebarSessions: ((value: Response) => void) | null = null; - - global.fetch = vi.fn((input: RequestInfo | URL) => { - const url = String(input); - if (url === "/api/projects") { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => ({ - projects: [{ id: "my-app", name: "My App", sessionPrefix: "my-app" }], - }), - } as Response); - } - - if (url === "/api/sessions/worker-1") { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => workerSession, - } as Response); - } - - if (url === "/api/sessions?fresh=true") { - return new Promise((resolve) => { - resolveSidebarSessions = resolve; - }); - } - - if (url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true") { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => ({ orchestratorId: "my-app-orchestrator" }), - } as Response); - } - - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }) as typeof fetch; - - const { default: SessionPage } = await import("./page"); - - render(); - await flushAsyncWork(); - - const latestBeforeSidebarResolve = sessionDetailSpy.mock.lastCall?.[0] as { - sidebarLoading?: boolean; - sidebarSessions?: DashboardSession[] | null; - }; - - expect(latestBeforeSidebarResolve.sidebarLoading).toBe(true); - expect(latestBeforeSidebarResolve.sidebarSessions).toBeNull(); - - await act(async () => { - resolveSidebarSessions?.({ - ok: true, - status: 200, - json: async () => ({ sessions: [workerSession] }), - } as Response); - await Promise.resolve(); - }); - - const latestAfterSidebarResolve = sessionDetailSpy.mock.lastCall?.[0] as { - sidebarLoading?: boolean; - sidebarSessions?: DashboardSession[] | null; - }; - - expect(latestAfterSidebarResolve.sidebarLoading).toBe(false); - expect(latestAfterSidebarResolve.sidebarSessions).toEqual([workerSession]); - }); it("revalidates projects and sidebar sessions on remount even when cache exists", async () => { const workerSession = makeWorkerSession(); @@ -642,145 +571,7 @@ describe("SessionPage project polling", () => { ); }); - it("surfaces sidebar fetch failures instead of leaving the loading skeleton active", async () => { - const workerSession = makeWorkerSession(); - global.fetch = vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); - if (url === "/api/projects") { - return { - ok: true, - status: 200, - json: async () => ({ - projects: [{ id: "my-app", name: "My App", sessionPrefix: "my-app" }], - }), - } as Response; - } - - if (url === "/api/sessions/worker-1") { - return { - ok: true, - status: 200, - json: async () => workerSession, - } as Response; - } - - if (url === "/api/sessions?fresh=true") { - return { - ok: false, - status: 500, - json: async () => ({}), - } as Response; - } - - if (url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true") { - return { - ok: true, - status: 200, - json: async () => ({ orchestratorId: "my-app-orchestrator" }), - } as Response; - } - - throw new Error(`Unexpected fetch: ${url}`); - }) as typeof fetch; - - const { default: SessionPage } = await import("./page"); - - render(); - await flushAsyncWork(); - - const latestProps = sessionDetailSpy.mock.lastCall?.[0] as { - sidebarError?: boolean; - sidebarLoading?: boolean; - sidebarSessions?: DashboardSession[] | null; - }; - - expect(latestProps.sidebarLoading).toBe(false); - expect(latestProps.sidebarError).toBe(true); - expect(latestProps.sidebarSessions).toEqual([]); - }); - - it("applies mux snapshots that arrive before the initial sidebar fetch resolves", async () => { - const workerSession = makeWorkerSession(); - const muxPatchedLastActivityAt = "2026-04-14T12:00:00.000Z"; - let resolveSidebarSessions: ((value: Response) => void) | null = null; - - mockMuxState.current = { - status: "connected", - sessions: [ - { - id: "worker-1", - status: "working", - activity: "ready", - attentionLevel: "pending", - lastActivityAt: muxPatchedLastActivityAt, - }, - ], - }; - - global.fetch = vi.fn((input: RequestInfo | URL) => { - const url = String(input); - if (url === "/api/projects") { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => ({ - projects: [{ id: "my-app", name: "My App", sessionPrefix: "my-app" }], - }), - } as Response); - } - - if (url === "/api/sessions/worker-1") { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => workerSession, - } as Response); - } - - if (url === "/api/sessions?fresh=true") { - return new Promise((resolve) => { - resolveSidebarSessions = resolve; - }); - } - - if (url === "/api/sessions?project=my-app&orchestratorOnly=true&fresh=true") { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => ({ orchestratorId: "my-app-orchestrator" }), - } as Response); - } - - return Promise.reject(new Error(`Unexpected fetch: ${url}`)); - }) as typeof fetch; - - const { default: SessionPage } = await import("./page"); - - render(); - await flushAsyncWork(); - - await act(async () => { - resolveSidebarSessions?.({ - ok: true, - status: 200, - json: async () => ({ sessions: [workerSession] }), - } as Response); - await Promise.resolve(); - }); - - const latestProps = sessionDetailSpy.mock.lastCall?.[0] as { - sidebarSessions?: DashboardSession[] | null; - }; - - expect(latestProps.sidebarSessions).toEqual([ - { - ...workerSession, - activity: "ready", - lastActivityAt: muxPatchedLastActivityAt, - }, - ]); - }); it("redirects the legacy session URL to the project-scoped route for clean projects", async () => { mockPathname = "/sessions/worker-1"; diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx index f16c5c365..a69d80e9c 100644 --- a/packages/web/src/app/sessions/[id]/page.tsx +++ b/packages/web/src/app/sessions/[id]/page.tsx @@ -5,10 +5,7 @@ import { useParams, usePathname, useRouter } from "next/navigation"; import { ACTIVITY_STATE, SESSION_STATUS, isOrchestratorSession } from "@aoagents/ao-core/types"; import { SessionDetail } from "@/components/SessionDetail"; import { ErrorDisplay } from "@/components/ErrorDisplay"; -import { - ProjectSidebar, - type ProjectSidebarOrchestrator, -} from "@/components/ProjectSidebar"; +import { ProjectSidebar, type ProjectSidebarOrchestrator } from "@/components/ProjectSidebar"; import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery"; import { type DashboardSession, @@ -938,11 +935,6 @@ export default function SessionPage() { orchestratorZones={zoneCounts ?? undefined} projectOrchestratorId={projectOrchestratorId} projects={projects} - sidebarSessions={sidebarSessions} - sidebarOrchestrators={sidebarOrchestrators} - sidebarLoading={sidebarSessions === null} - sidebarError={sidebarError} - onRetrySidebar={fetchSidebarSessions} /> ); } diff --git a/packages/web/src/components/CopyDebugBundleButton.tsx b/packages/web/src/components/CopyDebugBundleButton.tsx index a58cc9607..847494c67 100644 --- a/packages/web/src/components/CopyDebugBundleButton.tsx +++ b/packages/web/src/components/CopyDebugBundleButton.tsx @@ -108,9 +108,10 @@ export function CopyDebugBundleButton({ projectId }: CopyDebugBundleButtonProps) return ( ); } diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index 857b738c0..e3f5c177b 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -18,7 +18,6 @@ import { AttentionZone } from "./AttentionZone"; import { DynamicFavicon, countNeedingAttention } from "./DynamicFavicon"; import { useSessionEvents } from "@/hooks/useSessionEvents"; import { useMuxOptional } from "@/providers/MuxProvider"; -import { ProjectSidebar } from "./ProjectSidebar"; import type { ProjectInfo } from "@/lib/project-name"; import { EmptyState } from "./Skeleton"; import { ToastProvider, useToast } from "./Toast"; @@ -26,7 +25,9 @@ import { ConnectionBar } from "./ConnectionBar"; import { UpdateBanner } from "./UpdateBanner"; import { CopyDebugBundleButton } from "./CopyDebugBundleButton"; import { DashboardNotificationButton } from "./DashboardNotificationButton"; -import { SidebarContext } from "./workspace/SidebarContext"; +import { SidebarContext, useSidebarContext } from "./workspace/SidebarContext"; +import { ProjectSidebar } from "./ProjectSidebar"; +import { isOrchestratorSession } from "@aoagents/ao-core/types"; import { projectDashboardPath, projectSessionPath } from "@/lib/routes"; import { BottomSheet } from "./BottomSheet"; @@ -176,6 +177,25 @@ function DashboardInner({ if (!projectId) return sessions; return sessions.filter((s) => s.projectId === projectId); }, [sessions, projectId]); + + const allSessionPrefixes = useMemo( + () => projects.map((p) => p.sessionPrefix ?? p.id), + [projects], + ); + + const sidebarOrchestrators = useMemo( + () => + sessions + .filter((s) => + isOrchestratorSession( + s, + projects.find((p) => p.id === s.projectId)?.sessionPrefix ?? s.projectId, + allSessionPrefixes, + ), + ) + .map((s) => ({ id: s.id, projectId: s.projectId })), + [sessions, projects, allSessionPrefixes], + ); const connectionStatus: "connected" | "reconnecting" | "disconnected" = mux?.status === "disconnected" ? "disconnected" @@ -196,9 +216,20 @@ function DashboardInner({ useState(orchestratorLinks); const [spawningProjectIds, setSpawningProjectIds] = useState([]); const [spawnErrors, setSpawnErrors] = useState>({}); - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const isMobile = useMediaQuery(MOBILE_BREAKPOINT); + // Detect if a parent layout already owns the sidebar — if so, skip rendering our own. + const parentCtx = useSidebarContext(); + const isInsideLayout = parentCtx !== null; + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); + const handleToggleSidebar = useCallback(() => { + if (isInsideLayout && parentCtx) { parentCtx.onToggleSidebar(); return; } + if (isMobile) { + setMobileSidebarOpen((v) => !v); + } else { + setSidebarCollapsed((v) => !v); + } + }, [isMobile, isInsideLayout, parentCtx]); const [collapsedZones, setCollapsedZones] = useState>( () => new Set(["done", "working"]), ); @@ -251,10 +282,6 @@ function DashboardInner({ document.title = needsAttention > 0 ? `${label} (${needsAttention} need attention)` : label; }, [attentionLevels, projectName]); - useEffect(() => { - setMobileMenuOpen(false); - }, [searchParams]); - const grouped = useMemo(() => { const zones: Record = { merge: [], @@ -517,293 +544,337 @@ function DashboardInner({ return next; }); }; - const handleToggleSidebar = () => { - if (typeof window !== "undefined" && window.innerWidth < 768) { - setMobileMenuOpen((current) => !current); - } else { - setSidebarCollapsed((current) => !current); - } - }; - return ( - - <> - - -
-
- +
+ Agent Orchestrator +
+ {showHeaderProjectLabel ? ( + <> +
+ +
+ +
+

Dashboard

+

+ Live agent sessions, pull requests, and merge status. +

+
+ +
+ {loadErrorBanner} + {anyRateLimited && !rateLimitDismissed && ( +
+ - ) : ( - - )} - -
-
- {showHeaderProjectLabel ? ( - <> -
+
+ ); + + const bottomSheet = ( + + ); + + if (isInsideLayout) { + return ( + <> + + + {mainPanel} + {bottomSheet} + ); + } + + return ( + + + +
+
+
+ setSidebarCollapsed((v) => !v)} + onMobileClose={() => setMobileSidebarOpen(false)} + /> +
+ {mobileSidebarOpen && ( +
setMobileSidebarOpen(false)} + /> + )} + {mainPanel} +
+
+ {bottomSheet} ); } diff --git a/packages/web/src/components/OrchestratorSelector.tsx b/packages/web/src/components/OrchestratorSelector.tsx deleted file mode 100644 index a178a7a6a..000000000 --- a/packages/web/src/components/OrchestratorSelector.tsx +++ /dev/null @@ -1,273 +0,0 @@ -"use client"; - -import { useState, useRef } from "react"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { cn } from "@/lib/cn"; -import { projectDashboardPath, projectSessionPath } from "@/lib/routes"; - -export interface Orchestrator { - id: string; - projectId: string; - projectName: string; - status: string; - activity: string | null; - createdAt: string | null; - lastActivityAt: string | null; -} - -interface OrchestratorSelectorProps { - orchestrators: Orchestrator[]; - projectId: string; - projectName: string; - error: string | null; -} - -function formatRelativeTime(isoDate: string | null): string { - if (!isoDate) return "Unknown"; - const date = new Date(isoDate); - const timestamp = date.getTime(); - // Guard against invalid dates (NaN) and future timestamps - if (!Number.isFinite(timestamp)) return "Unknown"; - const now = new Date(); - const diffMs = now.getTime() - timestamp; - // Handle future timestamps - if (diffMs < 0) return "Just now"; - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMins / 60); - const diffDays = Math.floor(diffHours / 24); - - if (diffMins < 1) return "Just now"; - if (diffMins < 60) return `${diffMins}m ago`; - if (diffHours < 24) return `${diffHours}h ago`; - return `${diffDays}d ago`; -} - -function getStatusColor(status: string): string { - switch (status) { - case "working": - return "var(--color-status-working)"; - case "spawning": - return "var(--color-status-attention)"; - case "pr_open": - case "review_pending": - case "approved": - case "mergeable": - return "var(--color-status-ready)"; - case "ci_failed": - case "changes_requested": - return "var(--color-status-error)"; - case "merged": - case "done": - case "killed": - case "terminated": - return "var(--color-text-tertiary)"; - default: - return "var(--color-text-secondary)"; - } -} - -function getActivityLabel(activity: string | null): string { - if (!activity) return ""; - switch (activity) { - case "active": - return "Active"; - case "ready": - return "Ready"; - case "idle": - return "Idle"; - case "waiting_input": - return "Waiting"; - case "blocked": - return "Blocked"; - case "exited": - return "Exited"; - default: - return activity; - } -} - -export function OrchestratorSelector({ - orchestrators, - projectId, - projectName, - error, -}: OrchestratorSelectorProps) { - const router = useRouter(); - const [isSpawning, setIsSpawning] = useState(false); - const [spawnError, setSpawnError] = useState(null); - const spawnLockRef = useRef(false); - - const handleSpawnNew = async () => { - // Synchronous re-entrancy guard: React state updates are async, - // so two clicks before rerender would fire two POSTs without this. - if (spawnLockRef.current) return; - spawnLockRef.current = true; - setIsSpawning(true); - setSpawnError(null); - - try { - const response = await fetch("/api/orchestrators", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ projectId }), - }); - - if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || "Failed to spawn orchestrator"); - } - - const data = await response.json(); - router.push(projectSessionPath(projectId, data.orchestrator.id)); - } catch (err) { - setSpawnError(err instanceof Error ? err.message : "Failed to spawn orchestrator"); - } finally { - setIsSpawning(false); - spawnLockRef.current = false; - } - }; - - if (error) { - return ( -
-
-

Error

-

{error}

- - Go to Dashboard - -
-
- ); - } - - return ( -
- {/* Header */} -
-
-
-

- {projectName} -

-

Project orchestrator

-
- - Dashboard - -
-
- - {/* Content */} -
-
- {/* Info banner */} -
-

- AO keeps one main orchestrator per project. Opening or spawning here reuses that - canonical session instead of creating another duplicate. -

-
- - {/* Existing orchestrators */} -
-

- Main Session -

-
- {orchestrators.map((orch) => ( - -
-
-
-
{orch.id}
-
- {orch.status.replace(/_/g, " ")} - {orch.activity && ( - <> - · - {getActivityLabel(orch.activity)} - - )} -
-
-
-
-
Created {formatRelativeTime(orch.createdAt)}
- {orch.lastActivityAt && ( -
Active {formatRelativeTime(orch.lastActivityAt)}
- )} -
- - ))} -
-
- - {/* Start new section */} -
-

- Open Orchestrator -

- - {spawnError && ( -

{spawnError}

- )} -
-
-
-
- ); -} diff --git a/packages/web/src/components/ProjectSidebar.tsx b/packages/web/src/components/ProjectSidebar.tsx index 2b6b78e1c..c6b29a7f6 100644 --- a/packages/web/src/components/ProjectSidebar.tsx +++ b/packages/web/src/components/ProjectSidebar.tsx @@ -1,11 +1,11 @@ "use client"; import Link from "next/link"; -import { useState, useEffect, useMemo, useRef } from "react"; +import { useState, useEffect, useMemo, useRef, useCallback, memo } from "react"; import { useRouter } from "next/navigation"; import { cn } from "@/lib/cn"; import type { ProjectInfo } from "@/lib/project-name"; -import { getAttentionLevel, type DashboardSession, type AttentionLevel } from "@/lib/types"; +import { getAttentionLevel, type DashboardSession } from "@/lib/types"; import { isOrchestratorSession } from "@aoagents/ao-core/types"; import { getSessionTitle, humanizeBranch } from "@/lib/format"; import { usePopoverClamp } from "@/hooks/usePopoverClamp"; @@ -42,7 +42,7 @@ interface ProjectSidebarProps { type SessionDotLevel = "respond" | "review" | "action" | "pending" | "working" | "merge" | "done"; -function SessionDot({ level }: { level: SessionDotLevel }) { +const SessionDot = memo(function SessionDot({ level }: { level: SessionDotLevel }) { return (
); -} +}); // ProjectSidebar consumes `getAttentionLevel()` without passing a mode, // so the function defaults to "detailed" and `action` never appears here @@ -70,15 +70,41 @@ function loadShowSessionId(): boolean { } } -const LEVEL_LABELS: Record = { - working: "working", - pending: "pending", - review: "review", - respond: "respond", - action: "action", - merge: "merge", - done: "done", -}; +const SHOW_KILLED_KEY = "ao:sidebar:show-killed"; +const SHOW_DONE_KEY = "ao:sidebar:show-done"; +const EXPANDED_PROJECTS_KEY = "ao:sidebar:expanded-projects"; + +function loadShowKilled(): boolean { + if (typeof window === "undefined") return false; + try { + return window.sessionStorage.getItem(SHOW_KILLED_KEY) === "true"; + } catch { + return false; + } +} + +function loadShowDone(): boolean { + if (typeof window === "undefined") return false; + try { + return window.sessionStorage.getItem(SHOW_DONE_KEY) === "true"; + } catch { + return false; + } +} + +function loadExpandedProjects(): Set | null { + if (typeof window === "undefined") return null; + try { + const raw = window.sessionStorage.getItem(EXPANDED_PROJECTS_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) return new Set(parsed); + return null; + } catch { + return null; + } +} + export function ProjectSidebar(props: ProjectSidebarProps) { if (props.projects.length === 0) { @@ -87,6 +113,102 @@ export function ProjectSidebar(props: ProjectSidebarProps) { return ; } +interface SessionRowProps { + session: DashboardSession; + level: SessionDotLevel; + isActive: boolean; + showSessionId: boolean; + pendingRename: string | undefined; + onNavigate: (href: string, session: DashboardSession) => void; + onStartRename: (session: DashboardSession, title: string) => void; +} + +const SessionRow = memo(function SessionRow({ + session, + level, + isActive, + showSessionId, + pendingRename, + onNavigate, + onStartRename, +}: SessionRowProps) { + const effectiveDisplayName = + pendingRename !== undefined + ? pendingRename + : session.displayNameUserSet + ? (session.displayName ?? "") + : ""; + const title = + effectiveDisplayName !== "" + ? effectiveDisplayName + : (session.branch ?? getSessionTitle(session)); + const sessionHref = projectSessionPath(session.projectId, session.id); + + return ( +
+ { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; + e.preventDefault(); + onNavigate(sessionHref, session); + }} + className="project-sidebar__sess-link flex flex-1 min-w-0 items-center gap-[7px]" + aria-current={isActive ? "page" : undefined} + aria-label={`Open ${title}`} + > + +
+ + {title} + + {showSessionId ? ( +
+ {session.id} +
+ ) : null} +
+
+ +
+ ); +}); + function ProjectSidebarEmpty({ collapsed = false }: { collapsed?: boolean }) { const [addProjectOpen, setAddProjectOpen] = useState(false); @@ -162,13 +284,15 @@ function ProjectSidebarInner({ onMobileClose, }: ProjectSidebarProps) { const router = useRouter(); - const isLoading = loading || sessions === null; + const _isLoading = loading || sessions === null; const [expandedProjects, setExpandedProjects] = useState>( - () => new Set(activeProjectId && activeProjectId !== "all" ? [activeProjectId] : []), + () => + loadExpandedProjects() ?? + new Set(activeProjectId && activeProjectId !== "all" ? [activeProjectId] : []), ); - const [showKilled, setShowKilled] = useState(false); - const [showDone, setShowDone] = useState(false); + const [showKilled, setShowKilled] = useState(loadShowKilled); + const [showDone, setShowDone] = useState(loadShowDone); const [showSessionId, setShowSessionId] = useState(loadShowSessionId); // Inline session-rename state. Only one row is edited at a time. `pendingRenames` // mirrors the in-flight / just-saved value so the new label appears immediately @@ -198,6 +322,30 @@ function ProjectSidebarInner({ } }, [showSessionId]); + useEffect(() => { + try { + window.sessionStorage.setItem(SHOW_KILLED_KEY, String(showKilled)); + } catch { + // sessionStorage unavailable — accept in-memory state. + } + }, [showKilled]); + + useEffect(() => { + try { + window.sessionStorage.setItem(SHOW_DONE_KEY, String(showDone)); + } catch { + // sessionStorage unavailable — accept in-memory state. + } + }, [showDone]); + + useEffect(() => { + try { + window.sessionStorage.setItem(EXPANDED_PROJECTS_KEY, JSON.stringify([...expandedProjects])); + } catch { + // sessionStorage unavailable — accept in-memory state. + } + }, [expandedProjects]); + // Close the settings popover on outside click or Escape. useEffect(() => { if (!settingsOpen) return; @@ -266,20 +414,37 @@ function ProjectSidebarInner({ [visibleProjects], ); - // The API (selectPreferredOrchestratorId) sends at most one entry per - // project, so collapsing into a Map keyed by projectId is lossless. If a - // future API change starts emitting multiples, the last one wins here. const orchestratorByProject = useMemo( () => new Map((orchestrators ?? []).map((o) => [o.projectId, o])), [orchestrators], ); + // Stable ref so sessionsByProject can read latest sessions without depending + // on the array reference (which changes every SSE tick even when content is unchanged). + const sessionsRef = useRef(sessions); + sessionsRef.current = sessions; + + // Content-based key — only changes when session IDs, statuses, or projects change. + // Used as the sole sessions-related dependency of sessionsByProject below. + const sessionsKey = useMemo( + () => + (sessions ?? []) + .map( + (s) => + `${s.id}:${s.status}:${s.activity ?? ""}:${s.projectId}:${s.displayName ?? ""}:${s.displayNameUserSet ? "1" : "0"}:${s.branch ?? ""}:${s.issueTitle ?? ""}:${s.pr?.title ?? ""}:${s.summary ?? ""}`, + ) + .join("|"), + [sessions], + ); + const sessionsByProject = useMemo(() => { const map = new Map(); // Build a set of valid project IDs to filter sessions strictly const validProjectIds = new Set(visibleProjects.map((p) => p.id)); - for (const s of sessions ?? []) { + // Read via ref so this memo only reruns when sessionsKey changes (content + // changed), not when sessions gets a new array reference with identical data. + for (const s of sessionsRef.current ?? []) { // Only include sessions whose projectId matches a configured project if (!validProjectIds.has(s.projectId)) continue; if (isOrchestratorSession(s, prefixByProject.get(s.projectId), allPrefixes)) continue; @@ -295,7 +460,8 @@ function ProjectSidebarInner({ map.set(s.projectId, list); } return map; - }, [sessions, prefixByProject, allPrefixes, visibleProjects, showKilled, showDone]); + }, [sessionsKey, prefixByProject, allPrefixes, visibleProjects, showKilled, showDone]); + // Clear an optimistic rename once the prop session.displayName catches up. // Without this, we'd keep masking the server value forever after a save. @@ -313,18 +479,23 @@ function ProjectSidebarInner({ if (changed) setPendingRenames(next); }, [sessions, pendingRenames]); - const startRename = (session: DashboardSession, currentTitle: string) => { - // Prefer the in-flight optimistic value over the prop — if the user opens - // rename while a previous PATCH is still propagating, the prop still shows - // the pre-rename value but we want the input to start from the latest. - // Auto-derived displayName isn't pre-filled (user-set flag absent) — start - // from the live title so the user types over the visible label. - const pending = pendingRenames.get(session.id); - const initial = - pending ?? (session.displayNameUserSet ? (session.displayName ?? "") : ""); - setEditingSessionId(session.id); - setEditingValue(initial || currentTitle); - }; + const pendingRenamesRef = useRef(pendingRenames); + pendingRenamesRef.current = pendingRenames; + + const startRename = useCallback( + (session: DashboardSession, currentTitle: string) => { + // Prefer the in-flight optimistic value over the prop — if the user opens + // rename while a previous PATCH is still propagating, the prop still shows + // the pre-rename value but we want the input to start from the latest. + // Auto-derived displayName isn't pre-filled (user-set flag absent) — start + // from the live title so the user types over the visible label. + const pending = pendingRenamesRef.current.get(session.id); + const initial = pending ?? (session.displayNameUserSet ? (session.displayName ?? "") : ""); + setEditingSessionId(session.id); + setEditingValue(initial || currentTitle); + }, + [], + ); const cancelRename = () => { setEditingSessionId(null); @@ -367,17 +538,20 @@ function ProjectSidebarInner({ } }; - const navigate = (url: string, session?: DashboardSession) => { - if (session) { - try { - sessionStorage.setItem(`ao-session-nav:${session.id}`, JSON.stringify(session)); - } catch { - // sessionStorage unavailable — silent fallback + const navigate = useCallback( + (url: string, session?: DashboardSession) => { + if (session) { + try { + sessionStorage.setItem(`ao-session-nav:${session.id}`, JSON.stringify(session)); + } catch { + // sessionStorage unavailable — silent fallback + } } - } - router.push(url); - onMobileClose?.(); - }; + router.push(url); + onMobileClose?.(); + }, + [router, onMobileClose], + ); const toggleExpand = (projectId: string) => { setExpandedProjects((prev) => { @@ -544,6 +718,16 @@ function ProjectSidebarInner({ {/* Project tree */}
+ {sessions === null ? ( +
+ {Array.from({ length: 4 }, (_, i) => ( +
+
+
+
+ ))} +
+ ) : null} {visibleProjects.map((project) => { const workerSessions = sessionsByProject.get(project.id) ?? []; const isExpanded = expandedProjects.has(project.id); @@ -553,8 +737,14 @@ function ProjectSidebarInner({ // sessionsByProject already applies the showDone filter consistently. const visibleSessions = workerSessions; const hasActiveSessions = visibleSessions.length > 0; - const orchestratorLink = orchestratorByProject.get(project.id) ?? null; + // Look up the full session object so navigate() can cache it in + // sessionStorage — prevents the "Session unavailable" flash on + // first load. Orchestrators are filtered out of sessionsByProject + // but still present in the raw sessions prop. + const orchestratorSession = orchestratorLink + ? (sessions?.find((s) => s.id === orchestratorLink.id) ?? null) + : null; return (
@@ -625,7 +815,7 @@ function ProjectSidebarInner({ hasActiveSessions && "project-sidebar__proj-badge--active", )} > - {workerSessions.length} + {sessionsByProject.get(project.id)?.length ?? 0} )} @@ -656,14 +846,17 @@ function ProjectSidebarInner({ ) : null} - {/* Orchestrator button */} {!isDegraded && orchestratorLink && ( - { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; + e.preventDefault(); e.stopPropagation(); - onMobileClose?.(); + navigate( + projectSessionPath(project.id, orchestratorLink.id), + orchestratorSession ?? undefined, + ); }} className="project-sidebar__proj-action" aria-label={`Open ${project.name} orchestrator`} @@ -683,7 +876,7 @@ function ProjectSidebarInner({ - + )}
{ setProjectMenuOpenId(null); - navigate(projectSessionPath(project.id, orchestratorLink.id)); + navigate( + projectSessionPath(project.id, orchestratorLink.id), + orchestratorSession ?? undefined, + ); }} > Open orchestrator @@ -768,7 +964,7 @@ function ProjectSidebarInner({ {/* Sessions */} {!isDegraded && isExpanded && (
- {isLoading ? ( + {sessions === null ? (
{Array.from({ length: 3 }, (_, index) => (
{ const level = getAttentionLevel(session); const isSessionActive = activeSessionId === session.id; - // Display precedence: optimistic rename (just-saved value) - // → user-set displayName → branch → fallback chain. - // Auto-derived displayName (displayNameUserSet=false) is - // intentionally skipped here so PR/issue titles surfaced - // by getSessionTitle aren't shadowed — mirrors the gate in - // format.ts:getSessionTitle. - const pending = pendingRenames.get(session.id); - const effectiveDisplayName = - pending !== undefined - ? pending - : session.displayNameUserSet - ? (session.displayName ?? "") - : ""; - const title = - effectiveDisplayName !== "" - ? effectiveDisplayName - : (session.branch ?? getSessionTitle(session)); - const sessionHref = projectSessionPath(project.id, session.id); const isEditing = editingSessionId === session.id; if (isEditing) { return ( @@ -839,76 +1017,16 @@ function ProjectSidebarInner({ ); } return ( - + session={session} + level={level} + isActive={isSessionActive} + showSessionId={showSessionId} + pendingRename={pendingRenames.get(session.id)} + onNavigate={navigate} + onStartRename={startRename} + /> ); }) ) : error ? ( @@ -923,7 +1041,9 @@ function ProjectSidebarInner({
) : ( -
No sessions shown
+
+ No active sessions +
)}
)} diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index d904d559d..e94d2d972 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -11,10 +11,9 @@ import { import dynamic from "next/dynamic"; import { getSessionTitle } from "@/lib/format"; import type { ProjectInfo } from "@/lib/project-name"; -import { SidebarContext } from "./workspace/SidebarContext"; +import { useSidebarContext } from "./workspace/SidebarContext"; import { projectDashboardPath, projectSessionPath } from "@/lib/routes"; -import { ProjectSidebar, type ProjectSidebarOrchestrator } from "./ProjectSidebar"; import { MobileBottomNav } from "./MobileBottomNav"; import { SessionDetailHeader, type OrchestratorZones } from "./SessionDetailHeader"; import { SessionEndedSummary } from "./SessionEndedSummary"; @@ -40,11 +39,6 @@ interface SessionDetailProps { orchestratorZones?: OrchestratorZones; projectOrchestratorId?: string | null; projects?: ProjectInfo[]; - sidebarSessions?: DashboardSession[] | null; - sidebarOrchestrators?: ProjectSidebarOrchestrator[]; - sidebarLoading?: boolean; - sidebarError?: boolean; - onRetrySidebar?: () => void; } export function SessionDetail({ @@ -53,19 +47,14 @@ export function SessionDetail({ orchestratorZones, projectOrchestratorId = null, projects = [], - sidebarSessions = [], - sidebarOrchestrators, - sidebarLoading = false, - sidebarError = false, - onRetrySidebar, }: SessionDetailProps) { const router = useRouter(); const searchParams = useSearchParams(); const isMobile = useMediaQuery(MOBILE_BREAKPOINT); + const sidebarCtx = useSidebarContext(); const startFullscreen = searchParams.get("fullscreen") === "true"; - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [showTerminal, setShowTerminal] = useState(false); + const [relaunchError, setRelaunchError] = useState(null); const pr = session.pr; const terminalEnded = isDashboardSessionTerminal(session); const isRestorable = isDashboardSessionRestorable(session); @@ -119,11 +108,52 @@ export function SessionDetail({ } }, [session.id]); + const handleRelaunchClean = useCallback(async () => { + const confirmed = window.confirm( + "This will discard the current orchestrator's conversation and state. Continue?", + ); + if (!confirmed) return; + setRelaunchError(null); + try { + const res = await fetch("/api/orchestrators", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectId: session.projectId, clean: true }), + }); + if (!res.ok) { + // Surface server-side errors. Note: a failure here may indicate the + // existing orchestrator was killed but respawn failed — the banner + // tells the user the previous session was terminated so they don't + // assume the page they're looking at is still live. + let message = ""; + try { + const data = (await res.json()) as { error?: string }; + message = data.error ?? ""; + } catch { + message = await res.text().catch(() => ""); + } + throw new Error(message || `HTTP ${res.status}`); + } + // Hard-navigate to the freshly spawned orchestrator's session page. + // Orchestrator session IDs are fixed per project, so this is the same + // path in practice — but reading from the response keeps us correct if + // the contract ever changes (and a hard nav forces the terminal + // WebSocket to reconnect against the new tmux session). + const data = (await res.json()) as { orchestrator?: { id: string } }; + const newId = data.orchestrator?.id ?? session.id; + window.location.href = projectSessionPath(session.projectId, newId); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to relaunch orchestrator"; + console.error("Failed to relaunch orchestrator:", err); + setRelaunchError(message); + } + }, [session.id, session.projectId]); + const orchestratorHref = useMemo(() => { - if (isOrchestrator) return projectSessionPath(session.projectId, session.id); + if (isOrchestrator) return null; if (projectOrchestratorId) return projectSessionPath(session.projectId, projectOrchestratorId); return null; - }, [isOrchestrator, projectOrchestratorId, session.id, session.projectId]); + }, [isOrchestrator, projectOrchestratorId, session.projectId]); useEffect(() => { const frame = window.requestAnimationFrame(() => setShowTerminal(true)); @@ -133,104 +163,86 @@ export function SessionDetail({ }; }, [session.id]); - const handleToggleSidebar = useCallback(() => { - if (isMobile) { - setMobileSidebarOpen((v) => !v); - } else { - setSidebarCollapsed((v) => !v); - } - }, [isMobile]); - return ( - -
- - -
- {projects.length > 0 ? ( -
- setSidebarCollapsed((current) => !current)} - onMobileClose={() => setMobileSidebarOpen(false)} - /> -
- ) : null} - {mobileSidebarOpen && ( -
setMobileSidebarOpen(false)} /> - )} - -
-
-
- {!showTerminal ? ( -
- ) : terminalEnded ? ( - - ) : ( - - )} +
+ {})} + onRestore={handleRestore} + onKill={handleKill} + onRelaunchClean={isOrchestrator ? handleRelaunchClean : undefined} + /> +
+ {relaunchError ? ( +
+
+
+ Relaunch failed: {relaunchError} +
+ The previous orchestrator may already be terminated. Try again from the + project dashboard. +
-
+ +
+ ) : null} +
+ {!showTerminal ? ( +
+ ) : terminalEnded ? ( + + ) : ( + + )}
- -
- +
+ +
); } diff --git a/packages/web/src/components/SessionDetailHeader.tsx b/packages/web/src/components/SessionDetailHeader.tsx index 4fe884c45..b264632d4 100644 --- a/packages/web/src/components/SessionDetailHeader.tsx +++ b/packages/web/src/components/SessionDetailHeader.tsx @@ -33,6 +33,7 @@ interface SessionDetailHeaderProps { onToggleSidebar: () => void; onRestore: () => void; onKill: () => void; + onRelaunchClean?: () => void; } function normalizeActivityLabelForClass(activityLabel: string): string { @@ -77,6 +78,7 @@ export function SessionDetailHeader({ onToggleSidebar, onRestore, onKill, + onRelaunchClean, }: SessionDetailHeaderProps) { const pr = session.pr; const allGreen = pr ? isPRMergeReady(pr) : false; @@ -287,6 +289,30 @@ export function SessionDetailHeader({ ) : null} + {isOrchestrator && onRelaunchClean ? ( + + ) : null} + {orchestratorHref ? ( { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); + it("mounts the sidebar empty state on a fresh install with zero projects", () => { render(); diff --git a/packages/web/src/components/__tests__/OrchestratorSelector.test.tsx b/packages/web/src/components/__tests__/OrchestratorSelector.test.tsx deleted file mode 100644 index 28aefde42..000000000 --- a/packages/web/src/components/__tests__/OrchestratorSelector.test.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { OrchestratorSelector } from "../OrchestratorSelector"; - -const mockPush = vi.fn(); -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: mockPush }), -})); - -const mockOrchestrators = [ - { - id: "app-orchestrator", - projectId: "my-project", - projectName: "My Project", - status: "working", - activity: "active", - createdAt: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago - lastActivityAt: new Date(Date.now() - 300000).toISOString(), // 5 min ago - }, -]; - -const defaultProps = { - orchestrators: mockOrchestrators, - projectId: "my-project", - projectName: "My Project", - error: null, -}; - -describe("OrchestratorSelector", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockPush.mockClear(); - global.fetch = vi.fn(); - }); - - it("renders orchestrator list", () => { - render(); - - expect(screen.getByText("app-orchestrator")).toBeInTheDocument(); - }); - - it("displays project name in header", () => { - render(); - - expect(screen.getByText("My Project")).toBeInTheDocument(); - expect(screen.getByText("Project orchestrator")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Dashboard" })).toHaveAttribute( - "href", - "/projects/my-project", - ); - }); - - it("explains that orchestrator opening reuses the canonical session", () => { - render(); - - expect(screen.getByText(/one main orchestrator per project/i)).toBeInTheDocument(); - }); - - it("shows error state", () => { - render(); - - expect(screen.getByText("Error")).toBeInTheDocument(); - expect(screen.getByText("Project not found")).toBeInTheDocument(); - }); - - it("shows open orchestrator button", () => { - render(); - - expect(screen.getByRole("button", { name: /open orchestrator/i })).toBeInTheDocument(); - }); - - it("spawns new orchestrator on button click and navigates", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - orchestrator: { id: "app-orchestrator" }, - }), - }); - global.fetch = mockFetch; - - render(); - - const button = screen.getByRole("button", { name: /open orchestrator/i }); - fireEvent.click(button); - - await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/orchestrators", expect.any(Object)); - }); - - await waitFor(() => { - expect(mockPush).toHaveBeenCalledWith("/projects/my-project/sessions/app-orchestrator"); - }); - }); - - it("shows loading state while spawning", async () => { - const mockFetch = vi.fn().mockImplementation( - () => new Promise(() => {}), // Never resolves - ); - global.fetch = mockFetch; - - render(); - - const button = screen.getByRole("button", { name: /open orchestrator/i }); - fireEvent.click(button); - - await waitFor(() => { - expect(screen.getByText(/opening orchestrator/i)).toBeInTheDocument(); - }); - }); - - it("shows error when spawn fails", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: false, - json: () => Promise.resolve({ error: "Failed to spawn" }), - }); - global.fetch = mockFetch; - - render(); - - const button = screen.getByRole("button", { name: /open orchestrator/i }); - fireEvent.click(button); - - await waitFor(() => { - expect(screen.getByText("Failed to spawn")).toBeInTheDocument(); - }); - }); - - it("links to orchestrator session page", () => { - render(); - - const link = screen.getByRole("link", { name: /app-orchestrator/i }); - expect(link).toHaveAttribute("href", "/projects/my-project/sessions/app-orchestrator"); - }); - - it("displays status and activity for each orchestrator", () => { - render(); - - expect(screen.getByText("working")).toBeInTheDocument(); - expect(screen.getByText("Active")).toBeInTheDocument(); - }); - - it("covers relative time for days and status colors/labels", () => { - const wideOrchestrators = [ - { - id: "orch-2", - projectId: "my-project", - projectName: "My Project", - status: "ci_failed", - activity: "waiting_input", - createdAt: new Date(Date.now() - 3600000 * 50).toISOString(), // 2d ago - lastActivityAt: null, - }, - { - id: "orch-3", - projectId: "my-project", - projectName: "My Project", - status: "killed", - activity: "ready", - createdAt: new Date(Date.now() - 1000).toISOString(), // Just now - lastActivityAt: null, - }, - { - id: "orch-4", - projectId: "my-project", - projectName: "My Project", - status: "unknown", - activity: "blocked", - createdAt: new Date().toISOString(), - lastActivityAt: null, - }, - { - id: "orch-5", - projectId: "my-project", - projectName: "My Project", - status: "mergeable", - activity: "exited", - createdAt: new Date().toISOString(), - lastActivityAt: null, - }, - ]; - - render(); - - expect(screen.getByText(/2d ago/)).toBeInTheDocument(); - expect(screen.getAllByText(/Just now/i).length).toBeGreaterThan(0); - expect(screen.getByText(/Waiting/)).toBeInTheDocument(); - expect(screen.getByText(/Ready/)).toBeInTheDocument(); - expect(screen.getByText(/Blocked/)).toBeInTheDocument(); - expect(screen.getByText(/Exited/)).toBeInTheDocument(); - expect(screen.getByText(/ci failed/i)).toBeInTheDocument(); - }); - - describe("formatRelativeTime edge cases", () => { - it("shows Unknown for invalid date strings", () => { - const orchestratorsWithInvalidDate = [ - { - ...mockOrchestrators[0], - createdAt: "not-a-valid-date", - lastActivityAt: null, - }, - ]; - render( - , - ); - - // The "Created Unknown" text should appear for invalid dates - expect(screen.getByText(/Created Unknown/)).toBeInTheDocument(); - }); - - it("shows Just now for future timestamps", () => { - const futureDate = new Date(Date.now() + 60000).toISOString(); // 1 minute in future - const orchestratorsWithFutureDate = [ - { - ...mockOrchestrators[0], - createdAt: futureDate, - lastActivityAt: null, - }, - ]; - render( - , - ); - - // Future timestamps should show "Just now" instead of negative values - expect(screen.getByText(/Created Just now/)).toBeInTheDocument(); - }); - - it("shows Unknown for null dates", () => { - const orchestratorsWithNullDate = [ - { - ...mockOrchestrators[0], - createdAt: null, - lastActivityAt: null, - }, - ]; - render(); - - expect(screen.getByText(/Created Unknown/)).toBeInTheDocument(); - }); - }); -}); diff --git a/packages/web/src/components/__tests__/ProjectSidebar.test.tsx b/packages/web/src/components/__tests__/ProjectSidebar.test.tsx index 13536ee04..6f7150c37 100644 --- a/packages/web/src/components/__tests__/ProjectSidebar.test.tsx +++ b/packages/web/src/components/__tests__/ProjectSidebar.test.tsx @@ -399,6 +399,8 @@ describe("ProjectSidebar", () => { />, ); + // Only the killed-but-still-needs-attention session is counted; the merged + // session is filtered out by sessionsByProject (showDone = false by default). expect(screen.getByRole("button", { name: /^Project One 1$/ })).toBeInTheDocument(); expect( screen.getByRole("link", { name: "Open Runtime missing but needs review" }), diff --git a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx index d63ad18f8..07a77e48f 100644 --- a/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx +++ b/packages/web/src/components/__tests__/SessionDetail.desktop.test.tsx @@ -304,6 +304,161 @@ describe("SessionDetail desktop layout", () => { ).not.toBeInTheDocument(); }); + it("renders Relaunch (clean) on live orchestrator sessions and navigates to the new session", async () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + const hrefSetter = vi.fn(); + Object.defineProperty(window, "location", { + value: { + ...window.location, + set href(value: string) { + hrefSetter(value); + }, + }, + writable: true, + }); + vi.mocked(global.fetch).mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "/api/orchestrators") { + return { + ok: true, + json: async () => ({ + orchestrator: { id: "my-app-orchestrator", projectId: "my-app" }, + }), + } as Response; + } + return { ok: true, json: async () => ({}), text: async () => "" } as Response; + }); + + render( + , + ); + + const relaunchBtn = within(screen.getByRole("banner")).getByRole("button", { + name: /launch orchestrator \(clean context\)/i, + }); + fireEvent.click(relaunchBtn); + + expect(confirmSpy).toHaveBeenCalled(); + await act(async () => {}); + + expect(global.fetch).toHaveBeenCalledWith("/api/orchestrators", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectId: "my-app", clean: true }), + }); + expect(hrefSetter).toHaveBeenCalledWith("/projects/my-app/sessions/my-app-orchestrator"); + + confirmSpy.mockRestore(); + }); + + it("keeps Relaunch (clean) visible on terminated orchestrator sessions", () => { + render( + , + ); + + expect( + within(screen.getByRole("banner")).getByRole("button", { + name: /launch orchestrator \(clean context\)/i, + }), + ).toBeInTheDocument(); + }); + + it("surfaces a relaunch error banner when POST fails after confirm", async () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + vi.mocked(global.fetch).mockImplementation(async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "/api/orchestrators") { + return { + ok: false, + status: 500, + json: async () => ({ error: "kill+respawn failed" }), + text: async () => "kill+respawn failed", + } as Response; + } + return { ok: true, json: async () => ({}), text: async () => "" } as Response; + }); + + render( + , + ); + + fireEvent.click( + within(screen.getByRole("banner")).getByRole("button", { + name: /launch orchestrator \(clean context\)/i, + }), + ); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent(/kill\+respawn failed/i); + expect(alert).toHaveTextContent(/previous orchestrator may already be terminated/i); + + fireEvent.click(within(alert).getByRole("button", { name: "Dismiss" })); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + + confirmSpy.mockRestore(); + }); + + it("does not render Relaunch (clean) on worker sessions", () => { + render( + , + ); + + expect( + screen.queryByRole("button", { name: /launch orchestrator \(clean context\)/i }), + ).not.toBeInTheDocument(); + }); + it("restores without using router refresh on the client-only session page", async () => { render( { expect(routerRefreshMock).not.toHaveBeenCalled(); }); - it("keeps the desktop orchestrator button on orchestrator session pages", () => { + it("hides the desktop orchestrator button on orchestrator session pages", () => { render( { ); expect( - within(screen.getByRole("banner")).getByRole("link", { name: "Orchestrator" }), - ).toHaveAttribute("href", "/projects/my-app/sessions/my-app-orchestrator"); + within(screen.getByRole("banner")).queryByRole("link", { name: "Orchestrator" }), + ).not.toBeInTheDocument(); expect(screen.getByText("orchestrator")).toBeInTheDocument(); }); @@ -402,8 +557,8 @@ describe("SessionDetail desktop layout", () => { ); expect( - within(screen.getByRole("banner")).getByRole("link", { name: "Orchestrator" }), - ).toHaveAttribute("href", "/projects/my-app/sessions/my-app-orchestrator"); + within(screen.getByRole("banner")).queryByRole("link", { name: "Orchestrator" }), + ).not.toBeInTheDocument(); }); it("routes to the project orchestrator after killing a worker session", async () => { diff --git a/packages/web/src/lib/orchestrator-utils.ts b/packages/web/src/lib/orchestrator-utils.ts deleted file mode 100644 index b057654c9..000000000 --- a/packages/web/src/lib/orchestrator-utils.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { - isOrchestratorSession, - isTerminalSession, - type Session, -} from "@aoagents/ao-core/types"; -import type { Orchestrator } from "@/components/OrchestratorSelector"; - -export function getOrchestratorSessionId(project: { sessionPrefix: string }): string { - return `${project.sessionPrefix}-orchestrator`; -} - -/** - * Filter and map sessions to orchestrator DTOs. - * Shared between page.tsx and API route to ensure consistent orchestrator listing. - */ -export function mapSessionsToOrchestrators( - sessions: Session[], - sessionPrefix: string, - projectName: string, - allSessionPrefixes?: string[], -): Orchestrator[] { - const canonicalId = getOrchestratorSessionId({ sessionPrefix }); - return sessions - .filter((s) => isOrchestratorSession(s, sessionPrefix, allSessionPrefixes) && !isTerminalSession(s)) - .sort((a, b) => { - if (a.id === canonicalId) return -1; - if (b.id === canonicalId) return 1; - return (b.lastActivityAt?.getTime() ?? 0) - (a.lastActivityAt?.getTime() ?? 0); - }) - .map((s) => ({ - id: s.id, - projectId: s.projectId, - projectName, - status: s.status, - activity: s.activity, - createdAt: s.createdAt?.toISOString() ?? null, - lastActivityAt: s.lastActivityAt?.toISOString() ?? null, - })); -} diff --git a/website/content/docs/architecture.mdx b/website/content/docs/architecture.mdx index 421d7d935..dacbe9b96 100644 --- a/website/content/docs/architecture.mdx +++ b/website/content/docs/architecture.mdx @@ -253,7 +253,7 @@ Step 4 (the JSONL entry fallback) is mandatory. Skipping it means `getActivitySt |---|---|---| | `DEFAULT_ACTIVE_WINDOW_MS` | 30 seconds | Activity newer than this is `active`; older is `ready` | | `DEFAULT_READY_THRESHOLD_MS` | 5 minutes | `ready` sessions older than this become `idle` | -| `ACTIVITY_INPUT_STALENESS_MS` | 5 minutes | `waiting_input` / `blocked` JSONL entries expire after this duration | +| `ACTIVITY_INPUT_STALENESS_MS` | 5 minutes | Deprecated compatibility export. `waiting_input` / `blocked` entries no longer expire by wallclock; they persist until process death or a newer entry overrides them. | --- diff --git a/website/content/docs/plugins/authoring.mdx b/website/content/docs/plugins/authoring.mdx index 57a10415e..cd8798eb7 100644 --- a/website/content/docs/plugins/authoring.mdx +++ b/website/content/docs/plugins/authoring.mdx @@ -468,7 +468,7 @@ All utilities are exported from `@aoagents/ao-core`. The source lives in `packag |--------|-------| | `DEFAULT_READY_THRESHOLD_MS` | `300_000` (5 min) — ready → idle threshold | | `DEFAULT_ACTIVE_WINDOW_MS` | `30_000` (30 s) — active → ready window | -| `ACTIVITY_INPUT_STALENESS_MS` | `300_000` (5 min) — waiting_input/blocked expiry | +| `ACTIVITY_INPUT_STALENESS_MS` | Deprecated compatibility export (`300_000`); waiting_input/blocked no longer expire by wallclock | | `PREFERRED_GH_PATH` | `/usr/local/bin/gh` | | `CI_STATUS` | `{ PENDING, PASSING, FAILING, NONE }` | | `ACTIVITY_STATE` | `{ ACTIVE, READY, IDLE, WAITING_INPUT, BLOCKED, EXITED }` |