Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ All notable changes to this project will be documented in this file. See [standa

### 🐛 Bug Fixes

- `teamai session pull` no longer stops when two machines archived sessions for the same repo. It merges whatever the member's `pull.rebase` says, rebuilds a conflicted `_index.json` from the session files and commits the merge. A conflict in any other file aborts the merge and names the files, and an unfinished rebase, merge or other git operation is left as it is, with the step that finishes it. `teamai session push` pulls once and pushes again when the remote moved ahead, instead of keeping the commit local. A failed git step prints git's own reason (for [#1133](https://github.com/Tencent/teamai-cli/issues/1133)).
- A project `.codex/hooks.json` a repository commits no longer changes with the platform of the member who pulled last. The PowerShell form the built-in Codex hooks need on Windows (#1074) moves from `command` into Codex' Windows-only `commandWindows`, so `command` is the POSIX `bash -lc "..." || true` on every platform, also on Windows, where it used to carry the machine's Git Bash path. Codex on Windows lists such an entry by its `commandWindows`, so the trust step and `teamai doctor` recognise a written entry under either command; before, the trust step reported the hooks as not loaded and left them untrusted. Team hooks are left as their author wrote them.
- `teamai ci extract-mr --mode write` updates the teamwiki through `teamai import`: under the same `<provider>__<owner>__<repo>` slug as `import --from-repo` and `--from-mr` (it used the bare repo name, so one repo had two codebases), incrementally from the baseline published with the codebase, also for a merge that only deletes source files, rebuilding `router.md` / `index.md`, and without extracting into, then deleting, a `teamwiki/` in the checkout. The GitHub Actions template checks out the merged source, which its write job never did.
- An incremental import whose commit changes no source file no longer deletes the codebase's published evidence pages. Each codebase keeps its incremental baseline (facts and interface caches) in `evidence/code/<slug>/.indices/`, so importing another repo into the same teamwiki no longer forces the next import of this one to run in full.
Expand Down
2 changes: 1 addition & 1 deletion docs/dev/agent-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Each rule below spans more than one file. The rule names the code that owns it,
- **List through `AgentSession.list`.** A caller that wants a member's sessions (`session migrate`, `session push`, a scorer, the dashboard) calls `AgentSession.list({ platforms?, cwd?, updatedSince? })` in `agent-session.ts`, never an adapter's `listConversations`. Each listed session is a ref that `find` reads as is. A session whose first lines do not parse is still listed, under a placeholder title, so `find` reports it `unreadable`, not `not_found`. A tool that cannot be listed is one failure value (`no_adapter`, `tool_unavailable`, or `list_failed`) next to the other tools' sessions. OpenCode's CLI lists one project at a time, and inside a git repo that project is the whole repo, so its adapter keeps only the sessions of the workspace asked for. Without `cwd`, OpenCode is `list_failed`, rather than returning the workspace the process runs in as if it were all of them. Its listing has no message count or size, so those fields are absent rather than 0.
- **Stores follow the recorded tool root.** Claude Code and Codex keep their sessions under the root the member recorded with `teamai init` (`toolRoots`), resolved by `resolveMemberToolRoots` and `resolveToolRootDir`, never from `CLAUDE_CONFIG_DIR` or `CODEX_HOME` at run time. `AgentSession` and the `session` commands that build an adapter themselves (`resume`, `rollback`, `platforms`) resolve the roots with `recordedToolRoots` in `agent-session.ts` for the workspace at hand and pass them through the adapter registry (`adapters/index.ts`) to `getClaudeCodeProjectsDir` and `getCodexSessionsDir` in `fs.ts`. A run that reads or copies many sessions passes one `toolRootsLookup()`, so each workspace is resolved once. With nothing recorded they stay `~/.claude/projects` and `~/.codex/sessions`. The variants (`claude-internal`, `codex-internal`, ...) keep their fixed paths.
- **The driver picks how a session continues.** The driver that runs a workflow step decides: the same tool on the same machine resumes the session by id; a driver with its own way to move a session uses it; otherwise the local driver copies it with `AgentSession.copy`; failing those, the step starts a fresh session. The workflow engine never copies a session. A step's report carries the tool and that tool's native session id.
- **Session index lock.** Every write to a team repo archive (`saveSession`: dedup lookup, name choice, file writes, `_index.json` upsert) and every index rebuild (`rebuildIndex`, `rebuildAllIndexes`) runs inside `withSessionIndexLock(repoRoot, fn)`. The async command callers take it; `SyncManager` stays synchronous and never takes it, so it cannot re-enter. The lock file is `~/.teamai/locks/sessions-<hash>.lock`, keyed by the real path of the team repo's `sessions/`. It stays out of the team repo because there the archive commit could stage it and `pull --all`'s directory renames could move it while held. It covers one machine only; two machines writing `_index.json` is #1133.
- **Session index lock.** Every write to a team repo archive (`saveSession`: dedup lookup, name choice, file writes, `_index.json` upsert) and every index rebuild (`rebuildIndex`, `rebuildAllIndexes`) runs inside `withSessionIndexLock(repoRoot, fn)`. The async command callers take it; `SyncManager` stays synchronous and never takes it, so it cannot re-enter. The lock file is `~/.teamai/locks/sessions-<hash>.lock`, keyed by the real path of the team repo's `sessions/`. It stays out of the team repo because there the archive commit could stage it and `pull --all`'s directory renames could move it while held. It covers one machine only. Two machines that both wrote `_index.json` diverge there: `SyncManager.gitPull` (also the retry inside `gitPush`) merges and returns the conflicted indexes, and the command caller rebuilds them and commits the merge (`commitRebuiltIndexes`) under the lock, with the network pull and push outside it. When the lock stays busy the merge is left in progress. The next `teamai session pull` finishes it first, then runs its own pull.

## Tests

Expand Down
21 changes: 20 additions & 1 deletion src/__tests__/session-cmd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const mocks = vi.hoisted(() => ({
/** 非空时 git commit / git push 抛错,用于验证失败路径 */
commitError: '',
pushError: '',
pullError: '',
gitCalls: [] as Array<{ args: string[]; cwd?: string }>,
adaptersByPlatform: {} as Record<string, unknown>,
/** migrate.js mock 的 preview 返回值(Error 时抛出);agent-session.js mock 的 copy 返回值 */
Expand Down Expand Up @@ -69,6 +70,7 @@ vi.mock('node:child_process', async (importOriginal) => {
if (args[0] === 'rev-parse') return 'abc123def456\n';
if (args[0] === 'commit' && mocks.commitError) throw new Error(mocks.commitError);
if (args[0] === 'push' && mocks.pushError) throw new Error(mocks.pushError);
if (args.includes('pull') && mocks.pullError) throw new Error(mocks.pullError);
return ''; // add / commit / push / pull
},
};
Expand Down Expand Up @@ -179,6 +181,7 @@ beforeEach(() => {
mocks.porcelain = 'M sessions/changed\n';
mocks.commitError = '';
mocks.pushError = '';
mocks.pullError = '';
process.exitCode = 0;
mocks.gitCalls.length = 0;
mocks.adaptersByPlatform = {};
Expand Down Expand Up @@ -332,7 +335,7 @@ describe('session pull --all', () => {

const text = out.join('\n');
expect(text).toContain('Pulled and indexed 2 session(s) across 2 repo(s)');
expect(mocks.gitCalls.some((c) => c.args[0] === 'pull')).toBe(true);
expect(mocks.gitCalls.some((c) => c.args.includes('pull'))).toBe(true);

const restored = JSON.parse(fs.readFileSync(idxPath, 'utf-8'));
expect(restored.sessions).toHaveLength(1);
Expand Down Expand Up @@ -360,6 +363,22 @@ describe('session pull', () => {
});
});

describe('session pull failure', () => {
it("prints git's own reason instead of the command line", async () => {
mocks.pullError = 'Command failed: git pull --no-rebase --ff --no-edit origin main\n'
+ 'hint: You have divergent branches.\nfatal: unable to access the remote: Could not resolve host';
vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`exit ${code}`);
});

await expect(runSession('pull', '--all', '--repo-root', repoRoot)).rejects.toThrow('exit 1');

expect(errored.join('\n')).toContain(
`Error: git pull failed in ${repoRoot}: fatal: unable to access the remote: Could not resolve host`,
);
});
});

describe('session search --all', () => {
it('searches every repo including _unattributed without double-loading the current one', async () => {
seed('github.com/org/alpha', 'alice', 'a-1', 'payment alpha', 'alpha payment retry');
Expand Down
Loading
Loading