Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
16 changes: 16 additions & 0 deletions scripts/idle-reaper-test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
3 changes: 2 additions & 1 deletion src/governance/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/state/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
32 changes: 23 additions & 9 deletions src/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3915,16 +3915,23 @@ 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;
const terminal = r.status === 'done' || r.status === 'crashed'; // see sweep 2 — a crashed row can still hold a pane
// 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) {
Expand Down Expand Up @@ -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 */ }
}
}
Expand Down Expand Up @@ -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 — <agent>" 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.
Expand Down Expand Up @@ -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', {});
}
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <span className="font-mono">120–1007 h</span>, 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.
</p>
</div>
<div className="flex items-center gap-3">
Expand Down
Loading