mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 01:51:21 +08:00
feat(cli): add scheduled wakeup and cancel tools (#14094)
https://github.com/Kilo-Org/kilocode/pull/14094
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
---
|
||||
|
||||
Support scheduling and cancelling future wakeups: the agent can ask to resume a session at a later time, see what it scheduled, and cancel a pending wakeup before it fires.
|
||||
@@ -57,6 +57,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Notebook } from "@/kilocode/notebook/service"
|
||||
import { SessionDrain } from "@/kilocode/session/drain"
|
||||
import { AgentManager } from "@/kilocode/agent-manager/service"
|
||||
import { Wakeup } from "@/kilocode/wakeup"
|
||||
// kilocode_change end
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
@@ -77,6 +78,7 @@ const kilo = LayerNode.group([
|
||||
AgentManager.node,
|
||||
Notebook.node,
|
||||
SessionDrain.node,
|
||||
Wakeup.node,
|
||||
memory,
|
||||
])
|
||||
// kilocode_change end
|
||||
|
||||
@@ -12,12 +12,14 @@ import { SessionSummary } from "@/session/summary"
|
||||
import { SessionExport } from "@/kilocode/session-export"
|
||||
import { createWorkspaceProvider } from "@/kilocode/session-export/workspace-provider"
|
||||
import { Instance } from "@/kilocode/instance"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { Identity } from "@kilocode/kilo-telemetry"
|
||||
import { MemoryLifecycle } from "@/kilocode/memory/turn"
|
||||
import { MemoryService } from "@kilocode/kilo-memory/effect/service"
|
||||
import { MemoryEvents } from "@/kilocode/memory/events"
|
||||
import { installMemoryRuntime } from "@/kilocode/memory/runtime"
|
||||
import { KiloToolRegistry } from "@/kilocode/tool/registry"
|
||||
import { Wakeup } from "@/kilocode/wakeup"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { KilocodeWatcher } from "@/kilocode/watcher"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change
|
||||
@@ -43,6 +45,7 @@ export namespace KilocodeBootstrap {
|
||||
const provider = yield* Provider.Service
|
||||
const memory = yield* MemoryService.Service
|
||||
const watcher = yield* KilocodeWatcher.Service
|
||||
const wake = yield* Wakeup.Service
|
||||
|
||||
const init = Effect.fn("KilocodeBootstrap.init")(function* () {
|
||||
yield* watcher.init()
|
||||
@@ -55,6 +58,16 @@ export namespace KilocodeBootstrap {
|
||||
yield* bus.subscribeCallback(MemoryEvents.Updated, (evt) =>
|
||||
KiloToolRegistry.invalidateMemoryEnabled(evt.properties.directory),
|
||||
)
|
||||
// Re-arm this directory's persisted wakeups on every instance start: overdue ones
|
||||
// fire immediately, the rest get their timers. A failure must not block bootstrap.
|
||||
const inst = yield* InstanceRef
|
||||
if (inst) {
|
||||
yield* wake.adopt(inst.directory).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.sync(() => log.warn("wakeup adopt failed", { err: Cause.squash(cause) })),
|
||||
),
|
||||
)
|
||||
}
|
||||
// Session export bootstrap.
|
||||
yield* Effect.gen(function* () {
|
||||
if (!SessionExport.enabled) return
|
||||
@@ -105,6 +118,7 @@ export namespace KilocodeBootstrap {
|
||||
MemoryService.layer,
|
||||
Bus.defaultLayer,
|
||||
KilocodeWatcher.defaultLayer,
|
||||
AppNodeBuilder.build(Wakeup.node),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -114,7 +128,16 @@ export namespace KilocodeBootstrap {
|
||||
LayerNode.make({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [KiloSessions.node, Session.node, SessionSummary.node, Provider.node, memory, Bus.node, watcher],
|
||||
deps: [
|
||||
KiloSessions.node,
|
||||
Session.node,
|
||||
SessionSummary.node,
|
||||
Provider.node,
|
||||
memory,
|
||||
Bus.node,
|
||||
watcher,
|
||||
Wakeup.node,
|
||||
],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -63,6 +63,13 @@ export namespace KiloSessionControl {
|
||||
}),
|
||||
)
|
||||
|
||||
return { begin, stop }
|
||||
// Whether the session is between a stop and the next resuming prompt. A
|
||||
// synthetic background prompt does not clear it, so a caller that cannot
|
||||
// accept a dropped turn (a scheduled wakeup) reads this first.
|
||||
const paused = Effect.fn("KiloSessionControl.paused")(function* (id: SessionID) {
|
||||
return (yield* get(id)).paused
|
||||
})
|
||||
|
||||
return { begin, stop, paused }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Wakeup } from "@/kilocode/wakeup"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import { Effect, Schema } from "effect"
|
||||
import DESCRIPTION from "./cancel-wakeup.txt"
|
||||
|
||||
const Action = Schema.Literals(["list", "cancel"])
|
||||
type Action = Schema.Schema.Type<typeof Action>
|
||||
|
||||
export const Params = Schema.Struct({
|
||||
action: Action.annotate({ description: "Operation to perform" }),
|
||||
id: Schema.optional(Schema.String).annotate({
|
||||
description: "Required for cancel. Id of the pending wakeup to cancel.",
|
||||
}),
|
||||
}).check(
|
||||
Schema.makeFilter((params: { action: Action; id?: string }) => {
|
||||
if (params.action !== "cancel") return undefined
|
||||
if (params.id?.trim()) return undefined
|
||||
return "id is required when action is cancel"
|
||||
}),
|
||||
)
|
||||
export type Params = Schema.Schema.Type<typeof Params>
|
||||
|
||||
export type Meta = {
|
||||
id?: Wakeup.ID
|
||||
count?: number
|
||||
cancelled?: boolean
|
||||
}
|
||||
|
||||
/** Whole-unit countdown to the due time, e.g. `in 5m`. */
|
||||
function relative(dueAt: number, now: number) {
|
||||
const delta = Math.max(0, dueAt - now)
|
||||
if (delta < 60_000) return `in ${Math.max(1, Math.round(delta / 1_000))}s`
|
||||
if (delta < 3_600_000) return `in ${Math.round(delta / 60_000)}m`
|
||||
if (delta < 86_400_000) return `in ${Math.round(delta / 3_600_000)}h`
|
||||
return `in ${Math.round(delta / 86_400_000)}d`
|
||||
}
|
||||
|
||||
function excerpt(text: string, max = 80) {
|
||||
const flat = text.replace(/\s+/g, " ").trim()
|
||||
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
|
||||
}
|
||||
|
||||
function line(info: Wakeup.Info, now: number) {
|
||||
const due = new Date(info.dueAt).toISOString()
|
||||
return `${info.id} due ${due} (${relative(info.dueAt, now)}) ${excerpt(info.reason ?? info.prompt)}`
|
||||
}
|
||||
|
||||
export const CancelWakeupTool = Tool.define<typeof Params, Meta, Wakeup.Service, "cancel_wakeup">(
|
||||
"cancel_wakeup",
|
||||
Effect.gen(function* () {
|
||||
const wake = yield* Wakeup.Service
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Params,
|
||||
execute: (params, ctx) =>
|
||||
Effect.gen(function* () {
|
||||
if (params.action === "list") {
|
||||
const list = yield* wake.list({ sessionID: ctx.sessionID })
|
||||
return {
|
||||
title: "Scheduled wakeups",
|
||||
output: list.length
|
||||
? list.map((info) => line(info, Date.now())).join("\n")
|
||||
: "No pending wakeups for this session.",
|
||||
metadata: { count: list.length },
|
||||
}
|
||||
}
|
||||
|
||||
const id = params.id?.trim()
|
||||
if (!id) {
|
||||
return {
|
||||
title: "Invalid wakeup input",
|
||||
output: "id is required when action is cancel",
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel is idempotent: an already-fired, already-cancelled, or
|
||||
// unknown id is reported, never thrown.
|
||||
const removed = yield* wake.cancel(id as Wakeup.ID, ctx.sessionID)
|
||||
if (!removed) {
|
||||
return {
|
||||
title: "No pending wakeup",
|
||||
output: `No pending wakeup with id ${id}.`,
|
||||
metadata: { id: id as Wakeup.ID },
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: "Cancelled wakeup",
|
||||
output: `Cancelled wakeup ${removed.id} (${new Date(removed.dueAt).toISOString()}).`,
|
||||
metadata: { id: removed.id, cancelled: true },
|
||||
}
|
||||
}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
Review and cancel the wakeups scheduled for this session.
|
||||
|
||||
Use this tool to:
|
||||
- List the wakeups you scheduled, with their id, due time, and reason (or the scheduled prompt when you gave no reason)
|
||||
- Cancel a wakeup you no longer need, by its id
|
||||
|
||||
Call it with action "list" first to find the id, then action "cancel" with that id.
|
||||
Cancelling an id that is already gone is safe: the tool reports it and does not fail.
|
||||
|
||||
Do NOT use this tool:
|
||||
- As a poll loop while waiting — the harness wakes you when the wakeup fires
|
||||
- To cancel a wakeup that has already fired — it is gone, and the cancel is a no-op
|
||||
@@ -5,6 +5,7 @@ import { AgentManagerTool } from "./agent-manager"
|
||||
import { BackgroundProcessTool } from "./background-process"
|
||||
import { BoardReadTool, BoardPostTool } from "./board"
|
||||
import { BrowserOpenTool } from "./browser-open"
|
||||
import { CancelWakeupTool } from "./cancel-wakeup"
|
||||
import { ChartTool } from "./chart"
|
||||
import { GenerateImageTool } from "./generate-image"
|
||||
import { NotebookEditTool, NotebookExecuteTool, NotebookReadTool } from "./notebook-host"
|
||||
@@ -12,6 +13,7 @@ import { MemoryRecallTool } from "./memory-recall"
|
||||
import { MemorySaveTool } from "./memory-save"
|
||||
import { NotifyUserTool } from "./notify-user"
|
||||
import { OpenPlanTool } from "./open-plan"
|
||||
import { ScheduleWakeupTool } from "./schedule-wakeup"
|
||||
import { SendFileTool } from "./send-file"
|
||||
import * as Tool from "../../tool/tool"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
@@ -89,6 +91,9 @@ export namespace KiloToolRegistry {
|
||||
const notify = yield* NotifyUserTool.pipe(Effect.provideService(KiloSessions.Service, sessions))
|
||||
const openPlan = yield* OpenPlanTool
|
||||
const send = yield* SendFileTool
|
||||
// Wakeup.Service is provided by Wakeup.node in the tool-registry node graph.
|
||||
const schedule = yield* ScheduleWakeupTool
|
||||
const cancel = yield* CancelWakeupTool
|
||||
const board = yield* Effect.all({
|
||||
boardRead: BoardReadTool,
|
||||
boardPost: BoardPostTool,
|
||||
@@ -108,6 +113,8 @@ export namespace KiloToolRegistry {
|
||||
notify,
|
||||
openPlan,
|
||||
send,
|
||||
schedule,
|
||||
cancel,
|
||||
...board,
|
||||
}
|
||||
const tools = yield* Effect.all({
|
||||
@@ -128,6 +135,8 @@ export namespace KiloToolRegistry {
|
||||
notify,
|
||||
openPlan,
|
||||
send,
|
||||
schedule,
|
||||
cancel,
|
||||
...board,
|
||||
...tools,
|
||||
}
|
||||
@@ -150,6 +159,8 @@ export namespace KiloToolRegistry {
|
||||
notify: Tool.Info
|
||||
openPlan?: Tool.Info
|
||||
send: Tool.Info
|
||||
schedule?: Tool.Info
|
||||
cancel?: Tool.Info
|
||||
boardRead?: Tool.Info
|
||||
goalReport?: Tool.Info
|
||||
boardPost?: Tool.Info
|
||||
@@ -174,6 +185,8 @@ export namespace KiloToolRegistry {
|
||||
send: Tool.init(tools.send),
|
||||
})
|
||||
const openPlan = tools.openPlan ? yield* Tool.init(tools.openPlan) : undefined
|
||||
const schedule = tools.schedule ? yield* Tool.init(tools.schedule) : undefined
|
||||
const cancel = tools.cancel ? yield* Tool.init(tools.cancel) : undefined
|
||||
const report = tools.goalReport ? { goalReport: yield* Tool.init(tools.goalReport) } : {}
|
||||
const board =
|
||||
tools.boardRead && tools.boardPost
|
||||
@@ -197,6 +210,8 @@ export namespace KiloToolRegistry {
|
||||
...notebooks,
|
||||
semantic,
|
||||
openPlan,
|
||||
schedule,
|
||||
cancel,
|
||||
notify: base.notify,
|
||||
send: base.send,
|
||||
}
|
||||
@@ -262,6 +277,8 @@ export namespace KiloToolRegistry {
|
||||
notify: Tool.Def
|
||||
openPlan?: Tool.Def
|
||||
send: Tool.Def
|
||||
schedule?: Tool.Def
|
||||
cancel?: Tool.Def
|
||||
boardRead?: Tool.Def
|
||||
goalReport?: Tool.Def
|
||||
boardPost?: Tool.Def
|
||||
@@ -293,6 +310,8 @@ export namespace KiloToolRegistry {
|
||||
tools.recall,
|
||||
...(Flag.KILO_CLIENT === "vscode" ? [tools.chart] : []),
|
||||
...(Flag.KILO_CLIENT === "cli" || Flag.KILO_CLIENT === "vscode" ? [tools.process] : []),
|
||||
...((Flag.KILO_CLIENT === "cli" || Flag.KILO_CLIENT === "vscode") && tools.schedule ? [tools.schedule] : []),
|
||||
...((Flag.KILO_CLIENT === "cli" || Flag.KILO_CLIENT === "vscode") && tools.cancel ? [tools.cancel] : []),
|
||||
...(Flag.KILO_CLIENT === "vscode" || cfg.experimental?.task_model_selection === true
|
||||
? [tools.managerModels]
|
||||
: []),
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Wakeup } from "@/kilocode/wakeup"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import { Effect, Schema } from "effect"
|
||||
import DESCRIPTION from "./schedule-wakeup.txt"
|
||||
|
||||
export const Params = Schema.Struct({
|
||||
prompt: Schema.String.annotate({
|
||||
description: "Text to resume this session with when the wakeup fires.",
|
||||
}),
|
||||
delay: Schema.optional(Schema.String).annotate({
|
||||
description: "Relative span from now, e.g. 30s, 5m, 2h, 1d. A bare number is seconds.",
|
||||
}),
|
||||
when: Schema.optional(Schema.String).annotate({
|
||||
description: "Absolute ISO-8601 date-time, e.g. 2026-09-13T14:30:00Z. An offset makes it absolute; without one the host timezone applies.",
|
||||
}),
|
||||
reason: Schema.optional(Schema.String).annotate({
|
||||
description: "Short label shown in the sidebar and in cancel_wakeup's list.",
|
||||
}),
|
||||
})
|
||||
export type Params = Schema.Schema.Type<typeof Params>
|
||||
|
||||
export type Meta = {
|
||||
id?: Wakeup.ID
|
||||
dueAt?: number
|
||||
prompt?: string
|
||||
}
|
||||
|
||||
/** Whole-unit countdown to the due time, e.g. `in 5m`. */
|
||||
function relative(dueAt: number, now: number) {
|
||||
const delta = Math.max(0, dueAt - now)
|
||||
if (delta < 60_000) return `in ${Math.max(1, Math.round(delta / 1_000))}s`
|
||||
if (delta < 3_600_000) return `in ${Math.round(delta / 60_000)}m`
|
||||
if (delta < 86_400_000) return `in ${Math.round(delta / 3_600_000)}h`
|
||||
return `in ${Math.round(delta / 86_400_000)}d`
|
||||
}
|
||||
|
||||
function invalid(message: string) {
|
||||
return {
|
||||
title: "Invalid wakeup input",
|
||||
output: message,
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
|
||||
function tooMany() {
|
||||
return {
|
||||
title: "Too many scheduled wakeups",
|
||||
output: `Too many scheduled wakeups: this session already holds the maximum of ${Wakeup.MAX_PER_SESSION} pending wakeups. Cancel one with cancel_wakeup before scheduling another.`,
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
|
||||
function created(info: Wakeup.Info, now: number, input: Params) {
|
||||
const due = new Date(info.dueAt).toISOString()
|
||||
// The schedule's own clock is the reference for the clamp check: a fresh
|
||||
// Date.now() drifts a few milliseconds off resolve()'s base.
|
||||
const clamped = Wakeup.clampNotice(input, info.dueAt, info.created)
|
||||
return {
|
||||
title: `Scheduled wakeup ${info.id}`,
|
||||
output: [
|
||||
`Scheduled wakeup ${info.id}, due ${due} (${relative(info.dueAt, now)}).${clamped ? ` ${clamped}` : ""}`,
|
||||
`When it fires this session resumes with: ${info.prompt}`,
|
||||
].join("\n"),
|
||||
metadata: { id: info.id, dueAt: info.dueAt, prompt: info.prompt },
|
||||
}
|
||||
}
|
||||
|
||||
export const ScheduleWakeupTool = Tool.define<typeof Params, Meta, Wakeup.Service, "schedule_wakeup">(
|
||||
"schedule_wakeup",
|
||||
Effect.gen(function* () {
|
||||
const wake = yield* Wakeup.Service
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Params,
|
||||
execute: (params, ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const inst = yield* InstanceState.context
|
||||
return yield* wake
|
||||
.schedule({
|
||||
sessionID: ctx.sessionID,
|
||||
directory: inst.directory,
|
||||
prompt: params.prompt,
|
||||
delay: params.delay,
|
||||
when: params.when,
|
||||
reason: params.reason,
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((info) => created(info, Date.now(), params)),
|
||||
Effect.catchTags({
|
||||
"Wakeup.InvalidTime": (err) => Effect.succeed(invalid(err.message)),
|
||||
"Wakeup.PastTime": (err) => Effect.succeed(invalid(err.message)),
|
||||
"Wakeup.TooMany": () => Effect.succeed(tooMany()),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
Schedule a wakeup for yourself: a point in the future when the harness resumes this session with the prompt you give it.
|
||||
|
||||
Use this tool to defer your own continuation to a future time, when you cannot keep the turn open:
|
||||
- Waiting on a build, deploy, or CI window that finishes outside a blocking command.
|
||||
- A later check-in on something that changes slowly.
|
||||
- A reminder to poll a long-running task after a sensible interval.
|
||||
|
||||
Do NOT use this tool for short waits that a blocking shell command covers, for anything inside the next few seconds, for periodic or repeating schedules, or for more than the per-session cap. For a short wait, run the wait as a normal blocking shell command with its `timeout` raised instead.
|
||||
|
||||
Time:
|
||||
- Give exactly one of `when` or `delay`.
|
||||
- `when` is an absolute ISO-8601 date-time, e.g. `2026-09-13T14:30:00Z`. With an explicit offset (`Z` or `+02:00`) it is absolute; without one, the host's local timezone applies.
|
||||
- `delay` is a relative span, e.g. `30s`, `5m`, `2h`, `1d`; a bare number is seconds.
|
||||
- Clamps: a delay under 10 seconds is raised to the 10-second minimum, a time more than 7 days out is pulled back to the 7-day maximum, and a time at or before now is rejected. A session may hold at most 10 pending wakeups; cancel one with `cancel_wakeup` first when it is full.
|
||||
|
||||
After a successful call, the tool reports the wakeup `id`, its absolute due time, and the relative delay. Keep that id to list or cancel the wakeup later.
|
||||
@@ -0,0 +1,191 @@
|
||||
import { KiloShutdown } from "@/kilocode/cli/shutdown"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Context, Effect, Fiber, Layer, Semaphore } from "effect"
|
||||
import { fireLayer, text as wakeupText } from "./resume"
|
||||
import * as schema from "./schema"
|
||||
|
||||
export namespace Wakeup {
|
||||
export const MIN_DELAY_MS = schema.MIN_DELAY_MS
|
||||
export const MAX_HORIZON_MS = schema.MAX_HORIZON_MS
|
||||
export const MAX_PER_SESSION = schema.MAX_PER_SESSION
|
||||
export const ID = schema.ID
|
||||
export type ID = schema.ID
|
||||
export const Info = schema.Info
|
||||
export type Info = schema.Info
|
||||
export const Input = schema.Input
|
||||
export type Input = schema.Input
|
||||
export const InvalidTime = schema.InvalidTime
|
||||
export type InvalidTime = schema.InvalidTime
|
||||
export const PastTime = schema.PastTime
|
||||
export type PastTime = schema.PastTime
|
||||
export const TooMany = schema.TooMany
|
||||
export type TooMany = schema.TooMany
|
||||
export const Fire = schema.Fire
|
||||
export type Fire = schema.Fire
|
||||
export const resolve = schema.resolve
|
||||
export const clampNotice = schema.clampNotice
|
||||
export const text = wakeupText
|
||||
|
||||
export interface Interface {
|
||||
readonly schedule: (input: Input) => Effect.Effect<Info, InvalidTime | PastTime | TooMany>
|
||||
readonly list: (input?: { sessionID?: SessionID }) => Effect.Effect<Info[]>
|
||||
readonly cancel: (id: ID, sessionID?: SessionID) => Effect.Effect<Info | undefined>
|
||||
readonly adopt: (directory: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@kilocode/Wakeup") {}
|
||||
|
||||
const key = (info: { sessionID: SessionID; id: ID }) => ["wakeup", String(info.sessionID), String(info.id)]
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const storage = yield* Storage.Service
|
||||
const fire = yield* Fire
|
||||
// Timers live in the service scope, so tearing the layer down stops them.
|
||||
const scope = yield* Effect.scope
|
||||
const timers = new Map<ID, Fiber.Fiber<void>>()
|
||||
const entries = new Map<ID, Info>()
|
||||
// Ids whose persistence was already dropped and whose resume is in flight.
|
||||
// `adopt` must not re-fire one of these while the slow turn runs.
|
||||
const firing = new Set<ID>()
|
||||
// Serializes the count-and-write in `schedule` so two concurrent schedulers
|
||||
// cannot both pass the cap.
|
||||
const gate = Semaphore.makeUnsafe(1)
|
||||
|
||||
const stop = () => {
|
||||
for (const fiber of timers.values()) fiber.interruptUnsafe()
|
||||
timers.clear()
|
||||
}
|
||||
const unregister = KiloShutdown.register(stop)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
unregister()
|
||||
stop()
|
||||
}),
|
||||
)
|
||||
|
||||
const read = (target: string[]) =>
|
||||
storage.read<Info>(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
|
||||
const lookup = Effect.fnUntraced(function* (id: ID) {
|
||||
const known = entries.get(id)
|
||||
if (known) return known
|
||||
const keys = yield* storage.list(["wakeup"]).pipe(Effect.catch(() => Effect.succeed([] as string[][])))
|
||||
for (const target of keys) {
|
||||
if (target.at(-1) !== id) continue
|
||||
const info = yield* read(target)
|
||||
if (info) return info
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const fireNow = (info: Info, inPlace = false) =>
|
||||
Effect.gen(function* () {
|
||||
if (firing.has(info.id)) return
|
||||
firing.add(info.id)
|
||||
// The guard release belongs only to the branch that acquired it: an
|
||||
// early return above must not clear an in-flight fire's guard.
|
||||
yield* Effect.gen(function* () {
|
||||
entries.delete(info.id)
|
||||
timers.delete(info.id)
|
||||
// Drop the persistence before the resume: the model turn can be slow,
|
||||
// and a concurrent `adopt` that still sees the file would fire twice.
|
||||
yield* storage.remove(key(info)).pipe(Effect.ignore)
|
||||
yield* fire
|
||||
.run(info, { inPlace })
|
||||
.pipe(Effect.catchCause((cause) => Effect.logError("wakeup fire failed", { id: info.id, cause })))
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => firing.delete(info.id))))
|
||||
})
|
||||
|
||||
const arm = (info: Info) =>
|
||||
Effect.gen(function* () {
|
||||
const delay = Math.max(0, info.dueAt - Date.now())
|
||||
const fiber = yield* Effect.forkIn(
|
||||
Effect.sleep(`${delay} millis`).pipe(Effect.andThen(fireNow(info))),
|
||||
scope,
|
||||
)
|
||||
timers.set(info.id, fiber)
|
||||
})
|
||||
|
||||
const list = Effect.fn("Wakeup.list")(function* (input?: { sessionID?: SessionID }) {
|
||||
const found = new Map<ID, Info>(entries)
|
||||
const prefix = input?.sessionID ? ["wakeup", String(input.sessionID)] : ["wakeup"]
|
||||
const keys = yield* storage.list(prefix).pipe(Effect.catch(() => Effect.succeed([] as string[][])))
|
||||
for (const target of keys) {
|
||||
const info = yield* read(target)
|
||||
if (info && !found.has(info.id)) found.set(info.id, info)
|
||||
}
|
||||
return Array.from(found.values())
|
||||
.filter((info) => !input?.sessionID || info.sessionID === input.sessionID)
|
||||
.toSorted((a, b) => a.dueAt - b.dueAt || a.id.localeCompare(b.id))
|
||||
})
|
||||
|
||||
const schedule = Effect.fn("Wakeup.schedule")(function* (input: Input) {
|
||||
return yield* gate.withPermits(1)(
|
||||
Effect.gen(function* () {
|
||||
const now = Date.now()
|
||||
const dueAt = yield* schema.resolve(input, now)
|
||||
// Count only wakeups that still parse: an unreadable file must not
|
||||
// hold a slot, and the count and the write must be one critical section.
|
||||
const pending = yield* list({ sessionID: input.sessionID })
|
||||
if (pending.length >= MAX_PER_SESSION) {
|
||||
return yield* new TooMany({ message: `A session can hold at most ${MAX_PER_SESSION} pending wakeups` })
|
||||
}
|
||||
const info: Info = {
|
||||
id: ID.ascending(),
|
||||
sessionID: input.sessionID,
|
||||
directory: input.directory,
|
||||
prompt: input.prompt,
|
||||
reason: input.reason,
|
||||
agent: input.agent,
|
||||
dueAt,
|
||||
created: now,
|
||||
}
|
||||
yield* storage.write(key(info), info).pipe(Effect.orDie)
|
||||
entries.set(info.id, info)
|
||||
yield* arm(info)
|
||||
return info
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const cancel = Effect.fn("Wakeup.cancel")(function* (id: ID, sessionID?: SessionID) {
|
||||
const info = yield* lookup(id)
|
||||
if (!info || (sessionID && info.sessionID !== sessionID)) return undefined
|
||||
const fiber = timers.get(id)
|
||||
if (fiber) {
|
||||
timers.delete(id)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
}
|
||||
entries.delete(id)
|
||||
yield* storage.remove(key(info)).pipe(Effect.ignore)
|
||||
return info
|
||||
})
|
||||
|
||||
const adopt = Effect.fn("Wakeup.adopt")(function* (directory: string) {
|
||||
const keys = yield* storage.list(["wakeup"]).pipe(Effect.catch(() => Effect.succeed([] as string[][])))
|
||||
for (const target of keys) {
|
||||
const info = yield* read(target)
|
||||
if (!info || info.directory !== directory) continue
|
||||
if (entries.has(info.id) || timers.has(info.id) || firing.has(info.id)) continue
|
||||
entries.set(info.id, info)
|
||||
// Adopt runs inside the directory's bootstrap, so it must resume in
|
||||
// place; `provide` would await the in-flight load and deadlock.
|
||||
if (info.dueAt <= Date.now()) yield* fireNow(info, true)
|
||||
else yield* arm(info)
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ schedule, list, cancel, adopt })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(fireLayer))
|
||||
|
||||
export const node = LayerNode.make({ service: Service, layer: defaultLayer, deps: [Storage.node] })
|
||||
}
|
||||
|
||||
export * from "./schema"
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Instance, provide } from "@/kilocode/instance"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Fire, type Info } from "./schema"
|
||||
|
||||
const log = Log.create({ service: "wakeup" })
|
||||
|
||||
/** The prompt the model sees when a wakeup fires: the scheduled text plus wakeup context. */
|
||||
export function text(info: Info): string {
|
||||
return `[scheduled wakeup] ${info.prompt}\n\n(No user is present. You scheduled this wakeup yourself as ${info.id}, due ${new Date(info.dueAt).toISOString()}.)`
|
||||
}
|
||||
|
||||
async function resume(info: Info, inst?: InstanceContext, inPlace = false) {
|
||||
try {
|
||||
const [{ AppRuntime }, { Session }, { SessionPrompt }] = await Promise.all([
|
||||
import("@/effect/app-runtime"),
|
||||
import("@/session/session"),
|
||||
import("@/session/prompt"),
|
||||
])
|
||||
const fn = async () => {
|
||||
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(info.sessionID)))
|
||||
// The prompt path drops a synthetic turn while the session is paused, so
|
||||
// the wake would vanish without a trace. Refuse it here and log instead.
|
||||
const paused = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.paused(info.sessionID)))
|
||||
if (paused) {
|
||||
log.error("wakeup could not resume session", {
|
||||
id: info.id,
|
||||
sessionID: info.sessionID,
|
||||
directory: info.directory,
|
||||
reason: "session is paused",
|
||||
})
|
||||
return
|
||||
}
|
||||
// Fork the turn so the firing timer never blocks on the model running.
|
||||
await AppRuntime.runPromise(
|
||||
SessionPrompt.Service.use((svc) =>
|
||||
Effect.forkDetach(
|
||||
svc
|
||||
.prompt({
|
||||
sessionID: info.sessionID,
|
||||
agent: info.agent,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: text(info),
|
||||
synthetic: true,
|
||||
metadata: { background: true, wakeup: true, wakeupID: info.id },
|
||||
},
|
||||
],
|
||||
})
|
||||
.pipe(Effect.catchCause((cause) => Effect.logError("wakeup prompt failed", { id: info.id, cause }))),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
// An overdue wake fires from `adopt` while its directory's instance is still
|
||||
// bootstrapping. Re-entering `provide` would await that very load and
|
||||
// deadlock, so that path resumes in place. A timer fire happens after
|
||||
// bootstrap, so it re-resolves the instance and picks up a reload.
|
||||
if (inPlace && inst && inst.directory === info.directory) {
|
||||
await Instance.restore(inst, fn)
|
||||
return
|
||||
}
|
||||
await provide({ directory: info.directory, fn })
|
||||
} catch (err) {
|
||||
log.error("wakeup could not resume session", {
|
||||
id: info.id,
|
||||
sessionID: info.sessionID,
|
||||
directory: info.directory,
|
||||
err,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const fireLayer = Layer.succeed(
|
||||
Fire,
|
||||
Fire.of({
|
||||
run: (info, options) =>
|
||||
Effect.gen(function* () {
|
||||
const inst = yield* InstanceRef
|
||||
yield* Effect.promise(() => resume(info, inst, options?.inPlace === true))
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Identifier } from "@/id/id"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema"
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { Context, Effect, Schema, Types } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
/** A `delay` under this is raised to it; an absolute `when` is honored as given. */
|
||||
export const MIN_DELAY_MS = 10_000
|
||||
/** A scheduled wakeup never fires further out than seven days. */
|
||||
export const MAX_HORIZON_MS = 7 * 24 * 60 * 60 * 1000
|
||||
/** One session may hold at most this many pending wakeups. */
|
||||
export const MAX_PER_SESSION = 10
|
||||
|
||||
const idSchema = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("wku") }).pipe(
|
||||
Schema.brand("WakeupID"),
|
||||
)
|
||||
export type ID = typeof idSchema.Type
|
||||
export const ID = idSchema.pipe(
|
||||
withStatics((schema: typeof idSchema) => ({
|
||||
ascending: (id?: string) => {
|
||||
if (id && !id.startsWith("wku")) throw new Error(`Wakeup ID must start with wku: ${id}`)
|
||||
return schema.make(id ?? Identifier.create("wku", "ascending"))
|
||||
},
|
||||
zod: zod(schema),
|
||||
})),
|
||||
)
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
id: ID,
|
||||
sessionID: SessionID,
|
||||
directory: Schema.String,
|
||||
prompt: Schema.String,
|
||||
reason: optionalOmitUndefined(Schema.String),
|
||||
agent: optionalOmitUndefined(Schema.String),
|
||||
dueAt: NonNegativeInt,
|
||||
created: NonNegativeInt,
|
||||
})
|
||||
.annotate({ identifier: "WakeupInfo" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
directory: Schema.String,
|
||||
prompt: Schema.String,
|
||||
when: optionalOmitUndefined(Schema.String),
|
||||
delay: optionalOmitUndefined(Schema.String),
|
||||
reason: optionalOmitUndefined(Schema.String),
|
||||
agent: optionalOmitUndefined(Schema.String),
|
||||
})
|
||||
.annotate({ identifier: "WakeupInput" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Input = Types.DeepMutable<Schema.Schema.Type<typeof Input>>
|
||||
|
||||
/** Neither a usable `when` nor a usable `delay` was supplied. */
|
||||
export class InvalidTime extends Schema.TaggedErrorClass<InvalidTime>()("Wakeup.InvalidTime", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
/** The requested time is at or before now. */
|
||||
export class PastTime extends Schema.TaggedErrorClass<PastTime>()("Wakeup.PastTime", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
/** The session already holds the maximum number of pending wakeups. */
|
||||
export class TooMany extends Schema.TaggedErrorClass<TooMany>()("Wakeup.TooMany", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
/** The resume boundary: the service fires through this so tests can stub it. */
|
||||
export class Fire extends Context.Service<
|
||||
Fire,
|
||||
{ readonly run: (info: Info, options?: { inPlace?: boolean }) => Effect.Effect<void> }
|
||||
>()("@kilocode/WakeupFire") {}
|
||||
|
||||
// ISO-8601 date-time. The offset is optional; when present it is absolute, and
|
||||
// when omitted `Date.parse` interprets the wall clock in the host timezone.
|
||||
const WHEN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:?\d{2})?$/
|
||||
// `30`, `30s`, `5m`, `2h`, `1d` — bare numbers are seconds.
|
||||
const DELAY = /^\s*(\d+)\s*([smhd])?\s*$/i
|
||||
|
||||
function parseWhen(input: string) {
|
||||
if (!WHEN.test(input)) return undefined
|
||||
const value = Date.parse(input)
|
||||
return Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
function parseDelay(input: string) {
|
||||
const match = DELAY.exec(input)
|
||||
if (!match) return undefined
|
||||
const value = Number(match[1])
|
||||
if (!Number.isFinite(value)) return undefined
|
||||
const unit = (match[2] ?? "s").toLowerCase()
|
||||
const scale = unit === "s" ? 1_000 : unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : 86_400_000
|
||||
return value * scale
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the requested time to an absolute epoch, applying the clamps:
|
||||
* exactly one of `when`/`delay`; a past time is rejected; a positive delay
|
||||
* below the minimum clamps up to it; anything beyond the horizon clamps down.
|
||||
*/
|
||||
export function resolve(
|
||||
input: { when?: string; delay?: string },
|
||||
now = Date.now(),
|
||||
): Effect.Effect<number, InvalidTime | PastTime> {
|
||||
const when = input.when != null && input.when !== "" ? input.when : undefined
|
||||
const delay = input.delay != null && input.delay !== "" ? input.delay : undefined
|
||||
if ((when !== undefined) === (delay !== undefined)) {
|
||||
return Effect.fail(new InvalidTime({ message: "Provide exactly one of when or delay" }))
|
||||
}
|
||||
if (when !== undefined) {
|
||||
const target = parseWhen(when)
|
||||
if (target === undefined) return Effect.fail(new InvalidTime({ message: `Invalid time: ${when}` }))
|
||||
if (target <= now) return Effect.fail(new PastTime({ message: `Wakeup time is not in the future: ${when}` }))
|
||||
return Effect.succeed(Math.min(target, now + MAX_HORIZON_MS))
|
||||
}
|
||||
const span = parseDelay(delay as string)
|
||||
if (span === undefined) return Effect.fail(new InvalidTime({ message: `Invalid delay: ${delay}` }))
|
||||
if (span <= 0) return Effect.fail(new PastTime({ message: `Wakeup delay is not in the future: ${delay}` }))
|
||||
return Effect.succeed(Math.min(Math.max(now + span, now + MIN_DELAY_MS), now + MAX_HORIZON_MS))
|
||||
}
|
||||
|
||||
/**
|
||||
* The clamp that applied to a resolved schedule, as one model-facing sentence,
|
||||
* or undefined when the request was honored as given. Tools echo it so the
|
||||
* model knows its requested time was adjusted.
|
||||
*/
|
||||
export function clampNotice(
|
||||
input: { when?: string; delay?: string },
|
||||
dueAt: number,
|
||||
now = Date.now(),
|
||||
): string | undefined {
|
||||
const when = input.when != null && input.when !== "" ? input.when : undefined
|
||||
const delay = input.delay != null && input.delay !== "" ? input.delay : undefined
|
||||
if (when !== undefined && dueAt - now === MAX_HORIZON_MS && (parseWhen(when) ?? 0) > now + MAX_HORIZON_MS) {
|
||||
return `Requested when: "${when}" is beyond the 7-day horizon and was pulled back to it.`
|
||||
}
|
||||
if (delay === undefined) return undefined
|
||||
const span = parseDelay(delay)
|
||||
if (span === undefined) return undefined
|
||||
if (span < MIN_DELAY_MS && dueAt - now === MIN_DELAY_MS) {
|
||||
return `Requested delay: "${delay}" is under the 10-second minimum and was raised to it.`
|
||||
}
|
||||
if (span > MAX_HORIZON_MS && dueAt - now === MAX_HORIZON_MS) {
|
||||
return `Requested delay: "${delay}" is beyond the 7-day horizon and was pulled back to it.`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -146,6 +146,7 @@ function isOrphanedInterruptedTool(part: SessionV1.ToolPart) {
|
||||
|
||||
export interface Interface {
|
||||
readonly cancel: (sessionID: SessionID, scope?: KiloSessionControl.AbortScope) => Effect.Effect<void> // kilocode_change
|
||||
readonly paused: (sessionID: SessionID) => Effect.Effect<boolean> // kilocode_change - wakeup resume refuses a paused session instead of dropping its turn
|
||||
readonly prompt: (input: PromptInput) => Effect.Effect<SessionV1.WithParts, Image.Error>
|
||||
readonly loop: (input: LoopInput) => Effect.Effect<SessionV1.WithParts>
|
||||
readonly shell: (input: ShellInput) => Effect.Effect<SessionV1.WithParts, Session.BusyError>
|
||||
@@ -2569,6 +2570,7 @@ export const layer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
cancel,
|
||||
paused: (id) => control.paused(id), // kilocode_change - wakeup resume reads it before forking a turn
|
||||
prompt,
|
||||
loop: (input) => loop(input).pipe(Effect.orDie),
|
||||
shell,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { WebSearchTool } from "./websearch"
|
||||
import { KiloToolRegistry } from "../kilocode/tool/registry" // kilocode_change
|
||||
import { Notebook } from "@/kilocode/notebook/service" // kilocode_change
|
||||
import { AgentManager } from "@/kilocode/agent-manager/service" // kilocode_change
|
||||
import { Wakeup } from "@/kilocode/wakeup" // kilocode_change
|
||||
import { SessionDrain } from "@/kilocode/session/drain" // kilocode_change
|
||||
import { RepoOverviewTool } from "@/kilocode/tool/repo-overview" // kilocode_change
|
||||
import { RepoCloneTool } from "./repo_clone" // kilocode_change
|
||||
@@ -541,6 +542,7 @@ export const node = LayerNode.suspend(() =>
|
||||
Notebook.node,
|
||||
RepositoryCache.node,
|
||||
KiloSessions.node,
|
||||
Wakeup.node, // kilocode_change - provides Wakeup.Service to the schedule_wakeup/cancel_wakeup tools
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Agent } from "../../src/agent/agent"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { KiloIndexing } from "../../src/kilocode/indexing"
|
||||
import { KilocodeBootstrap } from "../../src/kilocode/bootstrap"
|
||||
import { Wakeup } from "../../src/kilocode/wakeup"
|
||||
import { KilocodeWatcher } from "../../src/kilocode/watcher"
|
||||
import { KiloSessions } from "../../src/kilo-sessions/kilo-sessions"
|
||||
import { KiloMemory } from "@kilocode/kilo-memory/effect"
|
||||
@@ -519,6 +520,15 @@ describe("kilocode tool registry indexing", () => {
|
||||
const summary = Layer.succeed(SessionSummary.Service, {} as SessionSummary.Interface)
|
||||
const provider = Layer.succeed(Provider.Service, {} as Provider.Interface)
|
||||
const watcher = Layer.succeed(KilocodeWatcher.Service, KilocodeWatcher.Service.of({ init: () => Effect.void }))
|
||||
const wakeup = Layer.succeed(
|
||||
Wakeup.Service,
|
||||
Wakeup.Service.of({
|
||||
schedule: () => Effect.die(new Error("wakeup schedule is not used by this test")),
|
||||
list: () => Effect.succeed([]),
|
||||
cancel: () => Effect.succeed(undefined),
|
||||
adopt: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const indexing = spyOn(KiloIndexing, "init").mockRejectedValue(err)
|
||||
const warn = spyOn(logger, "warn").mockImplementation(() => {})
|
||||
|
||||
@@ -526,7 +536,9 @@ describe("kilocode tool registry indexing", () => {
|
||||
await Effect.runPromise(
|
||||
KilocodeBootstrap.Service.use((svc) => svc.init()).pipe(
|
||||
Effect.provide(
|
||||
KilocodeBootstrap.layer.pipe(Layer.provide([sessions, bus, memory, session, summary, provider, watcher])),
|
||||
KilocodeBootstrap.layer.pipe(
|
||||
Layer.provide([sessions, bus, memory, session, summary, provider, watcher, wakeup]),
|
||||
),
|
||||
),
|
||||
Effect.scoped,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs"
|
||||
import { rm } from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Git } from "@/git"
|
||||
import { Wakeup } from "@/kilocode/wakeup"
|
||||
import { CancelWakeupTool, type Meta, Params } from "@/kilocode/tool/cancel-wakeup"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import * as Truncate from "@/tool/truncate"
|
||||
import type { Tool } from "@/tool/tool"
|
||||
|
||||
const agentInfo = {
|
||||
name: "code",
|
||||
mode: "primary",
|
||||
options: {},
|
||||
permission: {},
|
||||
} as Agent.Info
|
||||
|
||||
const agents = Agent.Service.of({
|
||||
get: () => Effect.succeed(agentInfo),
|
||||
list: () => Effect.succeed([agentInfo]),
|
||||
defaultInfo: () => Effect.succeed(agentInfo),
|
||||
defaultAgent: () => Effect.succeed("code"),
|
||||
generate: () => Effect.succeed({ identifier: "code", whenToUse: "", systemPrompt: "" }),
|
||||
})
|
||||
|
||||
const truncate = Truncate.Service.of({
|
||||
cleanup: () => Effect.void,
|
||||
write: () => Effect.succeed(""),
|
||||
output: (text) => Effect.succeed({ content: text as string, truncated: false }),
|
||||
limits: () => Effect.succeed({ maxLines: Truncate.MAX_LINES, maxBytes: Truncate.MAX_BYTES }),
|
||||
})
|
||||
|
||||
const ctx: Tool.Context = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make("msg_test"),
|
||||
callID: "call_test",
|
||||
agent: "code",
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
const fire = Layer.succeed(Wakeup.Fire, Wakeup.Fire.of({ run: () => Effect.void }))
|
||||
|
||||
function makeLayer(dir: string) {
|
||||
const storage = Storage.layerFromDir(path.join(dir, "storage")).pipe(
|
||||
Layer.provide(LayerNode.compile(LayerNode.group([FSUtil.node, Git.node]))),
|
||||
)
|
||||
return Layer.mergeAll(
|
||||
Wakeup.layer.pipe(Layer.provide(Layer.merge(storage, fire))),
|
||||
Layer.succeed(Agent.Service, agents),
|
||||
Layer.succeed(Truncate.Service, truncate),
|
||||
)
|
||||
}
|
||||
|
||||
type ToolDef = Tool.DefWithoutID<typeof Params, Meta>
|
||||
|
||||
/** Build the tool against a fresh, isolated Wakeup service and run one body. */
|
||||
async function run<T>(fn: (tool: ToolDef, wake: Wakeup.Interface, dir: string) => Effect.Effect<T>): Promise<T> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-cancel-wakeup-"))
|
||||
try {
|
||||
return await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const wake = yield* Wakeup.Service
|
||||
const info = yield* CancelWakeupTool
|
||||
const tool = yield* info.init()
|
||||
return yield* fn(tool, wake, dir)
|
||||
}).pipe(Effect.provide(makeLayer(dir))),
|
||||
)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = (wake: Wakeup.Interface, dir: string, delay = "1m") =>
|
||||
wake.schedule({ sessionID: ctx.sessionID, directory: dir, prompt: "check the build", delay }).pipe(Effect.orDie)
|
||||
|
||||
describe("cancel_wakeup tool", () => {
|
||||
test("describes the list as showing the reason, not the raw prompt", () =>
|
||||
run((tool) =>
|
||||
Effect.gen(function* () {
|
||||
expect(tool.description).toContain("reason")
|
||||
}),
|
||||
))
|
||||
|
||||
test("lists no wakeups on an empty store", () =>
|
||||
run((tool) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* tool.execute({ action: "list" }, ctx)
|
||||
|
||||
expect(result.title).toBe("Scheduled wakeups")
|
||||
expect(result.output).toBe("No pending wakeups for this session.")
|
||||
expect(result.metadata.count).toBe(0)
|
||||
}),
|
||||
))
|
||||
|
||||
test("lists a scheduled wakeup by id, due time, and prompt", () =>
|
||||
run((tool, wake, dir) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* schedule(wake, dir)
|
||||
|
||||
const result = yield* tool.execute({ action: "list" }, ctx)
|
||||
|
||||
expect(result.output).toContain(info.id)
|
||||
expect(result.output).toContain(new Date(info.dueAt).toISOString())
|
||||
expect(result.output).toContain("check the build")
|
||||
expect(result.metadata.count).toBe(1)
|
||||
}),
|
||||
))
|
||||
|
||||
test("cancels a pending wakeup and reports its id", () =>
|
||||
run((tool, wake, dir) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* schedule(wake, dir)
|
||||
|
||||
const result = yield* tool.execute({ action: "cancel", id: info.id }, ctx)
|
||||
|
||||
expect(result.output).toBe(`Cancelled wakeup ${info.id} (${new Date(info.dueAt).toISOString()}).`)
|
||||
expect(yield* wake.list({ sessionID: ctx.sessionID })).toEqual([])
|
||||
}),
|
||||
))
|
||||
|
||||
test("reports an already-gone wakeup without erroring", () =>
|
||||
run((tool, wake, dir) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* schedule(wake, dir)
|
||||
yield* tool.execute({ action: "cancel", id: info.id }, ctx)
|
||||
|
||||
const again = yield* tool.execute({ action: "cancel", id: info.id }, ctx)
|
||||
|
||||
expect(again.output).toBe(`No pending wakeup with id ${info.id}.`)
|
||||
expect(again.metadata.cancelled).toBeUndefined()
|
||||
}),
|
||||
))
|
||||
|
||||
test("rejects cancel without an id", async () => {
|
||||
await expect(
|
||||
run((tool) =>
|
||||
Effect.gen(function* () {
|
||||
return yield* tool.execute({ action: "cancel" }, ctx)
|
||||
}),
|
||||
),
|
||||
).rejects.toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs"
|
||||
import { rm } from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { Git } from "@/git"
|
||||
import { Wakeup } from "@/kilocode/wakeup"
|
||||
import { ScheduleWakeupTool, type Meta, Params } from "@/kilocode/tool/schedule-wakeup"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import * as Truncate from "@/tool/truncate"
|
||||
import type { Tool } from "@/tool/tool"
|
||||
|
||||
const agentInfo = {
|
||||
name: "code",
|
||||
mode: "primary",
|
||||
options: {},
|
||||
permission: {},
|
||||
} as Agent.Info
|
||||
|
||||
const agents = Agent.Service.of({
|
||||
get: () => Effect.succeed(agentInfo),
|
||||
list: () => Effect.succeed([agentInfo]),
|
||||
defaultInfo: () => Effect.succeed(agentInfo),
|
||||
defaultAgent: () => Effect.succeed("code"),
|
||||
generate: () => Effect.succeed({ identifier: "code", whenToUse: "", systemPrompt: "" }),
|
||||
})
|
||||
|
||||
const truncate = Truncate.Service.of({
|
||||
cleanup: () => Effect.void,
|
||||
write: () => Effect.succeed(""),
|
||||
output: (text) => Effect.succeed({ content: text as string, truncated: false }),
|
||||
limits: () => Effect.succeed({ maxLines: Truncate.MAX_LINES, maxBytes: Truncate.MAX_BYTES }),
|
||||
})
|
||||
|
||||
const ctx: Tool.Context = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make("msg_test"),
|
||||
callID: "call_test",
|
||||
agent: "code",
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
const fire = Layer.succeed(Wakeup.Fire, Wakeup.Fire.of({ run: () => Effect.void }))
|
||||
|
||||
function makeLayer(dir: string) {
|
||||
const storage = Storage.layerFromDir(path.join(dir, "storage")).pipe(
|
||||
Layer.provide(LayerNode.compile(LayerNode.group([FSUtil.node, Git.node]))),
|
||||
)
|
||||
return Layer.mergeAll(
|
||||
Layer.succeed(InstanceRef, { directory: dir, worktree: dir, project: {} as any }),
|
||||
Wakeup.layer.pipe(Layer.provide(Layer.merge(storage, fire))),
|
||||
Layer.succeed(Agent.Service, agents),
|
||||
Layer.succeed(Truncate.Service, truncate),
|
||||
)
|
||||
}
|
||||
|
||||
type ToolDef = Tool.DefWithoutID<typeof Params, Meta>
|
||||
|
||||
/** The success shape: a scheduled wakeup carries id, dueAt, and prompt. */
|
||||
function scheduled(result: { metadata: Meta; output: string }) {
|
||||
const { id, dueAt, prompt } = result.metadata
|
||||
if (id === undefined || dueAt === undefined || prompt === undefined) {
|
||||
throw new Error(`expected a scheduled wakeup, got: ${result.output}`)
|
||||
}
|
||||
return { id, dueAt, prompt }
|
||||
}
|
||||
|
||||
/** Build the tool against a fresh, isolated Wakeup service and run one body. */
|
||||
async function run<T>(
|
||||
fn: (tool: ToolDef, wake: Wakeup.Interface, dir: string, info: Tool.Info<typeof Params, Meta>) => Effect.Effect<T>,
|
||||
): Promise<T> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-schedule-wakeup-"))
|
||||
try {
|
||||
return await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const wake = yield* Wakeup.Service
|
||||
const info = yield* ScheduleWakeupTool
|
||||
const tool = yield* info.init()
|
||||
return yield* fn(tool, wake, dir, info)
|
||||
}).pipe(Effect.provide(makeLayer(dir))),
|
||||
)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
describe("schedule_wakeup tool", () => {
|
||||
test("registers as schedule_wakeup with when-not-to-use guidance", () =>
|
||||
run((tool, _wake, _dir, info) =>
|
||||
Effect.gen(function* () {
|
||||
expect(info.id).toBe("schedule_wakeup")
|
||||
expect(tool.description).toContain(
|
||||
"Do NOT use this tool for short waits that a blocking shell command covers",
|
||||
)
|
||||
expect(tool.description).toContain("10 seconds")
|
||||
}),
|
||||
))
|
||||
|
||||
test("schedules a wakeup and reports the id, due time, and prompt", () =>
|
||||
run((tool, wake, _dir) =>
|
||||
Effect.gen(function* () {
|
||||
const before = Date.now()
|
||||
|
||||
const result = yield* tool.execute({ prompt: "check the deploy", delay: "5m", reason: "deploy" }, ctx)
|
||||
|
||||
const { id, dueAt, prompt } = scheduled(result)
|
||||
expect(id).toMatch(/^wku/)
|
||||
expect(result.output).toContain(id)
|
||||
expect(result.output).toContain(new Date(dueAt).toISOString())
|
||||
expect(result.output).toContain("check the deploy")
|
||||
expect(dueAt).toBeGreaterThanOrEqual(before + 5 * 60_000)
|
||||
expect(prompt).toBe("check the deploy")
|
||||
|
||||
const list = yield* wake.list({ sessionID: ctx.sessionID })
|
||||
expect(list).toHaveLength(1)
|
||||
expect(list[0]?.prompt).toBe("check the deploy")
|
||||
expect(result.output).not.toContain("Requested")
|
||||
}),
|
||||
))
|
||||
|
||||
test("accepts an absolute when and resolves it to the due time", () =>
|
||||
run((tool, _wake, _dir) =>
|
||||
Effect.gen(function* () {
|
||||
const when = new Date(Date.now() + 60 * 60_000)
|
||||
|
||||
const result = yield* tool.execute({ prompt: "later", when: when.toISOString() }, ctx)
|
||||
|
||||
const { dueAt } = scheduled(result)
|
||||
expect(result.title).toMatch(/^Scheduled wakeup wku/)
|
||||
expect(dueAt).toBeGreaterThanOrEqual(when.getTime() - 2_000)
|
||||
expect(result.output).toContain(when.toISOString())
|
||||
}),
|
||||
))
|
||||
|
||||
test("returns an Invalid wakeup input result for a past when", () =>
|
||||
run((tool, wake, _dir) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* tool.execute(
|
||||
{ prompt: "too late", when: new Date(Date.now() - 60_000).toISOString() },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.title).toBe("Invalid wakeup input")
|
||||
expect(result.metadata.id).toBeUndefined()
|
||||
expect(yield* wake.list({ sessionID: ctx.sessionID })).toEqual([])
|
||||
}),
|
||||
))
|
||||
|
||||
test("returns an Invalid wakeup input result when neither when nor delay is given", () =>
|
||||
run((tool, _wake, _dir) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* tool.execute({ prompt: "when?" }, ctx)
|
||||
|
||||
expect(result.title).toBe("Invalid wakeup input")
|
||||
expect(result.output).toContain("exactly one of when or delay")
|
||||
}),
|
||||
))
|
||||
|
||||
test("clamps a sub-minimum delay up to the minimum", () =>
|
||||
run((tool, _wake, _dir) =>
|
||||
Effect.gen(function* () {
|
||||
const before = Date.now()
|
||||
|
||||
const result = yield* tool.execute({ prompt: "soon", delay: "1s" }, ctx)
|
||||
|
||||
const { dueAt } = scheduled(result)
|
||||
const ahead = dueAt - before
|
||||
expect(ahead).toBeGreaterThanOrEqual(Wakeup.MIN_DELAY_MS)
|
||||
expect(ahead).toBeLessThan(Wakeup.MIN_DELAY_MS + 5_000)
|
||||
expect(result.output).toContain(`Requested delay: "1s" is under the 10-second minimum`)
|
||||
}),
|
||||
))
|
||||
|
||||
test("clamps a beyond-horizon when down to the horizon", () =>
|
||||
run((tool, _wake, _dir) =>
|
||||
Effect.gen(function* () {
|
||||
const before = Date.now()
|
||||
|
||||
const result = yield* tool.execute(
|
||||
{ prompt: "far", when: new Date(Date.now() + Wakeup.MAX_HORIZON_MS * 2).toISOString() },
|
||||
ctx,
|
||||
)
|
||||
|
||||
const { dueAt } = scheduled(result)
|
||||
expect(dueAt - before).toBeLessThanOrEqual(Wakeup.MAX_HORIZON_MS + 2_000)
|
||||
expect(result.output).toContain("Requested when:")
|
||||
expect(result.output).toContain("7-day horizon")
|
||||
}),
|
||||
))
|
||||
|
||||
test("reports the cap when the session already holds the maximum", () =>
|
||||
run((tool, _wake, _dir) =>
|
||||
Effect.gen(function* () {
|
||||
for (let i = 0; i < Wakeup.MAX_PER_SESSION; i++) {
|
||||
const result = yield* tool.execute({ prompt: `wakeup ${i}`, delay: "1m" }, ctx)
|
||||
expect(result.metadata.id).toBeString()
|
||||
}
|
||||
|
||||
const result = yield* tool.execute({ prompt: "one more", delay: "1m" }, ctx)
|
||||
|
||||
expect(result.title).toBe("Too many scheduled wakeups")
|
||||
expect(result.output).toContain("Too many scheduled wakeups")
|
||||
expect(result.output).toContain(`maximum of ${Wakeup.MAX_PER_SESSION} pending wakeups`)
|
||||
expect(result.output).toContain("cancel_wakeup")
|
||||
expect(result.metadata.id).toBeUndefined()
|
||||
}),
|
||||
))
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test"
|
||||
import fs from "fs"
|
||||
import { rm } from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { Wakeup } from "@/kilocode/wakeup"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { pollWithTimeout } from "../../lib/effect"
|
||||
|
||||
const model = {
|
||||
name: "Test Model",
|
||||
tool_call: true,
|
||||
attachment: true,
|
||||
modalities: { input: ["text", "image"], output: ["text"] },
|
||||
limit: { context: 100000, output: 10000 },
|
||||
}
|
||||
|
||||
// The exact `chat.completion.chunk` frame shape the other session tests use.
|
||||
function line(input: unknown) {
|
||||
return `data: ${JSON.stringify(input)}\n\n`
|
||||
}
|
||||
|
||||
function chunk(input: { delta?: Record<string, unknown>; finish?: string }) {
|
||||
return {
|
||||
id: "chatcmpl-wakeup-resume-test",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [
|
||||
{
|
||||
delta: input.delta ?? {},
|
||||
...(input.finish ? { finish_reason: input.finish } : {}),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function reply(text: string) {
|
||||
const enc = new TextEncoder()
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(ctrl) {
|
||||
ctrl.enqueue(enc.encode(line(chunk({ delta: { role: "assistant" } }))))
|
||||
ctrl.enqueue(enc.encode(line(chunk({ delta: { content: text } }))))
|
||||
ctrl.enqueue(enc.encode(line(chunk({ finish: "stop" }))))
|
||||
ctrl.enqueue(enc.encode("data: [DONE]\n\n"))
|
||||
ctrl.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// The runtime holds the wakeup timer's scope; dispose it once for the file.
|
||||
afterAll(async () => {
|
||||
await AppRuntime.dispose()
|
||||
})
|
||||
|
||||
function config(baseURL: string) {
|
||||
return JSON.stringify({
|
||||
model: "test/test-model",
|
||||
small_model: "test/test-model",
|
||||
enabled_providers: ["test"],
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
provider: {
|
||||
test: {
|
||||
name: "Test",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: { apiKey: "test-key", baseURL },
|
||||
models: { "test-model": model },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe("wakeup resume", () => {
|
||||
test("an armed wakeup fires, resumes the session with its prompt, and clears the entry", async () => {
|
||||
const bodies: string[] = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (!url.pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 })
|
||||
bodies.push(await req.text())
|
||||
return new Response(reply("woke up"), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const base = fs.realpathSync(os.tmpdir())
|
||||
const dir = fs.mkdtempSync(path.join(base, "opencode-wakeup-resume-"))
|
||||
try {
|
||||
await Bun.write(path.join(dir, "opencode.json"), config(`${server.url.origin}/v1`))
|
||||
|
||||
const ctx = await AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load({ directory: dir })))
|
||||
const session = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) => svc.create({ title: "Wakeup resume" })).pipe(
|
||||
Effect.provideService(InstanceRef, ctx),
|
||||
),
|
||||
)
|
||||
|
||||
const info = await AppRuntime.runPromise(
|
||||
Wakeup.Service.use((wake) =>
|
||||
wake.schedule({
|
||||
sessionID: session.id,
|
||||
directory: dir,
|
||||
prompt: "poll the deploy",
|
||||
when: new Date(Date.now() + 1200).toISOString(),
|
||||
}),
|
||||
).pipe(Effect.provideService(InstanceRef, ctx)),
|
||||
)
|
||||
|
||||
await Effect.runPromise(
|
||||
pollWithTimeout(
|
||||
Effect.sync(() =>
|
||||
bodies.some((body) => body.includes("[scheduled wakeup]") && body.includes("poll the deploy"))
|
||||
? true
|
||||
: undefined,
|
||||
),
|
||||
"the wakeup prompt never reached the model",
|
||||
"8 seconds",
|
||||
),
|
||||
)
|
||||
|
||||
const pending = await AppRuntime.runPromise(
|
||||
Wakeup.Service.use((wake) => wake.list({ sessionID: session.id })).pipe(
|
||||
Effect.provideService(InstanceRef, ctx),
|
||||
),
|
||||
)
|
||||
expect(pending.map((item) => item.id)).not.toContain(info.id)
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test("a paused session logs the wakeup as unresumable instead of dropping it silently", async () => {
|
||||
const bodies: string[] = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (!url.pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 })
|
||||
bodies.push(await req.text())
|
||||
return new Response(reply("woke up"), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const base = fs.realpathSync(os.tmpdir())
|
||||
const dir = fs.mkdtempSync(path.join(base, "opencode-wakeup-paused-"))
|
||||
try {
|
||||
await Bun.write(path.join(dir, "opencode.json"), config(`${server.url.origin}/v1`))
|
||||
|
||||
const ctx = await AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load({ directory: dir })))
|
||||
const session = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) => svc.create({ title: "Wakeup paused" })).pipe(
|
||||
Effect.provideService(InstanceRef, ctx),
|
||||
),
|
||||
)
|
||||
|
||||
// Abort the idle session through the same pause path the UI uses.
|
||||
await AppRuntime.runPromise(
|
||||
SessionPrompt.Service.use((svc) => svc.cancel(session.id)).pipe(Effect.provideService(InstanceRef, ctx)),
|
||||
)
|
||||
const paused = await AppRuntime.runPromise(
|
||||
SessionPrompt.Service.use((svc) => svc.paused(session.id)).pipe(Effect.provideService(InstanceRef, ctx)),
|
||||
)
|
||||
expect(paused).toBe(true)
|
||||
|
||||
// The wakeup logger is a cached `Log.create` object, so patch the same
|
||||
// instance resume.ts holds; stderr is not reliable once another test
|
||||
// redirects the log stream to a file.
|
||||
const wakeLog = Log.create({ service: "wakeup" })
|
||||
const errors: Array<{ message?: unknown; extra?: Record<string, unknown> }> = []
|
||||
const originalLog = wakeLog.error.bind(wakeLog)
|
||||
wakeLog.error = ((message?: unknown, extra?: Record<string, unknown>) => {
|
||||
errors.push({ message, extra })
|
||||
}) as typeof wakeLog.error
|
||||
try {
|
||||
await AppRuntime.runPromise(
|
||||
Wakeup.Service.use((wake) =>
|
||||
wake.schedule({
|
||||
sessionID: session.id,
|
||||
directory: dir,
|
||||
prompt: "should be refused",
|
||||
when: new Date(Date.now() + 1200).toISOString(),
|
||||
}),
|
||||
).pipe(Effect.provideService(InstanceRef, ctx)),
|
||||
)
|
||||
|
||||
await Effect.runPromise(
|
||||
pollWithTimeout(
|
||||
Effect.sync(() =>
|
||||
errors.some(
|
||||
(entry) =>
|
||||
entry.message === "wakeup could not resume session" && entry.extra?.reason === "session is paused",
|
||||
)
|
||||
? true
|
||||
: undefined,
|
||||
),
|
||||
"the paused wakeup was dropped without an error log",
|
||||
"8 seconds",
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
wakeLog.error = originalLog
|
||||
}
|
||||
|
||||
// The wake never reached the model.
|
||||
expect(bodies.some((body) => body.includes("[scheduled wakeup]"))).toBe(false)
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -0,0 +1,505 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs"
|
||||
import { rm } from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Context, Effect, Exit, Layer } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Git } from "@/git"
|
||||
import { Wakeup } from "@/kilocode/wakeup"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { pollWithTimeout, testEffect } from "../../lib/effect"
|
||||
|
||||
type FireMode = { inPlace?: boolean } | undefined
|
||||
|
||||
const Recorder = Context.Service<{
|
||||
calls: Wakeup.Info[]
|
||||
modes: FireMode[]
|
||||
reenter: Effect.Effect<void>
|
||||
}>("@test/WakeupRecorder")
|
||||
const TestDir = Context.Service<{ dir: string }>("@test/WakeupDir")
|
||||
|
||||
const storageLayer = (dir: string) =>
|
||||
Storage.layerFromDir(path.join(dir, "storage")).pipe(
|
||||
Layer.provide(LayerNode.compile(LayerNode.group([FSUtil.node, Git.node]))),
|
||||
)
|
||||
|
||||
const fireLayer = (calls: Wakeup.Info[]) =>
|
||||
Layer.succeed(
|
||||
Wakeup.Fire,
|
||||
Wakeup.Fire.of({
|
||||
run: (info) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(info)
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
const recorderFire = Layer.effect(
|
||||
Wakeup.Fire,
|
||||
Effect.gen(function* () {
|
||||
const recorder = yield* Recorder
|
||||
return Wakeup.Fire.of({
|
||||
run: (info, options) =>
|
||||
Effect.gen(function* () {
|
||||
// Lets a test re-enter the service while a fire is in flight.
|
||||
yield* recorder.reenter
|
||||
recorder.calls.push(info)
|
||||
recorder.modes.push(options)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
// Layer.fresh: without it Effect's in-test layer cache hands nested builds the
|
||||
// outer test's storage and Fire, so a "restart" would share the first process.
|
||||
const serviceLayer = <R>(dir: string, fire: Layer.Layer<Wakeup.Fire, never, R>) =>
|
||||
Layer.fresh(Wakeup.layer.pipe(Layer.provide(Layer.merge(storageLayer(dir), fire))))
|
||||
|
||||
const dirLayer = Layer.effect(
|
||||
TestDir,
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => ({ dir: fs.mkdtempSync(path.join(os.tmpdir(), "opencode-wakeup-")) })),
|
||||
({ dir }) =>
|
||||
Effect.promise(() =>
|
||||
rm(dir, { recursive: true, force: true }).catch(() => {
|
||||
// best effort cleanup of a temp directory
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const wakeupLayer = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const { dir } = yield* TestDir
|
||||
const recorder = Layer.effect(
|
||||
Recorder,
|
||||
Effect.sync(() => ({ calls: [] as Wakeup.Info[], modes: [] as FireMode[], reenter: Effect.void })),
|
||||
)
|
||||
return Layer.provideMerge(serviceLayer(dir, recorderFire), recorder)
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.provideMerge(wakeupLayer, dirLayer))
|
||||
|
||||
const session = () => SessionID.descending()
|
||||
|
||||
function info(over: Partial<Wakeup.Info> = {}): Wakeup.Info {
|
||||
const now = Date.now()
|
||||
return {
|
||||
id: Wakeup.ID.ascending(),
|
||||
sessionID: session(),
|
||||
directory: "/tmp/example",
|
||||
prompt: "persisted",
|
||||
dueAt: now + 60_000,
|
||||
created: now,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
/** Write a wakeup straight to the file-backed store, bypassing schedule(). */
|
||||
function persist(dir: string, value: Wakeup.Info) {
|
||||
const file = path.join(dir, "storage", "wakeup", String(value.sessionID), `${value.id}.json`)
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
fs.writeFileSync(file, JSON.stringify(value))
|
||||
}
|
||||
|
||||
/** A file that `read` cannot parse; it must not hold a wakeup slot. */
|
||||
function corrupt(dir: string, sessionID: SessionID) {
|
||||
const file = path.join(dir, "storage", "wakeup", String(sessionID), "wku_corrupt.json")
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
fs.writeFileSync(file, "{ not json")
|
||||
}
|
||||
|
||||
describe("Wakeup", () => {
|
||||
it.effect("schedules and lists a wakeup", () =>
|
||||
Effect.gen(function* () {
|
||||
const wake = yield* Wakeup.Service
|
||||
const dir = (yield* TestDir).dir
|
||||
const sessionID = session()
|
||||
|
||||
const info = yield* wake.schedule({ sessionID, directory: dir, prompt: "check the build", delay: "1m" })
|
||||
const list = yield* wake.list({ sessionID })
|
||||
|
||||
expect(list.map((item) => item.id)).toEqual([info.id])
|
||||
expect(list[0]?.prompt).toBe("check the build")
|
||||
expect(list[0]?.dueAt).toBeGreaterThan(info.created)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("cancels a pending wakeup and is idempotent", () =>
|
||||
Effect.gen(function* () {
|
||||
const wake = yield* Wakeup.Service
|
||||
const dir = (yield* TestDir).dir
|
||||
const sessionID = session()
|
||||
|
||||
const info = yield* wake.schedule({ sessionID, directory: dir, prompt: "later", delay: "1m" })
|
||||
const removed = yield* wake.cancel(info.id)
|
||||
|
||||
expect(removed?.id).toBe(info.id)
|
||||
expect(yield* wake.list({ sessionID })).toEqual([])
|
||||
expect(yield* wake.cancel(info.id)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a wakeup in the past", () =>
|
||||
Effect.gen(function* () {
|
||||
const wake = yield* Wakeup.Service
|
||||
const dir = (yield* TestDir).dir
|
||||
|
||||
const err = yield* Effect.flip(
|
||||
wake.schedule({
|
||||
sessionID: session(),
|
||||
directory: dir,
|
||||
prompt: "nope",
|
||||
when: new Date(Date.now() - 1_000).toISOString(),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(err).toBeInstanceOf(Wakeup.PastTime)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requires exactly one of when or delay", () =>
|
||||
Effect.gen(function* () {
|
||||
const wake = yield* Wakeup.Service
|
||||
const dir = (yield* TestDir).dir
|
||||
|
||||
const missing = yield* Effect.flip(wake.schedule({ sessionID: session(), directory: dir, prompt: "nope" }))
|
||||
expect(missing).toBeInstanceOf(Wakeup.InvalidTime)
|
||||
|
||||
const both = yield* Effect.flip(
|
||||
wake.schedule({
|
||||
sessionID: session(),
|
||||
directory: dir,
|
||||
prompt: "nope",
|
||||
when: new Date(Date.now() + 60_000).toISOString(),
|
||||
delay: "1m",
|
||||
}),
|
||||
)
|
||||
expect(both).toBeInstanceOf(Wakeup.InvalidTime)
|
||||
|
||||
const malformed = yield* Effect.flip(
|
||||
wake.schedule({ sessionID: session(), directory: dir, prompt: "nope", delay: "soon" }),
|
||||
)
|
||||
expect(malformed).toBeInstanceOf(Wakeup.InvalidTime)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("clamps a sub-minimum delay up to the minimum", () =>
|
||||
Effect.gen(function* () {
|
||||
const wake = yield* Wakeup.Service
|
||||
const dir = (yield* TestDir).dir
|
||||
|
||||
const info = yield* wake.schedule({ sessionID: session(), directory: dir, prompt: "soon", delay: "1s" })
|
||||
expect(info.dueAt - info.created).toBe(Wakeup.MIN_DELAY_MS)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("clamps a wakeup beyond the horizon down to the horizon", () =>
|
||||
Effect.gen(function* () {
|
||||
const wake = yield* Wakeup.Service
|
||||
const dir = (yield* TestDir).dir
|
||||
|
||||
const info = yield* wake.schedule({
|
||||
sessionID: session(),
|
||||
directory: dir,
|
||||
prompt: "far",
|
||||
when: new Date(Date.now() + Wakeup.MAX_HORIZON_MS * 2).toISOString(),
|
||||
})
|
||||
expect(info.dueAt - info.created).toBe(Wakeup.MAX_HORIZON_MS)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts ten pending wakeups and rejects the eleventh", () =>
|
||||
Effect.gen(function* () {
|
||||
const wake = yield* Wakeup.Service
|
||||
const dir = (yield* TestDir).dir
|
||||
const sessionID = session()
|
||||
|
||||
for (let index = 0; index < Wakeup.MAX_PER_SESSION; index++) {
|
||||
yield* wake.schedule({ sessionID, directory: dir, prompt: `wake ${index}`, delay: "1m" })
|
||||
}
|
||||
expect(yield* wake.list({ sessionID })).toHaveLength(Wakeup.MAX_PER_SESSION)
|
||||
|
||||
const err = yield* Effect.flip(wake.schedule({ sessionID, directory: dir, prompt: "overflow", delay: "1m" }))
|
||||
expect(err).toBeInstanceOf(Wakeup.TooMany)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lists a persisted wakeup that was never scheduled in this process", () =>
|
||||
Effect.gen(function* () {
|
||||
const wake = yield* Wakeup.Service
|
||||
const dir = (yield* TestDir).dir
|
||||
const persisted = info({ directory: dir })
|
||||
persist(dir, persisted)
|
||||
|
||||
const list = yield* wake.list({ sessionID: persisted.sessionID })
|
||||
|
||||
expect(list.map((item) => item.id)).toEqual([persisted.id])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("scopes cancel to the caller's session", () =>
|
||||
Effect.gen(function* () {
|
||||
const wake = yield* Wakeup.Service
|
||||
const dir = (yield* TestDir).dir
|
||||
const owner = session()
|
||||
const other = session()
|
||||
|
||||
const created = yield* wake.schedule({ sessionID: owner, directory: dir, prompt: "mine", delay: "1m" })
|
||||
|
||||
expect(yield* wake.cancel(created.id, other)).toBeUndefined()
|
||||
expect((yield* wake.list({ sessionID: owner })).map((item) => item.id)).toEqual([created.id])
|
||||
expect((yield* wake.cancel(created.id, owner))?.id).toBe(created.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not let a corrupt persisted file consume a slot", () =>
|
||||
Effect.gen(function* () {
|
||||
const wake = yield* Wakeup.Service
|
||||
const dir = (yield* TestDir).dir
|
||||
const sessionID = session()
|
||||
corrupt(dir, sessionID)
|
||||
|
||||
for (let index = 0; index < Wakeup.MAX_PER_SESSION; index++) {
|
||||
yield* wake.schedule({ sessionID, directory: dir, prompt: `wake ${index}`, delay: "1m" })
|
||||
}
|
||||
|
||||
expect(yield* wake.list({ sessionID })).toHaveLength(Wakeup.MAX_PER_SESSION)
|
||||
const err = yield* Effect.flip(wake.schedule({ sessionID, directory: dir, prompt: "overflow", delay: "1m" }))
|
||||
expect(err).toBeInstanceOf(Wakeup.TooMany)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("enforces the cap under concurrent scheduling", () =>
|
||||
Effect.gen(function* () {
|
||||
const wake = yield* Wakeup.Service
|
||||
const dir = (yield* TestDir).dir
|
||||
const sessionID = session()
|
||||
|
||||
const results = yield* Effect.forEach(
|
||||
Array.from({ length: Wakeup.MAX_PER_SESSION + 5 }),
|
||||
(_, index) => Effect.exit(wake.schedule({ sessionID, directory: dir, prompt: `wake ${index}`, delay: "1m" })),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
expect(results.filter((exit) => Exit.isSuccess(exit))).toHaveLength(Wakeup.MAX_PER_SESSION)
|
||||
expect(yield* wake.list({ sessionID })).toHaveLength(Wakeup.MAX_PER_SESSION)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not fire a wakeup twice when adopt runs during its fire", () =>
|
||||
Effect.gen(function* () {
|
||||
const wake = yield* Wakeup.Service
|
||||
const recorder = yield* Recorder
|
||||
const dir = (yield* TestDir).dir
|
||||
const persisted = info({ directory: dir, dueAt: Date.now() - 1_000, created: Date.now() - 2_000 })
|
||||
|
||||
recorder.reenter = Effect.suspend(() => wake.adopt(dir))
|
||||
persist(dir, persisted)
|
||||
|
||||
yield* wake.adopt(dir)
|
||||
|
||||
expect(recorder.calls.map((item) => item.id)).toEqual([persisted.id])
|
||||
expect(recorder.modes).toEqual([{ inPlace: true }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the in-flight guard while the persisted wakeup is visible again", () =>
|
||||
Effect.gen(function* () {
|
||||
const wake = yield* Wakeup.Service
|
||||
const recorder = yield* Recorder
|
||||
const dir = (yield* TestDir).dir
|
||||
const persisted = info({ directory: dir, dueAt: Date.now() - 1_000, created: Date.now() - 2_000 })
|
||||
|
||||
// Re-create the record while the first fire is resuming, then re-enter
|
||||
// adopt: the in-flight guard, not the removed file, must stop a re-fire.
|
||||
recorder.reenter = Effect.suspend(() => {
|
||||
persist(dir, persisted)
|
||||
return wake.adopt(dir)
|
||||
})
|
||||
persist(dir, persisted)
|
||||
|
||||
yield* wake.adopt(dir)
|
||||
|
||||
expect(recorder.calls.map((item) => item.id)).toEqual([persisted.id])
|
||||
expect(recorder.modes).toEqual([{ inPlace: true }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("releases the guard once a fire completes", () =>
|
||||
Effect.gen(function* () {
|
||||
const wake = yield* Wakeup.Service
|
||||
const recorder = yield* Recorder
|
||||
const dir = (yield* TestDir).dir
|
||||
const persisted = info({ directory: dir, dueAt: Date.now() - 1_000, created: Date.now() - 2_000 })
|
||||
|
||||
persist(dir, persisted)
|
||||
yield* wake.adopt(dir)
|
||||
expect(recorder.calls.map((item) => item.id)).toEqual([persisted.id])
|
||||
|
||||
// The same id persisted again must fire: a completed fire released its guard.
|
||||
persist(dir, persisted)
|
||||
yield* wake.adopt(dir)
|
||||
expect(recorder.calls.map((item) => item.id)).toEqual([persisted.id, persisted.id])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts a when sooner than the delay minimum", () =>
|
||||
Effect.gen(function* () {
|
||||
const now = Date.now()
|
||||
expect(yield* Wakeup.resolve({ when: new Date(now + 1_000).toISOString() }, now)).toBe(now + 1_000)
|
||||
expect(yield* Wakeup.resolve({ delay: "1s" }, now)).toBe(now + Wakeup.MIN_DELAY_MS)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("describes the wake with the scheduled prompt and wake id", () =>
|
||||
Effect.gen(function* () {
|
||||
const info: Wakeup.Info = {
|
||||
id: Wakeup.ID.ascending(),
|
||||
sessionID: session(),
|
||||
directory: "/tmp/example",
|
||||
prompt: "inspect the release",
|
||||
dueAt: Date.now() + 60_000,
|
||||
created: Date.now(),
|
||||
}
|
||||
|
||||
const text = Wakeup.text(info)
|
||||
expect(text).toContain("inspect the release")
|
||||
expect(text).toContain(info.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("describes only the clamp that actually applied", () =>
|
||||
Effect.gen(function* () {
|
||||
const now = Date.now()
|
||||
|
||||
expect(Wakeup.clampNotice({ delay: "1s" }, now + Wakeup.MIN_DELAY_MS, now)).toBe(
|
||||
`Requested delay: "1s" is under the 10-second minimum and was raised to it.`,
|
||||
)
|
||||
expect(Wakeup.clampNotice({ delay: "10s" }, now + Wakeup.MIN_DELAY_MS, now)).toBeUndefined()
|
||||
expect(Wakeup.clampNotice({ delay: "30d" }, now + Wakeup.MAX_HORIZON_MS, now)).toContain("7-day horizon")
|
||||
expect(
|
||||
Wakeup.clampNotice(
|
||||
{ when: new Date(now + Wakeup.MAX_HORIZON_MS * 2).toISOString() },
|
||||
now + Wakeup.MAX_HORIZON_MS,
|
||||
now,
|
||||
),
|
||||
).toContain("Requested when:")
|
||||
expect(Wakeup.clampNotice({ delay: "1h" }, now + 3_600_000, now)).toBeUndefined()
|
||||
expect(Wakeup.clampNotice({ when: new Date(now + 3_600_000).toISOString() }, now + 3_600_000, now)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"fires a persisted wakeup exactly once after a restart",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestDir).dir
|
||||
const sessionID = session()
|
||||
const first: Wakeup.Info[] = []
|
||||
const second: Wakeup.Info[] = []
|
||||
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* Layer.build(serviceLayer(dir, fireLayer(first)))
|
||||
const wake = Context.get(ctx, Wakeup.Service)
|
||||
yield* wake.schedule({ sessionID, directory: dir, prompt: "resume the task", delay: "10s" })
|
||||
}),
|
||||
)
|
||||
|
||||
// Let the stored due time pass after the first process released the wakeup.
|
||||
yield* Effect.sleep("10500 millis")
|
||||
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* Layer.build(serviceLayer(dir, fireLayer(second)))
|
||||
const wake = Context.get(ctx, Wakeup.Service)
|
||||
yield* wake.adopt(dir)
|
||||
yield* wake.adopt(dir)
|
||||
expect(yield* wake.list({ sessionID })).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
expect(first).toEqual([])
|
||||
expect(second.map((info) => info.prompt)).toEqual(["resume the task"])
|
||||
}),
|
||||
20_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"fires an armed wakeup at its due time with the scheduled prompt",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const wake = yield* Wakeup.Service
|
||||
const recorder = yield* Recorder
|
||||
const dir = (yield* TestDir).dir
|
||||
|
||||
yield* wake.schedule({
|
||||
sessionID: session(),
|
||||
directory: dir,
|
||||
prompt: "poll the deploy",
|
||||
when: new Date(Date.now() + 1200).toISOString(),
|
||||
})
|
||||
|
||||
const fired = yield* pollWithTimeout(
|
||||
Effect.sync(() => recorder.calls[0]),
|
||||
"armed wakeup never fired",
|
||||
"8 seconds",
|
||||
)
|
||||
expect(fired.prompt).toBe("poll the deploy")
|
||||
// A timer fire re-resolves the instance through provide, not in place.
|
||||
expect(recorder.modes[0]).toEqual({ inPlace: false })
|
||||
}),
|
||||
20_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"never fires a wakeup cancelled before its due time",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const wake = yield* Wakeup.Service
|
||||
const recorder = yield* Recorder
|
||||
const dir = (yield* TestDir).dir
|
||||
|
||||
const info = yield* wake.schedule({
|
||||
sessionID: session(),
|
||||
directory: dir,
|
||||
prompt: "should not fire",
|
||||
when: new Date(Date.now() + 1200).toISOString(),
|
||||
})
|
||||
yield* wake.cancel(info.id)
|
||||
|
||||
// The sleep is the assertion: it spans the due time the cancelled
|
||||
// timer would have fired at.
|
||||
yield* Effect.sleep("1800 millis")
|
||||
expect(recorder.calls).toEqual([])
|
||||
}),
|
||||
20_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"fires two wakeups due at the same instant",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const wake = yield* Wakeup.Service
|
||||
const recorder = yield* Recorder
|
||||
const dir = (yield* TestDir).dir
|
||||
const when = new Date(Date.now() + 1200).toISOString()
|
||||
|
||||
yield* wake.schedule({ sessionID: session(), directory: dir, prompt: "first wake", when })
|
||||
yield* wake.schedule({ sessionID: session(), directory: dir, prompt: "second wake", when })
|
||||
|
||||
yield* pollWithTimeout(
|
||||
Effect.sync(() => (recorder.calls.length >= 2 ? recorder.calls : undefined)),
|
||||
"both wakeups never fired",
|
||||
"8 seconds",
|
||||
)
|
||||
expect(recorder.calls.map((info) => info.prompt).toSorted()).toEqual(["first wake", "second wake"])
|
||||
}),
|
||||
20_000,
|
||||
)
|
||||
})
|
||||
@@ -251,6 +251,18 @@ describe("tool.registry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
// kilocode_change start - the CLI can schedule and cancel its own future wakeups
|
||||
it.instance("exposes the scheduled wakeup tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
|
||||
expect(ids).toContain("schedule_wakeup")
|
||||
expect(ids).toContain("cancel_wakeup")
|
||||
}),
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
it.instance("does not expose execute unless code mode is enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
@@ -79,6 +79,19 @@ const testAllow: Record<string, { count: number; reason: string }> = {
|
||||
"server/experimental-session-list.test.ts": { count: 2, reason: "Kilo session list integration test" },
|
||||
"kilocode/server/cloud-session-import.test.ts": { count: 5, reason: "full app cloud import transaction integration" },
|
||||
"kilocode/server/listener-runtime.test.ts": { count: 4, reason: "listener and AppRuntime integration test" },
|
||||
"kilocode/wakeup/wakeup-resume.test.ts": {
|
||||
count: 11,
|
||||
reason:
|
||||
"the wakeup resume integration test schedules through the production Wakeup service and asserts the mock " +
|
||||
"model receives the scheduled prompt, so it must run the production Fire/resume path " +
|
||||
"(src/kilocode/wakeup/resume.ts). That path resolves Session and SessionPrompt from the global AppRuntime " +
|
||||
"because a static layer dependency is impossible: Wakeup.node <- kilocode/tool/registry.ts (via " +
|
||||
"schedule_wakeup/cancel_wakeup) <- SessionPrompt.node <- ToolRegistry.node, which already depends on Wakeup.node. " +
|
||||
"The test therefore creates the instance, session, and wakeup through that same global runtime and asserts the " +
|
||||
"pending list on it; scoped layers cannot express the boundary under test. The paused-session case pauses the " +
|
||||
"session and reads SessionPrompt.paused through the same runtime to prove resume refuses and logs instead of " +
|
||||
"dropping the wake.",
|
||||
},
|
||||
"tool/recall.test.ts": { count: 11, reason: "existing runtime integration test" },
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user