Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5603d1c
fix: classify sessions with armed heartbeats as idle, not running
snimu Sep 1, 2026
a7cf971
fix: passivate heartbeat sessions normally and wake them when jobs co…
snimu Sep 2, 2026
897d902
fix: fence the scheduled-session wake to public sessions and ledger t…
snimu Sep 2, 2026
0219cd3
chore: slim PR comments and merge overlapping heartbeat pins
snimu Sep 2, 2026
4ab2af3
fix: key the ephemeral cancel by persisted ids and fence it against t…
snimu Sep 2, 2026
d7a752e
fix: reach passive scheduled jobs from unscoped cron commands; drop s…
snimu Sep 2, 2026
297fa6b
fix: read each passive session's scheduled-jobs artifact in isolation
snimu Sep 2, 2026
4015292
fix: keep the ephemeral stop tombstone as the durable cancel intent
snimu Sep 2, 2026
0f221cf
fix: include passive terminal jobs in an inclusive unscoped cron_list
snimu Sep 2, 2026
2bfb4fb
fix: revalidate ownership inside the ephemeral cancel's destructive walk
snimu Sep 2, 2026
f115d55
fix: treat a resident worker anywhere on the owning chain as covering…
snimu Sep 2, 2026
3bfe7b5
fix: guard every destructive cancel walk with the same ownership truth
snimu Sep 2, 2026
19ab0c1
refactor: derive ephemeral cancel intents from the persisted descriptors
snimu Sep 2, 2026
1723f5e
fix: keep wake-blind scheduled workers resident through the idle sweep
snimu Sep 2, 2026
821d6d9
fix: own the wake-blind exemption in the eviction eligibility predicates
snimu Sep 2, 2026
5e9bde9
test: give the peer-transport eviction fixture a type-complete descri…
snimu Sep 2, 2026
281cfbf
feat(coding-agent): rank armed heartbeats first within the inactive s…
snimu Sep 3, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- Fixed sessions with armed heartbeats showing as Running forever in the agents view; between firings they now list as Idle with the heartbeat badge and a `heartbeat · next <time>` label.
- Added a dimmed heartbeat badge for sessions whose only heartbeats are paused.
- Added an armed-heartbeat warning to the agents-view delete confirmation for sessions and subagents.
- Changed sessions with armed heartbeats to passivate like any idle session; the daemon now wakes them when the next heartbeat is due, including after a daemon restart.
- Fixed heartbeats of passivated sessions disappearing from the heartbeat list and agents-view badges.
4 changes: 2 additions & 2 deletions packages/coding-agent/src/core/session-action-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,6 @@ export type IdleEvictionMinutes = number | "off";
export interface SessionEvictionSnapshot {
isSessionActive: boolean;
attachedClients: number;
hasRegisteredHeartbeat: boolean;
hasRegisteredCronJob: boolean;
lastActivityAt: number;
}
Expand All @@ -367,6 +366,7 @@ export interface WorkerEvictionSnapshot {
isStopping: boolean;
hasOwnerClient: boolean;
isPreparingUpdateRestart: boolean;
hasWakeBlindSchedule: boolean;
sessions: readonly SessionEvictionSnapshot[];
}

Expand All @@ -381,7 +381,6 @@ function isIdleEvictionThresholdMet(
return (
!session.isSessionActive &&
session.attachedClients === 0 &&
!session.hasRegisteredHeartbeat &&
!session.hasRegisteredCronJob &&
Number.isFinite(session.lastActivityAt) &&
now - session.lastActivityAt >= idleEvictionMinutes * 60_000
Expand Down Expand Up @@ -414,6 +413,7 @@ export function canEvictWorker(
worker.isStopping ||
worker.hasOwnerClient ||
worker.isPreparingUpdateRestart ||
worker.hasWakeBlindSchedule ||
worker.sessions.length === 0
) {
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2541,7 +2541,8 @@ export class AgentsViewMode implements Component, Focusable {
const details = row.section === "inactive" ? `${row.summary.messageCount} · ${age}` : age;
const detailsWidth = row.section === "inactive" ? Math.max(10, visibleWidth(details)) : 10;
const heartbeatBadge = !pendingDelete && !pendingKill ? formatHeartbeatBadge(row.heartbeat) : "";
const heartbeatCell = heartbeatBadge ? theme.fg("error", heartbeatBadge) : "";
const heartbeatPausedOnly = (row.heartbeat?.activeCount ?? 0) < 1;
const heartbeatCell = heartbeatBadge ? theme.fg(heartbeatPausedOnly ? "dim" : "error", heartbeatBadge) : "";
const heartbeatWidth = visibleWidth(heartbeatBadge);
const titleWidth = Math.max(
0,
Expand All @@ -2552,10 +2553,12 @@ export class AgentsViewMode implements Component, Focusable {
2 -
(heartbeatWidth > 0 ? heartbeatWidth + 1 : 0),
);
const armedHeartbeat = row.summary.hasActiveHeartbeat === true || (row.heartbeat?.activeCount ?? 0) > 0;
const heartbeatWarning = armedHeartbeat ? "has an armed heartbeat — " : "";
const title = pendingDelete
? this.getPendingDeleteTitle()
? `${heartbeatWarning}${this.getPendingDeleteTitle()}`
: pendingKill
? `${keyText("app.agents.delete")} again to ${row.section === "running" ? "stop" : "delete"}`
? `${heartbeatWarning}${keyText("app.agents.delete")} again to ${row.section === "running" ? "stop" : "delete"}`
: styleRowTitle(row);
// Keep stable model information ahead of the variable summary so narrow rows truncate the summary first.
const summaryText = !pendingDelete && !pendingKill ? row.summary.summary : undefined;
Expand Down
93 changes: 50 additions & 43 deletions packages/coding-agent/src/modes/agents-view/agents-view-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type AgentsViewSection = "running" | "idle" | "inactive";

export interface UnifiedSessionHeartbeat {
activeCount: number;
pausedCount?: number;
nextRunAt?: string;
}

Expand Down Expand Up @@ -94,13 +95,10 @@ export function classifyAgentsViewSession(summary: SessionSummary): AgentsViewSe
return summary.rosterStatus ?? classifySessionRosterStatus(summary);
}

export function classifyUnifiedSession(record: Pick<UnifiedSessionRecord, "daemon" | "heartbeat">): AgentsViewSection {
export function classifyUnifiedSession(record: Pick<UnifiedSessionRecord, "daemon">): AgentsViewSection {
if (!record.daemon) {
return "inactive";
}
if ((record.heartbeat?.activeCount ?? 0) > 0) {
return "running";
}
return classifyAgentsViewSession(record.daemon);
}

Expand Down Expand Up @@ -202,7 +200,10 @@ export function reconcileUnifiedSessions(
heartbeatByActiveId.get(daemon.activeSessionId ?? daemon.id) ??
(daemon.hasActiveHeartbeat ? { activeCount: 1 } : undefined);
const record: UnifiedSessionRecord = {
daemon: heartbeat && !daemon.hasActiveHeartbeat ? { ...daemon, hasActiveHeartbeat: true } : daemon,
daemon:
heartbeat && heartbeat.activeCount > 0 && !daemon.hasActiveHeartbeat
? { ...daemon, hasActiveHeartbeat: true }
: daemon,
identity: aliases[0]!,
identityAliases: aliases,
section: "idle",
Expand Down Expand Up @@ -483,41 +484,59 @@ export function aggregateSessionHeartbeats(
for (const summary of summaries) {
for (const key of getSummaryKeys(summary)) summaryByKey.set(key, summary);
}
const jobIdsByOwner = new Map<string, Set<string>>();
const activeJobIdsByOwner = new Map<string, Set<string>>();
const pausedJobIdsByOwner = new Map<string, Set<string>>();
const nextRunByJob = new Map<string, string>();
const add = (owner: string, jobId: string): void => {
const ids = jobIdsByOwner.get(owner) ?? new Set<string>();
const add = (byOwner: Map<string, Set<string>>, owner: string, jobId: string): void => {
const ids = byOwner.get(owner) ?? new Set<string>();
ids.add(jobId);
jobIdsByOwner.set(owner, ids);
byOwner.set(owner, ids);
};
for (const heartbeat of heartbeats) {
const job = heartbeat.job;
if (job.status !== "active") continue;
if (job.nextRunAt && Number.isFinite(Date.parse(job.nextRunAt))) nextRunByJob.set(job.id, job.nextRunAt);
let summary = summaryByKey.get(`active:${job.activeSessionId}`);
if (job.status !== "active" && job.status !== "paused") continue;
const byOwner = job.status === "active" ? activeJobIdsByOwner : pausedJobIdsByOwner;
if (job.status === "active" && job.nextRunAt && Number.isFinite(Date.parse(job.nextRunAt))) {
nextRunByJob.set(job.id, job.nextRunAt);
}
// Passivation stales the job's active id; session id and file still find the owning row.
let summary: SessionSummary | undefined;
for (const key of [`active:${job.activeSessionId}`, `session:${job.sessionId}`, fileIdentity(job.sessionFile)]) {
summary = summaryByKey.get(key);
if (summary) break;
}
const visited = new Set<string>();
if (!summary) add(job.activeSessionId, job.id);
if (!summary) add(byOwner, job.activeSessionId, job.id);
while (summary) {
const owner = summary.activeSessionId ?? summary.id;
if (visited.has(owner)) break;
visited.add(owner);
add(owner, job.id);
add(byOwner, owner, job.id);
summary = findParentSummary(summary, summaryByKey);
}
}
const result = new Map<string, UnifiedSessionHeartbeat>();
for (const [owner, jobIds] of jobIdsByOwner) {
for (const owner of new Set([...activeJobIdsByOwner.keys(), ...pausedJobIdsByOwner.keys()])) {
const jobIds = activeJobIdsByOwner.get(owner) ?? new Set<string>();
const pausedCount = pausedJobIdsByOwner.get(owner)?.size ?? 0;
const nextRunAt = [...jobIds]
.map((jobId) => nextRunByJob.get(jobId))
.filter((value): value is string => value !== undefined)
.sort((a, b) => Date.parse(a) - Date.parse(b))[0];
result.set(owner, { activeCount: jobIds.size, ...(nextRunAt ? { nextRunAt } : {}) });
result.set(owner, {
activeCount: jobIds.size,
...(pausedCount > 0 ? { pausedCount } : {}),
...(nextRunAt ? { nextRunAt } : {}),
});
}
return result;
}

export function formatHeartbeatBadge(heartbeat: UnifiedSessionHeartbeat | undefined, now = Date.now()): string {
if (!heartbeat || heartbeat.activeCount < 1) return "";
if (!heartbeat) return "";
if (heartbeat.activeCount < 1) {
return (heartbeat.pausedCount ?? 0) > 0 ? `♥ ${heartbeat.pausedCount}` : "";
}
const next = heartbeat.nextRunAt ? Date.parse(heartbeat.nextRunAt) : Number.NaN;
const countdown = Number.isFinite(next) ? formatHeartbeatCountdown(next - now) : undefined;
return `♥ ${heartbeat.activeCount}${countdown ? `·${countdown}` : ""}`;
Expand Down Expand Up @@ -672,7 +691,7 @@ export function buildAgentsViewRows(
summary,
title: getAgentsViewSessionTitle(summary),
subtitle: getSessionSubtitle(summary),
statusLabel: getSessionStatusLabel(summary),
statusLabel: getSessionStatusLabel(summary, record?.heartbeat),
depth: 0,
selectable: true,
runningSubagentCount: 0,
Expand All @@ -682,7 +701,6 @@ export function buildAgentsViewRows(
);
const rowsByKey = buildRowKeyMap(baseRows);
const childrenByParent = new Map<MutableAgentsViewRow, MutableAgentsViewRow[]>();
const parentByChild = new Map<MutableAgentsViewRow, MutableAgentsViewRow>();
const nestedRows = new Set<MutableAgentsViewRow>();

for (const row of baseRows) {
Expand All @@ -697,15 +715,13 @@ export function buildAgentsViewRows(
continue;
}
nestedRows.add(row);
parentByChild.set(row, parent);
if (row.section === "running") {
parent.runningSubagentCount += 1;
}
const siblings = childrenByParent.get(parent) ?? [];
siblings.push(row);
childrenByParent.set(parent, siblings);
}
propagateHeartbeatStateToAncestors(baseRows, parentByChild);

const roots = baseRows.filter((row) => !nestedRows.has(row));
const flattened: AgentsViewRow[] = [];
Expand Down Expand Up @@ -748,25 +764,6 @@ function isUnifiedSessionRecord(value: SessionSummary | UnifiedSessionRecord): v
return "identityAliases" in value;
}

function propagateHeartbeatStateToAncestors(
rows: readonly MutableAgentsViewRow[],
parentByChild: ReadonlyMap<MutableAgentsViewRow, MutableAgentsViewRow>,
): void {
for (const row of rows) {
if (!row.summary.hasActiveHeartbeat) {
continue;
}
const visited = new Set<MutableAgentsViewRow>([row]);
let ancestor = parentByChild.get(row);
while (ancestor && !visited.has(ancestor)) {
visited.add(ancestor);
ancestor.section = "running";
ancestor.statusLabel = getSessionStatusLabel(ancestor.summary, true);
ancestor = parentByChild.get(ancestor);
}
}
}

type MutableAgentsViewRow = AgentsViewRow;

function createSubagentSummaryRow(
Expand Down Expand Up @@ -875,6 +872,13 @@ function compareAgentsViewRows(a: AgentsViewRow, b: AgentsViewRow): number {
if (sectionDiff !== 0) {
return sectionDiff;
}
if (a.section === "inactive") {
const heartbeatDiff =
Number(b.summary.hasActiveHeartbeat ?? false) - Number(a.summary.hasActiveHeartbeat ?? false);
if (heartbeatDiff !== 0) {
return heartbeatDiff;
}
}
if (a.section !== "running") {
const activityDiff = getTimestamp(b.summary.lastActivityAt) - getTimestamp(a.summary.lastActivityAt);
if (activityDiff !== 0) {
Expand Down Expand Up @@ -979,7 +983,7 @@ function getSessionSubtitle(summary: SessionSummary): string {
return parts.join(" ");
}

function getSessionStatusLabel(summary: SessionSummary, hasActiveHeartbeat = summary.hasActiveHeartbeat): string {
function getSessionStatusLabel(summary: SessionSummary, heartbeat?: UnifiedSessionHeartbeat): string {
if (summary.statusLabel !== undefined) {
return summary.statusLabel;
}
Expand Down Expand Up @@ -1016,8 +1020,11 @@ function getSessionStatusLabel(summary: SessionSummary, hasActiveHeartbeat = sum
if (summary.lifecycle === "archived") {
return "archived";
}
if (hasActiveHeartbeat) {
return "heartbeat active";
if (summary.hasActiveHeartbeat) {
const next = heartbeat?.nextRunAt ? Date.parse(heartbeat.nextRunAt) : Number.NaN;
return Number.isFinite(next)
? `heartbeat · next ${formatHeartbeatCountdown(next - Date.now())}`
: "heartbeat active";
}
if (summary.runtimeKind === "subagent" && summary.repliedSinceTask) {
return "replied";
Expand Down
9 changes: 2 additions & 7 deletions packages/coding-agent/src/modes/daemon/agent-roster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,12 @@ export interface AgentStatusInput {
queuedChild: boolean;
/** Actively working: streaming, running tools/bash, or running children. */
busy: boolean;
hasActiveHeartbeat: boolean;
}

export function classifyAgentStatus(input: AgentStatusInput): AgentRosterStatus {
if (input.queuedChild) return "running";
if (!input.resident) return "inactive";
return input.busy || input.hasActiveHeartbeat ? "running" : "idle";
return input.busy ? "running" : "idle";
}

export function isSessionSummaryBusy(
Expand All @@ -27,17 +26,13 @@ export function isSessionSummaryBusy(
}

export function classifySessionRosterStatus(
summary: Pick<
SessionSummary,
"activeSessionId" | "activity" | "isSessionActive" | "hasRunningRlmChildren" | "hasActiveHeartbeat"
>,
summary: Pick<SessionSummary, "activeSessionId" | "activity" | "isSessionActive" | "hasRunningRlmChildren">,
queuedChild = false,
): AgentRosterStatus {
return classifyAgentStatus({
resident: !!summary.activeSessionId,
queuedChild,
busy: summary.activity === "working" || isSessionSummaryBusy(summary),
hasActiveHeartbeat: summary.hasActiveHeartbeat === true,
});
}

Expand Down
30 changes: 26 additions & 4 deletions packages/coding-agent/src/modes/daemon/daemon-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1532,6 +1532,32 @@ export class AgentDaemon {
this.cronStore.recoverSessionArtifact(session.sessionId);
this.cronScheduler.wake();
}
// A fresh worker only knows resident sessions' jobs; passive descendants' schedules must fire without hydration.
if (state.runtime.metadata.kind !== "subagent") {
void this.registerPassiveDescendantCronArtifacts().catch((error) => {
this.log(`Could not register passive descendant scheduled jobs: ${String(error)}`);
});
}
}

private async registerPassiveDescendantCronArtifacts(): Promise<void> {
let registered = false;
for (const passive of await this.listPassiveRlmSubagents()) {
try {
const artifactDir = getSessionArtifactPathForFile(resolve(passive.entry.sessionFile), passive.info.id);
if (this.cronStore.registerSessionArtifact(passive.info.id, artifactDir)) {
registered = true;
this.cronStore.recoverSessionArtifact(passive.info.id);
}
} catch (error) {
this.log(
`Could not register scheduled jobs for passive subagent ${passive.entry.childId}: ${String(error)}`,
);
}
}
Comment thread
snimu marked this conversation as resolved.
if (registered) {
this.cronScheduler.wake();
}
}

private async createRuntime(
Expand Down Expand Up @@ -2642,7 +2668,6 @@ export class AgentDaemon {
return {
isSessionActive: summary.isSessionActive || summary.hasRunningRlmChildren === true || hasPendingAdmission,
attachedClients: state.clients.size + state.pendingAttaches,
hasRegisteredHeartbeat: jobs.some((job) => isHeartbeatCronJob(job) && job.status === "active"),
hasRegisteredCronJob: jobs.some((job) => !isHeartbeatCronJob(job)),
lastActivityAt: Date.parse(summary.lastActivityAt ?? ""),
hasParent: state.runtime.metadata.kind === "subagent" && !!state.runtime.metadata.parentActiveSessionId,
Expand Down Expand Up @@ -5388,9 +5413,6 @@ export class AgentDaemon {
activity: session.isSessionActive ? "working" : "idle",
isSessionActive: session.isSessionActive,
hasRunningRlmChildren: session.hasRunningRlmChildren?.() ?? false,
hasActiveHeartbeat:
this.cronStore.getHeartbeat(state.activeSessionId)?.status === "active" ||
this.cronStore.listRlmHeartbeats(state.activeSessionId).some((job) => job.status === "active"),
isStreaming: session.isStreaming,
} as SessionSummary),
...(metadata.rlmChildId ? { rlmChildId: metadata.rlmChildId } : {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,6 @@ export function isEvictableEmptySessionSummary(summary: SessionSummary): boolean
summary.messageCount === 0 &&
!summary.sessionName &&
!isSessionSummaryBusy(summary) &&
summary.hasRegisteredHeartbeat !== true &&
summary.hasRegisteredCronJob !== true
);
}
Expand Down
Loading
Loading