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

@ -400,6 +400,66 @@ describe('GitService', () => {
});
});
describe('stageSquashMergeIntoMain', () => {
it('applies the branch to main without committing, leaving the changes staged', async () => {
const { commitHash: baseSha } = await writeAndCommit('seed.md', 'seed');
const parent = await realpath(join(tempDir, '..'));
const wtDir = join(parent, `wt-${Date.now()}-stage`);
await service.addWorktree(wtDir, 'session/stage', baseSha);
const scoped = service.forWorktree(wtDir);
await writeFile(join(wtDir, 'a.yaml'), 'one: 1\n', 'utf-8');
await scoped.commitFile('a.yaml', 'wip a', 'System User', 'system@example.com');
const result = await service.stageSquashMergeIntoMain('session/stage');
expect(result.ok).toBe(true);
if (!result.ok) {
throw new Error('unreachable');
}
expect(result.touchedPaths).toEqual(['a.yaml']);
expect(result.stagedTree).toMatch(/^[0-9a-f]{40}$/);
// HEAD did not advance: no commit was created.
expect(await service.revParseHead()).toBe(baseSha);
// The change is in main's working tree...
await expect(readFile(join(tempDir, 'a.yaml'), 'utf-8')).resolves.toBe('one: 1\n');
// ...and staged in the index for the user to commit.
const stagedNames = await createSimpleGit(tempDir).raw(['diff', '--cached', '--name-only']);
expect(stagedNames).toContain('a.yaml');
// The staged tree is usable as a diff/read ref for DB sync.
const treeListing = await createSimpleGit(tempDir).raw(['ls-tree', '-r', '--name-only', result.stagedTree]);
expect(treeListing).toContain('a.yaml');
await service.removeWorktree(wtDir).catch(() => undefined);
await rm(wtDir, { recursive: true, force: true }).catch(() => undefined);
});
it('reports conflicts without committing or mutating main', async () => {
const { commitHash: baseSha } = await writeAndCommit('conflict.md', 'base\n');
const parent = await realpath(join(tempDir, '..'));
const wtDir = join(parent, `wt-${Date.now()}-stage-conflict`);
await service.addWorktree(wtDir, 'session/stage-conflict', baseSha);
const scoped = service.forWorktree(wtDir);
await writeFile(join(wtDir, 'conflict.md'), 'from-branch\n', 'utf-8');
await scoped.commitFile('conflict.md', 'branch edit', 'System User', 'system@example.com');
// Move main ahead with a conflicting change.
await writeAndCommit('conflict.md', 'from-main\n');
const mainHead = await service.revParseHead();
const result = await service.stageSquashMergeIntoMain('session/stage-conflict');
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error('unreachable');
}
expect(result.conflictPaths).toContain('conflict.md');
expect(await service.revParseHead()).toBe(mainHead);
await service.removeWorktree(wtDir).catch(() => undefined);
await rm(wtDir, { recursive: true, force: true }).catch(() => undefined);
});
});
describe('squashMergeIntoMain', () => {
it('merges a session branch as one commit on main, returning the new SHA + touched paths', async () => {
const { commitHash: baseSha } = await writeAndCommit('seed.md', 'seed');

View file

@ -199,6 +199,7 @@ function makeDeps(
storage: {
homeDir: join(runtime.configDir, '.ktx'),
systemGitAuthor: { name: 'KTX Test', email: 'system@ktx.local' },
autoCommit: true,
resolveUploadDir: (id) => join(runtime.homeDir, 'upload', id),
resolvePullDir: (id) => join(runtime.homeDir, 'pull', id),
resolveTranscriptDir: (id) => join(runtime.configDir, '.ktx/ingest-transcripts', id),

View file

@ -262,6 +262,7 @@ const buildRunner = (deps: ReturnType<typeof makeDeps> = makeDeps(), overrides:
storage: {
homeDir: '/tmp/ktx-test',
systemGitAuthor: { name: 'KTX Test', email: 'system@ktx.local' },
autoCommit: true,
resolveUploadDir: (uploadId) => `/tmp/ktx-test/ingest-uploads/${uploadId}`,
resolvePullDir: (jobId) => `/tmp/ktx-test/ingest-pulls/${jobId}`,
resolveTranscriptDir: (jobId) => `/tmp/ktx-test/run/wu-transcripts/${jobId}`,
@ -1519,6 +1520,7 @@ describe('IngestBundleRunner — Stages 1 → 7', () => {
storage: {
homeDir: tempRoot,
systemGitAuthor: { name: 'KTX Test', email: 'system@ktx.local' },
autoCommit: true,
resolveUploadDir: (uploadId: string) => join(tempRoot, 'ingest-uploads', uploadId),
resolvePullDir: (jobId: string) => join(tempRoot, 'ingest-pulls', jobId),
resolveTranscriptDir: (jobId: string) => join(tempRoot, 'run', 'wu-transcripts', jobId),

View file

@ -424,6 +424,60 @@ describe('canonical local ingest', () => {
}
});
it('with auto_commit disabled, stages ingest changes and indexes the wiki without committing', async () => {
const projectDir = join(tempDir, 'no-autocommit-project');
await initKtxProject({ projectDir });
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'connections:',
' warehouse:',
' driver: postgres',
'ingest:',
' adapters:',
' - fake',
' embeddings:',
' backend: none',
'storage:',
' git:',
' auto_commit: false',
'',
].join('\n'),
'utf-8',
);
const stagedProject = await loadKtxProject({ projectDir });
const preHead = await stagedProject.git.revParseHead();
const sourceDir = join(tempDir, 'no-autocommit-source');
await mkdir(join(sourceDir, 'orders'), { recursive: true });
await writeFile(join(sourceDir, 'orders', 'orders.json'), '{"name":"orders"}\n', 'utf-8');
const result = await runLocalIngest({
project: stagedProject,
adapters: [new FakeSourceAdapter()],
adapter: 'fake',
connectionId: 'warehouse',
sourceDir,
jobId: 'wiki-staged-1',
agentRunner: new WikiWritingAgentRunner(),
});
expect(result.result.failedWorkUnits).toEqual([]);
// No commit was created: HEAD is unchanged.
expect(await stagedProject.git.revParseHead()).toBe(preHead);
// ...yet the wiki page is on disk (staged) and indexed for search, reconciled from the
// staged tree rather than a commit.
await expect(readFile(join(projectDir, 'wiki', 'global', 'orders_context.md'), 'utf-8')).resolves.toContain('Orders');
const db = new Database(join(projectDir, '.ktx', 'db.sqlite'), { readonly: true });
try {
expect(db.prepare('SELECT key, summary FROM knowledge_pages ORDER BY key').all()).toEqual([
{ key: 'orders_context', summary: 'Orders source context' },
]);
} finally {
db.close();
}
});
it('does not persist noop embedding vectors when local embeddings are disabled', async () => {
await writeFile(
join(project.projectDir, 'ktx.yaml'),

View file

@ -40,6 +40,7 @@ interface BuiltMocks {
slValidator: any;
toolsetFactory: any;
logger: any;
autoCommit: boolean;
}
const buildMocks = (overrides: Partial<BuiltMocks> = {}): BuiltMocks => {
@ -111,6 +112,9 @@ const buildMocks = (overrides: Partial<BuiltMocks> = {}): BuiltMocks => {
gitService: {
revParseHead: vi.fn().mockResolvedValue('basesha'),
squashMergeIntoMain: vi.fn().mockResolvedValue({ ok: true, squashSha: 'cafebabe', touchedPaths: ['a.yaml'] }),
stageSquashMergeIntoMain: vi
.fn()
.mockResolvedValue({ ok: true, touchedPaths: ['a.yaml'], stagedTree: 'deadbeeftree' }),
},
lockingService: {
withLock: vi.fn().mockImplementation((_key: string, fn: () => Promise<unknown>) => fn()),
@ -134,6 +138,7 @@ const buildMocks = (overrides: Partial<BuiltMocks> = {}): BuiltMocks => {
}),
},
logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
autoCommit: true,
};
return { ...defaults, ...overrides };
@ -151,6 +156,7 @@ const buildService = (mocks: BuiltMocks): MemoryAgentService =>
llm: {
memoryIngestionModel: mocks.appSettings.settings.llm.memoryIngestionModel,
},
autoCommit: mocks.autoCommit,
},
promptService: mocks.prompt,
skillsRegistry: mocks.skillsRegistry,
@ -242,6 +248,26 @@ describe('MemoryAgentService.ingest — session-branch orchestration', () => {
expect(result.commitHash).toBe('cafebabe');
});
it('with auto_commit disabled, stages the session on main without committing or enqueuing a note', async () => {
const mocks = buildMocks({ autoCommit: false });
const svc = buildService(mocks);
const result = await svc.ingest(baseInput);
// Applied to main via the staging path, never the committing path.
expect(mocks.gitService.stageSquashMergeIntoMain).toHaveBeenCalledWith('session/chat-1');
expect(mocks.gitService.squashMergeIntoMain).not.toHaveBeenCalled();
// No commit means no commit-message enhancement job.
expect(mocks.configService.enqueueCommitMessageJobForExternalCommit).not.toHaveBeenCalled();
// The session still applied successfully; there is just no commit hash.
expect(result.commitHash).toBeNull();
expect(mocks.sessionWorktreeService.cleanup).toHaveBeenCalledWith(
expect.objectContaining({ chatId: 'chat-1' }),
'success',
expect.any(Object),
);
});
it('normalizes load_skill output to markdown while preserving structured payload', async () => {
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-memory-skill-'));
const skillDir = join(tempDir, 'memory_agent');

View file

@ -193,6 +193,7 @@ describe('MemoryAgentService.reconcileCrossRefs', () => {
knowledge: { userScopedKnowledgeEnabled: false },
slValidation: { probeRowCount: 1 },
llm: { memoryIngestionModel: 'test-model' },
autoCommit: true,
},
promptService: undefined as never,
skillsRegistry: undefined as never,
@ -369,6 +370,7 @@ describe('MemoryAgentService.gateRevertInvalidSources (J3)', () => {
knowledge: { userScopedKnowledgeEnabled: false },
slValidation: { probeRowCount: 1 },
llm: { memoryIngestionModel: 'test-model' },
autoCommit: true,
},
promptService: undefined as never,
skillsRegistry: undefined as never,