fix(dry-run): thread { dryRun } through the loaders contribute, session save and recall use (#850) - #853
Merged
Conversation
…on save and recall use Tencent#837 threaded LoadOptions through the config loaders, and Tencent#850 fixes the three commands that still reach them bare (pull, push, status). Three more commands load their config before their own dry-run guard and pass nothing: - contribute (--scope project loads, then --scope user / auto-detect) - session save (same three branches) - recall (detection, the inherited user scope, and the user branch) On a config pending the legacy role migration each of them rewrote ~/.teamai/config.yaml under --dry-run, printing the migration line without any [dry-run] marker; the auto-detect and project branches can also adopt a pre-Tencent#546 partition and run the single-repo self-heal bootstrap. loadLocalConfigForScope is the loader Tencent#837 missed: it now takes LoadOptions and forwards them to detectProjectConfig and both migrateLegacyRoleConfig calls. Callers that pass nothing behave as before — a real run still migrates in place.
The PR description includes sufficient real-CLI verification for this runtime change, so no testing-record finding is warranted. |
jeff-r2026
approved these changes
Sep 27, 2026
5 tasks
Smilewithoutfalling
added a commit
to Smilewithoutfalling/teamai-cli
that referenced
this pull request
Sep 28, 2026
Fixes the red Lint & Test on the previous head (all four matrix entries).
pull-scope-isolation.test.ts asserted the exact argument list of
loadLocalConfigForScope, which this change widens to carry LoadOptions:
expect(loadLocalConfigForScope).toHaveBeenCalledWith('user');
received ['user', undefined, { dryRun: undefined }]
The shape is not a free choice: recall.ts:464 and recall.ts:515 already pass
the same third argument, landed with Tencent#853, so pull.ts matches the merged
precedent. recall-scope-isolation.test.ts never asserts the argument list,
which is why the same change left it green.
The affected set is derived from the changed symbols rather than from the
topic: every test file that mentions loadLocalConfigForScope,
detectProjectConfig or autoDetectInit (76 files, 1221 tests).
jeff-r2026
pushed a commit
that referenced
this pull request
Sep 28, 2026
…s and list use (#866) * fix(dry-run): thread { dryRun } through the loaders pull, push, status and list use `--dry-run` is documented as previewing without making changes, and #837 made that true for `tags subscribe`, `tags unsubscribe` and `roles set`. `pull`, `push` and `status` still wrote: each runs its scope-detection block before any dry-run guard, and that block called config loaders that were never given the flag. A preview could therefore persist the legacy role migration, and in a git repo adopt a pre-#546 partition or run the single-repo self-heal bootstrap. The loaders already take LoadOptions — #853 threaded them through `loadLocalConfigForScope` for contribute / session save / recall. These commands simply did not supply the flag. - pull.ts: both loaders take { dryRun: options.dryRun }. - push.ts: autoDetectInit takes it. - status.ts and list: { dryRun: true } unconditionally, because both are read-only and should never migrate, adopt a partition or bootstrap. Callers that pass nothing behave as before, the same compatibility promise #837 made. The one observable change is that the preview path logs, so `status`/`list` now surface a "[dry-run] Would ..." line where a migration or bootstrap is pending; the PR description asks for a decision on that label. Verification: seven new command-level cases, each failing on unmodified main with the identical test file (the project-scope three need a git project with a pre-#546 partition name, which the existing user-scope fixture never reaches); and the issue's own real-CLI reproduction, 6/6 — the control writes the reported fields, this change writes nothing. oxlint --deny-warnings and tsc --noEmit: rc=0 here and rc=0 on unmodified main. Closes #850 * fix(pull,push): a --dry-run must leave a fresh self-mode clone alone Resolves both P1 findings on this PR. pull.ts:1891 - the previewed self-mode config reached lockScope(), whose acquireLock() calls ensureDir() on the lock's parent. On a fresh clone the partition does not exist yet, so the directory was created and stayed: releaseLock removes the lock file, not its parent. The guard sits inside lockScope(), the one choke point all three call sites share. push.ts:731 - the same previewed config ran the whole self-mode setup before pushCore reached its own dry-run guard at push.ts:1577: the sync-lock, migrateSelfModeGitignore(), and the disposable knowledge worktree. Both guards are deliberately narrow. A blanket early return before the git-mode branch would also skip resetToCleanMaster/pullRepo (push.ts:934), which a dry run performs on purpose so it can name the destination the real command would use. Only writes that outlive the command are gated. The preview still reads the uncommitted teamai.yaml that pushCore receives as initialPendingTeamConfig; that block is now pendingSelfTeamConfig(), the identical read, so the preview keeps describing the config edit it exists to describe. Fixture gap, also flagged: dry-run-load-path.test.ts already had a fresh-self-mode-clone fixture, but only tags/roles ran against it. pull and push ran at user scope, or on a project partition that already exists - never on the one shape where acquireLock has something new to create. Two cases added there; the unfixed tree fails them at fs.existsSync(<HOME>/.teamai/projects) with "expected true to be false". Not fixed here, and named in the PR description: pull --dry-run on a fresh clone still creates an empty <HOME>/.teamai/locks/, via listPendingForInstall in utils/pending-learnings.ts. That call is unchanged by this PR and the file is outside its scope; the test declares and counts the entry, so anything else appearing still fails. * test(pull): pin the loader call shape the widened signature produces Fixes the red Lint & Test on the previous head (all four matrix entries). pull-scope-isolation.test.ts asserted the exact argument list of loadLocalConfigForScope, which this change widens to carry LoadOptions: expect(loadLocalConfigForScope).toHaveBeenCalledWith('user'); received ['user', undefined, { dryRun: undefined }] The shape is not a free choice: recall.ts:464 and recall.ts:515 already pass the same third argument, landed with #853, so pull.ts matches the merged precedent. recall-scope-isolation.test.ts never asserts the argument list, which is why the same change left it green. The affected set is derived from the changed symbols rather than from the topic: every test file that mentions loadLocalConfigForScope, detectProjectConfig or autoDetectInit (76 files, 1221 tests). * fix(update): a --dry-run asks the lock for its state instead of taking it `acquireLock` is not a read. It `ensureDir`s the lock's parent, which on a fresh self-mode clone is a `<getDataHome>` partition that does not exist yet — and `releaseLock` removes the lock FILE, not that directory, so the directory outlives the command. Any preview that calls it therefore writes, which is the defect this PR is about (#866). The new `options.dryRun` returns what the preview actually owes its caller: the ANSWER the real run would get. `lockState` already separates `live` (a holder is running) from `stale` and `missing`, and the real run reclaims either of the latter and wins, so `acquireLock(path, { dryRun: true })` is exactly that verdict — no mkdir, no lock file, no reclaim sentinel. Nothing is recorded in `heldLockOwners`, which is what makes the preview safe alongside the existing `releaseLock` calls: it returns at its first line when it holds no owner token for the path, so a preview cannot delete a lock another process owns. No caller passes `dryRun` yet; this commit is the primitive only. * fix(pull,push): acquire the preview's locks read-only, and stop hiding what it reports Supersedes the guards added in cfd7c57. Those guards stopped the writes, and E2E (fork-safe) caught what they cost — 6 failures in push-sync-followups-823.test.ts, all of the same shape: expected '- Scanning local resources...\nNo new or modified resources to push' to contain '[rules] teamai-rule (modified)' A preview that reports no changes for a tree with a deliberately edited team rule is not a conservative preview; it is this PR's own defect with the sign flipped. pull.ts — `lockScope()` returned `true` outright for a dry run, asserting the scope was uncontended. That is a fabricated fact: its callers read `true` as "you hold the lock". It now acquires through the read-only primitive and records the lock for release only when it really took one, so a scope with a live holder is reported as contended and skipped, exactly as a real pull does. push.ts — the self-mode branch returned early for a dry run. Two of the three things it skipped are right to skip and one is not. - the sync-lock: read-only now, at both push.ts:784 (self) and push.ts:839 (git mode). - `migrateSelfModeGitignore()`: still skipped. It rewrites a tracked file in the user's ACTIVE tree, which outlives the preview, and it is idempotent, so the next real push performs it. - the knowledge worktree: MUST run, and that is the correction. `pushCore` adds the active tree's `.teamai/{skills,rules}` as scan sources and diffs them against `localConfig.repo.localPath` (push.ts:1095-1112) — the clean worktree checkout. Outside the worktree those are the same path in self mode, so the diff is empty by construction: skipping the worktree does not report an edit early, it hides the edit. `withKnowledgeWorktree` already removes it in a `finally`, so it stays disposable. Fixture — `push --dry-run` now performs its `git fetch` for real, which leaves `app/.git/FETCH_HEAD` behind. Declared and counted next to the existing `<getDataHome>/locks/` entry, so any OTHER new entry still fails the case.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Complements #850, which fixes
pull/push/status. This covers the three commands that issue lists as "Not audited, same bare-call shape" —contribute,session saveandrecall— with the loader change #850 itself proposes. Happy to fold this into #850 instead if one PR is preferred.Problem
#837 threaded
LoadOptionsthrough the config loaders, and its own out-of-scope note (quoted in #850) named the commands that still reach them bare. Beyond the three #850 fixes, three more commands load their config before their own dry-run guard and pass nothing:contributeloadLocalConfigForScope('project'),requireInit()×2,detectProjectConfig()[dry-run] Would pushcheck, after the loadsession saveloadLocalConfigForScope('project'),requireInit()×2,detectProjectConfig()recalldetectProjectConfig(…),loadLocalConfigForScope('user'),requireInit()On a config pending the legacy role migration, each of them rewrote
~/.teamai/config.yamlunder--dry-run, printingMigrated legacy teamai config to default role profile: haiwith no[dry-run]marker (real CLI, before,contribute --file note.md --scope user --dry-run):recall test --dry-rundid the same. The project-scope branches can also adopt a pre-#546 partition and run the single-repo self-heal bootstrap, exactly as #850 describes forpull—detectProjectConfigandselfHealAndReadPartitionalready honouroptions.dryRun; these commands simply never supplied it.Fix
loadLocalConfigForScope(scope, projectRoot?, options)— the loader fix(tags,roles): honor --dry-run and count namespaced skills in tags list (#836) #837 missed: forwardsoptionsintodetectProjectConfigand bothmigrateLegacyRoleConfigcalls.contribute,session saveandrecallpass{ dryRun: options.dryRun }into every config load that precedes their guards.recallrun still migrates in place (verified below), which is the compatibility promise fix(tags,roles): honor --dry-run and count namespaced skills in tags list (#836) #837 made.Tests
src/__tests__/dry-run-load-path.test.ts, reusing its fixtures, tree snapshot and provider-call recorder:recall --dry-runon the legacy-role fixture;contribute --scope user --dry-run) and the loader-level pair fail 3/3 onmain— each writesconfig.yaml— and pass on this branch.loadLocalConfigForScopestill migrates in place.--dry-run.npx tsc --noEmitclean;npm run lint0 warnings under--deny-warnings.Real-CLI verification (
3b3c97b)npm run build, thennode dist/index.jsagainst a sandboxHOMEholding a role-less~/.teamai/config.yamlnext to a team repo whosemanifest/roles.yamldeclareshai, the same single-variable method as #850 (sha256 overconfig.yaml, fixture restored between runs):