Skip to content

Restart-time binding checkpoint is documented but never written: session-binding.ts has no writer and no caller #854

Description

@ysys143

Summary

changes.md documents a branch-local binding checkpoint that survives process restarts. It is not implemented. session-binding.ts — the module that reads it — has no writer and no production caller anywhere in the repo; the only binding that actually exists is a Map in process memory.

Two consequences:

  1. Restart-time resume never happens. After a restart senpi cannot see the session it was driving, so it bootstraps a brand-new Claude Code session and re-sends the whole conversation.
  2. No join key. The Claude Code session id senpi mints is never written to the senpi session log, so the transcripts a senpi session produces cannot be attributed back to it by anything reading the logs afterwards.

(Originally filed as "persist sdkSessionId"; verification showed the missing write is a symptom of the unimplemented checkpoint, and one number in the first version was wrong — see Data.)

Environment

@code-yeongyu/senpi 2026.8.12-4
omo-ai 5.0.0-0.beta.7
provider claude-sdk-oauth (default)
platform macOS (darwin-arm64), Node 24.19.0

What the docs say ships

src/core/extensions/builtin/claude-sdk-oauth/changes.md:129:

Restart. A branch-local binding checkpoint records {sdkSessionId, sentCount, sentPrefixHash, lastAssistantUuid, accountName, claudeConfigDir, modelId}; on the first turn after a restart it is verified against the SDK transcript before resuming, forks at the boundary when the local prefix advanced, and flattens only when the transcript or boundary is gone.

.../claude-sdk-oauth/AGENTS.md, file-role table:

session-binding.ts | Branch-local binding checkpoint for restart-time resume verification

What actually ships

session-binding.ts defines the reader:

// dist/.../claude-sdk-oauth/session-binding.js:1,8
export const BINDING_ENTRY_TYPE = "claude-sdk-oauth-binding";
export function latestBindingOnBranch(branch) {  }   // scans for type==="custom" && customType===BINDING_ENTRY_TYPE

Nothing calls it. Code search over this repo:

symbol files
BINDING_ENTRY_TYPE / "claude-sdk-oauth-binding" src/.../session-binding.ts, test/claude-sdk-oauth-binding.test.ts
latestBindingOnBranch same two
verifyBindingAgainstTranscript same two

The module is reachable only from its own unit test — in source, not just in the published dist.

The binding that is live is a different, incompatible one, and it is memory-only:

// dist/.../session-reattach.js:3
const bindings = new Map();                    // never touches disk

// dist/.../session-stream.js:43                (write, in-process)
rememberBinding(bindingFromEntry(entry, hashes));

// dist/.../session-stream.js:87-89             (read, the only read)
const decision = decideNativeContinuity({
  entry:   existing ? entrySnapshot(existing, residentHashes) : undefined,
  binding: getBinding(sessionId),

So after a restart existing and getBinding() are both undefined, and session-continuity.js:102-104 returns {kind:"bootstrap"} → flatten → createSessionUuid() mints a fresh id (session-registry.js:124,131) → the entire conversation is re-sent as a new Claude Code session. The Restart. paragraph above describes behaviour that cannot execute.

The id is kept on the in-memory registry entry (session-registry.js:143) and refreshed from the SDK stream on fork (session-registry-pump.js:77-78), but it is never written to ~/.senpi|.omo/sessions/**/*.jsonl. Since Claude Code names its transcript ~/.claude/projects/<project>/<sdkSessionId>.jsonl, the id senpi generated is the transcript filename — and it is dropped.

Data

Measured on ~/Documents/GitHub/unsloth: 6 senpi sessions, 111 Claude Code transcripts, of which 8 are my own direct claude sessions in the same repo and are excluded throughout — leaving 103 senpi-attributable transcripts.

How senpi-attributable transcripts were separated from my own direct Claude Code use

A transcript is counted as senpi's if any of:

  • filename is a UUIDv7 — only createSessionUuid() mints those; Claude Code itself uses UUIDv4. Sanity check: UUIDv7 transcripts exist in 2 of 74 project directories under ~/.claude/projects, and they are exactly the 2 directories where I ran omo.
  • first user message is senpi's flatten payload (<conversation_history> / <omo-senpi-*> blocks) — identifies SDK-minted fork ids whose filename is v4.
  • uses mcp__custom-tools__* — senpi's SDK MCP server.

The 8 excluded files match none of these: UUIDv4 names, a shell paste as the first message, native tools only (Bash, Read, Edit, AskUserQuestion). They account for 6.20M tokens (opus-5 3.61M, sonnet-5 2.56M) and are not part of any figure below.

No join key. All 103 senpi-attributable transcript ids checked against 4,429,562 bytes of ~/.omo/sessions/**/*.jsonl0 hits. The only occurrence of the string sdkSessionId anywhere under ~/.omo is inside codegraph/**/codegraph.db, i.e. an index of senpi's own source.

No usable substitute.

  • Session ids differ. senpi's session id (019ff6de-…) and the Claude session ids are unrelated values.
  • Time-window correlation is ambiguous. Max 3 senpi sessions live simultaneously, ≥3 concurrent for 11.5 h, all in one cwd — interval containment cannot partition the transcripts.
  • The closest thing to a key isn't one. senpi's task id st_<8 hex> is the top 32 bits of a UUIDv7 millisecond timestamp, i.e. a 65.5-second bucket. Of 32 task ids on disk, 14 resolve to exactly one transcript, 12 to none, 6 to two or more.

Fan-out is fork-driven, and large. Of the 103, 45 carry senpi-minted UUIDv7 ids and 58 carry SDK-minted UUIDv4 fork ids. Grouping files by their first user message, 60 fall into 2 fork lineages — one of which is 56 files descended from a single senpi session (1 parent + 55 forks). Across all senpi sessions, 442 continuity decisions were logged; 159 of them mint a new SDK session id (fork/model_changed 84, fork/sent_stream_diverged 55, fork|flatten/registry_miss 13, other 7).

The spawned-task lane is recorded nowhere: 16.2% of senpi's own tokens. Splitting the 103 transcripts into the conversation lane and the task lane (33 transcripts whose id prefix matches an st_… task id in the senpi log):

lane Claude transcripts (dedup) senpi log covered
conversation (opus-4-8 39.44M, fable-5 36.40M, opus-5 0.19M) 76.03M 76.16M ~100%
spawned tasks (opus-5 13.57M, haiku-4-5 0.74M, sonnet-5 0.50M) 14.82M ~0 0%
total 90.84M 76.16M 83.8%

The conversation lane is recorded essentially perfectly. What is missing is exactly the work senpi spawned — 14.69M tokens, referenced in the senpi transcript by nothing but a Started task … tool result. In one session a single spawned advisory task consumed 336K tokens with no other trace.

(The first version of this issue claimed ~97.5% coverage. That is wrong; so was the 78.5% I posted while verifying, which had not yet excluded my own direct claude sessions. 83.8% is the corrected figure — the gap is 6.5x what I originally reported, and it is entirely the task lane.)

Bonus hazard for any external consumer: summing usage over those transcripts naively gives 557.6M tokens; deduped by message.id it is 101.8M. Forks copy the parent's prefix verbatim, so without a lineage key an external tool overcounts ~5.5x.

Proposal

Persist the binding the live path actually uses, and read it back when the in-memory map misses.

The two shapes are not interchangeable:

session-binding.ts BindingCheckpoint live binding (bindingFromEntry)
prefix sentPrefixHash: string sentHashes: string[]
boundaries lastAssistantUuid only assistantUuidByIndex: [number,string][]
identity accountName, claudeConfigDir, modelId accountName, modelId, systemPromptHash, toolsetHash

decideFromBinding() computes commonPrefixLength(binding.sentHashes, input.currentHashes) (session-continuity.js:50,53), so a checkpoint carrying only sentPrefixHash cannot drive it. Writing the BindingCheckpoint shape would fix attribution but leave restart-resume dead.

So:

  1. Write a custom entry carrying the reattach-shaped binding wherever rememberBinding() is called today (session-stream.js:43, session-registry-wiring.js:9, session-reattach.js:82):

    {
      "type": "custom",
      "customType": "claude-sdk-oauth-binding",
      "data": {
        "schemaVersion": 2,
        "sdkSessionId": "019ff407-34c8-738c-b5f4-ed57259cddd1",
        "sentCount": 12,
        "sentHashes": [""],
        "lastAssistantUuid": "",
        "assistantUuidByIndex": [[11, ""]],
        "accountName": "", "modelId": "",
        "systemPromptHash": "", "toolsetHash": ""
      },
      "timestamp": "", "id": "", "parentId": ""
    }
  2. Wire the reader in: binding: getBinding(sessionId) ?? latestBindingOnBranch(branch) in createResidentAttempt (session-stream.js:89), reconciling latestBindingOnBranch's return type with the live shape.

That makes the documented Restart. behaviour real, gives verifyBindingAgainstTranscript() persisted state to work from, and as a side effect puts the transcript filename in the log. It changes no existing field and is backward compatible — older logs simply have none, and decideNativeContinuity already handles binding === undefined.

If persisting the full binding is unwanted, the narrower fix for attribution alone is to record sdkSessionId on each assistant message entry alongside model/provider — but that leaves restart-resume unimplemented and session-binding.ts dead, so the docs above should be corrected instead.

Reproduce

omo                                        # run any turn on the default claude-sdk-oauth provider
ls ~/.claude/projects/<project>/           # a <uuid>.jsonl appears
grep -c sdkSessionId ~/.omo/sessions/*/*.jsonl    # 0 — the id is nowhere in the senpi log

# restart-resume:
# quit omo, relaunch, resume the same senpi session, send one turn
ls -t ~/.claude/projects/<project>/ | head -1     # a NEW transcript, not the previous one

Happy to send a PR if the shape above looks right.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions