From 7aac28774124702f31a8d753da97a66d1e688965 Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Sat, 3 Oct 2026 17:30:52 +0530 Subject: [PATCH] fix(automations): an idle interactive run no longer blocks its cron (v0.450.1) An interactive run keeps its TUI after it finishes, and the pile-up guard read "pane alive" as "still running", silently skipping every later occurrence. The next occurrence now closes the previous run when it is detached, unclaimed, idle and not waiting on a person, and skips (audited once per occurrence) only while it is genuinely in use. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 ++++ package-lock.json | 4 +- package.json | 4 +- scripts/cron-supersede-test.cjs | 125 ++++++++++++++++++++++++++++++++ src/edge/automations.ts | 24 +++++- src/terminal.ts | 32 ++++++++ web/src/App.tsx | 2 +- 7 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 scripts/cron-supersede-test.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 17b65485..f1592b64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ new version heading in the same commit. ## [Unreleased] +## [0.450.1] - 2026-10-03 +### Fixed +- **An interactive cron no longer stops firing because its last run is still open.** An interactive run + keeps its TUI after it finishes, and the scheduler's pile-up guard read "pane alive" as "still running", + so every later occurrence was skipped — silently, with no audit — until someone closed the tab (the + console said as much: "this cron won't re-fire while its last run is live"). The next occurrence now + closes the previous run when nobody is using it (`TerminalManager.supersedeIdleRun`: detached, unclaimed, + no turn in flight, nothing waiting on a person) — episode written, audited `automation.superseded` + + `session.reaped` `reason: superseded` — and skips only while it is genuinely in use. A skip is now + audited once per occurrence (`automation.skipped`, with the reason). Pinned by + `scripts/cron-supersede-test.cjs`. + **For users:** Interactive scheduled automations now run on every occurrence — the new run closes the previous one if you've left it idle, and only waits while you're still using it. + ## [0.450.0] - 2026-10-03 ### Added - **Login-expiry warnings for Claude Code credentials.** Every `claude login` has a fixed refresh-token diff --git a/package-lock.json b/package-lock.json index 46a1157f..5bab6d7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.450.0", + "version": "0.450.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.450.0", + "version": "0.450.1", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index c56151b3..ac382d23 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.450.0", + "version": "0.450.1", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", @@ -27,7 +27,7 @@ "check-deps": "bash scripts/install-deps.sh --check", "dev": "ts-node src/cli.ts serve", "demo:dev": "ts-node src/demo.ts", - "test:governance": "node scripts/version-sync-test.cjs && node scripts/event-loop-freeze-test.cjs && node scripts/git-ceiling-test.cjs && node scripts/governance-conformance.cjs && node scripts/tier-a-policy-test.cjs && node scripts/policy-baseline-test.cjs && node scripts/heredoc-intent-test.cjs && node scripts/script-body-gate-test.cjs && node scripts/capability-registry-test.cjs && node scripts/composio-envelope-test.cjs && node scripts/composio-identity-test.cjs && node scripts/idle-reaper-test.cjs && node scripts/dm-continuity-test.cjs && node scripts/telegram-dm-lane-test.cjs && node scripts/cli-link-origin-test.cjs && node scripts/alert-staleness-test.cjs && node scripts/run-as-identity-test.cjs && node scripts/email-identity-guard-test.cjs && node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-usage-refresh-test.cjs && node scripts/keychain-credential-test.cjs && node scripts/credential-preflight-test.cjs && node scripts/capacity-queue-test.cjs && node scripts/runtime-pool-signed-out-test.cjs && node scripts/login-expiry-test.cjs && node scripts/runtime-login-test.cjs && node scripts/rotate-on-reload-test.cjs && node scripts/headless-resumable-test.cjs && node scripts/session-revive-gates-test.cjs && node scripts/session-pause-test.cjs && node scripts/make-live-lock-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs && node scripts/output-style-test.cjs && node scripts/session-cost-test.cjs && node scripts/chain-model-test.cjs && node scripts/task-workers-test.cjs && node scripts/tuning-patch-test.cjs && node scripts/task-runs-test.cjs && node scripts/task-pr-links-test.cjs && node scripts/task-draft-delete-test.cjs && node scripts/task-discussion-delivery-test.cjs && node scripts/task-resume-test.cjs && node scripts/task-unblock-test.cjs && node scripts/audience-session-access-test.cjs && node scripts/warm-chat-test.cjs && node scripts/poke-warm-caller-test.cjs && node scripts/wakeup-queue-test.cjs && node scripts/stranded-human-stop-test.cjs && node scripts/inject-submit-test.cjs && node scripts/blocked-routing-test.cjs && node scripts/self-dispatch-guard-test.cjs && node scripts/task-proposals-test.cjs && node scripts/npm-boundary-test.cjs && node scripts/agent-edit-guard-test.cjs && node scripts/per-agent-context-test.cjs && node scripts/goal-update-guard-test.cjs && node scripts/insights-signal-test.cjs && node scripts/outcome-derivation-test.cjs && node scripts/episode-quality-test.cjs && node scripts/memory-upkeep-test.cjs && node scripts/automem-health-test.cjs && node scripts/memory-store-switch-test.cjs && node scripts/memory-preload-test.cjs && node scripts/turn-lifecycle-test.cjs && node scripts/resume-seed-test.cjs && node scripts/outcome-vocabulary-test.cjs && node scripts/skill-presets-test.cjs && node scripts/skill-edit-proposal-test.cjs && node scripts/notify-hook-route-test.cjs && node scripts/review-notify-test.cjs && node scripts/turn-idle-background-guard-test.cjs && node scripts/waiting-brief-test.cjs && node scripts/runtime-death-alert-test.cjs && node scripts/github-per-member-test.cjs && node scripts/github-multi-org-test.cjs && node scripts/card-measurement-test.cjs && node scripts/scheduler-admission-test.cjs && node scripts/tick-liveness-test.cjs && node scripts/audit-mirror-test.cjs && node scripts/request-metrics-test.cjs && node scripts/tool-usage-test.cjs && node scripts/sessions-list-perf-test.cjs && node scripts/summarizer-degradation-test.cjs && node scripts/agent-history-scope-test.cjs && node scripts/webhook-ingress-test.cjs && node scripts/slack-content-filter-test.cjs && node scripts/slack-ingress-test.cjs && node scripts/discord-ingress-test.cjs && node scripts/chat-attachments-test.cjs && node scripts/clickup-task-bridge-test.cjs && node scripts/agentric-commands-test.cjs && node scripts/whats-new-test.cjs && node scripts/opencode-gate-test.cjs && node scripts/protected-path-guard-test.cjs && node scripts/attach-grace-test.cjs && node scripts/attach-file-liveness-test.cjs && node scripts/feed-smoke.cjs && node scripts/activity-classify-test.cjs && node scripts/goal-room-test.cjs && node scripts/secret-rotation-test.cjs && node scripts/update-watch-test.cjs && node scripts/runtime-update-watch-test.cjs && node scripts/setup-wizard-test.cjs && node scripts/md-pdf-test.cjs && node scripts/proposal-surfacing-test.cjs && node scripts/process-janitor-test.cjs && node scripts/detached-work-steer-test.cjs && node scripts/statusline-install-test.cjs && node scripts/docs-create-agent-test.cjs && node scripts/agent-stats-rollup-test.cjs && node scripts/task-discussion-rollup-test.cjs && node scripts/session-insights-stamp-test.cjs && node scripts/loop-stall-attribution-test.cjs && node scripts/session-progress-test.cjs && node scripts/drift-nudge-test.cjs && node scripts/goal-metric-review-test.cjs && node scripts/capability-gap-test.cjs && node scripts/workflow-proposal-test.cjs && node scripts/automation-edit-proposal-test.cjs && node scripts/app-card-close-test.cjs", + "test:governance": "node scripts/version-sync-test.cjs && node scripts/event-loop-freeze-test.cjs && node scripts/git-ceiling-test.cjs && node scripts/cron-supersede-test.cjs && node scripts/governance-conformance.cjs && node scripts/tier-a-policy-test.cjs && node scripts/policy-baseline-test.cjs && node scripts/heredoc-intent-test.cjs && node scripts/script-body-gate-test.cjs && node scripts/capability-registry-test.cjs && node scripts/composio-envelope-test.cjs && node scripts/composio-identity-test.cjs && node scripts/idle-reaper-test.cjs && node scripts/dm-continuity-test.cjs && node scripts/telegram-dm-lane-test.cjs && node scripts/cli-link-origin-test.cjs && node scripts/alert-staleness-test.cjs && node scripts/run-as-identity-test.cjs && node scripts/email-identity-guard-test.cjs && node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-usage-refresh-test.cjs && node scripts/keychain-credential-test.cjs && node scripts/credential-preflight-test.cjs && node scripts/capacity-queue-test.cjs && node scripts/runtime-pool-signed-out-test.cjs && node scripts/login-expiry-test.cjs && node scripts/runtime-login-test.cjs && node scripts/rotate-on-reload-test.cjs && node scripts/headless-resumable-test.cjs && node scripts/session-revive-gates-test.cjs && node scripts/session-pause-test.cjs && node scripts/make-live-lock-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs && node scripts/output-style-test.cjs && node scripts/session-cost-test.cjs && node scripts/chain-model-test.cjs && node scripts/task-workers-test.cjs && node scripts/tuning-patch-test.cjs && node scripts/task-runs-test.cjs && node scripts/task-pr-links-test.cjs && node scripts/task-draft-delete-test.cjs && node scripts/task-discussion-delivery-test.cjs && node scripts/task-resume-test.cjs && node scripts/task-unblock-test.cjs && node scripts/audience-session-access-test.cjs && node scripts/warm-chat-test.cjs && node scripts/poke-warm-caller-test.cjs && node scripts/wakeup-queue-test.cjs && node scripts/stranded-human-stop-test.cjs && node scripts/inject-submit-test.cjs && node scripts/blocked-routing-test.cjs && node scripts/self-dispatch-guard-test.cjs && node scripts/task-proposals-test.cjs && node scripts/npm-boundary-test.cjs && node scripts/agent-edit-guard-test.cjs && node scripts/per-agent-context-test.cjs && node scripts/goal-update-guard-test.cjs && node scripts/insights-signal-test.cjs && node scripts/outcome-derivation-test.cjs && node scripts/episode-quality-test.cjs && node scripts/memory-upkeep-test.cjs && node scripts/automem-health-test.cjs && node scripts/memory-store-switch-test.cjs && node scripts/memory-preload-test.cjs && node scripts/turn-lifecycle-test.cjs && node scripts/resume-seed-test.cjs && node scripts/outcome-vocabulary-test.cjs && node scripts/skill-presets-test.cjs && node scripts/skill-edit-proposal-test.cjs && node scripts/notify-hook-route-test.cjs && node scripts/review-notify-test.cjs && node scripts/turn-idle-background-guard-test.cjs && node scripts/waiting-brief-test.cjs && node scripts/runtime-death-alert-test.cjs && node scripts/github-per-member-test.cjs && node scripts/github-multi-org-test.cjs && node scripts/card-measurement-test.cjs && node scripts/scheduler-admission-test.cjs && node scripts/tick-liveness-test.cjs && node scripts/audit-mirror-test.cjs && node scripts/request-metrics-test.cjs && node scripts/tool-usage-test.cjs && node scripts/sessions-list-perf-test.cjs && node scripts/summarizer-degradation-test.cjs && node scripts/agent-history-scope-test.cjs && node scripts/webhook-ingress-test.cjs && node scripts/slack-content-filter-test.cjs && node scripts/slack-ingress-test.cjs && node scripts/discord-ingress-test.cjs && node scripts/chat-attachments-test.cjs && node scripts/clickup-task-bridge-test.cjs && node scripts/agentric-commands-test.cjs && node scripts/whats-new-test.cjs && node scripts/opencode-gate-test.cjs && node scripts/protected-path-guard-test.cjs && node scripts/attach-grace-test.cjs && node scripts/attach-file-liveness-test.cjs && node scripts/feed-smoke.cjs && node scripts/activity-classify-test.cjs && node scripts/goal-room-test.cjs && node scripts/secret-rotation-test.cjs && node scripts/update-watch-test.cjs && node scripts/runtime-update-watch-test.cjs && node scripts/setup-wizard-test.cjs && node scripts/md-pdf-test.cjs && node scripts/proposal-surfacing-test.cjs && node scripts/process-janitor-test.cjs && node scripts/detached-work-steer-test.cjs && node scripts/statusline-install-test.cjs && node scripts/docs-create-agent-test.cjs && node scripts/agent-stats-rollup-test.cjs && node scripts/task-discussion-rollup-test.cjs && node scripts/session-insights-stamp-test.cjs && node scripts/loop-stall-attribution-test.cjs && node scripts/session-progress-test.cjs && node scripts/drift-nudge-test.cjs && node scripts/goal-metric-review-test.cjs && node scripts/capability-gap-test.cjs && node scripts/workflow-proposal-test.cjs && node scripts/automation-edit-proposal-test.cjs && node scripts/app-card-close-test.cjs", "test:alert-staleness": "node scripts/alert-staleness-test.cjs", "test:deps": "node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-login-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs", "test:dm-continuity": "node scripts/dm-continuity-test.cjs", diff --git a/scripts/cron-supersede-test.cjs b/scripts/cron-supersede-test.cjs new file mode 100644 index 00000000..98870e95 --- /dev/null +++ b/scripts/cron-supersede-test.cjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +/* Cron supersede test — an INTERACTIVE cron automation keeps its pane after the run finishes, and the + * scheduler's pile-up guard used to read that idle pane as "still running" and skip every later occurrence + * until someone closed the tab. Now the next occurrence closes the previous run when nobody is using it, + * and skips (audited once per occurrence) only when it really is in use: attached, claimed, mid-turn, + * blocked on a person, an unattended run, or attachment can't be polled. Isolated home; the session backend + * and createSession are stubbed so no tmux/claude runs. */ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'aos-supersede-test-')); +process.env.AGENT_OS_HOME = HOME; +process.env.AGENT_OS_TENANT = 'testco'; +process.env.AOS_NO_TTYD = '1'; +delete process.env.AGENT_OS_SECRET_KEY; +delete process.env.AOS_MAX_CONCURRENT_SESSIONS; + +let pass = 0, fail = 0; +const assert = (c, name, d) => c ? (pass++, console.log(` \x1b[32m✓\x1b[0m ${name}`)) : (fail++, console.log(` \x1b[31m✗ ${name}\x1b[0m${d ? ' — ' + d : ''}`)); + +const { loadAgentOS } = require(path.join(ROOT, 'dist/kernel.js')); +const { TerminalManager } = require(path.join(ROOT, 'dist/terminal.js')); +const { Automations } = require(path.join(ROOT, 'dist/edge/automations.js')); + +const aos = loadAgentOS(); +const tm = new TerminalManager(aos, 'http://127.0.0.1:0', path.join(HOME, 'tmux.sock')); +const autos = new Automations(aos, tm); + +// ── stubs: a pane set stands in for tmux; createSession inserts a row instead of spawning ── +const panes = new Set(); +const attached = new Map(); // tmux → true | false | null (null = can't poll) +const killed = []; +tm.backend.aliveNames = () => new Set(panes); +tm.backend.hasClient = (_s, t) => (attached.has(t) ? attached.get(t) : false); +tm.backend.kill = (_s, t) => { killed.push(t); panes.delete(t); }; +let seq = 0; +tm.createSession = (agent, title, task, spawnedBy, headless) => { + const id = `s${++seq}`, tmux = `aos-${id}`, now = Date.now(); + aos.db.prepare("INSERT INTO term_sessions (id,agent,title,task,tmux,status,headless,resident,spawned_by,created_at,updated_at) VALUES (?,?,?,?,?,'running',?,0,?,?,?)") + .run(id, agent, title, task, tmux, headless ? 1 : 0, spawnedBy, now, now); + panes.add(tmux); + return { id, tmux }; +}; + +const AID = 'au_weekly'; +aos.db.prepare("INSERT INTO automations (id, agent_id, name, type, mode, schedule, task, enabled, created_at) VALUES (?,?,?,?,?,?,?,?,?)") + .run(AID, 'seo', 'Weekly SEO loop', 'cron', 'interactive', '30 3 * * 1', 'Run the loop.', 1, Date.now() - 30 * 86400000); +const auto = () => autos.list().find((a) => a.id === AID); +const row = (id) => aos.db.prepare('SELECT * FROM term_sessions WHERE id = ?').get(id); +const audits = (type) => aos.db.prepare('SELECT data FROM audit_events WHERE type = ?').all(type).map((r) => JSON.parse(r.data)); + +// A finished-but-open previous run: turn started, then a turn-end landed after it. +const prevRun = (patch = {}) => { + const first = autos.fire(auto(), { guard: false }); + const t = Date.now() - 3600_000; + aos.db.prepare('UPDATE term_sessions SET busy_since = ?, last_activity = ? WHERE id = ?').run(t, t + 60_000, first.sessionId); + for (const [k, v] of Object.entries(patch)) aos.db.prepare(`UPDATE term_sessions SET ${k} = ? WHERE id = ?`).run(v, first.sessionId); + return first.sessionId; +}; + +console.log('\n\x1b[1m1) idle, detached previous run → superseded, the cron fires\x1b[0m'); +{ + const prev = prevRun(); + const r = autos.fire(auto(), { guard: true }); + assert(r.ok === true, 'guarded fire succeeds', JSON.stringify(r)); + assert(killed.includes(`aos-${prev}`), 'previous pane killed'); + assert(row(prev).status === 'done', `previous row ended (status ${row(prev).status})`); + assert(auto().lastSessionId === r.sessionId, 'automation now points at the new run'); + assert(audits('automation.superseded').some((d) => d.previous === prev), 'automation.superseded audited'); + assert(aos.db.prepare("SELECT 1 FROM audit_events WHERE type='session.reaped' AND run_id=? AND data LIKE '%superseded%'").get(prev), 'session.reaped reason=superseded'); +} + +const skipCase = (name, setup, want) => { + const prev = prevRun(setup.patch); + setup.after && setup.after(prev); + const before = killed.length; + const r = autos.fire(auto(), { guard: true }); + assert(r.ok === false && r.reason.includes(`(${want})`), `${name} → skipped (${want})`, JSON.stringify(r)); + assert(killed.length === before && panes.has(`aos-${prev}`), `${name} → previous pane untouched`); + panes.delete(`aos-${prev}`); // clear the stage for the next case +}; + +console.log('\n\x1b[1m2) in use → keeps the skip\x1b[0m'); +skipCase('attached', { after: (id) => attached.set(`aos-${id}`, true) }, 'attached'); +skipCase('attachment unknown (null)', { after: (id) => attached.set(`aos-${id}`, null) }, 'attached'); +skipCase('claimed', { patch: { claimed_by: 'm1' } }, 'claimed'); +skipCase('mid-turn', { patch: { busy_since: Date.now() - 60_000, last_activity: Date.now() - 120_000 } }, 'working'); +skipCase('blocked on a question', { + after: (id) => aos.db.prepare("INSERT INTO questions (id,run_id,tenant,agent,prompt,status,created_at) VALUES (?,?,?,'seo','RFQ?','pending',?)").run(`q_${id}`, id, 'testco', Date.now()), +}, 'blocked'); +skipCase('unattended (headless) run still live', { patch: { headless: 1 } }, 'unattended'); + +console.log('\n\x1b[1m3) previous pane already gone → fires without a supersede\x1b[0m'); +{ + const prev = prevRun(); + panes.delete(`aos-${prev}`); + const n = audits('automation.superseded').length; + const r = autos.fire(auto(), { guard: true }); + assert(r.ok === true, 'fires'); + assert(audits('automation.superseded').length === n, 'no supersede audited'); +} + +console.log('\n\x1b[1m4) tick() audits a skip once per occurrence, not once per tick\x1b[0m'); +{ + const prev = prevRun(); + attached.set(`aos-${prev}`, true); + const monday = new Date(2026, 9, 5, 3, 30, 0, 0).getTime(); // a Monday 03:30 local + aos.db.prepare('UPDATE automations SET last_fired_at = ? WHERE id = ?').run(monday - 7 * 86400000, AID); + const n = audits('automation.skipped').length; + autos.tick(new Date(monday)); + autos.tick(new Date(monday + 60_000)); + autos.tick(new Date(monday + 120_000)); + const skips = audits('automation.skipped').slice(n); + assert(skips.length === 1, `one automation.skipped for three ticks (got ${skips.length})`); + assert(skips[0] && skips[0].reason.includes('attached'), 'skip reason names why'); + attached.set(`aos-${prev}`, false); + autos.tick(new Date(monday + 180_000)); + assert(auto().lastSessionId !== prev, 'once detached, the next tick inside the catch-up window supersedes and fires'); +} + +console.log(`\n${fail === 0 ? '\x1b[32m' : '\x1b[31m'}CRON SUPERSEDE: ${pass}/${pass + fail} passed\x1b[0m`); +try { fs.rmSync(HOME, { recursive: true, force: true }); } catch {} +process.exit(fail === 0 ? 0 : 1); diff --git a/src/edge/automations.ts b/src/edge/automations.ts index e4521dc2..b2fa6760 100644 --- a/src/edge/automations.ts +++ b/src/edge/automations.ts @@ -160,7 +160,8 @@ export function recentCronOccurrence(spec: CronSpec, from: Date, windowMin: numb * to idle, the pile-up guard releases). The unattended-correct default: no TUI, so the * upstream interactive-scroll issues don't apply and cron re-fires cleanly. * - `interactive` — a normal attachable claude TUI that stays open until closed. Good for - * automations you want to babysit, but a cron trigger won't re-fire while it's still running. + * automations you want to babysit. The next cron occurrence closes the previous run if it's idle + * (`TerminalManager.supersedeIdleRun`) and skips only while it's attached/claimed/mid-turn/blocked. */ export type ExecMode = 'interactive' | 'headless'; @@ -522,6 +523,8 @@ export function derivedConcurrencyCap(totalBytes = os.totalmem()): number { export class Automations { private readonly db: Db; private timer?: NodeJS.Timeout; + /** automation id → the cron occurrence (ms) whose pile-up skip was already audited — one event per occurrence, not per tick. */ + private readonly skipNoted = new Map(); constructor( private readonly os: AgentOS, @@ -768,7 +771,15 @@ export class Automations { */ fire(a: Automation, opts: { guard: boolean; extra?: string; runAs?: string; mode?: ExecMode; slack?: { channel: string; threadTs: string }; discord?: { channel: string; messageId: string }; telegram?: { chat: string; messageThreadId?: string; messageId: string }; clickup?: { taskId: string; commentId: string }; resumeClaudeId?: string } = { guard: true }): FireResult { if (opts.guard && a.lastSessionId && this.tm.reachable(a.lastSessionId)) { - return { ok: false, reason: 'previous session still running' }; + // An INTERACTIVE run keeps its pane after it finishes, so "pane alive" alone used to skip every + // later occurrence until someone closed the tab. Close the previous run when nobody is using it; + // skip only when it is genuinely in use (attached, claimed, mid-turn, or waiting on a person). + const prev = this.tm.supersedeIdleRun(a.lastSessionId); + if (prev !== 'superseded' && prev !== 'gone') return { ok: false, reason: `previous session still running (${prev})` }; + this.os.audit.append({ + ts: Date.now(), runId: a.lastSessionId, tenant: this.os.tenant, principal: `automation:${a.id}`, + type: 'automation.superseded', data: { automation: a.id, name: a.name, agent: a.agentId, previous: a.lastSessionId }, + }); } // Don't spawn a scheduled/triggered run into an exhausted quota — it would just hit the usage limit and // zombie. Defer (retry a later tick, firing once an account's limit resets). A human "Run now" @@ -2313,6 +2324,15 @@ export class Automations { // identity — its personal Composio/connectors are injected instead of the company-only fallback. const r = this.fire(a, { guard: true, runAs: a.runAs }); if (r.ok) running++; + // A skipped occurrence is retried every tick through the catch-up window and then dropped — which + // used to leave no trace at all. Record it once per occurrence so "why didn't my cron run" has an answer. + else if (r.reason?.startsWith('previous session still running') && this.skipNoted.get(a.id) !== due) { + this.skipNoted.set(a.id, due); + this.os.audit.append({ + ts: Date.now(), runId: a.lastSessionId ?? '-', tenant: this.os.tenant, principal: 'scheduler', + type: 'automation.skipped', data: { automation: a.id, name: a.name, agent: a.agentId, due, reason: r.reason }, + }); + } } // Settled blocks first: a task whose blockers all finished is dispatchable work, and returning it to // `todo` before the drain means it goes out in THIS tick rather than waiting a whole minute more. diff --git a/src/terminal.ts b/src/terminal.ts index 04d684ce..25862e48 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -2389,6 +2389,38 @@ export class TerminalManager { return this.db.prepare("SELECT 1 FROM term_sessions WHERE id = ? AND status = 'queued'").get(sessionId) != null; } + /** + * The next cron occurrence of an INTERACTIVE automation is due while its previous run is still live — + * close that run if nobody is using it, so the cron fires instead of skipping. + * + * An interactive run keeps its TUI after it finishes, by design, and the scheduler's pile-up guard reads + * "pane alive" as "still running". So the previous occurrence's idle pane silently swallowed the next + * one (the console even warned "this cron won't re-fire while its last run is live"). A pane nobody is + * attached to, with no turn in flight, no question/approval waiting on a person and no claim is a + * finished run that happens to still be open — the cron's own cadence is the instruction to replace it. + * Anything else is real use and keeps the skip: someone attached or claimed it, it is mid-turn, or it is + * waiting on an answer (the idle janitor's blocked ceiling decides when that wait is abandoned, not this). + * + * Returns why it did NOT supersede, or 'superseded' after tearing the run down (episode written, any + * dangling card cancelled — the same teardown every unattended reap uses). + */ + supersedeIdleRun(sessionId: string): 'superseded' | 'gone' | 'unattended' | 'claimed' | 'working' | 'blocked' | 'attached' { + if (this.launching.has(sessionId)) return 'working'; + const r = this.db.prepare('SELECT id, tmux, status, headless, claimed_by, run_as, spawned_by, busy_since, last_activity, created_at FROM term_sessions WHERE id = ?') + .get<{ id: string; tmux: string; status: string; headless: number | null; claimed_by: string | null; run_as: string | null; spawned_by: string | null; busy_since: number | null; last_activity: number | null; created_at: number }>(sessionId); + if (!r) return 'gone'; + if (r.headless) return 'unattended'; // an unattended run ends itself at turn-end; a live one is working + if (r.claimed_by) return 'claimed'; + const alive = this.backend.aliveNames(); + if (this.isWorking(r, alive)) return 'working'; + if (this.hasPendingHumanBlock(sessionId)) return 'blocked'; + const space = this.spaceFor(r.run_as ?? r.spawned_by); + // `null` = can't tell: treat as attached rather than cut someone off mid-read. + if (this.backend.hasClient(space, r.tmux) !== false) return 'attached'; + this.teardownUnattended(sessionId, space, r.tmux, 'superseded'); + return 'superseded'; + } + reachable(sessionId: string): boolean { if (this.launching.has(sessionId)) return true; // scheduled; its pane is imminent const r = this.db.prepare('SELECT tmux, status FROM term_sessions WHERE id = ?').get<{ tmux: string; status: string }>(sessionId); diff --git a/web/src/App.tsx b/web/src/App.tsx index de1a09f6..050682bc 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13645,7 +13645,7 @@ function AutomationsPage({ me, agents, sessions, serverTz, onOpen, nav, agentFil {a.type === 'cron' && a.mode === 'interactive' && ( -
Interactive runs stay open — this cron won't re-fire while its last run is live.
+
Interactive runs stay open after they finish. Each fire closes the last run if it's idle, but skips while someone is attached, it's mid-turn, or it's waiting on an answer.
)}
{a.task}
{a.hookUrl &&
}