diff --git a/backend/src/services/opencode-models.ts b/backend/src/services/opencode-models.ts index dbcced86f..e99083541 100644 --- a/backend/src/services/opencode-models.ts +++ b/backend/src/services/opencode-models.ts @@ -1,5 +1,5 @@ import type { ModelInfo, ModelRef } from '@opencode-manager/shared/opencode' -import { formatOpenCodeModelRef, openCodeLocation, parseOpenCodeModelRef } from '@opencode-manager/shared/opencode' +import { formatOpenCodeModelRef, openCodeLocation, parseOpenCodeModelRef, selectConfiguredModelRef } from '@opencode-manager/shared/opencode' import type { OpenCodeClient } from './opencode/client' export interface ResolvedOpenCodeModel { @@ -9,6 +9,14 @@ export interface ResolvedOpenCodeModel { model: string } +export interface ResolveOpenCodeModelOptions { + preferredModel?: string | null + signal?: AbortSignal +} + +export const MODEL_LOAD_TIMEOUT_MS = 15_000 +export const MODEL_LOAD_POLL_MS = 500 + function normalizeModelCandidate(model: string | null | undefined): string | null { if (!model) { return null @@ -31,29 +39,24 @@ function findAvailable(models: ModelInfo[], ref: ModelRef): ModelInfo | undefine return models.find((model) => model.providerID === ref.providerID && model.id === ref.id) } -export async function resolveOpenCodeModel( - client: OpenCodeClient, - directory: string, - options?: { - preferredModel?: string | null - }, -): Promise { - const location = openCodeLocation(directory) - const [modelsResponse, defaultResponse] = await Promise.all([ - client.api.model.list(location), - client.api.model.default(location), - ]) - const models = modelsResponse.data +function isTargetLoaded(models: ModelInfo[], targetRef: ModelRef | undefined): boolean { + return !targetRef || findAvailable(models, targetRef) !== undefined +} - const preferred = normalizeModelCandidate(options?.preferredModel) - if (preferred) { - const parsedPreferred = parseOpenCodeModelRef(preferred) - if (parsedPreferred && findAvailable(models, parsedPreferred)) { - return toResolvedModel(parsedPreferred) - } +function resolveFromLoadedModels( + models: ModelInfo[], + defaultModel: ModelInfo | null, + configuredRef: ModelRef | undefined, + preferredRef: ModelRef | undefined, +): ResolvedOpenCodeModel | null { + if (preferredRef && findAvailable(models, preferredRef)) { + return toResolvedModel(preferredRef) + } + + if (configuredRef && findAvailable(models, configuredRef)) { + return toResolvedModel(configuredRef) } - const defaultModel = defaultResponse.data if (defaultModel) { const defaultRef: ModelRef = { providerID: defaultModel.providerID, id: defaultModel.id } if (findAvailable(models, defaultRef)) { @@ -62,9 +65,166 @@ export async function resolveOpenCodeModel( } const fallback = models.find((model) => model.enabled) - if (fallback) { - return toResolvedModel({ providerID: fallback.providerID, id: fallback.id }) + return fallback ? toResolvedModel({ providerID: fallback.providerID, id: fallback.id }) : null +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) { + resolve() + return + } + + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, ms) + + function onAbort(): void { + clearTimeout(timer) + resolve() + } + + signal.addEventListener('abort', onAbort, { once: true }) + + if (signal.aborted) { + onAbort() + } + }) +} + +type PollingResult = { ok: true; value: T } | { ok: false } + +function isAbortLike(error: unknown, signal: AbortSignal): boolean { + if (error === signal.reason) { + return true + } + + if (typeof error !== 'object' || error === null) { + return false + } + + if ((error as { name?: unknown }).name === 'AbortError') { + return true + } + + const cause = (error as { cause?: unknown }).cause + return cause !== undefined && cause !== error && isAbortLike(cause, signal) +} + +async function runPollingRequest( + request: () => Promise, + external: AbortSignal | undefined, + deadline: AbortSignal, +): Promise> { + try { + return { ok: true, value: await request() } + } catch (error) { + external?.throwIfAborted() + + if (deadline.aborted && isAbortLike(error, deadline)) { + return { ok: false } + } + + throw error } +} + +async function readConfiguredRef( + client: OpenCodeClient, + location: ReturnType, + signal: AbortSignal, +): Promise { + const entries = await client.api.config.get(location, { signal }) + return selectConfiguredModelRef(entries) +} + +export async function resolveOpenCodeModel( + client: OpenCodeClient, + directory: string, + options?: ResolveOpenCodeModelOptions, +): Promise { + const location = openCodeLocation(directory) + const preferred = normalizeModelCandidate(options?.preferredModel) + const preferredRef = preferred ? parseOpenCodeModelRef(preferred) : undefined + const external = options?.signal + + const timeoutController = new AbortController() + const timeoutId = setTimeout(() => { + timeoutController.abort(new Error('Timed out waiting for the OpenCode model catalog to load')) + }, MODEL_LOAD_TIMEOUT_MS) + const signal = external ? AbortSignal.any([external, timeoutController.signal]) : timeoutController.signal + + let models: ModelInfo[] = [] + let defaultModel: ModelInfo | null = null + + try { + external?.throwIfAborted() + + const configuredRef = await readConfiguredRef(client, location, signal) + const targetRef = preferredRef ?? configuredRef - throw new Error('No configured OpenCode models are available') + for (;;) { + external?.throwIfAborted() + + if (timeoutController.signal.aborted) { + break + } + + const [listResult, defaultResult] = await Promise.all([ + runPollingRequest( + () => client.api.model.list(location, { signal }), + external, + timeoutController.signal, + ), + runPollingRequest( + () => client.api.model.default(location, { signal }), + external, + timeoutController.signal, + ), + ]) + + if (listResult.ok) { + models = listResult.value.data + } + + if (defaultResult.ok) { + defaultModel = defaultResult.value.data + } + + if (!listResult.ok || !defaultResult.ok) { + break + } + + external?.throwIfAborted() + + if (timeoutController.signal.aborted) { + break + } + + const resolved = resolveFromLoadedModels(models, defaultModel, configuredRef, preferredRef) + if (resolved && isTargetLoaded(models, targetRef)) { + return resolved + } + + await sleep(MODEL_LOAD_POLL_MS, signal) + } + + external?.throwIfAborted() + + const resolved = resolveFromLoadedModels(models, defaultModel, configuredRef, preferredRef) + if (resolved) { + return resolved + } + + if (models.length === 0) { + throw timeoutController.signal.reason instanceof Error + ? timeoutController.signal.reason + : new Error('Timed out waiting for the OpenCode model catalog to load') + } + + throw new Error('No configured OpenCode models are available') + } finally { + clearTimeout(timeoutId) + } } diff --git a/backend/src/services/schedules.ts b/backend/src/services/schedules.ts index 8b96a4890..2499fecd2 100644 --- a/backend/src/services/schedules.ts +++ b/backend/src/services/schedules.ts @@ -73,6 +73,20 @@ interface SessionMonitor { dispose(): void } +interface ActiveRun { + runId: number + abort: AbortController +} + +type CancelDecision = 'cancelled' | 'settled' | 'resume' + +interface PendingCancel { + runId: number + decision: Promise + resolve: (decision: CancelDecision) => void + resolved: boolean +} + type AssistantOutcome = | { kind: 'busy' } | { kind: 'settled'; responseText: string | null; errorText: string | null } @@ -248,7 +262,8 @@ function createSessionMonitor(directory: string, sessionId: string): SessionMoni } export class ScheduleService { - private static activeRuns = new Set() + private static activeRuns = new Map() + private static pendingCancels = new Map() private static activeTeardowns = new Set() private onJobChange: ((job: ScheduleJob | null, jobId: number) => void) | null = null @@ -262,6 +277,65 @@ export class ScheduleService { this.onJobChange = handler } + private static abortActiveRun(jobId: number, runId: number): void { + const active = ScheduleService.activeRuns.get(jobId) + if (active?.runId === runId) { + active.abort.abort() + } + } + + private static requestCancel(jobId: number, runId: number): Promise { + const existing = ScheduleService.pendingCancels.get(jobId) + + if (existing?.runId === runId && !existing.resolved) { + throw new ScheduleServiceError('Schedule run cancellation is already in progress', 409) + } + + if (existing) { + existing.resolved = true + existing.resolve('resume') + } + + let resolve!: (decision: CancelDecision) => void + const decision = new Promise((r) => { resolve = r }) + ScheduleService.pendingCancels.set(jobId, { runId, decision, resolve, resolved: false }) + return decision + } + + private static settleCancel(jobId: number, runId: number, status: ScheduleRun['status'] | undefined): void { + const decision: CancelDecision = status === 'cancelled' ? 'cancelled' : status === 'completed' || status === 'failed' ? 'settled' : 'resume' + ScheduleService.resolveCancel(jobId, runId, decision) + } + + private static resolveCancel(jobId: number, runId: number, decision: CancelDecision): void { + const pending = ScheduleService.pendingCancels.get(jobId) + if (pending?.runId !== runId || pending.resolved) { + return + } + + pending.resolved = true + pending.resolve(decision) + } + + private static awaitCancelDecision(jobId: number, runId: number): Promise { + const pending = ScheduleService.pendingCancels.get(jobId) + return pending?.runId === runId ? pending.decision : Promise.resolve('resume') + } + + private static clearCancel(jobId: number, runId: number): void { + if (ScheduleService.pendingCancels.get(jobId)?.runId !== runId) { + return + } + + ScheduleService.pendingCancels.delete(jobId) + } + + private static releaseActiveRun(jobId: number, runId: number): void { + if (ScheduleService.activeRuns.get(jobId)?.runId === runId) { + ScheduleService.activeRuns.delete(jobId) + } + } + getActiveRunSessions(): ScheduledSessionRef[] { const refs: ScheduledSessionRef[] = [] @@ -312,7 +386,7 @@ export class ScheduleService { continue } - ScheduleService.activeRuns.add(job.id) + ScheduleService.activeRuns.set(job.id, { runId: run.id, abort: new AbortController() }) await this.recoverRunningRun(job, run) } } @@ -475,8 +549,6 @@ export class ScheduleService { throw new ScheduleServiceError('Schedule is already running', 409) } - ScheduleService.activeRuns.add(jobId) - const startedAt = Date.now() const run = createScheduleRun(this.db, { jobId, @@ -487,6 +559,9 @@ export class ScheduleService { createdAt: startedAt, }) + const abort = new AbortController() + ScheduleService.activeRuns.set(jobId, { runId: run.id, abort }) + try { // Prepare worktree for isolated runs const wt = await this.worktreeManager.prepare(repo, job, run.id) @@ -498,10 +573,20 @@ export class ScheduleService { }) } + if (abort.signal.aborted) { + return this.abandonCancelledStartup(repoId, jobId, run, job, repo) + } + const runJob = { ...job, agentSlug: await this.resolveRunAgent(runDirectory, job.agentSlug) } const model = await resolveOpenCodeModel(this.openCodeClient, runDirectory, { preferredModel: job.model, + signal: abort.signal, }) + + if (abort.signal.aborted) { + return this.abandonCancelledStartup(repoId, jobId, run, job, repo) + } + const sessionTitle = buildSessionTitle(job) let session: { id: string } try { @@ -513,9 +598,16 @@ export class ScheduleService { permissions: buildSchedulePermissionRuleset(job.permissionConfig), }) } catch (error) { + if (abort.signal.aborted) { + return this.abandonCancelledStartup(repoId, jobId, run, job, repo) + } throw new ScheduleServiceError(getErrorMessage(error) || 'Failed to create OpenCode session', 502) } + if (abort.signal.aborted) { + return this.abandonCancelledStartup(repoId, jobId, run, job, repo, session.id) + } + const runWithSession = updateScheduleRunMetadata(this.db, repoId, jobId, run.id, { sessionId: session.id, sessionTitle, @@ -546,6 +638,10 @@ export class ScheduleService { return runWithSession } catch (error) { + if (abort.signal.aborted) { + return this.abandonCancelledStartup(repoId, jobId, run, job, repo) + } + const finishedAt = Date.now() const errorText = getErrorMessage(error) logger.error(`Failed to run schedule ${jobId}:`, error) @@ -574,17 +670,16 @@ export class ScheduleService { // Teardown worktree if one was created before the error await this.teardownWorktree(repoId, jobId, run.id, job, repo) + ScheduleService.releaseActiveRun(jobId, run.id) + if (!failedRun) { - ScheduleService.activeRuns.delete(jobId) throw new ScheduleServiceError('Failed to load failed run', 500) } if (error instanceof ScheduleServiceError) { - ScheduleService.activeRuns.delete(jobId) throw error } - ScheduleService.activeRuns.delete(jobId) throw new ScheduleServiceError(errorText, 500) } } @@ -598,8 +693,20 @@ export class ScheduleService { throw new ScheduleServiceError('Only running schedule runs can be cancelled', 409) } - if (run.sessionId) { - const outcome = await this.readAssistantOutcome(run.sessionId) + if (!run.sessionId) { + ScheduleService.abortActiveRun(jobId, runId) + return this.finalizeCancelledRun(repo, job, run, repoId, jobId, runId) + } + + ScheduleService.requestCancel(jobId, runId) + + try { + let outcome: AssistantOutcome + try { + outcome = await this.readAssistantOutcome(run.sessionId) + } catch (error) { + throw new ScheduleServiceError(getErrorMessage(error) || 'Failed to cancel schedule run', 502) + } if (outcome.kind === 'settled') { await this.finalizeRecoveredRun(job, run, { @@ -607,7 +714,7 @@ export class ScheduleService { responseText: outcome.responseText, errorText: outcome.errorText, }, repo) - + ScheduleService.resolveCancel(jobId, runId, 'settled') return this.getRun(repoId, jobId, runId) } @@ -616,8 +723,33 @@ export class ScheduleService { } catch (error) { throw new ScheduleServiceError(getErrorMessage(error) || 'Failed to cancel schedule run', 502) } + + return await this.finalizeCancelledRun(repo, job, run, repoId, jobId, runId) + } catch (error) { + this.settleCancelFromState(repoId, jobId, runId) + throw error } + } + + private settleCancelFromState(repoId: number, jobId: number, runId: number): void { + let status: ScheduleRun['status'] | undefined + try { + status = getScheduleRunById(this.db, repoId, jobId, runId)?.status + } catch { + status = undefined + } + + ScheduleService.settleCancel(jobId, runId, status) + } + private async finalizeCancelledRun( + repo: Repo, + job: ScheduleJob, + run: ScheduleRun, + repoId: number, + jobId: number, + runId: number, + ): Promise { const finishedAt = Date.now() const cancellationMessage = 'Run cancelled by user.' const cancelledRun = updateScheduleRun(this.db, repoId, jobId, runId, { @@ -644,7 +776,8 @@ export class ScheduleService { }) await this.teardownWorktree(repoId, jobId, runId, job, repo) - ScheduleService.activeRuns.delete(jobId) + ScheduleService.releaseActiveRun(jobId, runId) + ScheduleService.resolveCancel(jobId, runId, 'cancelled') if (!cancelledRun) { throw new ScheduleServiceError('Failed to update cancelled run', 500) @@ -666,7 +799,16 @@ export class ScheduleService { const repo = this.assertRepo(input.repoId) try { + if ((await ScheduleService.awaitCancelDecision(input.job.id, input.runId)) !== 'resume') { + return + } + const skills = await this.resolveSkillAttachments(input.directory, input.job.skillMetadata) + + if ((await ScheduleService.awaitCancelDecision(input.job.id, input.runId)) !== 'resume') { + return + } + const notes = input.job.skillMetadata?.notes?.trim() await this.openCodeClient.api.session.prompt({ sessionID: input.sessionId, @@ -674,6 +816,10 @@ export class ScheduleService { ...(skills.length > 0 ? { skills } : {}), }) + if ((await ScheduleService.awaitCancelDecision(input.job.id, input.runId)) !== 'resume') { + return + } + input.sessionMonitor.markSubmitted() await this.monitorRunCompletion({ @@ -687,6 +833,10 @@ export class ScheduleService { }) return } catch (error) { + if ((await ScheduleService.awaitCancelDecision(input.job.id, input.runId)) !== 'resume') { + return + } + const finishedAt = Date.now() const errorText = getErrorMessage(error) logger.error(`Failed to submit prompt for schedule ${input.job.id}:`, error) @@ -717,9 +867,11 @@ export class ScheduleService { nextRunAt: input.triggerSource === 'manual' ? input.job.nextRunAt : computeNextRunAtForJob(input.job, finishedAt), }) } finally { + await ScheduleService.awaitCancelDecision(input.job.id, input.runId) input.sessionMonitor.dispose() await this.teardownWorktree(input.repoId, input.job.id, input.runId, input.job, repo) - ScheduleService.activeRuns.delete(input.job.id) + ScheduleService.releaseActiveRun(input.job.id, input.runId) + ScheduleService.clearCancel(input.job.id, input.runId) } } @@ -736,6 +888,10 @@ export class ScheduleService { const repo = this.assertRepo(input.repoId) const currentAssistantState = await this.readSettledAssistantState(input.sessionId) if (currentAssistantState) { + if ((await ScheduleService.awaitCancelDecision(input.job.id, input.runId)) !== 'resume') { + return + } + await this.finalizeRecoveredRun(input.job, { id: input.runId, repoId: input.repoId, @@ -752,6 +908,11 @@ export class ScheduleService { } const response = await this.waitForAssistantMessage(input.sessionId, input.sessionMonitor) + + if ((await ScheduleService.awaitCancelDecision(input.job.id, input.runId)) !== 'resume') { + return + } + const currentRun = getScheduleRunById(this.db, input.repoId, input.job.id, input.runId) if (!currentRun || currentRun.status !== 'running') { return @@ -800,6 +961,10 @@ export class ScheduleService { nextRunAt: input.triggerSource === 'manual' ? input.job.nextRunAt : computeNextRunAtForJob(input.job, finishedAt), }) } catch (error) { + if ((await ScheduleService.awaitCancelDecision(input.job.id, input.runId)) !== 'resume') { + return + } + const finishedAt = Date.now() const errorText = getErrorMessage(error) logger.error(`Failed to monitor schedule ${input.job.id}:`, error) @@ -830,9 +995,11 @@ export class ScheduleService { nextRunAt: input.triggerSource === 'manual' ? input.job.nextRunAt : computeNextRunAtForJob(input.job, finishedAt), }) } finally { + await ScheduleService.awaitCancelDecision(input.job.id, input.runId) input.sessionMonitor.dispose() await this.teardownWorktree(input.repoId, input.job.id, input.runId, input.job, this.assertRepo(input.repoId)) - ScheduleService.activeRuns.delete(input.job.id) + ScheduleService.releaseActiveRun(input.job.id, input.runId) + ScheduleService.clearCancel(input.job.id, input.runId) } } @@ -925,7 +1092,36 @@ export class ScheduleService { }) await this.teardownWorktree(run.repoId, run.jobId, run.id, job, repo) - ScheduleService.activeRuns.delete(job.id) + ScheduleService.releaseActiveRun(job.id, run.id) + } + + private async interruptCancelledSession(sessionId: string): Promise { + try { + await this.openCodeClient.api.session.interrupt({ sessionID: sessionId }) + } catch (error) { + logger.error(`Failed to interrupt cancelled schedule session ${sessionId}:`, error) + } + } + + private loadRun(repoId: number, jobId: number, runId: number, fallback: ScheduleRun): ScheduleRun { + return getScheduleRunById(this.db, repoId, jobId, runId) ?? fallback + } + + private async abandonCancelledStartup( + repoId: number, + jobId: number, + run: ScheduleRun, + job: ScheduleJob, + repo: Repo, + sessionId?: string, + ): Promise { + if (sessionId) { + await this.interruptCancelledSession(sessionId) + } + ScheduleService.clearCancel(jobId, run.id) + await this.teardownWorktree(repoId, jobId, run.id, job, repo) + ScheduleService.releaseActiveRun(jobId, run.id) + return this.loadRun(repoId, jobId, run.id, run) } private async teardownWorktree(repoId: number, jobId: number, runId: number, job: ScheduleJob, repo: Repo): Promise { diff --git a/backend/src/services/sse-aggregator.ts b/backend/src/services/sse-aggregator.ts index 7bd8539fb..2636192c2 100644 --- a/backend/src/services/sse-aggregator.ts +++ b/backend/src/services/sse-aggregator.ts @@ -1,7 +1,7 @@ import { EventSource } from 'eventsource' import { logger } from '../utils/logger' import { DEFAULTS } from '@opencode-manager/shared/config' -import { openCodeLocation, type OpenCodeApi, type V2Event } from '@opencode-manager/shared/opencode' +import { openCodeLocation, sessionIDFromEvent, type OpenCodeApi, type V2Event } from '@opencode-manager/shared/opencode' import { getOpenCodeBasicAuthHeader, type OpenCodePasswordResolver } from './opencode/auth' import { getOpenCodeUpstreamBaseUrl } from './opencode/upstream' import { encodeSSEFrame } from '../utils/sse-frame' @@ -44,6 +44,7 @@ class SSEAggregator { private clients: Map = new Map() private directoryClients: Map> = new Map() private activeSessions: Map> = new Map() + private sessionDirectories: Map = new Map() private eventListeners: Set = new Set() private subagentSessions: Map> = new Map() private upstream: EventSource | null = null @@ -284,12 +285,14 @@ class SSEAggregator { for (const ref of this.getScheduledSessions()) { tracked.set(ref.sessionID, ref.directory) } + tracked.forEach((directory, sessionID) => this.sessionDirectories.set(sessionID, directory)) return tracked } private async resolveSessionDirectory(fetcher: PendingActionsFetcher, sessionID: string): Promise { try { const session = await fetcher.api.session.get({ sessionID }) + this.sessionDirectories.set(sessionID, session.location.directory) return session.location.directory } catch (error) { logger.warn(`replay: failed to resolve directory for session ${sessionID}: ${String(error)}`) @@ -403,24 +406,35 @@ class SSEAggregator { const payloadJson = MULTILINE_PATTERN.test(data) ? JSON.stringify(event) : data const directory = event.location?.directory + const sessionID = sessionIDFromEvent(event) try { + const resolvedDirectory = directory ?? (sessionID ? this.sessionDirectories.get(sessionID) : undefined) + + if (sessionID && event.type === 'session.deleted') { + this.sessionDirectories.delete(sessionID) + } else if (directory && sessionID) { + this.sessionDirectories.set(sessionID, directory) + } + if (directory) { this.deliverEvent(directory, event, payloadJson) - } else { - this.writeEnvelopeToClients(this.clients.keys(), null, payloadJson) + return } + + if (resolvedDirectory) { + this.deliverResolvedEvent(resolvedDirectory, event, payloadJson) + return + } + + this.writeEnvelopeToClients(this.clients.keys(), null, payloadJson) } catch (error) { logger.error(`SSE failed to handle ${event.type} event:`, error) } } private deliverEvent(directory: string, event: SSEEvent, payloadJson?: string): void { - this.handleEvent(directory, event) - - this.eventListeners.forEach(listener => { - try { listener(directory, event) } catch { /* ignore listener errors */ } - }) + this.notifyEvent(directory, event) const subscriberIds = this.directoryClients.get(directory) if (!subscriberIds || subscriberIds.size === 0) return @@ -428,6 +442,19 @@ class SSEAggregator { this.writeEnvelopeToClients(subscriberIds, directory, payloadJson ?? JSON.stringify(event)) } + private deliverResolvedEvent(directory: string, event: SSEEvent, payloadJson: string): void { + this.notifyEvent(directory, event) + this.writeEnvelopeToClients(this.clients.keys(), null, payloadJson) + } + + private notifyEvent(directory: string, event: SSEEvent): void { + this.handleEvent(directory, event) + + this.eventListeners.forEach(listener => { + try { listener(directory, event) } catch { /* ignore listener errors */ } + }) + } + private writeEnvelopeToClients(clientIds: Iterable, directory: string | null, payloadJson: string): void { let frame: Uint8Array | null = null for (const clientId of clientIds) { @@ -594,6 +621,7 @@ class SSEAggregator { this.upstreamConnected = false this.activeSessions.clear() + this.sessionDirectories.clear() this.subagentSessions.clear() this.directoryClients.clear() this.clients.clear() diff --git a/backend/test/services/opencode-models.test.ts b/backend/test/services/opencode-models.test.ts index 1067be251..0d9514ffe 100644 --- a/backend/test/services/opencode-models.test.ts +++ b/backend/test/services/opencode-models.test.ts @@ -1,79 +1,104 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { ModelInfo } from '@opencode-manager/shared/opencode' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ConfigEntry, ModelInfo } from '@opencode-manager/shared/opencode' import type { OpenCodeClient } from '../../src/services/opencode/client' -import { resolveOpenCodeModel } from '../../src/services/opencode-models' +import { + MODEL_LOAD_POLL_MS, + MODEL_LOAD_TIMEOUT_MS, + resolveOpenCodeModel, +} from '../../src/services/opencode-models' + +const DIR = '/workspace/repos/sample-project' +const LOCATION = { directory: DIR } function model(providerID: string, id: string, enabled: boolean = true): ModelInfo { return { providerID, id, enabled } as ModelInfo } +function configEntry(model: string): ConfigEntry { + return { type: 'document', info: { model } } as ConfigEntry +} + function createClientStub(input: { models: ModelInfo[] defaultModel?: ModelInfo | null + configEntries?: ConfigEntry[] listError?: Error defaultError?: Error + configError?: Error }): OpenCodeClient { return { api: { model: { list: vi.fn(async () => { if (input.listError) throw input.listError - return { location: { directory: '/workspace/repos/sample-project' }, data: input.models } + return { location: LOCATION, data: input.models } }), default: vi.fn(async () => { if (input.defaultError) throw input.defaultError - return { location: { directory: '/workspace/repos/sample-project' }, data: input.defaultModel ?? null } + return { location: LOCATION, data: input.defaultModel ?? null } + }), + }, + config: { + get: vi.fn(async () => { + if (input.configError) throw input.configError + return input.configEntries ?? [] }), }, }, } as unknown as OpenCodeClient } +function createStalledListClient(): OpenCodeClient { + return { + api: { + model: { + list: vi.fn((_input: unknown, requestOptions?: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + const signal = requestOptions?.signal + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + })), + default: vi.fn(async () => ({ location: LOCATION, data: null })), + }, + config: { + get: vi.fn(async () => []), + }, + }, + } as unknown as OpenCodeClient +} + describe('resolveOpenCodeModel', () => { beforeEach(() => { + vi.useFakeTimers() vi.clearAllMocks() }) + afterEach(() => { + vi.useRealTimers() + }) + it('returns the preferred model when it is available', async () => { const client = createClientStub({ models: [model('openai', 'gpt-5'), model('openai', 'gpt-5-mini')], defaultModel: model('openai', 'gpt-5-mini'), }) - const result = await resolveOpenCodeModel(client, '/workspace/repos/sample-project', { - preferredModel: 'openai/gpt-5', - }) + const result = await resolveOpenCodeModel(client, DIR, { preferredModel: 'openai/gpt-5' }) - expect(result).toEqual({ - providerID: 'openai', - id: 'gpt-5', - model: 'openai/gpt-5', - }) + expect(result).toEqual({ providerID: 'openai', id: 'gpt-5', model: 'openai/gpt-5' }) }) it('preserves the variant of a preferred model reference', async () => { - const client = createClientStub({ - models: [model('openai', 'gpt-5')], - }) + const client = createClientStub({ models: [model('openai', 'gpt-5')] }) - const result = await resolveOpenCodeModel(client, '/workspace/repos/sample-project', { - preferredModel: 'openai/gpt-5#high', - }) + const result = await resolveOpenCodeModel(client, DIR, { preferredModel: 'openai/gpt-5#high' }) - expect(result).toEqual({ - providerID: 'openai', - id: 'gpt-5', - variant: 'high', - model: 'openai/gpt-5#high', - }) + expect(result).toEqual({ providerID: 'openai', id: 'gpt-5', variant: 'high', model: 'openai/gpt-5#high' }) }) it('keeps slashes inside the model id when parsing the preferred model', async () => { - const client = createClientStub({ - models: [model('openrouter', 'anthropic/claude-sonnet-4')], - }) + const client = createClientStub({ models: [model('openrouter', 'anthropic/claude-sonnet-4')] }) - const result = await resolveOpenCodeModel(client, '/workspace/repos/sample-project', { + const result = await resolveOpenCodeModel(client, DIR, { preferredModel: 'openrouter/anthropic/claude-sonnet-4', }) @@ -90,15 +115,14 @@ describe('resolveOpenCodeModel', () => { defaultModel: model('openai', 'gpt-5-mini'), }) - const result = await resolveOpenCodeModel(client, '/workspace/repos/sample-project', { - preferredModel: 'openai/retired', - }) - - expect(result).toEqual({ + const promise = resolveOpenCodeModel(client, DIR, { preferredModel: 'openai/retired' }) + const assertion = expect(promise).resolves.toEqual({ providerID: 'openai', id: 'gpt-5-mini', model: 'openai/gpt-5-mini', }) + await vi.advanceTimersByTimeAsync(MODEL_LOAD_TIMEOUT_MS + MODEL_LOAD_POLL_MS) + await assertion }) it('falls back to the first enabled model when the default model is unavailable', async () => { @@ -107,15 +131,14 @@ describe('resolveOpenCodeModel', () => { defaultModel: model('openai', 'retired'), }) - const result = await resolveOpenCodeModel(client, '/workspace/repos/sample-project', { - preferredModel: 'openai/retired', - }) - - expect(result).toEqual({ + const promise = resolveOpenCodeModel(client, DIR, { preferredModel: 'openai/retired' }) + const assertion = expect(promise).resolves.toEqual({ providerID: 'openai', id: 'gpt-5', model: 'openai/gpt-5', }) + await vi.advanceTimersByTimeAsync(MODEL_LOAD_TIMEOUT_MS + MODEL_LOAD_POLL_MS) + await assertion }) it('skips disabled models when choosing the first enabled entry', async () => { @@ -123,37 +146,280 @@ describe('resolveOpenCodeModel', () => { models: [model('openai', 'gpt-5', false), model('anthropic', 'claude-sonnet-4')], }) - const result = await resolveOpenCodeModel(client, '/workspace/repos/sample-project') + const result = await resolveOpenCodeModel(client, DIR) + + expect(result).toEqual({ providerID: 'anthropic', id: 'claude-sonnet-4', model: 'anthropic/claude-sonnet-4' }) + }) + + it('throws when no enabled models are available', async () => { + const client = createClientStub({ models: [model('openai', 'gpt-5', false)] }) + + const promise = resolveOpenCodeModel(client, DIR) + const assertion = expect(promise).rejects.toThrow('No configured OpenCode models are available') + await vi.advanceTimersByTimeAsync(MODEL_LOAD_TIMEOUT_MS + MODEL_LOAD_POLL_MS) + await assertion + }) + + it('waits for the preferred model while the server loads the directory catalog', async () => { + let listCalls = 0 + const client = { + api: { + model: { + list: vi.fn(async () => { + listCalls += 1 + return { + location: LOCATION, + data: listCalls === 1 + ? [model('opencode', 'longcat-2.5-preview-free')] + : [model('opencode', 'longcat-2.5-preview-free'), model('GreatScott', 'deepseek-v4.1-flash')], + } + }), + default: vi.fn(async () => ({ location: LOCATION, data: null })), + }, + config: { get: vi.fn(async () => []) }, + }, + } as unknown as OpenCodeClient + + const promise = resolveOpenCodeModel(client, DIR, { preferredModel: 'GreatScott/deepseek-v4.1-flash' }) + const assertion = expect(promise).resolves.toEqual({ + providerID: 'GreatScott', + id: 'deepseek-v4.1-flash', + model: 'GreatScott/deepseek-v4.1-flash', + }) + await vi.advanceTimersByTimeAsync(MODEL_LOAD_POLL_MS) + await assertion + expect(listCalls).toBe(2) + }) + + it('awaits the configured model while an empty then partial catalog loads', async () => { + let listCalls = 0 + const client = { + api: { + model: { + list: vi.fn(async () => { + listCalls += 1 + const catalogs = [ + [] as ModelInfo[], + [model('opencode', 'longcat-2.5-preview-free')], + [model('opencode', 'longcat-2.5-preview-free'), model('GreatScott', 'deepseek-v4.1-flash')], + ] + return { location: LOCATION, data: catalogs[Math.min(listCalls, catalogs.length) - 1] } + }), + default: vi.fn(async () => ({ + location: LOCATION, + data: model('opencode', 'longcat-2.5-preview-free'), + })), + }, + config: { get: vi.fn(async () => [configEntry('GreatScott/deepseek-v4.1-flash')]) }, + }, + } as unknown as OpenCodeClient + + const promise = resolveOpenCodeModel(client, DIR) + const assertion = expect(promise).resolves.toEqual({ + providerID: 'GreatScott', + id: 'deepseek-v4.1-flash', + model: 'GreatScott/deepseek-v4.1-flash', + }) + await vi.advanceTimersByTimeAsync(MODEL_LOAD_POLL_MS * 2) + await assertion + expect(listCalls).toBe(3) + }) + + it('resolves an object configured model and preserves its variant', async () => { + const client = createClientStub({ + models: [model('openai', 'gpt-5')], + configEntries: [ + { type: 'document', info: { model: { providerID: 'openai', model: 'gpt-5', variant: 'high' } } } as ConfigEntry, + ], + }) + + const result = await resolveOpenCodeModel(client, DIR) expect(result).toEqual({ + providerID: 'openai', + id: 'gpt-5', + variant: 'high', + model: 'openai/gpt-5#high', + }) + }) + + it('does not resolve a same-provider fallback before the target model loads', async () => { + const client = createClientStub({ + models: [model('openai', 'gpt-4')], + defaultModel: model('openai', 'gpt-4'), + }) + + const promise = resolveOpenCodeModel(client, DIR, { preferredModel: 'openai/gpt-5' }) + let settled = false + void promise.then(() => { settled = true }, () => { settled = true }) + + await vi.advanceTimersByTimeAsync(0) + expect(settled).toBe(false) + + const assertion = expect(promise).resolves.toEqual({ providerID: 'openai', id: 'gpt-4', model: 'openai/gpt-4' }) + await vi.advanceTimersByTimeAsync(MODEL_LOAD_TIMEOUT_MS + MODEL_LOAD_POLL_MS) + await assertion + }) + + it('falls back to the default when the configured target never loads', async () => { + const client = createClientStub({ + models: [model('openai', 'gpt-5')], + defaultModel: model('openai', 'gpt-5'), + configEntries: [configEntry('missing/model')], + }) + + const promise = resolveOpenCodeModel(client, DIR) + let settled = false + void promise.then(() => { settled = true }, () => { settled = true }) + + await vi.advanceTimersByTimeAsync(0) + expect(settled).toBe(false) + + const assertion = expect(promise).resolves.toEqual({ providerID: 'openai', id: 'gpt-5', model: 'openai/gpt-5' }) + await vi.advanceTimersByTimeAsync(MODEL_LOAD_TIMEOUT_MS + MODEL_LOAD_POLL_MS) + await assertion + }) + + it('waits for the catalog to populate when nothing is preferred or configured', async () => { + let listCalls = 0 + const client = { + api: { + model: { + list: vi.fn(async () => { + listCalls += 1 + return { + location: LOCATION, + data: listCalls === 1 ? [] : [model('anthropic', 'claude-sonnet-4')], + } + }), + default: vi.fn(async () => ({ location: LOCATION, data: null })), + }, + config: { get: vi.fn(async () => []) }, + }, + } as unknown as OpenCodeClient + + const promise = resolveOpenCodeModel(client, DIR) + const assertion = expect(promise).resolves.toEqual({ providerID: 'anthropic', id: 'claude-sonnet-4', model: 'anthropic/claude-sonnet-4', }) + await vi.advanceTimersByTimeAsync(MODEL_LOAD_POLL_MS) + await assertion }) - it('throws when no enabled models are available', async () => { - const client = createClientStub({ models: [model('openai', 'gpt-5', false)] }) + it('falls back to the cached catalog when a later poll stalls until the deadline', async () => { + let listCalls = 0 + const client = { + api: { + model: { + list: vi.fn((_input: unknown, requestOptions?: { signal?: AbortSignal }) => { + listCalls += 1 + if (listCalls === 1) { + return Promise.resolve({ location: LOCATION, data: [model('openai', 'gpt-5')] }) + } + return new Promise((_resolve, reject) => { + const signal = requestOptions?.signal + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + }), + default: vi.fn(async () => ({ location: LOCATION, data: null })), + }, + config: { get: vi.fn(async () => [configEntry('missing/model')]) }, + }, + } as unknown as OpenCodeClient - await expect(resolveOpenCodeModel(client, '/workspace/repos/sample-project')).rejects.toThrow( - 'No configured OpenCode models are available', + const promise = resolveOpenCodeModel(client, DIR) + const assertion = expect(promise).resolves.toEqual({ providerID: 'openai', id: 'gpt-5', model: 'openai/gpt-5' }) + await vi.advanceTimersByTimeAsync(MODEL_LOAD_TIMEOUT_MS + MODEL_LOAD_POLL_MS) + await assertion + expect(listCalls).toBe(2) + }) + + it('times out an abortable stalled catalog request', async () => { + const client = createStalledListClient() + + const promise = resolveOpenCodeModel(client, DIR) + const assertion = expect(promise).rejects.toThrow( + 'Timed out waiting for the OpenCode model catalog to load', ) + await vi.advanceTimersByTimeAsync(MODEL_LOAD_TIMEOUT_MS + 1) + await assertion }) - it('ignores a malformed preferred model reference and falls back to the default', async () => { + it('rejects when the external signal aborts a stalled request', async () => { + const client = createStalledListClient() + const controller = new AbortController() + + const promise = resolveOpenCodeModel(client, DIR, { signal: controller.signal }) + const assertion = expect(promise).rejects.toThrow('cancelled') + await vi.advanceTimersByTimeAsync(0) + controller.abort(new Error('cancelled')) + await vi.advanceTimersByTimeAsync(0) + await assertion + }) + + it('rejects immediately when the external signal is already aborted', async () => { + const client = createClientStub({ models: [model('openai', 'gpt-5')] }) + const controller = new AbortController() + controller.abort(new Error('cancelled')) + + await expect(resolveOpenCodeModel(client, DIR, { signal: controller.signal })).rejects.toThrow('cancelled') + }) + + it('rejects when the external signal aborts as the default model request fulfills', async () => { + const controller = new AbortController() + const client = { + api: { + model: { + list: vi.fn(async () => ({ location: LOCATION, data: [model('openai', 'gpt-5')] })), + default: vi.fn(async () => { + controller.abort(new Error('cancelled')) + return { location: LOCATION, data: model('openai', 'gpt-5') } + }), + }, + config: { get: vi.fn(async () => []) }, + }, + } as unknown as OpenCodeClient + + await expect( + resolveOpenCodeModel(client, DIR, { signal: controller.signal }), + ).rejects.toThrow('cancelled') + }) + + it('propagates a catalog list error', async () => { + const client = createClientStub({ models: [], listError: new Error('list failed') }) + + await expect(resolveOpenCodeModel(client, DIR)).rejects.toThrow('list failed') + }) + + it('propagates a default model error', async () => { const client = createClientStub({ models: [model('openai', 'gpt-5')], - defaultModel: model('openai', 'gpt-5'), + defaultError: new Error('default failed'), }) - const result = await resolveOpenCodeModel(client, '/workspace/repos/sample-project', { - preferredModel: 'openai', + await expect(resolveOpenCodeModel(client, DIR)).rejects.toThrow('default failed') + }) + + it('propagates a configured model read error', async () => { + const client = createClientStub({ + models: [model('openai', 'gpt-5')], + defaultModel: model('openai', 'gpt-5'), + configError: new Error('config failed'), }) - expect(result).toEqual({ - providerID: 'openai', - id: 'gpt-5', - model: 'openai/gpt-5', + await expect(resolveOpenCodeModel(client, DIR)).rejects.toThrow('config failed') + }) + + it('ignores a malformed preferred model reference and falls back to the default', async () => { + const client = createClientStub({ + models: [model('openai', 'gpt-5')], + defaultModel: model('openai', 'gpt-5'), }) + + const result = await resolveOpenCodeModel(client, DIR, { preferredModel: 'openai' }) + + expect(result).toEqual({ providerID: 'openai', id: 'gpt-5', model: 'openai/gpt-5' }) }) }) diff --git a/backend/test/services/schedules.test.ts b/backend/test/services/schedules.test.ts index 698f57b14..a5861f9b0 100644 --- a/backend/test/services/schedules.test.ts +++ b/backend/test/services/schedules.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ScheduleJob, ScheduleRun } from '@opencode-manager/shared/types' import type { OpenCodeApi } from '@opencode-manager/shared/opencode' @@ -175,6 +175,7 @@ describe('ScheduleService', () => { vi.clearAllMocks() Reflect.get(ScheduleService, 'activeRuns').clear() Reflect.get(ScheduleService, 'activeTeardowns')?.clear() + Reflect.get(ScheduleService, 'pendingCancels').clear() mocks.getRepoById.mockReturnValue(repo) mocks.getScheduleJobById.mockReturnValue(job) @@ -314,7 +315,7 @@ describe('ScheduleService', () => { expect(mocks.resolveOpenCodeModel).toHaveBeenCalledWith( expect.anything(), repo.fullPath, - { preferredModel: 'openai/retired' }, + { preferredModel: 'openai/retired', signal: expect.any(AbortSignal) }, ) }) @@ -385,6 +386,52 @@ describe('ScheduleService', () => { }) }) + it('completes from a directory-less session.execution.succeeded resolved to the run directory', async () => { + const stub = createStubScheduleApi({ + sessionID: 'ses-run-exec', + active: { 'ses-run-exec': { type: 'running' } }, + }) + const service = makeService(stub.api) + const runWithSession: ScheduleRun = { + ...baseRun, + sessionId: 'ses-run-exec', + sessionTitle: 'Scheduled: Weekly engineering summary', + logText: 'Run started. Waiting for assistant response...', + } + + mocks.updateScheduleRunMetadata.mockReturnValue(runWithSession) + mocks.getScheduleRunById.mockReturnValue(runWithSession) + + await service.runJob(42, 7, 'manual') + + await vi.waitFor(() => { + expect(stub.api.session.active).toHaveBeenCalled() + }) + expect(mocks.updateScheduleRun).not.toHaveBeenCalled() + + stub.state.active = {} + stub.state.messages = [assistantMessage('Execution summary.', { completed: true })] + captureEventListener()(repo.fullPath, { + id: 'evt_exec_succeeded', + created: Date.now(), + type: 'session.execution.succeeded', + data: { sessionID: 'ses-run-exec' }, + }) + + await vi.waitFor(() => { + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ + status: 'completed', + responseText: 'Execution summary.', + }), + ) + }) + }) + it('keeps waiting when an intermediate assistant step completes while the session is still busy', async () => { const stub = createStubScheduleApi({ sessionID: 'ses-multi', @@ -875,7 +922,7 @@ describe('ScheduleService', () => { it('blocks deleteJob when a running run exists in activeRuns', () => { const stub = createStubScheduleApi() const service = makeService(stub.api) - Reflect.get(ScheduleService, 'activeRuns').add(7) + Reflect.get(ScheduleService, 'activeRuns').set(7, { runId: 5, abort: new AbortController() }) expect(() => service.deleteJob(42, 7)).toThrow('Cannot delete a schedule while it is running. Cancel the run first.') }) @@ -894,7 +941,7 @@ describe('ScheduleService', () => { const onJobChange = vi.fn() service.setJobChangeHandler(onJobChange) mocks.listScheduleJobIdsByRepo.mockReturnValue([7, 8]) - Reflect.get(ScheduleService, 'activeRuns').add(7) + Reflect.get(ScheduleService, 'activeRuns').set(7, { runId: 5, abort: new AbortController() }) expect(() => service.prepareRepoDelete(42)).toThrow('Cannot delete a repo while a schedule run is in progress. Cancel the run first.') expect(onJobChange).not.toHaveBeenCalled() @@ -1106,6 +1153,657 @@ describe('ScheduleService', () => { }) }) +describe('ScheduleService startup cancellation', () => { + beforeEach(() => { + vi.clearAllMocks() + Reflect.get(ScheduleService, 'activeRuns').clear() + Reflect.get(ScheduleService, 'activeTeardowns')?.clear() + Reflect.get(ScheduleService, 'pendingCancels').clear() + + mocks.getRepoById.mockReturnValue(repo) + mocks.getScheduleJobById.mockReturnValue(job) + mocks.getRunningScheduleRunByJob.mockReturnValue(null) + mocks.createScheduleRun.mockReturnValue(baseRun) + mocks.resolveOpenCodeModel.mockResolvedValue({ providerID: 'openai', id: 'gpt-5-mini', model: 'openai/gpt-5-mini' }) + mocks.onEvent.mockReturnValue(vi.fn()) + mocks.stubWorktreeManager.prepare.mockResolvedValue(null) + mocks.stubWorktreeManager.finalize.mockResolvedValue({ commitHash: null }) + }) + + afterEach(() => { + Reflect.get(ScheduleService, 'activeRuns').clear() + Reflect.get(ScheduleService, 'activeTeardowns')?.clear() + Reflect.get(ScheduleService, 'pendingCancels').clear() + }) + + it('stops a startup cancelled while the model is resolving without creating or prompting a session', async () => { + const stub = createStubScheduleApi({ messages: [] }) + const service = makeService(stub.api) + + let resolveModel!: (value: { providerID: string; id: string; model: string }) => void + mocks.resolveOpenCodeModel.mockReturnValueOnce(new Promise((resolve) => { resolveModel = resolve })) + + const runningRun: ScheduleRun = { ...baseRun, sessionId: null, sessionTitle: null } + const cancelledRun: ScheduleRun = { + ...runningRun, + status: 'cancelled', + finishedAt: Date.UTC(2026, 2, 9, 12, 10, 0), + errorText: 'Run cancelled by user.', + } + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue(cancelledRun) + + const runPromise = service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(mocks.resolveOpenCodeModel).toHaveBeenCalled()) + + await service.cancelRun(42, 7, 5) + + resolveModel({ providerID: 'openai', id: 'gpt-5-mini', model: 'openai/gpt-5-mini' }) + await runPromise + + expect(stub.api.session.create).not.toHaveBeenCalled() + expect(stub.api.session.prompt).not.toHaveBeenCalled() + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) + + it('interrupts a session created concurrently with cancellation and never prompts it', async () => { + const stub = createStubScheduleApi({ sessionID: 'ses-cancel-race', messages: [] }) + const service = makeService(stub.api) + + let resolveCreate!: (value: { id: string }) => void + const createDeferred = new Promise<{ id: string }>((resolve) => { resolveCreate = resolve }) + vi.mocked(stub.api.session.create).mockReturnValueOnce(createDeferred as never) + + const runningRun: ScheduleRun = { ...baseRun, sessionId: null, sessionTitle: null } + const cancelledRun: ScheduleRun = { + ...runningRun, + status: 'cancelled', + finishedAt: Date.UTC(2026, 2, 9, 12, 10, 0), + errorText: 'Run cancelled by user.', + } + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue(cancelledRun) + + const runPromise = service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.create).toHaveBeenCalled()) + + await service.cancelRun(42, 7, 5) + + resolveCreate({ id: 'ses-cancel-race' }) + await runPromise + + expect(stub.api.session.interrupt).toHaveBeenCalledWith({ sessionID: 'ses-cancel-race' }) + expect(stub.api.session.prompt).not.toHaveBeenCalled() + expect(mocks.updateScheduleRunMetadata).not.toHaveBeenCalled() + }) + + it('tears down a worktree prepared after cancellation instead of leaking it', async () => { + const stub = createStubScheduleApi({ messages: [] }) + const service = makeService(stub.api) + const worktreePath = '/workspace/worktrees/job-7-run-5' + const runBranch = 'schedule/7/run-5' + + let currentRun: ScheduleRun = { ...baseRun, sessionId: null, sessionTitle: null, worktreePath: null, runBranch: null } + mocks.getScheduleRunById.mockImplementation(() => currentRun) + mocks.updateScheduleRunWorktree.mockImplementation((_db, _repoId, _jobId, _runId, input) => { + currentRun = { ...currentRun, ...input } + return currentRun + }) + mocks.updateScheduleRun.mockReturnValue({ + ...currentRun, + status: 'cancelled', + finishedAt: Date.UTC(2026, 2, 9, 12, 10, 0), + errorText: 'Run cancelled by user.', + }) + + let resolvePrepare!: (value: { directory: string; worktreePath: string; runBranch: string }) => void + mocks.stubWorktreeManager.prepare.mockReturnValueOnce(new Promise((resolve) => { resolvePrepare = resolve })) + mocks.stubWorktreeManager.finalize.mockResolvedValue({ commitHash: 'abc123' }) + + const runPromise = service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(mocks.stubWorktreeManager.prepare).toHaveBeenCalled()) + + await service.cancelRun(42, 7, 5) + + resolvePrepare({ directory: worktreePath, worktreePath, runBranch }) + await runPromise + + expect(mocks.stubWorktreeManager.finalize).toHaveBeenCalledWith( + repo, + job, + expect.objectContaining({ id: 5, worktreePath }), + ) + expect(mocks.updateScheduleRunWorktree).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ worktreePath: null, commitHash: 'abc123' }), + ) + expect(stub.api.session.create).not.toHaveBeenCalled() + }) + + it('does not release the guard of a newer run when an older startup finishes cancelling', async () => { + const stub = createStubScheduleApi({ messages: [] }) + const service = makeService(stub.api) + + let resolveModel!: (value: { providerID: string; id: string; model: string }) => void + mocks.resolveOpenCodeModel.mockReturnValueOnce(new Promise((resolve) => { resolveModel = resolve })) + + const runningRun: ScheduleRun = { ...baseRun, sessionId: null, sessionTitle: null } + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue({ ...runningRun, status: 'cancelled' }) + + const runPromise = service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(mocks.resolveOpenCodeModel).toHaveBeenCalled()) + + await service.cancelRun(42, 7, 5) + + const activeRuns = Reflect.get(ScheduleService, 'activeRuns') as Map + activeRuns.set(7, { runId: 6, abort: new AbortController() }) + + resolveModel({ providerID: 'openai', id: 'gpt-5-mini', model: 'openai/gpt-5-mini' }) + await runPromise + + expect(activeRuns.get(7)?.runId).toBe(6) + }) + + it('skips the prompt when cancellation lands while skills are loading', async () => { + const stub = createStubScheduleApi({ sessionID: 'ses-skill-cancel', skills: [skill('git-release')], messages: [] }) + const service = makeService(stub.api) + const jobWithSkills: ScheduleJob = { ...job, skillMetadata: { skillSlugs: ['git-release'], notes: undefined } } + mocks.getScheduleJobById.mockReturnValue(jobWithSkills) + + let resolveSkillList!: (value: unknown) => void + vi.mocked(stub.api.skill.list).mockReturnValueOnce(new Promise((resolve) => { resolveSkillList = resolve }) as never) + + const runningRun: ScheduleRun = { ...baseRun, sessionId: 'ses-skill-cancel', sessionTitle: 'Scheduled: Weekly engineering summary' } + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRunMetadata.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue({ ...runningRun, status: 'cancelled' }) + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.skill.list).toHaveBeenCalled()) + + const unsubscribe = mocks.onEvent.mock.results.at(-1)?.value as ReturnType + await service.cancelRun(42, 7, 5) + + resolveSkillList({ location: { directory: '' }, data: [skill('git-release')] }) + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalled()) + + expect(stub.api.session.prompt).not.toHaveBeenCalled() + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) + + it('does not mark a cancelled run failed when the prompt rejects after cancellation', async () => { + const stub = createStubScheduleApi({ sessionID: 'ses-prompt-abort', messages: [] }) + const service = makeService(stub.api) + + let rejectPrompt!: (error: Error) => void + const promptDeferred = new Promise((_resolve, reject) => { rejectPrompt = reject }) + vi.mocked(stub.api.session.prompt).mockReturnValueOnce(promptDeferred as never) + + const runningRun: ScheduleRun = { ...baseRun, sessionId: 'ses-prompt-abort', sessionTitle: 'Scheduled: Weekly engineering summary' } + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue({ ...runningRun, status: 'cancelled' }) + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.prompt).toHaveBeenCalled()) + + const unsubscribe = mocks.onEvent.mock.results.at(-1)?.value as ReturnType + await service.cancelRun(42, 7, 5) + + rejectPrompt(new Error('Prompt aborted')) + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalled()) + + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) + + it('does not start monitoring when cancellation lands while the prompt is in flight', async () => { + const stub = createStubScheduleApi({ sessionID: 'ses-prompt-monitor', messages: [] }) + const service = makeService(stub.api) + + let resolvePrompt!: (value: unknown) => void + const promptDeferred = new Promise((resolve) => { resolvePrompt = resolve }) + vi.mocked(stub.api.session.prompt).mockReturnValueOnce(promptDeferred as never) + + const runningRun: ScheduleRun = { ...baseRun, sessionId: 'ses-prompt-monitor', sessionTitle: 'Scheduled: Weekly engineering summary' } + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue({ ...runningRun, status: 'cancelled' }) + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.prompt).toHaveBeenCalled()) + + const unsubscribe = mocks.onEvent.mock.results.at(-1)?.value as ReturnType + await service.cancelRun(42, 7, 5) + + resolvePrompt({}) + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalled()) + + expect(stub.api.session.prompt).toHaveBeenCalledTimes(1) + expect(stub.api.session.active).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) +}) + +describe('ScheduleService monitor cancellation', () => { + beforeEach(() => { + vi.clearAllMocks() + Reflect.get(ScheduleService, 'activeRuns').clear() + Reflect.get(ScheduleService, 'activeTeardowns')?.clear() + Reflect.get(ScheduleService, 'pendingCancels').clear() + + mocks.getRepoById.mockReturnValue(repo) + mocks.getScheduleJobById.mockReturnValue(job) + mocks.getRunningScheduleRunByJob.mockReturnValue(null) + mocks.createScheduleRun.mockReturnValue(baseRun) + mocks.resolveOpenCodeModel.mockResolvedValue({ providerID: 'openai', id: 'gpt-5-mini', model: 'openai/gpt-5-mini' }) + mocks.onEvent.mockReturnValue(vi.fn()) + mocks.stubWorktreeManager.prepare.mockResolvedValue(null) + mocks.stubWorktreeManager.finalize.mockResolvedValue({ commitHash: null }) + }) + + afterEach(() => { + Reflect.get(ScheduleService, 'activeRuns').clear() + Reflect.get(ScheduleService, 'activeTeardowns')?.clear() + Reflect.get(ScheduleService, 'pendingCancels').clear() + }) + + function setupRunningMonitor(sessionID: string) { + const stub = createStubScheduleApi({ sessionID, messages: [] }) + const service = makeService(stub.api) + const runningRun: ScheduleRun = { + ...baseRun, + sessionId: sessionID, + sessionTitle: 'Scheduled: Weekly engineering summary', + } + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue({ ...runningRun, status: 'cancelled' }) + return { stub, service } + } + + it('does not overwrite cancelled with a completion that arrives after cancel', async () => { + const { stub, service } = setupRunningMonitor('ses-monitor-idle') + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalled()) + + const unsubscribe = mocks.onEvent.mock.results.at(-1)?.value as ReturnType + await service.cancelRun(42, 7, 5) + + stub.state.messages = [assistantMessage('Late completion.', { completed: true })] + captureEventListener()(repo.fullPath, sessionIdleEvent(repo.fullPath, 'ses-monitor-idle')) + + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalled()) + + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) + + it('does not overwrite cancelled with a monitor error that arrives after cancel', async () => { + const { stub, service } = setupRunningMonitor('ses-monitor-error') + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalled()) + + const unsubscribe = mocks.onEvent.mock.results.at(-1)?.value as ReturnType + await service.cancelRun(42, 7, 5) + + stub.state.messageError = new Error('messages unavailable') + captureEventListener()(repo.fullPath, sessionIdleEvent(repo.fullPath, 'ses-monitor-error')) + + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalled()) + + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) + + it('does not finalize a settled monitor after cancellation', async () => { + const stub = createStubScheduleApi({ sessionID: 'ses-monitor-settled', messages: [] }) + const service = makeService(stub.api) + const runningRun: ScheduleRun = { + ...baseRun, + sessionId: 'ses-monitor-settled', + sessionTitle: 'Scheduled: Weekly engineering summary', + } + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue({ ...runningRun, status: 'cancelled' }) + + let resolveActive!: (value: Record) => void + vi.mocked(stub.api.session.active).mockReturnValueOnce(new Promise((resolve) => { resolveActive = resolve }) as never) + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalledTimes(1)) + + await service.cancelRun(42, 7, 5) + + stub.state.messages = [assistantMessage('Completed before monitor resolved.', { completed: true })] + resolveActive({}) + + await vi.waitFor(() => expect(mocks.updateScheduleRun).toHaveBeenCalled()) + + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) + + it('keeps monitoring when the interrupt fails during cancellation', async () => { + const stub = createStubScheduleApi({ sessionID: 'ses-interrupt-fail', messages: [], interruptError: new Error('Abort refused') }) + const service = makeService(stub.api) + const runningRun: ScheduleRun = { + ...baseRun, + sessionId: 'ses-interrupt-fail', + sessionTitle: 'Scheduled: Weekly engineering summary', + } + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue({ ...runningRun, status: 'completed' }) + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalled()) + + const unsubscribe = mocks.onEvent.mock.results.at(-1)?.value as ReturnType + await expect(service.cancelRun(42, 7, 5)).rejects.toMatchObject({ status: 502 }) + + stub.state.messages = [assistantMessage('Finished after failed cancel.', { completed: true })] + captureEventListener()(repo.fullPath, sessionIdleEvent(repo.fullPath, 'ses-interrupt-fail')) + + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalled()) + + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'completed' }), + ) + }) + + it('holds monitor completion until a pending cancellation resolves', async () => { + const { stub, service } = setupRunningMonitor('ses-pending-monitor') + const worktreePath = '/workspace/worktrees/job-7-run-5' + const runBranch = 'schedule/7/run-5' + const runningRun: ScheduleRun = { + ...baseRun, + sessionId: 'ses-pending-monitor', + sessionTitle: 'Scheduled: Weekly engineering summary', + worktreePath, + runBranch, + } + let currentRun = runningRun + mocks.getScheduleRunById.mockImplementation(() => currentRun) + mocks.updateScheduleRun.mockImplementation((_db, _repoId, _jobId, _runId, input) => { + currentRun = { ...currentRun, ...input } + return currentRun + }) + mocks.updateScheduleRunWorktree.mockImplementation((_db, _repoId, _jobId, _runId, input) => { + currentRun = { ...currentRun, ...input } + return currentRun + }) + mocks.stubWorktreeManager.finalize.mockResolvedValue({ commitHash: 'abc123' }) + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalled()) + + let resolveInterrupt!: (value: unknown) => void + vi.mocked(stub.api.session.interrupt).mockReturnValueOnce(new Promise((resolve) => { resolveInterrupt = resolve }) as never) + + const cancelPromise = service.cancelRun(42, 7, 5) + await vi.waitFor(() => expect(stub.api.session.interrupt).toHaveBeenCalled()) + + stub.state.messages = [assistantMessage('Late completion.', { completed: true })] + captureEventListener()(repo.fullPath, sessionIdleEvent(repo.fullPath, 'ses-pending-monitor')) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mocks.updateScheduleRun).not.toHaveBeenCalled() + expect(mocks.stubWorktreeManager.finalize).not.toHaveBeenCalled() + + resolveInterrupt({ interrupted: true }) + await cancelPromise + await vi.waitFor(() => expect(mocks.updateScheduleRun).toHaveBeenCalled()) + + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + expect(mocks.stubWorktreeManager.finalize).toHaveBeenCalledTimes(1) + }) + + it('holds monitor status writes until a pending cancellation read resolves', async () => { + const { stub, service } = setupRunningMonitor('ses-pending-read') + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalledTimes(1)) + + let resolveActive!: (value: Record) => void + vi.mocked(stub.api.session.active).mockReturnValueOnce(new Promise((resolve) => { resolveActive = resolve }) as never) + + const cancelPromise = service.cancelRun(42, 7, 5) + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalledTimes(2)) + + captureEventListener()(repo.fullPath, { + id: 'evt_pending_interrupted', + created: Date.now(), + type: 'session.execution.interrupted', + location: { directory: repo.fullPath }, + data: { sessionID: 'ses-pending-read' }, + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mocks.updateScheduleRun).not.toHaveBeenCalled() + + resolveActive({}) + await cancelPromise + await vi.waitFor(() => expect(mocks.updateScheduleRun).toHaveBeenCalled()) + + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) + + it('holds the prompt until a pending cancellation resolves', async () => { + const stub = createStubScheduleApi({ sessionID: 'ses-pending-skill', skills: [skill('git-release')], messages: [] }) + const service = makeService(stub.api) + const jobWithSkills: ScheduleJob = { ...job, skillMetadata: { skillSlugs: ['git-release'], notes: undefined } } + mocks.getScheduleJobById.mockReturnValue(jobWithSkills) + const runningRun: ScheduleRun = { + ...baseRun, + sessionId: 'ses-pending-skill', + sessionTitle: 'Scheduled: Weekly engineering summary', + } + mocks.getScheduleRunById.mockReturnValue(runningRun) + mocks.updateScheduleRun.mockReturnValue({ ...runningRun, status: 'cancelled' }) + + let resolveSkillList!: (value: unknown) => void + vi.mocked(stub.api.skill.list).mockReturnValueOnce(new Promise((resolve) => { resolveSkillList = resolve }) as never) + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.skill.list).toHaveBeenCalled()) + + let resolveInterrupt!: (value: unknown) => void + vi.mocked(stub.api.session.interrupt).mockReturnValueOnce(new Promise((resolve) => { resolveInterrupt = resolve }) as never) + + const cancelPromise = service.cancelRun(42, 7, 5) + await vi.waitFor(() => expect(stub.api.session.interrupt).toHaveBeenCalled()) + + resolveSkillList({ location: { directory: '' }, data: [skill('git-release')] }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(stub.api.session.prompt).not.toHaveBeenCalled() + + resolveInterrupt({ interrupted: true }) + await cancelPromise + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(stub.api.session.prompt).not.toHaveBeenCalled() + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) + + it('holds a failed cancellation retry pending', async () => { + const { stub, service } = setupRunningMonitor('ses-cancel-retry') + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalled()) + + vi.mocked(stub.api.session.interrupt).mockRejectedValueOnce(new Error('Abort refused')) + await expect(service.cancelRun(42, 7, 5)).rejects.toMatchObject({ status: 502 }) + + let resolveRetry!: (value: unknown) => void + vi.mocked(stub.api.session.interrupt).mockReturnValueOnce(new Promise((resolve) => { resolveRetry = resolve }) as never) + + const retryPromise = service.cancelRun(42, 7, 5) + await vi.waitFor(() => expect(stub.api.session.interrupt).toHaveBeenCalledTimes(2)) + + stub.state.messages = [assistantMessage('Late completion.', { completed: true })] + captureEventListener()(repo.fullPath, sessionIdleEvent(repo.fullPath, 'ses-cancel-retry')) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mocks.updateScheduleRun).not.toHaveBeenCalled() + + resolveRetry({ interrupted: true }) + await retryPromise + await vi.waitFor(() => expect(mocks.updateScheduleRun).toHaveBeenCalled()) + + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) + + it('rejects a duplicate cancellation while one is in progress', async () => { + const { stub, service } = setupRunningMonitor('ses-cancel-dup') + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalled()) + + let resolveInterrupt!: (value: unknown) => void + vi.mocked(stub.api.session.interrupt).mockReturnValueOnce(new Promise((resolve) => { resolveInterrupt = resolve }) as never) + + const firstCancel = service.cancelRun(42, 7, 5) + await vi.waitFor(() => expect(stub.api.session.interrupt).toHaveBeenCalledTimes(1)) + + await expect(service.cancelRun(42, 7, 5)).rejects.toMatchObject({ status: 409 }) + expect(stub.api.session.interrupt).toHaveBeenCalledTimes(1) + + resolveInterrupt({ interrupted: true }) + await firstCancel + await vi.waitFor(() => expect(mocks.updateScheduleRun).toHaveBeenCalled()) + + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) + + it('settles the decision from persisted state when finalization throws', async () => { + const stub = createStubScheduleApi({ sessionID: 'ses-finalize-throw', messages: [] }) + const service = makeService(stub.api) + let currentRun: ScheduleRun = { + ...baseRun, + sessionId: 'ses-finalize-throw', + sessionTitle: 'Scheduled: Weekly engineering summary', + } + mocks.getScheduleRunById.mockImplementation(() => currentRun) + mocks.updateScheduleRun.mockImplementation((_db, _repoId, _jobId, _runId, input) => { + currentRun = { ...currentRun, ...input } + return currentRun + }) + mocks.updateScheduleJobRunState.mockImplementationOnce(() => { + throw new Error('db write failed') + }) + + await service.runJob(42, 7, 'manual') + await vi.waitFor(() => expect(stub.api.session.active).toHaveBeenCalled()) + + const unsubscribe = mocks.onEvent.mock.results.at(-1)?.value as ReturnType + await expect(service.cancelRun(42, 7, 5)).rejects.toThrow('db write failed') + + stub.state.messages = [assistantMessage('Late completion.', { completed: true })] + captureEventListener()(repo.fullPath, sessionIdleEvent(repo.fullPath, 'ses-finalize-throw')) + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalled()) + + await vi.waitFor(() => expect(Reflect.get(ScheduleService, 'activeRuns').has(7)).toBe(false)) + + expect(mocks.updateScheduleRun).toHaveBeenCalledTimes(1) + expect(mocks.updateScheduleRun).toHaveBeenCalledWith( + expect.anything(), + 42, + 7, + 5, + expect.objectContaining({ status: 'cancelled' }), + ) + }) +}) + describe('ScheduleService worktree isolation', () => { const worktreePath = '/workspace/worktrees/job-7-run-5' const runBranch = 'schedule/7/run-5' diff --git a/backend/test/services/sse-aggregator.test.ts b/backend/test/services/sse-aggregator.test.ts index edb400d5c..b8c621c90 100644 --- a/backend/test/services/sse-aggregator.test.ts +++ b/backend/test/services/sse-aggregator.test.ts @@ -15,6 +15,7 @@ vi.mock('../../src/utils/logger', () => ({ })) import type { OpenCodeApi } from '@opencode-manager/shared/opencode' +import { sessionIDFromEvent } from '@opencode-manager/shared/opencode' import { sseAggregator, type PendingActionsFetcher } from '../../src/services/sse-aggregator' interface CapturedEvent { @@ -647,3 +648,91 @@ describe('SSEAggregator directory-indexed broadcast', () => { expect(clientB.frames).toHaveLength(1) }) }) + +describe('SSEAggregator directory-less session event routing', () => { + beforeEach(() => { + sseAggregator.shutdown() + sseAggregator.setPendingActionsFetcher(null) + sseAggregator.setScheduledSessionsResolver(() => []) + }) + + it('resolves a directory-less terminal event from the remembered directory for listeners, active sessions, and all clients', () => { + const subscribed = createCapturingClient() + const global = createCapturingClient() + sseAggregator.addClient('resolved-a', subscribed.callback, subscribed.writeFrame, ['/r']) + sseAggregator.addClient('resolved-b', global.callback, global.writeFrame, []) + + const seen: Array<{ directory: string; type: string; sessionID: string }> = [] + sseAggregator.onEvent((directory, event) => { + seen.push({ directory, type: event.type, sessionID: sessionIDFromEvent(event) ?? '' }) + }) + + emitRawEvent({ type: 'session.execution.started', location: { directory: '/r' }, data: { sessionID: 'ses_1' } }) + expect(sseAggregator.getActiveSessions()).toEqual({ '/r': ['ses_1'] }) + + emitRawEvent({ type: 'session.execution.succeeded', data: { sessionID: 'ses_1' } }) + + expect(seen).toEqual([ + { directory: '/r', type: 'session.execution.started', sessionID: 'ses_1' }, + { directory: '/r', type: 'session.execution.succeeded', sessionID: 'ses_1' }, + ]) + expect(sseAggregator.getActiveSessions()).toEqual({}) + + expect(subscribed.frames.map(parseFrame)).toEqual([ + { + directory: '/r', + payload: { id: 'evt_1', created: 1, type: 'session.execution.started', location: { directory: '/r' }, data: { sessionID: 'ses_1' } }, + }, + { + directory: null, + payload: { id: 'evt_1', created: 1, type: 'session.execution.succeeded', data: { sessionID: 'ses_1' } }, + }, + ]) + expect(global.frames.map(parseFrame)).toEqual([ + { + directory: null, + payload: { id: 'evt_1', created: 1, type: 'session.execution.succeeded', data: { sessionID: 'ses_1' } }, + }, + ]) + }) + + it('broadcasts a directory-less event from an unknown session without notifying listeners', () => { + const client = createCapturingClient() + sseAggregator.addClient('unknown-a', client.callback, client.writeFrame, ['/r']) + + const seen: string[] = [] + sseAggregator.onEvent((_directory, event) => { seen.push(event.type) }) + + emitRawEvent({ type: 'session.execution.succeeded', data: { sessionID: 'ses_unknown' } }) + + expect(seen).toEqual([]) + expect(sseAggregator.getActiveSessions()).toEqual({}) + expect(client.frames.map(parseFrame)).toEqual([ + { + directory: null, + payload: { id: 'evt_1', created: 1, type: 'session.execution.succeeded', data: { sessionID: 'ses_unknown' } }, + }, + ]) + }) + + it('forgets the remembered directory when session.deleted arrives', () => { + const client = createCapturingClient() + sseAggregator.addClient('deleted-a', client.callback, client.writeFrame, ['/r']) + + const seen: string[] = [] + sseAggregator.onEvent((_directory, event) => { seen.push(event.type) }) + + emitRawEvent({ type: 'session.created', location: { directory: '/r' }, data: { sessionID: 'ses_del' } }) + emitRawEvent({ type: 'session.deleted', data: { sessionID: 'ses_del' } }) + + seen.length = 0 + emitRawEvent({ type: 'session.execution.succeeded', data: { sessionID: 'ses_del' } }) + + expect(seen).toEqual([]) + expect(sseAggregator.getActiveSessions()).toEqual({}) + expect(client.frames.map(parseFrame).at(-1)).toEqual({ + directory: null, + payload: { id: 'evt_1', created: 1, type: 'session.execution.succeeded', data: { sessionID: 'ses_del' } }, + }) + }) +}) diff --git a/docs/features/chat.md b/docs/features/chat.md index a044f1a78..120b6ac4f 100644 --- a/docs/features/chat.md +++ b/docs/features/chat.md @@ -12,6 +12,18 @@ Messages stream in real-time using Server-Sent Events (SSE): ![Chat Conversation](../images/chat-conversation.png) +## Background Work + +While a shell command or subagent is running, **Move to background** above the prompt lets it keep running while the session continues. The background tasks bar lists each background shell and subagent with its status: + +- **running** - still working +- **completed** / **failed** - finished, using the shell exit code or the subagent outcome +- **killed** - you stopped the shell from the bar +- **interrupted** - the subagent was interrupted +- **unavailable** - OpenCode no longer knows the shell and the session has no completion notice for it + +Shell rows can show live output and be killed; subagent rows open the child session. Statuses are reconciled when the connection returns, so work that finished while you were away is shown as finished. After a page reload, a shell you killed is shown as **failed**, which matches OpenCode's own notice for it. + ## Model Selection Click the **model name** in the chat prompt area to open the quick model switcher, where you can switch models, mark favorites, and pick variants without leaving the chat. Each agent keeps its own model selection. See [AI Configuration](ai-config.md#model-selection) for the full reference. diff --git a/docs/features/schedules.md b/docs/features/schedules.md index 59aec7fd8..cd67894b4 100644 --- a/docs/features/schedules.md +++ b/docs/features/schedules.md @@ -14,6 +14,8 @@ Schedules make OpenCode Manager proactive instead of purely session-driven. Good Each run is stored with status, timestamps, logs, assistant output, and a linked session you can open and continue. +Cancelling during startup stops model loading and prevents later prompt submission. If session creation was already in flight, the returned session is interrupted rather than prompted. + ## Creating a Schedule 1. Open a repository @@ -65,7 +67,7 @@ Schedules can run with: - a custom agent slug - a specific model override when needed -If a requested model is no longer available, OpenCode Manager falls back to a valid configured model for that provider so the run can still start when possible. +OpenCode Manager allows up to 15 seconds for a fresh worktree's model catalog to load before treating a missing requested or configured model as unavailable. It prefers the schedule override, then the configured model, then OpenCode's default, then the first enabled model. A fallback can use a different provider. Model-catalog requests are bounded by that deadline. ## Skills diff --git a/frontend/src/api/providers.ts b/frontend/src/api/providers.ts index 584e5d733..6c7a87d26 100644 --- a/frontend/src/api/providers.ts +++ b/frontend/src/api/providers.ts @@ -2,7 +2,7 @@ import { API_BASE_URL } from "@/config"; import { settingsApi } from "./settings"; import { fetchWrapper } from "./fetchWrapper"; import { callOpenCode } from "./opencodeApi"; -import { openCodeLocation, type ConfigEntry, type FormAnswer, type ModelInfo } from "@opencode-manager/shared/opencode"; +import { openCodeLocation, selectConfiguredModelRef, type FormAnswer, type ModelInfo } from "@opencode-manager/shared/opencode"; import type { CredentialListResponse, CredentialStatusResponse } from "@opencode-manager/shared/schemas"; import type { OpenCodeConfigFile } from "./types/settings"; @@ -128,20 +128,11 @@ export async function getProviders(directory?: string): Promise } } -type ConfigDocumentModel = Extract["info"]["model"]; - -function formatConfigModel(model: NonNullable): string { - return typeof model === "string" ? model : `${model.providerID}/${model.model}`; -} - export async function getOpenCodeConfigModel(directory?: string): Promise { try { const entries = await callOpenCode((api) => api.config.get(openCodeLocation(directory))); - const model = entries.reduce( - (current, entry) => (entry.type === "document" && entry.info.model ? entry.info.model : current), - undefined, - ); - return model ? formatConfigModel(model) : null; + const ref = selectConfiguredModelRef(entries); + return ref ? `${ref.providerID}/${ref.id}` : null; } catch { return null; } diff --git a/frontend/src/components/message/MessagePart.tsx b/frontend/src/components/message/MessagePart.tsx index 029582a1e..8f8ac4553 100644 --- a/frontend/src/components/message/MessagePart.tsx +++ b/frontend/src/components/message/MessagePart.tsx @@ -3,17 +3,20 @@ import type { SessionMessageAssistant } from '@opencode-manager/shared/opencode' import { TextPart } from './TextPart' import { ToolCallPart } from './ToolCallPart' import { useSettings } from '@/hooks/useSettings' +import type { ShellNoticeOutcome } from '@/lib/backgroundWork' type AssistantContentPart = SessionMessageAssistant['content'][number] interface MessagePartProps { part: AssistantContentPart messageID?: string + directory?: string + shellOutcome?: ShellNoticeOutcome onFileClick?: (filePath: string, lineNumber?: number) => void onChildSessionClick?: (sessionId: string) => void } -export const MessagePart = memo(function MessagePart({ part, messageID, onFileClick, onChildSessionClick }: MessagePartProps) { +export const MessagePart = memo(function MessagePart({ part, messageID, directory, shellOutcome, onFileClick, onChildSessionClick }: MessagePartProps) { const { preferences } = useSettings() const simpleChatMode = preferences?.simpleChatMode ?? false const showReasoning = preferences?.showReasoning ?? false @@ -40,6 +43,8 @@ export const MessagePart = memo(function MessagePart({ part, messageID, onFileCl diff --git a/frontend/src/components/message/MessageThread.test.tsx b/frontend/src/components/message/MessageThread.test.tsx index 6b8ceb2a6..d0865b26d 100644 --- a/frontend/src/components/message/MessageThread.test.tsx +++ b/frontend/src/components/message/MessageThread.test.tsx @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' +import { render as rtlRender, screen, fireEvent } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactElement } from 'react' import { MessageThread } from './MessageThread' import { useUIState } from '@/stores/uiStateStore' import { applySessionEvent, emptySessionTranscript } from '@/lib/session-projection' @@ -47,6 +49,12 @@ vi.mock('@/hooks/useTTS', () => ({ }), })) +vi.mock('@/api/opencode', async (importOriginal) => ({ + ...(await importOriginal()), + getSession: () => new Promise(() => {}), + listShells: () => Promise.resolve([]), +})) + interface MockSettingsReturn { preferences: { simpleChatMode: boolean @@ -63,6 +71,11 @@ const setupSettings = (preferences: MockSettingsReturn['preferences']) => { }) } +const render = (ui: ReactElement) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return rtlRender({ui}) +} + const project = (events: V2Event[]): SessionMessageInfo[] => events.reduce(applySessionEvent, emptySessionTranscript).messages @@ -191,6 +204,43 @@ describe('MessageThread', () => { expect(onChildSessionClick).toHaveBeenCalledWith('child-1') }) + it('resolves a background shell tool from its completion notice', () => { + setupSettings({ simpleChatMode: false, showReasoning: false }) + + const messages: SessionMessageInfo[] = [ + userMessage('1', 'Hello'), + assistantMessage('2', [ + { + type: 'tool', + id: 'tool_shell', + name: 'shell', + state: { + status: 'completed', + input: { command: 'npm run dev' }, + content: [{ type: 'text', text: 'started in the background' }], + metadata: { status: 'running', shellID: 'sh_notice' }, + }, + time: { created: Date.now(), completed: Date.now() + 100 }, + }, + ]), + { + id: 'syn-shell', + type: 'synthetic', + text: '', + metadata: { source: 'shell', shellID: 'sh_notice', state: 'completed', exit: 0 }, + time: { created: Date.now() }, + }, + ] + + const { container } = render( + , + ) + + expect(screen.getByText('completed')).toBeInTheDocument() + expect(screen.queryByText('unavailable')).not.toBeInTheDocument() + expect(container.querySelector('.animate-spin')).toBeNull() + }) + it('renders a synthetic message that only carries text', () => { setupSettings({ simpleChatMode: false, showReasoning: false }) diff --git a/frontend/src/components/message/MessageThread.tsx b/frontend/src/components/message/MessageThread.tsx index b9e683f40..f5332115b 100644 --- a/frontend/src/components/message/MessageThread.tsx +++ b/frontend/src/components/message/MessageThread.tsx @@ -22,6 +22,7 @@ import { EditableUserMessage, ClickableUserMessage } from './EditableUserMessage import { useSettings } from '@/hooks/useSettings' import { useTTS } from '@/hooks/useTTS' import { CopyButton } from '@/components/ui/copy-button' +import { backgroundShellID, collectBackgroundParts, type ShellNoticeOutcome } from '@/lib/backgroundWork' function getMessageText(message: SessionMessageInfo): string { switch (message.type) { @@ -364,6 +365,7 @@ interface MessageRowProps { model?: string simpleChatMode: boolean showReasoning: boolean + shellOutcomes: ReadonlyMap } const MessageRow = memo(function MessageRow({ @@ -382,8 +384,11 @@ const MessageRow = memo(function MessageRow({ model, simpleChatMode, showReasoning, + shellOutcomes, }: MessageRowProps) { const messageTextContent = getMessageText(message) + const shellOutcomeFor = (part: SessionMessageAssistant['content'][number]) => + part.type === 'tool' ? shellOutcomes.get(backgroundShellID(part) ?? '') : undefined const streaming = message.type === 'assistant' && message.time.completed === undefined const isEditingThisMessage = editingUserMessageId === message.id const canEditUserMessage = isLastUserMessage && !isSessionBusy @@ -479,6 +484,8 @@ const MessageRow = memo(function MessageRow({ @@ -516,6 +523,8 @@ const MessageRow = memo(function MessageRow({ @@ -607,6 +616,15 @@ export const MessageThread = memo(function MessageThread({ const lastUserMessageId = useMemo(() => findLastUserMessageId(messages), [messages]) + const shellOutcomesKey = useMemo( + () => [...collectBackgroundParts(messages).shellNotices].map(([id, outcome]) => `${id}=${outcome}`).join(','), + [messages], + ) + const shellOutcomes = useMemo( + () => new Map(shellOutcomesKey ? shellOutcomesKey.split(',').map((entry) => entry.split('=') as [string, ShellNoticeOutcome]) : []), + [shellOutcomesKey], + ) + const nextAssistantIdByMessageId = useMemo(() => { const map = new Map() let nextAssistantId: string | undefined @@ -656,6 +674,7 @@ export const MessageThread = memo(function MessageThread({ model={model} simpleChatMode={simpleChatMode} showReasoning={showReasoning} + shellOutcomes={shellOutcomes} /> ))} {pending.map((item) => ( diff --git a/frontend/src/components/message/ToolCallPart.test.tsx b/frontend/src/components/message/ToolCallPart.test.tsx index fc5fadc97..b98a330fb 100644 --- a/frontend/src/components/message/ToolCallPart.test.tsx +++ b/frontend/src/components/message/ToolCallPart.test.tsx @@ -1,10 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen } from '@testing-library/react' +import { act, render, screen, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MemoryRouter } from 'react-router-dom' import type { PermissionRequest, SessionMessageAssistantTool } from '@opencode-manager/shared/opencode' import { ToolCallPart } from './ToolCallPart' import { useUserBash } from '@/stores/userBashStore' +import { useSessionStatus } from '@/stores/sessionStatusStore' const mocks = vi.hoisted(() => ({ useSettings: vi.fn(), @@ -19,8 +20,13 @@ vi.mock('@/contexts/EventContext', () => ({ useToolCallPermission: mocks.useToolCallPermission, })) -const renderWithProviders = (ui: React.ReactElement) => { - const queryClient = new QueryClient() +vi.mock('@/api/opencode', async (importOriginal) => ({ + ...(await importOriginal()), + getSession: () => new Promise(() => {}), + listShells: () => Promise.resolve([]), +})) + +const renderWithProviders = (ui: React.ReactElement, queryClient = new QueryClient()) => { return render( {ui} @@ -112,6 +118,13 @@ describe('ToolCallPart background indicator', () => { }) mocks.useToolCallPermission.mockReturnValue(null) useUserBash.setState({ userBashCommands: new Map() }) + useSessionStatus.setState({ + statuses: new Map(), + statusCache: new Map(), + statusRevisions: new Map(), + knownSessions: new Set(), + revision: 0, + }) }) const completedShell = (metadata: Record): SessionMessageAssistantTool => ({ @@ -127,6 +140,20 @@ describe('ToolCallPart background indicator', () => { }, }) + const completedSubagent = (metadata: Record): SessionMessageAssistantTool => ({ + type: 'tool', + id: 'call_3', + name: 'subagent', + time: { created: 1, ran: 2, completed: 3 }, + state: { + status: 'completed', + input: { description: 'Explore' }, + content: [{ type: 'text', text: 'Task moved to the background (session ID: ses_child).' }], + metadata, + }, + }) + + it('marks a shell call that returned while its command keeps running', () => { renderWithProviders() @@ -147,4 +174,94 @@ describe('ToolCallPart background indicator', () => { expect(screen.getByText('background')).toBeInTheDocument() expect(screen.queryByText('✓')).not.toBeInTheDocument() }) + + it('clears the background badge once the live shell completes', async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + queryClient.setQueryData(['opencode', 'shells', '/repo'], [ + { + id: 'sh_1', + status: 'exited', + exit: 0, + command: 'npm run dev', + cwd: '/repo', + shell: 'zsh', + file: '/tmp/sh_1.log', + metadata: { sessionID: 'ses_1' }, + time: { started: 1, completed: 2 }, + }, + ]) + + renderWithProviders( + , + queryClient, + ) + + await waitFor(() => expect(screen.queryByText('background')).not.toBeInTheDocument()) + expect(screen.getByText('completed')).toBeInTheDocument() + }) + + it('shows a background shell missing from a loaded list as a non-spinning unavailable icon', async () => { + const { container } = renderWithProviders( + , + ) + + await waitFor(() => expect(screen.getByText('unavailable')).toBeInTheDocument()) + expect(screen.queryByText('background')).not.toBeInTheDocument() + expect(container.querySelector('.animate-spin')).toBeNull() + }) + + it('completes a background shell missing from the list from its notice', async () => { + const { container } = renderWithProviders( + , + ) + + await waitFor(() => expect(screen.getByText('completed')).toBeInTheDocument()) + expect(screen.queryByText('background')).not.toBeInTheDocument() + expect(container.querySelector('.animate-spin')).toBeNull() + }) + + it('fails a background shell missing from the list from its notice', async () => { + renderWithProviders( + , + ) + + await waitFor(() => expect(screen.getByText('failed')).toBeInTheDocument()) + expect(screen.queryByText('background')).not.toBeInTheDocument() + }) + + it('treats an unknown child session as running, not finished', () => { + renderWithProviders( + , + ) + + expect(screen.getByText('background')).toBeInTheDocument() + expect(screen.queryByText('✓')).not.toBeInTheDocument() + }) + + it('completes a backgrounded subagent when the child session is known idle', async () => { + act(() => { + useSessionStatus.getState().setStatus('child-1', { type: 'idle' }) + }) + + renderWithProviders( + , + ) + + await waitFor(() => expect(screen.getByText('✓')).toBeInTheDocument()) + expect(screen.queryByText('background')).not.toBeInTheDocument() + }) }) diff --git a/frontend/src/components/message/ToolCallPart.tsx b/frontend/src/components/message/ToolCallPart.tsx index 87506a0c8..5e8d327c1 100644 --- a/frontend/src/components/message/ToolCallPart.tsx +++ b/frontend/src/components/message/ToolCallPart.tsx @@ -3,12 +3,28 @@ import { unwrapSandboxExecCommand } from '@opencode-manager/shared/utils' import { toolContentText, type SessionMessageAssistantTool } from '@opencode-manager/shared/opencode' import { useSettings } from '@/hooks/useSettings' import { useUserBash } from '@/stores/userBashStore' -import { useSessionStatusForSession } from '@/stores/sessionStatusStore' +import { useChildLifecycleForSession } from '@/stores/sessionStatusStore' import { useToolCallPermission } from '@/contexts/EventContext' +import { useChildSessionReconciliation } from '@/hooks/useOpenCode' +import { useShell } from '@/hooks/useSessionShells' import { detectFileReferences } from '@/lib/fileReferences' import { ExternalLink, Loader2, Shield } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { CopyButton } from '@/components/ui/copy-button' +import { BackgroundTaskStatusIcon } from '@/components/session/BackgroundTaskStatusIcon' +import { + backgroundChildSessionID, + backgroundShellID, + backgroundTaskStatusColor, + isRunningLifecycle, + lifecycleLabel, + shellToolLifecycle, + subagentLifecycle, + subagentSessionID, + toolMetadata, + type BackgroundTaskLifecycle, + type ShellNoticeOutcome, +} from '@/lib/backgroundWork' import { getToolInputPath, getToolSpecificRender } from './FileToolRender' const DISPLAY_LIMIT = 30_000 @@ -41,6 +57,8 @@ function BoundedPre({ content, className }: { content: string; className: string interface ToolCallPartProps { part: SessionMessageAssistantTool messageID?: string + directory?: string + shellOutcome?: ShellNoticeOutcome onFileClick?: (filePath: string, lineNumber?: number) => void onChildSessionClick?: (sessionId: string) => void } @@ -50,21 +68,11 @@ function toolInput(part: SessionMessageAssistantTool): Record | return part.state.input } -function toolMetadata(part: SessionMessageAssistantTool): Record { - if (part.state.status === 'streaming') return {} - return part.state.metadata ?? {} -} - function toolOutputText(part: SessionMessageAssistantTool): string { if (part.state.status === 'streaming') return '' return toolContentText(part.state.status === 'running' ? undefined : part.state.content) } -function getSubagentSessionId(part: SessionMessageAssistantTool): string | undefined { - const sessionID = toolMetadata(part).sessionID - return typeof sessionID === 'string' ? sessionID : undefined -} - function ClickableJson({ json, onFileClick }: { json: unknown; onFileClick?: (filePath: string) => void }) { const jsonString = useMemo(() => JSON.stringify(json, null, 2), [json]) const references = useMemo(() => detectFileReferences(jsonString), [jsonString]) @@ -105,12 +113,28 @@ function ClickableJson({ json, onFileClick }: { json: unknown; onFileClick?: (fi return
{parts}
} -export const ToolCallPart = memo(function ToolCallPart({ part, messageID, onFileClick, onChildSessionClick }: ToolCallPartProps) { +export const ToolCallPart = memo(function ToolCallPart({ part, messageID, directory, shellOutcome, onFileClick, onChildSessionClick }: ToolCallPartProps) { const { preferences } = useSettings() const { userBashCommands } = useUserBash() const isSubagent = part.name === 'subagent' - const subagentSessionId = isSubagent ? getSubagentSessionId(part) : undefined - const subagentSessionStatus = useSessionStatusForSession(subagentSessionId) + const subagentSessionId = isSubagent ? subagentSessionID(part) : undefined + const backgroundSubagent = isSubagent ? backgroundChildSessionID(part) : undefined + const subagentSessionLifecycle = useChildLifecycleForSession(subagentSessionId) + useChildSessionReconciliation( + subagentSessionId && (backgroundSubagent !== undefined || part.state.status === 'running') + ? subagentSessionId + : undefined, + ) + const subagentStatus = subagentLifecycle( + part.state.status, + backgroundSubagent !== undefined, + subagentSessionLifecycle, + ) + const backgroundShell = part.name === 'shell' ? backgroundShellID(part) : undefined + const { shell, listLoaded: shellListLoaded } = useShell(backgroundShell, directory) + const shellStatus = shellToolLifecycle(backgroundShell, shell, shellListLoaded, shellOutcome) + const isBackgroundShell = backgroundShell !== undefined + const isBackgroundShellRunning = isBackgroundShell && isRunningLifecycle(shellStatus) const pendingPermission = useToolCallPermission(part.id, messageID) const isWaitingPermission = part.state.status === 'running' && pendingPermission !== null const outputRef = useRef(null) @@ -138,6 +162,7 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, onFile }, [expanded, part.name]) const getStatusColor = () => { + if (isBackgroundShell) return backgroundTaskStatusColor(shellStatus) switch (part.state.status) { case 'completed': return 'text-success' @@ -151,7 +176,12 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, onFile } } + const renderShellLifecycleIcon = (status: BackgroundTaskLifecycle) => ( + + ) + const getStatusIcon = () => { + if (isBackgroundShell) return renderShellLifecycleIcon(shellStatus) switch (part.state.status) { case 'completed': return ✓ @@ -193,7 +223,9 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, onFile const previewText = getPreviewText() const isFileTool = ['read', 'write', 'edit', 'patch'].includes(part.name) - const isBackgrounded = part.state.status === 'completed' && toolMetadata(part).status === 'running' + const isBackgroundSubagent = backgroundSubagent !== undefined + const isBackgroundSubagentRunning = isBackgroundSubagent && isRunningLifecycle(subagentStatus) + const isBackgrounded = isBackgroundShell ? isBackgroundShellRunning : isBackgroundSubagentRunning const backgroundIndicator = isBackgrounded ? (
- {isBackgrounded ? ( - - ) : ( - ✓ - )} + {isBackgroundShell ? renderShellLifecycleIcon(shellStatus) : ✓} $ {command} {sandboxIndicator} @@ -347,7 +375,9 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, onFile {previewText} ) : null} - {isWaitingPermission ? 'awaiting permission' : part.state.status} + + {isWaitingPermission ? 'awaiting permission' : isBackgroundShell ? lifecycleLabel(shellStatus) : part.state.status} + {expanded && ( diff --git a/frontend/src/components/session/BackgroundTaskStatusIcon.tsx b/frontend/src/components/session/BackgroundTaskStatusIcon.tsx new file mode 100644 index 000000000..31e1ca444 --- /dev/null +++ b/frontend/src/components/session/BackgroundTaskStatusIcon.tsx @@ -0,0 +1,12 @@ +import { Ban, Check, Loader2, Square, XCircle } from 'lucide-react' +import { backgroundTaskStatusColor, type BackgroundTaskLifecycle } from '@/lib/backgroundWork' + +export function BackgroundTaskStatusIcon({ status, className }: { status: BackgroundTaskLifecycle; className: string }) { + const iconClassName = `${className} ${backgroundTaskStatusColor(status)}` + if (status === 'completed') return + if (status === 'failed') return + if (status === 'interrupted') return + if (status === 'killed') return + if (status === 'unavailable') return + return +} diff --git a/frontend/src/components/session/BackgroundWorkBar.test.tsx b/frontend/src/components/session/BackgroundWorkBar.test.tsx index 7cf33ecaa..5dcef7ac1 100644 --- a/frontend/src/components/session/BackgroundWorkBar.test.tsx +++ b/frontend/src/components/session/BackgroundWorkBar.test.tsx @@ -1,13 +1,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { act, render, screen, fireEvent, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import type { ReactNode } from 'react' import type { SessionMessageAssistant, SessionMessageInfo } from '@opencode-manager/shared/opencode' import type { ShellInfo } from '@/api/opencode' +import { clearShellExitRecord, recordShellExit } from '@/lib/backgroundWork' +import { useSessionStatus } from '@/stores/sessionStatusStore' import { BackgroundWorkBar } from './BackgroundWorkBar' const api = vi.hoisted(() => ({ backgroundSession: vi.fn(), + getSession: vi.fn(), listShells: vi.fn(), readShellOutput: vi.fn(), removeShell: vi.fn(), @@ -50,17 +53,80 @@ const runningShellTool = (): SessionMessageAssistant => ({ time: { created: 1 }, }) -function renderBar(messages: SessionMessageInfo[], isSessionActive: boolean) { +const backgroundShellTool = (shellID: string, command = 'npm run dev'): SessionMessageAssistant => ({ + id: 'msg-shell', + type: 'assistant', + agent: 'build', + model: { providerID: 'p', id: 'm' }, + content: [ + { + type: 'tool', + id: 'tool-shell', + name: 'shell', + state: { + status: 'completed', + input: { command }, + content: [{ type: 'text', text: 'started in the background' }], + metadata: { status: 'running', shellID }, + }, + time: { created: 1, completed: 2 }, + }, + ], + time: { created: 1 }, +}) + +const shellNotice = ( + shellID: string, + state: 'completed' | 'error', + exit?: number, +): SessionMessageInfo => ({ + id: `notice-${shellID}`, + type: 'synthetic', + text: '', + metadata: { source: 'shell', shellID, state, ...(exit === undefined ? {} : { exit }) }, + time: { created: 3 }, +}) + +const backgroundSubagentTool = (childSessionID: string, description = 'Explore'): SessionMessageAssistant => ({ + id: 'msg-sub', + type: 'assistant', + agent: 'build', + model: { providerID: 'p', id: 'm' }, + content: [ + { + type: 'tool', + id: 'tool-sub', + name: 'subagent', + state: { + status: 'completed', + input: { description }, + content: [{ type: 'text', text: 'started in the background' }], + metadata: { status: 'running', sessionID: childSessionID }, + }, + time: { created: 1, completed: 2 }, + }, + ], + time: { created: 1 }, +}) + +function renderBar(messages: SessionMessageInfo[], isSessionActive: boolean, onChildSessionClick = vi.fn()) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const wrapper = ({ children }: { children: ReactNode }) => ( {children} ) return { ...render( - , + , { wrapper }, ), queryClient, + onChildSessionClick, } } @@ -68,9 +134,18 @@ describe('BackgroundWorkBar', () => { beforeEach(() => { vi.clearAllMocks() api.listShells.mockResolvedValue([]) + api.getSession.mockImplementation(() => new Promise(() => {})) api.backgroundSession.mockResolvedValue(undefined) api.removeShell.mockResolvedValue(undefined) api.readShellOutput.mockResolvedValue({ output: '', cursor: 0, size: 0, truncated: false }) + useSessionStatus.setState({ + statuses: new Map(), + statusCache: new Map(), + statusRevisions: new Map(), + knownSessions: new Set(), + outcomes: new Map(), + revision: 0, + }) }) it('renders nothing when there is no running or background work', async () => { @@ -93,20 +168,270 @@ describe('BackgroundWorkBar', () => { expect(screen.queryByRole('button', { name: /Move to background/ })).not.toBeInTheDocument() }) - it('lists only this session\'s running shells and kills one', async () => { + it('hides completed shells and removes a killed shell', async () => { api.listShells.mockResolvedValue([shell('dev'), shell('other', 'session-2'), shell('done', 'session-1', 'exited')]) - renderBar([], false) + const { container } = renderBar([], false) - fireEvent.click(await screen.findByRole('button', { name: /1 background shell$/ })) + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) expect(screen.getByText('npm run dev')).toBeInTheDocument() - expect(screen.queryByText('npm run other')).not.toBeInTheDocument() expect(screen.queryByText('npm run done')).not.toBeInTheDocument() + expect(screen.queryByText('npm run other')).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Kill npm run dev' })) await waitFor(() => expect(api.removeShell).toHaveBeenCalledWith('dev', '/repo')) - await waitFor(() => expect(screen.queryByText(/background shell/)).not.toBeInTheDocument()) + await waitFor(() => expect(container).toBeEmptyDOMElement()) + clearShellExitRecord('/repo', 'dev') + }) + + it('removes a shell row once it completes', async () => { + api.listShells.mockResolvedValue([shell('dev')]) + const { queryClient } = renderBar([], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + expect(screen.getByText('running')).toBeInTheDocument() + + await act(async () => { + queryClient.setQueryData(['opencode', 'shells', '/repo'], [ + { ...shell('dev', 'session-1', 'exited'), exit: 0, time: { started: 1, completed: 2 } }, + ]) + }) + + await waitFor(() => expect(screen.queryByText('npm run dev')).not.toBeInTheDocument()) + expect(screen.queryByRole('button', { name: /background task/ })).not.toBeInTheDocument() + }) + + it('preserves a shell created while a stale list fetch was in flight', async () => { + let resolveList: ((value: ShellInfo[]) => void) | undefined + api.listShells.mockImplementation(() => new Promise((resolve) => { resolveList = resolve })) + const { queryClient } = renderBar([], false) + await waitFor(() => expect(api.listShells).toHaveBeenCalled()) + + await act(async () => { + queryClient.setQueryData(['opencode', 'shells', '/repo'], [ + { ...shell('dev'), time: { started: Date.now() + 60_000 } }, + ]) + }) + + await act(async () => { + resolveList?.([]) + }) + + expect(await screen.findByRole('button', { name: /1 background task$/ })).toBeInTheDocument() + expect(screen.queryByText('unavailable')).not.toBeInTheDocument() + }) + + it('hides a running shell omitted from a fresh list once it becomes unavailable', async () => { + api.listShells.mockResolvedValue([]) + const { queryClient, container } = renderBar([], false) + await waitFor(() => expect(api.listShells).toHaveBeenCalled()) + + await act(async () => { + queryClient.setQueryData(['opencode', 'shells', '/repo'], [shell('dev')]) + await queryClient.invalidateQueries({ queryKey: ['opencode', 'shells', '/repo'] }) + }) + + await waitFor(() => expect(container).toBeEmptyDOMElement()) + }) + + it('does not resurrect a completed shell from a stale list fetch', async () => { + api.listShells.mockResolvedValue([shell('dev')]) + const { queryClient } = renderBar([], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + expect(screen.getByText('running')).toBeInTheDocument() + + queryClient.setQueryData(['opencode', 'shells', '/repo'], [ + { ...shell('dev', 'session-1', 'exited'), exit: 0, time: { started: 1, completed: 2 } }, + ]) + + await act(async () => { + await queryClient.invalidateQueries({ queryKey: ['opencode', 'shells', '/repo'] }) + }) + + await waitFor(() => expect(screen.queryByText('npm run dev')).not.toBeInTheDocument()) + expect(screen.queryByText('running')).not.toBeInTheDocument() + }) + + it('shows a backgrounded subagent and removes it when the child goes idle', async () => { + renderBar([backgroundSubagentTool('child-1')], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + expect(screen.getByText('Explore')).toBeInTheDocument() + expect(screen.getByText('running')).toBeInTheDocument() + + act(() => { + useSessionStatus.getState().setStatus('child-1', { type: 'busy' }) + }) + expect(screen.getByText('running')).toBeInTheDocument() + + act(() => { + useSessionStatus.getState().setStatus('child-1', { type: 'idle' }) + }) + + await waitFor(() => expect(screen.queryByText('Explore')).not.toBeInTheDocument()) + }) + + it('treats an unknown child session as still running', async () => { + renderBar([backgroundSubagentTool('child-unknown')], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + + expect(screen.getByText('running')).toBeInTheDocument() + expect(screen.queryByText('completed')).not.toBeInTheDocument() + }) + + it('reconciles a child session that finished before it was observed', async () => { + api.getSession.mockResolvedValue({ + id: 'child-1', + projectID: 'project-1', + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + outcome: 'succeeded', + time: { created: 1, updated: 2, idle: 2 }, + location: { directory: '/repo' }, + }) + const { container } = renderBar([backgroundSubagentTool('child-1')], false) + + await waitFor(() => expect(api.getSession).toHaveBeenCalled()) + await waitFor(() => expect(container).toBeEmptyDOMElement()) + }) + + it('hides a failed child session outcome', async () => { + act(() => { + useSessionStatus.getState().setOutcome('child-1', 'failed') + useSessionStatus.getState().setStatus('child-1', { type: 'idle' }) + }) + const { container } = renderBar([backgroundSubagentTool('child-1')], false) + + await waitFor(() => expect(container).toBeEmptyDOMElement()) + }) + + it('hides an interrupted child session outcome', async () => { + act(() => { + useSessionStatus.getState().setOutcome('child-1', 'interrupted') + useSessionStatus.getState().setStatus('child-1', { type: 'idle' }) + }) + const { container } = renderBar([backgroundSubagentTool('child-1')], false) + + await waitFor(() => expect(container).toBeEmptyDOMElement()) + }) + + it('hides a deleted shell once it becomes unavailable', async () => { + api.listShells.mockResolvedValue([shell('dev')]) + const { queryClient, container } = renderBar([], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + + await act(async () => { + queryClient.setQueryData(['opencode', 'shells', '/repo'], [ + { ...shell('dev'), status: 'unavailable', time: { started: 1, completed: 2 } }, + ]) + }) + + await waitFor(() => expect(container).toBeEmptyDOMElement()) + }) + + it('hides a historical shell absent from the fetched list', async () => { + api.listShells.mockResolvedValue([]) + const { container } = renderBar([backgroundShellTool('sh-history', 'npm run history')], false) + + await waitFor(() => expect(api.listShells).toHaveBeenCalled()) + await waitFor(() => expect(container).toBeEmptyDOMElement()) + }) + + it('hides a historical shell absent from the list once its notice reports completion', async () => { + api.listShells.mockResolvedValue([]) + const { container } = renderBar( + [backgroundShellTool('sh-notice', 'npm run notice'), shellNotice('sh-notice', 'completed', 0)], + false, + ) + + await waitFor(() => expect(api.listShells).toHaveBeenCalled()) + await waitFor(() => expect(container).toBeEmptyDOMElement()) + }) + + it('hides a historical shell absent from the list once its error notice reports failure', async () => { + api.listShells.mockResolvedValue([]) + const { container } = renderBar( + [backgroundShellTool('sh-notice', 'npm run notice'), shellNotice('sh-notice', 'error')], + false, + ) + + await waitFor(() => expect(api.listShells).toHaveBeenCalled()) + await waitFor(() => expect(container).toBeEmptyDOMElement()) + }) + + it('keeps a historical shell unknown while its list is still loading', async () => { + api.listShells.mockImplementation(() => new Promise(() => {})) + renderBar([backgroundShellTool('sh-history', 'npm run history')], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + + expect(screen.getByText('running')).toBeInTheDocument() + expect(screen.queryByText('unavailable')).not.toBeInTheDocument() + }) + + it('uses an already cached shell list when the bar remounts', async () => { + const messages = [backgroundShellTool('sh-history', 'npm run history')] + const { queryClient, unmount, container } = renderBar(messages, false) + await waitFor(() => expect(container).toBeEmptyDOMElement()) + unmount() + + const remount = render( + + + , + ) + await waitFor(() => expect(remount.container).toBeEmptyDOMElement()) + expect(api.listShells).toHaveBeenCalledTimes(1) + }) + + it('does not overwrite a newer live child status with a stale child query', async () => { + let resolveSession: ((value: unknown) => void) | undefined + api.getSession.mockImplementation(() => new Promise((resolve) => { resolveSession = resolve })) + renderBar([backgroundSubagentTool('child-1')], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + + act(() => { + useSessionStatus.getState().setStatus('child-1', { type: 'idle' }) + }) + expect(screen.queryByText('Explore')).not.toBeInTheDocument() + + await act(async () => { + resolveSession?.({ + id: 'child-1', + projectID: 'project-1', + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 2 }, + location: { directory: '/repo' }, + }) + }) + + expect(screen.queryByText('Explore')).not.toBeInTheDocument() + expect(screen.queryByText('running')).not.toBeInTheDocument() + }) + + it('applies a shell exit recorded before the list was seeded', async () => { + recordShellExit('/repo', { id: 'sh-seeded', status: 'exited', exit: 0 }) + api.listShells.mockResolvedValue([shell('sh-seeded')]) + const { container } = renderBar([], false) + + await waitFor(() => expect(api.listShells).toHaveBeenCalled()) + await waitFor(() => expect(container).toBeEmptyDOMElement()) + clearShellExitRecord('/repo', 'sh-seeded') + }) + + it('navigates to a backgrounded subagent session', async () => { + const { onChildSessionClick } = renderBar([backgroundSubagentTool('child-1')], false) + + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) + fireEvent.click(screen.getByRole('button', { name: 'View Explore' })) + + expect(onChildSessionClick).toHaveBeenCalledWith('child-1') }) it('shows the tail of a background shell output', async () => { @@ -114,7 +439,7 @@ describe('BackgroundWorkBar', () => { api.readShellOutput.mockResolvedValue({ output: 'server listening on 3000', cursor: 24, size: 24, truncated: false }) renderBar([], false) - fireEvent.click(await screen.findByRole('button', { name: /1 background shell$/ })) + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) fireEvent.click(screen.getByRole('button', { name: 'Output' })) expect(await screen.findByText('server listening on 3000')).toBeInTheDocument() @@ -136,7 +461,7 @@ describe('BackgroundWorkBar', () => { }) const { queryClient } = renderBar([], false) - fireEvent.click(await screen.findByRole('button', { name: /1 background shell$/ })) + fireEvent.click(await screen.findByRole('button', { name: /1 background task$/ })) fireEvent.click(screen.getByRole('button', { name: 'Output' })) await waitFor(() => expect(calls).toBe(1)) diff --git a/frontend/src/components/session/BackgroundWorkBar.tsx b/frontend/src/components/session/BackgroundWorkBar.tsx index 83d665630..ed229ed3b 100644 --- a/frontend/src/components/session/BackgroundWorkBar.tsx +++ b/frontend/src/components/session/BackgroundWorkBar.tsx @@ -1,15 +1,30 @@ -import { useEffect, useRef, useState } from 'react' -import { ChevronDown, ChevronUp, Loader2, MoveDownRight, Square, Terminal } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Bot, Check, ChevronDown, ChevronUp, ExternalLink, Loader2, MoveDownRight, Square, Terminal } from 'lucide-react' +import { useShallow } from 'zustand/react/shallow' import type { SessionMessageInfo } from '@opencode-manager/shared/opencode' -import { readShellOutput, type ShellInfo } from '@/api/opencode' +import { readShellOutput } from '@/api/opencode' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { useBackgroundSession } from '@/hooks/useOpenCode' +import { useBackgroundSession, useChildSessionReconciliation } from '@/hooks/useOpenCode' import { useKillShell, useSessionShells } from '@/hooks/useSessionShells' +import { useSessionStatus } from '@/stores/sessionStatusStore' +import { BackgroundTaskStatusIcon } from './BackgroundTaskStatusIcon' +import { + BACKGROUNDABLE_TOOLS, + childLifecycle, + collectBackgroundParts, + isRunningLifecycle, + lifecycleLabel, + shellBackgroundTasks, + shellLifecycle, + subagentBackgroundTasks, + type BackgroundTask, + type BackgroundTaskLifecycle, + type ShellRecord, +} from '@/lib/backgroundWork' const SHELL_OUTPUT_LIMIT = 50_000 const SHELL_OUTPUT_POLL_INTERVAL_MS = 1000 -const BACKGROUNDABLE_TOOLS = new Set(['shell', 'subagent']) function hasBackgroundableWork(messages: SessionMessageInfo[]): boolean { for (let index = messages.length - 1; index >= 0; index -= 1) { @@ -89,7 +104,7 @@ function useShellOutput(shellID: string, directory: string, running: boolean): S return state } -function ShellOutputView({ shell, directory, running }: { shell: ShellInfo; directory: string; running: boolean }) { +function ShellOutputView({ shell, directory, running }: { shell: ShellRecord; directory: string; running: boolean }) { const { output, truncated } = useShellOutput(shell.id, directory, running) const bottomRef = useRef(null) @@ -108,28 +123,123 @@ function ShellOutputView({ shell, directory, running }: { shell: ShellInfo; dire ) } +function BackgroundTaskKindIcon({ task }: { task: BackgroundTask }) { + return task.kind === 'shell' + ? + : +} + +function BackgroundTaskReconciler({ sessionID }: { sessionID: string }) { + useChildSessionReconciliation(sessionID) + return null +} + +function BackgroundTaskRow({ + task, + killShell, + onChildSessionClick, + onView, +}: { + task: BackgroundTask + killShell: ReturnType + onChildSessionClick?: (sessionId: string) => void + onView: (shell: ShellRecord) => void +}) { + return ( +
  • + + {task.label} + + + {lifecycleLabel(task.status)} + + {task.kind === 'shell' && task.shell && ( + + )} + {task.kind === 'shell' && task.status === 'running' && task.shell && ( + + )} + {task.kind === 'subagent' && task.childSessionID && onChildSessionClick && ( + + )} +
  • + ) +} + interface BackgroundWorkBarProps { sessionID: string directory: string messages: SessionMessageInfo[] isSessionActive: boolean + onChildSessionClick?: (sessionId: string) => void } -export function BackgroundWorkBar({ sessionID, directory, messages, isSessionActive }: BackgroundWorkBarProps) { - const shells = useSessionShells(sessionID, directory) +export function BackgroundWorkBar({ sessionID, directory, messages, isSessionActive, onChildSessionClick }: BackgroundWorkBarProps) { + const { shells, listLoaded } = useSessionShells(sessionID, directory) + const collected = useMemo(() => collectBackgroundParts(messages), [messages]) + const childLifecycles = useSessionStatus( + useShallow((state) => { + const lifecycles: Record = {} + for (const part of collected.subagents) { + lifecycles[part.childSessionID] = childLifecycle( + state.statuses.get(part.childSessionID) ?? { type: 'idle' }, + state.knownSessions.has(part.childSessionID), + state.outcomes.get(part.childSessionID), + ) + } + return lifecycles + }), + ) const killShell = useKillShell(directory) const backgroundSession = useBackgroundSession() const [expanded, setExpanded] = useState(false) - const [viewing, setViewing] = useState(null) + const [viewing, setViewing] = useState(null) + + const tasks = useMemo( + () => [ + ...shellBackgroundTasks(shells, collected.shells, collected.shellNotices, listLoaded), + ...subagentBackgroundTasks(collected.subagents, childLifecycles), + ].filter((task) => isRunningLifecycle(task.status)), + [shells, collected, listLoaded, childLifecycles], + ) + const hasRunningTask = tasks.some((task) => isRunningLifecycle(task.status)) const canBackground = isSessionActive && hasBackgroundableWork(messages) - const viewingRunning = viewing !== null && shells.some((shell) => shell.id === viewing.id) + const liveViewingShell = viewing ? shells.find((shell) => shell.id === viewing.id) : undefined + const viewingShell = liveViewingShell ?? viewing + const viewingRunning = liveViewingShell !== undefined && isRunningLifecycle(shellLifecycle(liveViewingShell)) - if (!canBackground && shells.length === 0 && viewing === null) return null + if (!canBackground && tasks.length === 0 && viewing === null) return null return (
    -
    + {tasks.map((task) => + task.kind === 'subagent' && task.childSessionID ? ( + + ) : null, + )} +
    {canBackground && ( )}
    - {expanded && shells.length > 0 && ( + {expanded && tasks.length > 0 && (
      - {shells.map((shell) => ( -
    • - - {shell.command} - - -
    • + {tasks.map((task) => ( + ))}
    )} @@ -185,10 +286,12 @@ export function BackgroundWorkBar({ sessionID, directory, messages, isSessionAct { if (!open) setViewing(null) }}> - {viewing?.command} - {viewingRunning ? 'Running in the background' : 'Finished'} + {viewingShell?.command ?? viewing?.command} + + {viewingRunning ? 'Running in the background' : liveViewingShell === undefined ? 'Status unavailable' : 'Finished'} + - {viewing && } + {viewingShell && }
    diff --git a/frontend/src/components/ui/dialog.test.tsx b/frontend/src/components/ui/dialog.test.tsx index 827afc7a9..2328d94a4 100644 --- a/frontend/src/components/ui/dialog.test.tsx +++ b/frontend/src/components/ui/dialog.test.tsx @@ -383,6 +383,21 @@ describe("DialogContent", () => { }); }); +describe("DialogHeader", () => { + it("can shrink so a long title truncates instead of widening the dialog", () => { + render( + + + + a very long command that would otherwise widen the dialog + + + + ); + expect(screen.getByTestId("dialog-header")).toHaveClass("min-w-0"); + }); +}); + function stubVisualViewport(height: number) { const listeners = new Set<() => void>() Object.defineProperty(window, 'visualViewport', { diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index a62c546f9..a7aa85520 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -146,7 +146,7 @@ const DialogHeader = ({ }: React.HTMLAttributes) => (
    { mocks.replyForm.mockResolvedValue(undefined) mocks.cancelForm.mockResolvedValue(undefined) mocks.getHealth.mockReturnValue({ isConnected: false, isHealthy: false, lastEventAt: null, isStalled: false }) - useSessionStatus.setState({ statuses: new Map(), statusCache: new Map(), statusRevisions: new Map(), revision: 0 }) + useSessionStatus.setState({ statuses: new Map(), statusCache: new Map(), statusRevisions: new Map(), knownSessions: new Set(), outcomes: new Map(), revision: 0 }) mocks.subscribeGlobalMonitor.mockReturnValue({ dispose: vi.fn(), updateDirectories: vi.fn(), @@ -1014,6 +1014,209 @@ describe('EventProvider permissions and forms', () => { expect(useSessionStatus.getState().getStatus('session-9')).toEqual({ type: 'idle' }) }) + it('marks a child session as known from its execution lifecycle events', async () => { + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'session.execution.started', data: { sessionID: 'child-1' }, directory: '/repo' }) + }) + + expect(useSessionStatus.getState().knownSessions.has('child-1')).toBe(true) + expect(useSessionStatus.getState().knownSessions.has('child-unknown')).toBe(false) + }) + + it('does not treat a child omitted from a global snapshot as finished', async () => { + mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }]) + mocks.listActiveSessions.mockResolvedValue({}) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + const onResync = lastSubscribeCall[0].onResync as (() => void) | undefined + + act(() => { + onEvent({ type: 'session.execution.started', data: { sessionID: 'child-1' }, directory: '/repo' }) + }) + expect(useSessionStatus.getState().getStatus('child-1')).toEqual({ type: 'busy' }) + + act(() => { + onResync?.() + }) + + await waitFor(() => { + expect(useSessionStatus.getState().knownSessions.has('child-1')).toBe(false) + }) + }) + + it('records a failed child outcome and preserves it when the child goes idle', async () => { + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'session.execution.started', data: { sessionID: 'child-1' }, directory: '/repo' }) + onEvent({ type: 'session.execution.failed', data: { sessionID: 'child-1', error: { message: 'boom' } }, directory: '/repo' }) + }) + + expect(useSessionStatus.getState().outcomes.get('child-1')).toBe('failed') + expect(useSessionStatus.getState().getStatus('child-1')).toEqual({ type: 'idle' }) + + act(() => { + onEvent({ type: 'session.idle', data: { sessionID: 'child-1' }, directory: '/repo' }) + }) + + expect(useSessionStatus.getState().outcomes.get('child-1')).toBe('failed') + }) + + it('records an interrupted child outcome', async () => { + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'session.execution.interrupted', data: { sessionID: 'child-1', reason: 'user' }, directory: '/repo' }) + }) + + expect(useSessionStatus.getState().outcomes.get('child-1')).toBe('interrupted') + expect(useSessionStatus.getState().getStatus('child-1')).toEqual({ type: 'idle' }) + }) + + it('does not turn a failed child into completed across a reconnect snapshot', async () => { + mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }]) + mocks.listActiveSessions.mockResolvedValue({}) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + const handleStatusChange = lastSubscribeCall[0].onStatusChange as (connected: boolean) => void + + act(() => { + onEvent({ type: 'session.execution.failed', data: { sessionID: 'child-1', error: { message: 'boom' } }, directory: '/repo' }) + }) + expect(useSessionStatus.getState().outcomes.get('child-1')).toBe('failed') + + act(() => { + handleStatusChange(true) + }) + + await waitFor(() => { + expect(useSessionStatus.getState().outcomes.get('child-1')).toBe('failed') + expect(useSessionStatus.getState().getStatus('child-1')).toEqual({ type: 'idle' }) + expect(useSessionStatus.getState().knownSessions.has('child-1')).toBe(true) + }) + }) + + it('clears a previous child outcome when a new execution starts', async () => { + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'session.execution.failed', data: { sessionID: 'child-1', error: { message: 'boom' } }, directory: '/repo' }) + onEvent({ type: 'session.execution.started', data: { sessionID: 'child-1' }, directory: '/repo' }) + }) + + expect(useSessionStatus.getState().outcomes.get('child-1')).toBeUndefined() + }) + + it('forgets a deleted session status, knowledge, and outcome while keeping the list invalidation', async () => { + const queryClient = createTestQueryClient() + queryClient.setQueryData(['opencode', 'sessions', '/repo'], { pages: [], pageParams: [] }) + + render(, { wrapper: createWrapper(queryClient) }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'session.execution.failed', data: { sessionID: 'child-1', error: { message: 'boom' } }, directory: '/repo' }) + }) + expect(useSessionStatus.getState().outcomes.get('child-1')).toBe('failed') + expect(useSessionStatus.getState().knownSessions.has('child-1')).toBe(true) + + act(() => { + onEvent({ type: 'session.deleted', data: { sessionID: 'child-1' }, directory: '/repo' }) + }) + + const after = useSessionStatus.getState() + expect(after.statuses.has('child-1')).toBe(false) + expect(after.knownSessions.has('child-1')).toBe(false) + expect(after.outcomes.has('child-1')).toBe(false) + + await waitFor(() => { + expect(queryClient.getQueryState(['opencode', 'sessions', '/repo'])?.isInvalidated).toBe(true) + }) + }) + + it('invalidates only the reconciliation of children that moved from busy to unknown during a poll', async () => { + mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }]) + mocks.listActiveSessions + .mockResolvedValueOnce({ 'child-1': { type: 'running' }, 'child-2': { type: 'running' } }) + .mockResolvedValue({ 'child-2': { type: 'running' } }) + + const queryClient = createTestQueryClient() + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') + queryClient.setQueryData(['opencode', 'session-reconcile', 'child-1'], { id: 'child-1' }) + queryClient.setQueryData(['opencode', 'session-reconcile', 'child-2'], { id: 'child-2' }) + + render(, { wrapper: createWrapper(queryClient) }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const handleStatusChange = lastSubscribeCall[0].onStatusChange as (connected: boolean) => void + + vi.useFakeTimers() + try { + act(() => { + handleStatusChange(true) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(useSessionStatus.getState().knownSessions.has('child-1')).toBe(true) + invalidateQueries.mockClear() + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000) + }) + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['opencode', 'session-reconcile', 'child-1'], + }) + expect(invalidateQueries).not.toHaveBeenCalledWith({ + queryKey: ['opencode', 'session-reconcile', 'child-2'], + }) + expect(invalidateQueries).not.toHaveBeenCalledWith({ + queryKey: ['opencode', 'session-reconcile'], + }) + } finally { + vi.useRealTimers() + } + }) + it('reconciles the global active snapshot on reconnect and clears omitted sessions', async () => { mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }]) mocks.listActiveSessions.mockResolvedValue({ 'session-other': { type: 'running' } }) diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx index 23fa42bf1..4b71656fe 100644 --- a/frontend/src/contexts/EventContext.tsx +++ b/frontend/src/contexts/EventContext.tsx @@ -18,6 +18,7 @@ import { openCodeEventStream, type EventStreamHealthState } from '@/lib/opencode import { addToSessionKeyedState, removeFromSessionKeyedState } from '@/lib/sessionKeyedState' import { busyStatusesFromActiveSessions, useSessionStatus } from '@/stores/sessionStatusStore' import { + invalidateChildSessionCaches, invalidateProviderCachesDebounced, invalidateQueryKeysDebounced, invalidateRepoGitCachesDebounced, @@ -392,17 +393,28 @@ export function EventProvider({ children }: { children: React.ReactNode }) { useSessionStatus.getState().endStatusSnapshot(snapshotToken) return } + const busyBeforeSnapshot = new Set() + for (const [sessionID, status] of useSessionStatus.getState().statuses.entries()) { + if (status.type !== 'idle') busyBeforeSnapshot.add(sessionID) + } useSessionStatus.getState().replaceStatuses( busyStatusesFromActiveSessions(active), snapshotToken, ) + const knownAfterSnapshot = useSessionStatus.getState().knownSessions + const becameUnknown = [...busyBeforeSnapshot].filter( + (sessionID) => !knownAfterSnapshot.has(sessionID), + ) + if (becameUnknown.length > 0) { + invalidateChildSessionCaches(queryClient, becameUnknown) + } } catch (error) { useSessionStatus.getState().endStatusSnapshot(snapshotToken) if (import.meta.env.DEV) { console.warn('Failed to fetch active sessions:', error) } } - }, []) + }, [queryClient]) const stopStatusPoll = useCallback(() => { if (statusPollIntervalRef.current === null) return @@ -469,19 +481,35 @@ export function EventProvider({ children }: { children: React.ReactNode }) { break } case 'session.execution.started': { + useSessionStatus.getState().clearOutcome(event.data.sessionID) useSessionStatus.getState().setStatus(event.data.sessionID, { type: 'busy' }) break } - case 'session.execution.succeeded': - case 'session.execution.failed': + case 'session.execution.succeeded': { + useSessionStatus.getState().setOutcome(event.data.sessionID, 'succeeded') + useSessionStatus.getState().setStatus(event.data.sessionID, { type: 'idle' }) + invalidateSessionListCachesDebounced(queryClient, event.directory) + break + } + case 'session.execution.failed': { + useSessionStatus.getState().setOutcome(event.data.sessionID, 'failed') + useSessionStatus.getState().setStatus(event.data.sessionID, { type: 'idle' }) + invalidateSessionListCachesDebounced(queryClient, event.directory) + break + } case 'session.execution.interrupted': { + useSessionStatus.getState().setOutcome(event.data.sessionID, 'interrupted') useSessionStatus.getState().setStatus(event.data.sessionID, { type: 'idle' }) invalidateSessionListCachesDebounced(queryClient, event.directory) break } + case 'session.deleted': { + useSessionStatus.getState().forgetSession(event.data.sessionID) + invalidateSessionListCachesDebounced(queryClient, event.directory) + break + } case 'session.created': case 'session.renamed': - case 'session.deleted': case 'session.moved': case 'session.metadata.updated': invalidateSessionListCachesDebounced(queryClient, event.directory) @@ -529,6 +557,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { initialFetchDoneRef.current = false fetchInitialPendingData() fetchInitialSessionStatuses() + invalidateChildSessionCaches(queryClient) startStatusPoll() } else { statusSyncVersionRef.current += 1 @@ -538,6 +567,8 @@ export function EventProvider({ children }: { children: React.ReactNode }) { const handleResync = () => { void reconcilePendingActionsForDirectories(collectTrackedDirectories()) + void fetchInitialSessionStatuses() + invalidateChildSessionCaches(queryClient) } const initialDirectories = [...new Set((reposRef.current ?? []).map(r => r.fullPath))] diff --git a/frontend/src/hooks/useOpenCode.test.tsx b/frontend/src/hooks/useOpenCode.test.tsx index 48205d795..e5019ca66 100644 --- a/frontend/src/hooks/useOpenCode.test.tsx +++ b/frontend/src/hooks/useOpenCode.test.tsx @@ -3,14 +3,16 @@ import { act, renderHook, waitFor } from '@testing-library/react' import type { ReactNode } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SessionInfo } from '@opencode-manager/shared/opencode' -import { useCreateSession, useDeleteSession, useSession, useSessionsAcrossDirectories } from './useOpenCode' +import { useChildSessionReconciliation, useCreateSession, useDeleteSession, useSession, useSessionsAcrossDirectories } from './useOpenCode' import { FetchError } from '../api/fetchWrapper' import { showToast } from '../lib/toast' +import { useSessionStatus } from '../stores/sessionStatusStore' const mocks = vi.hoisted(() => ({ listSessionPage: vi.fn(), deleteSession: vi.fn(), createSession: vi.fn(), + getSession: vi.fn(), })) vi.mock('../lib/toast', () => ({ @@ -22,11 +24,13 @@ vi.mock('../lib/toast', () => ({ vi.mock('@/api/opencode', async (importOriginal) => { const actual = await importOriginal() + mocks.getSession.mockImplementation(actual.getSession) return { ...actual, listSessionPage: mocks.listSessionPage, deleteSession: mocks.deleteSession, createSession: mocks.createSession, + getSession: mocks.getSession, } }) @@ -218,3 +222,105 @@ describe('useCreateSession', () => { expect(mocks.createSession).toHaveBeenCalledWith({ directory: '/w/a', agent: undefined }) }) }) + +describe('useChildSessionReconciliation', () => { + beforeEach(() => { + mocks.getSession.mockReset() + useSessionStatus.setState({ + statuses: new Map(), + statusCache: new Map(), + statusRevisions: new Map(), + knownSessions: new Set(), + outcomes: new Map(), + revision: 0, + }) + }) + + const idleSession = (): SessionInfo => ({ + ...sessionInfo('child-1', '/repo'), + time: { created: 1000, updated: 2000, idle: 2000 }, + }) + + it('applies one guarded snapshot for multiple observers sharing one fetch', async () => { + let resolveSession: ((value: SessionInfo) => void) | undefined + mocks.getSession.mockImplementation( + () => new Promise((resolve) => { resolveSession = resolve }), + ) + + const queryClient = createQueryClient() + const wrapper = createWrapper(queryClient) + const first = renderHook(() => useChildSessionReconciliation('child-1'), { wrapper }) + const second = renderHook(() => useChildSessionReconciliation('child-1'), { wrapper }) + + await waitFor(() => expect(mocks.getSession).toHaveBeenCalledTimes(1)) + + await act(async () => { + resolveSession?.(idleSession()) + }) + + expect(mocks.getSession).toHaveBeenCalledTimes(1) + expect(useSessionStatus.getState().getStatus('child-1')).toEqual({ type: 'idle' }) + expect(useSessionStatus.getState().knownSessions.has('child-1')).toBe(true) + + first.unmount() + second.unmount() + }) + + it('releases the snapshot when unmounted while the request is pending', async () => { + let resolveSession: ((value: SessionInfo) => void) | undefined + mocks.getSession.mockImplementation( + () => new Promise((resolve) => { resolveSession = resolve }), + ) + + const queryClient = createQueryClient() + const wrapper = createWrapper(queryClient) + const { unmount } = renderHook(() => useChildSessionReconciliation('child-1'), { wrapper }) + + await waitFor(() => expect(mocks.getSession).toHaveBeenCalledTimes(1)) + + act(() => { + useSessionStatus.getState().setStatus('child-1', { type: 'busy' }) + }) + expect(useSessionStatus.getState().statusRevisions.size).toBeGreaterThan(0) + + unmount() + + await act(async () => { + resolveSession?.(idleSession()) + }) + + await waitFor(() => expect(useSessionStatus.getState().statusRevisions.size).toBe(0)) + }) + + it('clears a stale child outcome when the fresh snapshot is busy', async () => { + mocks.getSession.mockResolvedValue(sessionInfo('child-1', '/repo')) + useSessionStatus.getState().setStatus('child-1', { type: 'busy' }) + useSessionStatus.getState().setOutcome('child-1', 'failed') + + const queryClient = createQueryClient() + renderHook(() => useChildSessionReconciliation('child-1'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => { + expect(useSessionStatus.getState().statuses.get('child-1')).toEqual({ type: 'busy' }) + }) + expect(useSessionStatus.getState().outcomes.get('child-1')).toBeUndefined() + }) + + it('does not reconcile a child whose lifecycle is already terminal', async () => { + useSessionStatus.getState().setStatus('child-1', { type: 'idle' }) + + const queryClient = createQueryClient() + renderHook(() => useChildSessionReconciliation('child-1'), { + wrapper: createWrapper(queryClient), + }) + + await act(async () => { + await Promise.resolve() + }) + + expect(mocks.getSession).not.toHaveBeenCalled() + expect(queryClient.getQueryState(['opencode', 'session-reconcile', 'child-1'])?.fetchStatus).not.toBe('fetching') + }) +}) diff --git a/frontend/src/hooks/useOpenCode.ts b/frontend/src/hooks/useOpenCode.ts index d53f6d46c..a4f189b6a 100644 --- a/frontend/src/hooks/useOpenCode.ts +++ b/frontend/src/hooks/useOpenCode.ts @@ -23,8 +23,9 @@ import type { ModelRef, SessionInfo } from "@opencode-manager/shared/opencode"; import { parseNetworkError, isGatewayTimeout } from "../lib/opencode-errors"; import { showToast } from "../lib/toast"; import { useSendErrorStore } from "../stores/sendErrorStore"; -import { useSessionStatus } from "../stores/sessionStatusStore"; -import { invalidateSessionListCaches, sessionTranscriptQueryKey } from "../lib/queryInvalidation"; +import { useSessionStatus, useChildLifecycleForSession } from "../stores/sessionStatusStore"; +import { isRunningLifecycle } from "../lib/backgroundWork"; +import { childSessionReconciliationQueryKey, invalidateSessionListCaches, sessionTranscriptQueryKey } from "../lib/queryInvalidation"; import { buildPinnedSessionKeys, buildSessionKey } from "../lib/sessionKey"; import { toggleSessionPin } from "../api/sessionPins"; import { SESSION_PINS_QUERY_KEY } from "./useSessionPins"; @@ -147,6 +148,27 @@ export const useSession = (sessionID: string | undefined, directory?: string) => return useQuery(sessionQueryOptions(sessionID, directory)); }; +export const useChildSessionReconciliation = (sessionID: string | undefined) => { + const lifecycle = useChildLifecycleForSession(sessionID) + useQuery({ + queryKey: childSessionReconciliationQueryKey(sessionID), + queryFn: async () => { + const token = useSessionStatus.getState().beginStatusSnapshot(); + try { + const session = await getSession(sessionID!); + useSessionStatus.getState().applySessionSnapshot(sessionID!, session, token); + return session; + } finally { + useSessionStatus.getState().endStatusSnapshot(token); + } + }, + enabled: !!sessionID && isRunningLifecycle(lifecycle), + staleTime: 5_000, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); +}; + export const useCreateSession = ( directory?: string, onSuccess?: (session: { id: string }) => void, diff --git a/frontend/src/hooks/useSSE.test.tsx b/frontend/src/hooks/useSSE.test.tsx index eda0012af..d014d512a 100644 --- a/frontend/src/hooks/useSSE.test.tsx +++ b/frontend/src/hooks/useSSE.test.tsx @@ -3,6 +3,8 @@ import { act, renderHook, waitFor } from '@testing-library/react' import type { ReactNode } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useSSE } from './useSSE' +import { useKillShell } from './useSessionShells' +import { clearShellExitRecord } from '@/lib/backgroundWork' import { useSessionStatus } from '../stores/sessionStatusStore' import { useSendErrorStore } from '../stores/sendErrorStore' import { showToast } from '@/lib/toast' @@ -17,6 +19,16 @@ vi.mock('@/lib/toast', () => ({ }, })) +const shellApi = vi.hoisted(() => ({ + listShells: vi.fn(), + removeShell: vi.fn(), +})) + +vi.mock('@/api/opencode', async (importOriginal) => ({ + ...(await importOriginal()), + ...shellApi, +})) + class MockEventSource { static instances: MockEventSource[] = [] @@ -53,14 +65,16 @@ describe('useSSE', () => { beforeEach(() => { vi.clearAllMocks() MockEventSource.instances = [] - useSessionStatus.setState({ statuses: new Map(), statusCache: new Map(), statusRevisions: new Map(), revision: 0 }) + shellApi.listShells.mockResolvedValue([]) + shellApi.removeShell.mockResolvedValue(undefined) + useSessionStatus.setState({ statuses: new Map(), statusCache: new Map(), statusRevisions: new Map(), knownSessions: new Set(), outcomes: new Map(), revision: 0 }) useSendErrorStore.setState({ errors: {} }) globalThis.EventSource = MockEventSource as unknown as typeof EventSource globalThis.fetch = vi.fn(() => Promise.resolve({ ok: true } as Response)) }) afterEach(() => { - useSessionStatus.setState({ statuses: new Map(), statusCache: new Map(), statusRevisions: new Map(), revision: 0 }) + useSessionStatus.setState({ statuses: new Map(), statusCache: new Map(), statusRevisions: new Map(), knownSessions: new Set(), outcomes: new Map(), revision: 0 }) useSendErrorStore.setState({ errors: {} }) globalThis.EventSource = originalEventSource globalThis.fetch = originalFetch @@ -119,6 +133,8 @@ describe('useSSE', () => { expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['opencode', 'pending-actions', 'session-1', '/repo'], }) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['opencode', 'shells'] }) + expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: ['opencode', 'session-reconcile'] }) }) unmount() @@ -357,11 +373,12 @@ describe('useSSE', () => { queryKey: ['opencode', 'pending-actions', 'session-1', '/repo'], }) expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['opencode', 'shells'] }) + expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: ['opencode', 'session-reconcile'] }) unmount() }) - it('tracks background shells from shell lifecycle events', async () => { + it('tracks background shells from shell lifecycle events without dropping completed shells', async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -401,20 +418,193 @@ describe('useSSE', () => { }) }) - expect(queryClient.getQueryData>(['opencode', 'shells', '/repo'])?.map((shell) => shell.id)) - .toEqual(['shell-2']) + const shells = queryClient.getQueryData>( + ['opencode', 'shells', '/repo'], + ) + expect(shells?.map((shell) => shell.id)).toEqual(['shell-1', 'shell-2']) + expect(shells?.[0]?.status).toBe('exited') + expect(shells?.[0]?.time.completed).toBeDefined() + + act(() => { + MockEventSource.instances[0].emit('message', { + type: 'shell.deleted', + directory: '/repo', + data: { id: 'shell-1' }, + }) + }) + + const afterDelete = queryClient.getQueryData>(['opencode', 'shells', '/repo']) + expect(afterDelete?.map((shell) => shell.id)).toEqual(['shell-1', 'shell-2']) + expect(afterDelete?.[0]?.status).toBe('exited') + + unmount() + }) + + it('keeps a deleted running shell visible as unavailable', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + queryClient.setQueryData(['opencode', 'shells', '/repo'], [ + { + id: 'shell-1', + status: 'running', + command: 'sleep 1', + cwd: '/repo', + shell: 'zsh', + file: '/tmp/shell-1.log', + metadata: { sessionID: 'session-1' }, + time: { started: 1 }, + }, + ]) + + const { result, unmount } = renderHook( + () => useSSE('/repo', 'session-1'), + { wrapper: createWrapper(queryClient) } + ) + + await waitFor(() => expect(MockEventSource.instances).toHaveLength(1)) + await connect(0, 'client-1') + await waitFor(() => expect(result.current.isConnected).toBe(true)) act(() => { MockEventSource.instances[0].emit('message', { type: 'shell.deleted', directory: '/repo', - data: { id: 'shell-2' }, + data: { id: 'shell-1' }, + }) + }) + + expect( + queryClient.getQueryData>(['opencode', 'shells', '/repo'])?.[0], + ).toMatchObject({ id: 'shell-1', status: 'unavailable' }) + + unmount() + }) + + it('does not discard a shell exit that arrives before the cache is seeded', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') + + const { result, unmount } = renderHook( + () => useSSE('/repo', 'session-1'), + { wrapper: createWrapper(queryClient) } + ) + + await waitFor(() => expect(MockEventSource.instances).toHaveLength(1)) + await connect(0, 'client-1') + await waitFor(() => expect(result.current.isConnected).toBe(true)) + invalidateQueries.mockClear() + + act(() => { + MockEventSource.instances[0].emit('message', { + type: 'shell.exited', + directory: '/repo', + data: { id: 'shell-1', exit: 0, status: 'exited' }, + }) + }) + + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['opencode', 'shells', '/repo'] }) + + unmount() + }) + + it('does not resurrect a completed shell from a stale created event', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + const shell = (status: string) => ({ + id: 'shell-1', + status, + command: 'sleep 1', + cwd: '/repo', + shell: 'zsh', + file: '/tmp/shell-1.log', + metadata: { sessionID: 'session-1' }, + time: { started: 1 }, + }) + queryClient.setQueryData(['opencode', 'shells', '/repo'], [ + { ...shell('exited'), exit: 0, time: { started: 1, completed: 2 } }, + ]) + + const { result, unmount } = renderHook( + () => useSSE('/repo', 'session-1'), + { wrapper: createWrapper(queryClient) } + ) + + await waitFor(() => expect(MockEventSource.instances).toHaveLength(1)) + await connect(0, 'client-1') + await waitFor(() => expect(result.current.isConnected).toBe(true)) + + act(() => { + MockEventSource.instances[0].emit('message', { + type: 'shell.created', + directory: '/repo', + data: { info: shell('running') }, + }) + }) + + expect( + queryClient.getQueryData>(['opencode', 'shells', '/repo'])?.[0]?.status, + ).toBe('exited') + + unmount() + }) + + it('keeps a killed shell killed after a later shell.exited event', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + const key = ['opencode', 'shells', '/repo'] + queryClient.setQueryData(key, [ + { + id: 'shell-1', + status: 'running', + command: 'sleep 1', + cwd: '/repo', + shell: 'zsh', + file: '/tmp/shell-1.log', + metadata: { sessionID: 'session-1' }, + time: { started: 1 }, + }, + ]) + + const { result, unmount } = renderHook( + () => ({ sse: useSSE('/repo', 'session-1'), kill: useKillShell('/repo') }), + { wrapper: createWrapper(queryClient) }, + ) + + await waitFor(() => expect(MockEventSource.instances).toHaveLength(1)) + await connect(0, 'client-1') + await waitFor(() => expect(result.current.sse.isConnected).toBe(true)) + + await act(async () => { + await result.current.kill.mutateAsync('shell-1') + }) + + expect(queryClient.getQueryData>(key)?.[0]?.status).toBe('killed') + + act(() => { + MockEventSource.instances[0].emit('message', { + type: 'shell.exited', + directory: '/repo', + data: { id: 'shell-1', status: 'exited' }, }) }) - expect(queryClient.getQueryData(['opencode', 'shells', '/repo'])).toEqual([]) + expect(queryClient.getQueryData>(key)?.[0]?.status).toBe('killed') unmount() + clearShellExitRecord('/repo', 'shell-1') }) it('does not write session status from stream events', async () => { diff --git a/frontend/src/hooks/useSSE.ts b/frontend/src/hooks/useSSE.ts index 2b4002fe8..5d9f18d88 100644 --- a/frontend/src/hooks/useSSE.ts +++ b/frontend/src/hooks/useSSE.ts @@ -1,12 +1,19 @@ import { useEffect, useRef, useState, useCallback, useMemo } from 'react' import { useQueryClient } from '@tanstack/react-query' import type { SessionInfo, V2Event } from '@opencode-manager/shared/opencode' -import { invalidateSessionListCaches, shellsQueryKey } from '@/lib/queryInvalidation' +import { invalidateSessionListCaches, invalidateShellCaches, shellsQueryKey } from '@/lib/queryInvalidation' +import { + applyShellExit, + markShellDeleted, + recordShellDeleted, + recordShellExit, + upsertShell, + type ShellRecord, +} from '@/lib/backgroundWork' import { showToast } from '@/lib/toast' import { useSendErrorStore } from '@/stores/sendErrorStore' import { openCodeEventStream } from '@/lib/opencode-event-stream' import type { EventStreamSubscription } from '@/lib/opencode-event-stream' -import type { ShellInfo } from '@/api/opencode' type V2StreamEvent = V2Event & { directory?: string } @@ -109,18 +116,34 @@ export const useSSE = (directory?: string | string[], currentSessionId?: string) case 'shell.created': { const info = event.data.info - queryClient.setQueryData(shellsQueryKey(cacheDirectory), (current) => - current ? [...current.filter((shell) => shell.id !== info.id), info] : current, - ) + const key = shellsQueryKey(cacheDirectory) + const current = queryClient.getQueryData(key) + if (!current) { + queryClient.invalidateQueries({ queryKey: key }) + break + } + queryClient.setQueryData(key, (shells) => upsertShell(shells ?? [], info, cacheDirectory ?? '')) + break + } + + case 'shell.exited': { + recordShellExit(cacheDirectory ?? '', event.data) + const key = shellsQueryKey(cacheDirectory) + const current = queryClient.getQueryData(key) + if (!current || !current.some((shell) => shell.id === event.data.id)) { + queryClient.invalidateQueries({ queryKey: key }) + break + } + queryClient.setQueryData(key, (shells) => applyShellExit(shells ?? [], event.data)) break } - case 'shell.exited': case 'shell.deleted': { - const id = event.data.id - queryClient.setQueryData(shellsQueryKey(cacheDirectory), (current) => - current?.filter((shell) => shell.id !== id), - ) + recordShellDeleted(cacheDirectory ?? '', event.data.id) + const key = shellsQueryKey(cacheDirectory) + const current = queryClient.getQueryData(key) + if (!current || !current.some((shell) => shell.id === event.data.id)) break + queryClient.setQueryData(key, (shells) => markShellDeleted(shells ?? [], event.data.id)) break } @@ -178,6 +201,7 @@ export const useSSE = (directory?: string | string[], currentSessionId?: string) if (connected) { setError(null) syncCurrentSession() + invalidateShellCaches(queryClient) eventStreamSubscriptionRef.current?.reportVisibility(document.visibilityState === 'visible', sessionIdRef.current) } else { setError('Connection lost. Reconnecting...') @@ -187,7 +211,7 @@ export const useSSE = (directory?: string | string[], currentSessionId?: string) const handleResync = () => { if (!mountedRef.current) return invalidateSessionListCaches(queryClient) - queryClient.invalidateQueries({ queryKey: ['opencode', 'shells'] }) + invalidateShellCaches(queryClient) refreshCurrentSession() } diff --git a/frontend/src/hooks/useSessionShells.test.tsx b/frontend/src/hooks/useSessionShells.test.tsx new file mode 100644 index 000000000..ec384b3d6 --- /dev/null +++ b/frontend/src/hooks/useSessionShells.test.tsx @@ -0,0 +1,161 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useKillShell, useShell } from './useSessionShells' +import { applyShellExit, clearShellExitRecord, recordShellExit, type ShellRecord } from '@/lib/backgroundWork' + +const api = vi.hoisted(() => ({ + listShells: vi.fn(), + removeShell: vi.fn(), +})) + +vi.mock('@/api/opencode', async (importOriginal) => ({ + ...(await importOriginal()), + ...api, +})) + +vi.mock('@/lib/toast', () => ({ + showToast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), loading: vi.fn() }, +})) + +const shell = (id: string, sessionID = 'session-1'): ShellRecord => ({ + id, + status: 'running', + command: `npm run ${id}`, + cwd: '/repo', + shell: 'zsh', + file: `/tmp/${id}.log`, + metadata: { sessionID }, + time: { started: 1 }, +}) + +const createWrapper = (queryClient: QueryClient) => { + return ({ children }: { children: ReactNode }) => ( + {children} + ) +} + +const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } }) + +describe('useKillShell', () => { + beforeEach(() => { + vi.clearAllMocks() + api.listShells.mockResolvedValue([]) + api.removeShell.mockResolvedValue(undefined) + }) + + it('marks a killed shell terminal without erasing it', async () => { + const queryClient = createQueryClient() + queryClient.setQueryData(['opencode', 'shells', '/repo'], [shell('dev')]) + + const { result } = renderHook(() => useKillShell('/repo'), { + wrapper: createWrapper(queryClient), + }) + + await act(async () => { + await result.current.mutateAsync('dev') + }) + + const [updated] = queryClient.getQueryData(['opencode', 'shells', '/repo']) ?? [] + expect(updated?.status).toBe('killed') + expect(updated?.time.completed).toBeDefined() + clearShellExitRecord('/repo', 'dev') + }) + + it('records a killed shell so a later refetch keeps it killed', async () => { + const queryClient = createQueryClient() + const key = ['opencode', 'shells', '/repo'] + queryClient.setQueryData(key, [shell('dev')]) + + const { result } = renderHook( + () => ({ kill: useKillShell('/repo'), shell: useShell('dev', '/repo') }), + { wrapper: createWrapper(queryClient) }, + ) + + await act(async () => { + await result.current.kill.mutateAsync('dev') + }) + + queryClient.setQueryData(key, []) + api.listShells.mockResolvedValue([shell('dev')]) + + await act(async () => { + await queryClient.invalidateQueries({ queryKey: key }) + }) + + const [refetched] = queryClient.getQueryData(key) ?? [] + expect(refetched?.status).toBe('killed') + clearShellExitRecord('/repo', 'dev') + }) + + it('keeps a killed shell killed through a later exit event', async () => { + const queryClient = createQueryClient() + const key = ['opencode', 'shells', '/repo'] + queryClient.setQueryData(key, [shell('dev')]) + + const { result } = renderHook(() => useKillShell('/repo'), { + wrapper: createWrapper(queryClient), + }) + + await act(async () => { + await result.current.mutateAsync('dev') + }) + + await act(async () => { + recordShellExit('/repo', { id: 'dev', status: 'exited', exit: 0 }) + queryClient.setQueryData(key, (current) => + current ? applyShellExit(current, { id: 'dev', status: 'exited', exit: 0 }) : current, + ) + }) + + const [after] = queryClient.getQueryData(key) ?? [] + expect(after?.status).toBe('killed') + clearShellExitRecord('/repo', 'dev') + }) +}) + +describe('useShell', () => { + beforeEach(() => { + vi.clearAllMocks() + api.listShells.mockResolvedValue([]) + api.removeShell.mockResolvedValue(undefined) + }) + + it('returns only the matching shell record once the list loads', async () => { + const queryClient = createQueryClient() + api.listShells.mockResolvedValue([shell('dev'), shell('other')]) + + const { result } = renderHook(() => useShell('dev', '/repo'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => expect(result.current.shell?.id).toBe('dev')) + expect(result.current.listLoaded).toBe(true) + }) + + it('does not re-render when an unrelated shell record changes', async () => { + const queryClient = createQueryClient() + const dev = shell('dev') + const other = shell('other') + api.listShells.mockResolvedValue([dev, other]) + + let renderCount = 0 + const { result } = renderHook( + () => { + renderCount += 1 + return useShell('dev', '/repo') + }, + { wrapper: createWrapper(queryClient) }, + ) + + await waitFor(() => expect(result.current.shell).toBe(dev)) + const rendersAfterLoad = renderCount + + await act(async () => { + queryClient.setQueryData(['opencode', 'shells', '/repo'], [dev, { ...other, command: 'changed' }]) + }) + + expect(renderCount).toBe(rendersAfterLoad) + }) +}) diff --git a/frontend/src/hooks/useSessionShells.ts b/frontend/src/hooks/useSessionShells.ts index 0be57f441..a5da4979f 100644 --- a/frontend/src/hooks/useSessionShells.ts +++ b/frontend/src/hooks/useSessionShells.ts @@ -1,26 +1,54 @@ import { useMemo } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { listShells, removeShell, type ShellInfo } from '@/api/opencode' +import { listShells, removeShell } from '@/api/opencode' +import { applyShellExit, recordShellExit, reconcileShellList, type ShellRecord } from '@/lib/backgroundWork' import { shellsQueryKey } from '@/lib/queryInvalidation' import { showToast } from '@/lib/toast' -export function useSessionShells(sessionID: string | undefined, directory: string | undefined) { - const query = useQuery({ +function useShellListQuery( + directory: string | undefined, + enabled: boolean, + select?: (shells: ShellRecord[]) => TData, +) { + const queryClient = useQueryClient() + + return useQuery({ queryKey: shellsQueryKey(directory), - queryFn: () => listShells(directory ?? ''), - enabled: Boolean(directory), + queryFn: async (): Promise => { + const fetchStartedAt = Date.now() + const fetched = await listShells(directory ?? '') + const existing = queryClient.getQueryData(shellsQueryKey(directory)) ?? [] + return reconcileShellList(existing, fetched, fetchStartedAt, directory ?? '') + }, + enabled, staleTime: Infinity, + select, }) +} + +export function useSessionShells(sessionID: string | undefined, directory: string | undefined) { + const query = useShellListQuery(directory, Boolean(directory)) const shells = useMemo( - () => - (query.data ?? []).filter( - (shell) => shell.status === 'running' && shell.metadata.sessionID === sessionID, - ), + () => (query.data ?? []).filter((shell) => shell.metadata.sessionID === sessionID), [query.data, sessionID], ) - return shells + const listLoaded = query.isSuccess + + return { shells, listLoaded } +} + +export function useShell(shellID: string | undefined, directory: string | undefined) { + const query = useShellListQuery( + directory, + Boolean(directory && shellID), + (shells) => shells.find((candidate) => candidate.id === shellID), + ) + + const listLoaded = query.isSuccess + + return { shell: query.data, listLoaded } } export function useKillShell(directory: string | undefined) { @@ -29,8 +57,9 @@ export function useKillShell(directory: string | undefined) { return useMutation({ mutationFn: (id: string) => removeShell(id, directory ?? ''), onSuccess: (_result, id) => { - queryClient.setQueryData(shellsQueryKey(directory), (current) => - current?.filter((shell) => shell.id !== id), + recordShellExit(directory ?? '', { id, status: 'killed' }) + queryClient.setQueryData(shellsQueryKey(directory), (current) => + current && applyShellExit(current, { id, status: 'killed' }), ) }, onError: (error) => { diff --git a/frontend/src/lib/backgroundWork.test.ts b/frontend/src/lib/backgroundWork.test.ts new file mode 100644 index 000000000..5b7bdf2ea --- /dev/null +++ b/frontend/src/lib/backgroundWork.test.ts @@ -0,0 +1,386 @@ +import { describe, expect, it } from 'vitest' +import type { SessionMessageInfo } from '@opencode-manager/shared/opencode' +import { + applyShellExit, + childLifecycle, + clearShellExitRecord, + collectBackgroundParts, + isRunningLifecycle, + markShellDeleted, + reconcileShellList, + recordShellDeleted, + recordShellExit, + shellBackgroundTasks, + shellLifecycle, + shellToolLifecycle, + subagentLifecycle, + upsertShell, + type ShellRecord, +} from './backgroundWork' + +const shellNotice = ( + id: string, + shellID: string, + state: 'completed' | 'error', + exit?: number, +): SessionMessageInfo => ({ + id, + type: 'synthetic', + text: '', + metadata: { source: 'shell', shellID, state, ...(exit === undefined ? {} : { exit }) }, + time: { created: 1 }, +}) + +const shell = (id: string, status: ShellRecord['status'], exit?: number, started = 1): ShellRecord => ({ + id, + status, + command: `npm run ${id}`, + cwd: '/repo', + shell: 'zsh', + file: `/tmp/${id}.log`, + metadata: { sessionID: 'session-1' }, + time: { started, ...(exit === undefined ? {} : { completed: started + 1 }) }, + ...(exit === undefined ? {} : { exit }), +}) + +describe('shellLifecycle', () => { + it('maps every shell status to a lifecycle state', () => { + expect(shellLifecycle(shell('a', 'running'))).toBe('running') + expect(shellLifecycle(shell('b', 'exited', 0))).toBe('completed') + expect(shellLifecycle(shell('c', 'exited', 1))).toBe('failed') + expect(shellLifecycle(shell('d', 'timeout'))).toBe('failed') + expect(shellLifecycle(shell('e', 'killed'))).toBe('killed') + expect(shellLifecycle(shell('f', 'unavailable'))).toBe('unavailable') + }) + + it('treats an exited shell without an exit code as completed', () => { + expect(shellLifecycle(shell('a', 'exited'))).toBe('completed') + }) + + it('never treats a missing shell as completed', () => { + expect(shellLifecycle(undefined)).toBe('unknown') + expect(isRunningLifecycle(shellLifecycle(undefined))).toBe(true) + }) +}) + +describe('childLifecycle', () => { + it('never treats an unknown child as finished', () => { + expect(childLifecycle({ type: 'idle' }, false, undefined)).toBe('unknown') + }) + + it('maps a known child status to a lifecycle state', () => { + expect(childLifecycle({ type: 'idle' }, true, undefined)).toBe('completed') + expect(childLifecycle({ type: 'idle' }, true, 'succeeded')).toBe('completed') + expect(childLifecycle({ type: 'idle' }, true, 'failed')).toBe('failed') + expect(childLifecycle({ type: 'idle' }, true, 'interrupted')).toBe('interrupted') + expect(childLifecycle({ type: 'busy' }, true, undefined)).toBe('running') + expect(childLifecycle({ type: 'compact' }, true, undefined)).toBe('running') + expect(childLifecycle({ type: 'retry', attempt: 1, message: 'retry', next: 2 }, true, undefined)).toBe('running') + }) +}) + +describe('subagentLifecycle', () => { + it('fails on a tool error', () => { + expect(subagentLifecycle('error', false, 'completed')).toBe('failed') + }) + + it('keeps an unknown backgrounded child running', () => { + expect(subagentLifecycle('completed', true, 'unknown')).toBe('unknown') + }) + + it('completes a backgrounded child once its status is known idle', () => { + expect(subagentLifecycle('completed', true, 'completed')).toBe('completed') + }) + + it('reports a failed backgrounded child as failed', () => { + expect(subagentLifecycle('completed', true, 'failed')).toBe('failed') + }) + + it('reports an interrupted backgrounded child as interrupted', () => { + expect(subagentLifecycle('completed', true, 'interrupted')).toBe('interrupted') + }) + + it('completes a foreground subagent whose child is known idle', () => { + expect(subagentLifecycle('running', false, 'completed')).toBe('completed') + }) + + it('keeps a foreground subagent running while its child status is unknown', () => { + expect(subagentLifecycle('running', false, 'unknown')).toBe('running') + }) +}) + +describe('shellToolLifecycle', () => { + it('reports a non-background shell call as completed', () => { + expect(shellToolLifecycle(undefined, undefined, true)).toBe('completed') + }) + + it('reports a background shell from its live status', () => { + expect(shellToolLifecycle('sh_1', shell('sh_1', 'killed'), true)).toBe('killed') + }) + + it('keeps a shell unknown until the list loads, then unavailable when absent', () => { + expect(shellToolLifecycle('sh_1', undefined, false)).toBe('unknown') + expect(shellToolLifecycle('sh_1', undefined, true)).toBe('unavailable') + }) + + it('falls back to the transcript notice outcome when the shell is absent', () => { + expect(shellToolLifecycle('sh_1', undefined, true, 'completed')).toBe('completed') + expect(shellToolLifecycle('sh_1', undefined, true, 'failed')).toBe('failed') + }) + + it('prefers the live shell record over the transcript notice outcome', () => { + expect(shellToolLifecycle('sh_1', shell('sh_1', 'running'), true, 'failed')).toBe('running') + }) +}) + +describe('collectBackgroundParts', () => { + it('collects shell completion notices by shell ID', () => { + const collected = collectBackgroundParts([ + shellNotice('syn-1', 'sh_1', 'completed', 0), + shellNotice('syn-2', 'sh_2', 'error'), + shellNotice('syn-3', 'sh_3', 'completed'), + ]) + + expect(collected.shellNotices.get('sh_1')).toBe('completed') + expect(collected.shellNotices.get('sh_2')).toBe('failed') + expect(collected.shellNotices.get('sh_3')).toBe('completed') + }) + + it('ignores notices without a shell ID', () => { + const collected = collectBackgroundParts([ + { + id: 'syn-1', + type: 'synthetic', + text: '', + metadata: { source: 'shell', state: 'completed' }, + time: { created: 1 }, + }, + ]) + + expect(collected.shellNotices.size).toBe(0) + }) +}) + +describe('shellBackgroundTasks', () => { + it('uses a transcript notice when the shell has no live record', () => { + const tasks = shellBackgroundTasks( + [], + [{ id: 'sh_1', label: 'npm run dev' }], + new Map([['sh_1', 'completed']]), + true, + ) + + expect(tasks[0]?.status).toBe('completed') + }) + + it('uses a failed transcript notice when the shell has no live record', () => { + const tasks = shellBackgroundTasks( + [], + [{ id: 'sh_1', label: 'npm run dev' }], + new Map([['sh_1', 'failed']]), + true, + ) + + expect(tasks[0]?.status).toBe('failed') + }) +}) + +describe('reconcileShellList', () => { + it('preserves a terminal shell when a stale list reports it running', () => { + const cached = shell('a', 'exited', 0) + const fetched = shell('a', 'running') + + expect(reconcileShellList([cached], [fetched], 100, '/repo')).toEqual([cached]) + }) + + it('accepts a terminal status from the list', () => { + const fetched = shell('a', 'exited', 0) + + expect(reconcileShellList([shell('a', 'running')], [fetched], 100, '/repo')).toEqual([fetched]) + }) + + it('preserves a running shell created after a stale fetch started', () => { + const cached = shell('a', 'running', undefined, 200) + + expect(reconcileShellList([cached], [], 100, '/repo')).toEqual([cached]) + }) + + it('preserves terminal history omitted from a fresh fetch', () => { + const cached = shell('a', 'exited', 0, 50) + + expect(reconcileShellList([cached], [], 100, '/repo')).toEqual([cached]) + }) + + it('marks a running shell omitted from a fresh fetch as unavailable', () => { + const cached = shell('a', 'running', undefined, 50) + + const [result] = reconcileShellList([cached], [], 100, '/repo') + + expect(result?.status).toBe('unavailable') + expect(result?.time.completed).toBeDefined() + }) + + it('applies a shell exit recorded before the list was seeded', () => { + recordShellExit('/repo', { id: 'a', status: 'exited', exit: 0 }) + + const [result] = reconcileShellList([], [shell('a', 'running')], 100, '/repo') + + expect(result?.status).toBe('exited') + expect(result?.exit).toBe(0) + + clearShellExitRecord('/repo', 'a') + }) + + it('applies a shell deletion recorded before the list was seeded', () => { + recordShellDeleted('/repo', 'a') + + const [result] = reconcileShellList([], [shell('a', 'running')], 100, '/repo') + + expect(result?.status).toBe('unavailable') + + clearShellExitRecord('/repo', 'a') + }) + + it('does not apply a shell exit recorded for another directory', () => { + recordShellExit('/other', { id: 'a', status: 'exited', exit: 0 }) + + const [result] = reconcileShellList([], [shell('a', 'running')], 100, '/repo') + + expect(result?.status).toBe('running') + + clearShellExitRecord('/other', 'a') + }) + + it('prunes a shell exit record once a later fetch confirms the terminal status', () => { + recordShellExit('/repo', { id: 'a', status: 'exited', exit: 0 }) + + const [result] = reconcileShellList([], [shell('a', 'exited', 0)], Date.now() + 1000, '/repo') + + expect(result?.status).toBe('exited') + + const [afterPrune] = upsertShell([], shell('a', 'running'), '/repo') + expect(afterPrune?.status).toBe('running') + }) + + it('keeps a shell exit record when the fetch started before it was written', () => { + const fetchStartedAt = Date.now() - 1000 + recordShellExit('/repo', { id: 'a', status: 'exited', exit: 0 }) + + const [result] = reconcileShellList([], [shell('a', 'exited', 0)], fetchStartedAt, '/repo') + + expect(result?.status).toBe('exited') + + const [afterStaleFetch] = upsertShell([], shell('a', 'running'), '/repo') + expect(afterStaleFetch?.status).toBe('exited') + + clearShellExitRecord('/repo', 'a') + }) + + it('prunes a shell exit record once a later fetch omits a terminal shell', () => { + recordShellExit('/repo', { id: 'a', status: 'exited', exit: 0 }) + const cached = shell('a', 'exited', 0, 50) + + const [result] = reconcileShellList([cached], [], Date.now() + 1000, '/repo') + + expect(result?.status).toBe('exited') + + const [afterPrune] = upsertShell([], shell('a', 'running'), '/repo') + expect(afterPrune?.status).toBe('running') + }) + + it('prunes a shell exit record once a later fetch omits the shell it marked', () => { + recordShellExit('/repo', { id: 'a', status: 'exited', exit: 0 }) + + const [result] = reconcileShellList([shell('a', 'running', undefined, 50)], [], Date.now() + 1000, '/repo') + + expect(result?.status).toBe('exited') + expect(result?.exit).toBe(0) + + const [afterPrune] = upsertShell([], shell('a', 'running'), '/repo') + expect(afterPrune?.status).toBe('running') + }) +}) + +describe('upsertShell', () => { + it('does not downgrade a terminal shell to running', () => { + const cached = shell('a', 'exited', 0) + + expect(upsertShell([cached], shell('a', 'running'), '/repo')).toEqual([cached]) + }) + + it('does not downgrade an unavailable shell to running', () => { + const cached = shell('a', 'unavailable') + + expect(upsertShell([cached], shell('a', 'running'), '/repo')).toEqual([cached]) + }) + + it('does not replace a terminal shell status with a different status', () => { + const cached = shell('a', 'killed') + + expect(upsertShell([cached], shell('a', 'exited', 0), '/repo')).toEqual([cached]) + }) + + it('does not resurrect a shell deleted before the cache was seeded', () => { + recordShellDeleted('/repo', 'a') + + const [result] = upsertShell([], shell('a', 'running'), '/repo') + + expect(result?.status).toBe('unavailable') + + clearShellExitRecord('/repo', 'a') + }) + + it('replaces a running shell with the latest info', () => { + const next = shell('a', 'exited', 0) + + expect(upsertShell([shell('a', 'running')], next, '/repo')).toEqual([next]) + }) + + it('appends a newly created shell', () => { + expect(upsertShell([shell('a', 'running')], shell('b', 'running'), '/repo').map((item) => item.id)).toEqual(['a', 'b']) + }) +}) + +describe('applyShellExit', () => { + it('marks a matching shell terminal and records its completion', () => { + const [result] = applyShellExit([shell('a', 'running')], { id: 'a', status: 'exited', exit: 0 }) + + expect(result?.status).toBe('exited') + expect(result?.exit).toBe(0) + expect(result?.time.completed).toBeDefined() + }) + + it('does not turn an unavailable shell back into running', () => { + const [result] = applyShellExit([shell('a', 'unavailable')], { id: 'a', status: 'running' }) + + expect(result?.status).toBe('unavailable') + }) + + it('does not replace a killed shell with a later exit event', () => { + const [result] = applyShellExit([shell('a', 'killed')], { id: 'a', status: 'exited', exit: 0 }) + + expect(result?.status).toBe('killed') + }) + + it('leaves unrelated shells untouched', () => { + const other = shell('b', 'running') + const [result] = applyShellExit([other], { id: 'a', status: 'killed' }) + + expect(result).toBe(other) + }) +}) + +describe('markShellDeleted', () => { + it('keeps the shell visible as unavailable instead of erasing it', () => { + const [result] = markShellDeleted([shell('a', 'running')], 'a') + + expect(result?.id).toBe('a') + expect(result?.status).toBe('unavailable') + }) + + it('does not reset a terminal shell to running', () => { + const [result] = markShellDeleted([shell('a', 'exited', 0)], 'a') + + expect(result?.status).toBe('exited') + }) +}) diff --git a/frontend/src/lib/backgroundWork.ts b/frontend/src/lib/backgroundWork.ts new file mode 100644 index 000000000..d07aedd86 --- /dev/null +++ b/frontend/src/lib/backgroundWork.ts @@ -0,0 +1,380 @@ +import { unwrapSandboxExecCommand } from '@opencode-manager/shared/utils' +import type { SessionMessageAssistantTool, SessionMessageInfo } from '@opencode-manager/shared/opencode' +import type { ShellInfo } from '@/api/opencode' +import type { SessionStatusType } from '@/stores/sessionStatusStore' + +export const BACKGROUNDABLE_TOOLS = new Set(['shell', 'subagent']) + +export type ChildOutcome = 'succeeded' | 'failed' | 'interrupted' +export type ShellNoticeOutcome = 'completed' | 'failed' +type ShellLifecycleStatus = ShellInfo['status'] | 'unavailable' +export type ShellRecord = Omit & { status: ShellLifecycleStatus } +type BackgroundTaskKind = 'shell' | 'subagent' +type BackgroundTaskStatus = + | 'running' + | 'completed' + | 'failed' + | 'killed' + | 'interrupted' + | 'unavailable' +export type BackgroundTaskLifecycle = BackgroundTaskStatus | 'unknown' + +export interface BackgroundTask { + id: string + kind: BackgroundTaskKind + label: string + status: BackgroundTaskLifecycle + shell?: ShellRecord + childSessionID?: string +} + +interface ShellExitRecord { + status: ShellLifecycleStatus + exit?: number + completedAt: number +} + +const shellExitRecords = new Map() + +const shellExitKey = (directory: string, id: string): string => `${directory}\u0000${id}` + +export function recordShellExit( + directory: string, + exit: { id: string; status: ShellInfo['status']; exit?: number }, +): void { + if (exit.status === 'running') return + shellExitRecords.set(shellExitKey(directory, exit.id), { + status: exit.status, + exit: exit.exit, + completedAt: Date.now(), + }) +} + +export function recordShellDeleted(directory: string, id: string): void { + shellExitRecords.set(shellExitKey(directory, id), { + status: 'unavailable', + completedAt: Date.now(), + }) +} + +export function clearShellExitRecord(directory: string, id: string): void { + shellExitRecords.delete(shellExitKey(directory, id)) +} + +function shellExitRecord(directory: string, id: string): ShellExitRecord | undefined { + return shellExitRecords.get(shellExitKey(directory, id)) +} + +export function toolMetadata(part: SessionMessageAssistantTool): Record { + if (part.state.status === 'streaming') return {} + return part.state.metadata ?? {} +} + +function metadataString(part: SessionMessageAssistantTool, key: string): string | undefined { + const value = toolMetadata(part)[key] + return typeof value === 'string' ? value : undefined +} + +export function subagentSessionID(part: SessionMessageAssistantTool): string | undefined { + return metadataString(part, 'sessionID') +} + +function isBackgroundPart(part: SessionMessageAssistantTool): boolean { + if (part.state.status !== 'completed') return false + const metadata = toolMetadata(part) + return metadata.background === true || metadata.status === 'running' +} + +export function backgroundShellID(part: SessionMessageAssistantTool): string | undefined { + if (part.name !== 'shell' || !isBackgroundPart(part)) return undefined + return metadataString(part, 'shellID') +} + +export function backgroundChildSessionID(part: SessionMessageAssistantTool): string | undefined { + if (part.name !== 'subagent' || !isBackgroundPart(part)) return undefined + return subagentSessionID(part) +} + +function isTerminalShell(shell: { status: ShellLifecycleStatus }): boolean { + return shell.status !== 'running' +} + +export function shellLifecycle(shell: ShellRecord | undefined): BackgroundTaskLifecycle { + if (!shell) return 'unknown' + if (shell.status === 'running') return 'running' + if (shell.status === 'killed') return 'killed' + if (shell.status === 'timeout') return 'failed' + if (shell.status === 'unavailable') return 'unavailable' + return shell.exit !== undefined && shell.exit !== 0 ? 'failed' : 'completed' +} + +export function childLifecycle( + status: SessionStatusType, + known: boolean, + outcome: ChildOutcome | undefined, +): BackgroundTaskLifecycle { + if (!known) return 'unknown' + if (status.type !== 'idle') return 'running' + if (outcome === 'failed') return 'failed' + if (outcome === 'interrupted') return 'interrupted' + return 'completed' +} + +export function subagentLifecycle( + toolStatus: SessionMessageAssistantTool['state']['status'], + background: boolean, + childLifecycle: BackgroundTaskLifecycle, +): BackgroundTaskLifecycle { + if (toolStatus === 'error') return 'failed' + if (background) return childLifecycle + if (toolStatus === 'completed') return 'completed' + if (childLifecycle !== 'unknown' && childLifecycle !== 'running') return childLifecycle + return 'running' +} + +export function shellToolLifecycle( + shellID: string | undefined, + shell: ShellRecord | undefined, + listLoaded: boolean, + notice?: ShellNoticeOutcome, +): BackgroundTaskLifecycle { + if (!shellID) return 'completed' + if (shell) return shellLifecycle(shell) + if (notice) return notice + return listLoaded ? 'unavailable' : 'unknown' +} + +export function isRunningLifecycle(status: BackgroundTaskLifecycle): boolean { + return status === 'running' || status === 'unknown' +} + +export function backgroundTaskStatusColor(status: BackgroundTaskLifecycle): string { + if (status === 'completed') return 'text-success' + if (status === 'failed') return 'text-destructive' + if (status === 'unavailable') return 'text-muted-foreground' + return 'text-warning' +} + +export function lifecycleLabel(status: BackgroundTaskLifecycle): string { + return status === 'unknown' ? 'running' : status +} + +function applyShellExitRecord(shell: ShellRecord, record: ShellExitRecord): ShellRecord { + return { + ...shell, + status: record.status, + exit: record.exit ?? shell.exit, + time: { ...shell.time, completed: shell.time.completed ?? record.completedAt }, + } +} + +function markShellUnavailable(shell: ShellRecord): ShellRecord { + return { + ...shell, + status: 'unavailable', + time: { ...shell.time, completed: shell.time.completed ?? Date.now() }, + } +} + +function clearConfirmedShellExitRecord( + directory: string, + id: string, + record: ShellExitRecord | undefined, + fetchStartedAt: number, +): void { + if (!record || record.completedAt > fetchStartedAt) return + clearShellExitRecord(directory, id) +} + +export function reconcileShellList( + existing: ShellRecord[], + fetched: ShellInfo[], + fetchStartedAt: number, + directory: string, +): ShellRecord[] { + const existingByID = new Map(existing.map((shell) => [shell.id, shell])) + const fetchedByID = new Set(fetched.map((shell) => shell.id)) + const merged: ShellRecord[] = fetched.map((shell) => { + const cached = existingByID.get(shell.id) + if (cached && isTerminalShell(cached) && !isTerminalShell(shell)) return cached + const record = shellExitRecord(directory, shell.id) + if (isTerminalShell(shell)) { + clearConfirmedShellExitRecord(directory, shell.id, record, fetchStartedAt) + return shell + } + return record ? applyShellExitRecord(shell, record) : shell + }) + + for (const shell of existing) { + if (fetchedByID.has(shell.id)) continue + if (shell.time.started > fetchStartedAt) { + merged.push(shell) + continue + } + const record = shellExitRecord(directory, shell.id) + if (isTerminalShell(shell)) { + clearConfirmedShellExitRecord(directory, shell.id, record, fetchStartedAt) + merged.push(shell) + continue + } + if (record) { + merged.push(applyShellExitRecord(shell, record)) + clearConfirmedShellExitRecord(directory, shell.id, record, fetchStartedAt) + continue + } + merged.push(markShellUnavailable(shell)) + } + + return merged.sort((left, right) => left.time.started - right.time.started) +} + +export function upsertShell(current: ShellRecord[], info: ShellInfo, directory: string): ShellRecord[] { + const cached = current.find((shell) => shell.id === info.id) + if (cached && isTerminalShell(cached) && cached.status !== info.status) return current + const record = shellExitRecord(directory, info.id) + const next = record && !isTerminalShell(info) ? applyShellExitRecord(info, record) : info + return [...current.filter((shell) => shell.id !== info.id), next] +} + +export function applyShellExit( + current: ShellRecord[], + exit: { id: string; status: ShellInfo['status']; exit?: number }, +): ShellRecord[] { + return current.map((shell) => { + if (shell.id !== exit.id) return shell + if (isTerminalShell(shell) && shell.status !== exit.status) return shell + const terminal = exit.status !== 'running' + return { + ...shell, + status: exit.status, + exit: exit.exit ?? shell.exit, + time: terminal ? { ...shell.time, completed: shell.time.completed ?? Date.now() } : shell.time, + } + }) +} + +export function markShellDeleted(current: ShellRecord[], id: string): ShellRecord[] { + return current.map((shell) => { + if (shell.id !== id || isTerminalShell(shell)) return shell + return markShellUnavailable(shell) + }) +} + +interface BackgroundShellPart { + id: string + label: string +} + +interface BackgroundSubagentPart { + childSessionID: string + label: string + toolStatus: SessionMessageAssistantTool['state']['status'] +} + +interface CollectedBackgroundParts { + shells: BackgroundShellPart[] + subagents: BackgroundSubagentPart[] + shellNotices: Map +} + +function shellNoticeFromMessage(message: SessionMessageInfo): { shellID: string; outcome: ShellNoticeOutcome } | undefined { + if (message.type !== 'synthetic' && message.type !== 'system') return undefined + const metadata = message.metadata + if (!metadata || metadata.source !== 'shell') return undefined + const shellID = metadata.shellID + if (typeof shellID !== 'string' || !shellID) return undefined + const exit = metadata.exit + const completed = metadata.state === 'completed' && (exit === undefined || exit === 0) + return { shellID, outcome: completed ? 'completed' : 'failed' } +} + +function shellLabel(part: SessionMessageAssistantTool): string { + if (part.state.status === 'streaming') return 'Shell command' + const command = part.state.input.command + if (typeof command !== 'string' || !command) return 'Shell command' + return unwrapSandboxExecCommand(command) +} + +function subagentLabel(part: SessionMessageAssistantTool): string { + if (part.state.status !== 'streaming') { + const description = part.state.input.description + if (typeof description === 'string' && description) return description + } + return 'Sub-agent task' +} + +export function collectBackgroundParts(messages: SessionMessageInfo[]): CollectedBackgroundParts { + const shells = new Map() + const subagents = new Map() + const shellNotices = new Map() + for (const message of messages) { + const notice = shellNoticeFromMessage(message) + if (notice) { + if (!shellNotices.has(notice.shellID)) shellNotices.set(notice.shellID, notice.outcome) + continue + } + if (message.type !== 'assistant') continue + for (const part of message.content) { + if (part.type !== 'tool') continue + if (part.name === 'shell') { + const shellID = backgroundShellID(part) + if (!shellID || shells.has(shellID)) continue + shells.set(shellID, { id: shellID, label: shellLabel(part) }) + continue + } + if (part.name === 'subagent') { + const childSessionID = backgroundChildSessionID(part) + if (!childSessionID) continue + subagents.set(childSessionID, { + childSessionID, + label: subagentLabel(part), + toolStatus: part.state.status, + }) + } + } + } + return { shells: [...shells.values()], subagents: [...subagents.values()], shellNotices } +} + +export function shellBackgroundTasks( + shells: ShellRecord[], + shellParts: BackgroundShellPart[], + notices: ReadonlyMap, + listLoaded: boolean, +): BackgroundTask[] { + const tasks = new Map() + for (const shell of shells) { + tasks.set(shell.id, { + id: shell.id, + kind: 'shell', + label: shell.command, + status: shellToolLifecycle(shell.id, shell, listLoaded, notices.get(shell.id)), + shell, + }) + } + + for (const part of shellParts) { + if (tasks.has(part.id)) continue + tasks.set(part.id, { + id: part.id, + kind: 'shell', + label: part.label, + status: shellToolLifecycle(part.id, undefined, listLoaded, notices.get(part.id)), + }) + } + + return [...tasks.values()] +} + +export function subagentBackgroundTasks( + parts: BackgroundSubagentPart[], + lifecycles: Record, +): BackgroundTask[] { + return parts.map((part) => ({ + id: part.childSessionID, + kind: 'subagent', + label: part.label, + status: subagentLifecycle(part.toolStatus, true, lifecycles[part.childSessionID] ?? 'unknown'), + childSessionID: part.childSessionID, + })) +} diff --git a/frontend/src/lib/queryInvalidation.ts b/frontend/src/lib/queryInvalidation.ts index 179cdf4b4..8ebb8eefe 100644 --- a/frontend/src/lib/queryInvalidation.ts +++ b/frontend/src/lib/queryInvalidation.ts @@ -9,6 +9,24 @@ export function shellsQueryKey(directory: string | null | undefined) { return ['opencode', 'shells', directory] } +export function invalidateShellCaches(queryClient: QueryClient) { + queryClient.invalidateQueries({ queryKey: ['opencode', 'shells'] }) +} + +export function childSessionReconciliationQueryKey(sessionID: string | null | undefined) { + return ['opencode', 'session-reconcile', sessionID] +} + +export function invalidateChildSessionCaches(queryClient: QueryClient, sessionIDs?: string[]) { + if (sessionIDs) { + for (const sessionID of sessionIDs) { + queryClient.invalidateQueries({ queryKey: childSessionReconciliationQueryKey(sessionID) }) + } + return + } + queryClient.invalidateQueries({ queryKey: ['opencode', 'session-reconcile'] }) +} + export function invalidateProviderCaches(queryClient: QueryClient) { queryClient.invalidateQueries({ queryKey: ['provider-credentials'] }) queryClient.invalidateQueries({ queryKey: ['provider-auth-methods'] }) diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 10848087b..a5c62e612 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -635,6 +635,7 @@ export function SessionDetail() { directory={sessionDirectory} messages={messages} isSessionActive={isSessionActive} + onChildSessionClick={handleChildSessionClick} /> { statuses: new Map(), statusCache: new Map(), statusRevisions: new Map(), + knownSessions: new Set(), + outcomes: new Map(), revision: 0, }) } @@ -60,12 +62,172 @@ describe('sessionStatusStore', () => { }) }) - it('leaves the store state untouched for an idle event on an untracked session with no snapshot in flight', () => { + it('records knowledge for an idle event on an untracked session without adding a status', () => { const before = useSessionStatus.getState() before.setStatus('untracked', { type: 'idle' }) - expect(useSessionStatus.getState()).toBe(before) + const after = useSessionStatus.getState() + expect(after.statuses).toBe(before.statuses) + expect(after.getStatus('untracked')).toEqual({ type: 'idle' }) + expect(after.knownSessions.has('untracked')).toBe(true) + }) + + it('keeps knowledge of a session after it goes idle', () => { + const store = useSessionStatus.getState() + store.setStatus('session-a', { type: 'busy' }) + expect(useSessionStatus.getState().knownSessions.has('session-a')).toBe(true) + + store.setStatus('session-a', { type: 'idle' }) + + expect(useSessionStatus.getState().knownSessions.has('session-a')).toBe(true) + expect(useSessionStatus.getState().getStatus('session-a')).toEqual({ type: 'idle' }) + }) + + it('marks sessions from a global snapshot as known', () => { + const store = useSessionStatus.getState() + const token = store.beginStatusSnapshot() + + useSessionStatus.getState().replaceStatuses({ 'session-a': { type: 'busy' } }, token) + + expect(useSessionStatus.getState().knownSessions.has('session-a')).toBe(true) + expect(useSessionStatus.getState().knownSessions.has('session-unknown')).toBe(false) + }) + + it('does not treat a session omitted from a later snapshot as finished', () => { + const store = useSessionStatus.getState() + store.setStatus('session-a', { type: 'busy' }) + + const token = store.beginStatusSnapshot() + useSessionStatus.getState().replaceStatuses({}, token) + + expect(useSessionStatus.getState().knownSessions.has('session-a')).toBe(false) + expect(useSessionStatus.getState().getStatus('session-a')).toEqual({ type: 'idle' }) + }) + + it('records a child outcome and preserves it when the status goes idle', () => { + const store = useSessionStatus.getState() + store.setStatus('child-a', { type: 'busy' }) + store.setOutcome('child-a', 'failed') + + store.setStatus('child-a', { type: 'idle' }) + + expect(useSessionStatus.getState().outcomes.get('child-a')).toBe('failed') + expect(useSessionStatus.getState().knownSessions.has('child-a')).toBe(true) + }) + + it('clears a child outcome when a new execution starts', () => { + const store = useSessionStatus.getState() + store.setOutcome('child-a', 'interrupted') + + store.clearOutcome('child-a') + + expect(useSessionStatus.getState().outcomes.get('child-a')).toBeUndefined() + }) + + it('applies an authoritative idle session snapshot from the child session query', () => { + const store = useSessionStatus.getState() + const token = store.beginStatusSnapshot() + + store.applySessionSnapshot('child-a', { + id: 'child-a', + projectID: 'project-1', + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + outcome: 'failed', + time: { created: 1, updated: 2, idle: 2 }, + location: { directory: '/repo' }, + }, token) + + expect(useSessionStatus.getState().knownSessions.has('child-a')).toBe(true) + expect(useSessionStatus.getState().getStatus('child-a')).toEqual({ type: 'idle' }) + expect(useSessionStatus.getState().outcomes.get('child-a')).toBe('failed') + }) + + it('applies a running child session snapshot as busy', () => { + const store = useSessionStatus.getState() + const token = store.beginStatusSnapshot() + + store.applySessionSnapshot('child-a', { + id: 'child-a', + projectID: 'project-1', + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 2 }, + location: { directory: '/repo' }, + }, token) + + expect(useSessionStatus.getState().knownSessions.has('child-a')).toBe(true) + expect(useSessionStatus.getState().getStatus('child-a')).toEqual({ type: 'busy' }) + }) + + it('does not overwrite a newer live terminal event with a stale session snapshot', () => { + const store = useSessionStatus.getState() + const token = store.beginStatusSnapshot() + store.setStatus('child-a', { type: 'idle' }) + + store.applySessionSnapshot('child-a', { + id: 'child-a', + projectID: 'project-1', + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 2 }, + location: { directory: '/repo' }, + }, token) + + expect(useSessionStatus.getState().getStatus('child-a')).toEqual({ type: 'idle' }) + }) + + it('clears a stale child outcome when a fresh authoritative busy snapshot arrives', () => { + const store = useSessionStatus.getState() + store.setOutcome('child-a', 'failed') + const token = store.beginStatusSnapshot() + + store.applySessionSnapshot('child-a', { + id: 'child-a', + projectID: 'project-1', + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 2 }, + location: { directory: '/repo' }, + }, token) + + expect(useSessionStatus.getState().outcomes.get('child-a')).toBeUndefined() + expect(useSessionStatus.getState().getStatus('child-a')).toEqual({ type: 'busy' }) + }) + + it('preserves confirmed idle knowledge across a global snapshot', () => { + const store = useSessionStatus.getState() + store.setStatus('child-idle', { type: 'idle' }) + + const token = store.beginStatusSnapshot() + useSessionStatus.getState().replaceStatuses({}, token) + + expect(useSessionStatus.getState().knownSessions.has('child-idle')).toBe(true) + expect(useSessionStatus.getState().getStatus('child-idle')).toEqual({ type: 'idle' }) + }) + + it('turns a formerly active session omitted from a global snapshot unknown', () => { + const store = useSessionStatus.getState() + store.setStatus('child-active', { type: 'busy' }) + + const token = store.beginStatusSnapshot() + useSessionStatus.getState().replaceStatuses({}, token) + + expect(useSessionStatus.getState().knownSessions.has('child-active')).toBe(false) + }) + + it('does not let a snapshot erase an idle event received in flight', () => { + const store = useSessionStatus.getState() + store.setStatus('child-active', { type: 'busy' }) + + const token = store.beginStatusSnapshot() + store.setStatus('child-active', { type: 'idle' }) + + useSessionStatus.getState().replaceStatuses({}, token) + + expect(useSessionStatus.getState().knownSessions.has('child-active')).toBe(true) + expect(useSessionStatus.getState().getStatus('child-active')).toEqual({ type: 'idle' }) }) it('leaves the statuses map identity unchanged for a repeated identical busy status outside a snapshot', () => { @@ -248,4 +410,40 @@ describe('sessionStatusStore', () => { expect(useSessionStatus.getState().getStatus('active')).toEqual({ type: 'busy' }) }) + + it('forgets a session status, knowledge, and outcome', () => { + const store = useSessionStatus.getState() + store.setStatus('child-a', { type: 'busy' }) + store.setOutcome('child-a', 'failed') + + store.forgetSession('child-a') + + const after = useSessionStatus.getState() + expect(after.statuses.has('child-a')).toBe(false) + expect(after.statusCache.has('child-a')).toBe(false) + expect(after.knownSessions.has('child-a')).toBe(false) + expect(after.outcomes.has('child-a')).toBe(false) + }) + + it('does not let an in-flight snapshot resurrect a forgotten session', () => { + const store = useSessionStatus.getState() + store.setStatus('child-a', { type: 'busy' }) + + const token = store.beginStatusSnapshot() + store.forgetSession('child-a') + + useSessionStatus.getState().applySessionSnapshot('child-a', { + id: 'child-a', + projectID: 'project-1', + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 2 }, + location: { directory: '/repo' }, + }, token) + + const after = useSessionStatus.getState() + expect(after.statuses.has('child-a')).toBe(false) + expect(after.knownSessions.has('child-a')).toBe(false) + expect(after.outcomes.has('child-a')).toBe(false) + }) }) diff --git a/frontend/src/stores/sessionStatusStore.ts b/frontend/src/stores/sessionStatusStore.ts index 7996b0ce2..6cb520224 100644 --- a/frontend/src/stores/sessionStatusStore.ts +++ b/frontend/src/stores/sessionStatusStore.ts @@ -1,4 +1,6 @@ import { create } from 'zustand' +import type { SessionInfo } from '@opencode-manager/shared/opencode' +import { childLifecycle, type BackgroundTaskLifecycle, type ChildOutcome } from '@/lib/backgroundWork' export type SessionStatusType = | { type: 'idle' } @@ -15,14 +17,20 @@ interface SessionStatusStore { statuses: Map statusCache: Map statusRevisions: Map + knownSessions: Set + outcomes: Map revision: number setStatus: (sessionID: string, status: SessionStatusType) => void + setOutcome: (sessionID: string, outcome: ChildOutcome) => void + clearOutcome: (sessionID: string) => void setOptimisticActive: (sessionID: string, timeoutMs?: number) => void replaceStatuses: (statuses: Record, token: StatusSnapshotToken) => void + applySessionSnapshot: (sessionID: string, session: SessionInfo, token: StatusSnapshotToken) => void beginStatusSnapshot: () => StatusSnapshotToken endStatusSnapshot: (token: StatusSnapshotToken) => void getStatus: (sessionID: string) => SessionStatusType clearStatus: (sessionID: string) => void + forgetSession: (sessionID: string) => void } const DEFAULT_STATUS: SessionStatusType = { type: 'idle' } @@ -54,6 +62,21 @@ const sameEntries = (left: Map, right: Map): boolean => return true } +const sameSets = (left: Set, right: Set): boolean => { + if (left.size !== right.size) return false + for (const value of right) { + if (!left.has(value)) return false + } + return true +} + +const markSessionKnown = (knownSessions: Set, sessionID: string): Set | null => { + if (knownSessions.has(sessionID)) return null + const next = new Set(knownSessions) + next.add(sessionID) + return next +} + const registerSnapshot = (token: StatusSnapshotToken): void => { inFlightSnapshots.set(token.order, token.revision) } @@ -107,13 +130,27 @@ export const useSessionStatus = create((set, get) => { const statusCache = new Map(state.statusCache) statuses.set(sessionID, status) statusCache.set(sessionID, getStatusHash(status)) - return { statuses, statusCache, ...(recordRevisionPatch(state, sessionID) ?? {}) } + const knownSessions = markSessionKnown(state.knownSessions, sessionID) + return { + statuses, + statusCache, + ...(knownSessions ? { knownSessions } : {}), + ...(recordRevisionPatch(state, sessionID) ?? {}), + } + } + + const isLiveAfterCapture = (sessionID: string, captureRevision: number): boolean => { + if (optimisticActiveTimers.has(sessionID)) return true + const touched = get().statusRevisions.get(sessionID) + return touched !== undefined && touched > captureRevision } return { statuses: new Map(), statusCache: new Map(), statusRevisions: new Map(), + knownSessions: new Set(), + outcomes: new Map(), revision: 0, setStatus: (sessionID: string, status: SessionStatusType) => { @@ -124,14 +161,55 @@ export const useSessionStatus = create((set, get) => { clearOptimisticActiveTimer(sessionID) if (get().statusCache.get(sessionID) === getStatusHash(status)) { - const patch = recordRevisionPatch(get(), sessionID) - if (patch) set(patch) + const revisionPatch = recordRevisionPatch(get(), sessionID) + const knownSessions = markSessionKnown(get().knownSessions, sessionID) + if (revisionPatch || knownSessions) { + set({ + ...(revisionPatch ?? {}), + ...(knownSessions ? { knownSessions } : {}), + }) + } return } set((state) => applyStatusPatch(state, sessionID, status)) }, + setOutcome: (sessionID: string, outcome: ChildOutcome) => { + if (get().outcomes.get(sessionID) === outcome) { + const knownSessions = markSessionKnown(get().knownSessions, sessionID) + if (knownSessions) set({ knownSessions }) + return + } + set((state) => { + const outcomes = new Map(state.outcomes) + outcomes.set(sessionID, outcome) + const knownSessions = markSessionKnown(state.knownSessions, sessionID) + return { outcomes, ...(knownSessions ? { knownSessions } : {}) } + }) + }, + + clearOutcome: (sessionID: string) => { + if (!get().outcomes.has(sessionID)) return + set((state) => { + const outcomes = new Map(state.outcomes) + outcomes.delete(sessionID) + return { outcomes } + }) + }, + + applySessionSnapshot: (sessionID: string, session: SessionInfo, token: StatusSnapshotToken) => { + releaseSnapshot(token) + if (isLiveAfterCapture(sessionID, token.revision)) return + if (session.time.idle !== undefined) { + get().setStatus(sessionID, { type: 'idle' }) + if (session.outcome) get().setOutcome(sessionID, session.outcome) + return + } + get().clearOutcome(sessionID) + get().setStatus(sessionID, { type: 'busy' }) + }, + setOptimisticActive: (sessionID: string, timeoutMs = OPTIMISTIC_ACTIVE_TIMEOUT_MS) => { clearOptimisticActiveTimer(sessionID) @@ -173,25 +251,20 @@ export const useSessionStatus = create((set, get) => { const currentStatuses = get().statuses const currentRevisions = get().statusRevisions + const currentKnownSessions = get().knownSessions const captureRevision = token.revision - const isLiveAfterCapture = (sessionID: string): boolean => { - if (optimisticActiveTimers.has(sessionID)) return true - const touched = currentRevisions.get(sessionID) - return touched !== undefined && touched > captureRevision - } - const newMap = new Map() const newCache = new Map() for (const [sessionID, status] of currentStatuses.entries()) { - if (!isLiveAfterCapture(sessionID)) continue + if (!isLiveAfterCapture(sessionID, captureRevision)) continue newMap.set(sessionID, status) newCache.set(sessionID, getStatusHash(status)) } for (const [sessionID, status] of Object.entries(statuses)) { - if (isLiveAfterCapture(sessionID)) continue + if (isLiveAfterCapture(sessionID, captureRevision)) continue if (status.type === 'idle') continue const current = currentStatuses.get(sessionID) const effective = status.type === 'busy' && current !== undefined && current.type !== 'idle' ? current : status @@ -200,13 +273,28 @@ export const useSessionStatus = create((set, get) => { } const nextRevisions = prunedRevisions(currentRevisions) + const nextKnownSessions = new Set(Object.keys(statuses)) + for (const [sessionID, revision] of currentRevisions.entries()) { + if (revision > captureRevision) nextKnownSessions.add(sessionID) + } + for (const sessionID of optimisticActiveTimers.keys()) { + nextKnownSessions.add(sessionID) + } + for (const sessionID of currentKnownSessions) { + if (!currentStatuses.has(sessionID)) nextKnownSessions.add(sessionID) + } - if (sameEntries(get().statusCache, newCache) && sameEntries(currentRevisions, nextRevisions)) return + if ( + sameEntries(get().statusCache, newCache) && + sameEntries(currentRevisions, nextRevisions) && + sameSets(currentKnownSessions, nextKnownSessions) + ) return set({ statuses: newMap, statusCache: newCache, statusRevisions: nextRevisions, + knownSessions: nextKnownSessions, }) }, @@ -232,16 +320,53 @@ export const useSessionStatus = create((set, get) => { clearStatus: (sessionID: string) => { clearOptimisticActiveTimer(sessionID) - if (!get().statuses.has(sessionID) && inFlightSnapshots.size === 0) return + const knownPatch = markSessionKnown(get().knownSessions, sessionID) + if (!get().statuses.has(sessionID) && inFlightSnapshots.size === 0) { + if (knownPatch) set({ knownSessions: knownPatch }) + return + } set((state) => { const revisionPatch = recordRevisionPatch(state, sessionID) - if (!state.statuses.has(sessionID)) return revisionPatch ?? state + const knownSessions = markSessionKnown(state.knownSessions, sessionID) + const knownFields = knownSessions ? { knownSessions } : {} + if (!state.statuses.has(sessionID)) { + return { ...(revisionPatch ?? {}), ...knownFields } + } + const statuses = new Map(state.statuses) + const statusCache = new Map(state.statusCache) + statuses.delete(sessionID) + statusCache.delete(sessionID) + return { statuses, statusCache, ...(revisionPatch ?? {}), ...knownFields } + }) + }, + + forgetSession: (sessionID: string) => { + clearOptimisticActiveTimer(sessionID) + const current = get() + const hasSession = + current.statuses.has(sessionID) || + current.statusCache.has(sessionID) || + current.knownSessions.has(sessionID) || + current.outcomes.has(sessionID) + if (!hasSession && inFlightSnapshots.size === 0) return + + set((state) => { const statuses = new Map(state.statuses) const statusCache = new Map(state.statusCache) + const knownSessions = new Set(state.knownSessions) + const outcomes = new Map(state.outcomes) statuses.delete(sessionID) statusCache.delete(sessionID) - return { statuses, statusCache, ...(revisionPatch ?? {}) } + knownSessions.delete(sessionID) + outcomes.delete(sessionID) + return { + statuses, + statusCache, + knownSessions, + outcomes, + ...(recordRevisionPatch(state, sessionID) ?? {}), + } }) }, } @@ -252,3 +377,11 @@ export const useSessionStatusForSession = (sessionID: string | undefined): Sessi sessionID ? (state.statuses.get(sessionID) ?? DEFAULT_STATUS) : DEFAULT_STATUS ) } + +export const useChildLifecycleForSession = (sessionID: string | undefined): BackgroundTaskLifecycle => { + return useSessionStatus((state) => { + if (!sessionID) return 'unknown' + const status = state.statuses.get(sessionID) ?? DEFAULT_STATUS + return childLifecycle(status, state.knownSessions.has(sessionID), state.outcomes.get(sessionID)) + }) +} diff --git a/shared/src/opencode/index.ts b/shared/src/opencode/index.ts index 2d5cb4f4e..c881e4d89 100644 --- a/shared/src/opencode/index.ts +++ b/shared/src/opencode/index.ts @@ -48,7 +48,7 @@ export { ClientError, isIntegrationNotFoundError, isMcpServerNotFoundError, isSe export { assistantText, sessionIDFromEvent, toolContentText } from './content' -export { formatOpenCodeModelRef, parseOpenCodeModelRef } from './modelRef' +export { formatOpenCodeModelRef, parseOpenCodeModelRef, selectConfiguredModelRef } from './modelRef' export { MCP_OAUTH_CALLBACK_PATH, diff --git a/shared/src/opencode/modelRef.ts b/shared/src/opencode/modelRef.ts index 397c10c11..2d7f89952 100644 --- a/shared/src/opencode/modelRef.ts +++ b/shared/src/opencode/modelRef.ts @@ -1,4 +1,6 @@ -import type { ModelRef } from '@opencode/client' +import type { ConfigEntry, ModelRef } from '@opencode/client' + +type ConfigDocumentModel = Extract['info']['model'] export function parseOpenCodeModelRef(model: string): ModelRef | undefined { const providerEnd = model.indexOf('/') @@ -19,3 +21,19 @@ export function parseOpenCodeModelRef(model: string): ModelRef | undefined { export function formatOpenCodeModelRef(ref: ModelRef): string { return ref.variant ? `${ref.providerID}/${ref.id}#${ref.variant}` : `${ref.providerID}/${ref.id}` } + +export function selectConfiguredModelRef(entries: ConfigEntry[]): ModelRef | undefined { + const model = entries.reduce( + (current, entry) => (entry.type === 'document' && entry.info.model ? entry.info.model : current), + undefined, + ) + + if (!model) return undefined + if (typeof model === 'string') return parseOpenCodeModelRef(model) + + return { + providerID: model.providerID, + id: model.model, + ...(model.variant ? { variant: model.variant } : {}), + } +}