feat(v0.4): add WriteMetaLocked wrapper backed by workspace write lock
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/warrenronsiek/ctask/internal/lockfile"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -61,3 +64,37 @@ func ReadMeta(path string) (*TaskMeta, error) {
|
||||
}
|
||||
return &meta, nil
|
||||
}
|
||||
|
||||
// MetadataLockTimeout is the maximum time WriteMetaLocked will wait to
|
||||
// acquire the workspace write lock before giving up (non-fatal).
|
||||
const MetadataLockTimeout = 2 * time.Second
|
||||
|
||||
// MetadataStaleLockAfter is the age at which a write.lock is considered
|
||||
// abandoned and gets removed.
|
||||
const MetadataStaleLockAfter = 10 * time.Second
|
||||
|
||||
// WriteMetaLocked writes meta to path while holding the workspace's write
|
||||
// lock (<wsDir>/.ctask/write.lock). On lock-acquisition timeout, the write
|
||||
// is SKIPPED and a warning is printed to stderr — per spec, metadata lock
|
||||
// failures must never block or error out.
|
||||
//
|
||||
// Used by resume, open, and archive (session-lifecycle metadata writes).
|
||||
// NOT used by Create (workspace does not exist yet) or by test helpers.
|
||||
func WriteMetaLocked(path string, meta *TaskMeta) error {
|
||||
return writeMetaLockedWithTimeout(path, meta, MetadataLockTimeout)
|
||||
}
|
||||
|
||||
// writeMetaLockedWithTimeout is the test-overridable form of WriteMetaLocked.
|
||||
func writeMetaLockedWithTimeout(path string, meta *TaskMeta, timeout time.Duration) error {
|
||||
wsDir := filepath.Dir(path)
|
||||
lockPath := filepath.Join(wsDir, ".ctask", "write.lock")
|
||||
|
||||
skipped, err := lockfile.WithLock(lockPath, timeout, MetadataStaleLockAfter, func() error {
|
||||
return WriteMeta(path, meta)
|
||||
})
|
||||
if skipped {
|
||||
fmt.Fprintf(os.Stderr, "[ctask] Warning: could not acquire metadata lock, skipping write to %s\n", path)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -8,6 +8,61 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWriteMetaLockedHappyPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
metaPath := filepath.Join(dir, "task.yaml")
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
meta := &TaskMeta{
|
||||
ID: "1", Slug: "s", Title: "t",
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
Status: "active", Category: "general", Type: "task",
|
||||
Mode: "local", Agent: "claude",
|
||||
WorkspacePath: dir,
|
||||
}
|
||||
|
||||
if err := WriteMetaLocked(metaPath, meta); err != nil {
|
||||
t.Fatalf("WriteMetaLocked: %v", err)
|
||||
}
|
||||
|
||||
got, err := ReadMeta(metaPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadMeta: %v", err)
|
||||
}
|
||||
if got.Slug != "s" {
|
||||
t.Errorf("Slug: got %q", got.Slug)
|
||||
}
|
||||
|
||||
lockPath := filepath.Join(dir, ".ctask", "write.lock")
|
||||
if _, err := os.Stat(lockPath); !os.IsNotExist(err) {
|
||||
t.Errorf("lock should be released, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMetaLockedSkipsOnBusyLock(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
metaPath := filepath.Join(dir, "task.yaml")
|
||||
ctaskDir := filepath.Join(dir, ".ctask")
|
||||
os.MkdirAll(ctaskDir, 0755)
|
||||
lockPath := filepath.Join(ctaskDir, "write.lock")
|
||||
|
||||
if err := os.WriteFile(lockPath, nil, 0644); err != nil {
|
||||
t.Fatalf("plant lock: %v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
os.Chtimes(lockPath, now, now)
|
||||
|
||||
meta := &TaskMeta{Slug: "s"}
|
||||
err := writeMetaLockedWithTimeout(metaPath, meta, 200*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteMetaLocked should not fail on busy lock: %v", err)
|
||||
}
|
||||
|
||||
if _, statErr := os.Stat(metaPath); !os.IsNotExist(statErr) {
|
||||
t.Error("task.yaml should not exist when lock is busy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAndReadMeta(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "task.yaml")
|
||||
|
||||
Reference in New Issue
Block a user