Skip to content

Commit 0acd851

Browse files
authored
feat(agents): add managed git worktree lifecycle (create/provision/snapshot/restore/GC) (#100535)
Centralized managed worktrees under <state-dir>/worktrees/<repo-fingerprint>/<name> with branch-per-task (openclaw/<name>), .worktreeinclude provisioning, an optional .openclaw/worktree-setup.sh repo hook, and a SQLite registry in the shared state DB. Removal always snapshots the tree (untracked included, gitignored excluded) to refs/openclaw/snapshots/<id>; restore rebuilds the branch at the original commit with the snapshot content as uncommitted state. Lossless run-end cleanup, 7-day idle GC for run-owned worktrees (manual exempt), orphan reconciliation, 30-day snapshot retention. Surfaces: worktrees.* gateway RPC (operator.admin mutations), openclaw worktrees CLI, Control UI page, plugin-SDK facade + Workboard kind:"worktree" materialization. E2E-verified on Testbox: full create->work->remove->restore->gc lifecycle.
1 parent 4b7661e commit 0acd851

129 files changed

Lines changed: 4026 additions & 303 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -864,6 +864,7 @@ public struct AgentParams: Codable, Sendable {
864864
public let timeout: Int?
865865
public let besteffortdeliver: Bool?
866866
public let lane: String?
867+
public let cwd: String?
867868
public let cleanupbundlemcponrunend: Bool?
868869
public let modelrun: Bool?
869870
public let promptmode: AnyCodable?
@@ -906,6 +907,7 @@ public struct AgentParams: Codable, Sendable {
906907
timeout: Int?,
907908
besteffortdeliver: Bool?,
908909
lane: String?,
910+
cwd: String?,
909911
cleanupbundlemcponrunend: Bool?,
910912
modelrun: Bool?,
911913
promptmode: AnyCodable?,
@@ -947,6 +949,7 @@ public struct AgentParams: Codable, Sendable {
947949
self.timeout = timeout
948950
self.besteffortdeliver = besteffortdeliver
949951
self.lane = lane
952+
self.cwd = cwd
950953
self.cleanupbundlemcponrunend = cleanupbundlemcponrunend
951954
self.modelrun = modelrun
952955
self.promptmode = promptmode
@@ -990,6 +993,7 @@ public struct AgentParams: Codable, Sendable {
990993
case timeout
991994
case besteffortdeliver = "bestEffortDeliver"
992995
case lane
996+
case cwd
993997
case cleanupbundlemcponrunend = "cleanupBundleMcpOnRunEnd"
994998
case modelrun = "modelRun"
995999
case promptmode = "promptMode"
@@ -1111,6 +1115,180 @@ public struct WakeParams: Codable, Sendable {
11111115
}
11121116
}
11131117

1118+
public struct WorktreeRecord: Codable, Sendable {
1119+
public let id: String
1120+
public let name: String
1121+
public let repofingerprint: String
1122+
public let reporoot: String
1123+
public let path: String
1124+
public let branch: String
1125+
public let baseref: String
1126+
public let ownerkind: String
1127+
public let ownerid: String?
1128+
public let snapshotref: String?
1129+
public let createdat: Int
1130+
public let lastactiveat: Int
1131+
public let removedat: Int?
1132+
1133+
public init(
1134+
id: String,
1135+
name: String,
1136+
repofingerprint: String,
1137+
reporoot: String,
1138+
path: String,
1139+
branch: String,
1140+
baseref: String,
1141+
ownerkind: String,
1142+
ownerid: String?,
1143+
snapshotref: String?,
1144+
createdat: Int,
1145+
lastactiveat: Int,
1146+
removedat: Int?)
1147+
{
1148+
self.id = id
1149+
self.name = name
1150+
self.repofingerprint = repofingerprint
1151+
self.reporoot = reporoot
1152+
self.path = path
1153+
self.branch = branch
1154+
self.baseref = baseref
1155+
self.ownerkind = ownerkind
1156+
self.ownerid = ownerid
1157+
self.snapshotref = snapshotref
1158+
self.createdat = createdat
1159+
self.lastactiveat = lastactiveat
1160+
self.removedat = removedat
1161+
}
1162+
1163+
private enum CodingKeys: String, CodingKey {
1164+
case id
1165+
case name
1166+
case repofingerprint = "repoFingerprint"
1167+
case reporoot = "repoRoot"
1168+
case path
1169+
case branch
1170+
case baseref = "baseRef"
1171+
case ownerkind = "ownerKind"
1172+
case ownerid = "ownerId"
1173+
case snapshotref = "snapshotRef"
1174+
case createdat = "createdAt"
1175+
case lastactiveat = "lastActiveAt"
1176+
case removedat = "removedAt"
1177+
}
1178+
}
1179+
1180+
public struct WorktreesListParams: Codable, Sendable {}
1181+
1182+
public struct WorktreesListResult: Codable, Sendable {
1183+
public let worktrees: [WorktreeRecord]
1184+
1185+
public init(
1186+
worktrees: [WorktreeRecord])
1187+
{
1188+
self.worktrees = worktrees
1189+
}
1190+
1191+
private enum CodingKeys: String, CodingKey {
1192+
case worktrees
1193+
}
1194+
}
1195+
1196+
public struct WorktreesCreateParams: Codable, Sendable {
1197+
public let reporoot: String
1198+
public let name: String?
1199+
public let baseref: String?
1200+
1201+
public init(
1202+
reporoot: String,
1203+
name: String?,
1204+
baseref: String?)
1205+
{
1206+
self.reporoot = reporoot
1207+
self.name = name
1208+
self.baseref = baseref
1209+
}
1210+
1211+
private enum CodingKeys: String, CodingKey {
1212+
case reporoot = "repoRoot"
1213+
case name
1214+
case baseref = "baseRef"
1215+
}
1216+
}
1217+
1218+
public struct WorktreesRemoveParams: Codable, Sendable {
1219+
public let id: String
1220+
public let force: Bool?
1221+
1222+
public init(
1223+
id: String,
1224+
force: Bool?)
1225+
{
1226+
self.id = id
1227+
self.force = force
1228+
}
1229+
1230+
private enum CodingKeys: String, CodingKey {
1231+
case id
1232+
case force
1233+
}
1234+
}
1235+
1236+
public struct WorktreesRemoveResult: Codable, Sendable {
1237+
public let removed: Bool
1238+
public let snapshotref: String?
1239+
1240+
public init(
1241+
removed: Bool,
1242+
snapshotref: String?)
1243+
{
1244+
self.removed = removed
1245+
self.snapshotref = snapshotref
1246+
}
1247+
1248+
private enum CodingKeys: String, CodingKey {
1249+
case removed
1250+
case snapshotref = "snapshotRef"
1251+
}
1252+
}
1253+
1254+
public struct WorktreesRestoreParams: Codable, Sendable {
1255+
public let id: String
1256+
1257+
public init(
1258+
id: String)
1259+
{
1260+
self.id = id
1261+
}
1262+
1263+
private enum CodingKeys: String, CodingKey {
1264+
case id
1265+
}
1266+
}
1267+
1268+
public struct WorktreesGcParams: Codable, Sendable {}
1269+
1270+
public struct WorktreesGcResult: Codable, Sendable {
1271+
public let removed: [String]
1272+
public let orphansdeleted: Int
1273+
public let snapshotspruned: Int
1274+
1275+
public init(
1276+
removed: [String],
1277+
orphansdeleted: Int,
1278+
snapshotspruned: Int)
1279+
{
1280+
self.removed = removed
1281+
self.orphansdeleted = orphansdeleted
1282+
self.snapshotspruned = snapshotspruned
1283+
}
1284+
1285+
private enum CodingKeys: String, CodingKey {
1286+
case removed
1287+
case orphansdeleted = "orphansDeleted"
1288+
case snapshotspruned = "snapshotsPruned"
1289+
}
1290+
}
1291+
11141292
public struct NodePairRequestParams: Codable, Sendable {
11151293
public let nodeid: String
11161294
public let displayname: String?

docs/concepts/managed-worktrees.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
---
2+
summary: "Run agent tasks in isolated git checkouts with automatic snapshots and cleanup"
3+
read_when:
4+
- You want an isolated branch and checkout for an agent task
5+
- You are configuring Workboard cards with worktree workspaces
6+
- You need to restore or clean up an OpenClaw-managed worktree
7+
title: "Managed worktrees"
8+
---
9+
10+
Managed worktrees give an agent task its own git branch and checkout without placing temporary directories inside the source repository. OpenClaw creates them under its state directory, records them in the shared state database, and snapshots their tracked and non-ignored untracked contents before removal.
11+
12+
## Layout and names
13+
14+
Each worktree lives at:
15+
16+
```text
17+
<openclaw-state-dir>/worktrees/<repo-fingerprint>/<name>
18+
```
19+
20+
The repository fingerprint is the first 16 hexadecimal characters of a SHA-256 hash over the canonical git common directory and origin URL. A supplied name must match `[a-z0-9][a-z0-9-]{0,63}`. Without a name, OpenClaw generates `wt-` followed by eight random hexadecimal characters.
21+
22+
OpenClaw creates branch `openclaw/<name>` at the requested base ref. Without a base ref, it fetches `origin`, uses the remote default branch when available, and falls back to local `HEAD` when the repository is offline or has no usable remote.
23+
24+
## Provision ignored files
25+
26+
Add `.worktreeinclude` at the source repository root to copy selected ignored, untracked files into a new worktree. The file uses gitignore-pattern syntax, one pattern per line, with `#` comments:
27+
28+
```gitignore
29+
.env.local
30+
fixtures/generated/**
31+
```
32+
33+
Only files reported by git as both ignored and untracked are eligible. Tracked files are already present through git and are never copied by this step. OpenClaw does not overwrite destination files or follow symlinked directories, and it preserves copied file modes.
34+
35+
## Run repository setup
36+
37+
If `.openclaw/worktree-setup.sh` exists in the source repository and is executable, OpenClaw runs it with the new worktree as its current directory. The script receives:
38+
39+
```text
40+
OPENCLAW_SOURCE_TREE_PATH=<source checkout>
41+
OPENCLAW_WORKTREE_PATH=<managed worktree>
42+
```
43+
44+
A nonzero exit aborts creation and removes the new worktree and branch. This is a repository-local contract; there is no OpenClaw config key for it.
45+
46+
## Snapshots, cleanup, and restore
47+
48+
Removal first creates a synthetic commit containing tracked and non-ignored untracked files, and pins it at `refs/openclaw/snapshots/<id>`. Gitignored files are excluded from the repository object database; files selected by `.worktreeinclude` are copied again during restore. If snapshot creation fails, removal stops. An explicit force delete can continue without a snapshot.
49+
50+
OpenClaw applies these cleanup rules:
51+
52+
- At run end, it removes a worktree only when `git status --porcelain` is empty and `git log HEAD --not --remotes --oneline` finds no unpushed commits. Otherwise it only releases the activity lock.
53+
- Hourly cleanup snapshots and removes unlocked Workboard- and session-owned worktrees idle for more than 7 days, even when dirty. Manual worktrees are never automatically removed.
54+
- Snapshot records remain restorable for 30 days. Cleanup then deletes the snapshot ref and registry row.
55+
- A live OpenClaw process lock and any foreign or unrecognized git worktree lock protect a worktree from garbage collection.
56+
57+
Restore recreates `openclaw/<name>` at the original pre-snapshot commit, then rebuilds the snapshot differences as unstaged modifications and untracked files. This keeps the synthetic snapshot commit out of branch history. The snapshot ref remains recorded as provenance.
58+
59+
## CLI
60+
61+
```bash
62+
openclaw worktrees list [--json]
63+
openclaw worktrees create <repo-root> [--name <name>] [--base-ref <ref>] [--json]
64+
openclaw worktrees remove <id> [--force] [--json]
65+
openclaw worktrees restore <id> [--json]
66+
openclaw worktrees gc [--json]
67+
```
68+
69+
The Control UI **Worktrees** page provides the same list, delete, restore, and cleanup actions.
70+
71+
## Gateway methods
72+
73+
| Method | Purpose |
74+
| ------------------- | --------------------------------------------- |
75+
| `worktrees.list` | List active and restorable worktree records. |
76+
| `worktrees.create` | Create or reuse a named managed worktree. |
77+
| `worktrees.remove` | Snapshot and remove a worktree. |
78+
| `worktrees.restore` | Restore a removed worktree from its snapshot. |
79+
| `worktrees.gc` | Run idle, orphan, and retention cleanup now. |
80+
81+
`worktrees.list` requires `operator.read`. Mutating methods require `operator.admin`.
82+
83+
## Workboard workspaces
84+
85+
The bundled [Workboard plugin](/plugins/workboard) can materialize a card workspace as a managed worktree:
86+
87+
```json
88+
{
89+
"kind": "worktree",
90+
"path": "/absolute/path/to/source-checkout",
91+
"branch": "main"
92+
}
93+
```
94+
95+
`path` identifies the source git checkout. `branch` is optional and becomes the base ref. When dispatch starts the card's worker, Workboard creates or reuses `wb-<card-id>`, runs the subagent with the managed checkout as its working directory, and writes the resolved path and branch back to the card. Gateway-triggered materialization requires `operator.admin`. On run end, Workboard removes the checkout only when it is provably lossless; dirty work or unpushed commits remain available.
96+
97+
Sandboxed embedded agents currently reject a task working directory outside their configured agent workspace. Use an unsandboxed target agent for Workboard managed-worktree cards until the sandbox runtime supports an additive checkout mount.

docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1194,6 +1194,7 @@
11941194
"concepts/context",
11951195
"concepts/context-engine",
11961196
"concepts/agent-workspace",
1197+
"concepts/managed-worktrees",
11971198
"concepts/soul",
11981199
"concepts/oauth",
11991200
"start/bootstrapping",

extensions/workboard/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { definePluginEntry } from "./api.js";
33
import { registerWorkboardGatewayMethods } from "./runtime-api.js";
44
import { registerWorkboardCommand } from "./src/command.js";
5+
import { cleanupWorkboardRunWorktree } from "./src/dispatcher.js";
56
import { WorkboardStore } from "./src/store.js";
67
import { createWorkboardTools } from "./src/tools.js";
78

@@ -13,6 +14,15 @@ export default definePluginEntry({
1314
const store = WorkboardStore.openSqlite();
1415
registerWorkboardGatewayMethods({ api, store });
1516
registerWorkboardCommand({ api, store });
17+
api.on("subagent_ended", async (event) => {
18+
if (event.runId) {
19+
await cleanupWorkboardRunWorktree({
20+
store,
21+
worktrees: api.runtime.worktrees,
22+
runId: event.runId,
23+
});
24+
}
25+
});
1626
api.registerCli(
1727
async ({ program }) => {
1828
const { registerWorkboardCli } = await import("./src/cli.js");

0 commit comments

Comments
 (0)