mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
daemon: matcher fixback — B1/B2/f4/P3 (two-tier doctrine)
Blocker-only repair on frozen candidate 302036aa (guard B1/B2/B3, arch
fixback plan v1.4 f1-f6).
B1 (hasActiveQitem): drop the qitemIds leg-2 membership check — it inherited
matchQitems' sanctioned legacy substring/body fallback, so a zero-typed
body-mention-only row promoted activeQitemPresent to true (phase BUILD) while
the strict band was empty. Leg 1 streams (iterate + break on first canonical
confirm, NO SQL LIMIT — a pre-confirmation LIMIT would recreate B2 at the
phase signal). Equivalence: every canonical member contains the literal
slice:<name> substring (P1), so leg 1's untruncated prefilter finds them all;
leg 2 could only add non-canonical display-tier ids.
B2 (matchQitems typed tier): cap on CONFIRMED matches, not the prefilter
window. iterate() ORDER BY ts_created DESC, qitem_id DESC (P2 tiebreak =
deterministic), confirm per row, break at 500 confirmed; NO prefilter LIMIT
(else >500 suffix decoys hide a true typed member and un-gate the fallback).
Substring tier + its LIMIT 500 + terms untouched.
f4: delete the dead, untested excludeTagPrefix === "mission:" branch (only
caller passes "slice:").
P3: the two-tier doctrine ships by name in the helper JSDoc and both consumer
comments — SIGNAL tier (phase/band/attention) = canonical-only; DISPLAY tier
(queue-tab qitemIds) = gated legacy fallback; never promote display into signal.
Tests: §3.9 (zero-typed body-only: display-tier keeps it, signal-tier drops it,
phase/band agree), §3.10 (suffix-storm: true member kept, fallback gated,
hasActiveQitem true), §3.10a (P2 tie-vector: deterministic id-DESC order).
Daemon-only; UI untouched; agentsForSlices/composeAgents band code unchanged.
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
302036aa6c
commit
cab26bb0a0
4 files changed
+134
-36
No files matched your search
@@ -236,7 +236,7 @@ export class ReviewGatherer {
|
||||
attention,
|
||||
agents,
|
||||
workflows: this.gatherWorkflowExceptions(this.now()),
|
||||
activeQitemPresent: this.hasActiveQitem(name, slice),
|
||||
activeQitemPresent: this.hasActiveQitem(name),
|
||||
git: this.gatherGitFacts(frontmatter, candidateRef),
|
||||
approval,
|
||||
nowIso: this.now(),
|
||||
@@ -511,7 +511,6 @@ export class ReviewGatherer {
|
||||
// intent — "rows that carry a slice tag") so comma-legacy rows are
|
||||
// excluded like clean ones; unknown prefixes keep raw semantics.
|
||||
if (excludeTagPrefix === "slice:") return scopes.slices.size === 0;
|
||||
if (excludeTagPrefix === "mission:") return scopes.missions.size === 0;
|
||||
if (excludeTagPrefix) {
|
||||
try {
|
||||
const tags = (JSON.parse(r.tags ?? "[]") as string[]) ?? [];
|
||||
@@ -821,34 +820,35 @@ export class ReviewGatherer {
|
||||
}
|
||||
}
|
||||
|
||||
private hasActiveQitem(name: string, slice: SliceRecord): boolean {
|
||||
private hasActiveQitem(name: string): boolean {
|
||||
if (!this.tableExists("queue_items")) return false;
|
||||
// Leg 1 (canonical): unquoted prefilter now catches comma-legacy rows;
|
||||
// each candidate is confirmed against the shared membership predicate,
|
||||
// short-circuiting on the first true. Post-fix, leg 2 (qitemIds, which
|
||||
// inherits matchQitems) converges on the same answer.
|
||||
const prefiltered = this.db
|
||||
.prepare(
|
||||
`SELECT tags FROM queue_items WHERE state IN (${ACTIVE_STATES.map(() => "?").join(",")}) AND tags LIKE ?`,
|
||||
)
|
||||
.all(...ACTIVE_STATES, `%slice:${name}%`) as Array<{ tags: string | null }>;
|
||||
for (const r of prefiltered) {
|
||||
// TWO-TIER DOCTRINE: this is a SIGNAL-tier answer (phase / band /
|
||||
// attention) — it answers from canonical membership ONLY. The DISPLAY
|
||||
// tier (the queue-tab's qitemIds) may carry the gated legacy substring
|
||||
// fallback; never promote a display-tier match into a signal. (P3)
|
||||
//
|
||||
// B1 fix: the qitemIds (leg 2) membership check is DROPPED. It inherited
|
||||
// matchQitems' sanctioned legacy substring/body fallback, so in a
|
||||
// zero-typed corpus a body-mention-only row promoted activeQitemPresent
|
||||
// to true (phase BUILD) while the strict band was empty — the exact
|
||||
// one-compose-two-answers divergence this slice kills. Equivalence: any
|
||||
// canonically-confirmable row necessarily contains the literal
|
||||
// `slice:<name>` substring (P1 construction), so leg 1's UNTRUNCATED
|
||||
// prefilter finds every canonical member; leg 2 could only ever ADD
|
||||
// non-canonical (display-tier) ids — precisely the class that must not
|
||||
// reach the phase signal.
|
||||
//
|
||||
// Leg 1 streams: iterate() + break on the FIRST canonical confirm. NO SQL
|
||||
// LIMIT — a pre-confirmation LIMIT would recreate B2 here (a suffix-storm
|
||||
// could hide the true member and flip the phase signal); streaming + break
|
||||
// gives the same bounded cost with no truncation hole.
|
||||
const stmt = this.db.prepare(
|
||||
`SELECT tags FROM queue_items WHERE state IN (${ACTIVE_STATES.map(() => "?").join(",")}) AND tags LIKE ?`,
|
||||
);
|
||||
for (const r of stmt.iterate(...ACTIVE_STATES, `%slice:${name}%`) as Iterable<{ tags: string | null }>) {
|
||||
if (parseScopeTags(r.tags).slices.has(name)) return true;
|
||||
}
|
||||
if (slice.qitemIds.length === 0) return false;
|
||||
const placeholders = slice.qitemIds.map(() => "?").join(",");
|
||||
try {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT 1 FROM queue_items
|
||||
WHERE qitem_id IN (${placeholders})
|
||||
AND state IN (${ACTIVE_STATES.map(() => "?").join(",")})
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(...slice.qitemIds, ...ACTIVE_STATES) !== undefined;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** §4 — the two staged-approval stamps (arch F-A: the SHIPPED verb's
|
||||
|
||||
@@ -17,7 +17,12 @@ export interface QitemScopeTags {
|
||||
* comma-split (the legacy CLI form, per the matchQitems contract comment)
|
||||
* and trimmed; exact `slice:`/`mission:` prefix match per token. No
|
||||
* substring semantics live here — the legacy substring tier remains a
|
||||
* matchQitems-local concern behind its zero-typed-rows condition. */
|
||||
* matchQitems-local concern behind its zero-typed-rows condition.
|
||||
*
|
||||
* TWO-TIER DOCTRINE: the SIGNAL tier (phase / band / attention) answers
|
||||
* from canonical membership ONLY — this function. The DISPLAY tier (the
|
||||
* queue-tab's qitemIds) may carry the gated legacy substring fallback.
|
||||
* Never promote a display-tier match into a signal. */
|
||||
export function parseScopeTags(rawTags: string | null | undefined): QitemScopeTags {
|
||||
const slices = new Set<string>();
|
||||
const missions = new Set<string>();
|
||||
|
||||
@@ -451,18 +451,33 @@ export class SliceIndexer {
|
||||
// form (legacy CLI tags) are caught by a wildcard on the slice id.
|
||||
let typedTagMatchCount = 0;
|
||||
try {
|
||||
const rows = this.db.prepare(
|
||||
`SELECT qitem_id, tags FROM queue_items WHERE tags LIKE ? LIMIT 500`,
|
||||
).all(`%slice:${sliceName}%`) as Array<{ qitem_id: string; tags: string | null }>;
|
||||
// The unquoted LIKE stays a PREFILTER (catches comma-legacy AND the
|
||||
// TWO-TIER DOCTRINE: the SIGNAL tier (phase / band / attention)
|
||||
// answers from canonical membership ONLY; this DISPLAY tier (the
|
||||
// queue-tab's qitemIds) may carry the gated legacy substring fallback
|
||||
// below. Never promote a display-tier match into a signal. (P3)
|
||||
//
|
||||
// B2 fix: cap on CONFIRMED matches, never the prefilter window. The
|
||||
// unquoted LIKE stays a PREFILTER (catches comma-legacy AND the
|
||||
// suffix/sibling over-matches); parseScopeTags is the authoritative
|
||||
// row-level confirm. typedTagMatchCount counts CONFIRMED rows only,
|
||||
// so the substring gate below reflects real typed membership — this
|
||||
// kills the VM-004 `slice:X-suffix` over-match at the typed tier.
|
||||
for (const r of rows) {
|
||||
// row-level confirm. We iterate() ORDER BY ts_created DESC, qitem_id
|
||||
// DESC (P2: the id tiebreak makes the confirmed set fully deterministic
|
||||
// under equal timestamps — byte-consistent with the id-lex tail
|
||||
// fallback below, since the id encodes the ts prefix), confirm per
|
||||
// row, and break at 500 CONFIRMED. There is NO pre-confirmation SQL
|
||||
// LIMIT: with one, >500 suffix/sibling over-match rows could hide a
|
||||
// true typed member and un-gate the fallback (VM-004 returns).
|
||||
// Streaming keeps memory bounded, and the leading-wildcard LIKE never
|
||||
// used an index anyway (the old LIMIT saved materialization only,
|
||||
// which iterate+break also saves). typedTagMatchCount counts CONFIRMED
|
||||
// rows only, so the substring gate reflects real typed membership.
|
||||
const stmt = this.db.prepare(
|
||||
`SELECT qitem_id, tags FROM queue_items WHERE tags LIKE ? ORDER BY ts_created DESC, qitem_id DESC`,
|
||||
);
|
||||
for (const r of stmt.iterate(`%slice:${sliceName}%`) as Iterable<{ qitem_id: string; tags: string | null }>) {
|
||||
if (parseScopeTags(r.tags).slices.has(sliceName)) {
|
||||
ids.add(r.qitem_id);
|
||||
typedTagMatchCount++;
|
||||
if (typedTagMatchCount >= 500) break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -223,3 +223,81 @@ describe("scope-membership byte-identity carve (clean corpus, zero regression)",
|
||||
expect(band?.provenance).toBe(`no agents holding or recently holding work — computed from queue at ${NOW}`);
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4 fixback regressions — the two-tier doctrine made observable.
|
||||
describe("scope-membership B1/B2/P2 blocker regressions (fixback)", () => {
|
||||
let missionsRoot: string;
|
||||
let cleanup: string;
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
cleanup = fs.mkdtempSync(path.join(os.tmpdir(), "scope-fixback-"));
|
||||
missionsRoot = path.join(cleanup, "missions");
|
||||
fs.mkdirSync(missionsRoot, { recursive: true });
|
||||
for (const s of ["target", "sibling", "legacyonly", "tie-slice"]) {
|
||||
writeSlice(missionsRoot, path.join("relx", "slices"), s);
|
||||
}
|
||||
db = createDb(":memory:");
|
||||
migrate(db, [coreSchema, eventsSchema, streamItemsSchema, queueItemsSchema, queueItemSummarySchema]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
fs.rmSync(cleanup, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function harness() {
|
||||
const indexer = new SliceIndexer({ slicesRoot: missionsRoot, additionalSliceRoots: [], dogfoodEvidenceRoot: null, db });
|
||||
const gatherer = new ReviewGatherer({ db, indexer, gitRepoPath: null, now: () => NOW });
|
||||
return { indexer, gatherer };
|
||||
}
|
||||
|
||||
it("§3.9 B1: zero-typed body-only row shows in the DISPLAY tier but NOT the SIGNAL tier (phase/band agree)", () => {
|
||||
// legacyonly has zero typed slice: rows; one ACTIVE body-mention-only qitem.
|
||||
insertQitem(db, { id: "q-body-legacy", dest: "leg@rig", tags: [], body: "advance the legacyonly rollout" });
|
||||
const { indexer, gatherer } = harness();
|
||||
// DISPLAY tier (queue-tab qitemIds): the legacy substring fallback keeps it.
|
||||
const ids = new Set(indexer.get("legacyonly")?.qitemIds ?? []);
|
||||
expect(ids.has("q-body-legacy")).toBe(true);
|
||||
// SIGNAL tier: hasActiveQitem FALSE (leg 2 dropped) AND band empty -> AGREE.
|
||||
const active = (gatherer as unknown as { hasActiveQitem(n: string): boolean }).hasActiveQitem("legacyonly");
|
||||
const band = gatherer.composeAgents("slice:legacyonly");
|
||||
expect(active).toBe(false); // was TRUE at 302036aa via the dropped leg-2 -> phase BUILD divergence
|
||||
expect(band?.rows).toEqual([]);
|
||||
});
|
||||
|
||||
it("§3.10 B2 suffix-storm: >500 slice:target-extra decoys ahead of the true row — true member kept, fallback gated, phase TRUE", () => {
|
||||
// Decoys FIRST (they'd fill a pre-confirmation LIMIT-500 window and hide the true row at 302036aa).
|
||||
const seedDecoys = db.transaction((n: number) => {
|
||||
for (let i = 0; i < n; i++) {
|
||||
insertQitem(db, { id: `q-decoy-${String(i).padStart(3, "0")}`, dest: "decoy@rig", tags: ["slice:target-extra"] });
|
||||
}
|
||||
});
|
||||
seedDecoys(550);
|
||||
insertQitem(db, { id: "q-true-target", dest: "truebuilder@rig", tags: ["slice:target"] });
|
||||
insertQitem(db, { id: "q-body-target", dest: "bodyer@rig", tags: [], body: "notes about target here" });
|
||||
const { indexer, gatherer } = harness();
|
||||
const ids = new Set(indexer.get("target")?.qitemIds ?? []);
|
||||
expect(ids.has("q-true-target")).toBe(true); // retained despite the storm (no truncation before confirm)
|
||||
expect(ids.has("q-body-target")).toBe(false); // typed confirmed >=1 -> substring fallback GATED off
|
||||
expect(ids.has("q-decoy-000")).toBe(false); // suffix over-match rejected by canonical confirm
|
||||
// SIGNAL tier immune to truncation: hasActiveQitem TRUE, band shows the true seat only.
|
||||
const active = (gatherer as unknown as { hasActiveQitem(n: string): boolean }).hasActiveQitem("target");
|
||||
expect(active).toBe(true);
|
||||
const sessions = new Set((gatherer.composeAgents("slice:target")?.rows ?? []).map((r) => r.sessionName));
|
||||
expect(sessions.has("truebuilder@rig")).toBe(true);
|
||||
expect(sessions.has("decoy@rig")).toBe(false);
|
||||
expect(sessions.has("bodyer@rig")).toBe(false);
|
||||
});
|
||||
|
||||
it("§3.10a P2 tie-vector: two confirmable rows sharing one ts_created — deterministic id-DESC order across re-runs", () => {
|
||||
insertQitem(db, { id: "qtie-a", dest: "a@rig", tags: ["slice:tie-slice"] });
|
||||
insertQitem(db, { id: "qtie-b", dest: "b@rig", tags: ["slice:tie-slice"] }); // same ts_created (NOW)
|
||||
const { indexer } = harness();
|
||||
const mq = indexer as unknown as { matchQitems(s: string, r: string | null, m: string | null): string[] };
|
||||
const first = mq.matchQitems("tie-slice", null, "relx");
|
||||
const second = mq.matchQitems("tie-slice", null, "relx");
|
||||
expect(first).toEqual(second); // deterministic under equal timestamps
|
||||
expect(first).toEqual(["qtie-b", "qtie-a"]); // qitem_id DESC tiebreak (P2)
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user