feat(viz): a station shows whether it is pending, working, or done

Three independent questions get three independent channels, because encoding
them all as brightness makes "not started" and "finished" identical:

  stateAlpha  presence  — a pending station is faint; it has not happened yet
  heatFloor   life      — a running station stays lit between events
  settledColor settlement — a terminal station wears a ring (green/red/grey)

heatFloor is one line in the decay (`max(floor, heat - dt*0.5)`) and it lights
the whole existing treatment, since emissive, radius, glow and sparks are all
already heat-driven. The ring is the only new primitive and it earns its place.

The part that matters most is the staleness decay. A phase is drawn lit because
`mission_phases.status` says `running` — and that column keeps saying `running`
long after the agents behind it have died. Drawing that confidently lit is the
exact failure this codebase keeps hitting: something that looks alive because a
status field says so. After 90s with no real event landing on the station, its
floor sinks to a dim ember and the HUD counts it as "quiet", so a busy station
and an abandoned one cannot look the same.

That is also why `applyMissionPlan` only sets a running phase's floor ONCE, on
first sight. Re-applying it on every plan refresh would relight a dead station
every few seconds — the poll would silently undo the decay.

Rings are removed as well as added: status moves backwards when a phase
re-enters `running` on a retry, and they are swept with the mesh they orbit or
they leak one per phase and keep drawing at a stale position.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-10 22:31:00 -07:00
co-authored by Claude Opus 5
parent 006432c2dc
commit f37c6b92d8
4 changed files with 189 additions and 11 deletions
@@ -0,0 +1 @@
[{"name":"generate-buildid","duration":71,"timestamp":206263664583,"id":4,"parentId":1,"tags":{},"startTime":1786426214814,"traceId":"98a094b8cd51901d"},{"name":"load-custom-routes","duration":573,"timestamp":206263664682,"id":5,"parentId":1,"tags":{},"startTime":1786426214814,"traceId":"98a094b8cd51901d"},{"name":"create-dist-dir","duration":117,"timestamp":206263665263,"id":6,"parentId":1,"tags":{},"startTime":1786426214815,"traceId":"98a094b8cd51901d"},{"name":"clean","duration":135,"timestamp":206263665825,"id":7,"parentId":1,"tags":{},"startTime":1786426214816,"traceId":"98a094b8cd51901d"},{"name":"next-build","duration":64191,"timestamp":206263601813,"id":1,"tags":{"buildMode":"default","version":"16.2.9","bundler":"turbopack","failed":true},"startTime":1786426214752,"traceId":"98a094b8cd51901d"}]
@@ -0,0 +1 @@
[{"name":"next-build","duration":64191,"timestamp":206263601813,"id":1,"tags":{"buildMode":"default","version":"16.2.9","bundler":"turbopack","failed":true},"startTime":1786426214752,"traceId":"98a094b8cd51901d"}]
+84 -8
View File
@@ -21,7 +21,7 @@ import { useClawmatesLive, useLiveState } from "@/lib/live/useClawmatesLive";
import { WorldFlow, type WorldItem } from "../dashboard/flow/WorldFlow"; import { WorldFlow, type WorldItem } from "../dashboard/flow/WorldFlow";
import { BRAIN, agentRegion } from "./brain"; import { BRAIN, agentRegion } from "./brain";
import { WorldEngine, type Formation, type GNode, type WorldSeed } from "./engine"; import { WorldEngine, type Formation, type GNode, type PhaseStatus, type WorldSeed } from "./engine";
import { paletteFor } from "./palette"; import { paletteFor } from "./palette";
type ReplayEvent = { t: number; type: string; data: Record<string, unknown> }; type ReplayEvent = { t: number; type: string; data: Record<string, unknown> };
@@ -130,7 +130,7 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
const [speedMult, setSpeedMult] = useState(1); const [speedMult, setSpeedMult] = useState(1);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const replayHours = 24; const replayHours = 24;
const [hud, setHud] = useState({ agents: 0, active: 0 }); const [hud, setHud] = useState({ agents: 0, active: 0, phases: 0, done: 0, stale: 0 });
const telemetry = useLiveState<"telemetry", { tokensPerMin?: number; doorsPending?: number; loops?: number }>( const telemetry = useLiveState<"telemetry", { tokensPerMin?: number; doorsPending?: number; loops?: number }>(
"telemetry", "telemetry",
(d) => d, (d) => d,
@@ -151,8 +151,12 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
const planRef = useRef<{ const planRef = useRef<{
missionId: string | null; missionId: string | null;
title: string; title: string;
phases: Map<string, { phaseId: string; label: string; orderIdx: number; color?: string }>; finished: boolean;
}>({ missionId: null, title: "", phases: new Map() }); phases: Map<
string,
{ phaseId: string; label: string; orderIdx: number; status: PhaseStatus; color?: string }
>;
}>({ missionId: null, title: "", finished: false, phases: new Map() });
useEffect(() => { useEffect(() => {
onSelectRef.current = onSelect; onSelectRef.current = onSelect;
selectedRef.current = selectedId; selectedRef.current = selectedId;
@@ -193,7 +197,20 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
if (!e) return; if (!e) return;
let active = 0; let active = 0;
for (const p of e.pawns.values()) if (p.status === "working") active += 1; for (const p of e.pawns.values()) if (p.status === "working") active += 1;
setHud({ agents: e.pawns.size, active }); // Phase progress, and whether the lit ones are actually producing events.
let phases = 0;
let done = 0;
let stale = 0;
for (const n of e.nodes.values()) {
if (n.tier !== "phase") continue;
phases += 1;
if (n.settledColor) done += 1;
// A station whose floor has decayed to the ember is running-by-status
// but silent in fact. Naming it is the difference between "this mission
// is working" and "this mission stopped and nobody noticed".
if ((n.heatFloor ?? 0) > 0 && (n.heatFloor ?? 0) <= 0.16) stale += 1;
}
setHud({ agents: e.pawns.size, active, phases, done, stale });
}, 600); }, 600);
return () => clearInterval(id); return () => clearInterval(id);
}, []); }, []);
@@ -221,6 +238,7 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
missionId: plan.missionId, missionId: plan.missionId,
title: plan.title || "Mission", title: plan.title || "Mission",
color: paletteFor(templateKind).mission, color: paletteFor(templateKind).mission,
finished: plan.finished,
phases: [...plan.phases.values()], phases: [...plan.phases.values()],
}); });
}; };
@@ -264,6 +282,8 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
if (focusMissionId && d.missionId !== focusMissionId) return; if (focusMissionId && d.missionId !== focusMissionId) return;
planRef.current.title = d.title; planRef.current.title = d.title;
planRef.current.missionId = d.missionId; planRef.current.missionId = d.missionId;
// A terminal mission freezes the scene — see `WorldEngine.frozen`.
planRef.current.finished = d.status !== "running";
pushPlan(); pushPlan();
}), }),
live.on("mission.phase", (d) => { live.on("mission.phase", (d) => {
@@ -274,6 +294,7 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
phaseId: d.phaseId, phaseId: d.phaseId,
label: PHASE_LABEL[d.kind] ?? d.kind, label: PHASE_LABEL[d.kind] ?? d.kind,
orderIdx: d.orderIdx, orderIdx: d.orderIdx,
status: d.status,
color: pal.phase[d.kind], color: pal.phase[d.kind],
}); });
// Agents rest at the phase they are actually on. Without this every // Agents rest at the phase they are actually on. Without this every
@@ -356,6 +377,8 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
const glow = makeGlowTexture(); const glow = makeGlowTexture();
const sphere = new THREE.SphereGeometry(1, 18, 14); const sphere = new THREE.SphereGeometry(1, 18, 14);
// Shared by every settle ring; one geometry, per-node materials.
const ringGeo = new THREE.RingGeometry(1.28, 1.42, 48);
// lights so the Live nodes/pawns read as shaded 3D balls (the additive glow // lights so the Live nodes/pawns read as shaded 3D balls (the additive glow
// sprites + particles stay unlit, so the neon effects are unaffected) // sprites + particles stay unlit, so the neon effects are unaffected)
scene.add(new THREE.AmbientLight(0xffffff, 0.5)); scene.add(new THREE.AmbientLight(0xffffff, 0.5));
@@ -371,6 +394,8 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
const nodeMeshes = new Map<string, THREE.Mesh>(); const nodeMeshes = new Map<string, THREE.Mesh>();
const nodeGlows = new Map<string, THREE.Sprite>(); const nodeGlows = new Map<string, THREE.Sprite>();
/// Settle rings, created only for nodes carrying `settledColor`.
const ringMeshes = new Map<string, THREE.Mesh>();
const pawnMeshes = new Map<string, THREE.Mesh>(); const pawnMeshes = new Map<string, THREE.Mesh>();
const pawnGlows = new Map<string, THREE.Sprite>(); const pawnGlows = new Map<string, THREE.Sprite>();
const neuronGlows = new Map<string, THREE.Sprite>(); const neuronGlows = new Map<string, THREE.Sprite>();
@@ -813,8 +838,13 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
mat.color.copy(col); mat.color.copy(col);
mat.emissive.copy(col); mat.emissive.copy(col);
mat.emissiveIntensity = 0.35 + n.heat * 0.6 + residual * 0.4; mat.emissiveIntensity = 0.35 + n.heat * 0.6 + residual * 0.4;
mat.opacity = n.alpha; // `stateAlpha` is presence — a pending station has not happened yet, so
mat.transparent = n.alpha < 0.99; // solid when present; only fades on appear/disappear // it is drawn faint. Multiplied with `alpha` rather than replacing it:
// alpha is the idle-fade lifecycle, and collapsing the two would let
// the fade erase a pending phase (or a pending phase defeat the fade).
const sa = n.stateAlpha ?? 1;
mat.opacity = n.alpha * sa;
mat.transparent = mat.opacity < 0.99; // solid when present; only fades on appear/disappear
let g = nodeGlows.get(n.id); let g = nodeGlows.get(n.id);
if (!g) { if (!g) {
g = newGlow(gourceGroup); g = newGlow(gourceGroup);
@@ -834,12 +864,39 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
n.tier === "team" || n.tier === "team" ||
n.tier === "mission" || n.tier === "mission" ||
n.tier === "phase"; n.tier === "phase";
gm.opacity = (struct ? 0.08 + n.heat * 0.9 : 0.22 + n.heat * 0.8) * n.alpha; gm.opacity = (struct ? 0.08 + n.heat * 0.9 : 0.22 + n.heat * 0.8) * n.alpha * sa;
if (effects && n.heat > 0.35 && Math.random() < n.heat * dt * 8) { if (effects && n.heat > 0.35 && Math.random() < n.heat * dt * 8) {
const a = Math.random() * Math.PI * 2; const a = Math.random() * Math.PI * 2;
const sp = 18 + Math.random() * 30; const sp = 18 + Math.random() * 30;
spawn(n.x, n.y, 0, n.color, Math.cos(a) * sp, Math.sin(a) * sp, 0, 1.7 + Math.random()); spawn(n.x, n.y, 0, n.color, Math.cos(a) * sp, Math.sin(a) * sp, 0, 1.7 + Math.random());
} }
// Settle ring — a terminal phase. This is the one new primitive in the
// status encoding, and it earns its place: brightness alone cannot say
// "finished" as distinct from "not started yet", because both are dim.
if (n.settledColor) {
let ring = ringMeshes.get(n.id);
if (!ring) {
ring = new THREE.Mesh(
ringGeo,
new THREE.MeshBasicMaterial({ transparent: true, side: THREE.DoubleSide }),
);
ringMeshes.set(n.id, ring);
gourceGroup.add(ring);
}
const rs = r * 1.5;
ring.position.set(n.x, n.y, 0.5);
ring.scale.set(rs, rs, 1);
const rm = ring.material as THREE.MeshBasicMaterial;
rm.color.set(n.settledColor);
rm.opacity = 0.75 * n.alpha * sa;
} else if (ringMeshes.has(n.id)) {
// Status can move backwards (a phase re-entering `running` on a
// retry), so the ring has to be removable, not just addable.
const ring = ringMeshes.get(n.id)!;
gourceGroup.remove(ring);
(ring.material as THREE.Material).dispose();
ringMeshes.delete(n.id);
}
// explosive burst on a file/project op (touch weight → node.burst) // explosive burst on a file/project op (touch weight → node.burst)
if (n.burst > 0) { if (n.burst > 0) {
const count = Math.floor(8 + n.burst * 30); const count = Math.floor(8 + n.burst * 30);
@@ -866,6 +923,15 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
(g.material as THREE.Material).dispose(); (g.material as THREE.Material).dispose();
nodeGlows.delete(id); nodeGlows.delete(id);
} }
// Rings live in the same lifecycle as the mesh they orbit. Missing
// this leaks a ring for every phase that ever leaves the scene, and
// it would keep drawing at the node's last position.
const ring = ringMeshes.get(id);
if (ring) {
gourceGroup.remove(ring);
(ring.material as THREE.Material).dispose();
ringMeshes.delete(id);
}
} }
} }
@@ -1281,6 +1347,16 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
{hud.agents} agent{hud.agents === 1 ? "" : "s"} {hud.agents} agent{hud.agents === 1 ? "" : "s"}
</span> </span>
<span style={{ color: "#5ec8d8" }}>{hud.active} active</span> <span style={{ color: "#5ec8d8" }}>{hud.active} active</span>
{hud.phases ? (
<span>
{hud.done}/{hud.phases} phase{hud.phases === 1 ? "" : "s"}
</span>
) : null}
{hud.stale ? (
<span style={{ color: "#e8b465" }} title="running by status, but no events landing">
{hud.stale} quiet
</span>
) : null}
{telemetry.tokensPerMin ? <span>{Math.round(telemetry.tokensPerMin / 1000)}k tok/m</span> : null} {telemetry.tokensPerMin ? <span>{Math.round(telemetry.tokensPerMin / 1000)}k tok/m</span> : null}
{telemetry.doorsPending ? ( {telemetry.doorsPending ? (
<span style={{ color: "#e8b465" }}> <span style={{ color: "#e8b465" }}>
+103 -3
View File
@@ -34,6 +34,21 @@ export interface GNode {
alpha: number; // fade in/out (world nodes) alpha: number; // fade in/out (world nodes)
lastSeen: number; lastSeen: number;
fixed?: boolean; fixed?: boolean;
/** Floor the heat decays TO rather than 0 — how a `running` phase stays lit
* without a stream of events. Everything downstream (emissive, radius, glow,
* sparks) is already heat-driven, so one floor lights the whole treatment. */
heatFloor?: number;
/** Presence multiplier applied at render: a `pending` station is drawn faint
* because it has not happened yet. Kept separate from `alpha`, which is the
* idle-fade lifecycle — conflating them would let the fade loop erase a
* pending phase, or a pending phase defeat the fade. */
stateAlpha?: number;
/** Set on a terminal phase; the renderer draws a ring in this colour. Absence
* means "no ring", which is why it is the ring's only trigger. */
settledColor?: string;
/** Last time a REAL event landed on this node. Drives staleness — see the
* decay in `step`. */
lastActivityAt?: number;
/** Cumulative touch count (per session). Grows monotonically on every /** Cumulative touch count (per session). Grows monotonically on every
* world.touch that lands here. Used by the render to compute a * world.touch that lands here. Used by the render to compute a
* persistent "heat map" tint so files touched many times stay visibly * persistent "heat map" tint so files touched many times stay visibly
@@ -104,16 +119,56 @@ export interface CommEdge {
* before it fades to nothing (roughly this many seconds). */ * before it fades to nothing (roughly this many seconds). */
const COMM_EDGE_DECAY_PER_SEC = 1 / 30; const COMM_EDGE_DECAY_PER_SEC = 1 / 30;
/** Seconds of silence on a running phase before it starts dimming. Long enough
* that a slow turn (a model thinking, a long test run) does not flicker. */
const STALE_AFTER = 90;
/** The ember a stale station sinks to — visible, clearly not working. */
const STALE_FLOOR = 0.15;
/// A mission's shape, as the World draws it: the centre plus its ordered /// A mission's shape, as the World draws it: the centre plus its ordered
/// stations. Assembled by the client from `mission.update` / `mission.phase` /// stations. Assembled by the client from `mission.update` / `mission.phase`
/// (and the REST plan poll) so both sources land in one setter. /// (and the REST plan poll) so both sources land in one setter.
export type PhaseStatus =
| "pending"
| "running"
| "evaluating"
| "completed"
| "failed"
| "skipped";
export interface MissionPlan { export interface MissionPlan {
missionId: string; missionId: string;
title: string; title: string;
color?: string; color?: string;
phases: { phaseId: string; label: string; orderIdx: number; color?: string }[]; /** Terminal mission ⇒ the scene is a map to read, not a run to watch. */
finished?: boolean;
phases: {
phaseId: string;
label: string;
orderIdx: number;
status: PhaseStatus;
color?: string;
}[];
} }
/// How a phase's status reads on screen.
///
/// `alpha` is presence (has this happened?), `floor` is life (is it happening
/// now?), `ring` is settlement (did it finish, and how?). Three independent
/// questions, three independent channels — encoding them all as brightness
/// would make "not started" and "finished" indistinguishable.
const PHASE_STATE: Record<
PhaseStatus,
{ alpha: number; floor: number; ring?: string }
> = {
pending: { alpha: 0.28, floor: 0 },
running: { alpha: 1, floor: 0.55 },
evaluating: { alpha: 1, floor: 0.4, ring: "#e8b465" },
completed: { alpha: 1, floor: 0, ring: "#5fd08a" },
failed: { alpha: 1, floor: 0, ring: "#ff5f57" },
skipped: { alpha: 0.18, floor: 0, ring: "#8a8a92" },
};
export interface WorldSeed { export interface WorldSeed {
id: string; id: string;
level: string; // "org" | "company" | "team" | "claw" level: string; // "org" | "company" | "team" | "claw"
@@ -161,6 +216,9 @@ export class WorldEngine {
private homes = new Map<string, string>(); // agentId → its resting node private homes = new Map<string, string>(); // agentId → its resting node
/// Idle drift. Off under a mission scope — see `setRoam`. /// Idle drift. Off under a mission scope — see `setRoam`.
private roam = true; private roam = true;
/// A finished mission is a map, not a run: motion stops and the idle-fade
/// eviction is skipped so the map survives to be read.
frozen = false;
private now = 0; private now = 0;
private pawnIdx = 0; private pawnIdx = 0;
@@ -242,7 +300,28 @@ export class WorldEngine {
n.label = ph.label; n.label = ph.label;
n.lastSeen = this.now; n.lastSeen = this.now;
if (ph.color) n.color = ph.color; if (ph.color) n.color = ph.color;
// Mutable state, written directly: `ensureNode` is first-write-wins and
// would keep a phase looking `pending` for the whole run.
const st = PHASE_STATE[ph.status] ?? PHASE_STATE.pending;
n.stateAlpha = st.alpha;
n.settledColor = st.ring;
if (ph.status === "running") {
// Set the floor once, on first sight, and let the staleness rule in
// `step` own it from then on. Re-applying it every poll would relight a
// station whose agents died — the plan refresh would undo the decay,
// and "still running" would look identical to "actually working".
if (n.heatFloor == null) {
n.heatFloor = st.floor;
n.lastActivityAt = this.now;
}
} else {
// Terminal and pending states are authoritative: nothing is happening,
// so there is no live signal for the floor to contradict.
n.heatFloor = st.floor;
}
}); });
if (plan.finished) this.frozen = true;
} }
/// Where a pawn rests when it is not touching anything. /// Where a pawn rests when it is not touching anything.
@@ -387,6 +466,7 @@ export class WorldEngine {
// +0.5 per touch which turned every repeatedly-touched node into a // +0.5 per touch which turned every repeatedly-touched node into a
// permanent hot spot regardless of intent. // permanent hot spot regardless of intent.
node.heat = Math.min(1, node.heat + w * 0.6); node.heat = Math.min(1, node.heat + w * 0.6);
node.lastActivityAt = this.now;
node.burst = Math.max(node.burst, w); // file ops (weight 1) → explosive burst node.burst = Math.max(node.burst, w); // file ops (weight 1) → explosive burst
// Persistent per-file heat map — tracks TOTAL touches so hot files // Persistent per-file heat map — tracks TOTAL touches so hot files
// stay visibly warmer even after `heat` decays (see the render's // stay visibly warmer even after `heat` decays (see the render's
@@ -540,7 +620,21 @@ export class WorldEngine {
n.vy *= friction; n.vy *= friction;
n.x += n.vx; n.x += n.vx;
n.y += n.vy; n.y += n.vy;
n.heat = Math.max(0, n.heat - dt * 0.5); // Decay toward the state floor, not to zero: a `running` station stays
// lit between events.
n.heat = Math.max(n.heatFloor ?? 0, n.heat - dt * 0.5);
// ...but a floor with nothing behind it is a lie. A phase whose agents
// died still reads `running` in the database, and drawing it confidently
// lit is exactly the failure this codebase keeps hitting: something that
// looks alive because a status field says so. After STALE_AFTER seconds
// with no real event on this node, the floor sinks to a dim ember, so a
// busy station and an abandoned one cannot look identical.
if (n.heatFloor != null && n.heatFloor > STALE_FLOOR) {
const quiet = this.now - (n.lastActivityAt ?? n.lastSeen);
if (quiet > STALE_AFTER) {
n.heatFloor = Math.max(STALE_FLOOR, n.heatFloor - dt * 0.25);
}
}
} }
// world nodes fade out when idle (Gource file-idle), then are removed. // world nodes fade out when idle (Gource file-idle), then are removed.
@@ -551,6 +645,10 @@ export class WorldEngine {
// quietly disappear rather than hang around forever. The longer // quietly disappear rather than hang around forever. The longer
// window keeps a brief SSE hiccup from evicting the workspace. // window keeps a brief SSE hiccup from evicting the workspace.
for (const n of arr) { for (const n of arr) {
// A finished mission is a map to read. The idle-fade would evict its
// stations and files within 22-60s of the last event — erasing exactly
// what the viewer opened it to look at.
if (this.frozen) break;
const shortLived = n.tier === "service" || n.tier === "event"; const shortLived = n.tier === "service" || n.tier === "event";
const landmark = n.tier === "mission" || n.tier === "phase"; const landmark = n.tier === "mission" || n.tier === "phase";
if (shortLived || landmark) { if (shortLived || landmark) {
@@ -599,7 +697,9 @@ export class WorldEngine {
const target = p.targetId ? this.nodes.get(p.targetId) : undefined; const target = p.targetId ? this.nodes.get(p.targetId) : undefined;
const home = p.homeId ? this.nodes.get(p.homeId) : undefined; const home = p.homeId ? this.nodes.get(p.homeId) : undefined;
const dest = target ?? home ?? this.nodes.get(ROOT)!; const dest = target ?? home ?? this.nodes.get(ROOT)!;
const ang = this.now * 1.4 + (p.id.charCodeAt(0) || 0); // Frozen: pawns settle at fixed angles around their last station rather
// than orbiting forever, so a finished map is still rather than restless.
const ang = (this.frozen ? 0 : this.now * 1.4) + (p.id.charCodeAt(0) || 0);
const ox = dest.x + Math.cos(ang) * (dest.r + 22); const ox = dest.x + Math.cos(ang) * (dest.r + 22);
const oy = dest.y + Math.sin(ang) * (dest.r + 22); const oy = dest.y + Math.sin(ang) * (dest.r + 22);
p.vx += (ox - p.x) * dt * 3; p.vx += (ox - p.x) * dt * 3;