Skip to content

fix(config): keep config.yaml readable while it is being saved (#823) - #831

Merged
jeff-r2026 merged 1 commit into
Tencent:mainfrom
SaulMoro:fix-823-atomic-config
Sep 25, 2026
Merged

jeff-r2026 merged 1 commit into
Tencent:mainfrom
SaulMoro:fix-823-atomic-config

Conversation

@SaulMoro

@SaulMoro SaulMoro commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

config.yaml was rewritten in place. Opening it for writing truncates it, so a command reading it mid-save saw an empty file and failed, and a failed write left it truncated. The three writers of the local config now use the existing writeFileAtomic (same-dir temp file, renamed over the target, temp removed on failure). The partition config already used it. writeFileAtomic now follows a symlinked target to the end of its link chain first, so a symlinked config.yaml keeps its link, and a dangling link gets its missing target created, as the in-place write did.

 save config.yaml   (saveLocalConfig, saveLocalConfigForScope, legacy role migration in src/config.ts)
-  writeFile(config.yaml)             # open(O_TRUNC): readers see '' until the data lands
+  writeFileAtomic(config.yaml)
+    target = config.yaml; while target is a symlink:             # links kept
+      target = resolve(realpath(dirname(target)), readlink(target))   # works when the end is missing
+      more than 40 hops -> throw "part of a symbolic link loop" # nothing written
+    mkdir -p dirname(target)
+    write <target>.<pid>.<rand>.tmp  # next to the real file: same filesystem
+    chmod tmp to the existing file's mode (0600 if new)
+    rename tmp -> <target>           # readers see the old file or the new one, nothing in between
+    on error: remove tmp, rethrow    # target untouched

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
  • npx vitest run passes: 317 files, 4978 passed, 1 skipped
  • Added/updated tests for the change: src/__tests__/config-atomic-save.test.ts (14 tests)
  • E2E with --retry 0: init-unattended, init-project-all, data-layout-migration, multi-project, roles-tags-pull (5 files, 10 tests) pass
  • Real CLI: teamai init, then teamai skill exclude add on a plain, a symlinked and a dangling-symlinked config.yaml, and init on a link loop, in an isolated HOME (below)

The new tests make each write into ~/.teamai first truncate its target (the state open(O_TRUNC) exposes), then run a hook, then finish or throw ENOSPC:

for saveLocalConfig, saveLocalConfigForScope:
  loadLocalConfig() during the save      -> old config (username dev); after it -> new one
  save fails with ENOSPC                 -> old bytes intact; ~/.teamai holds only config.yaml
  existing config.yaml at 0644, save       -> still 0644   (guard; in-place writes kept it too)
  config.yaml symlinked to dotfiles/, save -> still a link to it; target has the new config; no temp file
  config.yaml -> ../dotfiles/config.yaml, dotfiles/ missing, save
                                         -> still the same link; dotfiles/config.yaml created with the new config; no temp file
  config.yaml -> other.yaml -> config.yaml, save
                                         -> rejects "symbolic link loop"; both links unchanged; nothing else in ~/.teamai
~/.teamai itself a symlink to dotfiles/teamai, config.yaml -> ../shared/config.yaml (missing), save
                                         -> dotfiles/shared/config.yaml created (the kernel's reading of ..)
legacy role migration (inside loadLocalConfig):
  migration write fails with ENOSPC      -> old bytes intact; no temp file
  • Before (origin/main code): the 5 fault-injection tests fail. The mid-save read gives expected undefined to be 'dev' (x2). A failed write gives expected '' to be 'repo:...' (x3).
  • Before the symlink fix (atomic writers, no link resolution): the 2 symlink tests fail with expected false to be true (the link became a regular file).
  • Before the dangling-link fix (link resolved with realpath, ENOENT ignored): the 2 dangling-link tests fail with expected false to be true (the link became a regular file), and the 2 loop tests get the raw ELOOP: too many symbolic links encountered instead of the loop error, and the symlinked-config-dir test fails with ENOENT ... dotfiles/shared/config.yaml (5 red).
  • After: 14/14 pass.
  • Ablation: reverting one writer at a time turns exactly its own tests red (migration 1, saveLocalConfig 2, saveLocalConfigForScope 2). Removing the link resolution from writeFileAtomic turns exactly the 2 symlink tests red. In the chain walk: resolving each hop against the lexical instead of the real directory of the link turns 1 red (the symlinked config dir); creating the link's directory instead of the target's, or falling back to the link path when the target is missing, turn 3 red (both dangling-link tests and the symlinked config dir); a generic loop message turns the 2 loop tests red.
Real CLI (built dist/index.js, isolated HOME, synthetic HTTPS team URL rewritten to a local bare repo)
$ teamai --version: 0.22.0
$ teamai init https://git.example.com/team/team.git --scope project --role common --project gamma --agent claude --force
✔ Local config saved to /private/var/folders/.../teamai-823-14.owm5n9/project/.teamai/config.yaml
✔ teamai initialized successfully!
--- config.yaml after: init
mode: -rw-------
parses: scope=project role=common repo.localPath set=true excludedSkills=[]
temp files next to config.yaml: 0
$ chmod 644 config.yaml; teamai skill exclude add gamma-only
✔ Excluded: gamma-only
--- config.yaml after: skill exclude add (existing 0644 file)
mode: -rw-r--r--
parses: scope=project role=common repo.localPath set=true excludedSkills=["gamma-only"]
temp files next to config.yaml: 0
--- separate run on the symlink-fix build, same setup (init without --role/--project)
$ teamai init https://git.example.com/team/team.git --scope project --agent claude --force
✔ Local config saved to <tmp>/project/.teamai/config.yaml
✔ teamai initialized successfully!
$ mv .teamai/config.yaml <tmp>/dotfiles/config.yaml; ln -s <tmp>/dotfiles/config.yaml .teamai/config.yaml
$ teamai skill exclude add gamma-only
✔ Excluded: gamma-only
Run `teamai pull` to remove them from local AI tools.
--- after
config.yaml is a symlink: yes -> <tmp>/dotfiles/config.yaml
dotfiles/config.yaml excludedSkills: - gamma-only
temp files in .teamai: 0, in dotfiles: 0
--- separate run on the dangling-link build, same setup
$ ln -s ../../dotfiles/config.yaml .teamai/config.yaml   # <tmp>/dotfiles does not exist
$ teamai init https://git.example.com/team/team.git --scope project --role common --project gamma --agent claude --force
✔ Local config saved to <tmp>/project/.teamai/config.yaml
✔ teamai initialized successfully!
--- after init
config.yaml is a symlink: yes -> ../../dotfiles/config.yaml
dotfiles/config.yaml: mode -rw-------, username=ci
temp files in .teamai: 0, in dotfiles: 0
$ teamai skill exclude add gamma-only
✔ Excluded: gamma-only
--- after
config.yaml is a symlink: yes -> ../../dotfiles/config.yaml
dotfiles/config.yaml excludedSkills: - gamma-only
temp files in .teamai: 0, in dotfiles: 0
--- link loop: .teamai/config.yaml -> other.yaml -> config.yaml
$ teamai init https://git.example.com/team/team.git --scope project --role common --project gamma --agent claude --force
Error: Cannot write <tmp>/project/.teamai/config.yaml: it is part of a symbolic link loop. Point the link at a regular file, then retry.
exit: 1; links: config.yaml -> other.yaml, other.yaml -> config.yaml; temp files: 0

Claude agent and the git provider only. The write path does not depend on the provider or the agent.

Related Issues

Part of #823 (item 14)

Notes for Reviewers

  • A newly created config.yaml is now 0600 (was 0644 under umask 022); an existing file keeps its mode. This matches the partition config, which this helper already writes, and the file holds no secrets (the token is ~/.teamai/token).
  • Left out on purpose: src/migrate.ts:428 writes the staged copy inside the staging dir, which verifyStaging checks before the directory is moved into place, so no reader sees it. src/hermes-config.ts writes Hermes' own config.yaml, not teamai's.
  • Door: two-way. Revert the commit and nothing on disk needs undoing; config.yaml has the same content and location.
  • Blast radius: narrow. It touches every command that saves the local config (init, skill exclude, projects, roles, uninstall, the legacy role migration), but only how the bytes land. writeFileAtomic's other callers (partition config.yaml, votes, model profiles, generated .gitignores) now also keep a symlinked target instead of replacing the link; none relied on replacing it, and models/switch.ts already resolved links before calling it. A dangling link gets its missing target created, as an in-place write did; a link loop now fails with a named error instead of a raw ELOOP.
  • Local reviews: a read-only adversarial pass with the codex-review-on-assign.yml prompt, plus separate standards and spec passes. No P1/P2. Fixed: added the mode guard test (spec pass: "keep the mode" was untested). Fixed after the Codex reviews: symlinked targets keep their link, and dangling links keep it too. Left as P3: a hard kill between the temp write and the rename leaves config.yaml.<pid>.<hex>.tmp, which the generated .gitignore of a legacy in-repo .teamai does not match (config.yaml only). That is a follow-up that touches init.ts, which fix(data-layout): let every self-mode worktree publish learnings and keep its queue (#808) #814 changes.

@jeff-r2026 jeff-r2026 self-assigned this Sep 25, 2026
@github-actions

Copy link
Copy Markdown
  • [P3 nit] Preserve symlinked config targets — src/config.ts:115 now renames a temporary file over config.yaml. If the config is a symlink, saving replaces the link with a regular file instead of updating its target as the previous writeFile did, silently detaching symlink-managed configurations. Resolve the writable target before the atomic write or explicitly reject symlinks.

The PR description includes sufficient unit, E2E, and representative real-CLI testing.

@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Fixed in the amended commit. writeFileAtomic now resolves a symlinked target before the write: the temp file goes next to the real file and is renamed over it, so the link stays and the rename stays on one filesystem. New test in src/__tests__/config-atomic-save.test.ts, "writes through a symlinked config.yaml and keeps the link" (both savers): after the save config.yaml is still a link to the same file, that file has the new config, and no temp file is left. Both cases fail without the fix. A broken link is still replaced by a regular file.

@github-actions

Copy link
Copy Markdown
  • [P2 non-blocking] Preserve dangling symlink targets — src/utils/fs.ts:88 catches realpath()’s ENOENT and subsequently renames over the symlink itself. If a dotfiles-managed config.yaml target is temporarily missing, the next save silently replaces the link with a regular file; the previous in-place writer instead created the missing target through the link. Resolve the link path lexically or reject dangling links rather than detaching them.

The earlier finding is resolved for symlinks whose targets exist. The PR description includes sufficient unit, E2E, and representative real-CLI testing.

…nt#823)

config.yaml was rewritten in place, so a command that read it mid-save saw
an empty file ("Invalid project config"), and a failed write left it
truncated. The three local config writers (saveLocalConfig,
saveLocalConfigForScope, the legacy role migration) now go through
writeFileAtomic: a sibling temp file renamed over the target, removed on
failure. The partition config.yaml already used it.

An existing config.yaml keeps its mode; a newly created one is 0600 (was
the umask default, usually 0644), as the partition config already is.

writeFileAtomic now writes a symlinked target at the end of its link chain
(temp file next to that file), so a symlinked config.yaml keeps its link
instead of becoming a regular file. A dangling link gets its missing target
(and directory) created, as the in-place write did; a link loop is refused
with an error and nothing is written.
@SaulMoro
SaulMoro force-pushed the fix-823-atomic-config branch from 807e19c to 952a77c Compare September 25, 2026 12:30
@SaulMoro

Copy link
Copy Markdown
Collaborator Author

Fixed in 952a77c (same commit, amended). writeFileAtomic now follows the link chain itself instead of realpath: each hop is readlink resolved against the link's real directory, so it still works when the last target is missing. It then creates that target's directory and renames the temp file into it, so the link stays. A loop (more than 40 hops) fails with Cannot write <path>: it is part of a symbolic link loop and nothing is written.

Test: src/__tests__/config-atomic-save.test.ts › "creates the missing target of a dangling config.yaml link and keeps the link" (for saveLocalConfig and saveLocalConfigForScope): config.yaml -> ../dotfiles/config.yaml with no dotfiles/. After a save it is still that link, dotfiles/config.yaml holds the new config, and no temp file is left. Before the fix it failed with expected false to be true (the link was a regular file). There are also loop and symlinked-config-dir tests; the real-CLI run is in the PR description.

@github-actions

Copy link
Copy Markdown
  • [P3 nit] Allow a valid 40-symlink chain — src/utils/fs.ts:72 follows the 40th link and then immediately reports a loop without checking its resulting target. On systems permitting 40 traversals, an acyclic chain of exactly 40 links is incorrectly rejected. Check the terminal target before throwing or track repeated paths to detect actual loops.

The earlier existing- and dangling-symlink findings are resolved. The PR description includes sufficient unit, E2E, and representative real-CLI testing.

@jeff-r2026
jeff-r2026 merged commit 87a606b into Tencent:main Sep 25, 2026
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.

2 participants