fix(git): the interval fetches one run per folder, and the rest of the review (review)

The scheduler is the part that runs with nobody watching, so it is decided per folder
rather than per session: sessions sharing a folder are the same clone and share one run
that both see, `inFlight`/`lastRunAt` are keyed by the resolved folder, the schedule is
stamped when a folder is picked instead of when its run starts, and a tick is skipped
while the previous one is still going. The click route joins a run already going for the
same folder rather than starting a second fetch in one clone.

The tick keeps ONE bound for the git processes of the whole run and hands it to every
folder, and bounds its folders with a bound of its own: the same semaphore cannot serve
both levels, because a folder holding a slot would then wait for a slot its own fetch
needs. Both bounds are `FETCH_CONCURRENCY`, so the ceiling is unchanged. The bound claim
in CLAUDE.md, the `Limiter` jsdoc and docs/api-reference.md now describes it as it is.

Before anything is fetched the folder is probed with the bounded path probe, so a case on
a mount that stopped answering is skipped and never touched.

The rest of the review: the hooks are named as they really are (`reference-transaction`,
`pre-auto-gc` through the maintenance a fetch triggers, config commands such as
`credential.helper`) and `FETCH_CONFIG_ARGS` turns maintenance and gc off for an
unattended run; the claim that an agent in the same repository is not disturbed is
replaced by the ref-lock collision it actually is; the click route publishes its own run
as `trigger: 'click'`; `autoSaveGitFetch` uses `_apiJson` so a 400 shows the error toast
and applies the returned settings; and the two wording fixes.

Tests: the scheduler pins one run per folder, the two bounds, the probe before anything
runs, and that closing the session holding the override keeps the folder while another
session is still open on it; the route test pins the click publish and the join, asserting
the join before the held run is released — releasing first lets the run finish and clears
the lane, so the joiner would be entitled to its own fetch and the test would pass for the
wrong reason.
This commit is contained in:
Devvyn committed 2026-10-25 20:04:01 +08:00
1 parent 9432d9f9e2
commit ea4318947b
20 files changed
+774 -209

No files matched your search

+10 -10
View File
@@ -541,22 +541,22 @@
// Git fetch on an interval (App Settings → Bottom bar, Session Options → Git, and the save toast in
// session-ui.js). 'Status' and 'Git' are not added: the first already has a key, the second stays a
// technical name, and the status row's text is written at runtime.
"Whether Codeman fetches this case's repositories on a timer.": '{name} 是否按间隔拉取此案例的仓库。',
"Whether Codeman fetches this session's repositories on a timer.": '{name} 是否按间隔拉取此会话的仓库。',
'Git fetch on an interval': 'Git 定时拉取',
'Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the one git command Codeman runs that writes, and the one that runs a repository\'s own hooks, so nothing runs on a timer until you turn it on. A case can override both the switch and the interval in Session Options → Git.':
'按间隔为 Git 窗口列出的仓库运行拉取,这样“落后”的数字无需点击就是最新的。默认关闭:这是 {name} 运行的唯一会写入的 git 命令,也是唯一会运行仓库自身钩子的命令,所以在您开启之前不会有任何定时运行。案例可以在“会话选项 → Git”中同时覆盖开关和间隔。',
'Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the one git command Codeman runs that writes, and the one that runs a repository\'s own hooks, so nothing runs on a timer until you turn it on. A session can override both the switch and the interval in Session Options → Git.':
'按间隔为 Git 窗口列出的仓库运行拉取,这样“落后”的数字无需点击就是最新的。默认关闭:这是 {name} 运行的唯一会写入的 git 命令,也是唯一会运行仓库自身钩子的命令,所以在您开启之前不会有任何定时运行。会话可以在“会话选项 → Git”中同时覆盖开关和间隔。',
'Git fetch: how often': 'Git 定时拉取:多久一次',
'Seconds between runs for a case that has no override of its own (30 to 3600, default 300). Only a case that is on — here, or in Session Options → Git — is fetched.':
'没有自己覆盖设置的案例,两次运行之间的秒数(30 到 3600,默认 300)。只有已开启的案例——这里,或在“会话选项 → Git”——才会被拉取。',
'Seconds between runs for a session that has no override of its own (30 to 3600, default 300). Only a session that is on — here, or in Session Options → Git — is fetched, and sessions sharing one folder are fetched by one run.':
'没有自己覆盖设置的会话,两次运行之间的秒数(30 到 3600,默认 300)。只有已开启的会话——这里,或在“会话选项 → Git”——才会被拉取,共用一个文件夹的会话由一次运行拉取。',
'Fetch on an interval': '按间隔拉取',
"Fetching is the only git command Codeman runs that writes, and the only one that runs a repository's own hooks, so nothing runs on a timer until you turn it on — here, or in App Settings → Bottom bar. The panel's Fetch button is always just a click.":
'拉取是 {name} 运行的唯一会写入的 git 命令,也是唯一会运行仓库自身钩子的命令,所以在您开启之前(这里,或“应用设置 → 底部栏”)不会有任何定时运行。该窗口中的拉取按钮始终只是一次点击。',
'Follow the app setting': '跟随应用设置',
'Uses the app-wide switch and interval for this case. Switch it off to pick your own.':
'为此案例使用应用级的开关和间隔。关闭它即可自行选择。',
'Fetch this case': '拉取此案例',
'Runs on its own even while the app-wide switch is off: a case that turned itself on means exactly that.':
'即使应用级开关关闭也自行运行:一个自己开启的案例正是这个意思。',
'Uses the app-wide switch and interval for this session. Switch it off to pick your own.':
'为此会话使用应用级的开关和间隔。关闭它即可自行选择。',
'Fetch this session': '拉取此会话',
"Runs on its own even while the app-wide switch is off: a session that turned itself on means exactly that. Sessions sharing one folder share one run, and each sees that run's result.":
'即使应用级开关关闭也自行运行:一个自己开启的会话正是这个意思。共用一个文件夹的会话共享一次运行,每个会话都会看到该运行的结果。',
'How often': '多久一次',
'30 sec to 1 hr, timed from the start of a run, so a slow repository does not push the next one out.':
'30 秒到 1 小时,从一次运行的开始计时,所以较慢的仓库不会把下一次推远。',
+11 -9
View File
@@ -1332,8 +1332,9 @@
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3v18h18"/><polyline points="7 14 11 10 14 13 20 7"/></svg>
<span>Summary</span>
</button>
<!-- Git is last: a case that has not turned fetching on shows nothing here, so the section only
earns its place once the feature is in use. -->
<!-- Git is last: the section shows for every local session, but until the interval feature is
in use it holds one toggle and a status row that has never run, so it earns its place at the
end of the rail rather than among the tabs a session uses every day. -->
<button type="button" class="set-rail-item" data-tab="git" onclick="app.switchOptionsTab('git')">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M6 3v12"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
<span>Git</span>
@@ -1733,13 +1734,14 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M6 3v12"/><path d="M18 9a9 9 0 0 1-9 9"/></svg>
<h2>Git</h2>
</div>
<p class="set-section-blurb">Whether Codeman fetches this case's repositories on a timer.</p>
<p class="set-section-blurb">Whether Codeman fetches this session's repositories on a timer.</p>
<div class="set-group">
<div class="set-group-head"><h4>Fetch on an interval</h4></div>
<p class="set-group-hint">Fetching is the only git command Codeman runs that writes, and the only one that runs a repository's own hooks, so nothing runs on a timer until you turn it on &mdash; here, or in App Settings &rarr; Bottom bar. The panel's Fetch button is always just a click.</p>
<div class="set-group-body">
<!-- Live from the case's own last run (session-ui.js: updateGitFetchStatus). -->
<!-- Live from the session's own last run (session-ui.js: updateGitFetchStatus), which is the
run its folder got: several sessions on one folder share one run and read the same row. -->
<div class="set-row">
<div class="set-row-text">
<span class="set-row-label">Status</span>
@@ -1749,14 +1751,14 @@
<div class="set-row">
<div class="set-row-text">
<span class="set-row-label">Follow the app setting</span>
<span class="set-row-desc">Uses the app-wide switch and interval for this case. Switch it off to pick your own.</span>
<span class="set-row-desc">Uses the app-wide switch and interval for this session. Switch it off to pick your own.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="modalGitFetchFollow" checked onchange="app.autoSaveGitFetch()"><span class="slider"></span></label>
</div>
<div class="set-row" id="gitFetchEnabledRow">
<div class="set-row-text">
<span class="set-row-label">Fetch this case</span>
<span class="set-row-desc">Runs on its own even while the app-wide switch is off: a case that turned itself on means exactly that.</span>
<span class="set-row-label">Fetch this session</span>
<span class="set-row-desc">Runs on its own even while the app-wide switch is off: a session that turned itself on means exactly that. Sessions sharing one folder share one run, and each sees that run's result.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="modalGitFetchEnabled" onchange="app.autoSaveGitFetch()"><span class="slider"></span></label>
</div>
@@ -2278,14 +2280,14 @@
<div class="set-row" data-search="git fetch interval timer behind remote tracking refs poll run automatically">
<div class="set-row-text">
<span class="set-row-label">Git fetch on an interval <span class="set-scope">synced</span></span>
<span class="set-row-desc">Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the one git command Codeman runs that writes, and the one that runs a repository's own hooks, so nothing runs on a timer until you turn it on. A case can override both the switch and the interval in Session Options → Git.</span>
<span class="set-row-desc">Runs `git fetch` for the repositories the Git window lists on a timer, so the "behind" number is current without a click. Off by default: this is the one git command Codeman runs that writes, and the one that runs a repository's own hooks, so nothing runs on a timer until you turn it on. A session can override both the switch and the interval in Session Options → Git.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsGitFetchEnabled"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="git fetch how often seconds between runs interval case override session options">
<div class="set-row-text">
<span class="set-row-label">Git fetch: how often <span class="set-scope">synced</span></span>
<span class="set-row-desc">Seconds between runs for a case that has no override of its own (30 to 3600, default 300). Only a case that is on — here, or in Session Options → Git — is fetched.</span>
<span class="set-row-desc">Seconds between runs for a session that has no override of its own (30 to 3600, default 300). Only a session that is on — here, or in Session Options → Git — is fetched, and sessions sharing one folder are fetched by one run.</span>
</div>
<input type="number" id="appSettingsGitFetchIntervalSeconds" class="set-num" value="300" min="30" max="3600" step="1">
</div>
+13 -4
View File
@@ -3048,7 +3048,7 @@ Object.assign(CodemanApp.prototype, {
if (!session) return;
const app = this.loadAppSettingsFromStorage() || {};
// No choice of its own on either field means the case follows the app. A partial one — its own switch,
// No choice of its own on either field means the session follows the app. A partial one — its own switch,
// the app's interval — means it does not, and the row that still follows shows the app's value.
const follows = session.gitFetchEnabled === undefined && session.gitFetchIntervalSeconds === undefined;
const enabled = follows ? app.gitFetchEnabled === true : session.gitFetchEnabled ?? false;
@@ -3069,7 +3069,7 @@ Object.assign(CodemanApp.prototype, {
run.state === 'ok' ? `${run.repos} repositories` : run.state,
run.failed ? `${run.failed} failed` : '',
]
: [follows ? 'Nothing fetched on a timer yet, and this case follows the app setting.' : 'Nothing fetched on a timer yet for this case.'];
: [follows ? 'Nothing fetched on a timer yet, and this session follows the app setting.' : 'Nothing fetched on a timer yet for this session.'];
statusEl.textContent = parts.filter(Boolean).join(' · ');
statusEl.hidden = false;
},
@@ -3096,9 +3096,18 @@ Object.assign(CodemanApp.prototype, {
),
};
try {
const data = await this._apiPost(`/api/sessions/${this.editingSessionId}/git-fetch-settings`, body);
// `_apiPost` hands back the Response without checking it, so a refused body (a 400) still read as a
// save. `_apiJson` is the one that says whether the call went through, and null here means it did not.
const data = await this._apiJson(`/api/sessions/${this.editingSessionId}/git-fetch-settings`, {
method: 'POST',
body,
});
if (!data) {
this.showToast('Failed to save Git fetch settings', 'error');
return;
}
const session = this.sessions.get(this.editingSessionId);
const saved = data?.gitFetchSettings;
const saved = data.gitFetchSettings;
if (session && saved) {
session.gitFetchEnabled = saved.enabled ?? undefined;
session.gitFetchIntervalSeconds = saved.intervalSeconds ?? undefined;
+25 -15
View File
@@ -16,10 +16,10 @@
import type { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { redactGitCredentials } from '../../git-clone.js';
import { emptyReport, fetchGitWorkspaceOverview, type GitFetchReport } from '../../git-fetch.js';
import { emptyReport, fetchWorkspaceOnce, gitFetchStatusFrom, type GitFetchReport } from '../../git-fetch.js';
import { readDockerCases } from '../../docker-hosts.js';
import { getDataDir } from '../../config/instance.js';
import { findSessionOrFail } from '../route-helpers.js';
import { findSessionOrFail, persistAndBroadcastSession } from '../route-helpers.js';
import {
emptyOverview,
findWorkspaceRepo,
@@ -32,7 +32,7 @@ import {
type GitRunner,
type GitWorkspaceOverview,
} from '../../git-workspace-status.js';
import type { SessionPort } from '../ports/index.js';
import type { EventPort, SessionPort } from '../ports/index.js';
/**
* Host paths of every Docker case workspace: repositories at or inside these are never inspected.
@@ -58,7 +58,9 @@ function limitsFrom(query: { maxRepos?: unknown; timeout?: unknown }) {
export function registerGitStatusRoutes(
app: FastifyInstance,
ctx: SessionPort,
// EventPort as well as SessionPort: the fetch route publishes the run it did, and a route that broadcasts
// has to say so in its own signature.
ctx: SessionPort & EventPort,
git?: GitRunner,
dockerWorkspaces: () => Promise<string[]> = defaultDockerWorkspaces
): void {
@@ -131,9 +133,14 @@ export function registerGitStatusRoutes(
// Fetch, and only because the user clicked Fetch in the panel (src/git-fetch.ts): one
// `git fetch --quiet` per repository the panel would show, through the same resolver so the two cannot
// disagree about which repositories belong to the workspace. This is the only git command Codeman runs
// that writes, and it writes remote-tracking refs only — nothing commits, checks out or merges, so an
// agent working in the same repository is not disturbed. Remote and Docker sessions answer `unsupported`
// like the status route, and a repository a container can write to is refused before any git runs.
// that writes, and it writes remote-tracking refs only — nothing commits, checks out or merges. It is not
// as harmless as a read though: a fetch holds git's ref lock, so an agent's own `git pull` in the same
// clone can collide with it, which is why the run goes through `fetchWorkspaceOnce` — a click while a tick
// is already fetching this folder joins that run instead of starting a second one. It can also run a
// repository's own hooks (`reference-transaction`, `pre-auto-gc` through the maintenance git runs after a
// fetch) and its config commands such as `credential.helper`, so the `FETCH_CONFIG_ARGS` overrides turn
// maintenance and gc off, and a repository a container can write to is refused before any git runs. Remote
// and Docker sessions answer `unsupported` like the status route.
app.post('/api/sessions/:id/git-fetch', async (req): Promise<ApiResponse<GitFetchReport>> => {
const { id } = req.params as { id: string };
const query = req.query as { maxRepos?: unknown; timeout?: unknown };
@@ -141,13 +148,16 @@ export function registerGitStatusRoutes(
const session = findSessionOrFail(ctx, id, req);
if (session.remote) return { success: true, data: emptyReport('unsupported', { reason: 'remote' }) };
if (session.docker) return { success: true, data: emptyReport('unsupported', { reason: 'docker' }) };
return {
success: true,
data: await fetchGitWorkspaceOverview(session.workingDir, {
git,
...limits,
dockerWorkspaces: await dockerWorkspaces(),
}),
};
const report = await fetchWorkspaceOnce(session.workingDir, {
git,
...limits,
dockerWorkspaces: await dockerWorkspaces(),
});
// The response answers the click, and the run is also the session's current truth: what the panel's
// "behind" number is based on. Publishing it is what the api docs say a click does, and what makes a
// second tab open on the same folder see the same run — including a click that joined a tick already
// going, where the row has to say who last fetched.
if (session.setGitFetchStatus(gitFetchStatusFrom(report, 'click'))) persistAndBroadcastSession(ctx, session);
return { success: true, data: report };
});
}
+7 -5
View File
@@ -3611,11 +3611,13 @@ export function registerSessionRoutes(
// ========== Git fetch on an interval ==========
// Per-case override for the fetch interval (src/git-fetch-scheduler.ts). `null` on either field clears
// the case's choice so the app-wide setting applies again, and a field left out of the body leaves the
// other half as it was: the Session Options rows save on their own. Refused for a remote or Docker
// session, exactly as the Git panel excludes them — a container can write to the repository it mounts,
// so git there would run that repository's own configuration on the host.
// Per-session override for the fetch interval (src/git-fetch-scheduler.ts), which is what the Session
// Options rows say it is: two sessions on one folder can hold different choices, and that folder is
// fetched when the first of them is due. `null` on either field clears this session's choice so the
// app-wide setting applies again, and a field left out of the body leaves the other half as it was: the
// Session Options rows save on their own. Refused for a remote or Docker session, exactly as the Git
// panel excludes them — a container can write to the repository it mounts, so git there would run that
// repository's own configuration on the host.
app.post('/api/sessions/:id/git-fetch-settings', async (req) => {
const { id } = req.params as { id: string };
const body = parseBody(GitFetchSettingsSchema, req.body, 'Invalid request body');
+5 -3
View File
@@ -1492,9 +1492,11 @@ export const SettingsUpdateSchema = z
* Git fetch on an interval (src/git-fetch-scheduler.ts): `git fetch` for the repositories the Git panel
* lists, run on a timer instead of only on the panel's Fetch click. SYNCED, default OFF, because the
* server is what runs it: `git fetch` is the only git command Codeman runs that writes, and the only
* one that runs a repository's own hooks, so nothing runs on a timer until the user turns this on.
* Cap in seconds between runs (30 to 3600, default 300). A case can override both in Session Options
* → Git; `null` there clears the override.
* one that can run a repository's own hooks (`reference-transaction`, `pre-auto-gc` through the
* automatic maintenance a fetch triggers, config commands such as `credential.helper`), so nothing runs
* on a timer until the user turns this on. Cap in seconds between runs (30 to 3600, default 300). A
* session can override both in Session Options → Git; `null` there clears the override, and sessions
* sharing one folder are fetched by one run.
*/
gitFetchEnabled: z.boolean().optional(),
gitFetchIntervalSeconds: z.number().int().min(30).max(3600).optional(),
+11 -3
View File
@@ -124,8 +124,9 @@ import { AI_CHECK_MODEL } from '../config/ai-defaults.js';
import { approvalInbox } from './approval-inbox.js';
import { SCREEN_BLOCKED_SOURCE } from './screen-blocked.js';
import { PromptCacheKeepWarmService } from '../prompt-cache-keepwarm.js';
import { fetchGitWorkspaceOverview } from '../git-fetch.js';
import { fetchWorkspaceOnce } from '../git-fetch.js';
import { GitFetchScheduler } from '../git-fetch-scheduler.js';
import { probePathKind } from '../utils/bounded-path-probe.js';
import { defaultDockerWorkspaces } from './routes/git-status-routes.js';
import { composerIsEmpty, plainFrame } from '../session-submit-verifier.js';
import { detectUsageLimitPause } from '../usage-limit-patterns.js';
@@ -405,12 +406,19 @@ export class WebServer extends EventEmitter {
readSettings: readGitFetchSettings,
// The same Docker list the panel's routes use: the rule has to be one rule.
dockerWorkspaces: defaultDockerWorkspaces,
fetch: (cwd, opts) => fetchGitWorkspaceOverview(cwd, opts),
// Bounded probe, DEFAULT options (rule #516): a tick is a bulk scan, so it keeps the stall cap and must
// never be handed `pastCap`. A folder on a mount that stopped answering is skipped, not fetched.
probePath: (path) => probePathKind(path),
// Through the join, so the tick and the panel's Fetch button are the same run: a click on a folder the
// tick is already fetching joins it rather than starting a second `git fetch` in the same clone.
fetch: (cwd, opts) => fetchWorkspaceOnce(cwd, opts),
publish: (id, status) => {
const session = this.sessions.get(id);
if (!session?.setGitFetchStatus(status)) return;
// No new SSE event: the status rides the session state the page already listens for, and the Git
// panel polls on its own clock. Persisted, because what a run found is the panel's current truth.
// panel polls on its own clock. It is written to disk with the rest of the record, but the restore
// copies only the fields its own config lists and this is not one of them (src/types/session.ts), so
// what a run found is the panel's truth for the life of the session, not across a restart.
this.persistSessionState(session);
this.broadcast(SseEvent.SessionUpdated, this.getSessionStateWithRespawn(session));
},