fix(ingest): honor storage.git.auto_commit and memory.auto_commit

Both documented flags were read only for status display; every ingest path
squash-committed to main unconditionally, so setting either to false was a
silent no-op (the reported symptom: 'Memory ingest (external_ingest): ...'
commits despite memory.auto_commit: false).

Gate the commit at the squash-merge onto main — the one point where ingest work
becomes a permanent commit (intermediate session-worktree commits must still
happen for the squash to collapse). When auto-commit is off, apply the squash to
main's working tree and leave it staged instead of committing, so the run is
never silently discarded:

- GitService.stageSquashMergeIntoMain: shares the merge core with
  squashMergeIntoMain but stops before committing and returns the staged tree
  SHA (a valid diff/read ref).
- memory.auto_commit gates MemoryAgentService (its DB writes are eager, so the
  staged files stay consistent); the commit-message job is skipped.
- storage.git.auto_commit gates IngestBundleRunner; the wiki index is reconciled
  from the staged tree via the existing syncFromCommit (git diff/show accept a
  write-tree ref), and SL reindex already reads from files.

Config descriptions now state precisely what each flag gates and the staged
semantics when false.
This commit is contained in:
Andrey Avtomonov 2026-06-09 12:44:58 +02:00
parent 1a6da14f62
commit a02fcab487
15 changed files with 303 additions and 43 deletions

View file

@ -2677,29 +2677,51 @@ export class IngestBundleRunner {
throw error;
}
const commitMessage = this.buildCommitMessage(job, syncId, diffSummary, failedWorkUnits);
// With auto-commit disabled, apply the run onto main's working tree and leave it staged
// rather than committing. The wiki index is reconciled from the staged tree (a valid
// diff/read ref), so search stays consistent with the staged files; only the git commit
// and its message-enhancement job are skipped.
const autoCommit = this.deps.storage.autoCommit;
const squashResult = await this.deps.lockingService.withLock('config:repo', async () => {
const preSquashSha = await this.deps.gitService.revParseHead();
const merge = await this.deps.gitService.squashMergeIntoMain(
sessionWorktree.branch,
this.deps.storage.systemGitAuthor.name,
this.deps.storage.systemGitAuthor.email,
commitMessage,
);
return { preSquashSha, merge };
if (autoCommit) {
const merge = await this.deps.gitService.squashMergeIntoMain(
sessionWorktree.branch,
this.deps.storage.systemGitAuthor.name,
this.deps.storage.systemGitAuthor.email,
commitMessage,
);
return { preSquashSha, committed: true as const, merge };
}
const merge = await this.deps.gitService.stageSquashMergeIntoMain(sessionWorktree.branch);
return { preSquashSha, committed: false as const, merge };
});
const mergeResult = squashResult.merge;
if (!mergeResult.ok) {
if (!squashResult.merge.ok) {
await this.deps.runs.markFailed(runRow.id);
throw new Error(`squash merge conflict: ${mergeResult.conflictPaths.join(', ')}`);
throw new Error(`squash merge conflict: ${squashResult.merge.conflictPaths.join(', ')}`);
}
const touchedPaths = squashResult.merge.touchedPaths;
const hasChanges = touchedPaths.length > 0;
// `syncRef` is the tree-ish to diff/read when reconciling the wiki index: the new commit
// SHA when committed, the staged tree SHA when staging. `commitSha` is only set when an
// actual commit was created (it surfaces in the report and progress UI).
let commitSha: string | null = null;
let syncRef: string | null = null;
if (hasChanges) {
if (squashResult.committed) {
commitSha = squashResult.merge.squashSha;
syncRef = commitSha;
} else {
syncRef = squashResult.merge.stagedTree;
}
}
const commitSha = mergeResult.touchedPaths.length === 0 ? null : mergeResult.squashSha;
await runTrace.event(
'debug',
'squash',
'squash_finished',
{
commitSha,
touchedPaths: mergeResult.touchedPaths,
touchedPaths,
},
undefined,
Date.now() - squashStartedAt,
@ -2714,18 +2736,28 @@ export class IngestBundleRunner {
wikiCount: countMemoryFlowActions(memoryFlowSavedActions, 'wiki'),
slCount: countMemoryFlowActions(memoryFlowSavedActions, 'sl'),
});
await stage6?.updateProgress(1.0, commitSha ? `Saved changes (${commitSha.slice(0, 8)})` : 'No changes to save');
await stage6?.updateProgress(
1.0,
commitSha
? `Saved changes (${commitSha.slice(0, 8)})`
: hasChanges
? 'Staged changes (auto-commit disabled)'
: 'No changes to save',
);
// Sync the shared `knowledge` index from the squashed diff in a single
// transaction. If this throws, the run fails and no partial index state
// survives (thanks to the transactional upsert in applyDiffTransactional).
if (commitSha) {
// `syncRef` is the new commit when committed, or the staged tree when staging.
if (syncRef) {
const indexSyncStartedAt = Date.now();
// Multi-file squash → omit path so the handler diffs the whole commit
// (a comma-joined pathspec would match nothing and the job would no-op).
const pathFilter = mergeResult.touchedPaths.length === 1 ? mergeResult.touchedPaths[0] : '';
await this.deps.commitMessages.enqueueForExternalCommit({ commitHash: commitSha }, commitMessage, pathFilter);
await this.deps.wikiService.syncFromCommit(squashResult.preSquashSha, commitSha, runRow.id);
const pathFilter = touchedPaths.length === 1 ? touchedPaths[0] : '';
if (squashResult.committed) {
await this.deps.commitMessages.enqueueForExternalCommit({ commitHash: syncRef }, commitMessage, pathFilter);
}
await this.deps.wikiService.syncFromCommit(squashResult.preSquashSha, syncRef, runRow.id);
await this.syncKnowledgeSlRefsFromActions(job.connectionId, memoryFlowSavedActions);
const touchedConnections = [
...new Set(