Skip to content

Commit 374b395

Browse files
committed
feat(pi): fallback-tag adoption pre-pass — fix in-flight §N§ drift (2/2)
The Pi-only half of the C★ fix. In runPipeline, before tagging: - buildEntryFingerprintMap: fingerprint each raw message (BEFORE transform mutates text) keyed by its resolved stable id, so the value is byte-stable across the in-flight (fallback-id) and settled (real-id) passes. - adoptPiFallbackTags: for each message now on a real id, find the tag(s) created for the same message under its earlier pi-msg-* id (matched by fingerprint), and migrate them onto the real id via the race-safe guarded UPDATE, then unbind the stale fallback alias + bind the real key so the subsequent tagTranscript exact-key hit returns the migrated tag (no fresh allocation). Per-part; unique-base-id guard skips ambiguous duplicates. The fingerprint map is also threaded to tagTranscript so freshly-allocated fallback tags persist their fingerprint for the NEXT pass to adopt. v25-cutover legacy pi-msg-${index} rows are excluded automatically: they have NULL entry_fingerprint (pre-v27) so they never match the adoption lookup. OpenCode never calls this path. Pi tsc clean.
1 parent e477408 commit 374b395

1 file changed

Lines changed: 100 additions & 0 deletions

File tree

packages/pi-plugin/src/context-handler.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ import {
5656
import { createScheduler } from "@magic-context/core/features/magic-context/scheduler";
5757
import {
5858
type ContextDatabase,
59+
adoptFallbackTagMessageId,
5960
clearPendingPiCompactionMarkerStateIf,
61+
findAdoptableFallbackTags,
6062
getActiveTagsBySession,
6163
getHistorianFailureState,
6264
getPendingOps,
@@ -1225,6 +1227,85 @@ function firstPiTextContent(content: unknown): string | null {
12251227
return null;
12261228
}
12271229

1230+
/**
1231+
* Build a `messageId → fingerprint` map from the RAW pre-transform messages,
1232+
* keyed by the stable id each message resolves to this pass. Captured before
1233+
* `runPipeline` mutates text (temporal markers, §N§ prefix, caveman) so the
1234+
* fingerprint is byte-stable across the fallback-pass (in-flight) and the
1235+
* real-id-pass (settled) — the invariant tag adoption depends on. The
1236+
* fingerprint is persisted on the tag row at creation; only message-typed
1237+
* entries get one (tool tags are out of scope).
1238+
*/
1239+
function buildEntryFingerprintMap(
1240+
messages: readonly PiAgentMessage[],
1241+
resolveStableId: (msg: unknown, index: number) => string | undefined,
1242+
): Map<string, string> {
1243+
const map = new Map<string, string>();
1244+
for (let i = 0; i < messages.length; i++) {
1245+
const msg = messages[i];
1246+
const id = resolveStableId(msg, i);
1247+
if (!id) continue;
1248+
const fp = piMessageEntryFingerprint(msg);
1249+
if (fp) map.set(id, fp);
1250+
}
1251+
return map;
1252+
}
1253+
1254+
/**
1255+
* Pi fallback-tag adoption pre-pass. Runs BEFORE tagging. For each message that
1256+
* now resolves to a REAL SessionEntry id this pass, find the tag(s) created for
1257+
* the same message under its earlier `pi-msg-*` fallback id (matched by raw
1258+
* fingerprint) and migrate them onto the real id — so the message keeps its
1259+
* tag_number (hence §N§ and all per-tag state) instead of getting a fresh tag
1260+
* and drifting. Per-part, uniqueness-guarded, race-safe; any miss degrades to a
1261+
* fresh allocation in `tagTranscript`. No-op for messages that are already on a
1262+
* real id or have no fallback predecessor.
1263+
*/
1264+
function adoptPiFallbackTags(
1265+
db: ContextDatabase,
1266+
sessionId: string,
1267+
tagger: Tagger,
1268+
fingerprintById: ReadonlyMap<string, string>,
1269+
): void {
1270+
for (const [realMessageId, fingerprint] of fingerprintById) {
1271+
// Only real ids can be adoption targets; a pi-msg-* id has no fallback
1272+
// predecessor to migrate from.
1273+
if (realMessageId.startsWith("pi-msg-")) continue;
1274+
const candidates = findAdoptableFallbackTags(db, sessionId, fingerprint);
1275+
if (candidates.length === 0) continue;
1276+
// Group candidates by their fallback message base id (strip the :pN
1277+
// suffix). A unique base means exactly one fallback message carried this
1278+
// fingerprint → safe to adopt; duplicates (same fingerprint on >1
1279+
// fallback message) are ambiguous → skip, let tagTranscript allocate
1280+
// fresh.
1281+
const baseIds = new Set<string>();
1282+
for (const c of candidates) {
1283+
const m = /^(.*):p\d+$/.exec(c.messageId);
1284+
baseIds.add(m ? m[1] : c.messageId);
1285+
}
1286+
if (baseIds.size !== 1) continue;
1287+
for (const c of candidates) {
1288+
const ordinalMatch = /:p(\d+)$/.exec(c.messageId);
1289+
if (!ordinalMatch) continue;
1290+
const realContentId = `${realMessageId}:p${ordinalMatch[1]}`;
1291+
const migrated = adoptFallbackTagMessageId(
1292+
db,
1293+
sessionId,
1294+
c.tagNumber,
1295+
c.messageId,
1296+
realContentId,
1297+
);
1298+
if (migrated) {
1299+
// Drop the stale fallback alias, bind the real key — so the
1300+
// subsequent tagTranscript exact-key lookup hits the migrated
1301+
// tag and does NOT allocate a fresh one.
1302+
tagger.unbindTag(sessionId, c.messageId);
1303+
tagger.bindTag(sessionId, realContentId, c.tagNumber);
1304+
}
1305+
}
1306+
}
1307+
}
1308+
12281309
/**
12291310
* Register the Pi `context` event handler.
12301311
*
@@ -2889,6 +2970,24 @@ async function runPipeline(args: RunPipelineArgs): Promise<RunPipelineResult> {
28892970
// 1. Tagging: assigns tag numbers + injects §N§ prefixes (unless
28902971
// ctx_reduce_enabled is false, in which case prefixes are skipped
28912972
// but DB-side tag IDs still get created so drops continue to work).
2973+
//
2974+
// Pi-only fallback-tag adoption: the newest (in-flight) message is tagged
2975+
// under an unstable pi-msg-* fallback id on the pass it is newest (its real
2976+
// SessionEntry id isn't resolvable yet), then resolves to its real id one
2977+
// pass later. Build a raw-message fingerprint map (BEFORE tagging mutates
2978+
// text) and migrate any fallback-id tag onto the real id up front, so the
2979+
// message keeps its tag_number/§N§ instead of getting a fresh tag. No-op for
2980+
// OpenCode (this path is Pi-only) and for messages already on a real id.
2981+
const entryFingerprintByMessageId = buildEntryFingerprintMap(
2982+
args.messages as PiAgentMessage[],
2983+
stableIdResolver,
2984+
);
2985+
adoptPiFallbackTags(
2986+
args.db,
2987+
args.sessionId,
2988+
args.tagger,
2989+
entryFingerprintByMessageId,
2990+
);
28922991
const tTag = performance.now();
28932992
const { targets } = tagTranscript(
28942993
args.sessionId,
@@ -2897,6 +2996,7 @@ async function runPipeline(args: RunPipelineArgs): Promise<RunPipelineResult> {
28972996
args.db,
28982997
{
28992998
skipPrefixInjection: !args.ctxReduceEnabled,
2999+
entryFingerprintByMessageId,
29003000
},
29013001
);
29023002
logTransformTiming(args.sessionId, "tagMessages", tTag);

0 commit comments

Comments
 (0)