Skip to content

fix(data-layout): let every self-mode worktree publish learnings and keep its queue (#808) - #814

Merged
jeff-r2026 merged 1 commit into
Tencent:mainfrom
SaulMoro:fix-808-self-mode-worktrees
Sep 25, 2026
Merged

jeff-r2026 merged 1 commit into
Tencent:mainfrom
SaulMoro:fix-808-self-mode-worktrees

Conversation

@SaulMoro

@SaulMoro SaulMoro commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

In self mode only a repo's first checkout could publish learnings and reports. In a linked worktree, contribute failed with 'teamai-learnings' is already used by worktree and saved the learning locally, and git worktree remove then deleted the queue. Side-branch checkouts and the queue now live in the shared partition and each checkout has its own search index. In every mode, teamai refuses a checkout it cannot prove belongs to this repo: it does not read or delete it. Queue writes, the migration and init share one queue lock, and a queue belongs to its install's kind and team repo (#823 items 11, 13).

 self mode                       partition = ~/.teamai/projects/<slug>/, shared by every checkout
-  learnings-wt/, reports-wt/    <checkout>/.teamai/<dirname>
+  learnings-wt/, reports-wt/    <partition>/<dirname>
-  pending-learnings/            <checkout>/.teamai/pending-learnings
+  pending-learnings/            <partition>/pending-learnings
-  search index                  <partition>/search-index.json
+  search index                  <partition>/workspaces/<managedMcpWorkspaceId(checkout)>/search-index.json
 upgrade: init, pull, push, contribute, import --from-mr
+  migrate first; <checkout>/.teamai/pending-learnings/* goes by the partition's install (settleCheckoutQueue):
+    same owner → partition queue; other kind → pending-learnings.<kind>; other repo → .<kind>-<repo>; unreadable → kept
+  queue would stay in the checkout → contribute, import --from-mr, init exit 1, nothing saved (queueKeptInCheckout)
+  old */.teamai/<dirname> checkout → `git worktree remove` (no --force); dirty → kept, warned, publish refused
+  self partition over a git config beside self knowledge ('superseded') → machine data to .teamai.bak, config.yaml last
 every mode
+  ensureWorktree, checkOwner, isForeignByFiles: fail closed; a .git without a live registration here
+    (gitdir with commondir) → ForeignCheckoutError, kept, never indexed, read, counted or rewritten
+  refresh → done | busy (lock path) | failed (cause) | throws refusal; recall maintenance/promote exit 1, nothing written
+  reports readers (members, projects members, digest, pull, stats, viz) → readableReportsWorktree: owner check; lock held → local copy
+  queue lock ~/.teamai/locks/queue-<hash(queue home)>.lock, 30 × 100 ms; order: sync, checkout queue, partition queue
+  savePendingLearning, publishQueuedLearnings re-read config.yaml under it; install changed → nothing saved / published
+  queue owner = kind + repo.remote (remotesMatch); init with another or unreadable owner → set aside, every search index dropped
Case main This PR
contribute in linked worktree b already used by worktree, saved locally published
git worktree remove b after a queued contribute (migrated or not) learning gone in the partition queue; r's next pull publishes it
recall in r after r and b contribute b's only, path into b both, paths exist
import --from-mr in b queued in b/.teamai/ queued in the partition, published
upgrade with old r/.teamai/learnings-wt other checkouts locked out removed if clean; dirty: kept, warned, maintenance exits 1, contribute says no pull publishes until it is dealt with
unmigrated b, queue cannot move (sync lock held, partition without a usable config.yaml, name clash, queue lock busy) queued in b/.teamai/, lost with b contribute, import --from-mr, init exit 1, cause and next step named, nothing saved
unmigrated git installs in r and b, one queued each; pull in r, then b; remove b b's learning retired into b/.teamai.bak moved to the partition queue, published
init --self in r over a git install; b merges main Auto-migration failed: migration refused to rename … on every write works; old queue in pending-learnings.git, old install in b/.teamai.bak, knowledge untouched
unmigrated self checkout, partition config.yaml does not parse checkout's valid config.yaml deleted all kept, warned; the command reads the checkout's config
superseded retirement interrupted, or a learning queued during it config.yaml in the backup; next run publishes the queue as self config.yaml moves last, kept while a learning is queued ('skipped'); next run resumes
orphan queue in r/.teamai/pending-learnings (older self install, project since switched to a team repo) never published; lost with a removed worktree pending-learnings.self, named; beside an unparseable config.yaml: exit 1, warning names it
contribute --scope user, import --from-mr --output migrate the project leave it alone
old .teamai/search-index.json; removed worktree's workspaces/<id>/ moved, unread; left behind deleted; pruned by a full pull
init switches git ↔ self, learnings queued published to the new repo pending-learnings.<old kind>, reported; indexes dropped
init against another team repo the new repo gets them pending-learnings.<kind>-<repo>; same repo written another way (.git, /, SSH/HTTPS): kept
init, previous config.yaml does not parse, learnings queued published to the new repo pending-learnings.unknown, config named; every index dropped
unmigrated checkout's queue from another kind or repo than the partition's drained and published to the partition's repo set aside by the partition's owner, named
partition checkouts of another repo (git ↔ self switch, team repo recloned) published to, indexed, votes counted; dirty one deleted refused and kept; not indexed, digested, counted or rewritten; vote-judge hook drops it (from .git, no git)
learnings-wt/.git leads to a moved, deleted or recloned repo, uncommitted files contribute deletes it; recall indexes it refused and kept; not indexed
teamai-learnings checked out outside the partition; maintenance, promote writes a plain learnings-wt, later deleted exit 1, git's error named, nothing written
maintenance, promote while the learnings or reports lock is held goes on with unchecked paths exit 1, The learnings checkout is locked: … / The reports checkout is locked: …
reports readers while a write holds the reports lock may create or prune under the holder; after a switch, reads the other repo's roster owner check, local copy, creates nothing
contribute while the migration or init changes its install learning in .teamai.bak, or published to the new repo waits; exit 1, This project's teamai install changed while this command ran (…)
queue lock held for more than 3 s (no lock) exit 1, Another teamai command is moving this project's queued learnings (…), nothing saved
uninstall with learnings queued deleted silently each queue listed before the prompt

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature causing existing behavior to change)
  • Documentation only
  • Refactor / internal cleanup

Test Plan

  • npx tsc --noEmit passes (82764f6 on 87a606b)
  • npx vitest run passes: 326 files, 5043 passed, 1 skipped (82764f6 on 87a606b)
  • npm run test:e2e -- --retry 0: 57 files passed, 3 skipped; 336 passed, 26 skipped (82764f6 before its rebase onto 87a606b, no conflicts; after it, e2e/self-mode-worktrees-808, e2e/data-layout-migration, e2e/import-mr-publish-823: 35 passed)
  • Added/updated tests: e2e/self-mode-worktrees-808.test.ts (22, real CLI), e2e/import-mr-publish-823 refusal case; unit: import-mr-learnings-checkout, viz-foreign-checkout, reports-votes-owner, reports-readers-busy, stats-foreign-reports (real git), pending-learnings-mode-switch, search-index-drop-all, readableReportsWorktree, migrate, hook-handlers, maintenance-paths

e2e 1–22: linked-worktree publish and queue survival (1, 2, 14); dirty, foreign and unproven checkouts (3, 4, 5, 20); locks (6, 15); mode and team switches (7, 9, 12, 17, 18, 19); queue kept in the checkout stops the command (8, 11, 13, 16, 22); checkout outside the partition (10); orphan queue (21).

Real CLI: the issue on origin/main (ec56a67), then this PR (sandbox HOME, bare origin; $SB sandbox, $PART partition)
# origin/main
$ teamai contribute --title b-note --file n2.md        # in b
    ⚠ Saved locally (… fatal: 'teamai-learnings' is already used by worktree at '$SB/r/.teamai/learnings-wt' …)
$ git worktree remove ../b
$ teamai recall note                                   # in r
    File: $SB/b/.teamai/pending-learnings/b-note-2026-09-24-3o2pr7.md
$ teamai pull; git ls-tree … teamai-learnings          # on origin: main-note only

# this PR
$ teamai contribute …                                  # in r, then in b: both ✔ Contributed
$ teamai contribute --title b-queued …                 # in b, push rejected
    ⚠ Saved locally (… [remote rejected] …)
$ git worktree remove ../b
$ teamai recall note                                   # in r
    File: $PART/learnings-wt/learnings/b-note-2026-09-24-rraitc.md
$ teamai pull                                          # in r
    ✔ Published 1 queued learning(s)
$ git ls-tree -r --name-only teamai-learnings          # on origin: b-note, b-queued, main-note

Ablations: origin/main (ec56a67) fails e2e 1 (not to contain 'already used by worktree'). Each part, reverted alone, fails at least one test. That is 108 parts: 44 in the first build, then 8, 4, 4, 8, 5, 1, 2, 8, 8, 6, 3, 3 and 4 in the rounds from 6 on. The same-form remote test is a guard and passes on both builds, and raw remote equality is caught by a unit test only. The late-learning superseded case is injected in migrate, since no real-CLI run can time it.

Not run: init <url> against a hosted provider (e2e uses local bare repos behind url.insteadOf), a real MR fetch, Windows.

Related Issues

Closes #808

Part of #823: items 11 (queue lock) and 13 (queue owner).

Notes for Reviewers

Door: two-way, with manual cleanup. From reading main's getWorktreeDir and ensureWorktree, not run: after a revert, a self-mode checkout's worktree add at <checkout>/.teamai/learnings-wt fails on the live <partition>/learnings-wt with #808's error, so learnings are saved locally until git worktree remove <partition>/learnings-wt (and reports-wt). main also does not read <partition>/pending-learnings in self mode, so those learnings stay there, unpublished, until someone moves them back by hand. Nothing is deleted except search indexes (rebuilt) and clean old checkouts (the branch is intact). Backups (.teamai.bak) and set-aside queues (pending-learnings.*) stay on disk.

Blast radius: wide. Every self-mode install migrates on the first init, pull, push, contribute or import --from-mr in each checkout. In every mode, teamai now refuses checkouts it cannot prove are this repo's, including the ones left after a team repo is recloned: the member moves them aside once, as the message says. contribute, import --from-mr and init exit 1 in the states listed above. The commands affected are init, pull, push, contribute, import --from-mr, recall maintenance, promote, members, projects members, digest, stats, viz, uninstall and the vote-judge hook.

Why these shapes:

  • A busy sync lock is 'busy'. 'skipped' still means the legacy dir is kept on purpose (Migration keeps the legacy .teamai/ while the partition config cannot be read #797).
  • init stops instead of settling, so the migration stays the only thing that moves a checkout's queue.
  • The queue lock lives outside the queue home, so it does not travel with the migration's rename, and a waiting writer cannot recreate .teamai/.

Known limits still in force:

  • A teamai older than this takes no queue lock: its contribute beside this version's migration can still leave its learning in .teamai.bak, as on main.
  • A self install whose origin URL moved (rename, transfer) sets its queue aside on the next init. This is conservative on purpose: the warning names the directory and says to move the files into pending-learnings/ if they belong here.
  • A git retire-only drain drops only the per-checkout indexes, so <partition>/search-index.json misses the drained learning until the next rebuild (P2).
  • In three no-plan states, contribute exits 1 with "see the warning above" and prints no warning: --dry-run with an orphan queue, an empty legacy config.yaml, and an orphan queue beside an unreadable partition config.yaml (P3).
  • settleOrphanQueue treats a queue with no config beside it as an old self install's. If a git install's repo.localPath is inside the checkout, pull sets its live queue aside as pending-learnings.self: nothing is lost, and contribute already refuses that layout (P3).
  • The unreadable-config warning prints the raw Zod message (describeConfigError exists). With HOME a git repo and an unparseable user config, it says the next init, pull or push moves the queue, but nothing does (P3).
  • The uninstall hint offers teamai pull for set-aside queues too. digest prints a refused reports checkout twice. The docs miss the CHANGELOG exits, "a plain recall runs no git" and the reports-lock message (P3).
  • e2e 21 runs init only after contribute has moved the orphan queue, so init with the orphan queue still present is not tested on its own.
  • init stops even with nothing queued when the checkout's data cannot move (chosen in round 13).
  • Also on main, for [bug] Follow-ups to the worktree fixes: import --from-mr never shares its learning, and push can still revert a teammate's update #823: readers call ensure unlocked after a failed refresh; viz in user scope reads unfiltered roots; init's busy exit after a team-repo reclone leaves the old config beside the new clone; a recall rebuild with no index during a skills collision; two unmigrated checkouts sweeping each other's old checkout at the same instant.
  • CI: local-agent.test.ts is flaky on main too.

Changed expectations:

  • git-kind-reports' "rebuilds a dangling sibling reports worktree after the clone is removed and re-cloned" (from main) now expects the refusal, with the uncommitted file kept, then the rebuild once the checkout is moved aside.
  • hook-handlers' "this project, pruned" now expects the vote judge to drop that root.
  • Fixtures that queued with a config not on disk now write it (helpers/install-config.ts, writeSelfPartitionConfig), since a write re-reads it. Mocks gain indexableVotesDir, readableReportsWorktree, isSilent and the real remotesMatch. e2e 16's old install records the origin its init sees. Other assertions are unchanged.

Rebases: #816 (the index builds keep both deliveredIndexSources and the per-checkout index and indexableVotesDir); #825 (import --from-mr queues and publishes as contribute, and its supersede check keeps indexableLearningsRoots); the rest, up to 87a606b (#831), without conflicts.

Review rounds, one commit throughout (from round 13 on, Codex hit its 15-minute cap, so the workflow prompt ran locally):

  1. refresh swallowed the refusal; stale indexes after the drain; empty branch from import --from-mr
  2. contribute drains its old queue; digest, viz, vote judge skip a refused checkout
  3. a kind change drops old indexes; a refused reports checkout's votes are ignored
  4. import --from-mr drains; refresh re-throws every refusal
  5. refresh returns busy; maintenance and promote exit 1
  6. contribute, import --from-mr migrate first; unproven checkouts refused; reports lock; readableReportsWorktree
  7. a busy sync lock is 'busy'; stats, viz read through readableReportsWorktree
  8. the old queue goes by the partition's config; refresh returns failed
  9. queueKeptInCheckout; the superseded plan
  10. retire-only settles the queue; an unreadable partition config stands the self plan down; config.yaml moves last
  11. superseded settles the queue before config.yaml
  12. the stats P2 cannot happen; test only
  13. queue lock (#823 item 11); init stops as contribute does
  14. local review: 7 fixes, none P1
  15. queue owner by kind and team repo (#823 item 13)
  16. unknown-owner set-aside; a gitdir without commondir is no proof; the self migration settles; refusal messages
  17. an unreadable previous config drops every index
  18. local review 2: orphan queue (settleOrphanQueue)
  19. local review 3: 6 P3s, no code change

@SaulMoro
SaulMoro force-pushed the fix-808-self-mode-worktrees branch from 638c20c to e7975ec Compare September 24, 2026 14:55
@jeff-r2026 jeff-r2026 self-assigned this Sep 24, 2026
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/utils/branch-worktree.ts:196 — refuseForeignCheckout() throws, but refreshImpl() catches and suppresses that error. Callers such as pull and resolveMaintenancePaths then continue using learningsRoots()/getReportsDir(). After a git↔self mode switch, teamai pull can index the foreign checkout, and maintenance commands can modify it, despite the PR promising it is “never reused.” Propagate ownership failures or require a successful ensure() before exposing these paths.
  • [P2 non-blocking] src/migrate.ts:621 — moving a legacy queued learning into the shared partition does not invalidate existing per-checkout indexes. For example, teamai push triggers this migration but does not rebuild indexes, so another checkout with an existing index cannot recall the migrated learning until a later pull/contribute. Invalidate other checkout indexes after moving queue entries.
  • [P2 non-blocking] src/import.ts:250 — learningsBranch.ensure() runs before MR fetching, AI extraction, and interactive acceptance, with pushIfCreated defaulting to true. If extraction fails or the user rejects the learning, the command still creates and pushes an empty teamai-learnings branch. Defer creation until acceptance or use a non-publishing preparation path.

@SaulMoro
SaulMoro force-pushed the fix-808-self-mode-worktrees branch from e7975ec to e949a62 Compare September 24, 2026 16:48
@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Thanks, all three were real. Fixed in the same commit, rebased on a725574:

  • [P1] refresh swallowed the ownership refusal. The refusal is now a ForeignCheckoutError, the one side-branch failure refresh re-throws. recall maintenance and recall promote stop with exit code 1 and write nothing; members and projects members do the same for a reports checkout. Every index build (pull, contribute, and recall when it has no index) now uses indexableLearningsRoots, which probes ownership and leaves out only that checkout's learnings, so the project's own queue and knowledge stay recallable. A plain recall runs no git. viz, digest and the recall hook's allowed read roots still list that checkout until it is removed; that is documented in docs/designs/data-directory-layout.md. e2e test 3 seeds a learning on the other team's branch and checks that contribute, recall (with and without an index), pull, maintenance and members never index, list or touch it.
  • [P2] the queue drain left per-checkout indexes stale. When the migration moves queued learnings into the partition, it now drops every workspaces/*/search-index.json there, so the next recall rebuilds with them. Covered in migrate.test.ts.
  • [P2] import --from-mr could publish an empty branch. The ensure now runs with pushIfCreated: false, so it only prepares the checkout locally; a later publish creates the remote branch. import-mr-learnings-checkout.test.ts checks that a failed extraction and an import with nothing accepted leave origin without teamai-learnings.

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/utils/pending-learnings.ts:22 — contribute now writes to the partition queue, but it is not one of the migration-triggering commands. After upgrading, if a linked worktree already contains queued files under <worktree>/.teamai/pending-learnings, running teamai contribute does not move them; removing that worktree still deletes those older unpublished learnings. Run the legacy queue drain before contribute switches to the new queue path.
  • [P1 blocking] src/utils/learnings-roots.ts:105 — ownership filtering is limited to index-building callers. With a foreign learnings-wt left after a git↔self switch, teamai digest reads and displays that repository’s learning titles, teamai viz can build its temporary index from it, and the recall hook treats it as an allowed read root. This concretely exposes another repository’s data despite the new refusal boundary; all readers must use ownership-checked roots.

The three earlier findings are resolved in the current diff. The PR description includes sufficient real-CLI/e2e testing evidence.

@SaulMoro
SaulMoro force-pushed the fix-808-self-mode-worktrees branch from e949a62 to 81b3015 Compare September 24, 2026 17:41
@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Reply to #814 (comment)

Thanks. Both addressed in the same commit:

  • [P1] contribute did not move a worktree's old queue. contribute now drains its own checkout's .teamai/pending-learnings/ into the partition queue before it publishes, with the migration's helper (atomic, never overwriting), in self mode only. It does not run the rest of the migration. e2e test 2 now puts an old queue in a linked worktree, runs only contribute there, and checks that the old queue is gone from the worktree, its learning is on origin, and removing the worktree leaves nothing queued.
  • [P1] other readers of a refused checkout. digest and the dashboard (viz: its temporary index and its promotion and prune candidates) now use indexableLearningsRoots, so they leave that checkout out. e2e test 3 checks digest, and viz-foreign-checkout.test.ts checks viz. The recall hook's vote judge keeps the full root list, deliberately, to stay free of git calls on that path. Those roots are not a source of content: they only gate which documents named in the session transcript the judge may read an excerpt from (hook-handlers.ts:592-613). Those are normally what recall printed from its index, which already leaves the refused checkout out; a transcript that names a file there anyway (hand-edited, or from a session that ran before the mode switch) passes that check. That trade-off is documented in docs/designs/data-directory-layout.md.
  • Also, from our own review: a contribute and a pull draining the same old queue at once no longer fail on a file the other already moved, and import --from-mr --dry-run/--output no longer count the refused checkout's learnings as overlaps.

@SaulMoro

SaulMoro commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

Follow-ups: the items that only exist in this branch (queue after a git↔self switch, uninstall and the unpublished queue, the vote-judge hook's read roots, and the old and per-worktree search indexes) are being fixed in this PR, so they are no longer tracked elsewhere. #823 tracks only what already happens on main, including item 1 (import --from-mr never commits its learning, the autoPushViaMR suspicion from #808).

@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Update in 0c21d43: the recall hook's vote judge now also leaves a refused checkout out. It reads the checkout's .git file (gitdir: and commondir), compares the owning repository's git dir with this project's, and drops the checkout's roots from its allowed read roots when they differ, with no git process. So the trade-off in my earlier replies (a transcript naming a file in the other checkout passing the root check) no longer applies. The same commit also sets a queue aside when init switches an install's kind, lists unpublished queues in uninstall, deletes the unread old search-index.json during the migration, and prunes removed worktrees' workspaces/<id>/ on a full pull.

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/types.ts:2185 — Existing self-mode installations already have a current-schema <dataHome>/search-index.json. This change moves self mode to a per-checkout index but leaves that old root index intact. After upgrading and switching to git/HTTP mode, recall reuses the root index without rebuilding and can return learnings/docs from the previous self repository. Delete or invalidate the root index during upgrade or mode switches.
  • [P1 blocking] src/types.ts:2223 — Moving reports-wt into the shared partition creates the same foreign-checkout hazard as learnings-wt, but several readers bypass ownership checks. After a self→git switch, recall/contribute index using the foreign checkout’s votes, and votes.ts can accept a negative vote based on that repository’s YAML. Require ownership validation before every direct reports read.

The earlier findings are resolved, and the PR description contains sufficient real-CLI/e2e testing evidence.

@SaulMoro
SaulMoro force-pushed the fix-808-self-mode-worktrees branch from 0c21d43 to 4b51eb0 Compare September 24, 2026 19:33
@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Both fixed in 4b51eb0 (still one commit).

Stale index after a mode switch. When init changes the install's kind, next to setting the queue aside it now deletes every search index in the data home: the root search-index.json and each workspaces/*/search-index.json (dropAllSearchIndexes). They were built from the other repository; recall rebuilds a missing index from the new install's roots. A unit test covers the helper (both kinds deleted, the managed MCP config and the queue kept), and the #808 e2e mode-switch test seeds both files before init --self --force and asserts they are gone.

Votes from a refused reports checkout. indexableVotesDir runs the same ownership probe on the reports checkout (reportsBranch.checkOwner) and returns undefined when it belongs to another repository, as indexableLearningsRoots does for learnings. contribute's index build, recall's rebuild and recall feedback --negative take the votes directory from it, so that team's votes neither rank this project's learnings nor count as its upvotes. Covered by a real-git unit test (own vs. another repo's reports-wt) and the #808 e2e: with another repo's reports checkout that upvotes this project's learning, the index built by contribute and by recall carries 0 votes for it, and a downvote of a doc only that team voted reports it as not found.

Each fix was red before, and reverting each part alone (the index drop, the downvote, the contribute and recall builds) fails the e2e.

@SaulMoro
SaulMoro force-pushed the fix-808-self-mode-worktrees branch from 4b51eb0 to 03560fe Compare September 25, 2026 03:58
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/import.ts:262 — import --from-mr writes directly to the new partition queue, but import is not a migration-triggering command and does not perform contribute’s legacy-queue drain. After upgrading, running this command from a linked self-mode worktree leaves its existing <worktree>/.teamai/pending-learnings untouched; removing that worktree then permanently loses those unpublished learnings. Drain the legacy queue before this write/publish path.

  • [P1 blocking] src/utils/branch-worktree.ts:806 — refreshImpl() still suppresses CheckoutRefusedError for a dirty legacy checkout. recall maintenance --confidence-writeback then proceeds with the nonexistent new shared write root, writes replacement files there, and publishLearningsMaintenance() subsequently deletes that plain directory as “stale” before failing again on the old checkout. This loses the maintenance edits while reporting that they “stay local.” Maintenance must stop on this refusal before resolving writable paths.

  • [P2 non-blocking] src/utils/branch-worktree.ts:797 — refreshImpl() calls ensureWorktree() even when acquireLock() returned false. Now that self-mode checkouts share this path, a concurrent first-time writer may be creating the worktree while a reader without the lock also prunes/removes/creates it, defeating the lock and potentially leaving a broken checkout. Return immediately on lock contention before performing any mutating ensure operation.

The previously reported findings are resolved, and the PR description includes sufficient real-CLI/e2e testing evidence.

@SaulMoro
SaulMoro force-pushed the fix-808-self-mode-worktrees branch from 03560fe to a7e98ef Compare September 25, 2026 04:56
@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Fixed in a7e98ef, still one commit, now on f558b94.

  • [P1] import --from-mr now drains the checkout's old queue before it writes, with the same helper and conditions as contribute (drainCheckoutQueue: self mode, <checkout>/.teamai is not the data home; a failure warns and the import goes on). Test: import-mr-learnings-checkout › "publishes what an older teamai queued in a linked worktree, so removing the worktree loses nothing". It queues a learning in a linked worktree's .teamai/, runs import --from-mr there, then git worktree removes it, and checks that both learnings are on origin and the partition queue is empty.
  • [P1] refresh now re-throws every CheckoutRefusedError, not only the foreign one, and recall maintenance / recall promote exit 1 on it before they resolve writable paths. Other refresh failures are still best effort. The other callers already threw this error from the ensure that follows, or catch it as before. Test: e2e/self-mode-worktrees-808 › e2e 2 runs recall maintenance --confidence-writeback against a dirty old checkout with a voted learning. It checks for exit 1, the refusal printed once, no "Updated confidence", no <partition>/learnings-wt, the learning unchanged, and the old checkout's status and HEAD untouched. Before the fix it printed "Updated confidence scores for 1 learning(s)" and then "stay local", and exited 0.
  • [P2] refresh returns before ensureWorktree when acquireLock fails. Test: reports-branch-readonly › "leaves a missing checkout to the write that holds the lock". With the lock taken and no checkout on disk, refresh makes no isGitRepo probe and runs no git raw command (prune/add).

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/utils/branch-worktree.ts:798 — On lock contention, refreshImpl() returns before validating ownership or confirming that the shared checkout exists. Consequently, recall maintenance proceeds with raw paths from resolveMaintenancePaths(): if the path contains a foreign checkout, maintenance can rewrite another repository’s learnings; if the checkout is still being created, confidence writeback can create a plain replacement directory whose edits are later deleted by ensureWorktree(). Abort maintenance or perform a non-mutating ownership/existence check when the lock is busy.

The previously reported findings are resolved in the current diff. The PR description includes sufficient real-CLI/e2e testing evidence.

@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Fixed in 31d8498. refresh now returns busy, with the lock path, when it cannot take the lock, and resolveMaintenancePaths throws LearningsCheckoutLockedError on it, so recall maintenance and recall promote exit 1 and write nothing: "The learnings checkout is locked: another teamai command may be updating it, or its lock at could not be created. Nothing was changed. Run this again when the other command finishes; if this keeps happening, check that is writable." Readers keep using the local copy.

Covered by e2e 5 in src/__tests__/e2e/self-mode-worktrees-808.test.ts ("stops maintenance and promote while another command holds the learnings lock, writing nothing"): with a live lock, maintenance --confidence-writeback and promote exit 1, print the message once, and leave the existing checkout's HEAD and status as they were; with the checkout removed, maintenance exits 1 and creates no learnings-wt. The test fails on a7e98ef and with the lock check removed.

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/index.ts:14 — contribute and import still do not trigger migration. For a pre-partition self-mode installation, configuration loads with dataHome === <checkout>/.teamai, so drainCheckoutQueue() returns early at src/contribute.ts:115; new and existing queued learnings remain checkout-local. If publishing fails and that linked worktree is removed, those unpublished learnings are lost. Run the full migration before either command.
  • [P1 blocking] src/utils/branch-worktree.ts:195 — Ownership is checked only when rev-parse succeeds. If a checkout from the previous mode has a dangling .git pointer because its owning repository was moved or deleted, ensureWorktree() falls through and recursively removes it at line 203, potentially deleting uncommitted foreign data. checkOwnerImpl() and isForeignByFilesImpl() similarly fail open and allow stale foreign content to be indexed. Existing checkout-shaped paths must be rejected whenever ownership cannot be proven.
  • [P1 blocking] src/maintenance/paths.ts:50 — The reports refresh result is discarded, so when .reports-lock is busy maintenance proceeds with the unchecked raw reports-wt/votes path. During a mode switch, a live old-mode process can hold that lock while the path still belongs to another repository; --prune, promotion, or confidence writeback then uses the other team’s votes and can modify the current team’s learnings incorrectly. Propagate the busy result and abort maintenance as is already done for the learnings lock.
  • [P2 non-blocking] src/utils/reports-branch.ts:71 — Returning Promise<void> discards RefreshResult.status. members, projects members, digest, and pull consequently call ensureReportsWorktree() after a busy refresh, bypassing the shared lock; during first-time checkout creation this can race the writer’s remove/prune/worktree-add operations and make either command fail or leave a partial checkout. Return the refresh result and avoid ensure when busy.

The PR description includes sufficient real-CLI/e2e testing evidence. The earlier findings are otherwise addressed, but the legacy-config queue case above remains unresolved.

@SaulMoro
SaulMoro force-pushed the fix-808-self-mode-worktrees branch from 31d8498 to a94bd2d Compare September 25, 2026 06:12
@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Thanks. All four are fixed in a94bd2d.

  • P1 src/index.ts:14. contribute now triggers the migration, and so does import with --from-mr. Only that mode: the other import modes never touch the queue, and import --cache-status --json has to print nothing but JSON. Test: e2e 2 in self-mode-worktrees-808 sets up a linked worktree that was never migrated (legacy config.yaml plus a queued learning), runs import --cache-status --json (no migration), then contribute with pushes rejected, then git worktree remove --force. Both learnings are in the partition, and r's next pull publishes them. With the trigger reverted, the queue is lost; with the import narrowing reverted, --cache-status migrates.
  • P1 src/utils/branch-worktree.ts:195. When git cannot open a checkout, it is recreated only if its .git leads to this repo, i.e. a pruned <common>/worktrees/<name> as after a re-clone (git-kind-reports covers that case). Any other checkout-shaped path is refused with a ForeignCheckoutError and never removed. checkOwner and isForeignByFiles fail closed the same way. Only a path with no .git is still cleared. Tests: e2e 5 points learnings-wt/.git at a deleted repository and leaves an uncommitted file there. recall does not index it, and contribute warns once, keeps the file and queues its learning. There are also two hook-handlers cases (gone repository: dropped; this project's pruned registration: kept).
  • P1 src/maintenance/paths.ts:50. A busy reports lock now throws, just as a busy learnings lock does. Both use CheckoutLockedError (renamed from LearningsCheckoutLockedError) with the same message and the lock path. Tests: e2e 6 holds .reports-lock, and recall maintenance --confidence-writeback and recall promote exit 1 without writing anything. A maintenance-paths unit test checks that the learnings refresh never runs.
  • P2 src/utils/reports-branch.ts:71. refreshReportsWorktree returns the RefreshResult. members, projects members, digest and pull now read through readableReportsWorktree, which returns the local copy without calling ensure when the refresh is busy. Test: reports-branch-readonly holds the lock and checks that nothing is ensured, pruned or listed. A second case checks that nothing is pushed when the lock is free.

Each fix was reverted on its own, and its test fails every time. npx vitest run: 313 files, 4899 passed. e2e with --retry 0: self-mode-worktrees-808 (7), import-mr-publish-823, dashboard-unified, and the 12 other files that run digest, members or projects all pass.

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/index.ts:75 — maybeMigrate() silently returns when the legacy sync lock is busy, but contribute and import --from-mr continue. If they load the still-legacy self config, drainCheckoutQueue() returns early because dataHome === <checkout>/.teamai, and the new learning remains checkout-local. Removing that linked worktree then loses the unpublished learning. Abort queue-writing commands when migration returns skipped, or resolve/write the partition queue independently.
  • [P1 blocking] src/utils/reports-branch.ts:87 — On a busy or uncreatable reports lock, readableReportsWorktree() returns the raw checkout path without validating ownership. During a git↔self switch while the prior-mode process holds the lock, members, projects members, digest, and pull can read the previous repository’s roster, stats, sessions, or votes. Perform a non-mutating ownership check before returning the busy path, or treat it as unavailable.
  • [P2 non-blocking] src/types.ts:2249 — Moving self-mode reports-wt into shared storage makes unlocked ensureReportsWorktree() calls unsafe, but stats.ts:61 and viz.ts:175 still call it directly. Concurrent first-time access from another worktree can race the locked writer’s remove/prune/worktree-add sequence and cause either operation to fail or leave a partial checkout. Route these readers through the lock-aware helper.
  • [P2 non-blocking] docs/designs/data-directory-layout.md:236 — The migration gate still states that self mode is a “hard no-op” and that init --self retires the partition, directly contradicting the newly implemented self migration and the later P2 section. Update this section per the repository’s documentation-sync rule.
  • The PR description contains sufficient real-CLI/e2e testing evidence. The previously reported findings are otherwise resolved.

@SaulMoro
SaulMoro force-pushed the fix-808-self-mode-worktrees branch from a94bd2d to 462b2f3 Compare September 25, 2026 06:37
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/migrate.ts:713 — The final queue check is still a TOCTOU operation because savePendingLearning() does not acquire the migration lock. An already-running older contribute can write after queueSettled() returns but before config.yaml is moved; the queue is then left without its old-kind marker and may be published to the new self repository. The full and retire-only paths have the analogous window before renaming .teamai, where the late learning is stranded in .teamai.bak. Queue creation must participate in the lock or retirement must preserve an authoritative marker atomically.

  • [P1 blocking] src/init.ts:22 — A mode switch moves the shared queue before saving the new config, without excluding concurrent current-version contributors. If a contribution using the old config recreates the live queue after setAsideQueueOnModeSwitch() and its push fails, init then installs the new kind and the next pull publishes that old-repository learning to the new repository. Serialize the queue move/config switch with queue writes.

  • [P2 non-blocking] src/team-push.ts:768 — snapshotWrittenAt() still reads getReportsDir(config) directly, bypassing the new ownership-checked reports helper. After a git↔self switch leaves a foreign reports checkout, teamai stats rejects that checkout for reported totals but still uses its stats-file mtime to calculate dropped rollouts, producing incorrect local/unreported metrics. Pass an ownership-validated reports root or treat the timestamp as unavailable.

The other previously reported cases appear resolved, and the PR description includes sufficient real-CLI/e2e testing evidence.

@SaulMoro
SaulMoro force-pushed the fix-808-self-mode-worktrees branch from 7068e5c to 628f797 Compare September 25, 2026 09:35
@SaulMoro

SaulMoro commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Round 12 (628f797):

  • P2 src/team-push.ts:768. This can't happen through teamai stats. A refused reports checkout makes loadReportedStats return null. teamHasReported is then false, so unreportedDashboardStats (the only stats caller of snapshotWrittenAt) never runs. pull's report uses that timestamp only for the delta it writes through updateReports, and that write's ensure refuses the checkout. No production change. stats-foreign-reports (real git, another repo's reports-wt holding totals for the same user) pins this: it passes now, and it fails if either stats's ownership check or its teamHasReported guard is removed.
  • P1 src/migrate.ts:713, P1 src/init.ts:22. Both are the same race: queue writes take no lock, because savePendingLearning runs outside the migration and init locks. The race exists on main too, and there it is wider. A contribute during the migration leaves its learning in .teamai.bak, and a mode switch sets nothing aside, so the whole old queue is published to the new repository. This PR narrows the window but does not close it. Closing it needs one lock shared by queue writes, the migration and init. That lock lands in this PR in the next push.

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/index.ts:97 — Only commands that directly queue learnings abort when migration returns busy or leaves data behind. If an unmigrated linked checkout has queued learnings and init runs while its sync lock is busy—or while the partition config is missing/unreadable—init continues and can establish the partition without moving that queue. Removing the linked worktree afterward permanently deletes the unpublished learnings. init must abort or settle the legacy queue before completing.

  • [P1 blocking] src/migrate.ts:713 — The final queueSettled() check is still unsynchronized with savePendingLearning(). An older contribute can write after this check but before config.yaml or the legacy directory is moved; the late learning is then stranded in .teamai.bak or loses its old-kind marker and can be published to the wrong repository. Queue writes and retirement must share the migration lock or preserve the marker atomically.

  • [P1 blocking] src/init.ts:22 — A mode switch moves the shared queue before saving the new config, without excluding concurrent contributors. A contribution using the old config can recreate the live queue after it was set aside; if its old-repository push fails, the newly installed configuration later publishes it to the new repository. Serialize the queue move and config replacement with queue writes.

  • [P3 nit] src/migrate.ts:892 — Index invalidation depends on moved > 0. If migration crashes after atomically renaming a learning into the partition queue but before incrementing moved or invalidating indexes, the retry sees identical destination content, removes the source, and never drops existing per-checkout indexes. The migrated learning remains invisible until another operation rebuilds each index.

The previously reported checkout ownership and reader-path issues appear resolved. The PR description contains sufficient real-CLI/e2e testing evidence.

SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 25, 2026
…ncent#823)

Three more ways push could list a copy the member never edited as modified,
ready to send a teammate's change back as the old version.

Single-repo mode (item 2). Push runs against a knowledge worktree whose team
root is <wt>/.teamai, a subdirectory of the git repo. The pre-push sync read
each base version with `git show <rev>:rules/x.md`, which git resolves from
the repo root, so it never found one, and every rule or skill a teammate
updated read as a local edit. The three reads now pass `./<path>`, which git
resolves from the working directory, as getFileContentWhenAdded and the agent
guard already did.

Placed agents (item 3). An agent placed with --role/--project is held when it
changed on the team since this machine's copy was current, and "current"
meant the version at the shared lastPullRev, which a pull in another checkout
moves past a copy a stale worktree still holds (the Tencent#812 revert, for agents).
The guard now reads this checkout's bases through checkoutBaseRevs, and falls
back to the shared lastPullRev for a checkout with no entry, as the pre-push
sync does. Push bases record where the sync moved rules and skills, not
agents, so the copy stays at the revision pull delivered: the guard holds an
agent that differs from its version at any base. Push records the team HEAD
as a base before the scan, and the file there is always the current one, so
the version the agent was added with is compared too whenever a base
predates it; otherwise a placement that landed after the last pull would go
back over a teammate's later edit. The hold message now says "this checkout".

Skill copy (item 5). The sync overwrote a local skill in place, so a copy
that failed partway left files from two revisions, matching no base, and the
next push listed the skill as modified. The update is now built in a hidden
sibling (the local copy, then the team version over it, so files only the
member has survive as before) and renamed into place; a failure leaves the
previous version whole. The stage carries the local modes, so cleanup makes
a read-only stage writable before removing it, and warns with the path if a
leftover cannot be removed; if the previous version cannot be renamed back,
the error names where it is.

Item 4 (user-scope push base) follows once Tencent#814 is merged.
@SaulMoro
SaulMoro force-pushed the fix-808-self-mode-worktrees branch from 628f797 to 8f3f0be Compare September 25, 2026 10:21
@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Rounds 12 and 13 (5830032660, 5830259599), in 8f3f0be:

  • P1 src/migrate.ts:713, P1 src/init.ts:22 (both reviews). Closed with one queue lock shared by queue writes, the migration and init. It's one file per queue home, ~/.teamai/locks/queue-<hash>.lock, outside the home so it doesn't move with the .teamai/ rename. It isn't .sync-lock, because git pulls hold that for their whole run. savePendingLearning holds the lock only for the write. Under it, it re-reads its install's config.yaml: if the file is gone, can't be read, or names another kind, it saves nothing and the command exits 1. import --from-mr now queues through it too; it used to write the file itself. The migration holds the checkout's lock from after its sync lock through the rename or the last config.yaml move. settleCheckoutQueue holds the partition's lock while it reads the kind and moves the queue. init holds it while it sets the queue aside and saves the new config. The publish also lists the queue under the lock and the same check, so a command whose install changed after its write publishes nothing. Waits are bounded (3 s), then exit 1 with Another teamai command is moving this project's queued learnings (...). Nothing was saved. Tests: migrate (retire-only rename, superseded config.yaml move, drain during a kind switch), pending-learnings-mode-switch (write during init's save, lock held), git-kind-learnings (publish after a switch), e2e self-mode-worktrees-808 (contribute exits 1 with the lock held). Each one fails before the lock (this round's API with the lock left out), and again when its side of the lock is removed: writer, migration, settle, init or publish. Not closed: a teamai older than this takes no queue lock, so its contribute beside a migration can still leave a learning in .teamai.bak, as on main.
  • P1 src/index.ts:97. init (except --scope user) now stops where contribute does, through queueKeptInCheckout, with exit 1 before it writes anything: on a busy sync lock, a partition config it can't read, or an old queue that could not move. I chose stopping over settling in init so the migration stays the only thing that moves a checkout's queue. Tests: index-migration-trigger. The e2e runs init --self in an unmigrated linked worktree with a queued learning, first with the sync lock held and then with an unreadable partition config (exit 1 both times, learning still in the checkout). Once both are fixed, init passes, git worktree remove --force runs, and the learning is in the partition queue.
  • P3 src/migrate.ts:892. The drain drops the per-checkout indexes whenever a learning is in the partition queue afterwards, whether this run moved it or an interrupted run already had. Test: migrate (the same file already in both queues).

@SaulMoro
SaulMoro force-pushed the fix-808-self-mode-worktrees branch from 8f3f0be to 03f28d7 Compare September 25, 2026 11:25
@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Local adversarial review of 8f3f0be. The Codex job hit its 15-minute cap on this diff, so I ran the workflow's prompt (Code Review Rules, the earlier reviews, the PR body, the diff) with three Claude reviewers, one per area, and checked each finding against the code. 12 findings, no P1.

Fixed in 03f28d7. Each fix has a test that fails without it:

  • P2 import --from-mr: its task list runs with the logger silenced, so the "see the warning above" reason pointed at a warning nobody saw. A refusal hit during a silent run no longer counts as warned, and the reason is now the whole message.
  • P2 queueKeptInCheckout: when the home is itself a git repo, ~/.teamai/pending-learnings (the user scope's queue) was read as a checkout's, so contribute and init exited 1. Real CLI: exit 1 on 8f3f0be; published on 03f28d7.
  • P2 uninstall in user scope deletes ~/.teamai/projects/*/pending-learnings*, where the self queue now lives, but did not list those queues. listQueuesIn now lists them.
  • P3: an old queue holding only a non-learning file never settled. holdsQueued now counts learnings the way the set-aside does.
  • P3: roles init hit init's queue stop.
  • P3: members and projects members threw a raw stack on a dirty old reports checkout. They now catch CheckoutRefusedError.
  • P3 docs: recall maintenance and promote exit 1 while a lock is held.

Not changed:

Checks, --retry 0: npx vitest run 324 files, 5021 passed, 1 skipped. The 9 e2e files (self-mode-worktrees-808, data-layout-migration, import-mr-publish-823 and the six that run members, uninstall, contribute or init): 49 passed, 22 skipped. Each of the 8 parts, removed alone, fails its test.

@SaulMoro

Copy link
Copy Markdown
Collaborator Author

This round adds #823 item 13 to the same commit (b85644a). A queue belonged to its install's kind only, so re-running init in git mode against another team repo kept the old team's queue, and the next pull published it to the new one. This PR also carried the same gap to a linked worktree's old queue.

A queue's owner is now the kind plus repo.remote, compared with remotesMatch: credentials, protocol, scp or URL form, .git and case are ignored. The owner replaces the kind in init's set-aside and index drop, in the writer's and the publish's re-check under the queue lock, and in settleCheckoutQueue. A queue for the same kind but another team repo goes to pending-learnings.<kind>-<repo>. One for another kind keeps pending-learnings.<kind>.

Tests: 3 unit tests in pending-learnings-mode-switch cover another team repo, the same repo written another way, and the writer and publish with the config switched under them. There are also 2 real-CLI e2e tests in self-mode-worktrees-808. e2e 17 runs init against team A, then team A written another way, then team B, then pull. e2e 18 covers a worktree's old install of another team repo. On 03f28d7 both e2e tests publish the other team's learning, and each of the 6 parts of the fix reverted alone fails a test. Full unit and e2e runs are in the Test Plan.

jeff-r2026 pushed a commit that referenced this pull request Sep 25, 2026
…) (#827)

Three more ways push could list a copy the member never edited as modified,
ready to send a teammate's change back as the old version.

Single-repo mode (item 2). Push runs against a knowledge worktree whose team
root is <wt>/.teamai, a subdirectory of the git repo. The pre-push sync read
each base version with `git show <rev>:rules/x.md`, which git resolves from
the repo root, so it never found one, and every rule or skill a teammate
updated read as a local edit. The three reads now pass `./<path>`, which git
resolves from the working directory, as getFileContentWhenAdded and the agent
guard already did.

Placed agents (item 3). An agent placed with --role/--project is held when it
changed on the team since this machine's copy was current, and "current"
meant the version at the shared lastPullRev, which a pull in another checkout
moves past a copy a stale worktree still holds (the #812 revert, for agents).
The guard now reads this checkout's bases through checkoutBaseRevs, and falls
back to the shared lastPullRev for a checkout with no entry, as the pre-push
sync does. Push bases record where the sync moved rules and skills, not
agents, so the copy stays at the revision pull delivered: the guard holds an
agent that differs from its version at any base. Push records the team HEAD
as a base before the scan, and the file there is always the current one, so
the version the agent was added with is compared too whenever a base
predates it; otherwise a placement that landed after the last pull would go
back over a teammate's later edit. The hold message now says "this checkout".

Skill copy (item 5). The sync overwrote a local skill in place, so a copy
that failed partway left files from two revisions, matching no base, and the
next push listed the skill as modified. The update is now built in a hidden
sibling (the local copy, then the team version over it, so files only the
member has survive as before) and renamed into place; a failure leaves the
previous version whole. The stage carries the local modes, so cleanup makes
a read-only stage writable before removing it, and warns with the path if a
leftover cannot be removed; if the previous version cannot be renamed back,
the error names where it is.

Item 4 (user-scope push base) follows once #814 is merged.
@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/utils/pending-learnings.ts:275 — previous === null is treated as a fresh install, but loadLocalConfigForScope() also returns null when an existing partition config.yaml is malformed or unreadable. Running teamai init <different-repo> --force in that state leaves the existing live queue in place, writes the new config, and the next pull publishes those unknown-owner learnings to the new repository. A non-empty queue with no readable previous owner must be set aside or make init abort.

  • [P1 blocking] src/utils/branch-worktree.ts:417 — the fallback accepts a dangling worktree solely because its .git pointer names the expected repository path. During git-team A → git-team B re-init, init deletes and reclones team-repo at the same path; an old A checkout therefore appears to belong to B even though its registration disappeared. ensureWorktree() then recursively removes that checkout, including any uncommitted A data. Missing gitdir/commondir metadata cannot prove ownership and must fail closed.

  • [P1 blocking] src/migrate.ts:792 — self migration ignores whether settleCheckoutQueue() actually moved the queue. If the partition queue lock remains busy while the partition belongs to a different install, migration continues and removes the legacy config.yaml, leaving the old queue without its ownership marker. Later migrations select the partition’s non-self kind and never recover that queue; deleting the linked worktree loses it. Return a settlement result and keep config.yaml whenever queued learnings remain.

The previously reported findings otherwise appear resolved, and the PR description includes sufficient real-CLI/e2e testing evidence.

@SaulMoro
SaulMoro force-pushed the fix-808-self-mode-worktrees branch from b85644a to 76c62a9 Compare September 25, 2026 13:02
@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Re 5832326638, fixed in d705979c (rebased onto e79db17):

  • pending-learnings.ts:275, unreadable previous config. setAsideQueueOnModeSwitch now tells a fresh install (no config.yaml in the queue's home) from one whose config exists but cannot be read. In the second case the live queue moves to pending-learnings.unknown[.N] under the queue lock, before the new config is saved, and the warning names that config. Tests: pending-learnings-mode-switch "moves the queue aside under an unknown owner…", and e2e "sets aside the queue under an unknown owner when init replaces a config it cannot read, and the next pull publishes none of it" (malformed partition config.yaml, init <team-b> --force, nothing reaches team-b).

  • branch-worktree.ts:417, dangling registration. commonDirFromFiles no longer takes a <common>/worktrees/<name> gitdir without commondir as proof; only a live registration counts. refuseUnprovenCheckout (ensureWorktree, checkOwner) refuses with ForeignCheckoutError and never removes the checkout, and isForeignByFiles judges it foreign. Test: e2e "refuses and keeps team A's learnings checkout once init reclones team B at the same path, so its uncommitted work stays". git-kind-reports (re-cloned clone) and hook-handlers ("this project, pruned") now expect the refusal.

  • migrate.ts:792, self settlement ignored. migrateSelfA1 settles through queueSettled, as retire-only and superseded do. If a learning is still in the checkout's queue, it relocates nothing, keeps the legacy config.yaml and returns 'skipped', so contribute / import --from-mr / init stop through queueKeptInCheckout. Test: migrate "keeps the checkout's config.yaml while its queue cannot move, so the next run settles it" (queue lock held, partition serving a git install; the retry sets the queue aside as pending-learnings.self).

  • Also in this round, two messages. While a checkout refusal blocks the publish, contribute and import --from-mr said the next teamai pull publishes the learning, but that pull is refused too. They now say it stays queued until that checkout is dealt with, and to do what the refusal says (refused carried from CheckoutRefusedError through the publish report). The dirty old checkout's refusal still offers "delete it by hand" and now says that loses its uncommitted changes. Tests: e2e 808 "upgrades a checkout an older teamai left…" (contribute while blocked, both texts) and import-mr-publish-823 "when a learnings checkout teamai refuses is in the way".

Each fix reverted on its own fails its tests. tsc, vitest run (5029 passed), test:e2e --retry 0 (334 passed).

@github-actions

Copy link
Copy Markdown
  • [P1 blocking] src/init.ts:23 — Index invalidation only runs when the previous config parses successfully. If an existing partition has a malformed config.yaml plus a valid search-index.json, init --force sets the queue aside and installs another repository without deleting that index. A subsequent teamai recall accepts the current-schema index without rebuilding and can expose results from the previous repository. Drop all indexes whenever an existing configuration is unreadable, not only when previous is non-null and its owner differs.

The previously reported findings appear resolved. The PR description includes sufficient real-CLI/e2e testing evidence.

@SaulMoro
SaulMoro force-pushed the fix-808-self-mode-worktrees branch from 76c62a9 to b8c3d7b Compare September 25, 2026 13:25
@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Fixed P1 src/init.ts:23 in the same commit. settleModeSwitch now drops every search index in the data home (dropAllSearchIndexes) when the previous config.yaml exists but cannot be read, not only when a readable previous config names another owner.

Test: e2e 19 in self-mode-worktrees-808 ("sets aside the queue under an unknown owner when init replaces a config it cannot read...") writes a current-schema search-index.json beside the malformed config.yaml, runs init <team-b> --force, and asserts the index is gone. It fails without the fix (expected true to be false) and passes with it. The previous Notes limitation is removed.

@SaulMoro
SaulMoro force-pushed the fix-808-self-mode-worktrees branch from b8c3d7b to 82764f6 Compare September 25, 2026 14:16
@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Local adversarial review of b8c3d7b. The Codex job was cancelled again at its 15-minute cap on this diff. I ran the workflow's prompt (Code Review Rules, the earlier reviews, the PR body, the diff) with three Claude reviewers on disjoint slices of the diff, and checked each finding against the code. 8 findings, none raised as P1.

Fixed in 82764f6, as a P1 after review:

  • migrate.ts, a queue with no plan. An older self install, whose config was already in the partition, queued a learning in <checkout>/.teamai/pending-learnings, and then an init on main switched the project to a team repo. No migration plan covered that queue, so contribute, init and import --from-mr stopped in that checkout for good with "see the warning above", and no warning was printed. Real CLI: exit 1 each time, pull and push exit 0, and the queue was never moved. On main those commands go on. maybeMigrate now settles such a queue as the self install's (settleOrphanQueue), which sets it aside as pending-learnings.self. When the queue sits beside a legacy config.yaml that does not parse, the migration now warns and names that file. Tests: e2e 21 and 22 in self-mode-worktrees-808, both red on b8c3d7b. Each of the 4 parts removed alone fails one of them.

Not changed (listed in Notes):

  • P2: a git retire-only drain drops only the per-checkout search indexes, so <partition>/search-index.json misses the drained learning until the next rebuild. No data is lost.
  • P3: the uninstall hint offers teamai pull for set-aside queues too; digest prints a refused reports checkout twice; three doc gaps (CHANGELOG exits, "a plain recall runs no git", the reports-lock message).
  • On main, for [bug] Follow-ups to the worktree fixes: import --from-mr never shares its learning, and push can still revert a teammate's update #823: readers call ensure unlocked after a failed refresh; viz in user scope reads unfiltered roots; init's busy exit after the team-repo reclone leaves the old config beside the new clone, as any init failure between clone and save does.

Checks, --retry 0: npx tsc --noEmit; npx vitest run 325 files, 5029 passed, 1 skipped; npm run test:e2e 57 files passed, 3 skipped, 336 passed, 26 skipped. After the rebase onto 87a606b (#831, no conflicts): tsc; npx vitest run 326 files, 5043 passed; the three e2e files above, 35 passed.

@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Local adversarial review of 82764f6, scoped to what changed since b8c3d7b. The Codex job was cancelled again at its time cap. The rebase onto 87a606b (#831) brought in only main's config.ts/fs.ts changes. So I reviewed the branch delta alone: settleOrphanQueue in maybeMigrate, the warning for an unreadable legacy config.yaml, e2e 21/22 and the design doc. I ran the workflow's prompt (Code Review Rules, the earlier reviews, the PR body, git range-diff and the delta diff) with one Claude reviewer and checked each finding against the code. No P1: the M1 fix is correct for the state it targets, and e2e 21/22 fail on b8c3d7b. There are 6 P3s, all confirmed by reading the code and none on main. None changes the code in this PR:

  • teamai --dry-run contribute in the M1 state still exits 1 with "see the warning above", and no warning is printed. With no plan, maybeMigrate returns undefined, not 'dry-run'.
  • The same message, with nothing above it, in two more no-plan states: an empty legacy config.yaml, and an orphan queue beside an unreadable partition config.yaml.
  • The new warning prints the raw Zod message, so a config that fails the schema shows "cannot be read ([)". describeConfigError in config.ts exists for this.
  • settleOrphanQueue treats every queue that has no config beside it as an old self install's queue. If a git install's repo.localPath is inside the checkout, pull sets that install's live queue aside as pending-learnings.self instead of publishing it. Nothing is lost, and contribute already refuses that layout.
  • When HOME is a git repo and the user config does not parse, the new warning says "the next init, pull or push moves them". Nothing moves that queue: it is the user install's own.
  • e2e 21 runs init after contribute has already moved the queue. So "init runs with an orphan queue present" is not tested on its own.

Testing: e2e 21/22 run the built CLI. Removing any one of the four parts makes one of them fail. The earlier checks and the post-rebase run are in the previous comment.

…keep its queue (Tencent#808)

Cause: in self mode, readConfigFrom re-anchors repo.localPath to each
checkout's .teamai/, and the teamai-learnings / teamai-reports checkouts
(getWorktreeDir) and the queue of unpublished learnings
(pendingLearningsDir) were derived from localPath. Git checks a branch out
in one worktree only, so the first checkout to create them locked every
other checkout out ("'teamai-learnings' is already used by worktree"), and
a learning queued in a linked worktree went with it on a plain
`git worktree remove`. The search index sat in the shared partition, so
recall served whichever checkout rebuilt it last, with paths into it.

Mechanism:
- getWorktreeDir and pendingLearningsDir return <dataHome>/<dirname> and
  <dataHome>/pending-learnings in self mode: the partition every checkout
  shares. The branch locks move with the checkouts.
- getProjectSearchIndexPath keys the self-mode index per checkout,
  workspaces/<managedMcpWorkspaceId(root)>/search-index.json, as managed MCP
  is. recall, pull, contribute and viz use it. Learnings are shared, so a
  contribute or pull rebuild deletes the other checkouts' index files in
  the partition; their next recall rebuilds from their own roots.
- contribute and import --from-mr now migrate first, as init/pull/push do
  (import's other modes do not: --cache-status --json prints only JSON;
  nor do contribute --scope user and import --from-mr --output, which do
  not write this project's queue).
- The self migration moves a checkout's old queue into the
  partition file by file, never overwriting: same content is dropped,
  different content is kept with a warning.
- ensureWorktree, before `git worktree add`, removes a registration of the
  same branch at <checkout>/.teamai/<dirname> without --force. A clean one
  goes, so a worktree works without a pull first; one with uncommitted
  changes stays, and the error names it and the next step. The error is
  also warned, since reports and refresh callers log it at debug only.
  Deleting it by hand, which the error offers, loses those changes, and
  it says so. While any checkout refusal blocks the publish, contribute
  and import --from-mr say the learning stays queued until that checkout
  is dealt with, not that the next pull publishes it.
  refresh re-throws it, so recall maintenance/promote stop with exit 1
  instead of writing into the missing shared checkout, which the next
  publish would clear as stale.
- Git mode keeps its checkouts at the same partition paths, so
  ensureWorktree accepts an existing checkout only when its git common dir
  matches the owning repo's, and otherwise refuses it without removing it
  (ForeignCheckoutError). A checkout git cannot open is recreated only
  while this repo still registers it (its gitdir's commondir leads here);
  one whose registration is gone, even with a clone at the same path (init
  reclones another team repo there), and any other is refused the same way
  and never removed.
  checkOwner and isForeignByFiles fail closed alike. refresh re-throws it too: recall
  maintenance/promote, members and projects members stop with exit 1.
  Every index build (pull, contribute, recall's rebuild), digest and viz
  use indexableLearningsRoots, which leaves out only that checkout's
  learnings.
- contribute and import --from-mr stop with exit 1, saving nothing, when
  the migration stands down on a busy sync lock: the queue would still be
  the checkout's. init, pull and push go on as before.
- refresh returns busy before ensureWorktree when a write holds the lock:
  every checkout now shares the path, and the holder may be creating it.
  recall maintenance/promote stop on either the learnings or the reports
  lock (CheckoutLockedError); members, projects members, digest, pull,
  stats and viz read through readableReportsWorktree, which skips ensure
  when busy and refuses another repository's copy (checkOwner).
- A queue's owner is its install's kind and team repository (repo.remote,
  compared as remotesMatch does, Tencent#823 item 13). init sets a queue aside as
  pending-learnings.<old kind>, or pending-learnings.<kind>-<repo> for
  another team repository of the same kind, when it changes the owner, so
  the new install never publishes the old one's learnings, and deletes
  every search index in the data home, built from the other repository;
  a previous config that exists but cannot be read names no owner, and
  its queue goes to pending-learnings.unknown and the indexes go too;
  the writer and the publish re-check the owner under the queue lock, and
  a checkout's old queue of another owner is set aside the same way; uninstall lists every unpublished queue before it
  asks.
- Index builds (pull, contribute, recall's rebuild) and recall feedback
  --negative take the votes directory from indexableVotesDir, which leaves
  out another repository's reports checkout, so its team's votes never
  rank or lower this project's learnings.
- The vote-judge hook drops a foreign learnings checkout from its allowed
  roots, judged from the checkout's .git file with no git process.
- The self migration deletes the old shared search-index.json instead of
  moving it; a full pull removes workspaces/<id>/ of removed worktrees.
- import --from-mr, which queues and publishes as contribute does (Tencent#823),
  leaves another repository's checkout out of its supersede check.
- The migration's queue drain drops every checkout's index file, so recall
  rebuilds with the moved learnings.
- A git or http checkout whose partition another checkout already built
  settles its queue by the partition's config before its .teamai/ is
  retired to .teamai.bak, which a removed worktree would take with it; a
  queue that cannot move keeps the directory. The self migration stands
  down, as the other kinds do, while the partition's config.yaml cannot be
  read, and relocates nothing, keeping config.yaml, while a learning is
  left in the checkout's queue once settled (a busy queue lock). A superseded install's config.yaml moves last, after its queue is
  set aside, and stays while a learning is still queued there, so a failed
  move or a late learning resumes on the next run.

Git and http mode paths are unchanged.
@SaulMoro
SaulMoro force-pushed the fix-808-self-mode-worktrees branch from 82764f6 to 59a37ae Compare September 25, 2026 16:21
@jeff-r2026
jeff-r2026 merged commit 7c834ce into Tencent:main Sep 25, 2026
10 of 11 checks passed
SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 25, 2026
…eckout (Tencent#823)

Item 7. import --from-mr in 0.25.0 to 0.26.0-beta.3 wrote
learnings/<date>-<title>.md, with source_mr in its frontmatter, into the
learnings checkout and never committed it. Nothing published it. In single-repo mode it also kept
`git worktree remove` from removing the checkout an older teamai left in
.teamai/, so every pull and contribute stopped on CheckoutRefusedError.
publishQueuedLearnings now takes the sync lock first, and under it, before
listing the queue, queues every untracked file of exactly that shape
(directly under learnings/, date name, source_mr), in the active namespace
and with contribute's name, then deletes the original. It finds the one
checkout this repo registers for the branch (git worktree list), so the
shared checkout and the old .teamai/learnings-wt are both covered and
another repository's never is. A file the branch or the queue already has,
by source_mr or by content, is deleted instead, and the warning names what
has it. A dry run touches nothing.

Item 21. The branch side of that duplicate check was the checkout's own
tracked files. In single-repo mode the checkout is often the old
.teamai/learnings-wt, which nothing syncs any more, so a teammate's later
import of the same MR was missed and the remnant went out as a duplicate.
When there are remnants, the check now also fetches origin/teamai-learnings
(best effort) and reads what origin has that the checkout's commit lacks.

Item 20. pull --dry-run published the queue: publishQueuedLearnings
honoured dryRun only for the remnants. It now stops after listing the queue,
and pull prints "[dry-run] Would publish N queued learning(s)" instead of
publishing or warning.

Maintenance sweep. publishLearningsMaintenance staged all of learnings/,
so a confidence write-back or a prune swept any uncommitted file into its
commit. confidence write-back, prune and promote now return the files they
wrote or removed, and only those are staged (a removed file git never
tracked is left out, since naming it would fail the add). That exposed a
second bug:
simple-git lists a staged rename under `renamed`, not `staged`, so a
`prune --archive` with nothing else to stage counted as nothing to commit
and was never published. commitAndPushAt now counts renames.

Tencent#814 follow-ups. drainCheckoutQueue is gone: the preAction migration moves a
checkout's queue before contribute and import --from-mr. Retire-only now
says "Retired <legacy> to <backup>: this project's data already lives in
<partition>"; a linked worktree lands there too, so "Finished an
interrupted migration" was wrong for it. config.yaml.*.tmp, the temp an
interrupted config save leaves (Tencent#831), is ignored in the single-repo and
project-scope .gitignore, and the single-repo self-heal adds it.

Item 15. After a failed refresh, readableReportsWorktree called ensure
without the reports lock, so it could create the checkout while a writer
that had just taken the lock created it too. It now refreshes once more
under the lock and throws the cause if that fails as well.

Item 17. init replaced the team clone before saving the new config, so an
init that stopped in between (an unknown --role, a busy queue lock) left
the old team's config.yaml beside the new team's clone. Just before it
clones another owner's repo, init now settles the old install as the final
save would (queue set aside, indexes dropped) and moves its config.yaml to
config.yaml.previous. A failed init then leaves no config, and commands ask
for teamai init.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] self mode: second worktree cannot publish learnings or reports, and loses its queue

2 participants