fix(lanes,reload): count a pipeline run by declarations, and stop the two reload commands merging
Closes #26, #25. `runs` is the denominator every pass rate on the Workflows page is read against, and any backwards move opened a new one. Stage detection bounces by design — one feature reads `implement → tests → implement` as the session edits, runs the suite and edits again — so a single feature was recorded as five or six runs, and a lane in constant use reported a handful of passes spread across dozens of phantom runs. A backwards move now opens a run only when it is DECLARED; a backwards detection continues the run in flight. Reaching the final node still closes the run for either, since work after `done` is new work however it was observed. Replayed over this repo's own lane the rule takes 26 recorded runs down to the 5 features that actually shipped through it. The panel itself is unchanged: passes are still declarations only. `/reload-plugins` and `/reload-skills` were typed back to back. Claude Code opens its slash-command menu the moment `/` is typed and the Enter that follows lands on that menu, so the second command was appended to the line still in the composer and the pane submitted `/reload-plugins/reload-skills` — which Claude Code runs as `/reload-plugins` with an argument, never reloading skills, and leaving a stray prompt turn in the scrollback. Reproduced and fixed against a live pane: SETTLE_MS (1s) between the two sends, which makes `reloadOpenSessions` async rather than block the event loop, and the same pause in the detached update-all worker.
This commit is contained in:
@@ -103,11 +103,11 @@ describe("reloadableLanes", () => {
|
||||
describe("reloadOpenSessions", () => {
|
||||
beforeEach(() => tmux.__reset());
|
||||
|
||||
it("types both reload commands, plugins before skills", () => {
|
||||
it("types both reload commands, plugins before skills", async () => {
|
||||
// Order matters: a skill shipped BY a plugin only exists on disk once the
|
||||
// plugin itself has been reloaded.
|
||||
const calls = stubTmux(["agenthub-lane-7"]);
|
||||
const out = reloadOpenSessions();
|
||||
const out = await reloadOpenSessions({ settleMs: 0 });
|
||||
|
||||
assert.deepEqual(out.commands, ["/reload-plugins", "/reload-skills"]);
|
||||
assert.equal(out.lanes_total, 1);
|
||||
@@ -119,27 +119,45 @@ describe("reloadOpenSessions", () => {
|
||||
assert.equal(calls.filter((c) => c[0] === "send-keys" && c[3] === "Enter").length, 2);
|
||||
});
|
||||
|
||||
it("honours a narrowed kinds list", () => {
|
||||
it("waits between the two commands so they cannot share one composer line", async () => {
|
||||
// Without the pause the first Enter lands on Claude Code's slash-command
|
||||
// menu, the second command is appended to the line still in the composer,
|
||||
// and the pane submits `/reload-plugins/reload-skills` as a prompt — the
|
||||
// reported bug. Timing is the behaviour, so timing is what is asserted.
|
||||
const calls = stubTmux(["agenthub-lane-1"]);
|
||||
const out = reloadOpenSessions({ kinds: ["skills"] });
|
||||
const at = [];
|
||||
const typed = () => calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").length;
|
||||
const before = typed();
|
||||
const started = Date.now();
|
||||
const done = reloadOpenSessions({ settleMs: 40 });
|
||||
at.push(typed() - before);
|
||||
await done;
|
||||
|
||||
assert.equal(at[0], 1, "only the first command is typed before the wait");
|
||||
assert.ok(Date.now() - started >= 35, "the second command waited for the first to submit");
|
||||
});
|
||||
|
||||
it("honours a narrowed kinds list", async () => {
|
||||
const calls = stubTmux(["agenthub-lane-1"]);
|
||||
const out = await reloadOpenSessions({ kinds: ["skills"], settleMs: 0 });
|
||||
assert.deepEqual(out.commands, ["/reload-skills"]);
|
||||
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
||||
assert.deepEqual(typed, ["/reload-skills"]);
|
||||
});
|
||||
|
||||
it("ignores an unknown kind rather than typing it into a REPL", () => {
|
||||
it("ignores an unknown kind rather than typing it into a REPL", async () => {
|
||||
// The command set is fixed in the module; a pane's stdin is not a place to
|
||||
// forward caller-supplied strings.
|
||||
const calls = stubTmux(["agenthub-lane-1"]);
|
||||
const out = reloadOpenSessions({ kinds: ["plugins", "rm -rf /"] });
|
||||
const out = await reloadOpenSessions({ kinds: ["plugins", "rm -rf /"], settleMs: 0 });
|
||||
assert.deepEqual(out.commands, ["/reload-plugins"]);
|
||||
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
||||
assert.deepEqual(typed, ["/reload-plugins"]);
|
||||
});
|
||||
|
||||
it("types into several lanes in one call", () => {
|
||||
it("types into several lanes in one call", async () => {
|
||||
stubTmux(["agenthub-lane-1", "agenthub-lane-2"]);
|
||||
const out = reloadOpenSessions();
|
||||
const out = await reloadOpenSessions({ settleMs: 0 });
|
||||
assert.equal(out.lanes_total, 2);
|
||||
assert.deepEqual(
|
||||
out.lanes.map((l) => l.laneId).sort((a, b) => a - b),
|
||||
@@ -147,9 +165,9 @@ describe("reloadOpenSessions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("records a pane that dies mid-send without failing the others", () => {
|
||||
it("records a pane that dies mid-send without failing the others", async () => {
|
||||
stubTmux(["agenthub-lane-1", "agenthub-lane-2"], { "agenthub-lane-1": "__send-fails__" });
|
||||
const out = reloadOpenSessions();
|
||||
const out = await reloadOpenSessions({ settleMs: 0 });
|
||||
const failed = out.lanes.find((l) => l.id === "agenthub-lane-1");
|
||||
const ok = out.lanes.find((l) => l.id === "agenthub-lane-2");
|
||||
assert.match(failed.error, /died mid-send/);
|
||||
@@ -158,9 +176,9 @@ describe("reloadOpenSessions", () => {
|
||||
assert.equal(ok.error, undefined);
|
||||
});
|
||||
|
||||
it("reports an empty result instead of throwing when nothing is open", () => {
|
||||
it("reports an empty result instead of throwing when nothing is open", async () => {
|
||||
stubTmux([]);
|
||||
const out = reloadOpenSessions();
|
||||
const out = await reloadOpenSessions({ settleMs: 0 });
|
||||
assert.equal(out.lanes_total, 0);
|
||||
assert.deepEqual(out.lanes, []);
|
||||
});
|
||||
|
||||
@@ -93,6 +93,26 @@ describe("lane_stage_events", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the run in flight when a DETECTION moves backwards", () => {
|
||||
// Detection bounces by design: one feature reads implement → tests →
|
||||
// implement as the session edits, runs the suite and edits again. Counting
|
||||
// each bounce as a new run recorded this repo's own lane as 26 features
|
||||
// when 5 had shipped, and `runs` is what every pass rate is divided by.
|
||||
lanesLib.recordDetection(laneId, { nodeId: "implement", signal: "`edit`" });
|
||||
lanesLib.recordDetection(laneId, { nodeId: "tests", signal: "`npm test`" });
|
||||
// The bounce back only reaches the log once the standing detection has aged
|
||||
// past the TTL — forward-only holds while it is fresh (see recordDetection).
|
||||
db.prepare("UPDATE lanes SET detected_at = '2020-01-01T00:00:00.000Z' WHERE id = ?").run(
|
||||
laneId
|
||||
);
|
||||
lanesLib.recordDetection(laneId, { nodeId: "implement", signal: "`edit`" });
|
||||
|
||||
assert.deepEqual(
|
||||
events().map((r) => `${r.node_id}#${r.run_seq}`),
|
||||
["implement#1", "tests#1", "implement#1"]
|
||||
);
|
||||
});
|
||||
|
||||
it("starts a new run when the pipeline's last node is left behind", () => {
|
||||
lanesLib.setStage(laneId, { stage: "done" });
|
||||
lanesLib.setStage(laneId, { stage: "intake" });
|
||||
|
||||
+41
-12
@@ -29,6 +29,20 @@ const { listRuns } = require("./pty-run");
|
||||
/** Shell process names — a pane sitting at one of these has no REPL to reload. */
|
||||
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
|
||||
|
||||
/**
|
||||
* Pause between two commands typed into the SAME pane.
|
||||
*
|
||||
* Claude Code's REPL opens a slash-command menu as soon as `/` is typed, and
|
||||
* the Enter that follows lands on that menu rather than submitting the line.
|
||||
* With no pause the next command's text is appended to the line still sitting
|
||||
* in the composer, and what gets submitted is the single string
|
||||
* `/reload-plugins/reload-skills` — which Claude Code runs as `/reload-plugins`
|
||||
* with `/reload-skills` as its argument, so the second reload never happens and
|
||||
* the pane shows a stray prompt turn. Measured against a live pane: 0.3s is
|
||||
* already enough on an idle one, 1s leaves headroom for a busy REPL.
|
||||
*/
|
||||
const SETTLE_MS = 1000;
|
||||
|
||||
/** The only commands this module is ever allowed to type. Not caller-supplied:
|
||||
* a pane's stdin is an arbitrary-code boundary, so the set is fixed here. */
|
||||
const RELOAD_COMMANDS = {
|
||||
@@ -61,30 +75,39 @@ function reloadableLanes() {
|
||||
/**
|
||||
* Type the requested reload commands into every live `claude` pane.
|
||||
*
|
||||
* Sends to a pane even when its agent is mid-turn: tmux queues the keystrokes
|
||||
* into the REPL's input buffer, so the command lands when the turn ends rather
|
||||
* than being lost. That is a deliberate choice — a reload that arrives late is
|
||||
* still a reload, whereas skipping busy panes silently leaves exactly the
|
||||
* sessions the user is actually working in running stale code.
|
||||
* Sends to a pane even when its agent is mid-turn: the keystrokes land in the
|
||||
* REPL's composer, so the command runs when the turn ends rather than being
|
||||
* lost. That is a deliberate choice — a reload that arrives late is still a
|
||||
* reload, whereas skipping busy panes silently leaves exactly the sessions the
|
||||
* user is actually working in running stale code. It is also why SETTLE_MS
|
||||
* exists: a composer that has not submitted yet is a line the next command
|
||||
* would be appended to.
|
||||
*
|
||||
* Never throws: one unreachable pane (killed between the listing and the send)
|
||||
* must not fail a batch that updated everything else successfully.
|
||||
*
|
||||
* @param {{kinds?: ("plugins"|"skills")[]}} [options] which commands to send;
|
||||
* defaults to both, plugins first (a skill shipped BY a plugin only exists on
|
||||
* disk once the plugin has been reloaded).
|
||||
* @returns {{lanes: {id: string, laneId: number|null, sent: string[], error?: string}[],
|
||||
* commands: string[], lanes_total: number}}
|
||||
* Async because of SETTLE_MS above: the two commands must reach a pane as two
|
||||
* separate submissions, and waiting for that without blocking the event loop is
|
||||
* the only way a live dashboard keeps broadcasting while it types.
|
||||
*
|
||||
* @param {{kinds?: ("plugins"|"skills")[], settleMs?: number}} [options] which
|
||||
* commands to send; defaults to both, plugins first (a skill shipped BY a
|
||||
* plugin only exists on disk once the plugin has been reloaded). `settleMs`
|
||||
* is a test seam — no route passes it.
|
||||
* @returns {Promise<{lanes: {id: string, laneId: number|null, sent: string[], error?: string}[],
|
||||
* commands: string[], lanes_total: number}>}
|
||||
*/
|
||||
function reloadOpenSessions(options = {}) {
|
||||
async function reloadOpenSessions(options = {}) {
|
||||
const kinds = (options.kinds || ["plugins", "skills"]).filter((k) => k in RELOAD_COMMANDS);
|
||||
const commands = kinds.map((k) => RELOAD_COMMANDS[k]);
|
||||
const settleMs = options.settleMs ?? SETTLE_MS;
|
||||
const lanes = [];
|
||||
|
||||
for (const lane of reloadableLanes()) {
|
||||
const sent = [];
|
||||
let error;
|
||||
for (const command of commands) {
|
||||
if (sent.length && settleMs > 0) await new Promise((r) => setTimeout(r, settleMs));
|
||||
try {
|
||||
tmux.sendText(lane.id, command);
|
||||
sent.push(command);
|
||||
@@ -99,4 +122,10 @@ function reloadOpenSessions(options = {}) {
|
||||
return { commands, lanes, lanes_total: lanes.length };
|
||||
}
|
||||
|
||||
module.exports = { reloadOpenSessions, reloadableLanes, RELOAD_COMMANDS, SHELL_COMMANDS };
|
||||
module.exports = {
|
||||
reloadOpenSessions,
|
||||
reloadableLanes,
|
||||
RELOAD_COMMANDS,
|
||||
SHELL_COMMANDS,
|
||||
SETTLE_MS,
|
||||
};
|
||||
|
||||
+13
-4
@@ -398,9 +398,17 @@ function reservedPorts(excludeLaneId = null) {
|
||||
*
|
||||
* The run a row belongs to is decided here rather than stored on the lane, so
|
||||
* there is one rule and no column to fall out of sync. A new run starts when
|
||||
* the lane moves BACKWARDS (the next feature begins at plan) or when the last
|
||||
* event sat on the pipeline's final node (the previous feature finished). Any
|
||||
* other move continues the run in flight.
|
||||
* the lane is DECLARED backwards (the next feature begins at plan) or when the
|
||||
* last event sat on the pipeline's final node (the previous feature finished).
|
||||
* Any other move continues the run in flight.
|
||||
*
|
||||
* Only a declaration may open a run by going backwards. Detection bounces by
|
||||
* design — one feature reads `implement → tests → implement` as the session
|
||||
* edits, runs the suite and edits again — so counting every backwards detection
|
||||
* as a new run recorded a single feature as five or six of them, and `runs` is
|
||||
* the denominator the Workflows page reads every pass rate against. A pass ends
|
||||
* where the session says it ends. Reaching the final node still closes the run
|
||||
* for a detection too: work after `done` is new work however it was observed.
|
||||
*
|
||||
* Never throws: a lane's stage must still advance if this log cannot be
|
||||
* written — the dashboard losing a statistic beats an agent's pipeline dying.
|
||||
@@ -415,7 +423,8 @@ function appendStageEvent(lane, nodeId, { source, result, evidence } = {}) {
|
||||
if (last) {
|
||||
const lastIdx = phaseIdx(pipeline, last.node_id);
|
||||
const nextIdx = phaseIdx(pipeline, nodeId);
|
||||
const wentBack = lastIdx > -1 && nextIdx > -1 && nextIdx < lastIdx;
|
||||
const declared = source !== "detected";
|
||||
const wentBack = declared && lastIdx > -1 && nextIdx > -1 && nextIdx < lastIdx;
|
||||
const finished = lastIdx > -1 && lastIdx === pipeline.nodes.length - 1 && nextIdx !== lastIdx;
|
||||
runSeq = last.run_seq + (wentBack || finished ? 1 : 0);
|
||||
}
|
||||
|
||||
@@ -1634,7 +1634,7 @@ const paths = {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Tell the open Claude Code sessions to re-read plugins and skills",
|
||||
description:
|
||||
'Types `/reload-plugins` and `/reload-skills` into the dashboard\'s live lane panes over `tmux send-keys`. Both commands are scoped BY CLAUDE CODE to the session that runs them, so there is no way to reload a session from outside — typing into a session that is already running is what a human would do, and it is what this does. It spawns no `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session and leave a junk session behind. Changes nothing on disk, so it does not queue on the CLI mutation chain and emits no `cc_config_changed`. Panes sitting at a shell prompt are skipped (the text would run as a shell command); only `claude` lanes are addressed, since Codex has no such command. Optional body `{kinds:["plugins"|"skills"]}` narrows which commands are sent; unknown kinds are ignored, as the command strings are fixed server-side.',
|
||||
'Types `/reload-plugins` and `/reload-skills` into the dashboard\'s live lane panes over `tmux send-keys`. Both commands are scoped BY CLAUDE CODE to the session that runs them, so there is no way to reload a session from outside — typing into a session that is already running is what a human would do, and it is what this does. It spawns no `claude` process: a one-shot `claude -p "/reload-plugins"` would reload only its own short-lived session and leave a junk session behind. Changes nothing on disk, so it does not queue on the CLI mutation chain and emits no `cc_config_changed`. Panes sitting at a shell prompt are skipped (the text would run as a shell command); only `claude` lanes are addressed, since Codex has no such command. The two commands are typed one second apart (`SETTLE_MS`): Claude Code opens its slash-command menu as soon as `/` is typed and swallows the Enter that follows, so back-to-back sends merged into the single line `/reload-plugins/reload-skills`, which runs as `/reload-plugins` with an argument and never reloads skills. Optional body `{kinds:["plugins"|"skills"]}` narrows which commands are sent; unknown kinds are ignored, as the command strings are fixed server-side.',
|
||||
operationId: "ccConfigReload",
|
||||
responses: {
|
||||
200: {
|
||||
|
||||
@@ -334,7 +334,7 @@ router.post("/plugins/action", async (req, res) => {
|
||||
let reloaded;
|
||||
if (reload && result.ok) {
|
||||
try {
|
||||
reloaded = reloadOpenSessions();
|
||||
reloaded = await reloadOpenSessions();
|
||||
} catch {
|
||||
/* a reload that cannot be delivered must not fail the update itself */
|
||||
}
|
||||
@@ -351,7 +351,7 @@ router.post("/plugins/action", async (req, res) => {
|
||||
* sessions to re-read what is already there — so it neither queues on the CLI
|
||||
* mutation chain nor emits `cc_config_changed`.
|
||||
*/
|
||||
router.post("/reload", (req, res) => {
|
||||
router.post("/reload", async (req, res) => {
|
||||
const { kinds } = req.body || {};
|
||||
if (kinds !== undefined && !Array.isArray(kinds)) {
|
||||
return res.status(400).json({
|
||||
@@ -359,7 +359,7 @@ router.post("/reload", (req, res) => {
|
||||
});
|
||||
}
|
||||
try {
|
||||
res.json({ ok: true, ...reloadOpenSessions({ kinds }) });
|
||||
res.json({ ok: true, ...(await reloadOpenSessions({ kinds })) });
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
error: { code: "ERELOAD", message: err.message || String(err) },
|
||||
|
||||
Reference in New Issue
Block a user