Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 22 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,28 @@ Current CLI output conventions:
- `json`: structured JSON output (`task.toJSON()` / array of `toJSON()` objects)
- `quiet`: suppresses non-error output

### Locking Model

Two lock layers now exist:

- **Project lock** (`<projectPath>/project.lock` via `PROJECT_LOCK_FILE`):
- Used in `Project.createTask` and `Project.deleteTask`.
- Purpose: serialize project-wide critical sections, especially next-ID allocation.
- API: `Project.lock()`, `Project.unlock(force=false)`, `Project.isLocked()`.
- `unlock()` is a no-op if missing; PID must match unless `force=true`.

- **Per-task lock** (`<task-file>.lock`):
- Used around all task-mutating operations:
- `write`, `create`, `addComment`, `updateTitle`, `updateDescription`, `updateLabels`, `deleteFile`.
- API: `Task.lock()`, `Task.unlock(force=false)`, `Task.isLocked()`.
- Same PID ownership semantics as project lock.

Implementation notes:

- Locks are acquired via atomic lockfile creation (`writeFile(..., { flag: "wx" })`) then PID write.
- Mutating flows release locks in `finally` using `unlock(true)` to avoid stale lockfiles on failure.
- Read paths (`view`, `list`, `search`, `Task.read`) remain lock-free.

### Task Identifier Formats

Commands accepting `<task-identifier>` support:
Expand Down
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@ All notable changes to this project will be documented in this file.

### Added

- _Nothing yet._
- Project-wide lockfile support via `PROJECT_LOCK_FILE` (`project.lock`).
- `Project.lock()`, `Project.unlock(force?)`, and `Project.isLocked()` APIs.
- Project lock coverage for `Project.createTask()` and `Project.deleteTask()` to serialize critical sections (including ID allocation).
- Per-task lockfile support using `<taskfile>.lock`.
- `Task.lock()`, `Task.unlock(force?)`, and `Task.isLocked()` APIs.
- Per-task lock wrapping for all task-mutating operations: `write`, `create`, `addComment`, `updateTitle`, `updateDescription`, `updateLabels`, and `deleteFile`.
- Unit tests for project/task lock lifecycle, PID ownership checks, forced unlock behavior, and lock cleanup.
- Reference docs updates for constants/model APIs to document locking semantics.

## [v0.9.0] - 2026-05-16

Expand Down
6 changes: 6 additions & 0 deletions docs/src/reference/constants.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,9 @@ Shared constants used across `taskdb`, with optional environment-variable overri

- Value: `32768`
- Used for grouped task directory computation (`Task.groupDir`).

### `PROJECT_LOCK_FILE: string`

- Value: `project.lock`
- Project-wide lockfile name used by `Project.lock` / `Project.unlock` / `Project.isLocked`.
- Resolved relative to project root (`<projectPath>/project.lock`).
36 changes: 36 additions & 0 deletions docs/src/reference/model-project.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,38 @@ Lists project subdirs treated as status directories (directories not in `NON_STA

Ensures `<status>/<groupDir>` exists. Returns `true` when the status directory is new.

## Locking

### `lockFilePath: string` (getter)

Absolute path to the project lockfile:

- `<projectPath>/<PROJECT_LOCK_FILE>`
- default filename is `project.lock`

### `lock(): Promise<void>`

Acquires the project-wide lock:

1. creates lockfile atomically (`wx`)
2. writes current `process.pid` into lockfile

Throws when lock already exists or filesystem operations fail.

### `unlock(force = false): Promise<void>`

Releases the project-wide lock.

Behavior:

- missing lockfile -> no-op
- `force = true` -> remove lockfile regardless of PID
- `force = false` -> remove only when lockfile PID matches current `process.pid`; otherwise throws

### `isLocked(): Promise<boolean>`

Returns whether the project lockfile exists.

## Task ID and symlink operations

### `maxTaskId(): Promise<number>`
Expand Down Expand Up @@ -107,6 +139,10 @@ Returns hydrated tasks sorted by id asc.

Creates task with next id, writes canonical file, and creates status symlink.

This operation is wrapped in a project-wide lock (`lock()` / `unlock(true)` in `finally`) to prevent duplicate ID allocation under concurrency.

### `deleteTask(task: Task): Promise<void>`

Removes all status symlinks and deletes canonical task file.

This operation is wrapped in a project-wide lock (`lock()` / `unlock(true)` in `finally`).
43 changes: 43 additions & 0 deletions docs/src/reference/model-task.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,35 @@ Absolute path: `<projectPath>/<ALL_TASKS_DIR>/<groupDir>/<filename>`.

Absolute group directory path for this task.

### `lockFilePath: string` (getter)

Absolute task lockfile path (`<taskfile>.lock`, colocated with canonical task file).

## Locking methods

### `lock(): Promise<void>`

Acquires per-task lock:

1. creates lockfile atomically (`wx`)
2. writes current `process.pid` into lockfile

Throws when lock already exists or filesystem operations fail.

### `unlock(force = false): Promise<void>`

Releases per-task lock.

Behavior:

- missing lockfile -> no-op
- `force = true` -> remove lockfile regardless of PID
- `force = false` -> remove only when lockfile PID matches current `process.pid`; otherwise throws

### `isLocked(): Promise<boolean>`

Returns whether the task lockfile exists.

## Instance methods

### `buildBody(): string`
Expand All @@ -83,6 +112,8 @@ Builds full file text via `gray-matter.stringify` (frontmatter + body).

Ensures group dir exists and writes file content.

This method acquires the per-task lock before writing.

### `create(): Promise<void>`

First-write lifecycle:
Expand All @@ -91,26 +122,38 @@ First-write lifecycle:
2. set `created` and `updated` (`makeRfc3339`)
3. write file

This method runs under the per-task lock.

### `addComment(comment: string): Promise<void>`

Appends comment row, bumps `updated`, writes.

This method runs under the per-task lock.

### `updateTitle(title: string): Promise<void>`

Updates title, bumps `updated`, writes.

This method runs under the per-task lock.

### `updateDescription(description: string): Promise<void>`

Updates description, bumps `updated`, writes.

This method runs under the per-task lock.

### `updateLabels(labels: string[]): Promise<void>`

Replaces labels, bumps `updated`, writes.

This method runs under the per-task lock.

### `deleteFile(): Promise<void>`

Deletes canonical task file only (does not remove status symlinks).

This method runs under the per-task lock.

### `toJSON(): object`

Returns plain object for CLI JSON output.
3 changes: 3 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,6 @@ export const COMMENTS_HEADER =
process.env.TASKDB_COMMENTS_HEADER ?? "## Task Comments";

export const MAX_FILES_PER_DIR = 32768;

/** Project-wide lockfile used for create/delete critical sections. */
export const PROJECT_LOCK_FILE = "project.lock";
156 changes: 125 additions & 31 deletions src/models/project.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { lstat, mkdir, readdir, symlink, unlink } from "node:fs/promises";
import {
lstat,
mkdir,
readdir,
readFile,
symlink,
unlink,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import {
ALL_TASKS_DIR,
COMPLETE_TASKS_DIR,
DEFAULT_STATUSES,
NON_STATUS_DIRS,
PROJECT_LOCK_FILE,
} from "../constants.ts";
import type { OutputFn } from "../types.ts";
import { toSlug } from "../utils/slug.ts";
Expand Down Expand Up @@ -109,6 +118,81 @@ export class Project {
return isNew;
}

// ── Locking ───────────────────────────────────────────────────────────────────

/**
* Absolute path to the project-scoped lockfile.
*
* The filename is controlled by {@link PROJECT_LOCK_FILE} and is resolved
* relative to this project's root directory.
*
* @returns Absolute filesystem path to `project.lock`.
*/
get lockFilePath(): string {
return join(this.path, PROJECT_LOCK_FILE);
}

/**
* Acquire the project-wide lock.
*
* This creates the lockfile with `wx` semantics (fail if it already exists),
* then writes the current process PID into the file.
*
* @throws When the lockfile already exists or when filesystem writes fail.
*/
async lock(): Promise<void> {
await writeFile(this.lockFilePath, "", { flag: "wx" });
await writeFile(this.lockFilePath, String(process.pid));
}

/**
* Release the project-wide lock.
*
* Behavior:
* - If the lockfile does not exist, this is a no-op.
* - If `force` is `true`, delete the lockfile regardless of owner PID.
* - Otherwise, only delete when the lockfile PID matches `process.pid`.
*
* @param force When `true`, bypasses PID ownership checks. Defaults to `false`.
* @throws When `force` is `false` and lockfile PID does not match current PID.
* @throws When filesystem operations fail for reasons other than missing file.
*/
async unlock(force: boolean = false): Promise<void> {
try {
if (force) {
await unlink(this.lockFilePath);
return;
}

const raw = await readFile(this.lockFilePath, "utf8");
const lockPid = parseInt(raw.trim(), 10);
if (lockPid !== process.pid) {
throw new Error(
`Cannot unlock project lock owned by PID ${lockPid}; current PID is ${process.pid}`,
);
}
await unlink(this.lockFilePath);
} catch (error) {
const e = error as NodeJS.ErrnoException;
if (e?.code === "ENOENT") return;
throw error;
}
}

/**
* Check whether the project lock is currently present.
*
* @returns `true` when the lockfile exists; otherwise `false`.
*/
async isLocked(): Promise<boolean> {
try {
await lstat(this.lockFilePath);
return true;
} catch {
return false;
}
}

// ── Max task ID ───────────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -419,33 +503,38 @@ export class Project {
labels: string[] = [],
warn?: OutputFn,
): Promise<Task> {
const nextId = (await this.maxTaskId()) + 1;
const slug = toSlug(title);

const task = new Task({
id: nextId,
slug,
title,
labels,
created: "",
updated: "",
description,
comments: [],
projectPath: this.path,
status,
});

await task.create(); // stamps created/updated and writes the file

const isNew = await this.ensureStatusDir(status, Task.groupDir(task.id));
if (isNew && warn) {
warn(
`Warning: "${status}" is a new status directory (not previously known).`,
);
}
await this.createStatusSymlink(status, task.id, task.filename);
await this.lock();
try {
const nextId = (await this.maxTaskId()) + 1;
const slug = toSlug(title);

const task = new Task({
id: nextId,
slug,
title,
labels,
created: "",
updated: "",
description,
comments: [],
projectPath: this.path,
status,
});

await task.create(); // stamps created/updated and writes the file

const isNew = await this.ensureStatusDir(status, Task.groupDir(task.id));
if (isNew && warn) {
warn(
`Warning: "${status}" is a new status directory (not previously known).`,
);
}
await this.createStatusSymlink(status, task.id, task.filename);

return task;
return task;
} finally {
await this.unlock(true);
}
}

/**
Expand All @@ -458,10 +547,15 @@ export class Project {
* @param task Task instance to delete.
*/
async deleteTask(task: Task): Promise<void> {
const statusDirs = await this.getStatusDirs();
for (const dir of statusDirs) {
await this.removeStatusSymlink(dir, task.id, task.filename);
await this.lock();
try {
const statusDirs = await this.getStatusDirs();
for (const dir of statusDirs) {
await this.removeStatusSymlink(dir, task.id, task.filename);
}
await task.deleteFile();
} finally {
await this.unlock(true);
}
await task.deleteFile();
}
}
Loading
Loading