Show task, active project and workspace per control board row (#1503)
Gate A fix asked by the professor session on Jason's behalf. Each row
now carries three derived fields, shown as "unknown" when the log and
tmux do not hold them:
- task: the session's first user message (pi logs have no task envelope)
- workspace: the live pane path of the pane running pi, else session cwd
- activeProject: basename of the nearest git checkout above the workspace
tmuxInspect replaces the bare liveness call in the CLI and returns
{ alive, workspace }; tmuxIsAlive stays as a wrapper. The grouping column
and seen.json keys are unchanged. Fixture test per field, tmux parse
tests, page test; missing launcher signals are recorded in the plan page.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -25,7 +25,10 @@ import {
|
||||
discoverFleetAgents,
|
||||
scan,
|
||||
panesRunPi,
|
||||
parsePanes,
|
||||
tmuxInspect,
|
||||
tmuxIsAlive,
|
||||
findRepoRoot,
|
||||
loadSeen,
|
||||
markSeen,
|
||||
} from "../src/scan.mjs";
|
||||
@@ -299,6 +302,117 @@ test("rule: a finished turn (text-only assistant message, stopReason stop) is wa
|
||||
assert.equal(rec.waitingOnYou, true);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4c. Gate A: task / workspace / active project per row, derived or "unknown"
|
||||
// (docs/plans/2026-09-12_control-board-mvp.md, Step 3 log). One fixture per field.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GATE_NOW = () => new Date("2026-09-12T14:00:10Z");
|
||||
|
||||
test("task: the first user message of the session, from text blocks", () => {
|
||||
const root = makeRoot();
|
||||
writeSessionFile(join(root, "sessions"), "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: "2026-09-12T14:00:01Z", role: "user", texts: ["[orch-01 -> code-be-01 class=actionable] T-H2-RE: fix #1466"] }),
|
||||
messageLine({ timestamp: "2026-09-12T14:00:02Z", role: "assistant", stopReason: "stop", texts: ["On it."] }),
|
||||
messageLine({ timestamp: "2026-09-12T14:00:03Z", role: "user", texts: ["and then report back"] }),
|
||||
messageLine({ timestamp: "2026-09-12T14:00:04Z", role: "assistant", stopReason: "stop", texts: ["Done."] }),
|
||||
]);
|
||||
const rec = scanAgent(ruleSpec(root), { isAlive: () => true, now: GATE_NOW });
|
||||
assert.equal(rec.task, "[orch-01 -> code-be-01 class=actionable] T-H2-RE: fix #1466");
|
||||
assert.equal(rec.taskSource, "first-user-message");
|
||||
});
|
||||
|
||||
test("task: a plain-string user content is accepted, whitespace collapsed and long text capped", () => {
|
||||
const root = makeRoot();
|
||||
const long = "orchestrate " + "x".repeat(300);
|
||||
const line = JSON.stringify({ type: "message", timestamp: "2026-09-12T14:00:01Z", message: { role: "user", content: " " + long + "\n\n" } });
|
||||
writeSessionFile(join(root, "sessions"), "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" }),
|
||||
line,
|
||||
]);
|
||||
const rec = scanAgent(ruleSpec(root), { isAlive: () => true, now: GATE_NOW });
|
||||
assert.equal(rec.task, collapseExpected(long));
|
||||
});
|
||||
|
||||
test("task: no user message in the log means null (shown as unknown), never a guess", () => {
|
||||
const root = makeRoot();
|
||||
writeSessionFile(join(root, "sessions"), "s.jsonl", [sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" })]);
|
||||
const rec = scanAgent(ruleSpec(root), { isAlive: () => true, now: GATE_NOW });
|
||||
assert.equal(rec.task, null);
|
||||
assert.equal(rec.taskSource, null);
|
||||
const none = scanAgent(ruleSpec(makeRoot()), { isAlive: () => true, now: GATE_NOW });
|
||||
assert.equal(none.task, null);
|
||||
});
|
||||
|
||||
test("workspace: the live tmux pane path wins; the session cwd is the fallback; neither means null", () => {
|
||||
const root = makeRoot();
|
||||
writeSessionFile(join(root, "sessions"), "s.jsonl", [sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/from/log" })]);
|
||||
const fromTmux = scanAgent(ruleSpec(root), { isAlive: () => ({ alive: true, workspace: "/from/tmux" }), now: GATE_NOW });
|
||||
assert.equal(fromTmux.workspace, "/from/tmux");
|
||||
assert.equal(fromTmux.workspaceSource, "tmux-pane");
|
||||
assert.equal(fromTmux.alive, true);
|
||||
assert.equal(fromTmux.cwd, "/from/log");
|
||||
|
||||
const fromLog = scanAgent(ruleSpec(root), { isAlive: () => true, now: GATE_NOW });
|
||||
assert.equal(fromLog.workspace, "/from/log");
|
||||
assert.equal(fromLog.workspaceSource, "session-cwd");
|
||||
|
||||
const offline = scanAgent(ruleSpec(root), { isAlive: () => ({ alive: false, workspace: null }), now: GATE_NOW });
|
||||
assert.equal(offline.state, "offline");
|
||||
assert.equal(offline.workspace, "/from/log");
|
||||
|
||||
const nothing = scanAgent(ruleSpec(makeRoot()), { isAlive: () => ({ alive: true, workspace: null }), now: GATE_NOW });
|
||||
assert.equal(nothing.workspace, null);
|
||||
assert.equal(nothing.workspaceSource, null);
|
||||
});
|
||||
|
||||
test("activeProject: basename of the nearest .git directory or .git file above the workspace; none means null", () => {
|
||||
const root = makeRoot();
|
||||
const repo = join(root, "repos", "my-repo");
|
||||
mkdirSync(join(repo, ".git"), { recursive: true });
|
||||
mkdirSync(join(repo, "packages", "deep"), { recursive: true });
|
||||
const worktree = join(root, "repos", "my-worktree");
|
||||
mkdirSync(join(worktree, "sub"), { recursive: true });
|
||||
writeFileSync(join(worktree, ".git"), "gitdir: /elsewhere\n");
|
||||
const plain = join(root, "plain", "dir");
|
||||
mkdirSync(plain, { recursive: true });
|
||||
|
||||
assert.equal(findRepoRoot(join(repo, "packages", "deep")), repo);
|
||||
assert.equal(findRepoRoot(join(worktree, "sub")), worktree);
|
||||
assert.equal(findRepoRoot("relative/path"), null);
|
||||
|
||||
const inRepo = scanAgent(ruleSpec(root), { isAlive: () => ({ alive: true, workspace: join(repo, "packages", "deep") }), now: GATE_NOW });
|
||||
assert.equal(inRepo.activeProject, "my-repo");
|
||||
const inWorktree = scanAgent(ruleSpec(root), { isAlive: () => ({ alive: true, workspace: join(worktree, "sub") }), now: GATE_NOW });
|
||||
assert.equal(inWorktree.activeProject, "my-worktree");
|
||||
// The group column (spec.project) is untouched; seen.json keys depend on it.
|
||||
assert.equal(inWorktree.project, "p");
|
||||
const noRepo = scanAgent(ruleSpec(root), { isAlive: () => ({ alive: true, workspace: plain }), now: GATE_NOW });
|
||||
// A temp dir may sit under a git checkout on some machines; only assert when it does not.
|
||||
if (findRepoRoot(plain) === null) assert.equal(noRepo.activeProject, null);
|
||||
const noWorkspace = scanAgent(ruleSpec(root), { isAlive: () => ({ alive: true, workspace: null }), now: GATE_NOW });
|
||||
assert.equal(noWorkspace.activeProject, null);
|
||||
});
|
||||
|
||||
test("scan: the written record carries task, workspace and activeProject", () => {
|
||||
const root = makeRoot();
|
||||
const boardDir = join(root, "board");
|
||||
const sessionsDir = join(root, "sessions");
|
||||
writeSessionFile(sessionsDir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: "2026-09-12T14:00:01Z", role: "user", texts: ["resume"] }),
|
||||
]);
|
||||
const index = scan([{ agent: "a", project: "p", sessionsDir, tmux: {} }], { boardDir, isAlive: () => ({ alive: true, workspace: null }), now: GATE_NOW });
|
||||
const rec = index.sessions[0];
|
||||
assert.equal(rec.task, "resume");
|
||||
assert.equal(rec.workspace, "/w");
|
||||
assert.equal("activeProject" in rec, true);
|
||||
const onDisk = JSON.parse(readFileSync(join(boardDir, "sessions", "p", "a.json"), "utf8"));
|
||||
assert.equal(onDisk.task, "resume");
|
||||
assert.equal(onDisk.workspace, "/w");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. scanAgent
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -573,11 +687,32 @@ test("tmuxIsAlive: passes -L <socket> only when a socket is given", () => {
|
||||
const withSocket = fakeExec({ status: 0, stdout: "pi\n" });
|
||||
tmuxIsAlive({ socket: "mosaic-fleet", session: "name" }, { exec: withSocket });
|
||||
assert.equal(withSocket.calls[0].cmd, "tmux");
|
||||
assert.deepEqual(withSocket.calls[0].args, ["-L", "mosaic-fleet", "list-panes", "-s", "-t", "=name", "-F", "#{pane_current_command}"]);
|
||||
assert.deepEqual(withSocket.calls[0].args, ["-L", "mosaic-fleet", "list-panes", "-s", "-t", "=name", "-F", "#{pane_current_command}\t#{pane_current_path}"]);
|
||||
|
||||
const noSocket = fakeExec({ status: 0, stdout: "pi\n" });
|
||||
tmuxIsAlive({ socket: null, session: "name" }, { exec: noSocket });
|
||||
assert.deepEqual(noSocket.calls[0].args, ["list-panes", "-s", "-t", "=name", "-F", "#{pane_current_command}"]);
|
||||
assert.deepEqual(noSocket.calls[0].args, ["list-panes", "-s", "-t", "=name", "-F", "#{pane_current_command}\t#{pane_current_path}"]);
|
||||
});
|
||||
|
||||
test("parsePanes: one pane per line, command and optional tab-separated path", () => {
|
||||
assert.deepEqual(parsePanes("bash\t/home/x\npi\t/mnt/repo\n"), [
|
||||
{ command: "bash", path: "/home/x" },
|
||||
{ command: "pi", path: "/mnt/repo" },
|
||||
]);
|
||||
assert.deepEqual(parsePanes("pi\n"), [{ command: "pi", path: null }]);
|
||||
assert.deepEqual(parsePanes(""), []);
|
||||
});
|
||||
|
||||
test("tmuxInspect: reports the path of the pane running pi, not of a shell pane", () => {
|
||||
const exec = fakeExec({ status: 0, stdout: "bash\t/home/x\npi\t/mnt/repo/sub\n" });
|
||||
assert.deepEqual(tmuxInspect({ socket: "mosaic-fleet", session: "a" }, { exec }), { alive: true, workspace: "/mnt/repo/sub" });
|
||||
assert.deepEqual(exec.calls[0].args, ["-L", "mosaic-fleet", "list-panes", "-s", "-t", "=a", "-F", "#{pane_current_command}\t#{pane_current_path}"]);
|
||||
});
|
||||
|
||||
test("tmuxInspect: no pi pane, no session, or no tmux gives no workspace and the matching liveness", () => {
|
||||
assert.deepEqual(tmuxInspect({ socket: null, session: "a" }, { exec: fakeExec({ status: 0, stdout: "bash\t/home/x\n" }) }), { alive: false, workspace: null });
|
||||
assert.deepEqual(tmuxInspect({ socket: null, session: "a" }, { exec: fakeExec({ status: 1, stdout: "" }) }), { alive: false, workspace: null });
|
||||
assert.deepEqual(tmuxInspect({ socket: null, session: "a" }, { exec: fakeExec({ error: new Error("ENOENT") }) }), { alive: null, workspace: null });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user