Skip to content
Open
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
29 changes: 29 additions & 0 deletions packages/core/src/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ export type BackgroundAllInput = {
type?: string
}

export type CancelAllInput = {
sessionID: SessionSchema.ID
type?: string
}

export interface Interface {
readonly get: (id: string) => Effect.Effect<Info | undefined>
readonly start: (input: StartInput) => Effect.Effect<Info>
Expand All @@ -138,6 +143,7 @@ export interface Interface {
readonly background: (id: string) => Effect.Effect<Info | undefined>
readonly backgroundAll: (input: BackgroundAllInput) => Effect.Effect<Info[]>
readonly cancel: (id: string) => Effect.Effect<Info | undefined>
readonly cancelAll: (input: CancelAllInput) => Effect.Effect<Info[]>
readonly pendingBackground: Effect.Effect<readonly Background[]>
readonly runningBackground: Effect.Effect<readonly Recovery[]>
readonly completeBackground: (notificationID: SessionMessage.ID) => Effect.Effect<void>
Expand Down Expand Up @@ -445,6 +451,28 @@ export const make = Effect.gen(function* () {
return result.info
})

const cancelAll: Interface["cancelAll"] = Effect.fn("Job.cancelAll")(function* (input) {
const owned = yield* SynchronizedRef.modifyEffect(
state.jobs,
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [string[], Map<string, Active>]> {
const ids: string[] = []
for (const [id, job] of jobs) {
if (job.info.status !== "running") continue
if (input.type !== undefined && job.info.type !== input.type) continue
if (job.info.metadata?.sessionID !== input.sessionID) continue
ids.push(id)
}
return [ids, jobs]
}),
)
const cancelled: Info[] = []
for (const id of owned) {
const info = yield* cancel(id)
if (info) cancelled.push(info)
}
return cancelled
})

const pendingBackground: Interface["pendingBackground"] = Effect.gen(function* () {
const recovered: Background[] = []
let after: string | undefined
Expand Down Expand Up @@ -486,6 +514,7 @@ export const make = Effect.gen(function* () {
background,
backgroundAll,
cancel,
cancelAll,
pendingBackground,
runningBackground,
completeBackground,
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/session/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,12 @@ export const layer = Layer.effect(
}
if (outcome.type === "interrupted") {
// Deliberate stops release the claim; shutdown keeps it for restart continuity.
if (outcome.reason !== "shutdown") yield* jobs.cancel(sessionID)
if (outcome.reason !== "shutdown") {
// A session running as a subagent job is keyed by its own id; owned
// background work (shell, image) carries the session in job metadata.
yield* jobs.cancel(sessionID)
yield* jobs.cancelAll({ sessionID })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cancels the shell, but notifyWhenDone in tool/plugin/shell.ts then posts the state="cancelled" notice through sessions.synthetic(...) without resume: false. Session.synthetic calls execution.wake unless resume === false, so the session starts a new turn as soon as the interrupt settles. I reproduced this with the #60107 script: model request #3 carries <shell … state="cancelled">Cancelled</shell>. The image job's notifier probably behaves the same way. For the interrupt to actually stop the session, the cancelled notice should be recorded without waking it, and a test should cover interrupt → no new turn.

}
yield* bus.publish(
SessionEvent.Execution.Interrupted,
{ sessionID, reason: outcome.reason },
Expand Down
57 changes: 57 additions & 0 deletions packages/core/test/job.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,4 +450,61 @@ describe("Job", () => {
expect((yield* jobs.get(job.id))?.status).toBe("running")
}),
)

it.live("cancels running jobs owned by the session and leaves foreign work alone", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const owner = SessionSchema.ID.make("ses_owner")
const other = SessionSchema.ID.make("ses_other")
const latch = yield* Deferred.make<void>()
const finish = Deferred.await(latch).pipe(Effect.as("done"))
const owned = yield* jobs.start({
id: "job_owned_shell",
type: "shell",
metadata: { sessionID: owner },
run: finish,
})
const foreign = yield* jobs.start({
id: "job_foreign_shell",
type: "shell",
metadata: { sessionID: other },
run: finish,
})
const unowned = yield* jobs.start({ id: "job_unowned", type: "test", run: finish })

const cancelled = yield* jobs.cancelAll({ sessionID: owner })

expect(cancelled.map((info) => info.id)).toEqual([owned.id])
expect(yield* jobs.get(owned.id)).toMatchObject({ status: "cancelled" })
expect(yield* jobs.get(foreign.id)).toMatchObject({ status: "running" })
expect(yield* jobs.get(unowned.id)).toMatchObject({ status: "running" })
yield* Deferred.succeed(latch, undefined)
expect((yield* jobs.wait({ id: foreign.id })).info).toMatchObject({ status: "completed" })
expect((yield* jobs.wait({ id: unowned.id })).info).toMatchObject({ status: "completed" })
expect((yield* jobs.wait({ id: owned.id })).info).toMatchObject({ status: "cancelled" })
}),
)

it.live("filters cancelAll by job type", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const owner = SessionSchema.ID.make("ses_filter")
const latch = yield* Deferred.make<void>()
const finish = Deferred.await(latch).pipe(Effect.as("done"))
yield* jobs.start({ id: "job_filter_shell", type: "shell", metadata: { sessionID: owner }, run: finish })
const image = yield* jobs.start({
id: "job_filter_image",
type: "image",
metadata: { sessionID: owner },
run: finish,
})

const cancelled = yield* jobs.cancelAll({ sessionID: owner, type: "image" })

expect(cancelled.map((info) => info.id)).toEqual([image.id])
expect(yield* jobs.get("job_filter_shell")).toMatchObject({ status: "running" })
yield* Deferred.succeed(latch, undefined)
expect((yield* jobs.wait({ id: "job_filter_shell" })).info).toMatchObject({ status: "completed" })
}),
)
})
2 changes: 2 additions & 0 deletions packages/tui/src/config/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
keybinds: TuiKeybind.BindingLookupView
leader: { timeout: number }
mouse: boolean
animations: boolean
cursor?: {
style: "block" | "underline" | "line" | "default"
blinking: boolean
Expand Down Expand Up @@ -315,6 +316,7 @@ export function resolve(
}),
leader: { timeout: input.leader?.timeout ?? 2000 },
mouse: input.mouse ?? true,
animations: input.animations ?? (options.environment ?? process.env).HERDR_ENV !== "1",
cursor: input.cursor
? {
style: input.cursor.style ?? "block",
Expand Down
9 changes: 9 additions & 0 deletions packages/tui/test/config-v2.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ test("resolves automatic tabs from the terminal environment", () => {
).toMatchObject({ mode: "on", enabled: true })
})

test("resolves animations off by default inside herdr panes", () => {
expect(resolve({}, { terminalSuspend: true, environment: {} }).animations).toBe(true)
expect(resolve({}, { terminalSuspend: true, environment: { HERDR_ENV: "1" } }).animations).toBe(false)
expect(resolve({ animations: true }, { terminalSuspend: true, environment: { HERDR_ENV: "1" } }).animations).toBe(
true,
)
expect(resolve({ animations: false }, { terminalSuspend: true, environment: {} }).animations).toBe(false)
})

test("shows resolved tab defaults in settings", () => {
expect(settings.find((setting) => setting.path.join(".") === "tabs.mode")).toMatchObject({
default: "auto",
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/test/mini/footer.view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1930,7 +1930,7 @@ test.each([8, 12])("production footer grows for wrapped instructions in %i rows"
test("an oversized queue shortcut prevents backfilling shorter work and identity hints", async () => {
const app = await renderFooter({
width: 32,
tuiConfig: createTuiResolvedConfig({ keybinds: { "session.queued_prompts": "ctrl+shift+alt+q" } }),
tuiConfig: createTuiResolvedConfig({ animations: false, keybinds: { "session.queued_prompts": "ctrl+shift+alt+q" } }),
queuedPrompts: [{ messageID: "queued", prompt: { text: "later", parts: [] }, delivery: "queue" }],
subagents: {
tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect" })],
Expand Down
Loading