Skip to content

Commit 9a61dfb

Browse files
committed
feat(core): add execution lifecycle and retries
1 parent 4145cfe commit 9a61dfb

61 files changed

Lines changed: 2694 additions & 537 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/client/src/promise/generated/types.ts

Lines changed: 521 additions & 36 deletions
Large diffs are not rendered by default.

packages/core/src/database/migration.gen.ts

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Effect } from "effect"
2+
import type { DatabaseMigration } from "../migration"
3+
4+
export default {
5+
id: "20260703210000_reset_v2_execution_errors",
6+
up(tx) {
7+
return Effect.gen(function* () {
8+
yield* tx.run(`DELETE FROM \`session_input\`;`)
9+
yield* tx.run(`DELETE FROM \`session_message\`;`)
10+
yield* tx.run(`DELETE FROM \`event\`;`)
11+
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
12+
})
13+
},
14+
} satisfies DatabaseMigration.Migration

packages/core/src/permission.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,14 @@ export type AskResult = typeof AskResult.Type
5959

6060
export const Event = Permission.Event
6161

62-
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionV2.RejectedError", {}) {}
62+
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionV2.RejectedError", {
63+
permission: Schema.String,
64+
resources: Schema.Array(Schema.String),
65+
}) {
66+
override get message() {
67+
return `Permission rejected: ${this.permission}`
68+
}
69+
}
6370

6471
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionV2.CorrectedError", {
6572
feedback: Schema.String,
@@ -119,9 +126,17 @@ const layer = Layer.effect(
119126
const pending = new Map<ID, Pending>()
120127

121128
yield* Effect.addFinalizer(() =>
122-
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), {
123-
discard: true,
124-
}).pipe(
129+
Effect.forEach(
130+
pending.values(),
131+
(item) =>
132+
Deferred.fail(
133+
item.deferred,
134+
new RejectedError({ permission: item.request.action, resources: [...item.request.resources] }),
135+
),
136+
{
137+
discard: true,
138+
},
139+
).pipe(
125140
Effect.ensuring(
126141
Effect.sync(() => {
127142
pending.clear()
@@ -230,7 +245,12 @@ const layer = Layer.effect(
230245
if (input.reply === "reject") {
231246
yield* Deferred.fail(
232247
existing.deferred,
233-
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
248+
input.message
249+
? new CorrectedError({ feedback: input.message })
250+
: new RejectedError({
251+
permission: existing.request.action,
252+
resources: [...existing.request.resources],
253+
}),
234254
)
235255
pending.delete(input.requestID)
236256
for (const [id, item] of pending) {
@@ -240,7 +260,10 @@ const layer = Layer.effect(
240260
requestID: item.request.id,
241261
reply: "reject",
242262
})
243-
yield* Deferred.fail(item.deferred, new RejectedError())
263+
yield* Deferred.fail(
264+
item.deferred,
265+
new RejectedError({ permission: item.request.action, resources: [...item.request.resources] }),
266+
)
244267
pending.delete(id)
245268
}
246269
return

packages/core/src/session/error.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Schema } from "effect"
22
import { SessionMessage } from "./message"
33
import { SessionSchema } from "./schema"
4+
import { SessionError } from "@opencode-ai/schema/session-error"
45

56
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
67
sessionID: SessionSchema.ID,
@@ -10,3 +11,20 @@ export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeErr
1011
return `Failed to decode message ${this.messageID} in session ${this.sessionID}`
1112
}
1213
}
14+
15+
export class StepFailedError extends Schema.TaggedErrorClass<StepFailedError>()("Session.StepFailedError", {
16+
error: SessionError.Error,
17+
}) {
18+
override get message() {
19+
return this.error.message
20+
}
21+
}
22+
23+
export class UserInterruptedError extends Schema.TaggedErrorClass<UserInterruptedError>()(
24+
"Session.UserInterruptedError",
25+
{},
26+
) {
27+
override get message() {
28+
return "Session interrupted by user"
29+
}
30+
}

packages/core/src/session/execution/local.ts

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Cause, DateTime, Effect, Exit, Layer } from "effect"
1+
import { Cause, Effect, Exit, Layer } from "effect"
22
import { EventV2 } from "../../event"
33
import { LocationServiceMap } from "../../location-service-map"
44
import { makeGlobalNode } from "../../effect/app-node"
@@ -8,6 +8,16 @@ import { SessionRunner } from "../runner"
88
import { SessionSchema } from "../schema"
99
import { SessionStore } from "../store"
1010
import { SessionExecution } from "../execution"
11+
import { toSessionError } from "../to-session-error"
12+
import { UserInterruptedError } from "../error"
13+
14+
export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?: "user" | "shutdown" | "superseded") {
15+
if (Exit.isSuccess(exit)) return { type: "succeeded" as const }
16+
if (Cause.hasInterrupts(exit.cause)) return { type: "interrupted" as const, reason: reason ?? "shutdown" }
17+
const failure = Cause.squash(exit.cause)
18+
if (failure instanceof UserInterruptedError) return { type: "interrupted" as const, reason: "user" as const }
19+
return { type: "failed" as const, error: toSessionError(failure) }
20+
}
1121

1222
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
1323
const layer = Layer.effect(
@@ -16,7 +26,23 @@ const layer = Layer.effect(
1626
const store = yield* SessionStore.Service
1727
const locations = yield* LocationServiceMap.Service
1828
const events = yield* EventV2.Service
19-
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
29+
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
30+
effect.pipe(
31+
Effect.tapCause((cause) =>
32+
Cause.hasInterruptsOnly(cause)
33+
? Effect.void
34+
: Effect.logError("Failed to publish Session execution lifecycle", cause).pipe(
35+
Effect.annotateLogs({ sessionID }),
36+
),
37+
),
38+
Effect.asVoid,
39+
)
40+
const coordinator = yield* SessionRunCoordinator.make<
41+
SessionSchema.ID,
42+
SessionRunner.RunError,
43+
"user" | "shutdown" | "superseded"
44+
>({
45+
started: (sessionID) => reportLifecycle(sessionID, events.publish(SessionEvent.Execution.Started, { sessionID })),
2046
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
2147
const session = yield* store.get(sessionID)
2248
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
@@ -29,28 +55,31 @@ const layer = Layer.effect(
2955
),
3056
)
3157
}),
32-
// One ExecutionSettled per execution (busy period), covering every coalesced drain.
33-
settled: (sessionID, exit) =>
34-
Effect.gen(function* () {
35-
const failure =
36-
Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause) ? Cause.squash(exit.cause) : undefined
37-
yield* events.publish(SessionEvent.ExecutionSettled, {
38-
sessionID,
39-
outcome: Exit.isSuccess(exit) ? "success" : Cause.hasInterrupts(exit.cause) ? "interrupted" : "failure",
40-
error:
41-
failure !== undefined
42-
? { type: "unknown", message: failure instanceof Error ? failure.message : String(failure) }
43-
: undefined,
44-
})
45-
}).pipe(
46-
Effect.catchCause(() => Effect.void),
47-
Effect.asVoid,
58+
// One terminal observation per busy period, covering every coalesced drain.
59+
settled: (sessionID, exit, reason) =>
60+
reportLifecycle(
61+
sessionID,
62+
Effect.gen(function* () {
63+
const outcome = terminal(exit, reason)
64+
if (outcome.type === "succeeded") {
65+
yield* events.publish(SessionEvent.Execution.Succeeded, { sessionID })
66+
return
67+
}
68+
if (outcome.type === "interrupted") {
69+
yield* events.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: outcome.reason })
70+
return
71+
}
72+
yield* events.publish(SessionEvent.Execution.Failed, {
73+
sessionID,
74+
error: outcome.error,
75+
})
76+
}),
4877
),
4978
})
5079

5180
return SessionExecution.Service.of({
5281
active: coordinator.active,
53-
interrupt: coordinator.interrupt,
82+
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"),
5483
resume: coordinator.run,
5584
wake: coordinator.wake,
5685
awaitIdle: coordinator.awaitIdle,

packages/core/src/session/message-updater.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { castDraft, produce, type WritableDraft } from "immer"
2-
import { Effect } from "effect"
2+
import { DateTime, Effect } from "effect"
33
import { SessionEvent } from "./event"
44
import { SessionMessage } from "./message"
55

@@ -111,6 +111,17 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
111111
if (assistant) yield* adapter.updateAssistant(produce(assistant, recipe))
112112
})
113113

114+
const clearCurrentRetry = Effect.gen(function* () {
115+
const assistant = yield* adapter.getCurrentAssistant()
116+
if (assistant?.retry) {
117+
yield* adapter.updateAssistant(
118+
produce(assistant, (draft) => {
119+
draft.retry = undefined
120+
}),
121+
)
122+
}
123+
})
124+
114125
return Effect.gen(function* () {
115126
yield* SessionEvent.All.match(event, {
116127
"session.agent.selected": (event) => {
@@ -144,7 +155,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
144155
"session.forked": () => Effect.void,
145156
"session.prompt.promoted": () => Effect.void,
146157
"session.prompt.admitted": () => Effect.void,
147-
"session.execution.settled": () => Effect.void,
158+
"session.execution.started": () => Effect.void,
159+
"session.execution.succeeded": () => clearCurrentRetry,
160+
"session.execution.failed": () => clearCurrentRetry,
161+
"session.execution.interrupted": () => clearCurrentRetry,
148162
"session.context.updated": (event) =>
149163
adapter.appendMessage(
150164
SessionMessage.System.make({
@@ -205,10 +219,26 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
205219
},
206220
"session.step.started": (event) => {
207221
return Effect.gen(function* () {
222+
const existing = yield* adapter.getAssistant(event.data.assistantMessageID)
223+
if (existing) {
224+
yield* adapter.updateAssistant(
225+
produce(existing, (draft) => {
226+
draft.agent = event.data.agent
227+
draft.model = castDraft(event.data.model)
228+
draft.retry = undefined
229+
draft.error = undefined
230+
draft.finish = undefined
231+
draft.time.completed = undefined
232+
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, start: event.data.snapshot }
233+
}),
234+
)
235+
return
236+
}
208237
const currentAssistant = yield* adapter.getCurrentAssistant()
209238
if (currentAssistant) {
210239
yield* adapter.updateAssistant(
211240
produce(currentAssistant, (draft) => {
241+
draft.retry = undefined
212242
draft.time.completed = event.created
213243
}),
214244
)
@@ -244,7 +274,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
244274
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
245275
draft.time.completed = event.created
246276
draft.finish = "error"
247-
draft.error = event.data.error
277+
draft.error = castDraft(event.data.error)
278+
draft.retry = undefined
248279
})
249280
},
250281
"session.text.started": (event) => {
@@ -383,7 +414,15 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
383414
}
384415
})
385416
},
386-
"session.retried": () => Effect.void,
417+
"session.retry.scheduled": (event) => {
418+
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
419+
draft.retry = {
420+
attempt: event.data.attempt,
421+
at: DateTime.makeUnsafe(event.data.at),
422+
error: castDraft(event.data.error),
423+
}
424+
})
425+
},
387426
"session.compaction.started": () => Effect.void,
388427
"session.compaction.delta": () => Effect.void,
389428
"session.compaction.ended": (event) => {

packages/core/src/session/projector.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,9 @@ const layer = Layer.effectDiscard(
634634
})
635635
}),
636636
)
637+
yield* events.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
638+
yield* events.project(SessionEvent.Execution.Failed, (event) => run(db, event))
639+
yield* events.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
637640
yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event))
638641
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
639642
yield* events.project(SessionEvent.Skill.Activated, (event) =>
@@ -660,7 +663,7 @@ const layer = Layer.effectDiscard(
660663
yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event))
661664
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
662665
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
663-
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
666+
yield* events.project(SessionEvent.RetryScheduled, (event) => run(db, event))
664667
yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
665668
yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
666669
db

0 commit comments

Comments
 (0)