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:
parent
cb7b83d223
commit
ce6b47adf8
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }` |
|
||||
|
|
|
|||
Loading…
Reference in New Issue