Compare commits

...
Author SHA1 Message Date
Kit Langton 03f18a93e4 fix(core): deliver idle synthetic notices without resuming 2026-08-26 11:04:00 -04:00
3 changed files with 111 additions and 21 deletions
+17 -2
View File
@@ -307,6 +307,7 @@ export interface Interface {
readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<boolean>
/** Non-resuming steers enter history immediately when idle and next in steer order; otherwise they stay pending. */
readonly synthetic: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
@@ -952,8 +953,22 @@ const layer = Layer.effect(
// retried payload, metadata, and delivery mode.
if (admitted.type !== "synthetic" || admitted.sessionID !== input.sessionID)
return yield* new SyntheticConflictError({ sessionID: input.sessionID, inputID })
if (input.resume !== false && !(yield* result.get(input.sessionID)).revert)
yield* execution.wake(input.sessionID)
if (!(yield* result.get(input.sessionID)).revert) {
if (input.resume !== false) yield* execution.wake(input.sessionID)
if (input.resume === false && admitted.delivery === "steer")
yield* SessionInbox.serialized(
input.sessionID,
Effect.gen(function* () {
if ((yield* execution.active).has(input.sessionID)) return
// Do not bypass earlier steers or consume user input outside the runner.
if ((yield* SessionInbox.nextPromotable(db, input.sessionID, "steer"))?.id !== admitted.id) return
yield* bus.publish(SessionEvent.InboxDelivered, {
sessionID: input.sessionID,
inboxID: admitted.id,
})
}),
)
}
return admitted
}),
),
+92 -19
View File
@@ -76,8 +76,7 @@ const locations = Layer.effect(
Layer.mock(Snapshot.Service, {
capture: () =>
ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () =>
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
restore: () => (ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready"))),
}),
Layer.succeed(
PluginSupervisor.Service,
@@ -102,6 +101,7 @@ const sessionID = Session.ID.make("ses_prompt_test")
const messageID = SessionMessage.ID.create()
const setup = Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.sync(() => activeSessions.clear()))
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
@@ -302,13 +302,17 @@ describe("Session.prompt", () => {
wakeCalls.length = 0
const completion = yield* session.synthetic({ sessionID, text: "stale completion" })
const notice = yield* session.synthetic({ sessionID, text: "non-resuming completion", resume: false })
expect(wakeCalls).toEqual([])
expect(yield* SessionInbox.find(db, completion.id)).toMatchObject({ type: "synthetic" })
expect(yield* SessionInbox.find(db, notice.id)).toMatchObject({ type: "synthetic" })
expect(yield* session.message({ sessionID, messageID: notice.id })).toBeUndefined()
yield* session.revert.commit(sessionID)
expect(yield* SessionInbox.find(db, completion.id)).toBeUndefined()
expect(yield* SessionInbox.find(db, notice.id)).toBeUndefined()
}),
)
@@ -937,12 +941,11 @@ describe("Session.prompt", () => {
}),
)
it.effect("durably admits synthetic input before transcript promotion", () =>
it.effect("immediately delivers an idle synthetic message without resuming execution", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
wakeCalls.length = 0
const input = yield* session.synthetic({
id: messageID,
@@ -953,20 +956,6 @@ describe("Session.prompt", () => {
resume: false,
})
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admitted(input.id)).toMatchObject({
type: "synthetic",
sessionID,
delivery: "steer",
payload: {
text: "Background work completed",
description: "shell completion",
metadata: { job: "shell" },
},
})
yield* SessionInbox.promote(db, bus, sessionID, "steer")
expect(yield* session.messages({ sessionID })).toMatchObject([
{
id: messageID,
@@ -976,6 +965,90 @@ describe("Session.prompt", () => {
metadata: { job: "shell" },
},
])
expect(yield* admitted(input.id)).toBeUndefined()
expect(wakeCalls).toEqual([])
}),
)
it.effect("keeps a synthetic steer pending while its session is already running", () =>
Effect.gen(function* () {
yield* setup
activeSessions.add(sessionID)
const session = yield* Session.Service
const input = yield* session.synthetic({ sessionID, text: "Background work completed", resume: false })
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admitted(input.id)).toMatchObject({ type: "synthetic", delivery: "steer" })
}),
)
it.effect("keeps an idle synthetic notice behind an earlier user steer", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const bus = yield* Bus.Service
const database = yield* Database.Service
wakeCalls.length = 0
const user = yield* session.prompt({ sessionID, text: "Prepare before running", resume: false })
const notice = yield* session.synthetic({ sessionID, text: "Background finished", resume: false })
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* session.inbox(sessionID)).toMatchObject([{ id: user.id }, { id: notice.id }])
expect(wakeCalls).toEqual([])
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
expect(yield* session.messages({ sessionID, order: "asc" })).toMatchObject([
{ id: user.id, type: "user" },
{ id: notice.id, type: "synthetic" },
])
}),
)
it.effect("keeps an idle synthetic notice behind a pending control item", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const control = yield* session.compact({ sessionID })
wakeCalls.length = 0
const notice = yield* session.synthetic({ sessionID, text: "Background finished", resume: false })
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* session.inbox(sessionID)).toMatchObject([
{ id: control.id, type: "compaction" },
{ id: notice.id, type: "synthetic" },
])
expect(wakeCalls).toEqual([])
}),
)
it.effect("delivers only the retried idle notice without consuming later user work", () =>
Effect.gen(function* () {
yield* setup
activeSessions.add(sessionID)
const session = yield* Session.Service
const input = { sessionID, id: messageID, text: "Background finished", resume: false }
yield* session.synthetic(input)
const user = yield* session.prompt({ sessionID, text: "Later work", resume: false })
activeSessions.delete(sessionID)
wakeCalls.length = 0
yield* session.synthetic(input)
expect(yield* session.messages({ sessionID })).toMatchObject([{ id: messageID, type: "synthetic" }])
expect(yield* session.inbox(sessionID)).toMatchObject([{ id: user.id, type: "user" }])
expect(wakeCalls).toEqual([])
}),
)
it.effect("delivers an idle synthetic steer while queued work stays pending", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const queued = yield* session.prompt({ sessionID, text: "Queued work", delivery: "queue", resume: false })
wakeCalls.length = 0
const notice = yield* session.synthetic({ sessionID, text: "Background finished", resume: false })
expect(yield* session.messages({ sessionID })).toMatchObject([{ id: notice.id, type: "synthetic" }])
expect(yield* session.inbox(sessionID)).toMatchObject([{ id: queued.id, delivery: "queue" }])
expect(wakeCalls).toEqual([])
}),
)
+2
View File
@@ -6,6 +6,8 @@ Status: **Current semantic overview.** Protocol owns public operations, Schema o
`Session.prompt(...)` publishes one durable `session.inbox.enqueued` fact whose projection inserts one `session_inbox` row before advisory execution begins. An inbox item remains outside model-visible Session History until delivery. The `session.inbox.delivered` projection consumes the row and inserts a visible user or synthetic message atomically; compaction and move control items are consumed without becoming transcript messages.
`Session.synthetic({ resume: false })` admits without waking execution and delivers the notice immediately when the Session is idle, no revert is staged, and that notice is next in steer order. Delivery consumes only that notice, not neighboring inbox items. Running Sessions, earlier steers or control items, and explicit queue delivery keep the notice pending for the normal runner boundary. User prompts remain pending until runner delivery, including when `resume: false`.
Reusing a Session ID adopts the existing Session. While a user or synthetic item remains pending, reusing its ID reconciles only when Session, item type, complete payload, metadata, and delivery match; conflicting reuse fails. After delivery, retry reconciliation for those message-producing items uses the projected message and does not require enqueue history or the original delivery mode. Compaction and move controls retain operation-specific conflict behavior.
`resume` controls scheduling, not durability: