feat(cli): add scheduled wakeup and cancel tools - #14094
Conversation
Surface: the agent tools in this repo (`packages/opencode/src/tool/` for core tools, `packages/opencode/src/kilocode/tool/` for Kilo-specific ones, registered through `packages/opencode/src/tool/registry.ts`; each tool ships a `.ts` and a `.txt` description). Add a scheduled wakeup tool and its cancel counterpart. What it must do. - The model calls the wakeup tool with a point in time in the future and a reason or prompt to resume with. The harness wakes the model at that time and continues the session from that prompt. - A second tool cancels a scheduled wakeup, by its id, and can list what is pending so the model can see what it scheduled. - A wakeup survives the process: if the session is idle or the harness restarted, the wake still fires. Say in the PR body how far that guarantee reaches (a running daemon, the next CLI start, or a persisted queue the host drains) and pick the mechanism that already exists in this repo rather than adding a new daemon. Design decisions the workflow makes, and must state in the PR body. - The time argument: accept an absolute time and/or a delay, and define the timezone behaviour and the clamps. There must be a minimum delay and a maximum horizon, and a cap on how many wakeups one session can hold. - Where the schedule is stored, and how it is keyed to the session so a wake resumes the right conversation. - What the model sees on wake: the prompt it scheduled, plus enough context to know a wakeup fired rather than a user message. - Wheth
| type: "text", | ||
| text: text(info), | ||
| synthetic: true, | ||
| metadata: { background: true, wakeup: true, wakeupID: info.id }, |
There was a problem hiding this comment.
[WARNING]: A wake that fires while the session is paused is silently consumed
Setting metadata.background: true routes the wake prompt through KiloSessionControl.background (packages/opencode/src/kilocode/session/control.ts:16). In SessionPrompt.prompt that makes control.begin(sessionID, /*resume*/ false) and then takes the createUserMessage branch; when data.paused is true (set by control.stop on any user cancel/stop, and only cleared by a later normal begin(id, true)), ticket.running() is false and the function returns at packages/opencode/src/session/prompt.ts:1498 without enqueuing loop. resume cannot observe that because the prompt was forked detached, so fireNow deletes the persisted wakeup and nothing is logged. The wake disappears silently, which contradicts the requirement that a wake that cannot resume must fail loudly in a log. Consider verifying the turn actually started (or logging when the prompt returned without running) before treating the fire as delivered.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
|
|
||
| const fireNow = (info: Info) => | ||
| Effect.gen(function* () { | ||
| entries.delete(info.id) |
There was a problem hiding this comment.
[WARNING]: Duplicate fire when adopt races an in-flight fireNow
fireNow removes the wakeup from the in-memory guards first (lines 81-82) and only deletes the persisted entry after fire.run resolves (line 86). fire.run is not instantaneous: for the production Fire it awaits provide/instance load and the session prompt fork. If adopt runs in that window it re-reads the still-persisted Info, sees neither entries.has nor timers.has (line 148), re-adds it, and arms it again, firing the same wakeup twice. Two processes that open the same directory concurrently (both call adopt against the shared file-backed store) hit the same shape with no timing window at all. Consider claiming the wakeup (remove or atomically rename the persisted entry) before running fire.run, so a second adopt cannot rearm it.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| const pending = yield* storage | ||
| .list(["wakeup", String(input.sessionID)]) | ||
| .pipe(Effect.catch(() => Effect.succeed([] as string[][]))) | ||
| if (pending.length >= MAX_PER_SESSION) { |
There was a problem hiding this comment.
[SUGGESTION]: The per-session cap counts persisted files, not live wakeups
The cap is computed from storage.list(["wakeup", sessionID]), but the service fires from entries. A wakeup that is mid-fire.run still counts (spurious TooMany), and an unreadable/corrupt file is never cleaned up: read swallows the decode error (lines 64-65), adopt skips it (lines 146-147), and lookup/cancel cannot return it either, so it permanently consumes one of the MAX_PER_SESSION slots and cannot be cancelled through the tool. The list-then-write check is also not atomic, so two concurrent schedule calls can both pass at 9 and exceed the cap. Counting the session's entries (and pruning files that fail to decode) would be more robust.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| return info | ||
| }) | ||
|
|
||
| const list = Effect.fn("Wakeup.list")(function* (input?: { sessionID?: SessionID }) { |
There was a problem hiding this comment.
[SUGGESTION]: list only reads the in-memory map while cancel falls back to storage
list returns entries only, whereas cancel resolves through lookup, which scans persisted wakeups when the entry is missing. If bootstrap's adopt failed (it is swallowed as a warning in packages/opencode/src/kilocode/bootstrap.ts:65-69) or a wakeup was written by another process, list confidently reports "No pending wakeups for this session" while cancel still finds and removes the same id. A storage-backed fallback in list (or not swallowing the adopt failure) would remove the inconsistency.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
|
|
||
| // Cancel is idempotent: an already-fired, already-cancelled, or | ||
| // unknown id is reported, never thrown. | ||
| const removed = yield* wake.cancel(id as Wakeup.ID) |
There was a problem hiding this comment.
[SUGGESTION]: cancel is not scoped to the caller's session
list filters by ctx.sessionID (line 58), but cancel passes only the id: Wakeup.cancel/lookup scan every stored wakeup and never compare info.sessionID to the caller (packages/opencode/src/kilocode/wakeup/index.ts:67-77, 130-141). A tool call in one session can therefore cancel another session's wakeup, and the description promises "the wakeups scheduled for this session". Passing ctx.sessionID through to cancel/lookup would make the two actions consistent.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| import { Context, Effect, Schema, Types } from "effect" | ||
| import z from "zod" | ||
|
|
||
| /** A scheduled wakeup never fires sooner than this after it is scheduled. */ |
There was a problem hiding this comment.
[SUGGESTION]: The MIN_DELAY_MS doc comment overstates the guarantee
resolve applies the minimum only on the delay branch (Math.max(now + span, now + MIN_DELAY_MS), line 121); an absolute when only has to be > now, so when = now + 1s fires in about a second. The .txt description correctly scopes the minimum to delay, but this comment ("never fires sooner than this after it is scheduled") - and the user-facing changelog that repeats it - implies every wakeup. Either scope the comment/changelog to delay or clamp when too.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| Review and cancel the wakeups scheduled for this session. | ||
|
|
||
| Use this tool to: | ||
| - List the wakeups you scheduled, with their id, due time, and prompt |
There was a problem hiding this comment.
[SUGGESTION]: Description says the list shows the prompt, but it shows the reason
line() renders info.reason ?? info.prompt (packages/opencode/src/kilocode/tool/cancel-wakeup.ts:45), so whenever a reason was supplied the scheduled prompt is not shown. Either state that the label/reason is listed (falling back to the prompt), or include the prompt in the row.
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
| await Instance.restore(inst, fn) | ||
| return | ||
| } | ||
| await provide({ directory: info.directory, fn }) |
There was a problem hiding this comment.
[SUGGESTION]: The provide fallback is unreachable, so a reloaded instance is never re-resolved
arm is always called from a context that already has the instance's InstanceRef (schedule from the tool, adopt from bootstrap with InstanceRef provided at packages/opencode/src/project/instance-store.ts:64), and adopt only arms wakes whose info.directory equals the instance directory. That makes inst.directory === info.directory true for every armed wake, so this provide path never runs and a long-horizon timer always restores the InstanceContext captured when it was armed. If that directory was later reloaded or disposed, the stale context is reused instead of re-loading the current one. Is that intended, or should the guard compare something that can actually go stale?
Reply with @kilocode-bot fix it to have Kilo Code address this issue.
Code Review SummaryStatus: 8 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (20 files)
Fix these issues in Kilo Cloud Reviewed by deepseek-v4.1-flash · Input: 0 · Output: 0 · Cached: 0 Review guidance: REVIEW.md from base branch |
Changelog for users
[scheduled wakeup], with a note that no user is present.Changelog for maintainers
when(ISO-8601; an explicit offset is absolute, otherwise the host timezone applies) ordelay(30s,5m,2h,1d; a bare number is seconds). A past time is rejected, a delay under 10 seconds clamps up, a time past 7 days clamps down, and a session holds at most 10 pending wakeups.Storageservice underwakeup/sessionID/id, and carriessessionIDanddirectoryso a fire resumes the right session in the right project. The guarantee reaches a running process and the next CLI start in that directory, not a separate daemon.adopt(directory)for the instance, which fires overdue wakeups immediately and arms timers for the rest.[scheduled wakeup], the scheduled prompt, and a note that no user is present with the wake id and due time.wakeup could not resume sessionnaming the id, session, and directory.patchedDependenciesentry was removed.E2E proof — log excerpts
/home/igor_kilocode_ai/.local/share/kwf/sections/agent-scheduled-wakeup-tool-c6b8/e2e-cli/e1-flow.log/home/igor_kilocode_ai/.local/share/kwf/sections/agent-scheduled-wakeup-tool-c6b8/e2e-cli/e2-flow.log/home/igor_kilocode_ai/.local/share/kwf/sections/agent-scheduled-wakeup-tool-c6b8/e2e-cli/e3-flow.log/home/igor_kilocode_ai/.local/share/kwf/sections/agent-scheduled-wakeup-tool-c6b8/e2e-cli/e4-flow.log/home/igor_kilocode_ai/.local/share/kwf/sections/agent-scheduled-wakeup-tool-c6b8/e2e-cli/e5-flow.log/home/igor_kilocode_ai/.local/share/kwf/sections/agent-scheduled-wakeup-tool-c6b8/e2e-cli/p1-flow.logOwner request