feat(board): session attention, Discord rows, task attribution and relaunch activity (rows 18, 22, #1511, #1512)
One cumulative control-board, webui and seat state. The four rows edit the
same files (scan.mjs, page.html, README.md, app.js), so they land together,
each on its own receipt:
- Row 18, Discord connector rows on the board (#1509): R3 approved by
Darkwing and Dewey, Gitea comment 26257, manifest 254403b8. Jason
accepted the visual test.
- Row 22, board attention status (#1503): Filbert approved R1, comment
26248, manifest e40b58ec; restart receipt 26249.
- #1511, task attribution (row 6 code phase): R2 approved by Filbert and
Dewey, manifest d4c96395. docs/TOOLS.md carries the approved --by usage
line (tools-usage.patch 86bcba3c).
- #1512, relaunch activity (row 6 pilot): R1 approved by Darkwing and
Dewey, candidate manifest 47769fad. All seven source files match it.
Row 16, internal development bootstrap (#1510): the seven files outside
shared records match Filbert's R1 pins, receipt 26204 (agents/researcher/*,
scripts/test-darkwing-launch.mjs, the bootstrap plan).
packages/webui/src/public/app.js is committed at its #1512 R1 pin ce7d79a4.
The working copy holds Dewey's unreviewed return-flow candidate on top of
that, and it stays uncommitted.
Also: the four row briefs and Darkwing's evidence records under
agents/darkwing/work, including the 2026-09-26 tree manifest and the #1512
re-run against 21e3e908. Serial acceptance command: 397/397, three runs.
The failures that only show when tests run concurrently are in #1509 engine
tests, and they reproduce on clean HEAD.
Suites on the exact staged tree: config 24, task 90, foundation 43,
conductor 17, release 14, auth 15, discord 63; package union 397/397
(serial); test-darkwing-launch 5/5.
Shared records (BUILD-LOG, QUEUE, CURRENT, DEFERRED, SESSIONS, AGENTS.md,
agents/README.md) follow in Sage's records commit.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -284,13 +284,13 @@ test("deriveState: full state table", () => {
|
||||
{ name: "alive=false is offline regardless of session", input: { alive: false, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "offline" },
|
||||
{ name: "no session is idle", input: { alive: true, session: null }, expected: "idle" },
|
||||
{ name: "no lastMessage is idle", input: { alive: true, session: { lastMessage: null } }, expected: "idle" },
|
||||
{ name: "assistant+stop is waiting", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "waiting" },
|
||||
{ name: "assistant+stop is idle", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "idle" },
|
||||
{ name: "assistant+error is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "error" } } }, expected: "error" },
|
||||
{ name: "assistant+aborted is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "aborted" } } }, expected: "error" },
|
||||
{ name: "assistant+toolUse is working", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "toolUse" } } }, expected: "working" },
|
||||
{ name: "assistant with a toolCall block is working even if stopReason says stop", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "Shall I?" }, TOOL_CALL] } } }, expected: "working" },
|
||||
{ name: "assistant+error with a toolCall block is still error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "error", content: [TOOL_CALL] } } }, expected: "error" },
|
||||
{ name: "assistant+stop with thinking and text (no tool call) is waiting", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop", content: [{ type: "thinking", thinking: "hm" }, { type: "text", text: "done" }] } } }, expected: "waiting" },
|
||||
{ name: "assistant+stop with thinking and text (no tool call) is idle", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop", content: [{ type: "thinking", thinking: "hm" }, { type: "text", text: "done" }] } } }, expected: "idle" },
|
||||
{ name: "user last is working", input: { alive: true, session: { lastMessage: { role: "user" } } }, expected: "working" },
|
||||
{ name: "toolResult last is working", input: { alive: true, session: { lastMessage: { role: "toolResult" } } }, expected: "working" },
|
||||
{ name: "assistant+length (cut off) is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "length" } } }, expected: "error" },
|
||||
@@ -335,7 +335,7 @@ test("rule: newest entry is a tool result with no assistant text after it is wor
|
||||
assert.equal(rec.waitingOnYou, false);
|
||||
});
|
||||
|
||||
test("rule: a finished turn (text-only assistant message, stopReason stop) is waiting", () => {
|
||||
test("rule: a finished ordinary turn is idle, even if it says your move", () => {
|
||||
const root = makeRoot();
|
||||
writeSessionFile(join(root, "sessions"), "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" }),
|
||||
@@ -344,8 +344,8 @@ test("rule: a finished turn (text-only assistant message, stopReason stop) is wa
|
||||
messageLine({ timestamp: "2026-09-12T14:00:04Z", role: "assistant", stopReason: "stop", texts: ["Done. Your move."] }),
|
||||
]);
|
||||
const rec = scanAgent(ruleSpec(root), { isAlive: () => true, now: () => new Date("2026-09-12T14:00:10Z") });
|
||||
assert.equal(rec.state, "waiting");
|
||||
assert.equal(rec.waitingOnYou, true);
|
||||
assert.equal(rec.state, "idle");
|
||||
assert.equal(rec.waitingOnYou, false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -764,7 +764,7 @@ test("scanAgent: waitingOnYou is true for waiting/error and false otherwise", ()
|
||||
mkdirSync(errorDir, { recursive: true });
|
||||
writeSessionFile(waitingDir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
||||
]);
|
||||
writeSessionFile(workingDir, "s.jsonl", [
|
||||
sessionLine({ id: "s2", timestamp: "2026-09-01T00:00:00Z", cwd: "/k" }),
|
||||
@@ -866,7 +866,7 @@ test("scan: writes per-agent files and index.json, rerun overwrites, no leftover
|
||||
sessionPath,
|
||||
[
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
||||
].join("\n") + "\n"
|
||||
);
|
||||
|
||||
@@ -938,7 +938,7 @@ test("CLI: scan with assume-alive liveness exits 0, prints board summary, writes
|
||||
join(sessionsDir, "s.jsonl"),
|
||||
[
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
||||
].join("\n") + "\n"
|
||||
);
|
||||
|
||||
@@ -1131,7 +1131,7 @@ test("scanAgent: a seen mark matching the waiting session's lastTimestamp clears
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
writeSessionFile(sessionsDir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
||||
]);
|
||||
const seen = { "p/a": "2026-09-01T00:00:01Z" };
|
||||
const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen });
|
||||
@@ -1146,7 +1146,7 @@ test("scanAgent: a stale mark (agent wrote something newer) is not seen and wait
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
writeSessionFile(sessionsDir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
||||
]);
|
||||
const seen = { "p/a": "2026-09-01T00:00:00Z" }; // stale: older than lastTimestamp
|
||||
const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen });
|
||||
@@ -1197,7 +1197,7 @@ test("scan: index.seen and waitingOnYou reflect seen.json, which scan never rewr
|
||||
const lastActivity = "2026-09-01T00:00:01Z";
|
||||
writeSessionFile(sessionsDir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["done"] }),
|
||||
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
|
||||
]);
|
||||
mkdirSync(boardDir, { recursive: true });
|
||||
const seenPath = join(boardDir, "seen.json");
|
||||
@@ -1219,3 +1219,87 @@ test("scan: a corrupt seen.json makes scan throw ConfigError (fail closed)", ()
|
||||
writeFile(join(boardDir, "seen.json"), "{ bad json");
|
||||
assert.throws(() => scan([], { boardDir, isAlive: () => true }), ConfigError);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task attribution (#1511): taskSetBy on the row, tied to the task source.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function attributionFixture(root, { taskSetBy, task = "registered task", pid = process.pid } = {}) {
|
||||
const sessionsDir = join(root, "sessions");
|
||||
writeSessionFile(sessionsDir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/derived/cwd" }),
|
||||
messageLine({ timestamp: "2026-09-12T14:00:01Z", role: "user", texts: ["derived task"] }),
|
||||
]);
|
||||
const spec = { agent: "a", project: "p", sessionsDir, tmux: {} };
|
||||
const base = makeRegistration({
|
||||
resolved: { seat: "a", project: "rp", sessionsDir, seatDir: join(root, "seat-dir"), launchScript: join(root, "seat-dir", "launch.sh"), layout: "repo", defaultWorkspace: "/w" },
|
||||
task, tmux: { socket: null, session: "a" }, pid, now: () => new Date("2026-09-12T13:00:00Z"),
|
||||
});
|
||||
const registration = taskSetBy === undefined ? base : { ...base, taskSetBy };
|
||||
return { spec, sessionsDir, registration };
|
||||
}
|
||||
|
||||
test("taskSetBy: a registered task carries the record's setter; a record without the field (pre-#1511) reads unknown; the value is not copied into registered", () => {
|
||||
const named = attributionFixture(makeRoot(), { taskSetBy: "darkwing" });
|
||||
const rec = scanAgent(named.spec, { isAlive: () => true, now: GATE_NOW, registration: named.registration });
|
||||
assert.equal(rec.taskSource, "registration");
|
||||
assert.equal(rec.taskSetBy, "darkwing");
|
||||
assert.equal("taskSetBy" in rec.registered, false, "attribution lives on the row next to taskSource only");
|
||||
|
||||
const old = attributionFixture(makeRoot());
|
||||
assert.equal("taskSetBy" in old.registration, false);
|
||||
const oldRec = scanAgent(old.spec, { isAlive: () => true, now: GATE_NOW, registration: old.registration });
|
||||
assert.equal(oldRec.taskSource, "registration");
|
||||
assert.equal(oldRec.taskSetBy, "unknown");
|
||||
|
||||
const literal = attributionFixture(makeRoot(), { taskSetBy: "unknown" });
|
||||
assert.equal(scanAgent(literal.spec, { isAlive: () => true, now: GATE_NOW, registration: literal.registration }).taskSetBy, "unknown");
|
||||
});
|
||||
|
||||
test("taskSetBy: null whenever the task shown is not the registered one: no registration, an empty registered task, a stale registration; the field is always present", () => {
|
||||
const none = attributionFixture(makeRoot(), { taskSetBy: "darkwing" });
|
||||
const withoutReg = scanAgent(none.spec, { isAlive: () => true, now: GATE_NOW });
|
||||
assert.equal(withoutReg.taskSource, "first-user-message");
|
||||
assert.equal(withoutReg.taskSetBy, null);
|
||||
assert.ok("taskSetBy" in withoutReg);
|
||||
|
||||
const empty = attributionFixture(makeRoot(), { taskSetBy: "darkwing", task: "" });
|
||||
const emptyRec = scanAgent(empty.spec, { isAlive: () => true, now: GATE_NOW, registration: empty.registration });
|
||||
assert.equal(emptyRec.task, "derived task");
|
||||
assert.equal(emptyRec.taskSource, "first-user-message");
|
||||
assert.equal(emptyRec.taskSetBy, null, "a setter on a record whose task is empty is not attached to the transcript's task");
|
||||
assert.notEqual(emptyRec.registered, null);
|
||||
|
||||
const stale = attributionFixture(makeRoot(), { taskSetBy: "darkwing" });
|
||||
const staleRec = scanAgent(stale.spec, { isAlive: () => true, now: GATE_NOW, registration: stale.registration, isPidAlive: () => false });
|
||||
assert.equal(staleRec.task, "derived task");
|
||||
assert.equal(staleRec.taskSource, "first-user-message");
|
||||
assert.equal(staleRec.taskSetBy, null, "a stale registration lends neither its task nor its setter");
|
||||
assert.equal(staleRec.registered.alive, false);
|
||||
|
||||
const noSession = scanAgent({ agent: "b", project: "p", sessionsDir: join(makeRoot(), "missing"), tmux: {} }, { isAlive: () => false, now: GATE_NOW });
|
||||
assert.equal(noSession.task, null);
|
||||
assert.equal(noSession.taskSetBy, null);
|
||||
});
|
||||
|
||||
test("taskSetBy: scan() reads the field from disk through the seat package (bounded there), writes it to the per-agent record and index, and an invalid on-disk value is a registrationError, never a row value", () => {
|
||||
const root = makeRoot();
|
||||
const seatsDir = join(root, "seats");
|
||||
const boardDir = join(root, "board");
|
||||
const f = attributionFixture(root, { taskSetBy: "jason@host:1" });
|
||||
writeRegistration(seatsDir, f.registration);
|
||||
const index = scan([f.spec], { boardDir, seatsDir, isAlive: () => true, now: GATE_NOW });
|
||||
assert.equal(index.sessions[0].taskSetBy, "jason@host:1");
|
||||
assert.equal(JSON.parse(readFileSync(join(boardDir, "sessions", "p", "a.json"), "utf8")).taskSetBy, "jason@host:1");
|
||||
assert.deepEqual(index.registrationErrors, []);
|
||||
|
||||
// A hand-edited record with markup in the field does not reach the board.
|
||||
writeFileSync(join(seatsDir, "repo", "a", "registration.json"), JSON.stringify({ ...f.registration, taskSetBy: "<img src=x onerror=alert(1)>" }));
|
||||
const rejected = scan([f.spec], { boardDir, seatsDir, isAlive: () => true, now: GATE_NOW });
|
||||
assert.equal(rejected.sessions[0].taskSource, "first-user-message");
|
||||
assert.equal(rejected.sessions[0].taskSetBy, null);
|
||||
assert.equal(rejected.sessions[0].registered, null);
|
||||
assert.equal(rejected.registrationErrors.length, 1);
|
||||
assert.match(rejected.registrationErrors[0], /taskSetBy/);
|
||||
assert.equal(JSON.stringify(rejected).includes("onerror"), false, "the offending value is not echoed anywhere on the index");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user