feat(viz): the coding station fractures into the files it worked on

The engine already turned `file:src/lib/a.ts` into a real dir chain, but
both copies of that loop rooted it at the origin — so a mission's files
floated beside the map instead of belonging to the work that produced
them. One `fileParent` helper now serves both call sites; splitting them
was how half the files could end up nesting correctly and half not,
decided by whichever code path saw the file first.

Files hang under the coding station when there is exactly one, else the
single running phase, else the origin. `world.touch` carries no phase id,
so with two coding phases any attribution is invented — the fallback is
the honest answer.

Two ordering hazards, both silent:
- the server emitted files BEFORE phases, so on the first pass a file
  arrived with no station to hang under and first-write-wins pinned its
  tree at the origin. Loops reordered, with a source-walk guard.
- `setFileHome` re-parents trees rooted before the plan landed, for the
  reconnect case the ordering alone cannot cover.

`mission.file` with `source: "tool"` is treated as motion (burst, pawn
beams); `"diff"` is end-of-phase truth and only marks the file present
and warm — bursting every file of a captured diff would set the whole
map alight at once on reconnect.

Directories taper in radius and opacity by path depth, so `src` and
`src/lib/live` no longer render as identical dots.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-11 08:29:55 -07:00
co-authored by Claude Opus 5
parent cb8184e784
commit c2fa8067e1
4 changed files with 249 additions and 50 deletions
+106
View File
@@ -176,6 +176,58 @@ async fn world_phases(pool: &PgPool, ws: WorkspaceId, only: Option<Uuid>) -> Vec
.collect() .collect()
} }
/// Files a phase touched, from the `code_diff` artifact captured at delivery.
///
/// This is recorded fact, not inference: `mission_delivery` writes the path
/// list with the same revision and excludes it uses for `files_changed`, so the
/// orbs the World draws are the files git says changed.
///
/// It is end-of-phase detail — the capture runs when a phase finishes — so a
/// running phase shows its station lit but no files until it lands. Live
/// per-tool file touches are a separate, structured source.
struct FileRow {
mission_id: String,
phase_id: String,
path: String,
status: String,
}
async fn world_files(pool: &PgPool, ws: WorkspaceId, only: Option<Uuid>) -> Vec<FileRow> {
let rows = sqlx::query(
"SELECT ma.mission_id::text AS mission_id,
ma.phase_id::text AS phase_id,
f->>'path' AS path,
f->>'status' AS status
FROM mission_artifacts ma
JOIN missions m ON m.id = ma.mission_id
CROSS JOIN LATERAL jsonb_array_elements(
COALESCE(ma.metadata->'files', '[]'::jsonb)) AS f
WHERE m.workspace_id = $1
AND ma.kind = 'code_diff'
AND ma.phase_id IS NOT NULL
AND ( m.status = 'running'
OR ( m.status IN ('completed', 'failed')
AND m.completed_at > now() - interval '24 hours' ) )
AND ($2::uuid IS NULL OR m.id = $2)
LIMIT 2000",
)
.bind(ws.as_uuid())
.bind(only)
.fetch_all(pool)
.await
.unwrap_or_default();
rows.into_iter()
.filter_map(|r| {
Some(FileRow {
mission_id: r.get::<String, _>("mission_id"),
phase_id: r.get::<String, _>("phase_id"),
path: r.get::<Option<String>, _>("path")?,
status: r.get::<Option<String>, _>("status").unwrap_or_default(),
})
})
.collect()
}
/// Agents that execute a phase of this kind, via the purposes the phase runner /// Agents that execute a phase of this kind, via the purposes the phase runner
/// itself uses. Returns empty for a teamless (microVM) mission. /// itself uses. Returns empty for a teamless (microVM) mission.
async fn phase_agents( async fn phase_agents(
@@ -495,6 +547,9 @@ pub async fn world_live(
std::collections::HashMap::new(); std::collections::HashMap::new();
let mut last_phase: std::collections::HashMap<String, String> = let mut last_phase: std::collections::HashMap<String, String> =
std::collections::HashMap::new(); std::collections::HashMap::new();
// (phase, path) pairs already announced — a delivered file is a fact
// that happened once, not a recurring event.
let mut last_file: HashSet<String> = HashSet::new();
// Audit-log cursor for edge-initiated inter-agent events (delegation, // Audit-log cursor for edge-initiated inter-agent events (delegation,
// A2A) that bypass the run loop. -1 until seeded on the first pass. // A2A) that bypass the run loop. -1 until seeded on the first pass.
let mut audit_cursor: i64 = -1; let mut audit_cursor: i64 = -1;
@@ -648,6 +703,33 @@ pub async fn world_live(
); );
} }
// Files come AFTER the phases they belong to, deliberately: the
// client hangs a file tree under the coding station, and parenting
// there is first-write-wins. A file that arrived before its plan
// would pin its whole directory tree at the origin.
//
// Files, once each. A file is emitted when its phase's diff was
// captured and never again — the World keeps the node alive itself,
// and re-sending would re-burst the orb every poll as though the
// file had just been touched again.
for f in world_files(&pool, ws, only_mission).await {
let key = format!("{}|{}", f.phase_id, f.path);
if last_file.contains(&key) {
continue;
}
last_file.insert(key);
yield sse(
"mission.file",
json!({
"missionId": f.mission_id,
"phaseId": f.phase_id,
"path": f.path,
"status": f.status,
"source": "diff",
}),
);
}
// Real convergence: each running agent beams toward its active-run node. // Real convergence: each running agent beams toward its active-run node.
for (run_id, agent_id) in &runs { for (run_id, agent_id) in &runs {
let node_id = format!("run:{}", &run_id[..run_id.len().min(8)]); let node_id = format!("run:{}", &run_id[..run_id.len().min(8)]);
@@ -913,4 +995,28 @@ mod mission_feed_tests {
disagree with the machine about who is working on what" disagree with the machine about who is working on what"
); );
} }
/// Files must be emitted after the phases they hang under.
///
/// The client parents a file's directory chain to the coding station, and
/// parenting is first-write-wins — so a file that reaches the browser
/// before its plan pins its whole tree at the origin permanently. Nothing
/// errors: the tree renders, in the wrong place, and reads as a layout
/// choice. Swapping the two loops back is a one-line-looking edit, which is
/// exactly why it needs a guard.
#[test]
fn files_are_emitted_after_the_phases_they_hang_under() {
let src = include_str!("world.rs");
let phases = src
.find("for p in world_phases(")
.expect("the phase emission loop");
let files = src
.find("for f in world_files(")
.expect("the file emission loop");
assert!(
phases < files,
"the phase loop must run before the file loop; files parented \
before their coding station stay at the origin forever"
);
}
} }
+22 -7
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 PhaseStatus, type WorldSeed } from "./engine"; import { WorldEngine, type Formation, type GNode, type MissionPlan, 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> };
@@ -152,10 +152,10 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
missionId: string | null; missionId: string | null;
title: string; title: string;
finished: boolean; finished: boolean;
phases: Map< // Derived from the engine's own type rather than restated: this map is
string, // pushed straight into `applyMissionPlan`, and a restated literal silently
{ phaseId: string; label: string; orderIdx: number; status: PhaseStatus; color?: string } // drops any field the engine later starts reading.
>; phases: Map<string, MissionPlan["phases"][number]>;
}>({ missionId: null, title: "", finished: false, phases: new Map() }); }>({ missionId: null, title: "", finished: false, phases: new Map() });
useEffect(() => { useEffect(() => {
onSelectRef.current = onSelect; onSelectRef.current = onSelect;
@@ -296,6 +296,7 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
orderIdx: d.orderIdx, orderIdx: d.orderIdx,
status: d.status, status: d.status,
color: pal.phase[d.kind], color: pal.phase[d.kind],
kind: 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
// pawn homes to the mission centre and the scene is a clump no matter // pawn homes to the mission centre and the scene is a clump no matter
@@ -303,6 +304,13 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
for (const a of d.agentIds ?? []) e.setHome(a, `phase:${d.phaseId}`); for (const a of d.agentIds ?? []) e.setHome(a, `phase:${d.phaseId}`);
pushPlan(); pushPlan();
}), }),
// The files a phase actually touched. Scoped by mission id, not by
// agent: a microVM phase has no platform agent to filter on, and its
// files are exactly the ones worth seeing.
live.on("mission.file", (d) => {
if (focusMissionId && d.missionId !== focusMissionId) return;
e.onMissionFile(d);
}),
live.on("room.message", (d) => { live.on("room.message", (d) => {
if (!d.fromAgentId || !inFocus(d.fromAgentId)) return; if (!d.fromAgentId || !inFocus(d.fromAgentId)) return;
for (const pid of d.participantIds ?? []) { for (const pid of d.participantIds ?? []) {
@@ -830,7 +838,14 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
} }
// touchCount → log-scaled residual heat that never decays. // touchCount → log-scaled residual heat that never decays.
const residual = Math.min(0.7, Math.log(1 + (n.touchCount ?? 0)) / Math.log(50)); const residual = Math.min(0.7, Math.log(1 + (n.touchCount ?? 0)) / Math.log(50));
const r = n.r * (1 + n.heat * 0.4 + residual * 0.15); // Directory taper. `src` and `src/lib/live` are both plain service
// orbs, so a deep tree rendered as a field of equal dots and the
// nesting was invisible. Taper by path depth rather than adding a
// tier: the depth is already in the id, and a new tier would need a
// colour, radius and fade of its own everywhere.
const dirDepth = n.id.startsWith("dir:") ? n.id.slice(4).split("/").length : 0;
const taper = dirDepth > 1 ? 1 / (1 + 0.3 * (dirDepth - 1)) : 1;
const r = n.r * (1 + n.heat * 0.4 + residual * 0.15) * taper;
m.position.set(n.x, n.y, 0); m.position.set(n.x, n.y, 0);
m.scale.set(r, r, r); m.scale.set(r, r, r);
const mat = m.material as THREE.MeshStandardMaterial; const mat = m.material as THREE.MeshStandardMaterial;
@@ -842,7 +857,7 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
// it is drawn faint. Multiplied with `alpha` rather than replacing it: // it is drawn faint. Multiplied with `alpha` rather than replacing it:
// alpha is the idle-fade lifecycle, and collapsing the two would let // 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). // the fade erase a pending phase (or a pending phase defeat the fade).
const sa = n.stateAlpha ?? 1; const sa = (n.stateAlpha ?? 1) * (dirDepth > 1 ? 0.55 + 0.45 * taper : 1);
mat.opacity = n.alpha * sa; mat.opacity = n.alpha * sa;
mat.transparent = mat.opacity < 0.99; // solid when present; only fades on appear/disappear 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);
+107 -43
View File
@@ -148,6 +148,8 @@ export interface MissionPlan {
orderIdx: number; orderIdx: number;
status: PhaseStatus; status: PhaseStatus;
color?: string; color?: string;
/** Raw phase kind. Files hang under the coding station — see `filesRoot`. */
kind?: string;
}[]; }[];
} }
@@ -214,6 +216,8 @@ export class WorldEngine {
commEdges = new Map<string, CommEdge>(); commEdges = new Map<string, CommEdge>();
formation: Formation = "live"; formation: Formation = "live";
private homes = new Map<string, string>(); // agentId → its resting node private homes = new Map<string, string>(); // agentId → its resting node
/// The node file trees hang from — see `filesRoot`. Null means the origin.
private fileHome: string | null = null;
/// 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 /// A finished mission is a map, not a run: motion stops and the idle-fade
@@ -251,6 +255,68 @@ export class WorldEngine {
}); });
} }
/// Parent, depth and label for a node id, synthesizing the `dir:` chain for
/// `file:` paths so a codebase renders as a real tree rather than flat leaves.
///
/// `onTouch` and `onNodeActivity` each carried their own copy of this loop.
/// Parenting is first-write-wins, so changing one and not the other would
/// leave half the files nesting under the coding station and half at the
/// origin — a layout that looks plausible and is really an artifact of which
/// code path happened to see the file first.
private fileParent(
nodeId: string,
fallbackLabel: string,
): { parentId: string; depth: number; label: string } {
const root = this.filesRoot();
const base = this.nodes.get(root)?.depth ?? 0;
if (!nodeId.startsWith("file:")) {
return { parentId: root, depth: base + 1, label: fallbackLabel };
}
const path = nodeId.slice(5);
const segs = path.split("/").filter((s) => s.length > 0);
let parentId = root;
let depth = base + 1;
let acc = "";
for (let i = 0; i < segs.length - 1; i++) {
const seg = segs[i];
acc = acc ? `${acc}/${seg}` : seg;
const dirId = `dir:${acc}`;
this.ensureNode(dirId, "service", seg, parentId, base + i + 1);
parentId = dirId;
depth = base + i + 2;
}
return { parentId, depth, label: segs[segs.length - 1] || path };
}
/// Where file trees hang: the coding station when there is exactly one, so a
/// file orb reads as part of THAT work.
///
/// With two coding phases the attribution would be a guess — `world.touch`
/// carries no phase id to settle it — so it falls back to the root rather
/// than inventing an answer.
private filesRoot(): string {
return this.fileHome && this.nodes.has(this.fileHome) ? this.fileHome : ROOT;
}
/// Set by `applyMissionPlan` when the mission has one unambiguous coding
/// station; cleared otherwise.
setFileHome(nodeId: string | null) {
const prev = this.filesRoot();
this.fileHome = nodeId;
const next = this.filesRoot();
if (next === prev) return;
// Re-parent trees that were rooted before the plan arrived. Parenting is
// first-write-wins, so a file event that beat the plan onto the wire would
// otherwise pin its whole directory tree at the origin FOREVER — and the
// result reads as a deliberate layout rather than a race.
for (const n of this.nodes.values()) {
if (n.parentId !== prev) continue;
if (!n.id.startsWith("dir:") && !n.id.startsWith("file:")) continue;
n.parentId = next;
n.depth = (this.nodes.get(next)?.depth ?? 0) + 1;
}
}
/** Build the structure tree from the world roots; claws become pawns. */ /** Build the structure tree from the world roots; claws become pawns. */
seed(roots: WorldSeed[]) { seed(roots: WorldSeed[]) {
const walk = (item: WorldSeed, parentId: string, depth: number) => { const walk = (item: WorldSeed, parentId: string, depth: number) => {
@@ -321,9 +387,48 @@ export class WorldEngine {
n.heatFloor = st.floor; n.heatFloor = st.floor;
} }
}); });
// Where files hang. Exactly one coding station ⇒ its files belong to that
// work and nest under it. Two coding phases and a `world.touch` cannot say
// which one it belongs to (it carries no phase id), so rather than split
// the tree on a guess we fall back to the running phase, then to the
// origin. A wrong parent is worse than a neutral one: it reads as an
// assertion about which phase edited the file.
const coding = ordered.filter((p) => p.kind === "coding");
const running = ordered.filter((p) => p.status === "running");
const home =
coding.length === 1 ? coding[0] : running.length === 1 ? running[0] : null;
this.setFileHome(home ? `phase:${home.phaseId}` : null);
if (plan.finished) this.frozen = true; if (plan.finished) this.frozen = true;
} }
/// A file this phase touched. Distinct from `world.touch`: there is no agent
/// beaming to it and no burst — this is end-of-phase truth from the captured
/// diff, replayed for every file, so bursting each one would set the whole
/// map alight at once. It marks the file present and warm, nothing more.
onMissionFile(e: TaxonomyEvents["mission.file"]) {
const nodeId = `file:${e.path}`;
const { parentId, depth, label } = this.fileParent(nodeId, e.path);
const node = this.ensureNode(nodeId, "service", label, parentId, depth);
node.lastSeen = this.now;
node.touchCount = (node.touchCount ?? 0) + 1;
if (e.source === "tool") {
// A live tool touch IS motion — treat it like one.
node.heat = Math.min(1, node.heat + 0.6);
node.burst = Math.max(node.burst, 1);
node.lastActivityAt = this.now;
if (e.agentId) {
const p = this.ensurePawn(e.agentId);
p.targetId = nodeId;
p.idle = 0;
p.retime = 1.2 + Math.random() * 1.6;
}
} else {
node.heat = Math.max(node.heat, 0.25);
}
}
/// Where a pawn rests when it is not touching anything. /// Where a pawn rests when it is not touching anything.
/// ///
/// This is what dissolves the clump. `homes` was written only by `seed()`, so /// This is what dissolves the clump. `homes` was written only by `seed()`, so
@@ -432,28 +537,7 @@ export class WorldEngine {
this.ensurePawn(e.agentId, undefined, e.status, this.homes.get(e.agentId) ?? null); this.ensurePawn(e.agentId, undefined, e.status, this.homes.get(e.agentId) ?? null);
} }
onTouch(e: TaxonomyEvents["world.touch"]) { onTouch(e: TaxonomyEvents["world.touch"]) {
// file:<path> nodes get a directory hierarchy synthesized on demand const { parentId, depth, label } = this.fileParent(e.nodeId, e.nodeId);
// so the repo detail view builds a real tree as agents crawl the
// repo. dir:<partial> nodes are the parents; the leaf file gets the
// final segment as its label so the layout stays legible when many
// files share a prefix. Non-file nodes fall through unchanged.
let parentId: string = ROOT;
let depth = 1;
let label = e.nodeId;
if (e.nodeId.startsWith("file:")) {
const path = e.nodeId.slice(5);
const segs = path.split("/").filter((s) => s.length > 0);
let acc = "";
for (let i = 0; i < segs.length - 1; i++) {
const seg = segs[i];
acc = acc ? `${acc}/${seg}` : seg;
const dirId = `dir:${acc}`;
this.ensureNode(dirId, "service", seg, parentId, i + 1);
parentId = dirId;
depth = i + 2;
}
label = segs[segs.length - 1] || path;
}
const tier = tierFor(e.nodeId, e.kind); const tier = tierFor(e.nodeId, e.kind);
const node = this.ensureNode(e.nodeId, tier, label, parentId, depth); const node = this.ensureNode(e.nodeId, tier, label, parentId, depth);
const p = this.ensurePawn(e.agentId); const p = this.ensurePawn(e.agentId);
@@ -527,27 +611,7 @@ export class WorldEngine {
// radius treatment — otherwise the tier collapses to service and a mission // radius treatment — otherwise the tier collapses to service and a mission
// looks identical to a tool call. // looks identical to a tool call.
const tier = tierFor(e.nodeId, e.kind); const tier = tierFor(e.nodeId, e.kind);
// file:<path> events (e.g. pre-seeded from the SSE loop's repo tree const { parentId, depth, label } = this.fileParent(e.nodeId, e.label ?? e.nodeId);
// snapshot) also need the dir:<partial> chain synthesized, otherwise
// the tree renders as flat leaves under ROOT. Mirrors onTouch — kept
// as a small local helper so both paths agree on the layout.
let parentId: string = ROOT;
let depth = 1;
let label = e.label ?? e.nodeId;
if (e.nodeId.startsWith("file:")) {
const path = e.nodeId.slice(5);
const segs = path.split("/").filter((s) => s.length > 0);
let acc = "";
for (let i = 0; i < segs.length - 1; i++) {
const seg = segs[i];
acc = acc ? `${acc}/${seg}` : seg;
const dirId = `dir:${acc}`;
this.ensureNode(dirId, "service", seg, parentId, i + 1);
parentId = dirId;
depth = i + 2;
}
label = e.label ?? segs[segs.length - 1] ?? path;
}
const node = this.ensureNode(e.nodeId, tier, label, parentId, depth); const node = this.ensureNode(e.nodeId, tier, label, parentId, depth);
if (e.label) node.label = e.label; if (e.label) node.label = e.label;
if (e.heat != null) node.heat = Math.max(node.heat, e.heat); if (e.heat != null) node.heat = Math.max(node.heat, e.heat);
+14
View File
@@ -94,6 +94,19 @@ export interface TaxonomyEvents {
completedAt?: string | null; completedAt?: string | null;
agentIds: string[]; agentIds: string[];
}; };
/** World → a file this phase touched, so the coding station fractures into
* the sections actually worked on. `source: "diff"` is end-of-phase truth
* from the captured diff; `"tool"` is a live per-tool touch. Ephemeral: the
* engine keeps the node alive itself, and replaying these would re-burst
* every orb on reconnect. */
"mission.file": {
missionId: string;
phaseId: string;
agentId?: string;
path: string;
status?: string;
source?: "diff" | "tool";
};
/** World → re-layout (add/remove org units, agents, projects). */ /** World → re-layout (add/remove org units, agents, projects). */
"topology.update": { "topology.update": {
formation: "hierarchy" | "flat" | "live"; formation: "hierarchy" | "flat" | "live";
@@ -138,6 +151,7 @@ export const TAXONOMY_TYPES: TaxonomyType[] = [
"node.activity", "node.activity",
"mission.update", "mission.update",
"mission.phase", "mission.phase",
"mission.file",
"topology.update", "topology.update",
"telemetry", "telemetry",
"routine.update", "routine.update",