From 7d9a1db630ec682b9dfd997ab7ba14ec7504b586 Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Thu, 8 Oct 2026 12:25:01 +0530 Subject: [PATCH] fix(sessions): a resume restarts the interactive lifetime clock (v0.456.2) The 168h lifetime ceiling measured age from created_at, so any session older than a week was reaped on the next sweep after every resume. Stamp term_sessions.opened_at on a deliberate re-open and measure from it; the lifetime reap audit now records lifetimeHours instead of idleHours. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++++ package-lock.json | 4 ++-- package.json | 2 +- scripts/idle-reaper-test.cjs | 16 ++++++++++++++++ src/governance/settings.ts | 3 ++- src/state/db.ts | 7 +++++++ src/terminal.ts | 32 +++++++++++++++++++++++--------- web/src/App.tsx | 3 ++- 8 files changed, 58 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3d43535..55f9b606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ new version heading in the same commit. ## [Unreleased] +## [0.456.2] - 2026-10-08 +### Fixed +- **A resumed session was cut again within minutes, mid-work.** The interactive LIFETIME ceiling (`interactiveMaxHours`, default 168 h) measured age from `created_at`, and nothing a human did reset it — so any session older than a week was "over lifetime" forever, and every resume was reaped on the next sweep the moment no browser terminal happened to be attached. Live instawp: a `qa` session created 13 days earlier was resumed by its owner five times in two days and reaped 1–30 minutes after each one, once with its agent mid-turn. A deliberate re-open (attach-resume `markResumed`, unpause, a crashed pane restored under an attached human) now stamps a new `term_sessions.opened_at`, and the ceiling measures from `COALESCE(opened_at, created_at)`. Sessions that were never re-opened — the 42-day zombies the ceiling exists for — age out exactly as before; restore-on-agent-activity deliberately does not restart the clock. The `session.reaped` audit on a lifetime reap also named the wrong knob (`idleHours: 72`, the idle setting); it now records `lifetimeHours` + `openedAt`. Pinned by `scripts/idle-reaper-test.cjs`. + **For users:** Resuming an old session no longer gets it closed again a few minutes later — the 7-day age limit now counts from when you last re-opened it. + ## [0.456.1] - 2026-10-06 ### Fixed - **"Ready to close" asked the task list instead of the number.** A goal was proposed for closing — and its owner carded that it was finished — as soon as every task filed under it was done, with no reference to its metric. On the instapods clicks goal that put a green "Ready to close" pill directly beside a metric panel reading "59 of 100 · Not moving": the same page contradicting itself, and an Inbox card saying the goal was finished when it was 41 clicks short. Task completion is the weakest evidence a measured goal has, so it no longer gets a vote: `GoalStore.readyToClose` (and therefore the scheduler's completion sweep) now excludes a goal with a metric unless its verdict is `achieved`, and the console derives the same way. A goal with NO metric is unchanged. The quieter fact is still said — a measured goal whose filed work ran out gets a muted "all filed tasks are done, but the number is at X of Y — close it only if the outcome is good enough, or plan the gap", because nobody having planned the rest is worth knowing; it just is not a close. Pinned by `scripts/goal-metric-review-test.cjs`. diff --git a/package-lock.json b/package-lock.json index c1a2b45e..68082194 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.456.1", + "version": "0.456.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.456.1", + "version": "0.456.2", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 0691000c..c43435d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.456.1", + "version": "0.456.2", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/scripts/idle-reaper-test.cjs b/scripts/idle-reaper-test.cjs index 2b9b18a4..32776c2e 100644 --- a/scripts/idle-reaper-test.cjs +++ b/scripts/idle-reaper-test.cjs @@ -226,6 +226,22 @@ console.log('\n\x1b[1m7b) the LIFETIME ceiling — the backstop the idle clocks tm.reapIdleSessions(); assert(statusOf(ancientAttached) === 'running', 'ATTACHED → never cut, whatever the age'); + // A human RE-OPEN restarts the age clock. Live instawp: a `qa` session created 13 days earlier was + // resumed by its owner five times in two days and cut by this ceiling 1–30 min after every resume — + // mid-turn — because the age was measured from creation and a resume never touched it. + const ev2 = aos.db.prepare("SELECT data FROM audit_events WHERE type='session.reaped' AND run_id=? ORDER BY ts DESC LIMIT 1").get(ancientBusy); + const d2 = ev2 && JSON.parse(ev2.data); + assert(d2 && d2.lifetimeHours === 168 && !('idleHours' in d2), 'a lifetime reap names lifetimeHours, not the idle setting', ev2 && ev2.data); + const reopened = mkSession({ created_at: Date.now() - 312 * H, last_activity: Date.now() - 1 * H, status: 'stopped' }); + tm.markResumed(reopened); // the attach-wrapper resume path + assert(aos.db.prepare('SELECT opened_at o FROM term_sessions WHERE id=?').get(reopened).o > Date.now() - 60_000, 'markResumed stamps opened_at'); + tm.reapIdleSessions(); + assert(statusOf(reopened) === 'running', '312h old but re-opened just now → NOT cut on age (the live case)'); + const reopenedLongAgo = mkSession({ created_at: Date.now() - 600 * H, last_activity: Date.now() - 1 * H }); + aos.db.prepare('UPDATE term_sessions SET opened_at = ? WHERE id = ?').run(Date.now() - 200 * H, reopenedLongAgo); + tm.reapIdleSessions(); + assert(statusOf(reopenedLongAgo) === 'stopped', 're-opened 200h ago → the ceiling still applies from the re-open'); + aos.settings.setInteractiveMaxHours(0); // hand the baton back — later sections own other clocks } diff --git a/src/governance/settings.ts b/src/governance/settings.ts index 824d49c9..8492ea2a 100644 --- a/src/governance/settings.ts +++ b/src/governance/settings.ts @@ -1269,7 +1269,8 @@ export class SettingsStore { } /** - * Hard AGE ceiling for a detached interactive session, measured from `created_at` — the backstop the + * Hard AGE ceiling for a detached interactive session, measured from its last human re-open (`opened_at`, + * else `created_at`) — the backstop the * idle clock cannot be. * * Every other ceiling in this family measures IDLENESS, and idleness is stamped by `markTurnBusy` on diff --git a/src/state/db.ts b/src/state/db.ts index 8f6c5bd4..e642dd5b 100644 --- a/src/state/db.ts +++ b/src/state/db.ts @@ -1428,6 +1428,13 @@ function migrate(db: Db): void { // flag every "is this paused" check reads through the status, not through these columns. addColumn(db, 'term_sessions', 'paused_at', 'INTEGER'); addColumn(db, 'term_sessions', 'paused_by', 'TEXT'); + + // OPENED_AT: when a human last deliberately brought this session back (attach-resume, unpause, a crashed + // pane restored by someone attached). The interactive LIFETIME ceiling measures age from here, falling + // back to `created_at` — measured from `created_at` alone, a session older than the ceiling was reaped + // on the next sweep after EVERY resume, so a long-lived conversation a human kept coming back to could + // never be used for more than ~30 minutes at a time. + addColumn(db, 'term_sessions', 'opened_at', 'INTEGER'); } /** Add a column only if it isn't already present (SQLite has no ADD COLUMN IF NOT EXISTS). */ diff --git a/src/terminal.ts b/src/terminal.ts index 9cb355b4..fcdacebe 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -3915,8 +3915,15 @@ export class TerminalManager { // ORed in rather than folded into that max: it reads `created_at`, and the rows it exists for are // precisely the ones a `last_activity` filter excludes. const scanCutoff = claimedCutoff == null ? idleCutoff : Math.max(idleCutoff, claimedCutoff); - const stale = this.db.prepare("SELECT id, tmux, run_as, spawned_by, agent, status, claimed_by, last_activity, created_at FROM term_sessions WHERE headless = 0 AND resident = 0 AND status IN ('running','done','crashed') AND (COALESCE(last_activity, created_at) < ? OR (? IS NOT NULL AND created_at < ?))") - .all<{ id: string; tmux: string; run_as: string | null; spawned_by: string | null; agent: string; status: string; claimed_by: string | null; last_activity: number | null; created_at: number }>(scanCutoff, lifetimeCutoff, lifetimeCutoff); + // + // The lifetime clock starts at the last deliberate human re-open (`opened_at`), not at `created_at`. + // Live instawp: a `qa` session created 13 days earlier was resumed by its owner five times in two + // days and reaped by this ceiling within 1–30 minutes of every resume — mid-turn, with the human + // still working in it — because a resume did nothing to an age measured from creation. A re-open is + // a human saying "this is live work again"; the 42-day zombies this ceiling exists for were never + // re-opened, so they still age out exactly as before. + const stale = this.db.prepare("SELECT id, tmux, run_as, spawned_by, agent, status, claimed_by, last_activity, created_at, opened_at FROM term_sessions WHERE headless = 0 AND resident = 0 AND status IN ('running','done','crashed') AND (COALESCE(last_activity, created_at) < ? OR (? IS NOT NULL AND COALESCE(opened_at, created_at) < ?))") + .all<{ id: string; tmux: string; run_as: string | null; spawned_by: string | null; agent: string; status: string; claimed_by: string | null; last_activity: number | null; created_at: number; opened_at: number | null }>(scanCutoff, lifetimeCutoff, lifetimeCutoff); for (const r of stale) { try { const idleSince = r.last_activity ?? r.created_at; @@ -3924,7 +3931,7 @@ export class TerminalManager { // Claimed: exempt unless the claim itself has gone stale. Unclaimed: the ordinary idle clock — // re-checked here because the scan may have been widened past it for the claimed rows. // Past the lifetime ceiling every idle-based exemption is moot — that is the point of it. - const overLifetime = lifetimeCutoff != null && r.created_at < lifetimeCutoff; + const overLifetime = lifetimeCutoff != null && (r.opened_at ?? r.created_at) < lifetimeCutoff; if (r.claimed_by) { if (!overLifetime && (claimedCutoff == null || idleSince >= claimedCutoff)) continue; } else if (!overLifetime && idleSince >= idleCutoff) { @@ -3972,7 +3979,11 @@ export class TerminalManager { // person who took it over, not read as the janitor closing an ownerless pane. : reason === 'claimed-abandoned' ? { reason, claimedHours, claimedBy: r.claimed_by, idleForMs: Date.now() - idleSince, status: r.status } - : { reason, idleHours, status: r.status }); + : reason === 'max-lifetime' + // Name the clock that actually fired: this used to print `idleHours` (the 72 h idle + // setting) on a lifetime reap, which pointed whoever read it at the wrong knob. + ? { reason, lifetimeHours, openedAt: r.opened_at ?? r.created_at, status: r.status } + : { reason, idleHours, status: r.status }); } catch { /* one bad row must not stop the sweep */ } } } @@ -4209,8 +4220,10 @@ export class TerminalManager { const attached = this.backend.hasClient(this.spaceFor(r.run_as ?? r.spawned_by), r.tmux) === true; const workedSince = r.last_activity != null && r.last_activity > r.updated_at; if (!attached && !workedSince) continue; - const restored = this.db.prepare("UPDATE term_sessions SET status = 'running', updated_at = ? WHERE id = ? AND status = 'crashed'") - .run(Date.now(), r.id); + // Restored under an ATTACHED human = a deliberate re-open, so it restarts the lifetime clock too; + // restored on agent activity alone does not (that is the never-idle case the ceiling exists for). + const restored = this.db.prepare("UPDATE term_sessions SET status = 'running', opened_at = CASE WHEN ? THEN ? ELSE opened_at END, updated_at = ? WHERE id = ? AND status = 'crashed'") + .run(attached ? 1 : 0, Date.now(), Date.now(), r.id); if (!restored.changes) continue; // raced with another writer — leave it be // The "Crashed — " card is now a lie about a live session; close it rather than leave the // owner's Inbox asserting a death that didn't stick. @@ -9259,7 +9272,8 @@ export class TerminalManager { // keeps its STALE last_activity (from the original run), so the very next idle sweep would reap it as // long-idle within ≤60s ("killed shortly after resumption"). A deliberate re-open means the human is // actively using it — give it a fresh idle window (the resident reaper keys off last_activity). - this.db.prepare("UPDATE term_sessions SET status = 'running', last_activity = ?, updated_at = ? WHERE id = ?").run(Date.now(), Date.now(), sessionId); + // `opened_at` restarts the interactive lifetime ceiling — a re-open is a human resuming live work. + this.db.prepare("UPDATE term_sessions SET status = 'running', last_activity = ?, opened_at = ?, updated_at = ? WHERE id = ?").run(Date.now(), Date.now(), Date.now(), sessionId); // No "Resumed" card — reconnecting is lifecycle noise, not something the operator needs in the feed. this.audit(sessionId, s.agent, 'session.resumed', {}); } @@ -9464,8 +9478,8 @@ export class TerminalManager { this.allowResume(sessionId); const now = Date.now(); const resident = row.resident ? 1 : 0; - this.db.prepare("UPDATE term_sessions SET status = 'running', headless = 0, claimed_by = COALESCE(claimed_by, ?), claimed_at = COALESCE(claimed_at, ?), paused_at = NULL, paused_by = NULL, last_activity = ?, updated_at = ? WHERE id = ?") - .run(by, now, now, now, sessionId); + this.db.prepare("UPDATE term_sessions SET status = 'running', headless = 0, claimed_by = COALESCE(claimed_by, ?), claimed_at = COALESCE(claimed_at, ?), paused_at = NULL, paused_by = NULL, last_activity = ?, opened_at = ?, updated_at = ? WHERE id = ?") + .run(by, now, now, now, now, sessionId); const hasSlack = !!this.db.prepare('SELECT 1 FROM slack_threads WHERE session_id = ?').get(sessionId); const hasDiscord = !!this.db.prepare('SELECT 1 FROM discord_threads WHERE session_id = ?').get(sessionId); const hasClickup = !!this.db.prepare('SELECT 1 FROM clickup_threads WHERE session_id = ?').get(sessionId); diff --git a/web/src/App.tsx b/web/src/App.tsx index ed6def46..f72e4980 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -19492,7 +19492,8 @@ function ConcurrencySettings({ me }: { me: Member }) { working never looks idle, however old it gets, and none of them can reach it. Measured on a live tenant: fifteen sessions open 120–1007 h, every one reporting under a day idle, skipped on every tick. This one asks the honest question instead — has it been open longer than any real piece of work — and overrides the claim and - blocked exemptions. Someone actually attached is still never cut. + blocked exemptions. The clock restarts whenever someone re-opens the session, so a conversation you keep coming back + to isn't cut minutes after you resume it. Someone actually attached is still never cut.