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

Baseline-deep-dogfood A2: adopted/live-session rigs silently showed no
assigned queue work because hasAssignedWork matched only
canonicalSessionName, missing the derived {pod}-{member}@{rig} form for
adopted seats. Real-user-facing slice-15 wiring defect.

Tight fix (no identity-layer refactor): countPendingForEntry resolves
pending qitems by BOTH canonicalSessionName and the derived canonical
form via a dedup Set (no managed-seat double-count), reusing the
already-shipped deriveCanonicalSessionName helper. 6 TDD discriminators
(3 fail-first proven), 13/13 focused + 45/45 adjacent slice-15 green.

driver (redo-driver-2) -> redo-guard-2 per-commit CLEAR @ 1aa0c523 (tight
scope verified, no identity/queue/CLI/UI drift) -> velocity-qa re-test
CLEAR @ 1aa0c523 (adopted raw + adopted canonical + managed, no
double-count). All three gates aligned at 1aa0c523, no post-guard
delta. Orch heavy pre-merge verify passed. Local main only -- no push.

Minor non-blocking follow-up noted by QA: dot logical-id destination
form (bench.assigned@rig) is not canonical and does not count -
possible future validation/UX item, not blocking.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-18 00:03:34 -07:00
commit 7f4085e684
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");