fix(slice-15): adopted-rig hasAssignedWork resolves both raw + canonical destination forms (qa BLOCKING-A2)

QA baseline-deep-dogfood BLOCKING-A2 (qitem-20260518063900-85745917):
adopted/live-session rigs do NOT surface assigned queue work. Root
cause: hasAssignedWork looks up pending qitems by
entry.canonicalSessionName ONLY.

For managed seats, canonicalSessionName is set by
deriveCanonicalSessionName at materialize time and equals the
canonical `{pod}-{member}@{rig}` form — the same form operators
address via `rig queue create --destination`. The single-key lookup
works.

For adopted seats, canonicalSessionName is the RAW tmux session name
(whatever the adopter chose, e.g., `my-existing-claude`). Operators
address adopted seats via the canonical `{pod}-{member}@{rig}` form
(derived from logicalId + rigName), so the single-key lookup misses
and adopted seats appear to have nothing assigned even when qitems
are pending.

Fix (tight, NO broad identity refactor per orch-lead's caution):
- New private countPendingForEntry resolves pending count by BOTH
  forms via a deduplicating Set:
  1. entry.canonicalSessionName (covers managed + adopted-by-raw)
  2. derived `{pod}-{member}@{rig}` from logicalId + rigName (covers
     adopted-by-canonical)
- The dot in logicalId (pod-aware `pod.member`) is split at the FIRST
  dot to produce pod + member, matching deriveCanonicalSessionName's
  format.
- Set-based dedup means managed seats (where canonicalSessionName ===
  derived canonical) never double-count.

Discriminators added (test-driven; 3 of the 6 NEW tests failed PRE-FIX
to prove the regression class, all 13 tests PASS post-fix):
- adopted entry + queue dest=canonical form → hasAssignedWork true
- adopted entry + queue dest=raw tmux name still matches (back-compat)
- managed entry → unchanged behavior
- no double-count when both forms equal
- separate dests at raw + canonical sum correctly (3 items = 3)
- logical IDs with dots normalized via deriveCanonicalSessionName

Test totals: 13/13 attachTerminalActivityAndWork; 45/45 adjacent
slice-15 (graph-projection-pl019 + ps-projection + seat-activity-service).
Daemon tsc EXIT=0.

BRANCH: slice-15-fix-adopted-queue-identity off main cfec5508. LOCAL,
NO push.
This commit is contained in:
mvschwarz committed 2026-05-17 23:48:21 -07:00
1 parent cfec550801
commit 1aa0c5237e
2 files changed
+150 -3

No files matched your search

+57 -3
View File
@@ -8,6 +8,7 @@ import type { TmuxAdapter } from "../adapters/tmux.js";
import { probeSessionActivity } from "./session-transport.js";
import { findLatestUsableSnapshot } from "./rig-repository.js";
import { resolveNodeWorkspace } from "./workspace/workspace-resolver.js";
import { deriveCanonicalSessionName } from "./session-name.js";
// -- Row types for SQL results --
@@ -656,9 +657,7 @@ export function attachTerminalActivityAndWork(
const obs = seatActivity.getSeatActivity(entry.canonicalSessionName);
terminalActive = obs ? obs.isActiveWithinWindow : null;
}
const pendingCount = entry.canonicalSessionName
? (pendingByDest.get(entry.canonicalSessionName) ?? 0)
: 0;
const pendingCount = countPendingForEntry(entry, pendingByDest);
return {
...entry,
terminalActive,
@@ -668,6 +667,61 @@ export function attachTerminalActivityAndWork(
});
}
/**
* QA baseline-deep-dogfood BLOCKING-A2 (qitem-20260518063900-85745917):
* adopted/live-session rigs do NOT surface assigned queue work because
* the prior lookup matched destination_session ONLY against
* canonicalSessionName.
*
* For MANAGED seats, canonicalSessionName equals the canonical form
* `{pod}-{member}@{rig}` (set by deriveCanonicalSessionName at
* materialize time) and queue operators address them by that form, so
* the single-key lookup works.
*
* For ADOPTED seats, canonicalSessionName is the RAW tmux session
* name (whatever the adopter chose, e.g., `my-existing-claude`).
* Operators address adopted seats by the canonical form (logical
* `{pod}-{member}@{rig}`) through `rig queue create --destination`,
* so the single-key lookup misses.
*
* Resolve via BOTH forms:
* 1. entry.canonicalSessionName (covers managed + adopted-by-raw)
* 2. derived `{pod}-{member}@{rig}` from logicalId + rigName
* (covers adopted-by-canonical)
*
* The logicalId is the pod-aware `pod.member` form (dot-separated).
* The canonical session form replaces the dot with a dash to match the
* convention from deriveCanonicalSessionName (so `redo.driver-2` in
* rig `openrig-velocity` becomes `redo-driver-2@openrig-velocity`).
*
* Sums distinct destination_session keys to avoid double-counting
* when both forms are identical (managed seats whose
* canonicalSessionName already equals the derived canonical form).
*/
function countPendingForEntry(
entry: NodeInventoryEntry,
pendingByDest: Map<string, number>,
): number {
const keys = new Set<string>();
if (entry.canonicalSessionName) keys.add(entry.canonicalSessionName);
const derived = deriveCanonicalFromEntry(entry);
if (derived) keys.add(derived);
let count = 0;
for (const key of keys) {
count += pendingByDest.get(key) ?? 0;
}
return count;
}
function deriveCanonicalFromEntry(entry: NodeInventoryEntry): string | null {
if (!entry.rigName || !entry.logicalId) return null;
const dotIdx = entry.logicalId.indexOf(".");
if (dotIdx <= 0 || dotIdx === entry.logicalId.length - 1) return null;
const pod = entry.logicalId.slice(0, dotIdx);
const member = entry.logicalId.slice(dotIdx + 1);
return deriveCanonicalSessionName(pod, member, entry.rigName);
}
function readPendingWorkBySession(db: Database.Database): Map<string, number> {
const rows = db.prepare(`
SELECT destination_session, COUNT(*) as c
@@ -161,6 +161,99 @@ describe("attachTerminalActivityAndWork — slice 15 per-node enrichment", () =>
expect(entry!.pendingWorkCount).toBe(3);
});
// QA baseline-deep-dogfood BLOCKING-A2 (qitem-20260518063900-85745917):
// adopted/live-session rigs do not surface assigned queue work because
// hasAssignedWork only matches destination_session against
// canonicalSessionName. For ADOPTED seats canonicalSessionName is the
// raw tmux session name (e.g., `my-existing-claude`); operators
// address adopted seats via the canonical `{pod}-{member}@{rig}` form
// through `rig queue create --destination <pod>-<member>@<rig>`.
// The match therefore fails for adopted, succeeds for managed (where
// canonicalSessionName is already the canonical form).
//
// Fix: resolve pending work by BOTH forms — entry.canonicalSessionName
// (covers managed + adopted users who queue by raw tmux name) AND the
// derived `{pod}-{member}@{rig}` form (covers adopted users who queue
// by canonical id). Discriminators below pin both directions.
it("BLOCKING-A2: adopted-style entry (canonicalSessionName=raw tmux name) — queue dest=canonical form matches → hasAssignedWork=true", () => {
const rig = seedRig(db, "my-rig");
const n = seedNode(db, rig, "default.dev");
// Adopted node: session name is the raw tmux session, NOT the
// canonical {pod}-{member}@{rig} form.
seedSession(db, n, "raw-tmux-name", "running");
// Operator queues by the canonical form (`rig queue create
// --destination default-dev@my-rig`).
seedPendingQitem(db, "default-dev@my-rig");
const [entry] = attachTerminalActivityAndWork(getNodeInventory(db, rig), { db });
expect(entry!.canonicalSessionName).toBe("raw-tmux-name");
expect(entry!.hasAssignedWork).toBe(true);
expect(entry!.pendingWorkCount).toBe(1);
});
it("BLOCKING-A2: adopted-style entry — queue dest=raw tmux name still matches (back-compat path)", () => {
const rig = seedRig(db, "my-rig");
const n = seedNode(db, rig, "default.dev");
seedSession(db, n, "raw-tmux-name", "running");
seedPendingQitem(db, "raw-tmux-name");
const [entry] = attachTerminalActivityAndWork(getNodeInventory(db, rig), { db });
expect(entry!.hasAssignedWork).toBe(true);
expect(entry!.pendingWorkCount).toBe(1);
});
it("BLOCKING-A2: managed-style entry (canonicalSessionName IS canonical form) — unchanged behavior", () => {
const rig = seedRig(db, "my-rig");
const n = seedNode(db, rig, "default.dev");
seedSession(db, n, "default-dev@my-rig", "running");
seedPendingQitem(db, "default-dev@my-rig");
const [entry] = attachTerminalActivityAndWork(getNodeInventory(db, rig), { db });
expect(entry!.hasAssignedWork).toBe(true);
expect(entry!.pendingWorkCount).toBe(1);
});
it("BLOCKING-A2: no double-count when canonicalSessionName already equals derived canonical form", () => {
const rig = seedRig(db, "my-rig");
const n = seedNode(db, rig, "default.dev");
seedSession(db, n, "default-dev@my-rig", "running");
// ONE pending qitem at the canonical destination — the entry's
// canonicalSessionName equals the derived canonical form, so
// looking up by both must not double-count.
seedPendingQitem(db, "default-dev@my-rig");
const [entry] = attachTerminalActivityAndWork(getNodeInventory(db, rig), { db });
expect(entry!.pendingWorkCount).toBe(1);
});
it("BLOCKING-A2: separate queue dests at raw + canonical forms sum correctly (each item counted once)", () => {
const rig = seedRig(db, "my-rig");
const n = seedNode(db, rig, "default.dev");
seedSession(db, n, "raw-tmux-name", "running");
seedPendingQitem(db, "raw-tmux-name");
seedPendingQitem(db, "default-dev@my-rig");
seedPendingQitem(db, "default-dev@my-rig");
const [entry] = attachTerminalActivityAndWork(getNodeInventory(db, rig), { db });
expect(entry!.hasAssignedWork).toBe(true);
// Total = 1 (raw) + 2 (canonical) = 3
expect(entry!.pendingWorkCount).toBe(3);
});
it("BLOCKING-A2: logical IDs with dots are normalized to dashes for canonical form (matches deriveCanonicalSessionName)", () => {
const rig = seedRig(db, "openrig-velocity");
const n = seedNode(db, rig, "redo.driver-2");
seedSession(db, n, "raw-name-X", "running");
// The convention is `{pod}-{member}@{rig}` so logicalId
// "redo.driver-2" becomes "redo-driver-2".
seedPendingQitem(db, "redo-driver-2@openrig-velocity");
const [entry] = attachTerminalActivityAndWork(getNodeInventory(db, rig), { db });
expect(entry!.hasAssignedWork).toBe(true);
expect(entry!.pendingWorkCount).toBe(1);
});
it("non-pending qitems do NOT count toward pendingWorkCount", () => {
const rig = seedRig(db, "states");
const n = seedNode(db, rig, "dev");