Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6a534af
feat: add Git workspace checkpoints
senamakel Aug 17, 2026
b2a8e97
fix(checkpoint): handle missing checkpoint directory gracefully
senamakel Aug 17, 2026
d32cec6
fix(checkpoint): handle missing checkpoint file gracefully
senamakel Aug 17, 2026
12add74
fix(harness): handle missing checkpoint file gracefully
senamakel Aug 17, 2026
c8a746f
fix(types): remove unused import of `std::collections::HashMap`
senamakel Aug 17, 2026
08650ff
fix(runtime): handle missing builder state in build method
senamakel Aug 17, 2026
0272f8d
fix(harness): handle missing checkpoint file gracefully
senamakel Aug 17, 2026
280f280
chore(harness): remove unused `use` statement for `std::fs`
senamakel Aug 17, 2026
ae7fc1f
fix(harness): handle missing checkpoint file gracefully
senamakel Aug 17, 2026
147efe1
fix(harness): handle missing checkpoint file gracefully
senamakel Aug 17, 2026
f4912a1
docs(sandbox): clarify sandbox lifecycle and resource cleanup
senamakel Aug 17, 2026
93bd107
docs(spec): clarify workspace layout for runtime environments
senamakel Aug 17, 2026
84e545a
chore(docs): update workspace layout specification
senamakel Aug 17, 2026
2e7042b
fix(test): add assertion message to workspace git toggle test
senamakel Aug 17, 2026
2715ecd
fix(harness): handle missing checkpoint file gracefully
senamakel Aug 17, 2026
3feab69
docs(spec): clarify workspace layout for runtime environments
senamakel Aug 17, 2026
d90c850
fix(harness): handle missing checkpoint file gracefully
senamakel Aug 17, 2026
ac6d5c0
fix(harness): restore checkpoint after test failure
senamakel Aug 17, 2026
ec8076c
fix(harness): handle missing checkpoint file gracefully
senamakel Aug 17, 2026
dd1b5ba
fix(checkpoint): handle missing checkpoint file gracefully
senamakel Aug 17, 2026
11e66d5
fix(harness): restore checkpoint file creation on first use
senamakel Aug 17, 2026
ba1b9e9
fix(harness): remove unused import in build.rs
senamakel Aug 17, 2026
869ef75
docs(spec): clarify workspace layout for runtime environments
senamakel Aug 17, 2026
2269367
refactor(checkpoint): simplify conditional formatting
senamakel Aug 17, 2026
fc10646
fix: avoid checkpoint initialization deadlock
senamakel Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions docs/spec/runtime/orchestration/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,15 @@ Work is checkpointed as it happens, so a run's intermediate states survive.
sibling runtime omits the shell from its write set, which means shell-written
files are committed only incidentally by the next tool write. Do not repeat
that.
- The history lives in an **out-of-band git directory**, never `.git`. A
conventional one would make the product repository treat every company
workspace as an embedded repository.
- The history lives in an **out-of-band git directory** (`workspace.git/`). The
working tree has only Git's `.git` pointer file, so ordinary Git commands work
there without putting the object database among agent-authored files.
- **The pointer file is never trusted.** An agent can plant a `.git` of its own
(it owns its workspace), so the checkpointer always runs its Git commands
against an explicit `--git-dir` for the out-of-band path, rewrites any planted
pointer back to it, and isolates those commands from inherited config and
hooks (`GIT_CONFIG_NOSYSTEM`, `GIT_CONFIG_GLOBAL`, `core.hooksPath=`): a
checkpoint commit must not execute code an agent wrote (CWE-94).
- An unchanged tree is a **no-op, not an error**.
- **A failed checkpoint never fails the tool that succeeded.** And precisely
because it swallows failures silently, the commit lock from
Expand All @@ -151,6 +157,9 @@ Work is checkpointed as it happens, so a run's intermediate states survive.
- Generated artifacts stay in the workspace. Do not write them into a source
directory.

This behavior is opt-in through `[workspace].git_enabled = true`; disabled is
the compatibility default.

---

## Declaring cost before spending it
Expand Down
37 changes: 37 additions & 0 deletions docs/spec/runtime/workspace-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,13 +245,50 @@ The `[workspace]` section of `config.toml` (in the data dir) tunes the lifecycle

```toml
[workspace]
git_enabled = false # opt in to automatic Git checkpoints per agent workspace
clear_tmp_on_startup = true # default; set false to preserve tmp/ across restarts
storage_quota_gb = 5 # soft whole-workspace quota; omit or <= 0 = unlimited
tmp_quota_gb = 1 # soft tmp/ quota; omit or <= 0 = unlimited
tree_quota_gb = 2 # HARD cap on the note tree's binary payloads (#553)
max_blob_mb = 64 # HARD cap on ONE binary write (default 64)
```

When `git_enabled = true`, every private agent filesystem workspace under
`harness/<company>/<agent>/workspace` is initialized as a Git working tree.
OpenCompany creates a baseline commit and then commits changed files after each
tool call, including shell commands, so redirects and generated files are not
missed. Calls that leave the tree unchanged add no commit. Git history lives in
the sibling `workspace.git/` directory; the working tree contains only Git's
small `.git` pointer file, which keeps ordinary Git commands usable from inside
the workspace. The pointer file is write-only scaffolding: a `.git` an agent
plants is ignored for the checkpointer's own commands (which pass an explicit
`--git-dir`) and rewritten to name the real repository, and checkpoint Git
invocations are isolated from inherited config and hooks. Checkpoint failures
are warned about but never replace a tool's successful result. The setting
defaults to `false`, preserving existing workspaces unless an operator
explicitly opts in. See [sandbox.md](orchestration/sandbox.md#checkpointing) for
the security rationale.

**Checkpoint history retains everything a workspace ever contained.** Because the
checkpoint repository records the workspace tree state at each tool call, a file
an agent writes and later deletes survives in `workspace.git/` history long after
it leaves the working tree. Content an agent downloads, generates, or is handed
by the operator can therefore accumulate there with no size bound — there are no
ignore rules and `[workspace]` quotas do not apply to Git objects. Treat the
feature as suitable for workspaces whose contents are not secrets, or purge
history deliberately on the same schedule such data would otherwise be rotated.
The supported purge path is to drop the checkpoint history from the host,
because every checkpoint is committed to the `checkpoints` branch and remains
reachable from it even after data leaves the working tree — reflog expiry and
`gc` alone do **not** remove it. The clean purge is to delete the whole
`<workspace>.git/` directory, after which the next tool call re-initializes the
checkpointer from a fresh baseline. To keep a workspace but drop its prior
history without fully resetting, delete the branch ref first so its commits
become unreachable, then garbage-collect them: `git --git-dir=<workspace>.git update-ref -d refs/heads/checkpoints` followed by `git --git-dir=<workspace>.git gc --prune=now`. Either way, `<workspace>.git` must be removed or the `checkpoints` ref must be deleted before `gc --prune=now` will actually free the blobs. Deleting
`<workspace>.git/` resets a workspace to its next checkpoint baseline. No
retention is automatic: the checkpointer never rewrites history and leaves the
repository alone between checkpoints.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
**The first two quotas are soft/advisory in the binary.** At boot `serve`
measures the workspace (and `tmp/`) and emits an operator-visible
`tracing::warn` when either exceeds its configured quota. **Hard enforcement**
Expand Down
1 change: 1 addition & 0 deletions examples/live_company_turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ async fn main() -> anyhow::Result<()> {
store: Arc::new(FsCompanyStore::new(dir.path())),
meter: Some(meter.clone()),
workspace_root: dir.path().join("harness"),
workspace_git_enabled: false,
// Issue #775: the shell audit sink hangs off the data root as
// `companies/<slug>/audit/<agent>/`, deliberately a sibling of the
// workspace tree rather than inside it.
Expand Down
15 changes: 15 additions & 0 deletions gitbooks/developers/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ live cognition is gated.

The CLI mirrors several of these as flags — see the [CLI reference](cli.md).

### Agent workspace checkpoints

Automatic Git history for each agent's private filesystem workspace is opt-in
in the data directory's `config.toml`:

```toml
[workspace]
git_enabled = true
```

OpenCompany creates a baseline commit, then checkpoints changes after tool
calls, including shell commands. Calls that change nothing create no commit.
The Git object database is stored beside the working tree rather than among the
agent's files. The default is `false`.

### Bind precedence

Where a flag and a variable name the same thing, the flag wins. For the
Expand Down
14 changes: 13 additions & 1 deletion src/app/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,9 @@ pub struct ConfigFile {
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct WorkspaceSection {
/// Turn each agent's private filesystem workspace into a Git repository and
/// checkpoint changes after tool calls. Default: false.
pub git_enabled: Option<bool>,
/// Empty the ephemeral `tmp/` scratch directory on startup. Default: true.
pub clear_tmp_on_startup: Option<bool>,
/// Soft quota on the whole workspace, in gibibytes. Absent or `<= 0` means
Expand Down Expand Up @@ -361,6 +364,7 @@ impl WorkspaceSection {
/// Resolves the section against its defaults.
pub fn resolve(&self) -> WorkspaceConfig {
WorkspaceConfig {
git_enabled: self.git_enabled.unwrap_or(false),
clear_tmp_on_startup: self.clear_tmp_on_startup.unwrap_or(true),
storage_quota_bytes: gib_to_bytes(self.storage_quota_gb),
tmp_quota_bytes: gib_to_bytes(self.tmp_quota_gb),
Expand All @@ -386,6 +390,8 @@ fn gib_to_bytes(gb: Option<f64>) -> Option<u64> {
/// Resolved `[workspace]` configuration.
#[derive(Clone, Debug)]
pub struct WorkspaceConfig {
/// Whether private agent workspaces keep automatic Git checkpoints.
pub git_enabled: bool,
/// Whether the ephemeral `tmp/` scratch is cleared on startup.
pub clear_tmp_on_startup: bool,
/// Soft whole-workspace quota in bytes; `None` is unlimited.
Expand All @@ -399,6 +405,7 @@ pub struct WorkspaceConfig {
impl Default for WorkspaceConfig {
fn default() -> Self {
Self {
git_enabled: false,
clear_tmp_on_startup: true,
storage_quota_bytes: None,
tmp_quota_bytes: None,
Expand Down Expand Up @@ -1064,17 +1071,20 @@ mod test {
let env = MapEnv::default();
let file = ConfigFile {
workspace: WorkspaceSection {
git_enabled: Some(true),
clear_tmp_on_startup: Some(false),
..WorkspaceSection::default()
},
..ConfigFile::default()
};
let (cfg, _) = resolve(&env, Some(&file), &default_manifest()).unwrap();
assert!(!cfg.workspace.clear_tmp_on_startup);
assert!(cfg.workspace.git_enabled);

// An absent `[workspace]` section resolves to the default (clear on boot).
let (cfg, _) = resolve(&env, None, &default_manifest()).unwrap();
assert!(cfg.workspace.clear_tmp_on_startup);
assert!(!cfg.workspace.git_enabled);
}

#[test]
Expand Down Expand Up @@ -1611,12 +1621,14 @@ mod test {
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(CONFIG_FILE),
"[workspace]\nclear_tmp_on_startup = false\n",
"[workspace]\ngit_enabled = true\nclear_tmp_on_startup = false\n",
)
.unwrap();
let file = ConfigFile::load(&dir).unwrap().unwrap();
assert_eq!(file.workspace.clear_tmp_on_startup, Some(false));
assert_eq!(file.workspace.git_enabled, Some(true));
assert!(!file.workspace.resolve().clear_tmp_on_startup);
assert!(file.workspace.resolve().git_enabled);
std::fs::remove_dir_all(&dir).ok();
}

Expand Down
11 changes: 11 additions & 0 deletions src/app/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ pub struct AppConfig {
/// each company's builder so the store-level quota decorator is configured
/// from one place rather than re-read per company.
pub workspace_quota: crate::runtime::WorkspaceQuota,
/// Whether each agent's private filesystem workspace is Git-backed and
/// automatically checkpointed after tool calls.
pub workspace_git_enabled: bool,
/// Tenant namespace for shared-single-DB deployments
/// (`OPENCOMPANY_TENANT_ID`). When set, provisioned/booted company ids are
/// prefixed with `<tenant>--` via [`Self::namespaced_company_id`] so many
Expand Down Expand Up @@ -133,6 +136,7 @@ impl Default for AppConfig {
max_companies: None,
max_companies_per_tenant: None,
workspace_quota: crate::runtime::WorkspaceQuota::default(),
workspace_git_enabled: false,
webhook: None,
tenant_namespace: None,
admin_email: None,
Expand Down Expand Up @@ -1039,6 +1043,13 @@ mod tests {
assert_eq!(AppConfig::default().bind, "127.0.0.1:8080");
}

/// Automatic Git checkpoints in agent workspaces are opt-in: the host
/// default is off, preserving the pre-checkpoint behavior exactly.
#[test]
fn workspace_git_checkpoints_default_off() {
assert!(!AppConfig::default().workspace_git_enabled);
}

fn bound_to(bind: &str) -> AppConfig {
AppConfig {
bind: bind.to_string(),
Expand Down
2 changes: 2 additions & 0 deletions src/bin/opencompany.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ fn company_builder(
.with_default_mcp_servers(state.config().default_mcp_servers.clone())
.with_host_base_url(state.config().host_base_url())
.with_workspace_quota(state.config().workspace_quota)
.with_workspace_git_enabled(state.config().workspace_git_enabled)
// Issue #752: the backend that serves this host's secrets, which the
// repository-credential gates refuse on. Threaded through `company_builder`
// rather than read from the environment further down, so a rebuild gets the
Expand Down Expand Up @@ -1166,6 +1167,7 @@ async fn async_main() -> Result<()> {
// the same `[workspace]` section as the soft disk quotas above
// and handed to every company's builder below.
workspace_quota: workspace_cfg.quota,
workspace_git_enabled: workspace_cfg.git_enabled,
..AppConfig::default()
})
.with_cors(opencompany::server::cors::CorsConfig::from_env()?)
Expand Down
1 change: 1 addition & 0 deletions src/company/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2309,6 +2309,7 @@ mod tests {
store: runtime.store.clone(),
meter,
workspace_root: std::env::temp_dir(),
workspace_git_enabled: false,
audit_root: std::env::temp_dir(),
model_override: None,
tasks: None,
Expand Down
11 changes: 11 additions & 0 deletions src/harness/brain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2911,6 +2911,7 @@ description = "Runs Acme."
store: Arc::new(FsCompanyStore::new(dir)),
meter: Some(Arc::new(FsOps::new(dir))),
workspace_root: dir.to_path_buf(),
workspace_git_enabled: false,
audit_root: dir.to_path_buf(),
model_override: None,
tasks: None,
Expand Down Expand Up @@ -3082,6 +3083,7 @@ description = "Builds it."
store: Arc::new(FsCompanyStore::new(dir)),
meter: Some(Arc::new(FsOps::new(dir))),
workspace_root: dir.to_path_buf(),
workspace_git_enabled: false,
audit_root: dir.to_path_buf(),
model_override: None,
tasks: Some(tasks.clone()),
Expand Down Expand Up @@ -3202,6 +3204,7 @@ members = ["engineer"]
store: Arc::new(FsCompanyStore::new(dir)),
meter: Some(Arc::new(FsOps::new(dir))),
workspace_root: dir.to_path_buf(),
workspace_git_enabled: false,
audit_root: dir.to_path_buf(),
model_override: None,
tasks: Some(ops.clone()),
Expand Down Expand Up @@ -5110,6 +5113,7 @@ members = ["engineer"]
store: Arc::new(FsCompanyStore::new(dir)),
meter: Some(Arc::new(FsOps::new(dir))),
workspace_root: dir.to_path_buf(),
workspace_git_enabled: false,
audit_root: dir.to_path_buf(),
model_override: None,
tasks: Some(tasks.clone()),
Expand Down Expand Up @@ -6066,6 +6070,7 @@ members = ["eng1", "eng2"]
store: Arc::new(FsCompanyStore::new(dir.path())),
meter: None,
workspace_root: dir.path().to_path_buf(),
workspace_git_enabled: false,
audit_root: dir.path().to_path_buf(),
model_override: None,
tasks: None,
Expand Down Expand Up @@ -6210,6 +6215,7 @@ members = ["eng1", "eng2"]
store: Arc::new(FsCompanyStore::new(dir.path())),
meter: None,
workspace_root: dir.path().to_path_buf(),
workspace_git_enabled: false,
audit_root: dir.path().to_path_buf(),
model_override: None,
tasks: None,
Expand Down Expand Up @@ -6300,6 +6306,7 @@ members = ["eng1", "eng2"]
store: Arc::new(FsCompanyStore::new(dir)),
meter: None,
workspace_root: dir.to_path_buf(),
workspace_git_enabled: false,
audit_root: dir.to_path_buf(),
model_override: None,
tasks: None,
Expand Down Expand Up @@ -6630,6 +6637,7 @@ members = ["eng1", "eng2"]
store: Arc::new(FsCompanyStore::new(dir)),
meter: None,
workspace_root: dir.to_path_buf(),
workspace_git_enabled: false,
audit_root: dir.to_path_buf(),
model_override: None,
tasks: None,
Expand Down Expand Up @@ -7131,6 +7139,7 @@ members = ["eng1", "eng2"]
store: Arc::new(FsCompanyStore::new(dir)),
meter: None,
workspace_root: dir.to_path_buf(),
workspace_git_enabled: false,
audit_root: dir.to_path_buf(),
model_override: Some("stub-model".to_string()),
tasks: Some(Arc::new(FsOps::new(dir))),
Expand Down Expand Up @@ -7451,6 +7460,7 @@ members = ["eng1", "eng2"]
store: Arc::new(FsCompanyStore::new(dir)),
meter: None,
workspace_root: dir.to_path_buf(),
workspace_git_enabled: false,
audit_root: dir.to_path_buf(),
model_override: None,
tasks: Some(tasks.clone()),
Expand Down Expand Up @@ -7853,6 +7863,7 @@ members = ["eng1", "eng2"]
store: Arc::new(FsCompanyStore::new(dir)),
meter: None,
workspace_root: dir.to_path_buf(),
workspace_git_enabled: false,
audit_root: dir.to_path_buf(),
model_override: None,
tasks: Some(tasks),
Expand Down
Loading
Loading