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
8 changes: 8 additions & 0 deletions packages/client/src/effect/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1905,6 +1905,13 @@ export type ShellOutputOutput = {
}
export type ShellOutputOperation<E = never> = (input: ShellOutputInput) => Effect.Effect<ShellOutputOutput, E>

export type ShellStopInput = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type ShellStopOutput = { readonly location: Location.Info; readonly data: Shell.Info }
export type ShellStopOperation<E = never> = (input: ShellStopInput) => Effect.Effect<ShellStopOutput, E>

export type ShellRemoveInput = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
Expand All @@ -1918,6 +1925,7 @@ export interface ShellApi<E = never> {
readonly get: ShellGetOperation<E>
readonly timeout: ShellTimeoutOperation<E>
readonly output: ShellOutputOperation<E>
readonly stop: ShellStopOperation<E>
readonly remove: ShellRemoveOperation<E>
}

Expand Down
10 changes: 10 additions & 0 deletions packages/client/src/effect/generated/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,8 @@ import type {
ShellTimeoutOutput,
ShellOutputInput,
ShellOutputOutput,
ShellStopInput,
ShellStopOutput,
ShellRemoveInput,
ShellRemoveOutput,
ReferenceListInput,
Expand Down Expand Up @@ -1428,6 +1430,13 @@ const EndpointShellOutput = (raw: RawClient["server.shell"]) => (input: ShellOut
}).pipe(Effect.mapError(mapClientError)),
)

const EndpointShellStop = (raw: RawClient["server.shell"]) => (input: ShellStopInput) =>
preserveEffect<ShellStopOutput>()(
raw["shell.stop"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
),
)

const EndpointShellRemove = (raw: RawClient["server.shell"]) => (input: ShellRemoveInput) =>
preserveEffect<ShellRemoveOutput>()(
raw["shell.remove"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
Expand All @@ -1441,6 +1450,7 @@ const adaptGroupShell = (raw: RawClient["server.shell"]) => ({
get: EndpointShellGet(raw),
timeout: EndpointShellTimeout(raw),
output: EndpointShellOutput(raw),
stop: EndpointShellStop(raw),
remove: EndpointShellRemove(raw),
})

Expand Down
14 changes: 14 additions & 0 deletions packages/client/src/promise/generated/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ import type {
ShellTimeoutOutput,
ShellOutputInput,
ShellOutputOutput,
ShellStopInput,
ShellStopOutput,
ShellRemoveInput,
ShellRemoveOutput,
ReferenceListInput,
Expand Down Expand Up @@ -1940,6 +1942,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
stop: (input: ShellStopInput, requestOptions?: RequestOptions) =>
request<ShellStopOutput>(
{
method: "POST",
path: `/api/shell/${encodeURIComponent(input.id)}/stop`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
),
remove: (input: ShellRemoveInput, requestOptions?: RequestOptions) =>
request<ShellRemoveOutput>(
{
Expand Down
26 changes: 19 additions & 7 deletions packages/client/src/promise/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export type SessionMessageShell = {
type: "shell"
shellID: string
command: string
status: "running" | "exited" | "timeout" | "killed"
status: "running" | "exited" | "timeout" | "killed" | "unavailable"
exit?: number | "Infinity" | "-Infinity" | "NaN"
output?: { output: string; cursor: number; size: number; truncated: boolean }
}
Expand Down Expand Up @@ -154,7 +154,7 @@ export type SessionInboxSyntheticPayload1 = { text: string; description?: string

export type ShellInfo = {
id: string
status: "running" | "exited" | "timeout" | "killed"
status: "running" | "exited" | "timeout" | "killed" | "unavailable"
command: string
cwd: string
shell: string
Expand Down Expand Up @@ -386,7 +386,7 @@ export type PersistentPtyHandoff = { directory: string; instanceID: string; tick

export type ShellInfo1 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
status: "running" | "exited" | "timeout" | "killed" | "unavailable"
command: string
cwd: string
shell: string
Expand Down Expand Up @@ -1065,7 +1065,7 @@ export type ShellExited = {
metadata?: { [x: string]: any }
type: "shell.exited"
location?: LocationRef
data: { id: string; exit?: number; status: "running" | "exited" | "timeout" | "killed" }
data: { id: string; exit?: number; status: "running" | "exited" | "timeout" | "killed" | "unavailable" }
}

export type ShellDeleted = {
Expand Down Expand Up @@ -2945,7 +2945,7 @@ export type SessionImportInput = {
readonly type: "shell"
readonly shellID: string
readonly command: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly status: "running" | "exited" | "timeout" | "killed" | "unavailable"
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
readonly output?: {
readonly output: string
Expand Down Expand Up @@ -3224,7 +3224,7 @@ export type SessionImportInput = {
readonly type: "shell"
readonly shellID: string
readonly command: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly status: "running" | "exited" | "timeout" | "killed" | "unavailable"
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
readonly output?: {
readonly output: string
Expand Down Expand Up @@ -3503,7 +3503,7 @@ export type SessionImportInput = {
readonly type: "shell"
readonly shellID: string
readonly command: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly status: "running" | "exited" | "timeout" | "killed" | "unavailable"
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
readonly output?: {
readonly output: string
Expand Down Expand Up @@ -6057,6 +6057,18 @@ export type ShellOutputOutput = {
data: { output: string; cursor: number; size: number; truncated: boolean }
}

export type ShellStopInput = {
readonly id: { readonly id: string }["id"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}

export type ShellStopOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: ShellInfo1
}

export type ShellRemoveInput = {
readonly id: { readonly id: string }["id"]
readonly location?: {
Expand Down
2 changes: 1 addition & 1 deletion packages/client/test/promise.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.pty.connect)).toEqual(["token"])
expect(Object.keys(client.experimental)).toEqual(["persistentPty"])
expect(client.experimental.persistentPty.read).toBeFunction()
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "stop", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "update", "current"])
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
})
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/config/plugin/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export const Plugin = define({
description: command.description ?? name,
}
yield* subagents.start(recovery)
yield* subagents.background(recovery)
yield* subagents.background(child.id)
return
}
if (agent !== undefined) {
Expand Down
37 changes: 26 additions & 11 deletions packages/core/src/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ import { Identifier } from "./id/id.js"
import { KV } from "./kv.js"
import { SessionMessage } from "./session/message.js"
import { SessionSchema } from "./session/schema.js"
import { ShellResult } from "./shell/result.js"
import { SubagentOutcome } from "./session/subagent-outcome.js"

/**
* The producer's typed account of how its work ended. A job never classifies the work itself:
* `status` says whether the run reported an outcome (`completed`), died (`error`), or was
* abandoned before reporting (`cancelled`). A user stop, a timeout, or a nonzero exit are all
* `completed` runs whose outcome says so.
*/
export const Outcome = Schema.Union([ShellResult.Outcome, SubagentOutcome.Outcome])
export type Outcome = typeof Outcome.Type

const Background = Schema.Struct({
id: Schema.String,
Expand All @@ -26,13 +37,17 @@ const Background = Schema.Struct({
}),
]),
status: Schema.Literals(["running", "completed", "error", "cancelled"]),
result: Schema.optionalKey(Outcome),
// Read-only compatibility with markers written before typed outcomes. Never reconstruct facts from this text.
output: Schema.optionalKey(Schema.String),
error: Schema.optionalKey(Schema.String),
})

export type Background = typeof Background.Type
export type Recovery = Background["recovery"]
export type Status = Background["status"]
/** One job's terminal facts, shared by live Info and the durable background marker. */
export type Terminal = Pick<Background, "status" | "result" | "output" | "error">

const decodeBackground = Schema.decodeUnknownResult(Background)
const backgroundPrefix = "job.background/"
Expand All @@ -44,10 +59,11 @@ export type Info = {
status: Status
started_at: number
completed_at?: number
output?: string
result?: Outcome
error?: string
metadata?: Record<string, unknown>
notificationID?: SessionMessage.ID
recovery?: Recovery
}

type Active = {
Expand All @@ -57,7 +73,6 @@ type Active = {
scope: Scope.Closeable
blockingSessions: Map<SessionSchema.ID, number>
isBackgrounded: boolean
recovery?: Recovery
}

type State = {
Expand Down Expand Up @@ -96,7 +111,7 @@ export type StartInput = {
metadata?: Record<string, unknown>
recovery?: Recovery
notificationID?: SessionMessage.ID
run: Effect.Effect<string, unknown>
run: Effect.Effect<Outcome, unknown>
}

export type WaitInput = {
Expand Down Expand Up @@ -172,18 +187,18 @@ export const make = Effect.gen(function* () {
}

const persistBackground = Effect.fnUntraced(function* (job: Active) {
if (!job.recovery || !job.info.notificationID) return
if (!job.info.recovery || !job.info.notificationID) return
yield* kv.set(`${backgroundPrefix}${job.info.notificationID}`, {
id: job.info.id,
notificationID: job.info.notificationID,
recovery: job.recovery,
recovery: job.info.recovery,
status: job.info.status,
...(job.info.output !== undefined ? { output: job.info.output } : {}),
...(job.info.result !== undefined ? { result: job.info.result } : {}),
...(job.info.error !== undefined ? { error: job.info.error } : {}),
})
})

const settle = Effect.fnUntraced(function* (id: string, scope: Scope.Closeable, exit: Exit.Exit<string, unknown>) {
const settle = Effect.fnUntraced(function* (id: string, scope: Scope.Closeable, exit: Exit.Exit<Outcome, unknown>) {
const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Expand All @@ -204,7 +219,7 @@ export const make = Effect.gen(function* () {
...job.info,
status,
completed_at,
...(Exit.isSuccess(exit) ? { output: exit.value } : {}),
...(Exit.isSuccess(exit) ? { result: exit.value } : {}),
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
},
}
Expand Down Expand Up @@ -248,14 +263,14 @@ export const make = Effect.gen(function* () {
status: "running" as const,
started_at,
metadata: input.metadata,
recovery: input.recovery,
...(input.notificationID ? { notificationID: input.notificationID } : {}),
},
done,
backgrounded,
scope,
blockingSessions: new Map<SessionSchema.ID, number>(),
isBackgrounded: false,
recovery: input.recovery,
}
return [{ info: snapshot(job), scope }, new Map(jobs).set(id, job)]
}),
Expand Down Expand Up @@ -324,7 +339,7 @@ export const make = Effect.gen(function* () {
blockingSessions: new Map<SessionSchema.ID, number>(),
info: {
...job.info,
...(job.recovery ? { notificationID: job.info.notificationID ?? SessionMessage.ID.create() } : {}),
...(job.info.recovery ? { notificationID: job.info.notificationID ?? SessionMessage.ID.create() } : {}),
},
}
yield* persistBackground(next)
Expand All @@ -337,7 +352,7 @@ export const make = Effect.gen(function* () {
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [BackgroundResult, Map<string, Active>]> {
const job = jobs.get(id)
// Recoverable work may finish before the caller backgrounds it.
if (!job || (job.info.status !== "running" && !job.recovery)) return [{}, jobs]
if (!job || (job.info.status !== "running" && !job.info.recovery)) return [{}, jobs]
if (job.isBackgrounded) return [{ info: snapshot(job) }, jobs]
const next = yield* markBackground(job)
return [{ info: snapshot(next), backgrounded: job.backgrounded }, new Map(jobs).set(id, next)]
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,9 @@ export interface Interface {
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
readonly resume: (
sessionID: SessionSchema.ID,
) => Effect.Effect<SessionExecution.Terminal, NotFoundError | SessionRunner.RunError>
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<boolean>
readonly synthetic: (
input: Parameters<Session.Handle["synthetic"]>[0] & { sessionID: SessionSchema.ID },
Expand Down
Loading
Loading