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:
2026-09-17 16:01:59 +07:00
parent 02ee46c862
commit 686b80bd99
11 changed files with 132 additions and 35 deletions
+41 -12
View File
@@ -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
View File
@@ -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);
}