fix(agent): define rootless v3 label handling

Document and test that v3 labels are skipped when their target has no retained ancestor.
This commit is contained in:
David Brailovsky 2026-08-19 22:24:20 +02:00
parent e92294d443
commit 56cca20ea0
3 changed files with 46 additions and 3 deletions

View file

@ -3757,7 +3757,7 @@ One corruption assertion constructs an `aborted` response with running control d
"v3" in this appendix names the legacy coding-agent JSONL session format, not this document. Old coding-agent v3 JSONL files must open unchanged and restore idle. Normalization on load:
- `custom_message` becomes a custom agent message.
- `label` and `session_info` become session-name/entry-label values (latest by file position wins) and leave the tree. A label targets its nearest retained parent.
- `label` and `session_info` become session-name/entry-label values (latest by file position wins) and leave the tree. A label target resolves through discarded nodes to its nearest retained ancestor; if no retained ancestor exists and resolution produces `null`, the label is skipped.
- Legacy `model_change`, `thinking_level_change`, and `active_tools_change` nodes disappear. They do **not** initialize or alter `LaneConfiguration`; a normalized `main` uses the immutable options seed.
- Each retained child of a discarded node is reparented to its nearest retained ancestor.
- `main`'s leaf is the final physical node resolved through discarded nodes to its nearest retained ancestor.
@ -3766,7 +3766,7 @@ One corruption assertion constructs an `aborted` response with running control d
- v3 ISO timestamps convert to Unix milliseconds.
- A v3 `parentSession` path resolves to an available parent header id; otherwise metadata and first-write conversion preserve it as `legacyParentSessionPath`.
- On first format-4 write, append one aggregate adjustment usage row with `details: { source: "v3-import" }`, summing v3 node usage so ledger-derived totals remain unchanged.
- Legacy v3 ids are re-minted at import: each entry gets a UUIDv7 whose prefix is the legacy entry's own timestamp (random tail for uniqueness), preserving time order and §1.2's every-id-is-time-prefixed property. All references the format knows are remapped — parent chains, `main`'s leaf, label keys, non-null `fromId`, usage `entryId`. Ids embedded in opaque payloads (custom entry data, `details`, message text) are not rewritten; the opaque-payload contract (§1.2) already covers them.
- Legacy v3 ids are re-minted at import: each entry gets a UUIDv7 whose prefix is the legacy entry's own timestamp (random tail for uniqueness), preserving time order and §1.2's every-id-is-time-prefixed property. All references the format knows are remapped — parent chains, `main`'s leaf, surviving label keys, non-null `fromId`, usage `entryId`. Ids embedded in opaque payloads (custom entry data, `details`, message text) are not rewritten; the opaque-payload contract (§1.2) already covers them.
Read-only open leaves the file unchanged and computes stats from normalized entry snapshots. The first format-4 write persists normalization through a temporary file and atomic rename over the original path, including the aggregate adjustment so subsequent stats are ledger-derived, and stamps the current `storageVersion` (§7.3). A fork from an unconfigured read-only v3 session follows §2.7 and leaves destination `main` for first harness attachment to seed.

View file

@ -380,7 +380,7 @@ function normalizeLegacyV3Values(
for (const entry of entries) {
if (entry.type !== "label") continue;
const targetId = resolver.resolve(entry.targetId);
// TODO: Decide whether labels whose targets normalize to the root should import as root labels.
// Labels have no current address when their target has no retained ancestor.
if (targetId === null) continue;
// Legacy v3 treated both undefined and the empty string as clearing a label.
if (entry.label) labels.set(targetId, entry.label);

View file

@ -333,6 +333,49 @@ describe("JSONL v3 migration", () => {
await session.close(BACKGROUND_CONTEXT);
});
it("skips a label whose target has no retained ancestor", async () => {
const message = {
role: "user",
content: [{ type: "text", text: "retained message" }],
timestamp: NOW + 3_000,
} satisfies AgentMessage;
await writeLegacyV3Fixture([
{
type: "session_info",
id: "root-session-info",
parentId: null,
timestamp: new Date(NOW + 1_000).toISOString(),
},
{
type: "label",
id: "root-label",
parentId: "root-session-info",
timestamp: new Date(NOW + 2_000).toISOString(),
targetId: "root-session-info",
label: "Skipped label",
},
{
type: "message",
id: "message-1",
parentId: "root-label",
timestamp: new Date(NOW + 3_000).toISOString(),
message,
},
]);
const [metadata] = await repo.list({ cwd: "/workspace" }, BACKGROUND_CONTEXT);
if (metadata === undefined) throw new Error("Legacy fixture was not discovered");
const session = await repo.open(metadata, BACKGROUND_CONTEXT);
const entries = await session.findEntries({ order: "asc" }, BACKGROUND_CONTEXT);
expect(entries).toHaveLength(1);
const [entry] = entries;
if (entry === undefined) throw new Error("Legacy message was not imported");
expect(entry.parentId).toBeNull();
expect(await session.getLabel(entry.id, BACKGROUND_CONTEXT)).toBeUndefined();
expect(await session.getLeafId(BACKGROUND_CONTEXT)).toBe(entry.id);
await session.close(BACKGROUND_CONTEXT);
});
it("uses the latest label after remapping discarded targets", async () => {
const firstMessage = {
role: "user",