Troubleshooting

Every refusal is a TetherError, printed as error: ... with exit status 1. Exit 3 means part of the work was done, and 2 is a usage error (exit codes). This page goes by error class, then by the messages the newer refusals print. From Python, catch the same classes: from tether import StalePlanError, and RefMovedError from tether.errors. Every BackendError message starts with the object’s [key] or the backend’s [kind].

By error class

Error It means The way out
StaleWorkingCopyError An object’s committed state changed since this checkout forked its branches (a teammate committed on a --shared bookmark, a bookmark moved by hand, a jj undo), or the checkout is on no bookmark. Writable open and commit refuse; reads work. tether new forks again from the current pins (Stale working copies); on no bookmark, tether new NAME or new -b NAME.
StalePlanError What the plan saw no longer holds (the manifests, a branch head, history, the bookmark, the checkout), or the plan lacks a required precondition, was edited, or was made by another tether version. Nothing was written. Plan again and read what moved (What a plan checks).
RefMovedError A store ref moved between the plan’s check and the move. Nothing was written. Someone else writes to that branch: look at it with the store’s tools, then plan again. On a shared bookmark, tether new --shared works on the branch as it is.
MergeConflict promote’s three-way merge (git, Dolt) stopped on conflicts, named in conflicts. The base is untouched and the fast-forwards were held. Resolve with the system’s tools, tether commit, then promote again; or --strategy ff, or tether promote KEY....
PinDriftError A pin’s ref points at another state than the manifest recorded: someone moved the tag. verify reports it drifted, and repair never overwrites it. Move the tag back, or delete it and let repair recreate it.
ImmutableObjectModified A --file immutable object’s bytes changed; status --snapshot labels it error and exits 1, and snapshot, commit and pull stop. If the change is legitimate: tether remove KEY, tether add KEY ..., commit (Drift).
UnpinnedStateError commit --strict met an Observed object. Drop --strict, or use a store that can hold a pin.
CapabilityError The backend cannot do what was asked: a writable open of a non-Forkable object, open --rev of an Observed one, --at on neon or file. Check the object’s tier (Backends); open read-only.
BackendError The store said no: credentials, network, a missing ref (pin missing on a lazy fork), a busy Neon compute, active writers at commit, a relative or in-checkout git path. The [key] names the object. Credentials go in .tether/secrets.toml. A missing ref: verify, then repair. Active writers: wait, or commit --force. Transient: run the command again (below).
MultiObjectError Several objects failed in one fan-out (snapshot, verify, commit, pull, new, promote, gc); gc and promote also print what they applied. Fix each object as its line says. status --snapshot labels failures error instead, and exits 1.
ConfigError An invalid tether.toml or manifest, a key the trust boundary forbids, a dataset version that is not this tether’s, a tracked per-checkout file, no such object. The message says where a forbidden key belongs. An older dataset: tether upgrade; a newer one: upgrade tether-vcs. More below.
VcsError jj or git failed or is too old (jj 0.43, git 2.38); a commit hook refused; jj reports a conflicted bookmark or an unresolved .tether/ conflict (gc, drop); a new commit lacks a manifest (an ignored directory). Fix the VCS side (jj bookmark list, jj resolve, the ignore rule, the hook) and run the command again.
TetherError (the lock) Another command holds the checkout lock (30 s) or the repository lock (60 s); or, on Windows, a writing command. Wait for, or stop, the other command. Write from a POSIX system or WSL (Windows).

By message

The message says What happened The way out
jj tracks .tether/workspace.toml (or secrets.toml, ops.jsonl; or git tracks) A clone brought a tracked copy of a per-checkout file, or one was committed by hand. find refuses it, and open does when a changed workspace.toml became tracked. Review it (delete a cloned secrets.toml), untrack it with the command the message gives (jj file untrack / git rm --cached), and commit. The file stays on disk.
cannot tell whether jj tracks this checkout's ... The VCS found through PATH, TETHER_JJ or TETHER_GIT failed; secrets.toml’s jj_path is read only after this check. Put jj or git on PATH, or set TETHER_JJ / TETHER_GIT.
skipping .../ops.jsonl of checkout ... (a warning) Another checkout’s per-checkout file is tracked there, so gc does not count it as that checkout’s record. Untrack it in that checkout.
this plan was made by tether X; this is tether Y: re-run the plan (or made by an older tether) A saved plan is valid only for the tether version that made it, and this one was saved by another (an upgrade of tether-vcs between --plan and --from-plan), or before plans recorded their version. Plan again with the tether you will apply it with.
was edited after it was saved / made in another checkout A saved plan fails its digest, or belongs to another checkout. Plan again where you will apply it.
has writes since the last commit ... tether commit them first (promote) A working branch holds writes the bookmark’s commit does not record: uncommitted, undone, or committed with --no-vcs. tether commit, then promote.
main (...) has commits this bookmark ... does not / has conflicting targets (promote) Landing would move the trunk bookmark backwards or sideways, or pick one side of a divergent trunk. Merge or rebase the manifests onto main (or jj bookmark set main -r REV), or name keys to land a subset.
base branch moved since the plan was made (promote) Another writer moved an upstream branch after the plan. The message says whether planning again helps: it merges where the backend can, and otherwise refuses again unless the base only moved along the fork.
refusing to reset working branches with unpinned writes (new) A branch holds writes no commit pins, and the message says whose. Yours: commit, or --discard. A peer’s: tether new --shared adopts the branch as it is. Writes that do not build on the bookmark’s pin can only be discarded. A branch left by a dataset whose repository was lost: tether new --adopt (Recovering a lost dataset repo).
... is gone since the plan was made / is superseded by ... / no longer builds on ... (new --adopt, --shared) The branch a saved plan adopts was deleted, a store made a newer generation of it (.2) because it could not reset it, or it was reset onto something that does not build on the bookmark’s pin. Writes on the branch alone never stale the plan. Plan again: --adopt takes the newest generation.
cannot undo ... (new): ... still works on NAME The checkout cannot go back from the bookmark that new put it on, because its working copy moved on. tether new PREVIOUS leaves it; the branches stay until gc --prune-bookmarks.
skipped workspace.toml: left ... (changed again by a later operation) (undo ID) tether undo ID restores only the fields nothing has changed since. Nothing to do; later operations own those fields.
... (drop NAME), which was interrupted ... (undo, repair) A drop died part-way; a drop is finished, not undone. Run what the message names: tether drop NAME again, or gc --prune-bookmarks once the bookmark is gone.
... is checked out in <worktree> (git) A fork, reset or promote would move a branch another worktree has checked out. Check out another branch in that worktree, then run the command again.
diff compares the working copy with its parent, but ... The jj working copy is a merge, with two parents. tether diff REV compares one of them with the working tree.
file storage_options: unknown client option ... A client option in secrets.toml is not one obstore takes, or its value cannot mean what the option needs. Fix the key or value; any spelling of a known option works.
a newer DuckDB has written this DuckDB-file catalog (ducklake) A DuckDB newer than the one tether runs with has written the catalog’s metadata file, in a storage format older ones cannot read. DuckDB 2.0 does this to a 1.x catalog at its first write. Upgrade duckdb where tether runs (DuckLake and DuckDB versions).
tether.toml is version 4; this tether expects 5 A 0.1.0b1-b3 dataset. tether upgrade, then re-sync the other clones; 0.1.0b3 refuses the upgraded dataset.
'lakefs': N manifest(s) skipped (a note or warning from gc, verify) History names a backend tether no longer ships. Its pins still count as references. Nothing; remove the objects in a new commit when convenient.
...takes no expected, so its ref moves are not conditional (a RuntimeWarning) A third-party backend predates conditional moves. Update the backend (Writing a Backend).

An interrupted command

A store-writing command journals its plan before the first side effect and marks completion afterwards. A process killed in between leaves an entry marked INCOMPLETE in tether ops:

  • tether repair --dry-run lists what the operation got done, and what was planned.
  • Running the same command again finishes what is left. commit first releases the pins its failed attempt created, unless the VCS commit landed; then the commit stands.
  • tether gc keeps every pin an unfinished operation names, from any checkout of the repository.
  • tether undo refuses it, since what it did is not fully known.

The journal: Operation log.

Warnings status prints

These are not errors, and the line says what to do:

  • (STALE: key, ...) in the header: see StaleWorkingCopyError above.
  • warning: bookmark ... is gone / renamed / moved / left: the VCS moved the bookmark behind tether’s back (Workspaces and bookmarks).
  • warning: dataset commit ... is no longer in VCS history: a jj undo, jj abandon or git reset removed a commit tether made. gc releases its pins, and repair recreates them if the commit comes back (Undo).
  • error as an object’s state: its fingerprint failed, now or at the last snapshot. The reason follows on stderr and status exits 1.

Recovering a lost dataset repo

The stores keep tether’s refs when the repository with the manifests is gone, such as a clone that was never pushed, or a lost disk. Every pin is a ref tether.<id>.<hash> (a tag, in most stores), and the hash covers the object, its state and the dataset id. Every bookmark has a working branch tether.ws.<id>.<bookmark>; Lance and Neon add .2, .3 when they cannot reset a branch in place. A new dataset under the old id takes them back:

  1. A fresh repository, the old id, the objects. In a new jj or git repository, run tether init --dataset-id ID with the old id, if you know it (if not, any id: step 2 finds it). Then tether add each object with its old locator. Kinds whose locator is a path need no key: tether add s3://lab/imaging.icechunk --kind icechunk registers imaging.

    jj git init lab-data && cd lab-data
    tether init --dataset-id 0a1b2c3d
    tether add s3://lab/imaging.icechunk --kind icechunk
  2. tether recover lists tether’s refs in each object’s store by the dataset id they carry: the pin count, and each bookmark with a working branch (and its generation, when there are several). It marks the id tether.toml names, prints what to run next, and writes nothing. Each step is a command, its values quoted for the shell, followed by a # note; a step with nothing to run is a note alone. If a store can’t be listed, it names that object, suggests nothing, and exits 1: fix access, or leave the object out with tether recover KEY.... A run scoped to some keys suggests no dataset id and no commit: a commit pins every object under the current id, which then can no longer change, so an id only a store left out holds would be lost. Its last step is tether recover without keys.

    $ tether recover
    dataset id 0a1b2c3d (tether.toml)
      imaging  [icechunk]
        0a1b2c3d (this dataset): 5 pin(s); bookmarks: relabel, sweep
    next steps:
      1. tether commit -m 'Recover main'
         # pins each object's upstream branch on the trunk (main); a state pinned before reuses its tag
      2. tether new -b relabel --adopt -- main && tether commit -m 'Recover relabel'
         # bookmark relabel, made afresh off the trunk: takes its branches as they are, uncommitted writes included, and pins them
      3. tether new -b sweep --adopt -- main && tether commit -m 'Recover sweep'
         # bookmark sweep, made afresh off the trunk: takes its branches as they are, uncommitted writes included, and pins them

    If a manifest already names a pin its store lacks (a tag deleted by hand, say), the first step is tether repair -- KEY... for those objects: a commit does not recreate a pin whose state is unchanged. A run scoped to some keys repairs only the selected ones.

  3. Set the id, if it is not the old one. When the refs carry another id and nothing is pinned under this dataset’s yet, recover’s first step is a note with what to put in tether.toml:

      1. # set this dataset's id in tether.toml (nothing is pinned under 12345678 yet):
         # [dataset]
         # id = "0a1b2c3d"

    The id can change only while nothing is pinned under it (Dataset id). Once something is, recover says so, and the way back is a fresh repository with tether init --dataset-id.

  4. Commit on the trunk. tether commit pins each object’s upstream branch. A state the old dataset pinned gets the same pin id, so its tag is reused and no new tag is made.

  5. Adopt each bookmark, and commit. tether new -b NAME --adopt -- main takes the bookmark’s branch as its working ref as it is: no reset, no copy, and writes nobody committed stay. status shows them as modified, and tether commit pins them. An object whose store has no branch for the bookmark forks from its pin, as new always does. A bookmark whose name had /, . or a space is listed as its branch spells it (feat-x-1a2b3c for feat/x), and either name finds the branch. Where the VCS still has the bookmark, recover suggests tether new --adopt -- feat/x. Where it does not, the original name cannot be read back from the branch: recover suggests tether new -b feat-x-1a2b3c --adopt -- main, which takes the branch under the escaped name. Every suggested command ends its options with --, since a key or a git branch name may start with -.

  6. Copy legacy branches. Datasets from before bookmarks named working branches per workspace, tether.ws.<id>.<workspace>.<key>. recover lists them under their dataset id as legacy branches of workspace <workspace>, since one may hold the only copy of writes nobody committed. new --adopt does not take them, and restore refuses the trunk, so each step first moves to a bookmark recover-<workspace>, made off the trunk by the workspace’s first step and joined by the rest. An existing bookmark of that name is joined only if it holds nothing but an earlier recovery’s commits and registers the objects; otherwise the steps make recover-<workspace>-2 and leave it alone. Then tether restore --at REF -- KEY copies the branch onto the bookmark’s branch, and tether commit pins it before the next step’s new, which would reset it otherwise. Objects that share a branch space, such as two Neon databases on one branch, are restored together in one tether restore --at REF -- KEY1 KEY2:

      2. tether new -b recover-ab12cd34 -- main && tether restore --at tether.ws.0a1b2c3d.ab12cd34.imaging-3f2a1b -- imaging && tether commit -m 'Recover tether.ws.0a1b2c3d.ab12cd34.imaging-3f2a1b'
         # tether.ws.0a1b2c3d.ab12cd34.imaging-3f2a1b is a legacy branch of workspace ab12cd34 (named before bookmarks) and may hold writes no commit pins: this copies it onto bookmark recover-ab12cd34 and pins it

The VCS history is lost for good: the commit messages, and which states were committed together. The pins survive, but nothing says which of them made up one version of the dataset. An old pin that no new commit reuses, such as a bookmark’s earlier commits, is named by no manifest and was not created by this clone. gc therefore plans it as keep-pin until tether gc --release-foreign. To keep one as a starting point, tether restore KEY --at tether.<id>.<hash> on a bookmark copies it onto the bookmark’s branch.

Removing tether from a repository

There is no tether uninstall yet (it is on the roadmap), so the recipe is manual. The point is the refs tether left in the stores: every pin is tether.<id>.<hash> (Icechunk repins add .2, .3), and every working branch tether.ws.<id>.<bookmark>, where <id> is the [dataset] id in tether.toml. Nothing else in a store is tether’s.

  1. Let tether release what it can. tether drop NAME --no-dry-run for every bookmark but the trunk releases their pins and branches; add --delete-stores to also delete the stores add --create made on them. Then tether gc --prune-bookmarks --no-dry-run, with --release-foreign if this is the last clone. A created store that a manifest in history still names (one the trunk committed) is kept, never deleted: remove it by hand in step 3.

  2. Find the stores. Every commit’s manifests name them: tether export refs.sqlite puts them in one table (SELECT DISTINCT kind, uri FROM objects), or read .tether/objects/*.toml at each commit.

  3. Delete the remaining refs, per store. History still names the trunk’s pins, so they go by hand:

    Kind List Delete
    icechunk repo.list_tags(), repo.list_branches() repo.delete_tag(name), repo.delete_branch(name)
    lance ds.tags.list(), ds.branches.list() ds.tags.delete(name) first, then ds.branches.delete(name) (a branch a tag references cannot be deleted)
    git git tag -l 'tether.*', git branch --list 'tether.ws.*' git tag -d NAME; git push REMOTE --delete NAME where pins were pushed; git branch -D NAME
    neon the project’s branch list (console or API), names tether.* the pin branches first (children of the working branches; unprotect the protected ones), then the tether.ws.* branches
    iceberg table.refs() table.manage_snapshots().remove_tag(name) / .remove_branch(name)
    dolt SELECT tag_name FROM dolt_tags, SELECT name FROM dolt_branches CALL DOLT_TAG('-d', name), CALL DOLT_BRANCH('-D', name)
    delta, ducklake, file – nothing: these hold no refs. A file directory add --create made has a .tether-owner marker to remove

    Delete only names carrying this dataset’s id. Releasing a ref makes the data eligible for the system’s own expiry (expire_snapshots, cleanup_old_versions); run that afterwards to reclaim the bytes.

  4. Remove the files. Delete tether.toml and .tether/ from the working tree and commit. Delete tether.lock, tether-pinned.jsonl, tether-created.jsonl and tether-touched.jsonl from the VCS’s shared directory (.jj/repo/ or .git/). Every other checkout deletes its own .tether/. Old commits keep their manifests as text; rewriting history to remove them is optional, and the VCS’s job.