Skip to content

Commit 200ef87

Browse files
committed
feat: server-side claimed_at staleness detection + effectiveStatus audit
Parser now reads claimed_at from phase YAML frontmatter. Phases that have been IN_PROGRESS for >5 minutes with no sessions or heartbeat are marked effectiveStatus: ON_HOLD. Also fixed 10 remaining places in EpicCard, KeepPanel, and QuestLog that read raw epic.status/phase.status instead of effectiveStatus for display rendering.
1 parent 7ef7857 commit 200ef87

File tree

8 files changed

+130
-12
lines changed

8 files changed

+130
-12
lines changed

server/__tests__/parser.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,79 @@ Body.
466466
// Missing / malformed phases array
467467
// ---------------------------------------------------------------------------
468468

469+
// ---------------------------------------------------------------------------
470+
// Phase claimed_at field parsing
471+
// ---------------------------------------------------------------------------
472+
473+
it('parses claimed_at as ISO string into claimedAt epoch ms', () => {
474+
const planPath = writeFile('claimed-iso/plan.md', `---
475+
epic: claimed-iso
476+
phases:
477+
- id: 1
478+
title: "Phase"
479+
persona: eng
480+
status: IN_PROGRESS
481+
claimed_at: "2026-04-06T10:00:00Z"
482+
---
483+
Body.
484+
`)
485+
486+
const epic = parsePlanFile(planPath, 'P', '/p')!
487+
expect(epic.phases[0].claimedAt).toBe(Date.parse('2026-04-06T10:00:00Z'))
488+
})
489+
490+
it('parses claimed_at as epoch ms number', () => {
491+
const ts = 1743933600000 // some known epoch ms
492+
const planPath = writeFile('claimed-ms/plan.md', `---
493+
epic: claimed-ms
494+
phases:
495+
- id: 1
496+
title: "Phase"
497+
persona: eng
498+
status: IN_PROGRESS
499+
claimed_at: ${ts}
500+
---
501+
Body.
502+
`)
503+
504+
const epic = parsePlanFile(planPath, 'P', '/p')!
505+
expect(epic.phases[0].claimedAt).toBe(ts)
506+
})
507+
508+
it('parses claimed_at as epoch seconds (auto-converts to ms)', () => {
509+
const secs = 1743933600 // < 1e12, treated as seconds
510+
const planPath = writeFile('claimed-secs/plan.md', `---
511+
epic: claimed-secs
512+
phases:
513+
- id: 1
514+
title: "Phase"
515+
persona: eng
516+
status: IN_PROGRESS
517+
claimed_at: ${secs}
518+
---
519+
Body.
520+
`)
521+
522+
const epic = parsePlanFile(planPath, 'P', '/p')!
523+
expect(epic.phases[0].claimedAt).toBe(secs * 1000)
524+
})
525+
526+
it('omits claimedAt when claimed_at is absent', () => {
527+
const planPath = writeFile('no-claimed/plan.md', `---
528+
epic: no-claimed
529+
phases:
530+
- id: 1
531+
title: "Phase"
532+
persona: eng
533+
status: IN_PROGRESS
534+
---
535+
Body.
536+
`)
537+
538+
const epic = parsePlanFile(planPath, 'P', '/p')!
539+
expect(epic.phases[0].claimedAt).toBeUndefined()
540+
})
541+
469542
it('treats missing phases key as an empty phases array', () => {
470543
// No phases: key at all — should produce a valid epic with zero phases
471544
const planPath = writeFile('no-phases-key/plan.md', `---

server/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -369,8 +369,17 @@ function buildState(projects: ProjectConfig[], scannedAgents: { name: string; en
369369

370370
if (linkedSessionIds.length === 0) {
371371
// No sessions linked at all — we cannot distinguish "agent hasn't
372-
// started yet" from "agent crashed". Keep IN_PROGRESS.
373-
phase.effectiveStatus = 'IN_PROGRESS'
372+
// started yet" from "agent crashed" purely from sessions.
373+
// However, if the phase has a claimed_at timestamp and it's older
374+
// than 5 minutes, the claiming agent likely crashed before creating
375+
// a session. Surface this as ON_HOLD so the UI shows it as stale.
376+
const CLAIM_STALE_MS = 5 * 60 * 1_000 // 5 minutes
377+
if (phase.claimedAt && (now - phase.claimedAt) > CLAIM_STALE_MS) {
378+
phase.effectiveStatus = 'ON_HOLD'
379+
phase.blockingReason = `@${phase.persona} claimed ${Math.round((now - phase.claimedAt) / 60_000)}m ago but has no active session`
380+
} else {
381+
phase.effectiveStatus = 'IN_PROGRESS'
382+
}
374383
} else {
375384
const hasActiveSession = linkedSessionIds.some(
376385
id => sessionActivityMap.get(id) === 'active'

server/parser.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,23 @@ function deriveEpicStatus(phases: Phase[]): EpicStatus {
5757
return 'TODO'
5858
}
5959

60+
/**
61+
* Parse a claimed_at value from YAML into a unix epoch ms number.
62+
* Accepts ISO 8601 strings, unix epoch ms numbers, or unix epoch seconds.
63+
* Returns undefined if the value cannot be parsed.
64+
*/
65+
function parseClaimedAt(raw: unknown): number | undefined {
66+
if (typeof raw === 'number') {
67+
// Distinguish seconds from milliseconds: if < 1e12, treat as seconds
68+
return raw < 1e12 ? raw * 1000 : raw
69+
}
70+
if (typeof raw === 'string') {
71+
const ms = Date.parse(raw)
72+
return Number.isFinite(ms) ? ms : undefined
73+
}
74+
return undefined
75+
}
76+
6077
export function parsePlanFile(planPath: string, projectName: string, projectPath: string): Epic | null {
6178
try {
6279
const raw = fs.readFileSync(planPath, 'utf8')
@@ -76,6 +93,9 @@ export function parsePlanFile(planPath: string, projectName: string, projectPath
7693
status: normalizeStatus(p.status),
7794
description: typeof p.description === 'string' ? p.description.trim() || undefined : undefined,
7895
checklist: Array.isArray(p.checklist) ? (p.checklist as unknown[]).map(item => String(item)) : undefined,
96+
// Parse claimed_at from YAML — agents write this as an ISO string or unix epoch
97+
// when they claim a phase. Convert to unix epoch ms for consistent comparison.
98+
claimedAt: p.claimed_at != null ? parseClaimedAt(p.claimed_at) : undefined,
7999
}))
80100

81101
const epicDir = path.dirname(planPath)

server/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,14 @@ export interface Phase {
244244
linkedSessions?: string[]
245245
/** Computed wall-clock duration in milliseconds (start of first session to end of last / now) */
246246
durationMs?: number
247+
/**
248+
* Unix epoch ms when this phase was claimed (set to IN_PROGRESS) by an agent.
249+
* Parsed from the `claimed_at` YAML frontmatter field on the phase.
250+
* Used for staleness detection: if a phase is IN_PROGRESS with no linked
251+
* sessions and no heartbeat, and claimedAt is older than CLAIM_STALE_MS,
252+
* the server marks the phase as ON_HOLD.
253+
*/
254+
claimedAt?: number
247255
}
248256

249257
export interface Epic {

src/components/EpicCard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ export default function EpicCard({
360360
>
361361
{pastelCandy && (
362362
<span aria-hidden="true" style={{ marginRight: 4 }}>
363-
{sparkle(`epic.${displayStatus === 'stalled' ? 'stalled' : epic.status}`, true)}
363+
{sparkle(`epic.${displayStatus === 'stalled' ? 'stalled' : (epic.effectiveStatus ?? epic.status)}`, true)}
364364
</span>
365365
)}
366366
{badge.label}

src/components/town/QuestLog.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,11 @@ function SectionHeading({
149149

150150
function QuestCard({ entry, accent }: { entry: QuestEntry; accent: string }) {
151151
const { phase, epicTitle } = entry
152-
const statusColor = STATUS_COLOR[phase.status] ?? '#3a3f5c'
153-
const statusLabel = STATUS_LABEL[phase.status] ?? phase.status
154-
const statusIcon = STATUS_ICON[phase.status] ?? ''
155-
const isDone = phase.status === 'DONE'
152+
const effStatus = phase.effectiveStatus ?? phase.status
153+
const statusColor = STATUS_COLOR[effStatus] ?? '#3a3f5c'
154+
const statusLabel = STATUS_LABEL[effStatus] ?? effStatus
155+
const statusIcon = STATUS_ICON[effStatus] ?? ''
156+
const isDone = effStatus === 'DONE'
156157

157158
return (
158159
<div

src/components/town/panels/KeepPanel.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,10 @@ function EpicRow({ epic, accent }: { epic: Epic; accent: string }) {
6767
const totalCount = epic.phases.length
6868
const progressPct = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0
6969

70-
const statusColor = STATUS_COLOR[epic.status] ?? STATUS_COLOR.TODO
71-
const isActive = epic.status === 'IN_PROGRESS'
72-
const isBlocked = epic.status === 'BLOCKED'
70+
const effEpicStatus = epic.effectiveStatus ?? epic.status
71+
const statusColor = STATUS_COLOR[effEpicStatus] ?? STATUS_COLOR.TODO
72+
const isActive = effEpicStatus === 'IN_PROGRESS'
73+
const isBlocked = effEpicStatus === 'BLOCKED'
7374

7475
return (
7576
<div
@@ -109,7 +110,7 @@ function EpicRow({ epic, accent }: { epic: Epic; accent: string }) {
109110
width: 7,
110111
height: 7,
111112
borderRadius: '50%',
112-
background: isActive || epic.status === 'DONE' ? statusColor : 'transparent',
113+
background: isActive || effEpicStatus === 'DONE' ? statusColor : 'transparent',
113114
border: `2px solid ${statusColor}`,
114115
flexShrink: 0,
115116
}}
@@ -157,7 +158,7 @@ function EpicRow({ epic, accent }: { epic: Epic; accent: string }) {
157158
flexShrink: 0,
158159
}}
159160
>
160-
{STATUS_LABEL[epic.status] ?? epic.status}
161+
{STATUS_LABEL[effEpicStatus] ?? effEpicStatus}
161162
</span>
162163

163164
{/* Expand chevron */}

src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ export interface Phase {
4242
linkedSessions?: string[]
4343
/** Computed wall-clock duration in milliseconds (start of first session to end of last / now) */
4444
durationMs?: number
45+
/**
46+
* Unix epoch ms when this phase was claimed (set to IN_PROGRESS) by an agent.
47+
* Parsed from the `claimed_at` YAML frontmatter field on the phase.
48+
* Used for staleness detection on the server side.
49+
*/
50+
claimedAt?: number
4551
}
4652

4753
export interface FlatTask {

0 commit comments

Comments
 (0)