Skip to content

Commit 62dc158

Browse files
fix(git): gitworktree is not creating
1 parent c9a0854 commit 62dc158

17 files changed

Lines changed: 1243 additions & 884 deletions

File tree

kernel/src/db/branches.rs

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// src/db/branches.rs
2+
//
3+
// Persistent record of every agent branch created by Stackbox.
4+
//
5+
// KEY DESIGN:
6+
// - One row per agent session (runbox_id + session_id + agent_kind).
7+
// - branch (stackbox/{short}/{slug}) is permanent — survives worktree removal.
8+
// - worktree_path is nullable — set on spawn, cleared when PTY exits.
9+
// - status: working → done → merged | deleted
10+
//
11+
// Schema DDL lives in db/schema.rs::migrate().
12+
13+
use rusqlite::{params, Result};
14+
use super::{Db, now_ms};
15+
16+
// ─────────────────────────────────────────────────────────────────────────────
17+
// Types
18+
// ─────────────────────────────────────────────────────────────────────────────
19+
20+
#[derive(Debug, Clone, serde::Serialize)]
21+
pub struct AgentBranch {
22+
pub id: String,
23+
pub runbox_id: String,
24+
pub session_id: String,
25+
pub agent_kind: String,
26+
/// e.g. "stackbox/a1b2c3d4/codex"
27+
pub branch: String,
28+
/// None once PTY exits; branch still lives.
29+
pub worktree_path: Option<String>,
30+
/// working | done | merged | deleted
31+
pub status: String,
32+
pub commit_count: i64,
33+
pub created_at: i64,
34+
pub updated_at: i64,
35+
/// None until user merges.
36+
pub merged_at: Option<i64>,
37+
}
38+
39+
fn row_to_branch(row: &rusqlite::Row<'_>) -> rusqlite::Result<AgentBranch> {
40+
Ok(AgentBranch {
41+
id: row.get(0)?,
42+
runbox_id: row.get(1)?,
43+
session_id: row.get(2)?,
44+
agent_kind: row.get(3)?,
45+
branch: row.get(4)?,
46+
worktree_path: row.get(5)?,
47+
status: row.get(6)?,
48+
commit_count: row.get(7)?,
49+
created_at: row.get(8)?,
50+
updated_at: row.get(9)?,
51+
merged_at: row.get(10)?,
52+
})
53+
}
54+
55+
// ─────────────────────────────────────────────────────────────────────────────
56+
// Write — lifecycle events
57+
// ─────────────────────────────────────────────────────────────────────────────
58+
59+
/// Called at PTY spawn: create the branch record with worktree_path set.
60+
pub fn record_branch_start(
61+
db: &Db,
62+
runbox_id: &str,
63+
session_id: &str,
64+
agent_kind: &str,
65+
branch: &str,
66+
worktree_path: &str,
67+
) -> Result<()> {
68+
let id = format!("{runbox_id}-{session_id}");
69+
let rb = runbox_id.to_string();
70+
let sid = session_id.to_string();
71+
let ak = agent_kind.to_string();
72+
let br = branch.to_string();
73+
let wt = worktree_path.to_string();
74+
let ts = now_ms();
75+
76+
db.write_async(move |conn| {
77+
let _ = conn.execute(
78+
"INSERT INTO agent_branches
79+
(id, runbox_id, session_id, agent_kind, branch, worktree_path,
80+
status, commit_count, created_at, updated_at)
81+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'working', 0, ?7, ?7)
82+
ON CONFLICT(id) DO UPDATE SET
83+
worktree_path = excluded.worktree_path,
84+
status = 'working',
85+
updated_at = excluded.updated_at",
86+
params![id, rb, sid, ak, br, wt, ts],
87+
);
88+
});
89+
Ok(())
90+
}
91+
92+
/// Called when PTY exits naturally or is killed.
93+
/// Clears worktree_path (directory removed) but keeps the branch.
94+
pub fn record_branch_done(db: &Db, runbox_id: &str, session_id: &str) -> Result<()> {
95+
let id = format!("{runbox_id}-{session_id}");
96+
let ts = now_ms();
97+
db.write_async(move |conn| {
98+
let _ = conn.execute(
99+
"UPDATE agent_branches SET
100+
worktree_path = NULL,
101+
status = CASE WHEN status = 'working' THEN 'done' ELSE status END,
102+
updated_at = ?1
103+
WHERE id = ?2",
104+
params![ts, id],
105+
);
106+
});
107+
Ok(())
108+
}
109+
110+
/// Called after user merges the branch into main.
111+
pub fn record_branch_merged(db: &Db, branch: &str) -> Result<()> {
112+
let br = branch.to_string();
113+
let ts = now_ms();
114+
db.write_async(move |conn| {
115+
let _ = conn.execute(
116+
"UPDATE agent_branches SET status='merged', merged_at=?1, updated_at=?1
117+
WHERE branch=?2",
118+
params![ts, br],
119+
);
120+
});
121+
Ok(())
122+
}
123+
124+
/// Called after user explicitly deletes a branch.
125+
pub fn record_branch_deleted(db: &Db, branch: &str) -> Result<()> {
126+
let br = branch.to_string();
127+
let ts = now_ms();
128+
db.write_async(move |conn| {
129+
let _ = conn.execute(
130+
"UPDATE agent_branches SET status='deleted', updated_at=?1 WHERE branch=?2",
131+
params![ts, br],
132+
);
133+
});
134+
Ok(())
135+
}
136+
137+
/// Increment the commit count for a branch (called after git_commit).
138+
pub fn increment_commit_count(db: &Db, branch: &str) -> Result<()> {
139+
let br = branch.to_string();
140+
let ts = now_ms();
141+
db.write_async(move |conn| {
142+
let _ = conn.execute(
143+
"UPDATE agent_branches SET commit_count=commit_count+1, updated_at=?1 WHERE branch=?2",
144+
params![ts, br],
145+
);
146+
});
147+
Ok(())
148+
}
149+
150+
// ─────────────────────────────────────────────────────────────────────────────
151+
// Read
152+
// ─────────────────────────────────────────────────────────────────────────────
153+
154+
/// All branch records for a runbox, newest first.
155+
pub fn list_for_runbox(db: &Db, runbox_id: &str) -> Result<Vec<AgentBranch>> {
156+
let conn = db.read();
157+
let mut stmt = conn.prepare(
158+
"SELECT id, runbox_id, session_id, agent_kind, branch, worktree_path,
159+
status, commit_count, created_at, updated_at, merged_at
160+
FROM agent_branches WHERE runbox_id=?1
161+
ORDER BY created_at DESC",
162+
)?;
163+
let rows = stmt.query_map(params![runbox_id], row_to_branch)?;
164+
Ok(rows.filter_map(|r| r.ok()).collect())
165+
}
166+
167+
/// All branches with an active worktree (worktree_path IS NOT NULL).
168+
/// Used by cleanup to detect orphans.
169+
pub fn list_active_worktrees(db: &Db) -> Vec<String> {
170+
let conn = db.read();
171+
let mut stmt = match conn.prepare(
172+
"SELECT worktree_path FROM agent_branches WHERE worktree_path IS NOT NULL",
173+
) {
174+
Ok(s) => s,
175+
Err(_) => return vec![],
176+
};
177+
stmt.query_map([], |row| row.get::<_, String>(0))
178+
.ok()
179+
.map(|rows| rows.filter_map(|r| r.ok()).collect())
180+
.unwrap_or_default()
181+
}
182+
183+
/// Get a single branch record by branch name.
184+
pub fn get_by_branch(db: &Db, branch: &str) -> Option<AgentBranch> {
185+
db.read()
186+
.query_row(
187+
"SELECT id, runbox_id, session_id, agent_kind, branch, worktree_path,
188+
status, commit_count, created_at, updated_at, merged_at
189+
FROM agent_branches WHERE branch=?1 ORDER BY created_at DESC LIMIT 1",
190+
params![branch],
191+
row_to_branch,
192+
)
193+
.ok()
194+
}
195+
196+
// ─────────────────────────────────────────────────────────────────────────────
197+
// Legacy compatibility — keep runbox_set_worktree working for callers not yet migrated
198+
// ─────────────────────────────────────────────────────────────────────────────
199+
200+
pub use super::runboxes::{
201+
runbox_set_branch,
202+
runbox_delete,
203+
};

kernel/src/db/schema.rs

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@
44
// Migrations are additive — never drop columns on existing installs.
55
//
66
// MIGRATION ORDER RULE: always run ALTER TABLE column additions BEFORE any
7-
// CREATE INDEX that references those columns. SQLite's CREATE TABLE IF NOT
8-
// EXISTS is a no-op on existing databases, so the index would reference a
9-
// non-existent column and panic at startup.
7+
// CREATE INDEX that references those columns.
108

119
use rusqlite::{Connection, Result};
1210

@@ -48,14 +46,9 @@ pub fn migrate(conn: &Connection) -> Result<()> {
4846
// Additive migrations — safe on existing databases
4947
conn.execute("ALTER TABLE runboxes ADD COLUMN worktree_path TEXT", []).ok();
5048

51-
// ── Per-agent worktree table (V2 schema) ─────────────────────────────────
52-
//
53-
// PRIMARY KEY = runbox_id (one row per runbox, not per agent-kind).
54-
// Tracks worktree path, branch, PR url, and lifecycle status.
55-
//
56-
// CRITICAL: all ALTER TABLE column additions for this table MUST come
57-
// before the CREATE INDEX on pr_url. On existing installs the CREATE TABLE
58-
// is a no-op, so the index would reference a missing column and crash.
49+
// ── Legacy agent_worktrees table — kept for backwards compat reads ─────────
50+
// New writes go to agent_branches. This table is not dropped to avoid
51+
// breaking existing installs that might still have data in it.
5952
conn.execute_batch("
6053
CREATE TABLE IF NOT EXISTS agent_worktrees (
6154
runbox_id TEXT PRIMARY KEY,
@@ -69,20 +62,79 @@ pub fn migrate(conn: &Connection) -> Result<()> {
6962
);
7063
")?;
7164

72-
// Additive column migrations — run BEFORE any index that touches these columns.
73-
// Each uses .ok() so they silently no-op on new installs (column already exists).
7465
conn.execute("ALTER TABLE agent_worktrees ADD COLUMN agent_kind TEXT NOT NULL DEFAULT ''", []).ok();
7566
conn.execute("ALTER TABLE agent_worktrees ADD COLUMN branch TEXT", []).ok();
7667
conn.execute("ALTER TABLE agent_worktrees ADD COLUMN pr_url TEXT", []).ok();
7768
conn.execute("ALTER TABLE agent_worktrees ADD COLUMN status TEXT NOT NULL DEFAULT 'working'", []).ok();
7869
conn.execute("ALTER TABLE agent_worktrees ADD COLUMN created_at INTEGER NOT NULL DEFAULT 0", []).ok();
7970
conn.execute("ALTER TABLE agent_worktrees ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0", []).ok();
8071

81-
// NOW safe to create the index — pr_url is guaranteed to exist.
8272
conn.execute_batch("
8373
CREATE INDEX IF NOT EXISTS idx_awt_pr_url ON agent_worktrees(pr_url);
8474
")?;
8575

76+
// ── Agent branches — the new persistent branch tracking table ─────────────
77+
//
78+
// Separates worktree lifetime (temporary) from branch lifetime (permanent).
79+
//
80+
// id = "{runbox_id}-{session_id}" — unique per session, not per runbox.
81+
// branch = "stackbox/{runbox_short}/{slug}" — survives worktree removal.
82+
// worktree_path = NULL once PTY exits.
83+
// status: working → done → merged | deleted
84+
conn.execute_batch("
85+
CREATE TABLE IF NOT EXISTS agent_branches (
86+
id TEXT PRIMARY KEY,
87+
runbox_id TEXT NOT NULL,
88+
session_id TEXT NOT NULL,
89+
agent_kind TEXT NOT NULL DEFAULT '',
90+
branch TEXT NOT NULL,
91+
worktree_path TEXT,
92+
status TEXT NOT NULL DEFAULT 'working',
93+
commit_count INTEGER NOT NULL DEFAULT 0,
94+
created_at INTEGER NOT NULL DEFAULT 0,
95+
updated_at INTEGER NOT NULL DEFAULT 0,
96+
merged_at INTEGER
97+
);
98+
")?;
99+
100+
// Additive column migrations for agent_branches (safe on existing installs)
101+
conn.execute("ALTER TABLE agent_branches ADD COLUMN session_id TEXT NOT NULL DEFAULT ''", []).ok();
102+
conn.execute("ALTER TABLE agent_branches ADD COLUMN agent_kind TEXT NOT NULL DEFAULT ''", []).ok();
103+
conn.execute("ALTER TABLE agent_branches ADD COLUMN branch TEXT NOT NULL DEFAULT ''", []).ok();
104+
conn.execute("ALTER TABLE agent_branches ADD COLUMN worktree_path TEXT", []).ok();
105+
conn.execute("ALTER TABLE agent_branches ADD COLUMN status TEXT NOT NULL DEFAULT 'working'", []).ok();
106+
conn.execute("ALTER TABLE agent_branches ADD COLUMN commit_count INTEGER NOT NULL DEFAULT 0", []).ok();
107+
conn.execute("ALTER TABLE agent_branches ADD COLUMN created_at INTEGER NOT NULL DEFAULT 0", []).ok();
108+
conn.execute("ALTER TABLE agent_branches ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0", []).ok();
109+
conn.execute("ALTER TABLE agent_branches ADD COLUMN merged_at INTEGER", []).ok();
110+
111+
conn.execute_batch("
112+
CREATE INDEX IF NOT EXISTS idx_ab_runbox ON agent_branches(runbox_id);
113+
CREATE INDEX IF NOT EXISTS idx_ab_session ON agent_branches(session_id);
114+
CREATE INDEX IF NOT EXISTS idx_ab_status ON agent_branches(status);
115+
CREATE INDEX IF NOT EXISTS idx_ab_branch ON agent_branches(branch);
116+
")?;
117+
118+
// Migrate existing agent_worktrees data into agent_branches (one-time, idempotent)
119+
conn.execute(
120+
"INSERT OR IGNORE INTO agent_branches
121+
(id, runbox_id, session_id, agent_kind, branch, worktree_path,
122+
status, created_at, updated_at)
123+
SELECT
124+
runbox_id,
125+
runbox_id,
126+
runbox_id,
127+
agent_kind,
128+
COALESCE(branch, 'stackbox/migrated'),
129+
NULL,
130+
CASE status WHEN 'merged' THEN 'merged' ELSE 'done' END,
131+
created_at,
132+
updated_at
133+
FROM agent_worktrees
134+
WHERE branch IS NOT NULL",
135+
[],
136+
).ok();
137+
86138
// ── Workspace events — the core append-only event log ─────────────────────
87139
conn.execute_batch("
88140
CREATE TABLE IF NOT EXISTS workspace_events (

0 commit comments

Comments
 (0)