Skip to content

Commit c7de8d7

Browse files
feat: add in-flight guard and attach hook scoping (#15)
1 parent fd9f281 commit c7de8d7

23 files changed

Lines changed: 2235 additions & 102 deletions

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opencode-forge",
3-
"version": "0.4.0",
3+
"version": "0.4.1",
44
"type": "module",
55
"oc-plugin": [
66
"server",

‎src/constants/loop.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,17 @@ export function buildLoopPermissionRuleset(): PermissionRule[] {
1414
rules.push({ permission: '*', pattern: '*', action: 'allow' })
1515

1616
// External directory access: always denied to prevent unauthorized file system traversal.
17+
// /tmp is allowed as a scratch area.
1718
rules.push({
1819
permission: 'external_directory',
1920
pattern: '*',
2021
action: 'deny',
2122
})
23+
rules.push({
24+
permission: 'external_directory',
25+
pattern: '/tmp',
26+
action: 'allow',
27+
})
2228

2329
// Code agent forbidden tools. Placed after *:allow so findLast picks them up.
2430
rules.push(

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

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,28 @@ export function createForgeSessionAttachHook(deps: ForgeSessionAttachHookDeps) {
2525
const sessionInfo = eventInput.event.properties?.info as Record<string, unknown> | undefined
2626
const sessionId = sessionInfo?.id as string | undefined
2727
const workspaceId = sessionInfo?.workspaceID as string | undefined
28+
const sessionDirectory = sessionInfo?.directory as string | undefined
29+
const sessionProjectId = (sessionInfo?.projectID as string | undefined) ?? deps.projectId
2830
if (!sessionId || !workspaceId) return
2931

30-
let ws = await findWorkspaceById(deps, workspaceId)
32+
let ws = await findWorkspaceById(deps, workspaceId, sessionDirectory)
3133
if (!ws) {
3234
await new Promise<void>((r) => setTimeout(r, 100))
33-
ws = await findWorkspaceById(deps, workspaceId)
35+
ws = await findWorkspaceById(deps, workspaceId, sessionDirectory)
3436
if (!ws) {
35-
deps.logger.log(`[forge-session-attach] skip session=${sessionId}: workspace ${workspaceId} not found via experimental.workspace.list (may be cross-project; check plugin directory)`)
37+
deps.logger.log(
38+
`[forge-session-attach] skip session=${sessionId}: workspace ${workspaceId} not found ` +
39+
`via experimental.workspace.list directory=${sessionDirectory ?? '(none)'} ` +
40+
`(cross-project or sync lag)`,
41+
)
42+
if (sessionDirectory) {
43+
publishAttachFailureToast(
44+
deps,
45+
sessionDirectory,
46+
`Forge loop (workspace ${workspaceId})`,
47+
'Workspace not visible from this plugin instance - open the TUI in the loop\'s project, or run the reconciler.',
48+
)
49+
}
3650
return
3751
}
3852
}
@@ -61,16 +75,16 @@ export function createForgeSessionAttachHook(deps: ForgeSessionAttachHookDeps) {
6175
return
6276
}
6377

64-
const existing = deps.execDeps.loopsRepo.get(deps.projectId, cfg.loopName)
78+
const existing = deps.execDeps.loopsRepo.get(sessionProjectId, cfg.loopName)
6579
if (existing && existing.status === 'running') {
6680
// Live loop with this name; skip to avoid double-attach.
6781
deps.logger.log(`[forge-session-attach] skip session=${sessionId} loop=${cfg.loopName} reason=already-running`)
6882
return
6983
}
7084
if (existing) {
71-
deps.logger.log(`[forge-session-attach] session=${sessionId} loop=${cfg.loopName} existing-row-status=${existing.status} (will re-attach)`)
85+
deps.logger.log(`[forge-session-attach] session=${sessionId} loop=${cfg.loopName} projectId=${sessionProjectId} existing-row-status=${existing.status} (will re-attach)`)
7286
} else {
73-
deps.logger.log(`[forge-session-attach] session=${sessionId} loop=${cfg.loopName} no existing row, proceeding`)
87+
deps.logger.log(`[forge-session-attach] session=${sessionId} loop=${cfg.loopName} projectId=${sessionProjectId} no existing row, proceeding`)
7488
}
7589

7690
const resolvedHostSessionId = cfg.hostSessionId && cfg.hostSessionId.length > 0
@@ -86,7 +100,7 @@ export function createForgeSessionAttachHook(deps: ForgeSessionAttachHookDeps) {
86100
if (planSource.kind === 'inline') {
87101
planText = planSource.planText
88102
} else {
89-
const row = deps.execDeps.plansRepo.getForSession(deps.projectId, planSource.sessionId)
103+
const row = deps.execDeps.plansRepo.getForSession(sessionProjectId, planSource.sessionId)
90104
if (!row) {
91105
deps.logger.error(`[forge-session-attach] plan not found for session=${planSource.sessionId} loop=${cfg.loopName} workspace=${workspaceId}`)
92106
await failAndCleanup(
@@ -104,7 +118,7 @@ export function createForgeSessionAttachHook(deps: ForgeSessionAttachHookDeps) {
104118
try {
105119
const result = await attachLoopToSession(
106120
deps.execDeps,
107-
{ surface: 'tui', projectId: deps.projectId, directory: ws.directory ?? deps.directory },
121+
{ surface: 'tui', projectId: sessionProjectId, directory: ws.directory ?? deps.directory },
108122
{
109123
sessionId,
110124
workspaceId,
@@ -153,14 +167,14 @@ async function failAndCleanup(
153167
loopName: string,
154168
message: string,
155169
): Promise<void> {
156-
publishAttachFailureToast(deps, directory, loopName, message)
170+
publishAttachFailureToast(deps, directory, `Forge loop "${loopName}"`, message)
157171
await removeOrphanWorkspace(deps, workspaceId, loopName)
158172
}
159173

160174
function publishAttachFailureToast(
161175
deps: ForgeSessionAttachHookDeps,
162176
directory: string,
163-
loopName: string,
177+
title: string,
164178
message: string,
165179
): void {
166180
const tui = deps.v2.tui
@@ -169,12 +183,7 @@ function publishAttachFailureToast(
169183
directory,
170184
body: {
171185
type: 'tui.toast.show',
172-
properties: {
173-
title: `Forge loop "${loopName}"`,
174-
message,
175-
variant: 'error',
176-
duration: 6000,
177-
},
186+
properties: { title, message, variant: 'error', duration: 6000 },
178187
},
179188
}).catch((err) => {
180189
deps.logger.error('[forge-session-attach] failed to publish toast', err)
@@ -206,9 +215,12 @@ async function removeOrphanWorkspace(
206215
async function findWorkspaceById(
207216
deps: ForgeSessionAttachHookDeps,
208217
workspaceId: string,
218+
directory?: string,
209219
): Promise<WorkspaceEntry | null> {
210220
try {
211-
const result = await deps.v2.experimental.workspace.list()
221+
const result = await deps.v2.experimental.workspace.list(
222+
directory ? { directory } : undefined,
223+
)
212224
const entries = (result.data ?? []) as WorkspaceEntry[]
213225
return entries.find((e) => e.id === workspaceId) ?? null
214226
} catch {

‎src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ export function createForgePlugin(config: PluginConfig): Plugin {
301301
loop: loopHandler.loop,
302302
sandboxManager,
303303
sectionPlansRepo,
304+
reviewFindingsRepo,
304305
workspaceStatusRegistry,
305306
})
306307

@@ -435,6 +436,7 @@ export function createForgePlugin(config: PluginConfig): Plugin {
435436
loop: loopHandler.loop,
436437
sandboxManager,
437438
sectionPlansRepo,
439+
reviewFindingsRepo,
438440
workspaceStatusRegistry,
439441
}
440442
const forgeSessionAttachHook = createForgeSessionAttachHook({

‎src/loop/in-flight-guard.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { Logger } from '../types'
2+
3+
export type PromptAgent = 'code' | 'auditor-loop' | 'decomposer'
4+
5+
export class ConcurrentPromptError extends Error {
6+
readonly code = 'concurrent_prompt'
7+
constructor(
8+
public readonly loopName: string,
9+
public readonly priorSessionId: string,
10+
public readonly priorAgent: PromptAgent,
11+
public readonly attemptedSessionId: string,
12+
public readonly attemptedAgent: PromptAgent,
13+
) {
14+
super(
15+
`Concurrent agent prompt rejected for loop=${loopName}: ` +
16+
`prior ${priorAgent} on session=${priorSessionId} still in-flight, ` +
17+
`attempted ${attemptedAgent} on session=${attemptedSessionId}`,
18+
)
19+
this.name = 'ConcurrentPromptError'
20+
}
21+
}
22+
23+
interface InFlightEntry {
24+
sessionId: string
25+
agent: PromptAgent
26+
startedAt: number
27+
}
28+
29+
const inFlight = new Map<string, InFlightEntry>()
30+
31+
export function markPromptInFlight(loopName: string, sessionId: string, agent: PromptAgent): void {
32+
inFlight.set(loopName, { sessionId, agent, startedAt: Date.now() })
33+
}
34+
35+
export function clearPromptInFlight(loopName: string): void {
36+
inFlight.delete(loopName)
37+
}
38+
39+
export function getPromptInFlight(loopName: string): InFlightEntry | undefined {
40+
return inFlight.get(loopName)
41+
}
42+
43+
export function assertNoPromptInFlight(
44+
loopName: string,
45+
attemptedSessionId: string,
46+
attemptedAgent: PromptAgent,
47+
logger: Logger,
48+
): void {
49+
const prior = inFlight.get(loopName)
50+
if (!prior) return
51+
if (prior.sessionId === attemptedSessionId && prior.agent === attemptedAgent) return
52+
logger.error(
53+
`[in-flight-guard] concurrent prompt rejected loop=${loopName} ` +
54+
`prior=${prior.agent}: ${prior.sessionId} attempted=${attemptedAgent}: ${attemptedSessionId}`,
55+
)
56+
throw new ConcurrentPromptError(loopName, prior.sessionId, prior.agent, attemptedSessionId, attemptedAgent)
57+
}
58+
59+
// Test-only: clear all state.
60+
export function __resetInFlightGuard(): void {
61+
inFlight.clear()
62+
}

0 commit comments

Comments
 (0)