Skip to content

Commit b4649aa

Browse files
authored
Merge pull request #172 from aictrl-dev/feat/issue-146-headless-no-snapshots
perf(run): skip per-step worktree snapshots in headless runs (#146)
2 parents 8ed4562 + 0a82d19 commit b4649aa

6 files changed

Lines changed: 323 additions & 6 deletions

File tree

‎packages/cli/README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,13 @@ Automatically fetches the PR branch and imports the agent session used to create
128128

129129
Aictrl reads config from `.aictrl/` (project) or `~/.config/aictrl/` (global).
130130

131+
### Snapshots
132+
133+
Sessions snapshot the git worktree at each model step, which backs patches, session
134+
diffs and revert. `aictrl run` (without `--attach`) skips them, because nothing in a
135+
headless run reads them and each one stages the whole worktree. Set `"snapshot": true`
136+
in config to keep them in a run, or `"snapshot": false` to turn them off everywhere.
137+
131138
### Models
132139

133140
| Provider | Env Var |

‎packages/cli/src/cli/cmd/run.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { PermissionNext } from "../../permission/next"
2323
import { Session } from "../../session"
2424
import type { MessageV2 } from "../../session/message-v2"
2525
import { SessionPrompt } from "../../session/prompt"
26+
import { Snapshot } from "../../snapshot"
2627
import { Config } from "../../config/config"
2728
import { GlobalBus } from "../../bus/global"
2829
import { Instance } from "../../project/instance"
@@ -1094,6 +1095,10 @@ export const RunCommand = cmd({
10941095
await Shutdown.flush()
10951096
}
10961097

1098+
// Nothing in a run reads snapshots, patches or session diffs, so local sessions skip
1099+
// them unless config sets `snapshot: true` (#146).
1100+
// consolidate with HeadlessRun (#152)
1101+
Snapshot.setDefault(false)
10971102
invocation.phase("bootstrap")
10981103
if (args.attach) {
10991104
return await invocation.run(() => execute(createAictrlClient({ baseUrl: args.attach, directory })))

‎packages/cli/src/session/summary.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ export namespace SessionSummary {
7272
messageID: z.string(),
7373
}),
7474
async (input) => {
75+
// Diffs come from step snapshots; without them there is nothing to summarize.
76+
if (!(await Snapshot.enabled())) return
7577
const all = await Session.messages({ sessionID: input.sessionID })
7678
await Promise.all([
7779
summarizeSession({ sessionID: input.sessionID, messages: all }),

‎packages/cli/src/snapshot/index.ts‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export namespace Snapshot {
1313
const log = Log.create({ service: "snapshot" })
1414
const hour = 60 * 60 * 1000
1515
const prune = "7.days"
16+
// Applies when config leaves `snapshot` unset; a headless `aictrl run` turns it off.
17+
let byDefault = true
1618

1719
export function init() {
1820
Scheduler.register({
@@ -23,10 +25,25 @@ export namespace Snapshot {
2325
})
2426
}
2527

26-
export async function cleanup() {
27-
if (Instance.project.vcs !== "git" || Flag.AICTRL_CLIENT === "acp") return
28+
/** Sets whether this process snapshots the worktree when config leaves `snapshot` unset. */
29+
export function setDefault(enabled: boolean) {
30+
byDefault = enabled
31+
}
32+
33+
/**
34+
* Whether this instance snapshots the worktree. Config `snapshot` decides when it
35+
* is set; otherwise the process default applies, which `aictrl run` turns off:
36+
* nothing in a run reads snapshots, and each one stages the whole worktree (#146).
37+
*/
38+
export async function enabled() {
39+
if (Instance.project.vcs !== "git" || Flag.AICTRL_CLIENT === "acp") return false
2840
const cfg = await Config.get()
29-
if (cfg.snapshot === false) return
41+
if (cfg.snapshot !== undefined) return cfg.snapshot
42+
return byDefault
43+
}
44+
45+
export async function cleanup() {
46+
if (!(await enabled())) return
3047
const git = gitdir()
3148
const exists = await fs
3249
.stat(git)
@@ -49,9 +66,7 @@ export namespace Snapshot {
4966
}
5067

5168
export async function track() {
52-
if (Instance.project.vcs !== "git" || Flag.AICTRL_CLIENT === "acp") return
53-
const cfg = await Config.get()
54-
if (cfg.snapshot === false) return
69+
if (!(await enabled())) return
5570
const git = gitdir()
5671
if (await fs.mkdir(git, { recursive: true })) {
5772
await $`git init`
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { describe, expect, test } from "bun:test"
2+
import fs from "fs/promises"
3+
import path from "path"
4+
import { tmpdir } from "../fixture/fixture"
5+
6+
const entry = path.resolve(import.meta.dir, "../../src/index.ts")
7+
8+
/**
9+
* Runs `aictrl run --format json` in a git worktree against a fixture Gemini server
10+
* whose first turn writes `out.txt` and whose second turn finishes. The run gets its
11+
* own home and XDG directories, so no outside config applies and the shadow snapshot
12+
* repo, if any, can be observed.
13+
*/
14+
async function run(config: Record<string, unknown> = {}) {
15+
await using tmp = await tmpdir({ git: true })
16+
// Outside the worktree, so a shadow repo never snapshots itself.
17+
await using home = await tmpdir()
18+
let calls = 0
19+
const server = Bun.serve({
20+
hostname: "127.0.0.1",
21+
port: 0,
22+
fetch(): Response {
23+
calls++
24+
const parts =
25+
calls === 1
26+
? [{ functionCall: { name: "write", args: { filePath: path.join(tmp.path, "out.txt"), content: "out\n" } } }]
27+
: [{ text: "Done." }]
28+
const chunk = {
29+
candidates: [{ index: 0, content: { role: "model", parts }, finishReason: "STOP" }],
30+
usageMetadata: { promptTokenCount: 7, candidatesTokenCount: 3, totalTokenCount: 10 },
31+
}
32+
return new Response(`data: ${JSON.stringify(chunk)}\n\n`, { headers: { "content-type": "text/event-stream" } })
33+
},
34+
})
35+
await Bun.write(
36+
path.join(tmp.path, "aictrl.json"),
37+
JSON.stringify({
38+
provider: {
39+
fixture: {
40+
npm: "@ai-sdk/google",
41+
options: { apiKey: "fixture", baseURL: `http://127.0.0.1:${server.port}` },
42+
models: { "gemini-fixture": { name: "fixture", limit: { context: 100000, output: 1000 } } },
43+
},
44+
},
45+
agent: { title: { disable: true } },
46+
...config,
47+
}),
48+
)
49+
const proc = Bun.spawn(
50+
[
51+
"bun",
52+
"run",
53+
"--conditions=browser",
54+
entry,
55+
"run",
56+
"--format",
57+
"json",
58+
"--model",
59+
"fixture/gemini-fixture",
60+
"Write out.txt.",
61+
],
62+
{
63+
cwd: tmp.path,
64+
env: {
65+
...process.env,
66+
HOME: home.path,
67+
XDG_CONFIG_HOME: path.join(home.path, ".config"),
68+
XDG_DATA_HOME: path.join(home.path, ".local/share"),
69+
XDG_CACHE_HOME: path.join(home.path, ".cache"),
70+
AICTRL_TEST_HOME: home.path,
71+
AICTRL_DISABLE_DEFAULT_PLUGINS: "true",
72+
AICTRL_DISABLE_MODELS_FETCH: "true",
73+
AICTRL_DISABLE_AUTOCOMPACT: "true",
74+
},
75+
stdin: "ignore",
76+
stdout: "pipe",
77+
stderr: "pipe",
78+
},
79+
)
80+
const timeout = setTimeout(() => proc.kill("SIGKILL"), 15000)
81+
try {
82+
const [stdout, stderr, exit] = await Promise.all([
83+
new Response(proc.stdout).text(),
84+
new Response(proc.stderr).text(),
85+
proc.exited,
86+
])
87+
const events = stdout
88+
.split("\n")
89+
.filter((line) => line.startsWith("{"))
90+
.map((line) => JSON.parse(line))
91+
const shadow = await fs.readdir(path.join(home.path, ".local/share", "aictrl", "snapshot")).catch(() => [])
92+
return {
93+
exit,
94+
output: stderr + stdout,
95+
calls,
96+
events,
97+
shadow,
98+
written: await Bun.file(path.join(tmp.path, "out.txt")).exists(),
99+
}
100+
} finally {
101+
clearTimeout(timeout)
102+
proc.kill("SIGKILL")
103+
server.stop(true)
104+
}
105+
}
106+
107+
describe("headless run snapshots (#146)", () => {
108+
test("a headless run creates no shadow snapshot repo and its step_finish events carry no snapshot", async () => {
109+
const result = await run()
110+
111+
expect(result.exit, result.output).toBe(0)
112+
expect(result.calls).toBe(2)
113+
expect(result.written).toBe(true)
114+
expect(result.shadow).toEqual([])
115+
const steps = result.events.filter((event) => event.type === "step_finish")
116+
expect(steps, result.output).toHaveLength(2)
117+
expect(steps.map((event) => event.part.snapshot)).toEqual([undefined, undefined])
118+
expect(result.events.filter((event) => event.type === "session_complete")).toHaveLength(1)
119+
}, 20000)
120+
121+
test("config `snapshot: true` snapshots every step of a headless run", async () => {
122+
const result = await run({ snapshot: true })
123+
124+
expect(result.exit, result.output).toBe(0)
125+
expect(result.written).toBe(true)
126+
expect(result.shadow).toHaveLength(1)
127+
const steps = result.events.filter((event) => event.type === "step_finish")
128+
expect(steps, result.output).toHaveLength(2)
129+
for (const event of steps) expect(event.part.snapshot).toBeString()
130+
}, 20000)
131+
})
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { afterEach, describe, expect, spyOn, test } from "bun:test"
2+
import fs from "fs/promises"
3+
import path from "path"
4+
import { Agent } from "../../src/agent/agent"
5+
import { Global } from "../../src/global"
6+
import { Identifier } from "../../src/id/id"
7+
import { Instance } from "../../src/project/instance"
8+
import { Provider } from "../../src/provider/provider"
9+
import { Session } from "../../src/session"
10+
import { LLM } from "../../src/session/llm"
11+
import { MessageV2 } from "../../src/session/message-v2"
12+
import { SessionProcessor } from "../../src/session/processor"
13+
import { SessionSummary } from "../../src/session/summary"
14+
import { Snapshot } from "../../src/snapshot"
15+
import { Storage } from "../../src/storage/storage"
16+
import type { Config } from "../../src/config/config"
17+
import { tmpdir } from "../fixture/fixture"
18+
19+
afterEach(() => Snapshot.setDefault(true))
20+
21+
/**
22+
* Runs one model step that writes `out.txt` in a git worktree and returns what the
23+
* step persisted: its parts, the session diff and whether the shadow repo exists.
24+
*/
25+
async function step(config: Partial<Config.Info> = {}) {
26+
await using tmp = await tmpdir({
27+
git: true,
28+
config: {
29+
enabled_providers: ["alibaba"],
30+
provider: { alibaba: { options: { apiKey: "test-key" } } },
31+
...config,
32+
},
33+
})
34+
35+
return await Instance.provide({
36+
directory: tmp.path,
37+
fn: async () => {
38+
const session = await Session.create({ title: "Snapshot fixture" })
39+
const agent = await Agent.get("build")
40+
const model = await Provider.getModel("alibaba", "qwen-plus")
41+
const user = (await Session.updateMessage({
42+
id: Identifier.ascending("message"),
43+
sessionID: session.id,
44+
role: "user",
45+
time: { created: Date.now() },
46+
agent: agent.name,
47+
model: { providerID: model.providerID, modelID: model.id },
48+
})) as MessageV2.User
49+
const assistant = (await Session.updateMessage({
50+
id: Identifier.ascending("message"),
51+
sessionID: session.id,
52+
role: "assistant",
53+
parentID: user.id,
54+
modelID: model.id,
55+
providerID: model.providerID,
56+
mode: agent.name,
57+
agent: agent.name,
58+
path: { cwd: tmp.path, root: tmp.path },
59+
cost: 0,
60+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
61+
time: { created: Date.now() },
62+
})) as MessageV2.Assistant
63+
64+
const stream = spyOn(LLM, "stream").mockResolvedValue({
65+
fullStream: (async function* () {
66+
yield { type: "start-step" }
67+
await Bun.write(path.join(tmp.path, "out.txt"), "written during the step\n")
68+
yield {
69+
type: "finish-step",
70+
finishReason: "stop",
71+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
72+
}
73+
})(),
74+
} as unknown as Awaited<ReturnType<typeof LLM.stream>>)
75+
76+
try {
77+
await SessionProcessor.create({
78+
assistantMessage: assistant,
79+
sessionID: session.id,
80+
model,
81+
abort: new AbortController().signal,
82+
}).process({
83+
user,
84+
sessionID: session.id,
85+
model,
86+
agent,
87+
abort: new AbortController().signal,
88+
system: [],
89+
messages: [],
90+
tools: {},
91+
})
92+
} finally {
93+
stream.mockRestore()
94+
}
95+
// The processor summarizes in the background; await it so the result is settled.
96+
await SessionSummary.summarize({ sessionID: session.id, messageID: user.id })
97+
98+
const parts = (await Session.messages({ sessionID: session.id })).flatMap((message) => message.parts)
99+
return {
100+
parts,
101+
diff: await Storage.read<Snapshot.FileDiff[]>(["session_diff", session.id]).catch(() => undefined),
102+
summary: (await Session.get(session.id)).summary,
103+
shadow: await fs
104+
.stat(path.join(Global.Path.data, "snapshot", Instance.project.id))
105+
.then(() => true)
106+
.catch(() => false),
107+
}
108+
},
109+
})
110+
}
111+
112+
describe("headless run snapshots (#146)", () => {
113+
test("a headless run takes no snapshot, records no patch and writes no session diff", async () => {
114+
// what `aictrl run` sets before it bootstraps
115+
Snapshot.setDefault(false)
116+
const result = await step()
117+
118+
expect(result.shadow).toBe(false)
119+
const start = result.parts.find((part) => part.type === "step-start")
120+
const finish = result.parts.find((part) => part.type === "step-finish")
121+
expect(start?.type === "step-start" ? start.snapshot : "missing").toBeUndefined()
122+
expect(finish?.type === "step-finish" ? finish.snapshot : "missing").toBeUndefined()
123+
expect(result.parts.filter((part) => part.type === "patch")).toHaveLength(0)
124+
expect(result.diff).toBeUndefined()
125+
expect(result.summary).toBeUndefined()
126+
})
127+
128+
test("config `snapshot: true` re-enables snapshots, patches and the session diff in a headless run", async () => {
129+
// what `aictrl run` sets before it bootstraps
130+
Snapshot.setDefault(false)
131+
const result = await step({ snapshot: true })
132+
133+
expect(result.shadow).toBe(true)
134+
const start = result.parts.find((part) => part.type === "step-start")
135+
expect(start?.type === "step-start" ? start.snapshot : undefined).toBeString()
136+
const patch = result.parts.find((part) => part.type === "patch")
137+
expect(patch?.type === "patch" ? patch.files.map((file) => path.basename(file)) : []).toEqual(["out.txt"])
138+
expect(result.diff?.map((item) => item.file)).toEqual(["out.txt"])
139+
expect(result.summary).toMatchObject({ files: 1, additions: 1 })
140+
})
141+
142+
test("sessions outside a headless run keep snapshots by default", async () => {
143+
const result = await step()
144+
145+
expect(result.shadow).toBe(true)
146+
expect(result.parts.filter((part) => part.type === "patch")).toHaveLength(1)
147+
expect(result.diff?.map((item) => item.file)).toEqual(["out.txt"])
148+
})
149+
150+
test("config `snapshot: false` still disables snapshots outside a headless run", async () => {
151+
const result = await step({ snapshot: false })
152+
153+
expect(result.shadow).toBe(false)
154+
expect(result.parts.filter((part) => part.type === "patch")).toHaveLength(0)
155+
expect(result.diff).toBeUndefined()
156+
})
157+
})

0 commit comments

Comments
 (0)