Skip to content
This repository was archived by the owner on Apr 11, 2026. It is now read-only.

Commit e6c99bf

Browse files
z23ccclaude
andcommitted
feat(file-lock): add read/write/directory_add lock modes with compatibility matrix [fn-110.7]
- Migration v3: composite PK (file_path, task_id) enables shared locks - LockMode enum with compatibility: read+read, read+dir_add, dir_add+dir_add OK; write exclusive - CLI: `flowctl lock --mode read|write|directory_add` (default: write) - check_locks() returns Vec<LockEntry> for multi-holder visibility - Task spec format: `**Files (write):**` and `**Files (read):**` - Merge conflict recovery: abort → classify → rebase-first → retry or restart - git rerere.enabled true in orchestrator setup - Retry storm protection: >50% retry triggers fresh wave - 13 lock-specific tests passing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2d32338 commit e6c99bf

10 files changed

Lines changed: 342 additions & 97 deletions

File tree

flowctl/crates/flowctl-cli/src/commands/db_shim.rs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
use std::path::{Path, PathBuf};
1616

17-
pub use flowctl_db::{DbError, GapRow, ReindexResult};
17+
pub use flowctl_db::{DbError, GapRow, LockEntry, LockMode, ReindexResult};
1818
pub use flowctl_db::metrics::{
1919
Bottleneck, DoraMetrics, EpicStats, Summary, TokenBreakdown, WeeklyTrend,
2020
};
@@ -304,9 +304,9 @@ impl FileLockRepo {
304304
Self(conn.inner())
305305
}
306306

307-
pub fn acquire(&self, file_path: &str, task_id: &str) -> Result<(), DbError> {
307+
pub fn acquire(&self, file_path: &str, task_id: &str, mode: &LockMode) -> Result<(), DbError> {
308308
block_on(
309-
flowctl_db::FileLockRepo::new(self.0.clone()).acquire(file_path, task_id),
309+
flowctl_db::FileLockRepo::new(self.0.clone()).acquire(file_path, task_id, mode),
310310
)
311311
}
312312

@@ -322,17 +322,21 @@ impl FileLockRepo {
322322
block_on(flowctl_db::FileLockRepo::new(self.0.clone()).check(file_path))
323323
}
324324

325+
pub fn check_locks(&self, file_path: &str) -> Result<Vec<LockEntry>, DbError> {
326+
block_on(flowctl_db::FileLockRepo::new(self.0.clone()).check_locks(file_path))
327+
}
328+
325329
pub fn heartbeat(&self, task_id: &str) -> Result<u64, DbError> {
326330
block_on(flowctl_db::FileLockRepo::new(self.0.clone()).heartbeat(task_id))
327331
}
328332

329-
/// List all active locks: (file_path, task_id, locked_at).
330-
pub fn list_all(&self) -> Result<Vec<(String, String, String)>, DbError> {
333+
/// List all active locks: (file_path, task_id, locked_at, lock_mode).
334+
pub fn list_all(&self) -> Result<Vec<(String, String, String, String)>, DbError> {
331335
let inner = self.0.clone();
332336
block_on(async move {
333337
let mut rows = inner
334338
.query(
335-
"SELECT file_path, task_id, locked_at FROM file_locks ORDER BY file_path",
339+
"SELECT file_path, task_id, locked_at, lock_mode FROM file_locks ORDER BY file_path",
336340
(),
337341
)
338342
.await?;
@@ -342,6 +346,7 @@ impl FileLockRepo {
342346
row.get::<String>(0)?,
343347
row.get::<String>(1)?,
344348
row.get::<String>(2)?,
349+
row.get::<String>(3)?,
345350
));
346351
}
347352
Ok(out)

flowctl/crates/flowctl-cli/src/commands/query.rs

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -598,32 +598,31 @@ pub fn cmd_files(json_mode: bool, epic: String) {
598598
// ── Lock commands (Teams mode) ─────────────────────────────────────
599599

600600

601-
pub fn cmd_lock(json: bool, task: String, files: String) {
601+
pub fn cmd_lock(json: bool, task: String, files: String, mode: String) {
602602
let _flow_dir = ensure_flow_exists();
603603

604604
let file_list: Vec<&str> = files.split(',').map(str::trim).filter(|s| !s.is_empty()).collect();
605605
if file_list.is_empty() {
606606
error_exit("No files specified for locking.");
607607
}
608608

609+
let lock_mode = crate::commands::db_shim::LockMode::from_str(&mode)
610+
.unwrap_or_else(|e| error_exit(&format!("Invalid lock mode: {}", e)));
611+
609612
let conn = require_db();
610613
let repo = crate::commands::db_shim::FileLockRepo::new(&conn);
611614

612615
let mut locked = Vec::new();
613616
let mut already_locked = Vec::new();
614617

615618
for file in &file_list {
616-
match repo.acquire(file, &task) {
619+
match repo.acquire(file, &task, &lock_mode) {
617620
Ok(()) => locked.push(file.to_string()),
618-
Err(crate::commands::db_shim::DbError::Constraint(_)) => {
621+
Err(crate::commands::db_shim::DbError::Constraint(msg)) => {
619622
// Already locked — find out by whom
620-
let owner = repo.check(file).ok().flatten().unwrap_or_else(|| "unknown".to_string());
621-
if owner == task {
622-
// Re-locking own file is fine, treat as locked
623-
locked.push(file.to_string());
624-
} else {
625-
already_locked.push(json!({"file": file, "owner": owner}));
626-
}
623+
let entries = repo.check_locks(file).ok().unwrap_or_default();
624+
let owners: Vec<String> = entries.iter().map(|e| format!("{}({})", e.task_id, e.lock_mode.as_str())).collect();
625+
already_locked.push(json!({"file": file, "owners": owners, "detail": msg}));
627626
}
628627
Err(e) => {
629628
error_exit(&format!("Failed to lock {}: {}", file, e));
@@ -636,16 +635,17 @@ pub fn cmd_lock(json: bool, task: String, files: String) {
636635
"locked": locked,
637636
"already_locked": already_locked,
638637
"task": task,
638+
"mode": mode,
639639
}));
640640
} else {
641641
if !locked.is_empty() {
642-
println!("Locked {} file(s) for task {}", locked.len(), task);
642+
println!("Locked {} file(s) for task {} (mode: {})", locked.len(), task, mode);
643643
}
644644
for al in &already_locked {
645645
println!(
646-
"Already locked: {} (owner: {})",
646+
"Already locked: {} (owners: {})",
647647
al["file"].as_str().unwrap_or(""),
648-
al["owner"].as_str().unwrap_or("")
648+
al["owners"],
649649
);
650650
}
651651
}
@@ -703,19 +703,24 @@ pub fn cmd_lock_check(json: bool, file: Option<String>) {
703703

704704
match file {
705705
Some(f) => {
706-
match repo.check(&f) {
707-
Ok(Some(owner)) => {
706+
match repo.check_locks(&f) {
707+
Ok(entries) if !entries.is_empty() => {
708+
let lock_info: Vec<serde_json::Value> = entries.iter().map(|e| json!({
709+
"task_id": e.task_id,
710+
"mode": e.lock_mode.as_str(),
711+
})).collect();
708712
if json {
709713
json_output(json!({
710714
"file": f,
711715
"locked": true,
712-
"owner": owner,
716+
"locks": lock_info,
713717
}));
714718
} else {
715-
println!("{}: locked by {}", f, owner);
719+
let owners: Vec<String> = entries.iter().map(|e| format!("{}({})", e.task_id, e.lock_mode.as_str())).collect();
720+
println!("{}: locked by {}", f, owners.join(", "));
716721
}
717722
}
718-
Ok(None) => {
723+
Ok(_) => {
719724
if json {
720725
json_output(json!({
721726
"file": f,
@@ -736,10 +741,11 @@ pub fn cmd_lock_check(json: bool, file: Option<String>) {
736741
.unwrap_or_else(|e| { error_exit(&format!("Query failed: {}", e)); });
737742
let locks: Vec<serde_json::Value> = rows
738743
.into_iter()
739-
.map(|(file, task_id, locked_at)| json!({
744+
.map(|(file, task_id, locked_at, lock_mode)| json!({
740745
"file": file,
741746
"task_id": task_id,
742747
"locked_at": locked_at,
748+
"mode": lock_mode,
743749
}))
744750
.collect();
745751

@@ -754,9 +760,10 @@ pub fn cmd_lock_check(json: bool, file: Option<String>) {
754760
println!("Active file locks ({}):\n", locks.len());
755761
for l in &locks {
756762
println!(
757-
" {} → {} (since {})",
763+
" {} → {} [{}] (since {})",
758764
l["file"].as_str().unwrap_or(""),
759765
l["task_id"].as_str().unwrap_or(""),
766+
l["mode"].as_str().unwrap_or("write"),
760767
l["locked_at"].as_str().unwrap_or("")
761768
);
762769
}

flowctl/crates/flowctl-cli/src/main.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,9 @@ enum Commands {
302302
/// Comma-separated file paths to lock.
303303
#[arg(long)]
304304
files: String,
305+
/// Lock mode: read, write, or directory_add (default: write).
306+
#[arg(long, default_value = "write", value_parser = ["read", "write", "directory_add"])]
307+
mode: String,
305308
},
306309
/// Unlock files for a task (Teams mode).
307310
Unlock {
@@ -530,7 +533,7 @@ fn main() {
530533
Commands::List => query::cmd_list(json),
531534
Commands::Cat { id } => query::cmd_cat(id),
532535
Commands::Files { epic } => query::cmd_files(json, epic),
533-
Commands::Lock { task, files } => query::cmd_lock(json, task, files),
536+
Commands::Lock { task, files, mode } => query::cmd_lock(json, task, files, mode),
534537
Commands::Unlock { task, files, all } => query::cmd_unlock(json, task, files, all),
535538
Commands::LockCheck { file } => query::cmd_lock_check(json, file),
536539
Commands::Heartbeat { task } => query::cmd_heartbeat(json, task),

flowctl/crates/flowctl-db/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ pub use skill::{SkillEntry, SkillMatch, SkillRepo};
3636
pub use pool::{cleanup, open_async, open_memory_async, resolve_db_path, resolve_libsql_path, resolve_state_dir};
3737
pub use repo::{
3838
DepRepo, EpicRepo, EventRepo, EventRow, EvidenceRepo, FileLockRepo, FileOwnershipRepo,
39-
GapRepo, GapRow, PhaseProgressRepo, RuntimeRepo, TaskRepo,
39+
GapRepo, GapRow, LockEntry, LockMode, PhaseProgressRepo, RuntimeRepo, TaskRepo,
4040
max_epic_num, max_task_num,
4141
};
4242

flowctl/crates/flowctl-db/src/migration.rs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use libsql::Connection;
88
use crate::error::DbError;
99

1010
/// Current target schema version. Bump this when adding new migrations.
11-
const TARGET_VERSION: i64 = 2;
11+
const TARGET_VERSION: i64 = 3;
1212

1313
/// Ensure `_meta` table exists and run any pending migrations.
1414
pub async fn migrate(conn: &Connection) -> Result<(), DbError> {
@@ -26,6 +26,10 @@ pub async fn migrate(conn: &Connection) -> Result<(), DbError> {
2626
migrate_v2(conn).await?;
2727
}
2828

29+
if current < 3 {
30+
migrate_v3(conn).await?;
31+
}
32+
2933
// Update stored version to target.
3034
if current < TARGET_VERSION {
3135
set_version(conn, TARGET_VERSION).await?;
@@ -100,6 +104,50 @@ async fn migrate_v2(conn: &Connection) -> Result<(), DbError> {
100104
Ok(())
101105
}
102106

107+
/// Migration v3: Change file_locks PK to composite (file_path, task_id)
108+
/// and add `lock_mode TEXT DEFAULT 'write'`.
109+
///
110+
/// SQLite can't ALTER PRIMARY KEY, so we recreate the table.
111+
async fn migrate_v3(conn: &Connection) -> Result<(), DbError> {
112+
conn.execute(
113+
"CREATE TABLE IF NOT EXISTS file_locks_new (
114+
file_path TEXT NOT NULL,
115+
task_id TEXT NOT NULL,
116+
locked_at TEXT NOT NULL,
117+
holder_pid INTEGER,
118+
expires_at TEXT,
119+
lock_mode TEXT NOT NULL DEFAULT 'write',
120+
PRIMARY KEY (file_path, task_id)
121+
)",
122+
(),
123+
)
124+
.await
125+
.map_err(|e| DbError::Schema(format!("file_locks_new creation failed: {e}")))?;
126+
127+
// Copy existing data (add default lock_mode for old rows).
128+
conn.execute(
129+
"INSERT OR IGNORE INTO file_locks_new (file_path, task_id, locked_at, holder_pid, expires_at, lock_mode)
130+
SELECT file_path, task_id, locked_at, holder_pid, expires_at, COALESCE(lock_mode, 'write')
131+
FROM file_locks",
132+
(),
133+
)
134+
.await
135+
.ok(); // May fail if file_locks doesn't have lock_mode column yet — that's fine.
136+
137+
conn.execute("DROP TABLE IF EXISTS file_locks", ())
138+
.await
139+
.map_err(|e| DbError::Schema(format!("file_locks drop failed: {e}")))?;
140+
141+
conn.execute(
142+
"ALTER TABLE file_locks_new RENAME TO file_locks",
143+
(),
144+
)
145+
.await
146+
.map_err(|e| DbError::Schema(format!("file_locks rename failed: {e}")))?;
147+
148+
Ok(())
149+
}
150+
103151
#[cfg(test)]
104152
mod tests {
105153
use super::*;

0 commit comments

Comments
 (0)