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
78 changes: 33 additions & 45 deletions packages/opencode/src/dag/runtime/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1232,54 +1232,42 @@ const serviceLayer = Layer.effect(
// information. A differing summary (new results committed
// between attempts) always prompts.
if (deliveredWakeSummaries.size > 1024) deliveredWakeSummaries.clear()
const didDeliver = Option.getOrElse(
yield* automation.use(
wakeLease,
Effect.gen(function* () {
if (deliveredWakeSummaries.get(sessionID) !== summary) {
const delivered = yield* promptSvc.promptIfIdle({
sessionID: SessionID.make(sessionID),
parts: [{ type: "text", text: summary, synthetic: true }],
})
if (Option.isNone(delivered)) return false
// Record BEFORE the mark: the transcript part was
// already written (the prompt just succeeded), so the
// retry must skip the prompt even when the mark below
// fails again.
deliveredWakeSummaries.set(sessionID, summary)
}
yield* store.markWakeBatchReported(batch).pipe(
Effect.tap(() =>
Effect.forEach(
batch.workflows.filter((workflow) =>
isWorkflowTerminalStatus(workflow.status as never),
),
(workflow) =>
automation.unregister(SessionID.make(sessionID), {
kind: "dag",
id: workflow.id,
}),
{ discard: true },
),
),
Effect.tap(() =>
Effect.sync(() => {
plan.unresponsiveDagIDs.forEach((workflowID) =>
deliveredUnresponsiveDagIDs.add(workflowID),
)
}),
),
)
return true
}).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("DAG wake delivery failed", { sessionID, cause: Cause.pretty(cause) }).pipe(
Effect.as(false),
),
const didDeliver = yield* Effect.gen(function* () {
if (deliveredWakeSummaries.get(sessionID) !== summary) {
const delivered = yield* SessionPrompt.admitIfIdle(promptSvc, automation, wakeLease, {
sessionID: SessionID.make(sessionID),
parts: [{ type: "text", text: summary, synthetic: true }],
})
if (Option.isNone(delivered)) return false
deliveredWakeSummaries.set(sessionID, summary)
yield* delivered.value.pipe(
Effect.onError(() =>
Effect.sync(() => {
if (deliveredWakeSummaries.get(sessionID) === summary) {
deliveredWakeSummaries.delete(sessionID)
}
}),
),
)
}

const markLease = yield* automation.claim(SessionID.make(sessionID), { kind: "dag" })
if (Option.isNone(markLease)) return false
const marked = yield* automation.use(markLease.value, store.markWakeBatchReported(batch))
if (Option.isNone(marked)) return false
plan.unresponsiveDagIDs.forEach((workflowID) => deliveredUnresponsiveDagIDs.add(workflowID))
yield* Effect.forEach(
batch.workflows.filter((workflow) => isWorkflowTerminalStatus(workflow.status as never)),
(workflow) => automation.unregister(SessionID.make(sessionID), { kind: "dag", id: workflow.id }),
{ discard: true },
)
return true
}).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("DAG wake delivery failed", { sessionID, cause: Cause.pretty(cause) }).pipe(
Effect.as(false),
),
),
() => false,
)
if (!didDeliver) return
}
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/goal/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Standing Goal keeps one durable autonomous objective for a Session and advances
- Terminal completion writes `goal_outcome` and deletes the current row in one transition; a durable `done` row is never an intermediate cleanup obligation.
- `blocked` pauses the Goal and remains distinguishable from `done` in state, events, transcript text, and judge prompts.
- `SessionAutomationLease` elects one automation owner per Session. DAG owns the Session while any registered workflow remains; Goal is eligible only after the final DAG owner releases it.
- Goal and DAG effects revalidate the claimed generation immediately before mutation or prompt admission. `SessionPrompt.promptIfIdle` remains the final idle-state guard.
- Goal and DAG hold the claimed generation fence through a durable mutation or prompt admission. Provider execution starts only after that fence is released; `SessionPrompt.promptIfIdle` remains the final idle-state guard.
- The current Session runner is process-local, so the automation lease is process-local. Clustered execution requires a separate durable lease design.

## Boundaries
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ A `done` verdict writes an immutable `goal_outcome` snapshot and deletes the cur

Judge output is tri-state: `done`, `continue`, or `blocked`. `blocked` writes a paused Goal with the blocker as its reason.

`SessionAutomationLease` is the process-local authority for Goal/DAG ownership. Goal and DAG register their active identities; DAG has priority while any workflow is registered. A claim carries a generation that is revalidated immediately before a state transition or autonomous prompt. Registration changes invalidate older claims. After that ownership check, `SessionPrompt.promptIfIdle` remains the final atomic idle-state admission guard. Failure at either boundary admits no Goal prompt and leaves the durable Goal available for a later idle event.
`SessionAutomationLease` is the process-local authority for Goal/DAG ownership. Goal and DAG register their active identities; DAG has priority while any workflow is registered. A claim carries a generation, and the lease holds its per-Session fence through the durable transition or prompt admission. Registration changes cannot overtake that commit. Provider execution starts after the fence is released, so a slow model turn does not block ownership transfer. `SessionPrompt.promptIfIdle` remains the final atomic idle-state admission guard. Failure at either boundary admits no Goal prompt and leaves the durable Goal available for a later idle event.

## Consequences

Expand Down
11 changes: 6 additions & 5 deletions packages/opencode/src/goal/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,12 +425,14 @@ const serviceLayer = Layer.effect(
// clearFiber — us — mid-publish; see the preempt branches above).
const continuationLease = Option.getOrUndefined(yield* automation.claim(sessionID, goalOwner))
if (!continuationLease) return
yield* automation.use(
continuationLease,
promptSvc.promptIfIdle({
yield* Effect.gen(function* () {
const admitted = yield* SessionPrompt.admitIfIdle(promptSvc, automation, continuationLease, {
sessionID,
parts: [{ type: "text", text: continuationText }],
}).pipe(
})
if (Option.isNone(admitted)) return
yield* admitted.value
}).pipe(
Effect.catchCause((cause) =>
Effect.gen(function* () {
// F1: Only pause for non-interrupt causes. An interrupt (user
Expand Down Expand Up @@ -468,7 +470,6 @@ const serviceLayer = Layer.effect(
return Option.none()
}),
),
),
)
const afterDispatch = yield* goal.load(sessionID)
if (!afterDispatch || afterDispatch.status !== "active") {
Expand Down
47 changes: 41 additions & 6 deletions packages/opencode/src/session/automation-lease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export interface Token {
readonly generation: number
}

export interface AfterFence<A, E = never, R = never> {
readonly activate: Effect.Effect<void>
readonly result: Effect.Effect<A, E, R>
readonly abort: Effect.Effect<void>
}

type Request =
| { readonly kind: "goal"; readonly id: string }
| { readonly kind: "dag" }
Expand All @@ -25,6 +31,10 @@ export interface Interface {
readonly unregister: (sessionID: SessionID, owner: Owner) => Effect.Effect<void>
readonly claim: (sessionID: SessionID, request: Request) => Effect.Effect<Option.Option<Token>>
readonly use: <A, E, R>(token: Token, effect: Effect.Effect<A, E, R>) => Effect.Effect<Option.Option<A>, E, R>
readonly handoff: <A, E, R, E2, R2>(
token: Token,
prepare: Effect.Effect<Option.Option<AfterFence<A, E, R>>, E2, R2>,
) => Effect.Effect<Option.Option<Effect.Effect<A, E, R>>, E2, R2>
/** Drop every registration and retry obligation for a session (session deletion). */
readonly purgeSession: (sessionID: SessionID) => Effect.Effect<void>
}
Expand Down Expand Up @@ -169,20 +179,45 @@ export const layer = Layer.effect(
})

const use: Interface["use"] = Effect.fn("SessionAutomationLease.use")(function* (token, effect) {
const valid = yield* locks.withLock(token.sessionID)(
Effect.sync(() => {
return yield* locks.withLock(token.sessionID)(
Effect.gen(function* () {
const current = registrations.get(token.sessionID)
const selected = owner(token.sessionID)
return !(
const valid = !(
!current ||
current.generation !== token.generation ||
selected?.kind !== token.owner.kind ||
selected.id !== token.owner.id
)
if (!valid) return Option.none()
return Option.some(yield* effect)
}),
)
})

const handoff: Interface["handoff"] = Effect.fn("SessionAutomationLease.handoff")(function* (token, prepare) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const prepared = yield* restore(
locks.withLock(token.sessionID)(
Effect.gen(function* () {
const current = registrations.get(token.sessionID)
const selected = owner(token.sessionID)
if (
!current ||
current.generation !== token.generation ||
selected?.kind !== token.owner.kind ||
selected.id !== token.owner.id
) return Option.none()
return yield* prepare
}),
),
)
if (Option.isNone(prepared)) return Option.none()
yield* prepared.value.activate.pipe(Effect.onError(() => prepared.value.abort))
return Option.some(prepared.value.result)
}),
)
if (!valid) return Option.none()
return Option.some(yield* effect)
})

// GOAL-FP-01-06: session deletion must drop every registration the session
Expand All @@ -200,7 +235,7 @@ export const layer = Layer.effect(
)
})

return Service.of({ register, unregister, claim, use, purgeSession })
return Service.of({ register, unregister, claim, use, handoff, purgeSession })
}),
)

Expand Down
51 changes: 44 additions & 7 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import { HookStartContext } from "@/hook/start-context"
import { Goal } from "@/goal/goal"
import { KeyedMutex } from "@opencode-ai/core/effect/keyed-mutex"
import { Memory } from "@/memory/memory"
import { SessionAutomationLease } from "./automation-lease"

// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
Expand Down Expand Up @@ -113,13 +114,20 @@ function isOrphanedInterruptedTool(part: SessionV1.ToolPart) {
export interface Interface {
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
readonly prompt: (input: PromptInput) => Effect.Effect<SessionV1.WithParts, Image.Error>
readonly prepareIfIdle: (input: PromptInput) => Effect.Effect<Option.Option<IdleAdmission>, Image.Error>
readonly promptIfIdle: (input: PromptInput) => Effect.Effect<Option.Option<SessionV1.WithParts>, Image.Error>
readonly loop: (input: LoopInput) => Effect.Effect<SessionV1.WithParts>
readonly shell: (input: ShellInput) => Effect.Effect<SessionV1.WithParts, Session.BusyError>
readonly command: (input: CommandInput) => Effect.Effect<SessionV1.WithParts, Image.Error>
readonly resolvePromptParts: (template: string) => Effect.Effect<PromptInput["parts"]>
}

export interface IdleAdmission {
readonly activate: Effect.Effect<void>
readonly result: Effect.Effect<SessionV1.WithParts>
readonly abort: Effect.Effect<void>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/SessionPrompt") {}

export const layer = Layer.effect(
Expand Down Expand Up @@ -1386,38 +1394,57 @@ export const layer = Layer.effect(
return yield* wait
})

const promptIfIdle: Interface["promptIfIdle"] = Effect.fn("SessionPrompt.promptIfIdle")(
const prepareIfIdle: Interface["prepareIfIdle"] = Effect.fn("SessionPrompt.prepareIfIdle")(
function* (input: PromptInput) {
const wait = yield* promptLocks.withLock(input.sessionID)(
return yield* promptLocks.withLock(input.sessionID)(
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const activation = yield* Deferred.make<void>()
const admission = yield* Deferred.make<
Exit.Exit<{ readonly message: SessionV1.WithParts; readonly run: boolean }, Image.Error>
>()
const wait = yield* state.startIfIdle(
input.sessionID,
lastAssistant(input.sessionID),
Effect.gen(function* () {
yield* Deferred.await(activation)
const admitted = yield* Deferred.await(admission)
if (Exit.isFailure(admitted)) return yield* Effect.failCause(admitted.cause)
if (!admitted.value.run) return admitted.value.message
return yield* runLoop(input.sessionID)
}).pipe(Effect.orDie),
)
if (Option.isNone(wait)) return wait
if (Option.isNone(wait)) return Option.none<IdleAdmission>()

const admitted = yield* restore(admitPrompt(input)).pipe(Effect.exit)
yield* Deferred.succeed(admission, admitted)
if (Exit.isFailure(admitted)) return yield* Effect.failCause(admitted.cause)
return wait
if (Exit.isFailure(admitted)) {
yield* Deferred.succeed(activation, undefined)
return yield* Effect.failCause(admitted.cause)
}
return Option.some({
activate: Deferred.succeed(activation, undefined).pipe(Effect.asVoid),
result: wait.value,
abort: state.cancel(input.sessionID),
})
}),
),
)
if (Option.isNone(wait)) return Option.none()
return Option.some(yield* wait.value)
},
)

const promptIfIdle: Interface["promptIfIdle"] = Effect.fn("SessionPrompt.promptIfIdle")(
(input: PromptInput) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const prepared = yield* restore(prepareIfIdle(input))
if (Option.isNone(prepared)) return Option.none()
yield* prepared.value.activate.pipe(Effect.onError(() => prepared.value.abort))
return Option.some(yield* restore(prepared.value.result))
}),
),
)

const lastAssistant = Effect.fnUntraced(function* (sessionID: SessionID) {
const match = yield* sessions.findMessage(sessionID, (m) => m.info.role !== "user").pipe(Effect.orDie)
if (Option.isSome(match)) return match.value
Expand Down Expand Up @@ -2097,6 +2124,7 @@ export const layer = Layer.effect(
return Service.of({
cancel,
prompt,
prepareIfIdle,
promptIfIdle,
loop,
shell,
Expand Down Expand Up @@ -2295,4 +2323,13 @@ export const node = LayerNode.make(layer, [
HookStartContext.node, SettingsHook.node, Goal.node,
])

export function admitIfIdle(
service: Interface,
automation: SessionAutomationLease.Interface,
token: SessionAutomationLease.Token,
input: PromptInput,
): Effect.Effect<Option.Option<Effect.Effect<SessionV1.WithParts>>, Image.Error> {
return automation.handoff(token, service.prepareIfIdle(input))
}

export * as SessionPrompt from "./prompt"
5 changes: 3 additions & 2 deletions packages/opencode/test/dag/dag-adoption-step-races.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { MessageID } from "@/session/schema"
import { Session } from "@/session/session"
import { SessionStatus } from "@/session/status"
import { pollWithTimeout } from "../lib/effect"
import { withIdleAdmission } from "../lib/session-prompt"

interface PromptGate {
readonly title: string
Expand Down Expand Up @@ -100,7 +101,7 @@ function raceLayer(input: {
}),
messages: (value) => input.messages(value as never) as never,
})
const prompt = Layer.mock(SessionPrompt.Service, {
const prompt = Layer.mock(SessionPrompt.Service, withIdleAdmission({
cancel: (sessionID) => Effect.sync(() => void input.cancelled.push(sessionID as string)),
prompt: Effect.fn("test.SessionPrompt.prompt")(function* (value: SessionPrompt.PromptInput) {
const sessionID = value.sessionID as string
Expand All @@ -113,7 +114,7 @@ function raceLayer(input: {
}),
// Keep wake delivery pending so the tests observe scheduling only.
promptIfIdle: () => Effect.succeed(Option.none()),
})
}))
const agent = Layer.mock(Agent.Service, {
get: () => Effect.succeed({
name: "build",
Expand Down
5 changes: 3 additions & 2 deletions packages/opencode/test/dag/dag-goal-wake-retrigger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { SessionPrompt } from "@/session/prompt"
import { Session } from "@/session/session"
import { SessionStatus } from "@/session/status"
import { pollWithTimeout } from "../lib/effect"
import { withIdleAdmission } from "../lib/session-prompt"

// GOAL-FP-01-02: the final DAG lease unregister (U2) lands AFTER the wake
// turn's idle event. GoalLoop's claim runs on idle while the dag registration
Expand Down Expand Up @@ -221,11 +222,11 @@ function goalWakeLayer(input: { childPrompts: Queue.Queue<ChildPromptGate>; fail
yield* Queue.offer(input.childPrompts, { title: childTitles.get(sessionID) ?? sessionID, release })
return reply(sessionID, yield* Deferred.await(release))
})
const prompt = Layer.mock(SessionPrompt.Service, {
const prompt = Layer.mock(SessionPrompt.Service, withIdleAdmission({
cancel: () => Effect.void,
prompt: deliver,
promptIfIdle: (value: SessionPrompt.PromptInput) => deliver(value).pipe(Effect.map(Option.some)),
})
}))
const agent = Layer.mock(Agent.Service, {
get: () =>
Effect.succeed({
Expand Down
Loading
Loading