Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/hooks/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ export function createLoopEventHandler(
: `Loop ended: ${reason}`

v2Client.tui.publish({
directory: state.worktreeDir,
directory: state.projectDir ?? state.worktreeDir,
body: {
type: 'tui.toast.show',
properties: {
Expand All @@ -327,6 +327,17 @@ export function createLoopEventHandler(

if (reason === 'completed' || reason === 'cancelled') {
await commitAndCleanupWorktree(state)
if (state.worktree) {
try {
await v2Client.session.delete({
sessionID: sessionId,
directory: state.projectDir ?? state.worktreeDir,
})
logger.log(`Loop: deleted loop session ${sessionId} for ${state.loopName}`)
} catch (err) {
logger.error(`Loop: failed to delete loop session ${sessionId}`, err)
}
}
}

if (state.sandbox && state.sandboxContainer && sandboxManager) {
Expand Down
3 changes: 3 additions & 0 deletions src/services/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface LoopState {
executionModel?: string
auditorModel?: string
workspaceId?: string
hostSessionId?: string
}

export interface LoopService {
Expand Down Expand Up @@ -108,6 +109,7 @@ export function createLoopService(
executionModel: row.executionModel ?? undefined,
auditorModel: row.auditorModel ?? undefined,
workspaceId: row.workspaceId ?? undefined,
hostSessionId: row.hostSessionId ?? undefined,
}
}

Expand Down Expand Up @@ -137,6 +139,7 @@ export function createLoopService(
terminationReason: state.terminationReason ?? null,
completionSummary: state.completionSummary ?? null,
workspaceId: state.workspaceId ?? null,
hostSessionId: state.hostSessionId ?? null,
}
}

Expand Down
4 changes: 3 additions & 1 deletion src/storage/cli-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function listLoopsFromDb(
worktree_branch, project_dir, max_iterations, iteration, audit_count,
error_count, phase, audit, execution_model, auditor_model,
model_failed, sandbox, sandbox_container, started_at, completed_at,
termination_reason, completion_summary, workspace_id
termination_reason, completion_summary, workspace_id, host_session_id
FROM loops
WHERE project_id = ? AND status IN (${placeholders})
`
Expand Down Expand Up @@ -80,6 +80,7 @@ interface LoopRowRaw {
termination_reason: string | null
completion_summary: string | null
workspace_id: string | null
host_session_id: string | null
}

function mapRow(row: LoopRowRaw): LoopRow {
Expand Down Expand Up @@ -108,5 +109,6 @@ function mapRow(row: LoopRowRaw): LoopRow {
terminationReason: row.termination_reason,
completionSummary: row.completion_summary,
workspaceId: row.workspace_id,
hostSessionId: row.host_session_id,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE loops ADD COLUMN host_session_id TEXT;
9 changes: 9 additions & 0 deletions src/storage/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,13 @@ export const migrations: Migration[] = [
db.run(loadSql('107_add_workspace_id_to_loops.sql'))
},
},
{
id: '108',
description: 'Add host_session_id column to loops table for post-completion redirect',
apply: (db: Database) => {
const cols = db.prepare('PRAGMA table_info(loops)').all() as Array<{ name: string }>
if (cols.some((c) => c.name === 'host_session_id')) return
db.run(loadSql('108_add_host_session_id_to_loops.sql'))
},
},
]
26 changes: 20 additions & 6 deletions src/storage/repos/loops-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface LoopRow {
terminationReason: string | null
completionSummary: string | null
workspaceId: string | null
hostSessionId: string | null
}

export interface LoopLargeFields {
Expand All @@ -47,6 +48,7 @@ export interface LoopsRepo {
setAuditCount(projectId: string, loopName: string, count: number): void
setCurrentSessionId(projectId: string, loopName: string, sessionId: string): void
setWorkspaceId(projectId: string, loopName: string, workspaceId: string): void
setHostSessionId(projectId: string, loopName: string, hostSessionId: string): void
setModelFailed(projectId: string, loopName: string, failed: boolean): void
setLastAuditResult(projectId: string, loopName: string, text: string | null): void
setSandboxContainer(projectId: string, loopName: string, containerName: string | null): void
Expand Down Expand Up @@ -98,6 +100,7 @@ function mapRow(row: LoopRowRaw): LoopRow {
terminationReason: row.termination_reason,
completionSummary: row.completion_summary,
workspaceId: row.workspace_id,
hostSessionId: row.host_session_id,
}
}

Expand Down Expand Up @@ -126,6 +129,7 @@ interface LoopRowRaw {
termination_reason: string | null
completion_summary: string | null
workspace_id: string | null
host_session_id: string | null
}

export function createLoopsRepo(db: Database): LoopsRepo {
Expand All @@ -135,8 +139,8 @@ export function createLoopsRepo(db: Database): LoopsRepo {
worktree_branch, project_dir, max_iterations, iteration, audit_count,
error_count, phase, audit, execution_model, auditor_model,
model_failed, sandbox, sandbox_container, started_at, completed_at,
termination_reason, completion_summary, workspace_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
termination_reason, completion_summary, workspace_id, host_session_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)

const upsertLargeStmt = db.prepare(`
Expand All @@ -152,7 +156,7 @@ export function createLoopsRepo(db: Database): LoopsRepo {
worktree_branch, project_dir, max_iterations, iteration, audit_count,
error_count, phase, audit, execution_model, auditor_model,
model_failed, sandbox, sandbox_container, started_at, completed_at,
termination_reason, completion_summary, workspace_id
termination_reason, completion_summary, workspace_id, host_session_id
FROM loops
WHERE project_id = ? AND loop_name = ?
`)
Expand All @@ -168,7 +172,7 @@ export function createLoopsRepo(db: Database): LoopsRepo {
worktree_branch, project_dir, max_iterations, iteration, audit_count,
error_count, phase, audit, execution_model, auditor_model,
model_failed, sandbox, sandbox_container, started_at, completed_at,
termination_reason, completion_summary, workspace_id
termination_reason, completion_summary, workspace_id, host_session_id
FROM loops
WHERE project_id = ? AND current_session_id = ?
`)
Expand All @@ -178,7 +182,7 @@ export function createLoopsRepo(db: Database): LoopsRepo {
worktree_branch, project_dir, max_iterations, iteration, audit_count,
error_count, phase, audit, execution_model, auditor_model,
model_failed, sandbox, sandbox_container, started_at, completed_at,
termination_reason, completion_summary, workspace_id
termination_reason, completion_summary, workspace_id, host_session_id
FROM loops
WHERE project_id = ? AND status IN
`
Expand Down Expand Up @@ -223,6 +227,11 @@ export function createLoopsRepo(db: Database): LoopsRepo {
WHERE project_id = ? AND loop_name = ?
`)

const setHostSessionIdStmt = db.prepare(`
UPDATE loops SET host_session_id = ?
WHERE project_id = ? AND loop_name = ?
`)

const setModelFailedStmt = db.prepare(`
UPDATE loops SET model_failed = ?
WHERE project_id = ? AND loop_name = ?
Expand Down Expand Up @@ -306,7 +315,8 @@ export function createLoopsRepo(db: Database): LoopsRepo {
row.completedAt,
row.terminationReason,
row.completionSummary,
row.workspaceId
row.workspaceId,
row.hostSessionId
)
if (result.changes === 0) {
// Conflict - row already exists
Expand Down Expand Up @@ -378,6 +388,10 @@ export function createLoopsRepo(db: Database): LoopsRepo {
setWorkspaceIdStmt.run(workspaceId, projectId, loopName)
},

setHostSessionId(projectId: string, loopName: string, hostSessionId: string): void {
setHostSessionIdStmt.run(hostSessionId, projectId, loopName)
},

setModelFailed(projectId: string, loopName: string, failed: boolean): void {
setModelFailedStmt.run(failed ? 1 : 0, projectId, loopName)
},
Expand Down
4 changes: 4 additions & 0 deletions src/tools/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ interface LoopSetupOptions {
executionModel?: string
auditorModel?: string
onLoopStarted?: (loopName: string) => void
hostSessionId?: string
}

export async function setupLoop(
Expand Down Expand Up @@ -224,6 +225,7 @@ export async function setupLoop(
executionModel: options.executionModel,
auditorModel: options.auditorModel,
workspaceId: options.worktree ? loopContext.workspaceId : undefined,
hostSessionId: options.hostSessionId,
}

// Plan is persisted into loop_large_fields.prompt by loopService.setState below.
Expand Down Expand Up @@ -367,6 +369,7 @@ export function createLoopTools(ctx: ToolContext): Record<string, ReturnType<typ
title: z.string().describe('Short title for the session (shown in session list)'),
worktree: z.boolean().optional().default(false).describe('Run in isolated git worktree instead of current directory'),
loopName: z.string().optional().describe('Name for the loop (max 25 chars, auto-incremented if collision exists)'),
hostSessionId: z.string().optional().describe('Host session ID for post-completion redirect'),
},
execute: async (args, context) => {
if (config.loop?.enabled === false) {
Expand Down Expand Up @@ -406,6 +409,7 @@ export function createLoopTools(ctx: ToolContext): Record<string, ReturnType<typ
worktree: args.worktree,
executionModel: executionModel,
auditorModel: auditorModel,
hostSessionId: args.hostSessionId,
onLoopStarted: (id) => loopHandler.startWatchdog(id),
})
},
Expand Down
27 changes: 27 additions & 0 deletions src/tui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,7 @@ function PlanViewerDialog(props: {
api: props.api,
executionModel,
auditorModel,
hostSessionId: props.sessionId,
})

if (launchResult) {
Expand Down Expand Up @@ -1124,6 +1125,8 @@ function Sidebar(props: { api: TuiPluginApi; opts: TuiOptions; sessionId?: strin
* - Periodic polling for transient graph states (5s interval)
* - Manual onRefresh callbacks from dialogs
*/
const redirectedSessions = new Set<string>()

function refreshSidebarData() {
if (!pid) return

Expand All @@ -1148,6 +1151,30 @@ function Sidebar(props: { api: TuiPluginApi; opts: TuiOptions; sessionId?: strin
setHasPlan(plan !== null)
}

// Auto-redirect if the currently-viewed session belongs to a loop that just completed
if (props.sessionId && !redirectedSessions.has(props.sessionId)) {
const ended = states.find(l =>
l.sessionId === props.sessionId
&& !l.active
&& l.terminationReason === 'completed'
&& l.worktree
&& l.hostSessionId,
)
if (ended) {
redirectedSessions.add(props.sessionId)
try {
props.api.route.navigate('session', { sessionID: ended.hostSessionId! })
props.api.ui.toast({
message: `Loop "${ended.name}" completed · click Forge sidebar to review`,
variant: 'success',
duration: 5000,
})
} catch (err) {
console.error('[forge] sidebar: failed to redirect after loop completion', err)
}
}
}

// Refresh graph status from KV (scoped to current directory)
const status = readGraphStatus(pid, undefined, directory)
setGraphStatusRaw(status)
Expand Down
4 changes: 4 additions & 0 deletions src/utils/loop-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface FreshLoopOptions {
auditorModel?: string
/** Optional override for sandbox enabled state (for testing) */
sandboxEnabled?: boolean
hostSessionId?: string
}

export interface LaunchResult {
Expand All @@ -44,6 +45,7 @@ export interface LaunchResult {
worktreeDir?: string
worktreeBranch?: string
workspaceId?: string
hostSessionId?: string
}

/**
Expand Down Expand Up @@ -251,6 +253,7 @@ export async function launchFreshLoop(options: FreshLoopOptions): Promise<Launch
terminationReason: null,
completionSummary: null,
workspaceId: workspaceId ?? null,
hostSessionId: options.hostSessionId ?? null,
}

const large: LoopLargeFields = {
Expand Down Expand Up @@ -376,5 +379,6 @@ export async function launchFreshLoop(options: FreshLoopOptions): Promise<Launch
worktreeDir: sessionDirectory,
worktreeBranch,
workspaceId,
hostSessionId: options.hostSessionId,
}
}
9 changes: 7 additions & 2 deletions src/utils/tui-refresh-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type LoopInfo = {
executionModel?: string
auditorModel?: string
workspaceId?: string
hostSessionId?: string
}

/**
Expand Down Expand Up @@ -57,7 +58,7 @@ export function readLoopStates(projectId: string, dbPathOverride?: string): Loop
worktree_branch, project_dir, max_iterations, iteration, audit_count,
error_count, phase, audit, execution_model, auditor_model,
model_failed, sandbox, sandbox_container, started_at, completed_at,
termination_reason, completion_summary, workspace_id
termination_reason, completion_summary, workspace_id, host_session_id
FROM loops
WHERE project_id = ?
ORDER BY started_at DESC
Expand Down Expand Up @@ -87,6 +88,7 @@ export function readLoopStates(projectId: string, dbPathOverride?: string): Loop
termination_reason: string | null
completion_summary: string | null
workspace_id: string | null
host_session_id: string | null
}>

const loops: LoopInfo[] = []
Expand All @@ -107,6 +109,7 @@ export function readLoopStates(projectId: string, dbPathOverride?: string): Loop
executionModel: row.execution_model ?? undefined,
auditorModel: row.auditor_model ?? undefined,
workspaceId: row.workspace_id ?? undefined,
hostSessionId: row.host_session_id ?? undefined,
})
}
return loops
Expand Down Expand Up @@ -139,7 +142,7 @@ export function readLoopByName(projectId: string, loopName: string, dbPathOverri
worktree_branch, project_dir, max_iterations, iteration, audit_count,
error_count, phase, audit, execution_model, auditor_model,
model_failed, sandbox, sandbox_container, started_at, completed_at,
termination_reason, completion_summary, workspace_id
termination_reason, completion_summary, workspace_id, host_session_id
FROM loops
WHERE project_id = ? AND loop_name = ?
`).get(projectId, loopName) as {
Expand Down Expand Up @@ -167,6 +170,7 @@ export function readLoopByName(projectId: string, loopName: string, dbPathOverri
termination_reason: string | null
completion_summary: string | null
workspace_id: string | null
host_session_id: string | null
} | null

if (!row) return null
Expand All @@ -187,6 +191,7 @@ export function readLoopByName(projectId: string, loopName: string, dbPathOverri
executionModel: row.execution_model ?? undefined,
auditorModel: row.auditor_model ?? undefined,
workspaceId: row.workspace_id ?? undefined,
hostSessionId: row.host_session_id ?? undefined,
}
} catch {
return null
Expand Down
1 change: 1 addition & 0 deletions test/cli-cancel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function createTestDb(tempDir: string): Database {
termination_reason TEXT,
completion_summary TEXT,
workspace_id TEXT,
host_session_id TEXT,
PRIMARY KEY (project_id, loop_name)
)
`)
Expand Down
1 change: 1 addition & 0 deletions test/cli-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function createTestDb(tempDir: string): Database {
termination_reason TEXT,
completion_summary TEXT,
workspace_id TEXT,
host_session_id TEXT,
PRIMARY KEY (project_id, loop_name)
)
`)
Expand Down
Loading