Skip to content

fix(dry-run): thread { dryRun } through the loaders contribute, session save and recall use (#850) - #853

Merged
jeff-r2026 merged 1 commit into
Tencent:mainfrom
ydflow:fix/dry-run-loader-sites
Sep 27, 2026
Merged

jeff-r2026 merged 1 commit into
Tencent:mainfrom
ydflow:fix/dry-run-loader-sites

Conversation

@ydflow

@ydflow ydflow commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

Complements #850, which fixes pull / push / status. This covers the three commands that issue lists as "Not audited, same bare-call shape" — contribute, session save and recall — with the loader change #850 itself proposes. Happy to fold this into #850 instead if one PR is preferred.

Problem

#837 threaded LoadOptions through 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:

Command Bare loader calls Own dry-run guard
contribute loadLocalConfigForScope('project'), requireInit() ×2, detectProjectConfig() the [dry-run] Would push check, after the load
session save loadLocalConfigForScope('project'), requireInit() ×2, detectProjectConfig() the local-log preview, then the push guard — both after the load
recall detectProjectConfig(…), loadLocalConfigForScope('user'), requireInit() the votes write at the end

On a config pending the legacy role migration, each of them rewrote ~/.teamai/config.yaml under --dry-run, printing Migrated legacy teamai config to default role profile: hai with no [dry-run] marker (real CLI, before, contribute --file note.md --scope user --dry-run):

ℹ Migrated legacy teamai config to default role profile: hai
ℹ [dry-run] Would push: learnings/session-notes-2026-09-27-pgbyvb.md (41 bytes)
config.yaml sha256: changed

recall test --dry-run did 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 for pull — detectProjectConfig and selfHealAndReadPartition already honour options.dryRun; these commands simply never supplied it.

Fix

Tests

src/__tests__/dry-run-load-path.test.ts, reusing its fixtures, tree snapshot and provider-call recorder:

  • The two new dedicated tests (recall --dry-run on the legacy-role fixture; contribute --scope user --dry-run) and the loader-level pair fail 3/3 on main — each writes config.yaml — and pass on this branch.
  • A third loader test pins the compatibility direction: with no options, loadLocalConfigForScope still migrates in place.
  • The provider-call recorder asserts no provider call happens under --dry-run.

npx tsc --noEmit clean; npm run lint 0 warnings under --deny-warnings.

Real-CLI verification (3b3c97b)

npm run build, then node dist/index.js against a sandbox HOME holding a role-less ~/.teamai/config.yaml next to a team repo whose manifest/roles.yaml declares hai, the same single-variable method as #850 (sha256 over config.yaml, fixture restored between runs):

$ teamai contribute --file note.md --scope user --dry-run
ℹ [dry-run] Would migrate legacy teamai config to default role profile: hai
ℹ [dry-run] Would push: learnings/session-notes-2026-09-27-gnm594.md (41 bytes)
config.yaml sha256: unchanged

$ teamai recall test --dry-run
ℹ [dry-run] Would migrate legacy teamai config to default role profile: hai
ℹ No learnings available. Run `teamai pull` first to sync team knowledge.
config.yaml sha256: unchanged

$ teamai recall test        # no --dry-run: a real run still migrates in place
ℹ Migrated legacy teamai config to default role profile: hai
ℹ No learnings available. Run `teamai pull` first to sync team knowledge.
config.yaml now carries primaryRole: hai

…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.
@github-actions

Copy link
Copy Markdown
  • [P2 non-blocking] src/save-session.ts:105 — In a fresh self-mode clone, both project loader paths now return the dry-run bootstrap preview, whose username is intentionally empty because authentication is skipped (src/bootstrap.ts:114). Consequently, teamai --dry-run session save --push --force reports sessions//<month>.md at src/save-session.ts:134, rather than a meaningful destination. Handle the unbootstrapped username explicitly, for example with a placeholder or conditional message.

The PR description includes sufficient real-CLI verification for this runtime change, so no testing-record finding is warranted.

@jeff-r2026
jeff-r2026 merged commit a8ab8e0 into Tencent:main Sep 27, 2026
11 checks passed
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.
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