fix(core): keep actionable activity sticky (#1902)

`waiting_input`/`blocked` no longer decay on wallclock; clears only on process death or newer entry.

Close #1894.
Reference #1899.
This commit is contained in:
Harshit Singh Bhandari 2026-05-17 20:03:56 +05:30 committed by GitHub
parent cb7b83d223
commit ce6b47adf8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 131 additions and 33 deletions

View File

@ -450,8 +450,8 @@ import {
validateUrl, // Webhook URL validation
readLastJsonlEntry, // Efficient JSONL log tail (native agent JSONL)
readLastActivityEntry, // Read last AO activity JSONL entry
checkActivityLogState, // Extract waiting_input/blocked from AO JSONL (with staleness cap)
getActivityFallbackState, // Last-resort fallback: entry state + age-based decay
checkActivityLogState, // Extract sticky waiting_input/blocked from AO JSONL
getActivityFallbackState, // Last-resort fallback: actionable states + liveness age decay
recordTerminalActivity, // Shared recordActivity impl (classify + dedup + append)
classifyTerminalActivity, // Classify terminal output via detectActivity
appendActivityEntry, // Low-level JSONL append
@ -460,7 +460,7 @@ import {
normalizeAgentPermissionMode, // Normalize permission mode strings
DEFAULT_READY_THRESHOLD_MS, // 5 min — ready→idle threshold
DEFAULT_ACTIVE_WINDOW_MS, // 30s — active→ready window
ACTIVITY_INPUT_STALENESS_MS, // 5 min — waiting_input/blocked expiry
ACTIVITY_INPUT_STALENESS_MS, // Deprecated compatibility export; actionable states no longer expire by wallclock
PREFERRED_GH_PATH, // /usr/local/bin/gh
CI_STATUS, ACTIVITY_STATE, SESSION_STATUS, // Constants
type Session, type ProjectConfig, type RuntimeHandle,

View File

@ -9,9 +9,26 @@ import {
appendActivityEntry,
recordTerminalActivity,
getActivityLogPath,
ACTIVITY_INPUT_STALENESS_MS,
getActivityFallbackState,
} from "../activity-log.js";
import type { ActivityState } from "../types.js";
import type { ActivityDetection, ActivityLogEntry, ActivityState } from "../types.js";
const minutesAgo = (minutes: number): string => new Date(Date.now() - minutes * 60_000).toISOString();
const toActivityResult = (
entry: ActivityLogEntry,
): { entry: ActivityLogEntry; modifiedAt: Date } => ({
entry,
modifiedAt: new Date(entry.ts),
});
const detectWithProcessCheck = (
isProcessRunning: boolean,
activityResult: { entry: ActivityLogEntry; modifiedAt: Date } | null,
): ActivityDetection | null => {
if (!isProcessRunning) return { state: "exited", timestamp: new Date() };
return checkActivityLogState(activityResult) ?? getActivityFallbackState(activityResult, 30_000, 5 * 60_000);
};
describe("classifyTerminalActivity", () => {
it("returns active state with no trigger", () => {
@ -56,13 +73,20 @@ describe("checkActivityLogState", () => {
expect(result?.state).toBe("blocked");
});
it("returns null for stale waiting_input entry", () => {
const staleTs = new Date(Date.now() - ACTIVITY_INPUT_STALENESS_MS - 1000).toISOString();
it("returns waiting_input even when older than the former wallclock cap", () => {
const result = checkActivityLogState({
entry: { ts: staleTs, state: "waiting_input", source: "terminal" },
entry: { ts: minutesAgo(10), state: "waiting_input", source: "terminal" },
modifiedAt: new Date(),
});
expect(result).toBeNull();
expect(result?.state).toBe("waiting_input");
});
it("returns blocked even when older than the former wallclock cap", () => {
const result = checkActivityLogState({
entry: { ts: minutesAgo(6), state: "blocked", source: "terminal" },
modifiedAt: new Date(),
});
expect(result?.state).toBe("blocked");
});
it("returns null for non-critical states", () => {
@ -82,6 +106,77 @@ describe("checkActivityLogState", () => {
});
});
describe("getActivityFallbackState", () => {
it("returns waiting_input for a 10-minute-old entry instead of decaying to idle", () => {
const result = getActivityFallbackState(
toActivityResult({ ts: minutesAgo(10), state: "waiting_input", source: "terminal" }),
30_000,
5 * 60_000,
);
expect(result?.state).toBe("waiting_input");
});
it("returns blocked for a 6-minute-old entry instead of decaying to idle", () => {
const result = getActivityFallbackState(
toActivityResult({ ts: minutesAgo(6), state: "blocked", source: "terminal" }),
30_000,
5 * 60_000,
);
expect(result?.state).toBe("blocked");
});
it("returns blocked for a 1-minute-old entry with unchanged behavior", () => {
const result = getActivityFallbackState(
toActivityResult({ ts: minutesAgo(1), state: "blocked", source: "terminal" }),
30_000,
5 * 60_000,
);
expect(result?.state).toBe("blocked");
});
it("lets a newer active entry override an older waiting_input entry", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "ao-test-"));
try {
await mkdir(join(tmpDir, ".ao"), { recursive: true });
const waitingEntry: ActivityLogEntry = {
ts: minutesAgo(6),
state: "waiting_input",
source: "terminal",
};
const activeEntry: ActivityLogEntry = {
ts: new Date(Date.now() - 1000).toISOString(),
state: "active",
source: "terminal",
};
await writeFile(
getActivityLogPath(tmpDir),
`${JSON.stringify(waitingEntry)}\n${JSON.stringify(activeEntry)}\n`,
"utf-8",
);
const activityResult = await readLastActivityEntry(tmpDir);
const result = getActivityFallbackState(activityResult, 30_000, 5 * 60_000);
expect(activityResult?.entry.state).toBe("active");
expect(result?.state).toBe("active");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
it("returns exited when the process check fails before a stale waiting_input can fall through", () => {
const result = detectWithProcessCheck(
false,
toActivityResult({ ts: minutesAgo(6), state: "waiting_input", source: "terminal" }),
);
expect(result?.state).toBe("exited");
});
});
describe("readLastActivityEntry", () => {
let tmpDir: string;
@ -144,6 +239,25 @@ describe("readLastActivityEntry", () => {
const result = await readLastActivityEntry(tmpDir);
expect(result).toBeNull();
});
it("falls back to the previous complete line when a read races a truncated tail", async () => {
await mkdir(join(tmpDir, ".ao"), { recursive: true });
const completeEntry: ActivityLogEntry = {
ts: minutesAgo(10),
state: "waiting_input",
source: "terminal",
trigger: "approve?",
};
await writeFile(
getActivityLogPath(tmpDir),
`${JSON.stringify(completeEntry)}\n{"ts":"${new Date().toISOString()}","state":`,
"utf-8",
);
const result = await readLastActivityEntry(tmpDir);
expect(result?.entry).toEqual(completeEntry);
});
});
describe("recordTerminalActivity", () => {

View File

@ -15,10 +15,8 @@ import { join, dirname } from "node:path";
import type { ActivityState, ActivityLogEntry, ActivityDetection } from "./types.js";
/**
* Maximum age (ms) for `waiting_input`/`blocked` entries before they're
* considered stale. If no new terminal output overwrites the entry within
* this window, the state falls through to downstream fallbacks instead of
* keeping the session stuck in `needs_input` on the dashboard forever.
* @deprecated Actionable states no longer decay on wallclock. Retained until
* the activity-reducer cleanup removes the old activity-log module.
*/
export const ACTIVITY_INPUT_STALENESS_MS = 5 * 60 * 1000; // 5 minutes
@ -129,7 +127,7 @@ export async function readLastActivityEntry(
/**
* Check the AO activity JSONL for actionable states only.
*
* Only returns `waiting_input`/`blocked` (with a staleness cap).
* Only returns `waiting_input`/`blocked`.
* Non-critical states (`active`, `ready`, `idle`) always return `null` so
* callers fall through to their native signals (git commits, chat history,
* API queries, native JSONL). This prevents the lifecycle manager's
@ -144,18 +142,12 @@ export function checkActivityLogState(
const { entry } = activityResult;
if (entry.state === "waiting_input" || entry.state === "blocked") {
// Use the entry's own timestamp for staleness — not file mtime, which
// gets refreshed every poll cycle by recordActivity and would prevent
// stale entries from ever being detected.
const entryTs = new Date(entry.ts);
if (Number.isNaN(entryTs.getTime())) return null;
const ageMs = Date.now() - entryTs.getTime();
if (ageMs <= ACTIVITY_INPUT_STALENESS_MS) {
return { state: entry.state, timestamp: entryTs };
}
return { state: entry.state, timestamp: entryTs };
}
// Non-critical states and stale entries — fall through to native signals
// Non-critical states fall through to native signals
return null;
}
@ -178,16 +170,8 @@ export function getActivityFallbackState(
const entryTs = new Date(entry.ts);
if (Number.isNaN(entryTs.getTime())) return null;
// Actionable states use the same staleness cap as checkActivityLogState.
// If the entry is stale, fall through to age-based decay instead of
// re-surfacing a waiting_input/blocked that checkActivityLogState already filtered.
if (entry.state === "waiting_input" || entry.state === "blocked") {
const ageMs = Date.now() - entryTs.getTime();
if (ageMs <= ACTIVITY_INPUT_STALENESS_MS) {
return { state: entry.state, timestamp: entryTs };
}
// Stale actionable entry — treat as idle
return { state: "idle", timestamp: entryTs };
return { state: entry.state, timestamp: entryTs };
}
// Age-based decay: active→ready→idle, but never promote past the

View File

@ -253,7 +253,7 @@ Step 4 (the JSONL entry fallback) is mandatory. Skipping it means `getActivitySt
|---|---|---|
| `DEFAULT_ACTIVE_WINDOW_MS` | 30 seconds | Activity newer than this is `active`; older is `ready` |
| `DEFAULT_READY_THRESHOLD_MS` | 5 minutes | `ready` sessions older than this become `idle` |
| `ACTIVITY_INPUT_STALENESS_MS` | 5 minutes | `waiting_input` / `blocked` JSONL entries expire after this duration |
| `ACTIVITY_INPUT_STALENESS_MS` | 5 minutes | Deprecated compatibility export. `waiting_input` / `blocked` entries no longer expire by wallclock; they persist until process death or a newer entry overrides them. |
---

View File

@ -468,7 +468,7 @@ All utilities are exported from `@aoagents/ao-core`. The source lives in `packag
|--------|-------|
| `DEFAULT_READY_THRESHOLD_MS` | `300_000` (5 min) — ready → idle threshold |
| `DEFAULT_ACTIVE_WINDOW_MS` | `30_000` (30 s) — active → ready window |
| `ACTIVITY_INPUT_STALENESS_MS` | `300_000` (5 min) — waiting_input/blocked expiry |
| `ACTIVITY_INPUT_STALENESS_MS` | Deprecated compatibility export (`300_000`); waiting_input/blocked no longer expire by wallclock |
| `PREFERRED_GH_PATH` | `/usr/local/bin/gh` |
| `CI_STATUS` | `{ PENDING, PASSING, FAILING, NONE }` |
| `ACTIVITY_STATE` | `{ ACTIVE, READY, IDLE, WAITING_INPUT, BLOCKED, EXITED }` |