feat(runtime): replace session-internal CronJob with unified Automation (corrects #639) - #643
feat(runtime): replace session-internal CronJob with unified Automation (corrects #639)#643hqhq1025 wants to merge 27 commits into
Conversation
… scheduling Replaces the fragmented CronCreate/CronDelete/CronList approach (apache#545) with a single `Automation` tool using a `mode` parameter (create/delete/list/pause/resume) and a `kind` parameter (heartbeat vs cron), following Codex Desktop's pattern. Key design decisions: - One tool, one concept: model only decides heartbeat (continue session) vs cron (fresh session) - Schedule supports: cron 5-field expressions, interval seconds, or one-shot delay - Optional durable persistence (JSON file, atomic writes) for cross-restart survival - 7-day auto-expiry, max_fires cap, consecutive-failure auto-pause (5 strikes) - Scheduler tick every 5s, defers when session busy (max 120s then skip) - Desktop + CLI/TUI both integrated with full persistence and turn-tail injection Addresses PR apache#545 reviewer feedback (Astro-Han): - Not in "unhappy middle": ephemeral heartbeats are honestly session-scoped, durable automations persist to disk. Each kind delivers what its API promises. - Trace is free: injectTurn → sendMessage → AgentRun → full RuntimeEvent trace without new event types or data model changes. - No dependency on apache#544 task/run/trace infrastructure. 3 rounds of adversarial review, 26 bugs found and fixed.
Automated coverage for: heartbeat fires on schedule, durable flag, pause/resume/delete lifecycle, turn-tail list, expiry sweep, max_fires cap, consecutive-failure auto-pause, and cron createFreshRun path.
…ehavior Each test creates a deliberately broken version of the system and asserts the expected failure, proving the integration tests are not vacuous: - injectTurn no-op → heartbeat test catches it - maxFires ignored → cap test catches it - expiresAt unchecked → expiry test catches it - pause no-op → lifecycle test catches it - auto-pause missing → failure test catches it - durable flag dropped → persistence test catches it
- Cron parser: range/step boundary (10-30/5 stops at 30), */10, clean timestamps - State: invalid cron rejection, pruneTerminal, skipFire, terminal guard, listAll, registerAll - Storage: 10 tests for automation-store (CRUD, atomic writes, corrupt/wrong-version handling) - Total: runtime 895 + storage 174 = 1069 automation-related tests passing
…tive 6)
Adds /goal-style autonomous execution: the agent works toward a durable
objective across turns without per-step approval, stopping when an external
evaluator judges the condition met/impossible or a safety cap trips.
Design (CC evaluator + Codex lifecycle):
- External evaluator (CC-style): a cheap-model judge runs after each turn and
returns {met, impossible, progress, waiting}. Keeping the judge external
prevents the working model from rationalizing a premature "done" (Codex's
documented failure mode).
- Evaluate-FIRST ordering: a goal genuinely completed on its final permitted
turn is detected as achieved, not misreported as a cap failure.
- Lifecycle (Codex-inspired): active → achieved/impossible/cleared/paused/
stalled/budget_limited/max_iterations. GoalSet/Clear/Status/Pause/Resume tools.
- Safety caps: block cap (8 consecutive no-progress turns → stalled), token
budget, max iterations (50). Evaluator timeout (30s) + parse failures are
NEUTRAL — a transient/garbled evaluator cannot defeat stall detection.
- Trace is free: continuation goes through runtime.sendMessage → AgentRun →
full RuntimeEvent trace, no new event types.
- Abort halts the loop (desktop turnAborted + CLI !closed guard + canContinue
rejects 'aborted'). Re-entrancy guarded per session.
Integration: desktop + CLI/TUI both wired (tools, turn-tail status injection,
continuation at the turn boundary), session cleanup on archive/remove.
The waiting→heartbeat automation bridge was explored and removed: coupling two
independent lifecycles created a maxFires zombie. A 'waiting' evaluation is now
neutral (no stall) + normal re-check, bounded by maxIterations.
5 rounds of adversarial workflow review, 20 confirmed issues found and fixed
until a clean pass. runtime + desktop test suites green (goal-specific: ~70 tests).
…m (PR apache#558 review G1-G5) Addresses Astro-Han's PR review — the must-fix functional gaps that stopped cron from working: - G1: cron now actually runs. Desktop wires createFreshRun (createSession in explore mode + sendMessage), so a cron fire spawns a real session + run labelled `automation`/`cron`. CLI has no multi-session surface, so cron is gated off there (cronEnabled derives from the executor; the tool advertises heartbeat only and rejects the cron kind at the schema). - G4: outcome is decided AFTER the run's stream finishes. Split the fire into attemptStarted / attemptSucceeded / attemptFailed; terminal completion (once / maxFires) commits only on a real success. injectTurn/createFreshRun now return a Promise<AutomationFireResult>; a rejected or ok:false run is recorded as a failure, never a success. A one-shot failure pauses (visible, not a zombie). - G2: fires carry their runId — markSuccess(id, runId) sets lastRunId. - G3b: automation-triggered runs are labelled. New TurnOrigin on UserMessageInput threads to AgentRunHeader.automationId, so trace can tell an automation run from a hand-typed one. - G5: CLI wraps the TUI in try/finally { scheduler.dispose() } so its timer does not keep the process alive; desktop already clears session heartbeats on archive/remove. - G6 (partial): canFire only fires into a genuinely idle session. Deferred (per reviewer's own sequencing — settle the data model first): G3 AutomationEvent ledger, G7 cron-parser edge cases (sparse annual crons, dom+dow OR semantics, timezone). Will follow up as an RFC/owned issue. Tests updated for the attempt* lifecycle; new cron integration + gating tests.
# Conflicts: # apps/desktop/src/main/main.ts # packages/cli/src/cli.ts # packages/cli/src/runtime-bootstrap.ts
…ropic API)
Caught by a headless end-to-end run against a real Anthropic model: the unified
Automation tool used a discriminatedUnion, which serializes to JSON Schema as
{ anyOf: [...] } with NO top-level "type". Anthropic rejects tool definitions
whose input_schema.type is not "object" ("tools.0.custom.input_schema.type:
Field required", HTTP 500) — and since all tools are sent every turn, this broke
EVERY turn in any session that had the Automation tool registered (the model's
reply came back empty and the session wedged in 'blocked').
Fix: flatten to a single top-level z.object with `mode` (enum) and per-mode
fields optional, validated in impl(). The `kind` enum still gates cron off on
hosts without a fresh-run executor. Nested `schedule` union stays (a union under
a property is fine; only the top level must be an object).
Verified: headless cron run now gets a real LLM reply (assistant="CRON_OK") and
the fresh session settles 'active' instead of 'blocked'.
…viewer G1) createMakaCliRuntimeContext now accepts an optional automationCreateFreshRun. When a host provides it, the Automation tool advertises the cron kind and cron fires spawn a fresh session + run through that executor; omitted (the default CLI, no multi-session surface) means heartbeat only. This matches the reviewer's G1 guidance — a host derives cron support from the executor it passes in. Verified end-to-end through the REAL Maka agent chain (runtime.sendMessage → AgentRun → AiSdkBackend + real Maka system prompt) with natural user phrasing against a real LLM: "每20秒检查系统状态" → heartbeat/interval/20; "工作日9点日报" → cron 0 9 * * 1-5; "5分钟后提醒" → once/300; "长期保留重启别丢" → durable; pause/resume/delete by natural reference (model lists then acts); GoalSet with max_iterations from "最多5轮" and pause. 9/9.
…mezone doc (PR apache#558 G7) - Sparse annual crons: search window 366d → ~8y (MAX_SEARCH_MINUTES). The max gap between Feb 29ths is 8 years, not 4 — a century year not divisible by 400 (2100) is skipped, so 2096→2104. 8y guarantees every satisfiable expression resolves while staying bounded. "0 0 29 2 *" now resolves; "0 0 30 2 *" still returns null after a bounded search (no infinite loop). - day-of-month + day-of-week semantics: when BOTH fields are restricted, a day matches if it satisfies EITHER (Vixie OR), not both. "0 0 13 * 5" now means "the 13th OR any Friday", not "Friday the 13th". When one field is *, AND applies (the * is a no-op). dom-only and dow-only unchanged. - timezone: documented contract (host local time via Date local getters, incl. DST behavior); per-automation IANA zones out of scope for this pass (would ripple through the schedule type and every caller). 8 new tests (Feb 29 resolves, Feb 30 null, dom+dow OR both directions, dom-only, dow-only, regressions for */5, 0 9 * * 1-5, 10-30/5). runtime suite green (pre-existing flaky shell tests aside).
…rectness (self-review)
Adversarial review of the done cronjob work surfaced 5 real bugs; all fixed:
- P1 concurrent cron re-fire: the scheduler had no in-flight guard, and canFire
gates the automation's CREATOR session — which stays idle for cron (the run
happens in a spawned session), so a cron whose run outlasts its cadence
re-fired every tick (duplicate sessions, maxFires blown, out-of-order counter
corruption). Added a per-automation in-flight Set: skip while a fire's run is
executing; add before dispatch, clear in both .then and .catch (and on dispose).
- P2 maxFires not enforced on failure: fireCount++ is unconditional in
attemptStarted but the cap only lived in attemptSucceeded, so a failing
recurring automation fired up to the consecutive-failure cap (5), and fireCount
could exceed maxFires ("Fires: 5/2"). maxFires is now a hard cap on ATTEMPTS —
attemptStarted nulls nextFireAt once fireCount reaches maxFires.
- P2 desktop fires never recorded lastRunId: streamEvents returns {turnId,...}
but the scheduler reads result.runId. Desktop injectTurn/createFreshRun now map
turnId → runId so attemptSucceeded sets lastRunId.
- P2 cron sessions accumulated unbounded: each cron fire spawned a fresh session
forever. createFreshRun now archives the fresh session after its run finalizes
(run/trace preserved, active list not flooded).
- P2 dow=7 never matched: cron allows 0 or 7 for Sunday but Date.getDay() is 0-6.
Added the Sunday 7-alias so "7", "5-7", "0,7" fire on Sundays.
New tests: in-flight guard (slow cron doesn't re-fire concurrently), maxFires
bounds attempts even when every run fails, dow=7 Sunday matching. Runtime +
desktop suites green (pre-existing flaky shell tests aside).
…ew round 2) A maxFires-exhausted (or one-shot that already fired) automation only reaches 'paused' via the attemptFailed path, which leaves nextFireAt=null. resume() previously re-armed it unconditionally, so the next tick bumped fireCount past maxFires (or re-fired the 'once') — spawning a real extra run beyond the declared hard cap. resume() now refuses when the fire budget is spent, and the Automation tool reports the exhausted budget instead of a misleading 'not paused'. Adds two regression tests.
…tart Persistence infra (FileAutomationStore + loadDurableAutomations + sync-on- mutation + scheduler.start) was fully wired in both desktop and cli, but durability was opt-in via a `durable` flag defaulting to false for BOTH kinds. A cron is a standalone scheduled task (fresh session each run) — it is pointless if it dies on restart, yet it only persisted when the model happened to pass durable:true. create() now defaults durable by kind: cron=true, heartbeat=false (bound to its session), with an explicit flag always winning. Updates the tool schema description and adds create-default tests.
Goal is independent of Automation (the heartbeat bridge was removed in the self-review pass), so it moves to its own stacked PR. Removes the 9 goal-only files plus every goal wiring point — index.ts exports, cli runtime-bootstrap (GoalManager/buildGoalTools/goalContinuationDeps), cli.ts + pi-tui-runner onTurnComplete hook, cli/desktop turn-tail goal fragments, and desktop main goalWiring (tools, session-lifecycle removal, turn-boundary continuation, quit dispose). Automation is untouched. Goal is re-added verbatim on the stacked branch via the inverse of this commit. Verified: runtime automation 87/0, cli 115/0, desktop main typecheck clean, zero goal/automation-named test failures.
…able across sessions Persisted crons reload from disk under their original sessionId, but list / pause / resume / delete were session-scoped, so after a restart a fresh session could not see or manage them — persistence without query. Durable automations are now app-global: listVisibleForSession surfaces this session's own plus every durable one, and pause/resume/delete accept a durable target from any session. Non-durable heartbeats stay session-private; the per-session create cap still counts only session-owned automations. Adds cross-session unit tests and a real desktop e2e (createMainAutomationWiring + FileAutomationStore on temp disk): create durable cron -> persist -> restart -> fresh session lists/manages/deletes it, deletion re-persisted. Verified end-to-end through the real Maka chain (runtime.sendMessage + real LLM): natural phrasing creates a durable cron that a brand-new session lists after restart.
… lifecycle hardening (adversarial review) Root cause of 3 P1s: canFire gated EVERY automation on its creator session's state, but a cron spawns a FRESH session — so archiving/deleting the creating conversation (or losing it across a restart) permanently stopped a durable cron from ever firing, then it silently expired. canFire is now kind-aware (receives the automation): cron is gated only on the global privacy (incognito) check; heartbeat still requires its own session to exist and be idle. The desktop gate is extracted to a pure, unit-tested evaluateAutomationCanFire. Also from the review: - heartbeat is now ALWAYS session-bound (durable is a cron-only concept) — a durable heartbeat was a post-restart zombie and evaded persistence on removeAllForSession. - registerAll heals an interrupted fire (active + nextFireAt=null, budget not spent → re-armed) instead of leaving a silent zombie until expiry. - resume() resets consecutiveFailures/lastError so a resumed automation isn't re-paused after a single fresh failure. - CLI canFire gates incognito and is kind-aware; CLI durable-sync no longer swallows disk-write errors silently. - desktop canFire drops the dead waiting_for_user branch and no longer throws when a heartbeat's session file is gone. Tests: runtime automation 96/0, cli 118/0, desktop canfire 9/0 + persistence e2e. New: evaluateAutomationCanFire gate, registerAll recovery, resume streak.
…d; heartbeat + incognito gated Ties the P1 fix through the real AutomationManager + AutomationScheduler + the kind-aware evaluateAutomationCanFire gate (injectable timers, no Electron): a durable cron still fires when its creating conversation is archived, while a heartbeat in that same archived session does not, and incognito blocks both.
…ecovery, no once drift - P1 regression: a heartbeat-only host (CLI) sharing the desktop workspace store paused the desktop's durable crons. The cron-without-executor branch called attemptFailed (never advancing nextFireAt) → a tight failure loop that persisted paused state to the shared automations.json. The scheduler now SILENTLY IGNORES a cron when no createFreshRun is configured — no fail, pause, advance, or emit — leaving shared durable state untouched for a host that can run it. - registerAll recovery was inert (it excluded the only naturally-reachable interrupted state). It now settles an interrupted spent-budget fire (once fired / at maxFires, active + nextFireAt=null) to 'completed' (at-most-once, no re-run), and re-arms only a corrupt recurring null. - skipFire on a one-shot settled it via computeNextFire, re-adding the full delay → drift + silent loss under sustained defer (busy/incognito). It now settles a skipped once to 'expired' with a reason instead of drifting. - The kind-aware fire gate moved to @maka/runtime (evaluateAutomationCanFire + HEARTBEAT_IDLE_STATUSES) so desktop and CLI share ONE idle-status definition; the CLI no longer fires a heartbeat into 'waiting_for_user'/'review' sessions. Tests: runtime automation 98/0, cli 118/0, desktop automation 14/0. New: skipFire once-terminal + recurring-advance, registerAll settle-to-completed.
…; interrupted fire records its unknown outcome Two P3s from round-3 (converged from round-2's P1+7): - The eager expiry sweep mutated+persisted crons even on a host without a cron executor, bypassing the 'leave crons untouched' invariant that attemptFire enforces — on a shared workspace a stale heartbeat-only CLI could expire and drop the desktop's cron from automations.json. The sweep now skips crons when createFreshRun is absent, mirroring attemptFire. - registerAll settled an interrupted (crash mid-run) fire to a clean 'completed' with no error, indistinguishable from a real success even though the run's outcome was never committed. It now records lastError='Interrupted on restart ... not re-run.' so the unknown outcome is surfaced (no silent unknown state). Tests: runtime automation 99/0, cli 137/0, desktop automation 14/0. New: sweep-skips-cron-on-cron-disabled-host + settle-records-uncertainty.
… automations (round-4 P1) The CLI shares the desktop's workspace by design (resolveMakaWorkspaceRoot reconstructs the Electron userData path), so its automations.json IS the desktop's. store.sync() is a full-file overwrite. Two P1 data-loss paths: - The heartbeat-only CLI has NO durable automations of its own (heartbeats are never durable), yet syncAutomations wrote its empty/stale durable list over the shared file, ERASING the desktop's crons. - loadDurableAutomations + registerAll adopted+reconciled crons the CLI can't run; the round-3 settle-to-completed then dropped them on the next sync. Root-cause fix: durable persistence is now gated on cron capability. A host without createFreshRun neither loads nor writes the durable store — it leaves that state entirely to the host that owns it. Applied symmetrically in the CLI (runtime-bootstrap) and desktop (automation-wiring). Two cron-enabled hosts sharing a store remains the separate, deferred leader-lock (G6). Tests: runtime automation 99/0, cli 137/0, desktop automation 21/0. New e2e: a cron-disabled host boots on a shared workspace, does heartbeat activity, and the owner's durable cron stays intact on disk.
…ates in O(1) (round-5) Two pre-existing defects surfaced by round-5 (the round-4 clobber class was confirmed fully closed): - Store loadAll() masked a corrupt/unreadable automations.json as an empty store, so a subsequent full-overwrite sync would silently and permanently erase real durable crons (a transient EMFILE/EBUSY or a version-mismatch at startup was enough). loadAll now distinguishes ENOENT (legitimately empty) from a present-but-unreadable file, which it FAILS LOUD on. Both hosts catch that on load and DISABLE persistence (durableStoreReadable=false) so a later mutation can never overwrite data they failed to read. - computeNextCronFire scanned up to ~8 years (4.2M iterations) synchronously in the Electron main process for a schema-valid-but-unsatisfiable expression — a ~1s freeze easily triggered by common LLM output like '0 9 * * MON' (named tokens were unsupported) or an impossible date like '0 0 30 2 *'. It now normalizes+validates in O(1) first: translates named day/month tokens (MON-SUN, JAN-DEC), rejects out-of-range fields, and fast-fails impossible calendar dates (respecting Vixie dom/dow OR-semantics), before any scan. Tests: runtime automation 105/0 (+6 cron validation), cli 137/0, desktop automation 21/0, storage 10/0. Store corrupt/version tests now assert fail-loud.
…k re-fire storm (round-6) computeNextCronFire minute-aligned the scan start with Date.setSeconds(0,0), which round-trips the instant through local wall-clock. During a DST fall-back (the repeated local hour), V8 re-encodes the ambiguous time to the earlier offset, shifting the start ~59 min BEFORE fromTime — so the scan returned a candidate <= fromTime, violating the strictly-after contract. attemptStarted then re-armed nextFireAt to a past time, and checkAndFire re-fired every tick for the whole repeated hour: one daily cron became a storm of duplicate fresh sessions + LLM runs (annual, per DST zone). Now the start is computed in epoch arithmetic (fromTime - fromTime%60000 + 60000), which is offset-safe; candidate wall-clock fields are still read with local getters, so 'N am local' semantics are unchanged and results are byte-identical for all non-DST expressions. Empirically verified (TZ=America/New_York, '30 1 * * *' at 2026-11-01T06:30Z): was returning an equal/past time, now strictly after. Regression test runs the built module in a child process with TZ set. Tests: runtime automation 106/0.
# Conflicts: # apps/desktop/src/main/main.ts
…th unified Automation Corrective PR for apache#639, which merged the earlier CC-style CronJob (apache#545) that had been superseded by the Codex-style unified Automation design (discussed and agreed before apache#545 was merged; apache#545 was left open, which misled the merge). Removes the wakeup-scheduler primitive and its CronCreate/CronDelete/CronList tools + UI cards, and replaces them with the single unified Automation tool (mode + kind: heartbeat = session-internal polling, cron = standalone fresh- session runs). The Automation implementation carries 7 rounds of adversarial review over the wakeup design: cron/creator-session decoupling, cross-session query+management, durable persistence with fail-loud reads + cron-capability gating (no shared-store clobber), incognito privacy gating, O(1) cron validation (named tokens, impossible-date fast-fail), and a DST fall-back re-fire-storm fix. Removed: wakeup-scheduler.ts, wakeup-tools.ts + tests; index.ts exports; desktop main.ts wiring; ui/tool-activity.tsx CronJob preview cards. Goal (P6) is unaffected and tracked separately.
…643 by @hqhq1025) — adopted with review fixes F1-F3+F5 (#656) Adopts PR #643 (unified Automation: heartbeat + cron, single tool) onto current main as a squash, preserving main's ToolCardBody dispatch and the #647 tool-activity refactors, with the maintainer's review fixes: - F1 (HIGH, automation-scheduler.ts): the busy-session defer budget is a ~45min wall-clock window (DEFER_WINDOW_MS, equivalent to the old wakeup-scheduler's 5s→5min exponential backoff), replacing the ~120s 24-retry cap. A transient busy window no longer terminally expires a `once` automation — skipFire only runs when the window is exhausted. - F2 (HIGH, automation-can-fire.ts): 'waiting_for_user' joins HEARTBEAT_IDLE_STATUSES (#639 decision — the wakeup's home scenario is starting a turn in place of the user); the desktop canfire test now pins the new set instead of the opposite. - F3 (MEDIUM, automation-state.ts): computeJitter ported verbatim from the old wakeup-scheduler and wired into computeNextFire — recurring (interval/cron) re-schedules get up to 10% delay jitter capped at 15min, one-shot fires landing on :00/:30 get up to 90s early jitter, computed on the actual fire timestamp. Mirrored the old jitter unit tests and added wiring coverage; AutomationManagerDeps gains an injectable `random`. - F5 (MEDIUM, packages/ui/tool-activity.tsx): AutomationResultPreview card (created/deleted/listed, localized) replaces the deleted CronJobResultPreview inside the shared ToolCardBody dispatch, plus a new desktop contract test binding the runtime tool's real output strings to the rendered card. - LOW: MAX_TERMINAL_KEPT 5 → 50 (old wakeup history cap) and the model-facing list now surfaces deferred fire attempts (deferredFireCount), mirroring the old CronList fire_attempts. - Fix: the CLI scheduler tick timer is unref()ed — the PR's always-on 5s tick kept the Node event loop alive, hanging any bootstrap consumer that exits without close() (this hung the CLI test suite indefinitely). Also allow-listed the PR's CLI persistence warnings in check-console. Original feature authored by @hqhq1025 in #643.
|
Adopted and merged as #656 — thank you @hqhq1025, this is excellent work. The design pivot is accepted: durable, cross-session, restart-surviving automations are a genuine capability the session-internal WakeupScheduler couldn't offer, and your test discipline (behavioral coverage + the mutation-verify suite) made the review straightforward. Four review fixes were applied during adoption because they silently reverted decisions from the #639 review:
Also found during verification: the always-on 5s tick kept the Node event loop alive and hung the CLI test suite — the timer is Closing this PR in favor of #656 (your commit authorship is credited in the title/message). The |
CC-style external evaluator + Codex lifecycle (pause/resume/blocked, token budget, block cap, agent-settable goals). GoalManager + evaluateGoal (30s timeout, evaluator-failure neutral) + handleGoalContinuation (evaluate-first, re-entrancy guard, block cap -> stalled, token budget -> budget_limited). Wired at the turn boundary in both cli (runtime-bootstrap + pi-tui onTurnComplete) and desktop (goal-wiring + main.ts continuation), with an active-goal turn-tail fragment in both system-prompt builders. Independent of Automation. Addresses review on apache#625 (jackwener BLOCKERs + Astro-Han follow-ups): - CLI interrupt now halts the loop: submitPromptToTranscript reports turn outcome (aborted/errored); pi-tui-runner gates onTurnComplete on a clean turn, mirroring the desktop turnAborted/turnError guard. An errored turn is non-continuable (no hammering a failing connection). - Visible kill switch: GoalManager onChange -> 'goal-change' session event -> desktop header pill (turn counter, warning-tinted) with one-click clear via goal:get / goal:clear IPC. Rebased onto main after apache#643 (unified Automation). - Evaluator runs on the session's own connection/model (not the global default). canContinue also rejects waiting_for_user. - Evaluator uses maxOutputTokens (AI SDK v6), raised to 1024 so model-side reasoning before the JSON verdict doesn't truncate it to empty output (found via a real-chain e2e). Unused wait_seconds dropped from the contract. - Goals are in-memory / session-lifetime by design (documented); a restart stops the loop rather than silently resuming.
CC-style external evaluator + Codex lifecycle (pause/resume/blocked, token budget, block cap, agent-settable goals). GoalManager + evaluateGoal (30s timeout, evaluator-failure neutral) + handleGoalContinuation (evaluate-first, re-entrancy guard, block cap -> stalled, token budget -> budget_limited). Wired at the turn boundary in both cli (runtime-bootstrap + pi-tui onTurnComplete) and desktop (goal-wiring + main.ts continuation), with an active-goal turn-tail fragment in both system-prompt builders. Independent of Automation. Addresses review on apache#625 (jackwener BLOCKERs + Astro-Han follow-ups): - CLI interrupt now halts the loop: submitPromptToTranscript reports turn outcome (aborted/errored); pi-tui-runner gates onTurnComplete on a clean turn, mirroring the desktop turnAborted/turnError guard. An errored turn is non-continuable (no hammering a failing connection). - Visible kill switch: GoalManager onChange -> 'goal-change' session event -> desktop header pill (turn counter, warning-tinted) with one-click clear via goal:get / goal:clear IPC. Rebased onto main after apache#643 (unified Automation). - Evaluator runs on the session's own connection/model (not the global default). canContinue also rejects waiting_for_user. - Evaluator uses maxOutputTokens (AI SDK v6), raised to 1024 so model-side reasoning before the JSON verdict doesn't truncate it to empty output (found via a real-chain e2e). Unused wait_seconds dropped from the contract. - Goals are in-memory / session-lifetime by design (documented); a restart stops the loop rather than silently resuming.
CC-style external evaluator + Codex lifecycle (pause/resume/blocked, token budget, block cap, agent-settable goals). GoalManager + evaluateGoal (30s timeout, evaluator-failure neutral) + handleGoalContinuation (evaluate-first, re-entrancy guard, block cap -> stalled, token budget -> budget_limited). Wired at the turn boundary in both cli (runtime-bootstrap + pi-tui onTurnComplete) and desktop (goal-wiring + main.ts continuation), with an active-goal turn-tail fragment in both system-prompt builders. Independent of Automation. Addresses review on apache#625 (jackwener BLOCKERs + Astro-Han follow-ups): - CLI interrupt now halts the loop: submitPromptToTranscript reports turn outcome (aborted/errored); pi-tui-runner gates onTurnComplete on a clean turn, mirroring the desktop turnAborted/turnError guard. An errored turn is non-continuable (no hammering a failing connection). - Visible kill switch: GoalManager onChange -> 'goal-change' session event -> desktop header pill (turn counter, warning-tinted) with one-click clear via goal:get / goal:clear IPC. Rebased onto main after apache#643 (unified Automation). - Evaluator runs on the session's own connection/model (not the global default). canContinue also rejects waiting_for_user. - Evaluator uses maxOutputTokens (AI SDK v6), raised to 1024 so model-side reasoning before the JSON verdict doesn't truncate it to empty output (found via a real-chain e2e). Unused wait_seconds dropped from the contract. - Goals are in-memory / session-lifetime by design (documented); a restart stops the loop rather than silently resuming.
CC-style external evaluator + Codex lifecycle (pause/resume/blocked, token budget, block cap, agent-settable goals). GoalManager + evaluateGoal (30s timeout, evaluator-failure neutral) + handleGoalContinuation (evaluate-first, re-entrancy guard, block cap -> stalled, token budget -> budget_limited). Wired at the turn boundary in both cli (runtime-bootstrap + pi-tui onTurnComplete) and desktop (goal-wiring + main.ts continuation), with an active-goal turn-tail fragment in both system-prompt builders. Independent of Automation. Addresses review on apache#625 (jackwener BLOCKERs + Astro-Han follow-ups): - CLI interrupt now halts the loop: submitPromptToTranscript reports turn outcome (aborted/errored); pi-tui-runner gates onTurnComplete on a clean turn, mirroring the desktop turnAborted/turnError guard. An errored turn is non-continuable (no hammering a failing connection). - Visible kill switch: GoalManager onChange -> 'goal-change' session event -> desktop header pill (turn counter, warning-tinted) with one-click clear via goal:get / goal:clear IPC. Rebased onto main after apache#643 (unified Automation). - Evaluator runs on the session's own connection/model (not the global default). canContinue also rejects waiting_for_user. - Evaluator uses maxOutputTokens (AI SDK v6), raised to 1024 so model-side reasoning before the JSON verdict doesn't truncate it to empty output (found via a real-chain e2e). Unused wait_seconds dropped from the contract. - Goals are in-memory / session-lifetime by design (documented); a restart stops the loop rather than silently resuming.
CC-style external evaluator + Codex lifecycle (pause/resume/blocked, token budget, block cap, agent-settable goals). GoalManager + evaluateGoal (30s timeout, evaluator-failure neutral) + handleGoalContinuation (evaluate-first, re-entrancy guard, block cap -> stalled, token budget -> budget_limited). Wired at the turn boundary in both cli (runtime-bootstrap + pi-tui onTurnComplete) and desktop (goal-wiring + main.ts continuation), with an active-goal turn-tail fragment in both system-prompt builders. Independent of Automation. Addresses review on apache#625 (jackwener BLOCKERs + Astro-Han follow-ups): - CLI interrupt now halts the loop: submitPromptToTranscript reports turn outcome (aborted/errored); pi-tui-runner gates onTurnComplete on a clean turn, mirroring the desktop turnAborted/turnError guard. An errored turn is non-continuable (no hammering a failing connection). - Visible kill switch: GoalManager onChange -> 'goal-change' session event -> desktop header pill (turn counter, warning-tinted) with one-click clear via goal:get / goal:clear IPC. Rebased onto main after apache#643 (unified Automation). - Evaluator runs on the session's own connection/model (not the global default). canContinue also rejects waiting_for_user. - Evaluator uses maxOutputTokens (AI SDK v6), raised to 1024 so model-side reasoning before the JSON verdict doesn't truncate it to empty output (found via a real-chain e2e). Unused wait_seconds dropped from the contract. - Goals are in-memory / session-lifetime by design (documented); a restart stops the loop rather than silently resuming.
CC-style external evaluator + Codex lifecycle (pause/resume/blocked, token budget, block cap, agent-settable goals). GoalManager + evaluateGoal (30s timeout, evaluator-failure neutral) + handleGoalContinuation (evaluate-first, re-entrancy guard, block cap -> stalled, token budget -> budget_limited). Wired at the turn boundary in both cli (runtime-bootstrap + pi-tui onTurnComplete) and desktop (goal-wiring + main.ts continuation), with an active-goal turn-tail fragment in both system-prompt builders. Independent of Automation. Addresses review on apache#625 (jackwener BLOCKERs + Astro-Han follow-ups): - CLI interrupt now halts the loop: submitPromptToTranscript reports turn outcome (aborted/errored); pi-tui-runner gates onTurnComplete on a clean turn, mirroring the desktop turnAborted/turnError guard. An errored turn is non-continuable (no hammering a failing connection). - Visible kill switch: GoalManager onChange -> 'goal-change' session event -> desktop header pill (turn counter, warning-tinted) with one-click clear via goal:get / goal:clear IPC. Rebased onto main after apache#643 (unified Automation). - Evaluator runs on the session's own connection/model (not the global default). canContinue also rejects waiting_for_user. - Evaluator uses maxOutputTokens (AI SDK v6), raised to 1024 so model-side reasoning before the JSON verdict doesn't truncate it to empty output (found via a real-chain e2e). Unused wait_seconds dropped from the contract. - Goals are in-memory / session-lifetime by design (documented); a restart stops the loop rather than silently resuming.
CC-style external evaluator + Codex lifecycle (pause/resume/blocked, token budget, block cap, agent-settable goals). GoalManager + evaluateGoal (30s timeout, evaluator-failure neutral) + handleGoalContinuation (evaluate-first, re-entrancy guard, block cap -> stalled, token budget -> budget_limited). Wired at the turn boundary in both cli (runtime-bootstrap + pi-tui onTurnComplete) and desktop (goal-wiring + main.ts continuation), with an active-goal turn-tail fragment in both system-prompt builders. Independent of Automation. Addresses review on #625 (jackwener BLOCKERs + Astro-Han follow-ups): - CLI interrupt now halts the loop: submitPromptToTranscript reports turn outcome (aborted/errored); pi-tui-runner gates onTurnComplete on a clean turn, mirroring the desktop turnAborted/turnError guard. An errored turn is non-continuable (no hammering a failing connection). - Visible kill switch: GoalManager onChange -> 'goal-change' session event -> desktop header pill (turn counter, warning-tinted) with one-click clear via goal:get / goal:clear IPC. Rebased onto main after #643 (unified Automation). - Evaluator runs on the session's own connection/model (not the global default). canContinue also rejects waiting_for_user. - Evaluator uses maxOutputTokens (AI SDK v6), raised to 1024 so model-side reasoning before the JSON verdict doesn't truncate it to empty output (found via a real-chain e2e). Unused wait_seconds dropped from the contract. - Goals are in-memory / session-lifetime by design (documented); a restart stops the loop rather than silently resuming.
Corrective PR — replaces the session-internal CronJob (wakeup-scheduler) merged in #639 with the unified Automation design that was meant to supersede it.
Context
The earlier CC-style CronJob (#545) and the Codex-style unified Automation were two designs for the same primitive. After discussion we pivoted to the unified design; #545 was left open, which misled the merge — #639 landed the superseded CC-style version. This PR corrects that: it removes wakeup-scheduler and lands the unified Automation instead.
What changes
Removed (the CC-style CronJob from #639):
packages/runtime/src/wakeup-scheduler.ts,wakeup-tools.ts(+ tests)CronCreate/CronDelete/CronListtoolsindex.tsexports, desktopmain.tswiring,ui/tool-activity.tsxCronJob preview cardsAdded (unified Automation):
Automationtool:mode(create/delete/list/pause/resume) +kind(heartbeat = session-internal polling, cron = standalone fresh-session runs)AutomationManager+AutomationScheduler+ durableFileAutomationStoreWhy it's better
Verified
Notes