diff --git a/docs/scratchpads/h1b-pane-idle-signal.md b/docs/scratchpads/h1b-pane-idle-signal.md new file mode 100644 index 0000000..a360014 --- /dev/null +++ b/docs/scratchpads/h1b-pane-idle-signal.md @@ -0,0 +1,53 @@ +# H1b — tmux pane idle signal wiring + +## Objective + +Feed `classifyReadiness()` a real idle signal on tmux 3.4 by deriving `idleSeconds` from the first available tmux timestamp source: pane activity, then window activity, then session activity. + +## Scope + +- `packages/mosaic/src/commands/fleet.ts` + - Extend `buildTmuxListPanesCommand()` format to include `#{window_activity}` and `#{session_activity}` after the existing fields. + - Update `parseTmuxListPanes()` to choose the first non-empty finite positive timestamp and clamp future idle values to 0. +- `packages/mosaic/src/commands/fleet.spec.ts` + - Cover pane/window/session activity parsing behavior, empty-field index alignment, null idle, future clamping, math correctness, and exact tmux format. + +## Out of Scope + +- No changes to `classifyReadiness()`, thresholds, `AgentPsRow`, or `fleet ps` rendering. +- No merge by worker; orchestrator routes review/merge. +- Workers do not modify `docs/TASKS.md`. + +## PRD Alignment + +Aligned with `docs/fleet/PRD.md` FR-1 and acceptance criteria for truthful `mosaic fleet ps` pane/pid/idle observability. + +## Plan + +1. Sync branch from latest `origin/main` and install dependencies with required pnpm env. +2. Add/confirm reproducer tests for tmux 3.4 empty `pane_activity` and new fallback behavior. +3. Implement the focused parser/format change only. +4. Run required build, baseline gates, fleet vitest, and independent review. +5. Run pre-push queue guard, push branch, and open PR to `main` with Mosaic wrapper. + +## Progress + +- 2026-06-24: Branch `fix/fleet-pane-idle-activity` created from `origin/main` @ `ec8dd7c` after fetching. +- 2026-06-24: Session-start generated local `.mosaic/orchestrator/*` changes on the previous release branch; stashed as `coder1 session-start state before H1b` to keep this branch clean. +- 2026-06-24: Added TDD coverage for the tmux 3.4 production case (`pane_activity` empty, `window_activity` populated), exact new list-panes format, null/future/multiple-source behavior. +- 2026-06-24: Implemented parser fallback without changing readiness classifier thresholds or render shape. + +## Verification Evidence + +- `pnpm install --store-dir "$HOME/.pnpm-store"` — pass. +- Reproducer before implementation: `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts` — failed as expected (old format, no fallback, negative future idle). +- `npx turbo build --filter=@mosaicstack/mosaic^...` — pass, 12/12 tasks successful. +- `pnpm typecheck` — pass, 41/41 tasks successful. +- `pnpm lint` — pass, 23/23 tasks successful. +- `pnpm format:check` — pass, all matched files use Prettier style. +- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts` — pass, 176 tests. +- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings (reviewed supplied diff; sandbox file-inspection limitation noted by tool). + +## Risks / Blockers + +- No current blocker. diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index cf0056e..ad87490 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -855,7 +855,7 @@ describe('fleet ps — command construction', () => { '-t', '=canary-pi:0.0', '-F', - '#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity}', + '#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity} #{window_activity} #{session_activity}', ]); }); @@ -1079,9 +1079,11 @@ describe('fleet ps — systemd show parsing', () => { describe('fleet ps — tmux list-panes parsing', () => { const NOW_MS = 1_700_000_000_000; - it('parses alive pane with pid, command, and idle time', () => { - const activityEpoch = Math.floor((NOW_MS - 30_000) / 1000); // 30s ago - const output = `12345 claude 0 ${activityEpoch}\n`; + it('uses pane_activity when present', () => { + const paneActivityEpoch = Math.floor((NOW_MS - 30_000) / 1000); // 30s ago + const windowActivityEpoch = Math.floor((NOW_MS - 60_000) / 1000); // 60s ago + const sessionActivityEpoch = Math.floor((NOW_MS - 90_000) / 1000); // 90s ago + const output = `12345 claude 0 ${paneActivityEpoch} ${windowActivityEpoch} ${sessionActivityEpoch}\n`; const result = parseTmuxListPanes(output, NOW_MS); expect(result.pid).toBe(12345); expect(result.command).toBe('claude'); @@ -1089,8 +1091,45 @@ describe('fleet ps — tmux list-panes parsing', () => { expect(result.idleSeconds).toBe(30); }); + it('uses window_activity when pane_activity is empty', () => { + const windowActivityEpoch = Math.floor((NOW_MS - 45_000) / 1000); // 45s ago + const sessionActivityEpoch = Math.floor((NOW_MS - 90_000) / 1000); // 90s ago + const output = `12345 node 0 ${windowActivityEpoch} ${sessionActivityEpoch}\n`; + expect(output).toContain('0 '); // empty pane_activity preserves index alignment + const result = parseTmuxListPanes(output, NOW_MS); + expect(result.pid).toBe(12345); + expect(result.command).toBe('node'); + expect(result.dead).toBe(false); + expect(result.idleSeconds).toBe(45); + }); + + it('uses session_activity when pane_activity and window_activity are empty', () => { + const sessionActivityEpoch = Math.floor((NOW_MS - 75_000) / 1000); // 75s ago + const output = `12345 node 0 ${sessionActivityEpoch}\n`; + const result = parseTmuxListPanes(output, NOW_MS); + expect(result.idleSeconds).toBe(75); + }); + + it('reports null idleSeconds when all activity sources are empty', () => { + const output = '12345 node 0 \n'; + const result = parseTmuxListPanes(output, NOW_MS); + expect(result.idleSeconds).toBeNull(); + }); + + it('computes exact idle seconds from now minus epoch seconds', () => { + const activityEpoch = 1_699_999_877; + const result = parseTmuxListPanes(`12345 claude 0 ${activityEpoch} 0 0\n`, NOW_MS); + expect(result.idleSeconds).toBe(123); + }); + + it('clamps future activity epochs to 0 idle seconds', () => { + const futureActivityEpoch = Math.floor((NOW_MS + 30_000) / 1000); + const result = parseTmuxListPanes(`12345 claude 0 ${futureActivityEpoch} 0 0\n`, NOW_MS); + expect(result.idleSeconds).toBe(0); + }); + it('reports dead pane when pane_dead=1', () => { - const output = `0 bash 1 0\n`; + const output = `0 bash 1 0 0 0\n`; const result = parseTmuxListPanes(output, NOW_MS); expect(result.dead).toBe(true); }); diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index 2cecf0e..073f6c6 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -524,7 +524,7 @@ export function buildSystemdShowCommand(agentName: string): string[] { /** * Returns the tmux list-panes command for an agent pane. - * Format: `#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity}` + * Format: `#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity} #{window_activity} #{session_activity}` */ export function buildTmuxListPanesCommand(agentName: string, socketName = ''): string[] { return [ @@ -534,7 +534,7 @@ export function buildTmuxListPanesCommand(agentName: string, socketName = ''): s '-t', `=${agentName}:0.0`, '-F', - '#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity}', + '#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity} #{window_activity} #{session_activity}', ]; } @@ -634,8 +634,8 @@ export function parseSystemdShow(output: string): { } /** - * Parse the output of `tmux list-panes -F '#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity}'` - * pane_activity is a Unix epoch timestamp (seconds). + * Parse the output of `tmux list-panes -F '#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity} #{window_activity} #{session_activity}'` + * Activity fields are Unix epoch timestamps (seconds), ordered most precise to coarsest. */ export function parseTmuxListPanes( output: string, @@ -645,16 +645,18 @@ export function parseTmuxListPanes( if (!line) { return { pid: null, command: null, dead: true, idleSeconds: null }; } - // format: + // format: const parts = line.split(' '); const pid = parts[0] ? (Number.isFinite(Number(parts[0])) ? Number(parts[0]) : null) : null; const command = parts[1] ?? null; const dead = parts[2] === '1'; - const activityEpoch = parts[3] ? Number(parts[3]) : NaN; - const idleSeconds = - Number.isFinite(activityEpoch) && activityEpoch > 0 - ? Math.floor((nowMs - activityEpoch * 1000) / 1000) - : null; + const activityEpoch = parts + .slice(3, 6) + .map((part) => (part ? Number(part) : NaN)) + .find((epoch) => Number.isFinite(epoch) && epoch > 0); + const idleSeconds = activityEpoch + ? Math.max(0, Math.floor((nowMs - activityEpoch * 1000) / 1000)) + : null; return { pid, command, dead, idleSeconds }; }