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:
@@ -12,10 +12,10 @@ rewritable; they are not run records and are not evidence.
|
||||
| State | Plain-words meaning |
|
||||
|---------|----------------------|
|
||||
| working | The agent is in the middle of a turn: thinking or running a tool. A seat whose newest log entry is a tool call or a tool result is working even if its last words looked like a question. |
|
||||
| waiting | The agent finished its turn with a text-only message. It is your move now. |
|
||||
| waiting | The agent explicitly requested your input in its completed reply. |
|
||||
| error | The agent's last turn ended in an error, was aborted, or was cut off. Go look at it. |
|
||||
| offline | There is no live tmux session for this agent right now, or its session exists but no longer runs `pi`. |
|
||||
| idle | The agent is live but has not had a conversation yet. |
|
||||
| idle | The agent is live and available, including after a normal completed reply. |
|
||||
| unknown | The scanner could not ask tmux (missing or not answering). It does not assume the agent is alive. |
|
||||
|
||||
Liveness means a pane in the agent's tmux session is actually running `pi`
|
||||
@@ -23,6 +23,79 @@ Liveness means a pane in the agent's tmux session is actually running `pi`
|
||||
exists but only runs bash or some other program counts as offline, not
|
||||
waiting.
|
||||
|
||||
### Explicit human attention
|
||||
|
||||
A completed assistant reply requests attention only when its first nonblank text
|
||||
line begins at column zero with `Input needed: ` and a nonempty request. Example:
|
||||
|
||||
```text
|
||||
Input needed: Choose staging or production for the approved test.
|
||||
```
|
||||
|
||||
Ordinary replies such as `BOARD_REPLY_OK`, completion reports and questions without
|
||||
this explicit signal are idle. Code/quote examples and thinking blocks do not
|
||||
count. Tool activity/errors retain precedence. A later ordinary completed reply
|
||||
clears the previous request; a user/tool message is working, not waiting.
|
||||
|
||||
Agents reserve the signal for Jason's decision or input, not another agent's
|
||||
review or routine completion. It is display state, never action authorization.
|
||||
Existing unmarked replies cannot establish a human blocker. Seen acknowledges
|
||||
the current event and removes it from the attention list; it does not resolve a
|
||||
genuine request or change waiting to idle. A new request reappears.
|
||||
|
||||
## Relaunch activity notice
|
||||
|
||||
A live native row with a positively live, matching registration gets a
|
||||
`relaunchedAt` timestamp when that registration's valid `startedAt` is strictly
|
||||
newer than valid recorded `lastActivity`. Both board presentations then show
|
||||
`relaunched at X, no messages since` in the current activity/preview positions.
|
||||
The inspector explicitly labels retained last activity, assistant text and errors
|
||||
as historical. CLI `scan --print` also replaces its old preview/age with the notice. New recorded session activity at or after the launch timestamp
|
||||
clears the notice. Equality does not assert a relaunch.
|
||||
|
||||
Missing/invalid timestamps, missing or mismatched registrations, unknown/dead PID
|
||||
or row liveness, and connector rows yield `relaunchedAt: null`. Unknown is not
|
||||
proof of relaunch. The comparison uses the existing session activity timestamp,
|
||||
not a new transcript index or authenticated process-incarnation protocol.
|
||||
|
||||
This field changes presentation only. Historical transcript files and serialized
|
||||
lastActivity/lastAssistantText/lastError remain intact. State, attention, Seen,
|
||||
task selection/attribution and reply eligibility are unchanged. An old unresolved
|
||||
waiting/error state therefore remains visible, with its text labelled historical,
|
||||
rather than being silently cleared by the new notice. No launcher or live
|
||||
registration mutation is required to test this behavior.
|
||||
|
||||
## Discord connector rows
|
||||
|
||||
The CLI discovers private `<dataRoot>/discord/<binding>.json` files on every
|
||||
scan, including server rescans. Only matching safe binding name and seat identity
|
||||
are used. Files must be regular, non-symlink, mode 0600 and at most 1 MiB.
|
||||
Discovery never resolves token paths or projects binding policy, Discord IDs or
|
||||
user/channel lists. Invalid bindings produce fixed, content-free discoveryErrors.
|
||||
Library callers enable this with `discordDataRoot` on scan/startServer.
|
||||
|
||||
Rows use project `fleet` and agent `<seat> (discord: <binding>)`, distinct from
|
||||
native seats. Task is fixed `Discord connector`, with source `connector`, never
|
||||
inferred from the first user message: Discord routing envelopes contain private
|
||||
IDs. Ordinary-agent task derivation and assistant transcript display are unchanged.
|
||||
Session history comes from `sessions/discord-<binding>` without
|
||||
following linked directories/files. Liveness uses the connector's readPid and
|
||||
ownerState checks, not tmux or registration: only a live PID with matching boot
|
||||
ID and start tick is live. Missing, invalid, dead or unverifiable owners are
|
||||
non-live. The `connector` projection contains binding, alive, ownerState and
|
||||
braked only; it does not expose the journal directory or owner record.
|
||||
|
||||
STOP presence is shown separately as braked, even when the owner is offline.
|
||||
An unsafe journal path gives brake unknown, not an unbraked claim. STOP contents
|
||||
are never read. Activity retains the usual idle/waiting/error rules. Both board
|
||||
pages omit Reply for connector rows; the backend refuses connector replies before
|
||||
transport even if a stale/forged native registration supplies tmux details.
|
||||
|
||||
This is read-only observation, not connector control. No brake, unlock, recovery,
|
||||
Discord request, counter collection or engine action is performed. Tests use
|
||||
isolated bindings, sessions and process identities; they do not prove live
|
||||
service transitions or authorize replacing a running board.
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
@@ -156,8 +229,18 @@ name alone. A registered task, project or workspace replaces the derived
|
||||
value and the row's source field (`taskSource`, `activeProjectSource`,
|
||||
`workspaceSource`) reads `registration`; the page shows a small source tag
|
||||
next to the value and a "Registered" line in the detail with the start time,
|
||||
harness, pid and tmux session. An empty task or a null project or workspace
|
||||
in the record leaves the derived value in place. Rows with no registration
|
||||
harness, pid and tmux session. When the task shown is the registered one,
|
||||
the row also carries `taskSetBy` (#1511): the record's `taskSetBy` from
|
||||
`mosaic seat task --by NAME` (else `$MOSAIC_AGENT_NAME`, else `unknown`),
|
||||
or `unknown` for a record written before the field existed. Both pages show
|
||||
it as a "set by NAME" tag after the task's source tag and a "Task set by"
|
||||
detail row. It is what the caller claimed, not a verified identity: the
|
||||
board escapes and displays it and nothing else reads it; in particular the
|
||||
reply gate looks only at the registration. For every other task source
|
||||
(`first-user-message`, `connector`, a stale registration, no task) it is
|
||||
null, so a record never lends its setter to a task it did not set. An empty
|
||||
task or a null project or workspace in the record leaves the derived value
|
||||
in place. Rows with no registration
|
||||
are exactly as before. The board only reads `seats/`; `mosaic launch` and
|
||||
`mosaic seat task` are the only writers. A malformed record is listed in
|
||||
`registrationErrors` on the index (and on stderr for `scan`) and skipped.
|
||||
@@ -194,6 +277,7 @@ values), the scan refuses rather than silently dropping every mark.
|
||||
"cwd": "/mnt/storage/src/mosaic-stack",
|
||||
"task": "Read agents/darkwing/work/RESTART.md",
|
||||
"taskSource": "first-user-message",
|
||||
"taskSetBy": null,
|
||||
"workspace": "/mnt/storage/src/mosaic-stack",
|
||||
"workspaceSource": "tmux-pane",
|
||||
"activeProject": "mosaic-stack",
|
||||
|
||||
@@ -42,7 +42,7 @@ async function main() {
|
||||
const boardDir = join(dataRoot, "board");
|
||||
const seatsDir = join(dataRoot, "seats");
|
||||
if (opts.command === "serve") {
|
||||
const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive, seatsDir });
|
||||
const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive, seatsDir, discordDataRoot: dataRoot });
|
||||
const addr = server.address();
|
||||
process.stdout.write(`control board: http://${opts.host}:${addr.port}/ (${specs.length} agents; board files in ${boardDir}; Ctrl-C to stop)\n`);
|
||||
const stop = () => server.close(() => process.exit(0));
|
||||
@@ -50,13 +50,15 @@ async function main() {
|
||||
process.on("SIGTERM", stop);
|
||||
return;
|
||||
}
|
||||
const index = scan(specs, { boardDir, isAlive, seatsDir });
|
||||
const index = scan(specs, { boardDir, isAlive, seatsDir, discordDataRoot: dataRoot });
|
||||
for (const line of index.discoveryErrors) process.stderr.write(`${line}\n`);
|
||||
for (const line of index.registrationErrors) process.stderr.write(`registration skipped: ${line}\n`);
|
||||
if (opts.print) {
|
||||
for (const s of index.sessions) {
|
||||
const flag = s.waitingOnYou ? "*" : s.seen ? "s" : " ";
|
||||
const age = s.ageSeconds == null ? "-" : `${Math.round(s.ageSeconds / 60)}m`;
|
||||
process.stdout.write(`${flag} ${s.state.padEnd(8)} ${s.project.padEnd(14)} ${s.agent.padEnd(16)} ${age.padStart(7)} ${s.lastAssistantText ? s.lastAssistantText.slice(0, 80) : ""}\n`);
|
||||
const age = s.relaunchedAt ? "relaunch" : s.ageSeconds == null ? "-" : `${Math.round(s.ageSeconds / 60)}m`;
|
||||
const preview = s.relaunchedAt ? `relaunched at ${s.relaunchedAt}, no messages since` : s.lastAssistantText ? s.lastAssistantText.slice(0, 80) : "";
|
||||
process.stdout.write(`${flag} ${s.state.padEnd(8)} ${s.project.padEnd(14)} ${s.agent.padEnd(16)} ${age.padStart(7)} ${preview}\n`);
|
||||
}
|
||||
}
|
||||
process.stdout.write(`board: ${boardDir} (${index.sessions.length} sessions, ${index.waitingOnYou.length} waiting on you, ${index.seen.length} seen, ${index.registered.length} registered, ${index.registrationStale.length} stale)\n`);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Read-only connector discovery. Never load binding policy or token resources.
|
||||
import { lstatSync, readdirSync, openSync, closeSync, fstatSync, readFileSync, constants } from 'node:fs';
|
||||
import { join, resolve, parse } from 'node:path';
|
||||
import { readPid, ownerState } from '../../discord/src/journal.mjs';
|
||||
|
||||
const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
// Check every existing ancestor, not just the final component.
|
||||
export function noLinks(path) {
|
||||
const absolute = resolve(path);
|
||||
let current = parse(absolute).root;
|
||||
for (const part of absolute.slice(current.length).split('/').filter(Boolean)) {
|
||||
current = join(current, part);
|
||||
try { if (lstatSync(current).isSymbolicLink()) return false; }
|
||||
catch (e) { if (e.code === 'ENOENT') return true; return false; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function stat(path) { try { return lstatSync(path); } catch { return null; } }
|
||||
export function discoverDiscordAgents(dataRoot) {
|
||||
const specs = [], errors = [];
|
||||
const dir = join(dataRoot, 'discord');
|
||||
if (!noLinks(dir)) return { specs, errors: ['discord discovery: unsafe directory'] };
|
||||
const ds = stat(dir);
|
||||
if (!ds) return { specs, errors };
|
||||
if (!ds.isDirectory()) return { specs, errors: ['discord discovery: invalid directory'] };
|
||||
let names;
|
||||
try { names = readdirSync(dir).sort(); } catch { return { specs, errors: ['discord discovery: unreadable directory'] }; }
|
||||
for (const file of names.filter(n => n.endsWith('.json'))) {
|
||||
let fd;
|
||||
try {
|
||||
const name = file.slice(0, -5);
|
||||
if (!NAME.test(name)) throw new Error();
|
||||
fd = openSync(join(dir, file), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
const s = fstatSync(fd);
|
||||
if (!s.isFile() || (s.mode & 0o7777) !== 0o600 || s.size > 1024 * 1024) throw new Error();
|
||||
const raw = JSON.parse(readFileSync(fd, 'utf8'));
|
||||
if (!raw || raw.name !== name || typeof raw.seat !== 'string' || !NAME.test(raw.seat)) throw new Error();
|
||||
// Do not retain the binding object, even in internal scan specs.
|
||||
specs.push({ agent: `${raw.seat} (discord: ${name})`, project: 'fleet', sessionsDir: join(dataRoot, 'sessions', `discord-${name}`), tmux: null, connector: { binding: name, journalDir: join(dir, name) } });
|
||||
} catch { errors.push('discord discovery: skipped invalid binding'); }
|
||||
finally { if (fd !== undefined) closeSync(fd); }
|
||||
}
|
||||
return { specs, errors };
|
||||
}
|
||||
export function inspectDiscord(connector, { identity } = {}) {
|
||||
const dir = connector.journalDir, owner = join(dir, 'run.lock', 'owner.json');
|
||||
let braked = null;
|
||||
if (noLinks(dir)) {
|
||||
try { lstatSync(join(dir, 'STOP')); braked = true; }
|
||||
catch (e) { if (e.code === 'ENOENT') braked = false; }
|
||||
}
|
||||
let ownerStatus = 'invalid';
|
||||
if (noLinks(owner)) {
|
||||
const s = stat(owner);
|
||||
if (!s || s.isFile()) {
|
||||
try { ownerStatus = ownerState(readPid(dir), identity ? { identity } : {}); } catch { ownerStatus = 'invalid'; }
|
||||
}
|
||||
}
|
||||
return { binding: connector.binding, braked, ownerState: ownerStatus, alive: ownerStatus === 'live' };
|
||||
}
|
||||
// Connector session trees must not follow links, including ancestor links.
|
||||
export function newestDiscordSession(dir) {
|
||||
if (!noLinks(dir)) return null;
|
||||
let best = null;
|
||||
const walk = path => {
|
||||
const s = stat(path);
|
||||
if (!s || s.isSymbolicLink()) return;
|
||||
if (s.isDirectory()) { for (const name of readdirSync(path)) walk(join(path, name)); }
|
||||
else if (s.isFile() && path.endsWith('.jsonl') && (!best || s.mtimeMs > best.time)) best = { path, time: s.mtimeMs };
|
||||
};
|
||||
walk(dir);
|
||||
return best?.path ?? null;
|
||||
}
|
||||
@@ -63,6 +63,7 @@
|
||||
.seen-tag{font-size:.72rem;color:var(--muted);margin-left:6px;vertical-align:middle}
|
||||
.msg-error{color:var(--danger)}
|
||||
.msg-text,.msg-error{display:block;max-width:36ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.relaunch-notice{display:block;max-width:36ch;overflow-wrap:anywhere;white-space:normal}
|
||||
.model{display:block;color:var(--muted);font-size:.75em;white-space:nowrap}
|
||||
.task-text{display:block;max-width:28ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.unknown{color:var(--muted);font-style:italic}
|
||||
@@ -71,6 +72,7 @@
|
||||
.reply-receipt{display:block;margin-top:.3em;font-size:.85em}
|
||||
.reply-ok{color:var(--muted)}
|
||||
.source{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
|
||||
.set-by{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
|
||||
.detail-row td{background:var(--raised)}
|
||||
.detail-list{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:.85rem;font-family:var(--mono)}
|
||||
.detail-list dt{color:var(--muted);font-family:var(--font)}
|
||||
@@ -194,6 +196,12 @@
|
||||
function sourceLabel(source) { return SOURCE_LABEL[source] || source; }
|
||||
function sourceTag(source) { return source ? '<span class="source" title="source: ' + esc(source) + '">' + esc(sourceLabel(source)) + "</span>" : ""; }
|
||||
function fromSource(source) { return source ? " (from " + esc(sourceLabel(source)) + ")" : ""; }
|
||||
// Who set a registered task (#1511): shown only when the task on the row is
|
||||
// the registered one (the scan gives null otherwise). It is what the
|
||||
// caller claimed when it ran `mosaic seat task`, not a verified identity,
|
||||
// and it never changes what the row lets you do.
|
||||
function setByTag(rec) { return rec.taskSetBy ? '<span class="set-by" title="set by (as claimed by the caller, not verified): ' + esc(rec.taskSetBy) + '">set by ' + esc(rec.taskSetBy) + "</span>" : ""; }
|
||||
function setByText(rec) { return rec.taskSetBy ? esc(rec.taskSetBy) + " (as claimed by the caller, not verified)" : "—"; }
|
||||
// The model a seat is running, e.g. "gpt-6-astra" with the provider in the
|
||||
// hover title. modelText() already escapes, so the title takes it as is.
|
||||
// Empty when the log has not named one yet.
|
||||
@@ -205,6 +213,7 @@
|
||||
var replyReceipts = {};
|
||||
var replyDrafts = {};
|
||||
function canReply(rec) {
|
||||
if (rec.connector) return false;
|
||||
var reg = rec.registered;
|
||||
return !!(reg && reg.alive !== false && reg.tmux && reg.tmux.session);
|
||||
}
|
||||
@@ -216,6 +225,7 @@
|
||||
}
|
||||
function replyControl(rec) {
|
||||
var id = [rec.project, rec.agent].join("/");
|
||||
if (rec.connector) return '<span class="unknown">Board replies disabled for Discord connectors</span>';
|
||||
if (!canReply(rec)) return '<span class="unknown">reply needs a registered seat</span>' + replyReceipt(id);
|
||||
return '<form class="reply-form" data-id="' + esc(id) + '">' +
|
||||
'<input type="text" class="reply-text" maxlength="2000" autocomplete="off" aria-label="Reply to ' + esc(rec.agent) + '" placeholder="Reply to ' + esc(rec.agent) + '" value="' + esc(replyDrafts[id] || "") + '">' +
|
||||
@@ -233,12 +243,14 @@
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
function relaunchNotice(rec) { return rec.relaunchedAt ? 'relaunched at ' + esc(rec.relaunchedAt) + ', no messages since' : ''; }
|
||||
function buildRowPair(rec, showProject) {
|
||||
var idx = rowIdx++;
|
||||
var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/");
|
||||
var open = !!openDetails[key];
|
||||
var cls = "session-row" + (rec.state === "offline" ? " is-offline" : "");
|
||||
var msg = rec.state === "error" && rec.lastError
|
||||
var notice = relaunchNotice(rec);
|
||||
var msg = notice ? '<span class="relaunch-notice">' + notice + '</span>' : rec.state === "error" && rec.lastError
|
||||
? '<span class="msg-error" title="' + esc(rec.lastError) + '">' + esc(rec.lastError) + "</span>"
|
||||
: '<span class="msg-text" title="' + esc(rec.lastAssistantText || "") + '">' + esc(rec.lastAssistantText || "—") + "</span>";
|
||||
var projectCell = showProject ? "<td>" + esc(rec.project) + "</td>" : "";
|
||||
@@ -246,7 +258,7 @@
|
||||
// from the log and tmux, else "unknown". Never guessed. The source tag on
|
||||
// the row says which one is shown.
|
||||
var task = rec.task
|
||||
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>" + sourceTag(rec.taskSource)
|
||||
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>" + sourceTag(rec.taskSource) + setByTag(rec)
|
||||
: '<span class="unknown">unknown</span>';
|
||||
var activeProject = rec.activeProject
|
||||
? '<span title="' + esc(rec.workspace || "") + '">' + esc(rec.activeProject) + "</span>" + sourceTag(rec.activeProjectSource)
|
||||
@@ -255,8 +267,8 @@
|
||||
'<tr class="' + cls + '">' +
|
||||
projectCell +
|
||||
'<td><button type="button" class="row-toggle" data-idx="' + idx + '" data-key="' + esc(key) + '" aria-expanded="' + (open ? "true" : "false") + '" aria-controls="detail-' + idx + '">' + esc(rec.agent) + "</button>" + modelTag(rec) + "</td>" +
|
||||
"<td>" + badge(rec.state) + seenControl(rec) + "</td>" +
|
||||
"<td>" + esc(humanAge(rec.ageSeconds)) + "</td>" +
|
||||
"<td>" + badge(rec.state) + (rec.connector ? '<span class="unknown"> · ' + (rec.connector.braked === true ? 'braked (STOP)' : rec.connector.braked === false ? 'not braked' : 'brake unknown') + ' · owner ' + esc(rec.connector.ownerState) + '</span>' : '') + seenControl(rec) + "</td>" +
|
||||
"<td>" + (notice ? '<span class="relaunch-notice">' + notice + '</span>' : esc(humanAge(rec.ageSeconds))) + "</td>" +
|
||||
"<td>" + task + "</td>" +
|
||||
"<td>" + activeProject + "</td>" +
|
||||
"<td>" + msg + "</td>" +
|
||||
@@ -271,6 +283,7 @@
|
||||
"<dt>Session ID</dt><dd>" + esc(rec.sessionId || "—") + "</dd>" +
|
||||
"<dt>Session file</dt><dd>" + esc(rec.sessionFile || "—") + "</dd>" +
|
||||
"<dt>Task</dt><dd>" + (rec.task ? esc(rec.task) : "unknown") + fromSource(rec.taskSource) + "</dd>" +
|
||||
"<dt>Task set by</dt><dd>" + setByText(rec) + "</dd>" +
|
||||
"<dt>Active project</dt><dd>" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + fromSource(rec.activeProjectSource) + "</dd>" +
|
||||
"<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + fromSource(rec.workspaceSource) + "</dd>" +
|
||||
"<dt>Model</dt><dd>" + modelText(rec) + "</dd>" +
|
||||
@@ -278,11 +291,12 @@
|
||||
"<dt>Reply</dt><dd>" + replyControl(rec) + "</dd>" +
|
||||
"<dt>Session cwd</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
|
||||
"<dt>Tmux session</dt><dd>" + tmux + "</dd>" +
|
||||
"<dt>Last activity</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
|
||||
(notice ? "<dt>Current activity</dt><dd>" + notice + "</dd>" : "") +
|
||||
"<dt>" + (notice ? "Historical last activity" : "Last activity") + "</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
|
||||
"<dt>Scanned at</dt><dd>" + esc(rec.scannedAt || "—") + "</dd>" +
|
||||
"<dt>Skipped lines</dt><dd>" + esc(rec.skippedLines) + "</dd>" +
|
||||
"<dt>Last message</dt><dd>" + esc(rec.lastAssistantText || "—") + "</dd>" +
|
||||
"<dt>Last error</dt><dd>" + esc(rec.lastError || "—") + "</dd>" +
|
||||
"<dt>" + (notice ? "Historical last message" : "Last message") + "</dt><dd>" + esc(rec.lastAssistantText || "—") + "</dd>" +
|
||||
"<dt>" + (notice ? "Historical last error" : "Last error") + "</dt><dd>" + esc(rec.lastError || "—") + "</dd>" +
|
||||
"</dl></td></tr>";
|
||||
return main + detail;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
//
|
||||
// States (plain words):
|
||||
// working - the agent is in the middle of a turn (thinking or running tools)
|
||||
// waiting - the agent finished its turn; it is your move
|
||||
// waiting - the agent explicitly requests human input in its completed reply
|
||||
// error - the agent's last turn ended in an error, was aborted, or was cut off; look at it
|
||||
// offline - no tmux session for this agent, or its session no longer runs pi
|
||||
// idle - the agent is live but has no conversation yet
|
||||
// idle - the agent is available, with no explicit input request
|
||||
// unknown - liveness could not be checked (tmux missing or unresponsive); not a guess
|
||||
//
|
||||
// Board files are derived and rewritable. They are not run records. The one
|
||||
@@ -19,6 +19,8 @@ import { homedir } from "node:os";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readRegistration, samePath, SeatError, LAYOUTS } from "../../seat/src/seat.mjs";
|
||||
|
||||
import { discoverDiscordAgents, inspectDiscord, newestDiscordSession } from './discord.mjs';
|
||||
|
||||
export const STATES = Object.freeze(["working", "waiting", "error", "offline", "idle", "unknown"]);
|
||||
const TEXT_LIMIT = 240;
|
||||
|
||||
@@ -137,12 +139,20 @@ export function hasToolCall(message) {
|
||||
return Array.isArray(message?.content) && message.content.some((c) => c && c.type === "toolCall");
|
||||
}
|
||||
|
||||
// Pure state rule. alive: true/false, or null when liveness could not be checked.
|
||||
// A null check is reported as "unknown" rather than assumed alive (fail closed).
|
||||
// Acceptance rule (plan page, 2026-09-12): a seat mid-tool-call is working,
|
||||
// never waiting. The newest entry being an assistant message with a tool call,
|
||||
// or a tool result, means working even if the last text looked like a question.
|
||||
// waiting needs a text-only assistant message whose turn ended (stopReason stop).
|
||||
// Explicit display convention, not a language classifier or control permission.
|
||||
// Only a plain first nonblank text line can request human input. Quoted/code
|
||||
// examples and thinking blocks must not create attention events.
|
||||
function requestsHumanInput(message) {
|
||||
if (!Array.isArray(message.content)) return false;
|
||||
const text = message.content.filter((c) => c?.type === "text" && typeof c.text === "string")
|
||||
.map((c) => c.text).join("\n");
|
||||
const first = text.split(/\r?\n/).find((line) => line.trim()) ?? "";
|
||||
const prefix = "Input needed: ";
|
||||
return first.startsWith(prefix) && first.slice(prefix.length).trim().length > 0;
|
||||
}
|
||||
|
||||
// A missing liveness check stays unknown. Offline/errors/tool activity take
|
||||
// precedence over attention text. Completion alone means idle, not waiting.
|
||||
export function deriveState({ alive, session }) {
|
||||
if (alive === false) return "offline";
|
||||
if (alive !== true) return "unknown";
|
||||
@@ -151,7 +161,7 @@ export function deriveState({ alive, session }) {
|
||||
if (m.role === "assistant") {
|
||||
if (m.stopReason === "error" || m.stopReason === "aborted" || m.stopReason === "length") return "error";
|
||||
if (hasToolCall(m)) return "working";
|
||||
if (m.stopReason === "stop") return "waiting";
|
||||
if (m.stopReason === "stop") return requestsHumanInput(m) ? "waiting" : "idle";
|
||||
return "working";
|
||||
}
|
||||
return "working";
|
||||
@@ -317,9 +327,10 @@ export function pidAlive(pid) {
|
||||
// it is still reported under `registered` (with alive false) but the derived
|
||||
// values win, because the record describes a launch that is not running.
|
||||
export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), seen = {}, registration = null, isPidAlive = pidAlive } = {}) {
|
||||
const live = liveness(isAlive(spec.tmux));
|
||||
const connector = spec.connector ? inspectDiscord(spec.connector) : null;
|
||||
const live = connector ? { alive: connector.alive, workspace: null } : liveness(isAlive(spec.tmux));
|
||||
const alive = live.alive;
|
||||
const file = findNewestSession(spec.sessionsDir);
|
||||
const file = connector ? newestDiscordSession(spec.sessionsDir) : findNewestSession(spec.sessionsDir);
|
||||
const session = file ? readSession(file) : null;
|
||||
const state = deriveState({ alive, session });
|
||||
const scannedAt = now();
|
||||
@@ -328,8 +339,16 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
|
||||
const needsYou = state === "waiting" || state === "error";
|
||||
const isSeen = needsYou && lastActivity !== null && seen[seenKey(spec)] === lastActivity;
|
||||
const cwd = session?.cwd ?? null;
|
||||
const record = registration && typeof registration === "object" ? registration : null;
|
||||
const record = !connector && registration && typeof registration === "object" ? registration : null;
|
||||
const registeredAlive = record ? isPidAlive(record.pid) : null;
|
||||
// Presentation only: retain history and attention/Seen semantics. Unknown
|
||||
// liveness, unmatched records and unknown timestamps cannot assert relaunch.
|
||||
const launchTime = typeof record?.startedAt === "string" ? Date.parse(record.startedAt) : NaN;
|
||||
const activityTime = typeof lastActivity === "string" ? Date.parse(lastActivity) : NaN;
|
||||
const relaunchedAt = alive === true && registeredAlive === true &&
|
||||
samePath(record?.sessionsDir, spec.sessionsDir) && Number.isFinite(launchTime) &&
|
||||
Number.isFinite(activityTime) && launchTime > activityTime
|
||||
? new Date(launchTime).toISOString() : null;
|
||||
const reg = record && registeredAlive !== false ? record : null;
|
||||
const derivedWorkspace = live.workspace ?? cwd;
|
||||
const workspace = reg?.workspace ?? derivedWorkspace;
|
||||
@@ -339,11 +358,21 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
|
||||
const activeProject = reg?.project ?? derivedProject;
|
||||
const activeProjectSource = reg?.project ? "registration" : derivedProject ? "workspace-git-root" : null;
|
||||
const firstUserText = session?.firstUserText ?? null;
|
||||
const task = reg?.task ? reg.task : firstUserText;
|
||||
const taskSource = reg?.task ? "registration" : firstUserText ? "first-user-message" : null;
|
||||
// Discord user text starts with a routing envelope containing private IDs.
|
||||
// Connector task metadata is fixed, never inferred from that transcript.
|
||||
const task = connector ? "Discord connector" : reg?.task ? reg.task : firstUserText;
|
||||
const taskSource = connector ? "connector" : reg?.task ? "registration" : firstUserText ? "first-user-message" : null;
|
||||
// Who set the task (#1511), only when the task shown *is* the registered
|
||||
// one: a record written before the field existed reads "unknown"; a
|
||||
// transcript-derived task, a stale registration and a connector's fixed
|
||||
// task carry no attribution at all (null), so a record can never lend its
|
||||
// setter to a task it did not set. It is the caller's claim, bounded by
|
||||
// the seat package on read, and it grants nothing.
|
||||
const taskSetBy = taskSource === "registration" ? (reg.taskSetBy ?? "unknown") : null;
|
||||
return {
|
||||
agent: spec.agent,
|
||||
project: spec.project,
|
||||
...(connector ? { connector } : {}),
|
||||
state,
|
||||
waitingOnYou: needsYou && !isSeen,
|
||||
seen: isSeen,
|
||||
@@ -359,6 +388,7 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
|
||||
provider: session?.provider ?? null,
|
||||
task,
|
||||
taskSource,
|
||||
taskSetBy,
|
||||
workspace,
|
||||
workspaceSource,
|
||||
activeProject,
|
||||
@@ -367,6 +397,7 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
|
||||
? { startedAt: record.startedAt, updatedAt: record.updatedAt, harness: record.harness, pid: record.pid, alive: registeredAlive, tmux: record.tmux, layout: record.layout, launchScript: record.launchScript }
|
||||
: null,
|
||||
lastActivity,
|
||||
relaunchedAt,
|
||||
ageSeconds,
|
||||
lastAssistantText: session?.lastAssistantText ?? null,
|
||||
lastError: session?.lastError ?? null,
|
||||
@@ -403,12 +434,13 @@ function writeAtomic(path, data) {
|
||||
|
||||
// Scan every spec and write <boardDir>/sessions/<project>/<agent>.json plus index.json.
|
||||
// seatsDir (optional): where `mosaic launch` registrations live; read only.
|
||||
export function scan(specs, { boardDir, isAlive, now, seatsDir = null, isPidAlive } = {}) {
|
||||
export function scan(specs, { boardDir, isAlive, now, seatsDir = null, isPidAlive, discordDataRoot = null } = {}) {
|
||||
if (!boardDir || !isAbsolute(boardDir)) throw new ConfigError("boardDir must be an absolute path");
|
||||
if (seatsDir !== null && (typeof seatsDir !== "string" || !isAbsolute(seatsDir))) throw new ConfigError("seatsDir must be an absolute path or null");
|
||||
const seen = loadSeen(boardDir);
|
||||
const { registrations, errors: registrationErrors } = loadRegistrations(seatsDir);
|
||||
const records = specs.map((spec) => scanAgent(spec, { isAlive, now, seen, registration: matchRegistration(spec, registrations), isPidAlive }));
|
||||
const discord = discordDataRoot ? discoverDiscordAgents(discordDataRoot) : { specs: [], errors: [] };
|
||||
const records = [...specs, ...discord.specs].map((spec) => scanAgent(spec, { isAlive, now, seen, registration: matchRegistration(spec, registrations), isPidAlive }));
|
||||
for (const rec of records) {
|
||||
const dir = join(boardDir, "sessions", rec.project);
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
@@ -423,6 +455,7 @@ export function scan(specs, { boardDir, isAlive, now, seatsDir = null, isPidAliv
|
||||
registered: records.filter((r) => r.registered).map(seenKey),
|
||||
registrationStale: records.filter((r) => r.registered && r.registered.alive === false).map(seenKey),
|
||||
registrationErrors,
|
||||
discoveryErrors: discord.errors,
|
||||
sessions: records,
|
||||
};
|
||||
mkdirSync(boardDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
@@ -53,6 +53,7 @@ export function replyToRow({ index, key, text, agentSend = DEFAULT_AGENT_SEND, e
|
||||
if (text.length > REPLY_LIMIT) return { status: 400, body: { error: `text is longer than ${REPLY_LIMIT} characters` } };
|
||||
const rec = index.sessions.find((r) => seenKey(r) === key);
|
||||
if (!rec) return { status: 404, body: { error: `unknown row: ${key}` } };
|
||||
if (rec.connector || / \(discord: [a-z0-9][a-z0-9._-]{0,63}\)$/.test(rec.agent)) return { status: 409, body: { error: "board replies are disabled for Discord connectors" } };
|
||||
const reg = rec.registered;
|
||||
if (!reg) return { status: 409, body: { error: "reply needs a registered seat (start it through scripts/mosaic launch)" } };
|
||||
if (reg.alive === false) return { status: 409, body: { error: `registration is stale: pid ${reg.pid} is gone` } };
|
||||
@@ -130,8 +131,8 @@ export function loadPage(path = join(import.meta.dirname, "page.html")) {
|
||||
}
|
||||
|
||||
// specs: agent specs to scan on each request. boardDir: where scan writes.
|
||||
export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync }) {
|
||||
const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive });
|
||||
export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, discordDataRoot = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync }) {
|
||||
const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive, discordDataRoot });
|
||||
return createHttpServer((req, res) => {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
if (req.method === "POST" && url.pathname === "/api/reply") {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { scan, markSeen, deriveState } from '../src/scan.mjs';
|
||||
|
||||
test('explicit request, Seen, ordinary completion and a new request have distinct attention states', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'board-attention-flow-'));
|
||||
try {
|
||||
const sessionsDir = join(root, 'sessions');
|
||||
mkdirSync(sessionsDir);
|
||||
const file = join(sessionsDir, 's.jsonl');
|
||||
writeFileSync(file, JSON.stringify({ type: 'session', id: 'fixture', cwd: root, timestamp: '2026-09-13T00:00:00Z' }) + '\n');
|
||||
const specs = [{ agent: 'researcher', project: 'fixture', sessionsDir, tmux: {} }];
|
||||
const boardDir = join(root, 'board');
|
||||
const get = () => scan(specs, { boardDir, isAlive: () => true }).sessions[0];
|
||||
const append = (second, text) => appendFileSync(file, JSON.stringify({
|
||||
type: 'message', timestamp: `2026-09-13T00:00:0${second}Z`,
|
||||
message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }] },
|
||||
}) + '\n');
|
||||
append(1, 'Input needed: Choose A or B.');
|
||||
let r = get();
|
||||
assert.equal(r.state, 'waiting');
|
||||
assert.equal(r.waitingOnYou, true);
|
||||
markSeen(boardDir, { project: 'fixture', agent: 'researcher', lastActivity: r.lastActivity, seen: true });
|
||||
r = get();
|
||||
assert.equal(r.state, 'waiting', 'acknowledgment does not resolve an input request');
|
||||
assert.equal(r.seen, true);
|
||||
assert.equal(r.waitingOnYou, false);
|
||||
append(2, 'BOARD_REPLY_OK');
|
||||
r = get();
|
||||
assert.equal(r.state, 'idle');
|
||||
assert.equal(r.waitingOnYou, false);
|
||||
assert.equal(r.seen, false, 'ordinary completion needs no Seen action');
|
||||
append(3, 'Input needed: Confirm the revised choice.');
|
||||
r = get();
|
||||
assert.equal(r.state, 'waiting');
|
||||
assert.equal(r.waitingOnYou, true, 'old Seen does not suppress a new request');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('attention convention ignores reasoning/quoted examples and permits leading blank lines', () => {
|
||||
const derive = (content, alive = true) => deriveState({ alive, session: { lastMessage: { role: 'assistant', stopReason: 'stop', content } } });
|
||||
const text = (value) => [{ type: 'text', text: value }];
|
||||
for (const value of ['> Input needed: Choose A.', ' Input needed: Choose A.', '`Input needed: Choose A.`', 'Input needed:\tChoose A.']) {
|
||||
assert.equal(derive(text(value)), 'idle', value);
|
||||
}
|
||||
assert.equal(derive([{ type: 'thinking', thinking: 'Input needed: Choose A.' }, ...text('Done.')]), 'idle');
|
||||
assert.equal(derive(text('\n\r\nInput needed: Choose A.')), 'waiting');
|
||||
assert.equal(derive(text('Input needed: Choose A.'), false), 'offline');
|
||||
assert.equal(derive(text('Input needed: Choose A.'), null), 'unknown');
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { deriveState } from '../src/scan.mjs';
|
||||
|
||||
const state = (text, overrides = {}) => deriveState({ alive: true, session: {
|
||||
lastMessage: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }], ...overrides },
|
||||
} });
|
||||
|
||||
test('completed smoke replies and ordinary questions are idle, not human blockers', () => {
|
||||
for (const text of ['BOARD_REPLY_OK', 'RESEARCHER_NATIVE_SMOKE_OK', 'Done.', 'Shall I continue?']) {
|
||||
assert.equal(state(text), 'idle', text);
|
||||
}
|
||||
});
|
||||
|
||||
test('only an explicit first-line input request makes a finished reply waiting', () => {
|
||||
assert.equal(state('Input needed: Choose the release target.\nDetails follow.'), 'waiting');
|
||||
for (const text of ['Input needed:', 'Input needed: ', 'Example:\nInput needed: Choose A.', '```\nInput needed: Choose A.\n```']) {
|
||||
assert.equal(state(text), 'idle', text);
|
||||
}
|
||||
});
|
||||
|
||||
test('tool activity, user text, errors and unfinished turns override attention text', () => {
|
||||
const text = 'Input needed: Choose A.';
|
||||
assert.equal(state(text, { content: [{ type: 'text', text }, { type: 'toolCall', name: 'read' }] }), 'working');
|
||||
assert.equal(state(text, { role: 'user' }), 'working');
|
||||
assert.equal(state(text, { role: 'toolResult' }), 'working');
|
||||
assert.equal(state(text, { stopReason: 'error' }), 'error');
|
||||
assert.equal(state(text, { stopReason: undefined }), 'working');
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, symlinkSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { discoverDiscordAgents, inspectDiscord } from '../src/discord.mjs';
|
||||
import { scan, scanAgent } from '../src/scan.mjs';
|
||||
import { replyToRow, startServer } from '../src/serve.mjs';
|
||||
import { identityOf } from '../../discord/src/journal.mjs';
|
||||
import { envelope } from '../../discord/src/context.mjs';
|
||||
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
|
||||
|
||||
test('STOP access failure is unknown, not absence, under a non-root identity', () => {
|
||||
const result = spawnSync(process.execPath, ['--input-type=module', '-e', `
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { inspectDiscord } from ${JSON.stringify(new URL('../src/discord.mjs', import.meta.url).href)};
|
||||
assert.notEqual(process.getuid(), 0, 'permission regression must not run as root');
|
||||
const root = mkdtempSync(join(tmpdir(), 'board-stop-permission-'));
|
||||
const dir = join(root, 'journal'), connector = { binding: 'fixture', journalDir: dir };
|
||||
mkdirSync(dir);
|
||||
try {
|
||||
assert.equal(inspectDiscord(connector).braked, false, 'verified absence');
|
||||
writeFileSync(join(dir, 'STOP'), 'private contents never read');
|
||||
assert.equal(inspectDiscord(connector).braked, true, 'verified presence');
|
||||
chmodSync(dir, 0o000);
|
||||
const inaccessible = inspectDiscord(connector);
|
||||
assert.equal(inaccessible.braked, null, 'inaccessible STOP is unknown');
|
||||
assert.equal(inaccessible.alive, false);
|
||||
chmodSync(dir, 0o700);
|
||||
assert.equal(inspectDiscord(connector).braked, true, 'restored access sees STOP');
|
||||
rmSync(join(dir, 'STOP'));
|
||||
assert.equal(inspectDiscord(connector).braked, false, 'verified removal');
|
||||
} finally { chmodSync(dir, 0o700); rmSync(root, { recursive: true, force: true }); }
|
||||
`], { encoding: 'utf8', timeout: 10000, ...(process.getuid() === 0 ? { uid: 65534, gid: 65534 } : {}) });
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
});
|
||||
|
||||
test('connector Task never inherits Discord routing envelopes; ordinary Task still uses user text', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const text = envelope({ guildName: 'Synthetic guild', channelName: 'synthetic-channel', authorId: '111111111111111111', messageId: '222222222222222222', text: 'Synthetic user request' });
|
||||
const sessionsDir = join(f.root, 'sessions', 'discord-demo');
|
||||
writeFileSync(join(sessionsDir, 's.jsonl'), JSON.stringify({ type: 'message', message: { role: 'user', content: [{ type: 'text', text }] } }) + '\n');
|
||||
const connector = scanAgent(discoverDiscordAgents(f.root).specs[0]);
|
||||
assert.equal(connector.task, 'Discord connector');
|
||||
assert.equal(connector.taskSource, 'connector');
|
||||
for (const secret of ['111111111111111111', '222222222222222222', 'Synthetic user request']) assert.equal(connector.task.includes(secret), false);
|
||||
const ordinary = scanAgent({ agent: 'native', project: 'repo', sessionsDir, tmux: {} }, { isAlive: () => true });
|
||||
assert.equal(ordinary.task, text.replace(/\s+/g, ' ').trim());
|
||||
assert.equal(ordinary.taskSource, 'first-user-message');
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'board-discord-'));
|
||||
mkdirSync(join(root, 'discord', 'demo', 'run.lock'), { recursive: true });
|
||||
writeFileSync(join(root, 'discord', 'demo.json'), JSON.stringify({ name: 'demo', seat: 'researcher', tokenFile: '/never-read-secret', guildId: 'PRIVATE-ID', users: ['PRIVATE-USER'] }), { mode: 0o600 });
|
||||
const sessions = join(root, 'sessions', 'discord-demo'); mkdirSync(sessions, { recursive: true });
|
||||
writeFileSync(join(sessions, 's.jsonl'), JSON.stringify({ type: 'message', timestamp: '2026-09-14T00:00:00Z', message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text: 'done' }] } }) + '\n');
|
||||
const owner = join(root, 'discord', 'demo', 'run.lock', 'owner.json');
|
||||
writeFileSync(owner, JSON.stringify({ pid: process.pid, ...identityOf(process.pid) }));
|
||||
return { root, owner, cleanup: () => rmSync(root, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
test('connector discovery keeps only safe identity; rejects modes, mismatches, links and traversal', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const result = discoverDiscordAgents(f.root);
|
||||
assert.equal(result.specs.length, 1); assert.deepEqual(result.errors, []);
|
||||
assert.equal(result.specs[0].agent, 'researcher (discord: demo)');
|
||||
assert.equal(JSON.stringify(result).includes('PRIVATE'), false); assert.equal(JSON.stringify(result).includes('tokenFile'), false);
|
||||
writeFileSync(join(f.root, 'discord', 'bad.json'), '{"name":"bad","seat":"../../escape","secret":"PRIVATE"}', { mode: 0o600 });
|
||||
symlinkSync(join(f.root, 'discord', 'demo.json'), join(f.root, 'discord', 'linked.json'));
|
||||
writeFileSync(join(f.root, 'discord', 'other.json'), '{"name":"wrong","seat":"x"}', { mode: 0o600 });
|
||||
chmodSync(join(f.root, 'discord', 'demo.json'), 0o644);
|
||||
const bad = discoverDiscordAgents(f.root); assert.equal(bad.specs.length, 0); assert.equal(bad.errors.length, 4);
|
||||
assert.equal(JSON.stringify(bad).includes('PRIVATE'), false);
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
|
||||
test('canonical owner identity and STOP are independent; no tmux fallback or forged registration', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const spec = discoverDiscordAgents(f.root).specs[0];
|
||||
const row = () => scanAgent(spec, { isAlive: () => { throw Error('must not ask tmux'); }, registration: { pid: process.pid, task: 'forged', taskSetBy: 'forged-setter', tmux: { session: 'forged' } } });
|
||||
assert.equal(row().alive, true); assert.equal(row().state, 'idle'); assert.equal(row().registered, null);
|
||||
// #1511: the connector's fixed task borrows no attribution from a native registration.
|
||||
assert.equal(row().task, 'Discord connector'); assert.equal(row().taskSource, 'connector'); assert.equal(row().taskSetBy, null);
|
||||
assert.equal(JSON.stringify(row()).includes('forged-setter'), false);
|
||||
writeFileSync(join(f.root, 'discord', 'demo', 'STOP'), 'PRIVATE BRAKE CONTENT');
|
||||
assert.equal(row().connector.braked, true); assert.equal(row().state, 'idle');
|
||||
assert.equal(JSON.stringify(row()).includes('PRIVATE'), false);
|
||||
assert.equal(inspectDiscord(spec.connector, { identity: () => ({ start: '1', boot: identityOf(process.pid).boot }) }).alive, false);
|
||||
assert.equal(inspectDiscord(spec.connector, { identity: () => ({ start: null, boot: null }) }).ownerState, 'unknown');
|
||||
assert.equal(inspectDiscord(spec.connector, { identity: () => ({ start: identityOf(process.pid).start, boot: '00000000-0000-0000-0000-000000000000' }) }).ownerState, 'mismatch');
|
||||
for (const value of ['broken PRIVATE', JSON.stringify({ pid: process.pid }), JSON.stringify({ pid: 2147483647, ...identityOf(process.pid) })]) {
|
||||
writeFileSync(f.owner, value); assert.equal(row().alive, false); assert.equal(row().state, 'offline');
|
||||
}
|
||||
rmSync(f.owner); assert.equal(row().connector.ownerState, 'absent');
|
||||
symlinkSync('/never-read-secret', f.owner); assert.equal(row().alive, false);
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
|
||||
test('connector reply refusal precedes forged live tmux registration; ordinary agent still sends', () => {
|
||||
let calls = 0; const exec = () => { calls++; return { status: 0, stdout: '', stderr: '' }; };
|
||||
const r = { agent: 'researcher (discord: demo)', project: 'fleet', connector: { binding: 'demo' }, registered: { alive: true, tmux: { session: 'forged' } } };
|
||||
assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/' + r.agent, text: 'hello', exec }).status, 409);
|
||||
delete r.connector;
|
||||
assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/' + r.agent, text: 'hello', exec }).status, 409);
|
||||
assert.equal(calls, 0);
|
||||
r.agent = 'ordinary'; assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/ordinary', text: 'hello', exec }).status, 200); assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('server rescans connector discovery and refuses HTTP reply without transport', async () => {
|
||||
const f = fixture(); let server;
|
||||
try {
|
||||
const sessionsDir = join(f.root, 'sessions', 'discord-demo'), seatsDir = join(f.root, 'seats');
|
||||
writeRegistration(seatsDir, makeRegistration({ resolved: { seat: 'forged', project: 'fleet', sessionsDir, seatDir: sessionsDir, launchScript: join(f.root, 'unused.sh'), layout: 'repo', defaultWorkspace: f.root }, task: 'forged', tmux: { session: 'forged', socket: null }, pid: process.pid }));
|
||||
server = await startServer({ port: 0, specs: [], seatsDir, boardDir: join(f.root, 'board'), discordDataRoot: f.root, exec: () => { throw Error('transport forbidden'); } });
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
let response = await fetch(base + '/api/board'); const board = await response.json();
|
||||
assert.equal(board.sessions[0].state, 'idle'); assert.equal(board.sessions[0].connector.alive, true);
|
||||
assert.equal(board.sessions[0].registered, null); assert.notEqual(board.sessions[0].task, 'forged');
|
||||
response = await fetch(base + '/api/reply', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: 'fleet/researcher (discord: demo)', text: 'hello' }) });
|
||||
assert.equal(response.status, 409);
|
||||
rmSync(join(f.root, 'discord', 'demo.json'));
|
||||
assert.equal((await (await fetch(base + '/api/board')).json()).sessions.length, 0);
|
||||
} finally { if (server) await new Promise(r => server.close(r)); f.cleanup(); }
|
||||
});
|
||||
|
||||
test('connector session links and linked directories are not read', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const sessions = join(f.root, 'sessions', 'discord-demo'); rmSync(sessions, { recursive: true });
|
||||
symlinkSync(join(f.root, 'discord'), sessions);
|
||||
const result = scan([], { boardDir: join(f.root, 'board'), discordDataRoot: f.root });
|
||||
assert.equal(result.sessions[0].sessionFile, null);
|
||||
rmSync(join(f.root, 'discord'), { recursive: true }); symlinkSync('/never-read-secret', join(f.root, 'discord'));
|
||||
assert.equal(discoverDiscordAgents(f.root).specs.length, 0);
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, appendFileSync, renameSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { scanAgent } from '../src/scan.mjs';
|
||||
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
|
||||
import { identityOf } from '../../discord/src/journal.mjs';
|
||||
|
||||
const oldAt = '2026-09-14T10:00:00.000Z';
|
||||
const launchAt = '2026-09-15T10:00:00.000Z';
|
||||
const newAt = '2026-09-15T10:01:00.000Z';
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'board-relaunch-'));
|
||||
const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir);
|
||||
const file = join(sessionsDir, 'session.jsonl');
|
||||
const message = (at, text) => JSON.stringify({ type: 'message', timestamp: at, message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }] } }) + '\n';
|
||||
writeFileSync(file, message(oldAt, 'Input needed: Historical request.'));
|
||||
const spec = { agent: 'fixture', project: 'repo', sessionsDir, tmux: {} };
|
||||
const registration = { ...makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Registered task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(launchAt) }), taskSetBy: 'fixture-setter' };
|
||||
const read = (extra = {}) => scanAgent(spec, { isAlive: () => true, isPidAlive: () => true, registration, now: () => new Date(newAt), ...extra });
|
||||
return { root, file, spec, registration, read, message, close: () => rmSync(root, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
test('newer live matching launch marks old activity, preserves history/attention/attribution, then clears on new activity', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const original = readFileSync(f.file, 'utf8');
|
||||
const row = f.read();
|
||||
assert.equal(row.relaunchedAt, launchAt);
|
||||
assert.equal(row.lastActivity, oldAt); assert.equal(row.lastAssistantText, 'Input needed: Historical request.');
|
||||
assert.equal(row.state, 'waiting'); assert.equal(row.waitingOnYou, true);
|
||||
assert.equal(row.taskSetBy, 'fixture-setter'); assert.equal(row.taskSource, 'registration');
|
||||
assert.equal(f.read({ seen: { 'repo/fixture': oldAt } }).seen, true);
|
||||
assert.equal(f.read({ seen: { 'repo/fixture': oldAt } }).waitingOnYou, false);
|
||||
assert.equal(readFileSync(f.file, 'utf8'), original, 'scanning never rewrites historical messages');
|
||||
appendFileSync(f.file, f.message(newAt, 'Fresh completion.'));
|
||||
const next = f.read(); assert.equal(next.relaunchedAt, null); assert.equal(next.state, 'idle');
|
||||
assert.equal(next.lastAssistantText, 'Fresh completion.'); assert.equal(next.lastActivity, newAt);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test('CLI print uses the relaunch notice instead of old current preview', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const state = join(f.root, '.pi', 'state', 'fixture'); mkdirSync(state, { recursive: true });
|
||||
const sessionsDir = join(state, 'sessions'); renameSync(f.spec.sessionsDir, sessionsDir);
|
||||
const dataRoot = join(f.root, 'data');
|
||||
writeRegistration(join(dataRoot, 'seats'), { ...f.registration, sessionsDir });
|
||||
const config = join(f.root, 'config.json'); writeFileSync(config, JSON.stringify({ dataRoot }));
|
||||
const run = spawnSync(process.execPath, [new URL('../src/cli.mjs', import.meta.url).pathname, 'scan', '--config', config, '--repo', f.root, '--fleet', 'none', '--liveness', 'assume-alive', '--print'], { encoding: 'utf8', timeout: 10000 });
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
assert.ok(run.stdout.includes(`relaunched at ${launchAt}, no messages since`));
|
||||
assert.equal(run.stdout.includes('Historical request.'), false);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test('connector owner and fixed task never inherit a native relaunch notice', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const journalDir = join(f.root, 'journal'); mkdirSync(join(journalDir, 'run.lock'), { recursive: true });
|
||||
writeFileSync(join(journalDir, 'run.lock', 'owner.json'), JSON.stringify({ pid: process.pid, ...identityOf(process.pid) }));
|
||||
const row = scanAgent({ ...f.spec, connector: { binding: 'fixture', journalDir } }, { registration: f.registration, isAlive: () => { throw Error('connector cannot use native liveness'); } });
|
||||
assert.equal(row.alive, true); assert.equal(row.relaunchedAt, null); assert.equal(row.task, 'Discord connector');
|
||||
assert.equal(row.taskSetBy, null); assert.equal(row.registered, null);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
|
||||
test('equality, stale/unknown/offline, mismatched registration and unknown activity do not assert relaunch', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
assert.equal(f.read({ registration: { ...f.registration, startedAt: oldAt } }).relaunchedAt, null);
|
||||
for (const value of [false, null]) assert.equal(f.read({ isPidAlive: () => value }).relaunchedAt, null);
|
||||
for (const value of [false, null]) assert.equal(f.read({ isAlive: () => value }).relaunchedAt, null);
|
||||
assert.equal(f.read({ registration: null }).relaunchedAt, null);
|
||||
assert.equal(f.read({ registration: { ...f.registration, sessionsDir: join(f.root, 'different') } }).relaunchedAt, null);
|
||||
assert.equal(f.read({ registration: { ...f.registration, startedAt: 'invalid' } }).relaunchedAt, null);
|
||||
writeFileSync(f.file, f.message('invalid', 'Undated history.'));
|
||||
assert.equal(f.read().relaunchedAt, null);
|
||||
writeFileSync(f.file, JSON.stringify({ type: 'message', message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text: 'No timestamp.' }] } }) + '\n');
|
||||
assert.equal(f.read().relaunchedAt, null);
|
||||
rmSync(f.file); assert.equal(f.read().relaunchedAt, null);
|
||||
} finally { f.close(); }
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -176,8 +176,8 @@ test("startServer: serves page, healthz, and a rescanning /api/board", async ()
|
||||
assert.equal(first.headers.get("content-type"), "application/json");
|
||||
assert.equal(first.headers.get("cache-control"), "no-store");
|
||||
const firstBody = await first.json();
|
||||
assert.equal(firstBody.sessions[0].state, "waiting");
|
||||
assert.ok(firstBody.waitingOnYou.includes("proj/agent1"));
|
||||
assert.equal(firstBody.sessions[0].state, "idle");
|
||||
assert.ok(!firstBody.waitingOnYou.includes("proj/agent1"));
|
||||
|
||||
assert.ok(existsSync(join(boardDir, "index.json")), "index.json must be written by the scan");
|
||||
assert.ok(existsSync(join(boardDir, "sessions", "proj", "agent1.json")), "per-agent file must be written by the scan");
|
||||
@@ -397,7 +397,7 @@ test("POST /api/seen marks a row; GET /api/board still shows it seen; seen:false
|
||||
const sessionsDir = join(root, "sessions");
|
||||
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 boardDir = join(root, "board");
|
||||
const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
|
||||
@@ -557,7 +557,7 @@ test("CLI: scan --print marks a seen row with 's' and the summary line ends with
|
||||
join(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."] }),
|
||||
].join("\n") + "\n"
|
||||
);
|
||||
const boardDir = join(dataRoot, "board");
|
||||
@@ -881,3 +881,64 @@ test("page.html: the reply box appears only where canReply() holds (live registr
|
||||
assert.match(html, /function withReplyFocus\(render\)/, "a refresh gives the caret back to the reply box");
|
||||
assert.doesNotMatch(html, /send-keys/, "the page never talks tmux");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task attribution (#1511): serialized by the server, rendered by page.html.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("startServer: /api/board carries taskSetBy from a live registration and null for the derived rows", async () => {
|
||||
const root = makeRoot();
|
||||
const sessionsDir = join(root, "sessions");
|
||||
const derivedDir = join(root, "derived-sessions");
|
||||
for (const dir of [sessionsDir, derivedDir]) {
|
||||
writeSessionFile(dir, "s.jsonl", [
|
||||
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
|
||||
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "user", texts: ["derived task"] }),
|
||||
]);
|
||||
}
|
||||
const seatsDir = join(root, "seats");
|
||||
const reg = makeRegistration({
|
||||
resolved: { seat: "agent1", project: "proj", sessionsDir, seatDir: join(root, "agent1"), launchScript: join(root, "agent1", "launch.sh"), layout: "repo", defaultWorkspace: null },
|
||||
task: "registered task",
|
||||
});
|
||||
writeRegistration(seatsDir, { ...reg, taskSetBy: "darkwing" });
|
||||
const specs = [
|
||||
{ agent: "agent1", project: "proj", sessionsDir, tmux: {} },
|
||||
{ agent: "agent2", project: "proj", sessionsDir: derivedDir, tmux: {} },
|
||||
];
|
||||
const server = await startServer({ host: "127.0.0.1", port: 0, specs, boardDir: join(root, "board"), seatsDir, isAlive: () => ({ alive: true, workspace: null }), page: "<html></html>" });
|
||||
try {
|
||||
const body = await (await fetch(`http://127.0.0.1:${server.address().port}/api/board`)).json();
|
||||
const byAgent = Object.fromEntries(body.sessions.map((r) => [r.agent, r]));
|
||||
assert.equal(byAgent.agent1.taskSource, "registration");
|
||||
assert.equal(byAgent.agent1.taskSetBy, "darkwing");
|
||||
assert.equal(byAgent.agent2.taskSource, "first-user-message");
|
||||
assert.equal(byAgent.agent2.taskSetBy, null);
|
||||
assert.deepEqual(body.registrationErrors, []);
|
||||
} finally {
|
||||
await new Promise((r) => server.close(r));
|
||||
}
|
||||
});
|
||||
|
||||
test("page.html: the task cell and detail show who set a registered task via setByTag()/setByText(), both escaped, only from rec.taskSetBy; the reply gate does not read it", () => {
|
||||
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
|
||||
const lines = html.split("\n");
|
||||
const rowPair = html.match(/function buildRowPair\(rec, showProject\) \{[\s\S]*?\n \}/);
|
||||
assert.ok(rowPair);
|
||||
assert.match(rowPair[0], /sourceTag\(rec\.taskSource\) \+ setByTag\(rec\)/, "the set-by tag follows the task's source tag on the row");
|
||||
assert.match(rowPair[0], /<dt>Task set by<\/dt><dd>" \+ setByText\(rec\) \+ "<\/dd>/, "the detail has a Task set by row");
|
||||
const tagLine = lines.find((l) => l.includes("function setByTag(rec)"));
|
||||
assert.ok(tagLine);
|
||||
assert.match(tagLine, /rec\.taskSetBy \?/, "nothing is shown without a value");
|
||||
assert.equal((tagLine.match(/esc\(rec\.taskSetBy\)/g) || []).length, 2, "the title and the text are both escaped");
|
||||
assert.match(tagLine, /not verified/);
|
||||
const textLine = lines.find((l) => l.includes("function setByText(rec)"));
|
||||
assert.ok(textLine);
|
||||
assert.match(textLine, /esc\(rec\.taskSetBy\)/);
|
||||
assert.match(textLine, /not verified/);
|
||||
// Attribution never enables a reply: canReply() reads the registration only.
|
||||
const canReplyFn = html.match(/function canReply\(rec\) \{[\s\S]*?\n \}/);
|
||||
assert.ok(canReplyFn);
|
||||
assert.equal(canReplyFn[0].includes("taskSetBy"), false);
|
||||
assert.equal(html.match(/function replyControl\(rec\) \{[\s\S]*?\n \}/)[0].includes("taskSetBy"), false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user