From 56cca20ea0d1ff127b105eff46b8724da9f55676 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Wed, 19 Aug 2026 22:24:20 +0200 Subject: [PATCH] fix(agent): define rootless v3 label handling Document and test that v3 labels are skipped when their target has no retained ancestor. --- packages/agent/docs/harness.md | 4 +- .../src/harness/session/jsonl/legacy-v3.ts | 2 +- .../test/harness/jsonl-v3-migration.test.ts | 43 +++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/agent/docs/harness.md b/packages/agent/docs/harness.md index 8f9f6da94..3753835d4 100644 --- a/packages/agent/docs/harness.md +++ b/packages/agent/docs/harness.md @@ -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. diff --git a/packages/agent/src/harness/session/jsonl/legacy-v3.ts b/packages/agent/src/harness/session/jsonl/legacy-v3.ts index a4d6d9dd2..93fcf4e3b 100644 --- a/packages/agent/src/harness/session/jsonl/legacy-v3.ts +++ b/packages/agent/src/harness/session/jsonl/legacy-v3.ts @@ -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); diff --git a/packages/agent/test/harness/jsonl-v3-migration.test.ts b/packages/agent/test/harness/jsonl-v3-migration.test.ts index fb2423a04..1fa97a9c3 100644 --- a/packages/agent/test/harness/jsonl-v3-migration.test.ts +++ b/packages/agent/test/harness/jsonl-v3-migration.test.ts @@ -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",