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.