Skip to content

Commit 1b40cee

Browse files
author
Forge
committed
fix: harden remote loop launches against stale branches, clock skew, and silent errors
1 parent 061cb93 commit 1b40cee

12 files changed

Lines changed: 211 additions & 19 deletions

‎docs/loop-system.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ graph TD
187187
G --> H[Branch preserved]
188188
```
189189

190-
When a workspace carries a SHA pin (`extra.startRef`, set by remote loop launches), the new branch is created from that exact commit instead of the clone's current `HEAD`. If the commit is not present locally, the adapter fetches the sync ref (`extra.syncRef`, default `refs/forge/<loopName>`) from the configured git remote first, and fails with a descriptive error when the SHA still cannot be resolved. Existing branches always win — the pin is ignored when the loop branch already exists. On final teardown the sync ref is deleted from the shared git remote. See [Configuration → Remotes](configuration.md#remotes).
190+
When a workspace carries a SHA pin (`extra.startRef`, set by remote loop launches), the new branch is created from that exact commit instead of the clone's current `HEAD`. If the commit is not present locally, the adapter fetches the sync ref (`extra.syncRef`, default `refs/forge/<loopName>`) from the configured git remote first, and fails with a descriptive error when the SHA still cannot be resolved. If the loop branch already exists, its tip must match the pinned SHA — a leftover same-named branch at a different commit fails creation with an actionable error instead of silently running old code (unpinned workspaces still reuse existing branches). On final teardown the sync ref is deleted from the shared git remote. See [Configuration → Remotes](configuration.md#remotes).
191191

192192
Benefits of worktree isolation:
193193
- Isolation from ongoing development

‎src/hooks/forge-session-attach.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,15 +143,28 @@ async function attachForgeSession(
143143
projectDirectory,
144144
)
145145

146-
if (action.action === 'keep' && action.reason !== 'running' && action.reason !== 'pending-attach') {
146+
// A fresh TUI-owned launch (remote or local) whose name collides with a
147+
// terminal loop row classifies as keep/pending-start (the classifier must
148+
// keep restart-in-flight workspaces). Silently skipping here would leave a
149+
// zombie session that runs the first prompt but never loops. Proceed to
150+
// attachLoopToSession instead so its conflict handling fails loudly
151+
// (toast + workspace removal). Restart-created workspaces carry no
152+
// forgeLoop config and still skip below.
153+
const isTerminalNameConflict =
154+
action.action === 'keep' &&
155+
action.reason === 'pending-start' &&
156+
cfg?.initialPromptOwner === 'tui' &&
157+
isPendingAttachWorkspace(classifyEntry)
158+
159+
if (action.action === 'keep' && action.reason !== 'running' && action.reason !== 'pending-attach' && !isTerminalNameConflict) {
147160
// bad config or wrong-project — toast and bail, do not attach
148161
deps.logger.log(
149162
`[forge-session-attach] skip session=${sessionId} workspace=${workspaceId} reason=${action.reason}`,
150163
)
151164
return
152165
}
153166

154-
if ((action.action === 'remove-fully' && action.reason === 'missing-row') || (action.action === 'keep' && action.reason === 'pending-attach')) {
167+
if ((action.action === 'remove-fully' && action.reason === 'missing-row') || (action.action === 'keep' && action.reason === 'pending-attach') || isTerminalNameConflict) {
155168
// Fresh attach (no loop row yet) - proceed to attach
156169
if (!cfg) {
157170
if (action.action === 'remove-fully') {

‎src/utils/git-service.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export interface GitService {
2020
revParseGitCommonDir(cwd: string): GitResult
2121
revParseGitPath(cwd: string, path: string): GitResult
2222
revParseHead(cwd: string): GitResult
23+
/** Resolve any ref (branch, tag, SHA) to a full commit SHA. */
24+
revParseRef(cwd: string, ref: string): GitResult
2325
commitExists(cwd: string, sha: string): boolean
2426
push(cwd: string, remote: string, refspec: string, force: boolean): GitResult
2527
fetchRef(cwd: string, remote: string, ref: string): GitResult
@@ -84,6 +86,10 @@ export function createGitService(): GitService {
8486
return runGit(['rev-parse', 'HEAD'], cwd)
8587
},
8688

89+
revParseRef(cwd: string, ref: string): GitResult {
90+
return runGit(['rev-parse', '--verify', `${ref}^{commit}`], cwd)
91+
},
92+
8793
commitExists(cwd: string, sha: string): boolean {
8894
return runGit(['cat-file', '-e', `${sha}^{commit}`], cwd).ok
8995
},

‎src/utils/tui-client.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ export interface LaunchTuiLoopOptions {
276276

277277
export async function launchTuiLoop(
278278
opts: LaunchTuiLoopOptions,
279-
): Promise<{ sessionId: string; loopName: string; worktreeDir?: string; workspaceId: string } | { error: string } | null> {
279+
): Promise<{ sessionId: string; loopName: string; worktreeDir?: string; workspaceId: string } | { error: string }> {
280280
const debug = opts.debug ?? tuiDebug
281281

282282
const committedError = getWorktreeProjectPreconditionError(opts.projectId)
@@ -360,7 +360,7 @@ export async function launchTuiLoop(
360360
} catch (err) {
361361
debug(`launchTuiLoop: promptAsync failed session=${session.id} workspace=${workspace.id} error=${err instanceof Error ? err.message : String(err)}`)
362362
await opts.client.workspace.remove({ id: workspace.id }).catch(() => undefined)
363-
return null
363+
return { error: `Failed to send initial loop prompt: ${err instanceof Error ? err.message : String(err)}` }
364364
}
365365
debug(`launchTuiLoop: promptAsync ok session=${session.id} workspace=${workspace.id}`)
366366

@@ -376,7 +376,7 @@ export async function launchTuiLoop(
376376
}
377377
} catch (err) {
378378
debug(`launchTuiLoop: post-create flow failed error=${err instanceof Error ? err.message : String(err)}`)
379-
return null
379+
return { error: `Loop launch failed: ${err instanceof Error ? err.message : String(err)}` }
380380
}
381381
}
382382

@@ -501,7 +501,7 @@ export async function connectForgeProject(
501501
allowDirectories: allowExternalDirectories,
502502
onLaunched: (sid, wid) => selectTuiSession(api, client, sid, wid),
503503
debug: tuiDebug,
504-
}) ?? null
504+
})
505505
}
506506

507507
return null

‎src/utils/tui-remote-launch.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -152,10 +152,11 @@ export async function executeRemoteLoop(
152152
debug,
153153
})
154154

155-
if (!launchResult || 'error' in launchResult) {
156-
const errMsg = launchResult && 'error' in launchResult ? launchResult.error : 'Failed to launch remote loop'
157-
debug(`remote-launch: launchTuiLoop FAILED: ${errMsg}`)
158-
return { error: errMsg }
155+
if ('error' in launchResult) {
156+
debug(`remote-launch: launchTuiLoop FAILED: ${launchResult.error}`)
157+
const cleanup = git.push(req.localDirectory, remote.gitRemote, `:${syncRef}`, false)
158+
debug(`remote-launch: sync ref cleanup ${cleanup.ok ? 'ok' : `failed: ${cleanup.stderr.trim() || 'unknown error'}`}`)
159+
return { error: launchResult.error }
159160
}
160161

161162
debug(`remote-launch: launched loop="${launchResult.loopName}" session=${launchResult.sessionId} on "${remote.name}"`)

‎src/workspace/forge-adapter.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,33 @@ export function createForgeWorkspaceAdapter(deps: ForgeAdapterDeps): WorkspaceAd
167167
})
168168
}
169169

170+
/**
171+
* Re-stamp launcher-provided attach timestamps with this server's clock.
172+
*
173+
* Remote launches stamp `workspaceCreatedAt` and
174+
* `forgeLoop.pendingAttachStartedAt` on the launching machine, but the
175+
* attach/pending-start grace windows are evaluated against this server's
176+
* clock (classify-stale.ts). Clock skew between the two machines beyond the
177+
* grace window would otherwise expire a fresh workspace immediately.
178+
* `configure` runs exactly once at creation on the owning server, so it is
179+
* the single normalization point.
180+
*/
181+
function restampAttachTimestamps(extra: unknown): unknown {
182+
if (typeof extra !== 'object' || extra === null) return extra
183+
const now = Date.now()
184+
const record = extra as Record<string, unknown>
185+
const result: Record<string, unknown> = { ...record, workspaceCreatedAt: now }
186+
const forgeLoop = record.forgeLoop
187+
if (
188+
typeof forgeLoop === 'object' &&
189+
forgeLoop !== null &&
190+
typeof (forgeLoop as Record<string, unknown>).pendingAttachStartedAt === 'number'
191+
) {
192+
result.forgeLoop = { ...(forgeLoop as Record<string, unknown>), pendingAttachStartedAt: now }
193+
}
194+
return result
195+
}
196+
170197
function deriveSyncPin(info: WorkspaceInfo, loopName: string): { startRef: string; syncRef: string; gitRemote: string } | null {
171198
const extra = (info.extra ?? {}) as Record<string, unknown>
172199
const startRef = typeof extra.startRef === 'string' && extra.startRef.length > 0 ? extra.startRef : null
@@ -226,6 +253,7 @@ export function createForgeWorkspaceAdapter(deps: ForgeAdapterDeps): WorkspaceAd
226253
name: loopName,
227254
branch: forgeBranchName(loopName),
228255
directory: forgeWorktreeDir(dataDir, loopName),
256+
extra: restampAttachTimestamps(info.extra),
229257
}
230258
},
231259
async create(info) {
@@ -256,6 +284,22 @@ export function createForgeWorkspaceAdapter(deps: ForgeAdapterDeps): WorkspaceAd
256284
// Detect orphan state from a prior failed run: branch may exist without a live worktree.
257285
const branchExists = git.branchExists(projectDir, info.branch)
258286

287+
// A pinned launch must run exactly the pushed SHA. Reusing a leftover
288+
// same-named branch at a different tip would silently run old code, so
289+
// fail with an actionable error instead.
290+
if (pin && branchExists) {
291+
const tipRes = git.revParseRef(projectDir, `refs/heads/${info.branch}`)
292+
const tip = tipRes.ok ? tipRes.stdout.trim() : ''
293+
const pinnedRes = git.revParseRef(projectDir, pin.startRef)
294+
const pinned = pinnedRes.ok ? pinnedRes.stdout.trim() : pin.startRef
295+
if (tip !== pinned) {
296+
throw new Error(
297+
`forge workspace adapter: branch ${info.branch} already exists at ${tip ? tip.substring(0, 7) : 'unknown'} ` +
298+
`but this launch pinned ${pinned.substring(0, 7)}; delete the stale branch or use a different loop name`,
299+
)
300+
}
301+
}
302+
259303
// Only pass startPoint when creating a new branch; existing branches always win.
260304
const startPoint = pin && !branchExists ? pin.startRef : undefined
261305

‎test/helpers/fake-git.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export function createFakeGitService(overrides?: Partial<GitService>): GitServic
1616
revParseGitCommonDir: vi.fn<[string], GitResult>(() => ({ ...defaultOk })),
1717
revParseGitPath: vi.fn<[string, string], GitResult>(() => ({ ...defaultOk })),
1818
revParseHead: vi.fn<[string], GitResult>(() => ({ ...defaultOk })),
19+
revParseRef: vi.fn<[string, string], GitResult>(() => ({ ...defaultOk })),
1920
commitExists: vi.fn<[string, string], boolean>(() => false),
2021
push: vi.fn<[string, string, string, boolean], GitResult>(() => ({ ...defaultOk })),
2122
fetchRef: vi.fn<[string, string, string], GitResult>(() => ({ ...defaultOk })),

‎test/hooks/forge-session-attach.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,61 @@ describe('createForgeSessionAttachHook', () => {
391391
expect(mockAttachLoop).not.toHaveBeenCalled()
392392
})
393393

394+
test('fresh TUI launch colliding with restartable terminal row fails loudly instead of leaving a zombie session', async () => {
395+
const workspaceRemove = vi.fn().mockResolvedValue(undefined)
396+
const tuiPublish = vi.fn().mockResolvedValue(undefined)
397+
const loopsRepoGet = vi.fn().mockReturnValue({ projectId: 'proj_1', loopName: 'collide-loop', status: 'cancelled' })
398+
mockAttachLoop.mockResolvedValueOnce({ ok: false, code: 'conflict', message: 'Loop collide-loop is terminal' })
399+
const deps = buildHookDeps({
400+
workspaceRemove,
401+
tuiPublish,
402+
loopsRepoGet,
403+
sessionGet: vi.fn().mockResolvedValue({
404+
id: 'sess_collide',
405+
workspaceID: 'ws_collide',
406+
directory: '/tmp/wt/collide',
407+
projectID: 'proj_1',
408+
}),
409+
workspaceList: vi.fn().mockResolvedValue([
410+
{
411+
id: 'ws_collide',
412+
type: 'forge',
413+
directory: '/tmp/wt/collide',
414+
extra: {
415+
loopName: 'collide-loop',
416+
projectDirectory: '/tmp/wt/collide',
417+
workspaceCreatedAt: Date.now(),
418+
forgeLoop: {
419+
title: 'Collide Loop',
420+
planSource: 'inline',
421+
planText: '# Plan',
422+
initialPromptOwner: 'tui',
423+
pendingAttachStartedAt: Date.now(),
424+
},
425+
},
426+
},
427+
]),
428+
})
429+
430+
const handler = createForgeSessionMessageAttachHook(deps as any)
431+
432+
await handler({ sessionID: 'sess_collide' })
433+
434+
expect(mockAttachLoop).toHaveBeenCalledTimes(1)
435+
expect(workspaceRemove).toHaveBeenCalledWith({ id: 'ws_collide' })
436+
expect(deps.execDeps.pendingTeardowns.set).toHaveBeenCalledWith(
437+
'collide-loop',
438+
expect.objectContaining({ doRemoveWorktree: false }),
439+
)
440+
expect(tuiPublish).toHaveBeenCalledWith(expect.objectContaining({
441+
body: expect.objectContaining({
442+
properties: expect.objectContaining({
443+
message: expect.stringContaining('terminal status'),
444+
}),
445+
}),
446+
}))
447+
})
448+
394449
test('inline planSource resolves planText inline', async () => {
395450
const deps = buildHookDeps({
396451
workspaceList: vi.fn().mockResolvedValue([

‎test/tui-client.loop-error.test.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ describe('plan.execute(loop) workspace.create failure', () => {
120120
}
121121
})
122122

123-
test('returns null for non-create failures in the post-create flow', async () => {
123+
test('returns error detail for non-create failures in the post-create flow', async () => {
124124
const api = createMockApi()
125125

126126
// Mock workspace.status to report the workspace as connected so awaitWorkspaceConnected resolves quickly
@@ -148,8 +148,12 @@ describe('plan.execute(loop) workspace.create failure', () => {
148148
plan: '# Test\n\nPost-create failure test.',
149149
})
150150

151-
// Post-create failures should still return null (generic)
152-
expect(result).toBeNull()
151+
// Post-create failures surface the underlying cause instead of a generic null
152+
expect(result).not.toBeNull()
153+
expect(result).toHaveProperty('error')
154+
if (result && 'error' in result) {
155+
expect(result.error).toContain('session create failed')
156+
}
153157
}, 10000)
154158

155159
test('returns committed-project error for global project before any workspace side effects', async () => {

‎test/utils/tui-client-warp-flow.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ describe('TUI warp flow for plan.execute mode=loop', () => {
297297
expect(result).toEqual({ error: 'Failed to create worktree workspace: no data returned' })
298298
})
299299

300-
test('failure: session.create returns error → returns null', async () => {
300+
test('failure: session.create returns error → returns {error} with cause', async () => {
301301
mockApi.client.session.create.mockResolvedValueOnce({ error: new Error('session create fail') })
302302

303303
const client = await connectForgeProject(mockApi, DIRECTORY)
@@ -311,7 +311,7 @@ describe('TUI warp flow for plan.execute mode=loop', () => {
311311
},
312312
)
313313

314-
expect(result).toBeNull()
314+
expect(result).toEqual({ error: expect.stringContaining('session create fail') })
315315
// workspace.create should still have been called but downstream after session.create shouldn't
316316
expect(mockApi.client.experimental.workspace.create).toHaveBeenCalled()
317317
expect(mockApi.client.tui.selectSession).not.toHaveBeenCalled()

0 commit comments

Comments
 (0)