Skip to content
Closed
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
93 changes: 93 additions & 0 deletions .tdd/spec-real-agent-heartbeat-truth-v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Real Agent Heartbeat Truth v1

## Problem

The Department Campus currently treats any fresh lifecycle event as live work,
including terminal `done` and `failed` events, while six idle residents animate
without a running task. Bridge lifecycle timestamps do not prove that an AI
process is still executing.

## Locked criteria

### AC-1 — live state requires a verified heartbeat

- Campus `state=active` and live agent sprites are driven only by fresh,
verified heartbeat records in `working` state.
- The heartbeat identifies an exact canonical project, its responsible
canonical agent, a safe run id, a safe session id, and a UTC heartbeat time.
- A valid heartbeat is no older than 45 seconds and is not in the future.

### AC-2 — lifecycle history is not live presence

- Bridge `pending`, `running`, `done`, and `failed` rows do not create live
Campus agents without a matching verified heartbeat.
- Direct terminal projection events (`done` and `failed`) never produce
`state=active`, active-agent counts, movement, or live routes.
- Existing completed-task and Git history surfaces remain unchanged.

### AC-3 — exact fail-closed identity

- The heartbeat project must exist in `CAMPUS_PROJECTS` and its `agent_id` must
equal that project's registered owner.
- Unknown projects/agents, mismatches, unsafe ids, duplicate live identities,
malformed JSON, missing fields, stale timestamps, and future timestamps are
omitted without partial or inferred activity.
- Raw task text, prompts, paths, tool output, credentials, and heartbeat file
contents never enter the public projection.

### AC-4 — honest motion

- All seven persistent residents are static while idle.
- Only a verified live ephemeral agent may hide its matching resident and use
route/sprite movement.
- Existing keyboard, mobile, and `prefers-reduced-motion` behavior remains.

### AC-5 — JARVIS producer keeps truth fresh only while a provider runs

- `jarvis-pixel-agent-event` stores the canonical project, canonical agent id,
run id, session id, state, and heartbeat timestamp atomically.
- A `heartbeat` command refreshes an existing working record without changing
its task/status copy and without posting a synthetic Pixel tool event.
- `jarvis-agent-pipeline` refreshes heartbeat at a bounded interval while the
Claude/Codex provider process runs, stops the loop afterward, and writes idle
on completion/failure.

### AC-6 — concurrent producer updates preserve every agent

- Heartbeat storage updates are protected across independent producer
processes, so one agent's read-modify-write cannot discard another agent's
newly written or refreshed record.
- The protection remains dependency-free, bounded, and fail-closed; an
abandoned lock must not block the producer forever.

### AC-7 — terminal state follows the complete provider process tree

- On `HUP`, `INT`, `TERM`, or pipeline exit, cleanup stops and waits for the
complete provider process group, not only its shell wrapper.
- The terminal Pixel `done` event is written only after that process group is
no longer running, so Campus cannot report idle while Claude/Codex work
continues in an orphaned descendant.

## Error and boundary criteria

- ERR-1: unreadable or malformed heartbeat storage returns no live events.
- ERR-2: a valid heartbeat mixed with a duplicate or identity conflict for the
same canonical agent fails that identity closed.
- EC-1: exactly 45 seconds old is accepted; older is stale.
- EC-2: terminal Bridge history may remain available elsewhere but never
changes Campus live counts or motion.
- EC-3: two independent producer processes updating different canonical agents
preserve both records.
- EC-4: interrupted cleanup is bounded and still removes provider temp output.

## Constraints

- Public/read-only behavior and owner-field privacy remain unchanged.
- No new dependency, credential, network service, dispatch control, or public
mutation endpoint.
- Maximum three visible live tasks remains.

## Out of scope

- Codex desktop tasks that do not emit the heartbeat contract.
- Merge, deploy, restart, credentials, permissions, pairing, or Remote changes.
12 changes: 9 additions & 3 deletions builder/dashboard-assets/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@
waiting: 'department',
failed: 'department',
};
const liveStatuses = ['active', 'testing'];
const movingStatuses = ['active', 'testing'];
const stateMessages = {
loading: 'Загрузка кампуса…',
Expand Down Expand Up @@ -473,7 +474,9 @@

function renderDepartmentCampus(payload) {
const state = payload && typeof payload.state === 'string' ? payload.state : 'unavailable';
const events = state === 'active' && Array.isArray(payload.events) ? payload.events : [];
const events = state === 'active' && Array.isArray(payload.events)
? payload.events.filter((event) => liveStatuses.includes(event?.status))
: [];
if (
state === 'empty'
|| state === 'stale'
Expand All @@ -494,7 +497,7 @@
}
const matchedEvents = [];
events.forEach((event) => {
if (!Object.prototype.hasOwnProperty.call(statusLabels, event?.status)) return;
if (!liveStatuses.includes(event?.status)) return;
const folder = projectFolderForEvent(event);
if (!folder) return;
matchedEvents.push(event);
Expand Down Expand Up @@ -533,7 +536,10 @@
animateCampusJourney(button, shouldAnimate, managerOriginRect);
});
const activeAgentCount = new Set(
matchedEvents.map((event) => String(event.agent_id || '')).filter(Boolean),
matchedEvents
.filter((event) => liveStatuses.includes(event.status))
.map((event) => String(event.agent_id || ''))
.filter(Boolean),
).size;
setCampusState(
'active',
Expand Down
55 changes: 52 additions & 3 deletions builder/dashboard-server-m4.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@
JARVIS_PIPELINE_SCRIPT = SCRIPTS_DIR / "jarvis-agent-pipeline"
JARVIS_PIPELINE_REPORT_DIR = HOME / "Library" / "Logs" / "jarvis-agent-pipeline"
JARVIS_PIPELINE_LOG_FILE = HOME / "Library" / "Logs" / "dashboard-jarvis-pipeline-run.log"
JARVIS_PIXEL_AGENT_DETAILS_FILE = Path(
os.environ.get(
"JARVIS_PIXEL_AGENT_DETAILS_FILE",
str(HOME / ".pixel-agents" / "jarvis-agent-details.json"),
)
).expanduser()
JARVIS_REPO = HOME / "jarvis"
JARVIS_PYTHON = JARVIS_REPO / ".venv" / "bin" / "python"
JARVIS_VAULT_ROOT = Path(
Expand Down Expand Up @@ -1193,7 +1199,7 @@ def _department_snapshot_time(value: object) -> datetime | None:


def _department_campus_state(state: str, *, now: datetime) -> dict:
payload = department_campus_projection([], now=now)
payload = department_campus_projection([], heartbeats=[], now=now)
payload["state"] = state
return payload

Expand Down Expand Up @@ -1278,9 +1284,34 @@ def _campus_bridge_event(task: dict) -> dict | None:
return event


def _department_campus_heartbeats(path: Path) -> list[dict]:
"""Read only the six heartbeat proof fields from local producer storage."""
try:
document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return []
agents = document.get("agents") if isinstance(document, dict) else None
if not isinstance(agents, dict):
return []
normalized: list[dict] = []
for record in agents.values():
if not isinstance(record, dict):
continue
normalized.append({
"project": record.get("project"),
"agent_id": record.get("agentId", record.get("agent_id")),
"run_id": record.get("runId", record.get("run_id")),
"session_id": record.get("sessionId", record.get("session_id")),
"state": record.get("state"),
"heartbeat_at": record.get("heartbeatAt", record.get("heartbeat_at")),
})
return normalized


def _department_campus_payload(
data: object,
*,
heartbeat_path: Path | None = None,
now: datetime | None = None,
owner_view: bool = False,
) -> dict:
Expand All @@ -1289,8 +1320,16 @@ def _department_campus_payload(
if current.tzinfo is None:
current = current.replace(tzinfo=timezone.utc)
current = current.astimezone(timezone.utc)
heartbeats = _department_campus_heartbeats(
heartbeat_path or JARVIS_PIXEL_AGENT_DETAILS_FILE
)
if not isinstance(data, dict) or not isinstance(data.get("tasks"), list):
return department_campus_projection(None, now=current, owner_view=owner_view)
return department_campus_projection(
None,
heartbeats=heartbeats,
now=current,
owner_view=owner_view,
)

candidates: list[tuple[int, datetime, list]] = []
malformed_verified_snapshot = False
Expand Down Expand Up @@ -1323,16 +1362,25 @@ def _department_campus_payload(

if not candidates:
if malformed_verified_snapshot:
return department_campus_projection(None, now=current, owner_view=owner_view)
return department_campus_projection(
None,
heartbeats=heartbeats,
now=current,
owner_view=owner_view,
)
events = [
event
for task in data["tasks"]
if isinstance(task, dict)
for event in [_campus_bridge_event(task)]
if event is not None
]
# A heartbeat can stand alone only when Bridge has no lifecycle rows.
# Non-empty but unverified rows are not allowed to borrow that proof.
fallback_heartbeats = heartbeats if events or not data["tasks"] else []
return department_campus_projection(
events,
heartbeats=fallback_heartbeats,
now=current,
max_tasks=3,
owner_view=owner_view,
Expand All @@ -1344,6 +1392,7 @@ def _department_campus_payload(
return _department_campus_state("stale", now=current)
return department_campus_projection(
events,
heartbeats=heartbeats,
now=current,
max_tasks=3,
owner_view=owner_view,
Expand Down
Loading