Skip to content

feat(runtime): replace session-internal CronJob with unified Automation (corrects #639) - #643

Closed
hqhq1025 wants to merge 27 commits into
apache:mainfrom
hqhq1025:feat/unified-automation-v2
Closed

feat(runtime): replace session-internal CronJob with unified Automation (corrects #639)#643
hqhq1025 wants to merge 27 commits into
apache:mainfrom
hqhq1025:feat/unified-automation-v2

Conversation

@hqhq1025

@hqhq1025 hqhq1025 commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

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 / CronList tools
  • index.ts exports, desktop main.ts wiring, ui/tool-activity.tsx CronJob preview cards

Added (unified Automation):

  • Single Automation tool: mode (create/delete/list/pause/resume) + kind (heartbeat = session-internal polling, cron = standalone fresh-session runs)
  • AutomationManager + AutomationScheduler + durable FileAutomationStore

Why it's better

  • One tool, two kinds — heartbeat (poll in the current conversation) and cron (independent scheduled runs), vs cron-only before.
  • Hardened by 7 rounds of adversarial review the wakeup design never had:
    • cron firing decoupled from its creator session (archiving/deleting the conversation no longer kills a durable cron)
    • cross-session query + management (a persisted cron is listable/manageable from any session after restart)
    • durable persistence gated on cron capability + fail-loud store reads → no shared-store clobber / silent data loss
    • incognito privacy gating (mirrors plan-reminders)
    • O(1) cron validation: named tokens (MON-SUN / JAN-DEC), impossible-date fast-fail — no main-thread stall
    • DST fall-back re-fire-storm fix (epoch-arithmetic scan start)

Verified

  • Unit + integration: runtime automation 106/0, storage 10/0, desktop automation e2e 9/0 (incl. a cron-disabled-host-does-not-clobber-the-shared-store e2e).
  • Real Maka chain (live LLM): natural phrasing "每天凌晨3点自动备份" → durable cron created + persisted → simulated restart → a brand-new session lists it. PASS.

Notes

hqhq1025 added 27 commits July 6, 2026 19:34
… 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.
…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.
jackwener added a commit that referenced this pull request Jul 8, 2026
…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.
@jackwener

Copy link
Copy Markdown
Member

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:

  1. Busy-session defer window: fixed 5s×24 (~120s) then drop → restored to a ~45min window; a transient busy turn no longer terminally expires a once automation. Agent turns routinely run for minutes — 120s guarantees dropped heartbeats.
  2. waiting_for_user is back in the heartbeat idle set — firing while the agent waits on the user is the wakeup's home scenario, not a blocked state.
  3. Jitter ported from the old scheduler (recurring ±10% cap 15min; one-shot up-to-90s-early on :00/:30 marks, computed on the actual fire timestamp) — durable cross-restart crons make wall-clock alignment more likely, not less.
  4. AutomationResultPreview cards replace the deleted CronJob preview (results were falling to raw JSON).

Also found during verification: the always-on 5s tick kept the Node event loop alive and hung the CLI test suite — the timer is unref()ed now. Plus terminal-history cap 5→50 and deferred-fire counts in list for observability.

Closing this PR in favor of #656 (your commit authorship is credited in the title/message). The CronCreate/CronDelete/CronListAutomation rename is flagged as breaking for prompt/skill authors in the release notes.

@jackwener jackwener closed this Jul 8, 2026
hqhq1025 added a commit to hqhq1025/maka-agent that referenced this pull request Jul 9, 2026
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.
hqhq1025 added a commit to hqhq1025/maka-agent that referenced this pull request Jul 9, 2026
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.
hqhq1025 added a commit to hqhq1025/maka-agent that referenced this pull request Jul 9, 2026
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.
hqhq1025 added a commit to hqhq1025/maka-agent that referenced this pull request Jul 9, 2026
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.
hqhq1025 added a commit to hqhq1025/maka-agent that referenced this pull request Jul 9, 2026
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.
hqhq1025 added a commit to hqhq1025/maka-agent that referenced this pull request Jul 11, 2026
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.
jackwener pushed a commit that referenced this pull request Jul 11, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants