Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d636352
fix(core): reclaim stale metadata locks and report contention as busy
BrainerVirus Oct 3, 2026
d6ece39
test(core): accept busy under load in the lock contention stress test
BrainerVirus Oct 3, 2026
61ffbc9
fix(core): harden lock identity, doctor clearing, and wait budget
BrainerVirus Oct 3, 2026
33d8426
fix(core): reclaim pre-reboot locks and warn on long-blocking unverif…
BrainerVirus Oct 3, 2026
8d3e7b5
test(core): run the plain-hostname container lock test only where loc…
BrainerVirus Oct 3, 2026
591f521
Merge origin/main into bugfix/store-lock-reclaim
BrainerVirus Oct 3, 2026
9476759
fix(core): bound recovery copies and add workit gc
BrainerVirus Oct 3, 2026
432470f
fix(core): keep gc off closed tasks and make its dry run read-only
BrainerVirus Oct 3, 2026
8581807
Merge branch 'main' into bugfix/store-lock-reclaim
BrainerVirus Oct 3, 2026
3df24da
test(core): satisfy stricter oxlint rules in the lock tests
BrainerVirus Oct 3, 2026
ec091f5
Merge bugfix/store-lock-reclaim into bugfix/bounded-recovery
BrainerVirus Oct 3, 2026
59514aa
fix(core): satisfy stricter oxlint rules in gc code and tests
BrainerVirus Oct 3, 2026
37eb9dc
fix(core): retry transient Windows sharing errors on snapshot read an…
BrainerVirus Oct 3, 2026
3fef303
Merge bugfix/store-lock-reclaim into bugfix/bounded-recovery
BrainerVirus Oct 3, 2026
7cf245e
Merge origin/main (squashed #150) into bugfix/bounded-recovery
BrainerVirus Oct 3, 2026
0ff481f
test(core): assert the contention invariant, not runner speed
BrainerVirus Oct 3, 2026
20b2968
Merge branch 'main' into bugfix/bounded-recovery
BrainerVirus Oct 3, 2026
a5a6de6
fix(core): keep Windows sharing violations under contention retryable
BrainerVirus Oct 3, 2026
da27be5
Merge origin/main (CLI router #163) into bugfix/bounded-recovery
BrainerVirus Oct 3, 2026
30db16f
fix(core): report a denied lock create without a holder as storage_error
BrainerVirus Oct 3, 2026
707b820
fix(core): tell a pending-delete lock from a permission denial by pro…
BrainerVirus Oct 3, 2026
01fe457
test(core): give the recovery gc tests a Windows-sized timeout
BrainerVirus Oct 3, 2026
1124bae
Merge branch 'main' into bugfix/bounded-recovery
BrainerVirus Oct 3, 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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ process around it.
`gh`/`glab` CLI identity, not separate GitHub/GitLab token files.
Doctor also fails when OpenCode's frozen `@latest` package cache lags the
published `workit-opencode` (delete the cache dir and restart OpenCode).
- Task state lives under `.workit/` in the session directory, even when that directory is not a Git repository; never edit it directly. A stale `metadata.lock` (dead/reused pid in the same host, pid namespace and boot; anything else only past its TTL) is reclaimed by the next write or cleared with `workit doctor --fix-lock` (`--force --yes` for an unverifiable lock); contention with a live holder returns retryable `busy`, and `recovery_required` is reserved for genuine state damage. Git/hosting actions accept `cwd` for an action-time target checkout without prior attachment; the coordinator task keeps the writer, while a conflicting writer in the target checkout blocks mutation. A managed action holds the target metadata lock through settlement so another writer cannot acquire during the effect. Use the eight shared operation families (`workit_task`, `workit_policy`, `workit_evidence`, `workit_finding`, `workit_decision`, `workit_worker`, `workit_writer`, `workit_state`) with closed `action` enums. CLI surface is `workit <family> <action>` (hyphenated actions). There are no `workit flow` aliases.
- Task state lives under `.workit/` in the session directory, even when that directory is not a Git repository; never edit it directly. A stale `metadata.lock` (dead/reused pid in the same host, pid namespace and boot; anything else only past its TTL) is reclaimed by the next write or cleared with `workit doctor --fix-lock` (`--force --yes` for an unverifiable lock); contention with a live holder returns retryable `busy`, and `recovery_required` is reserved for genuine state damage. `.workit/recovery/` keeps at most three copies per record; `workit gc` prunes older leftovers. `state.recover` is not advertised to hosts (no shipped host supplies native recovery authority); the engine path remains for embedders that do. Git/hosting actions accept `cwd` for an action-time target checkout without prior attachment; the coordinator task keeps the writer, while a conflicting writer in the target checkout blocks mutation. A managed action holds the target metadata lock through settlement so another writer cannot acquire during the effect. Use the eight shared operation families (`workit_task`, `workit_policy`, `workit_evidence`, `workit_finding`, `workit_decision`, `workit_worker`, `workit_writer`, `workit_state`) with closed `action` enums. CLI surface is `workit <family> <action>` (hyphenated actions). There are no `workit flow` aliases.
- `task.list` defaults to a compact, 20-item active/paused projection; bounded closed/all history is opt-in with `status` and `limit`, and `task.inspect` defaults to `summary`. Closed views evaluate their captured closure candidate and carry no current writer lease.
- Workit tracking is optional: direct investigation, questions, non-Git work, and routine reversible edits need no task, policy assessment, or writer calls. Start one compact task only when handoff, dependencies, concurrent actors, or meaningful decisions make continuity useful; assess or reassess when the relevant rules/evidence require it. `policy.preview` stays read-only.
- Routine authorized branch/commit work chooses native host Git/shell tools from the outset when managed coordination or reconciliation is unnecessary. Resolve target conventions; never switch paths after a denial or uncertain managed effect to evade safeguards. A local-commit endpoint creates no PR-readiness or task-closure ceremony. The distributed bootstrap carries this routing guidance.
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- `.workit/recovery/` no longer grows without bound: each task or workspace
record keeps its newest three recovery copies. `workit gc` (`--dry-run`,
`--json`) prunes copies left by older versions, removes stale temp files, and
collapses duplicate stored candidates in paused tasks (closed tasks are never
rewritten); `--dry-run` is read-only.
- `state.recover` is no longer advertised in host tool schemas or the CLI: it
requires native recovery authority that no shipped host supplies, so it could
only return `permission_denied`.
- A `.workit/metadata.lock` left by a dead process (or a reused pid, or a
foreign-host/namespace lock past its TTL) no longer bricks the store: the next
write reclaims it. Locks carry the pid namespace and boot id so a container
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ workit launch pi --auto-upgrade -- # update before starting Pi
workit doctor # offline installation health report (--json for machines)
workit doctor --fix-lock # clear a stale .workit metadata lock (WORKFLOW_WORKSPACE_ROOT or cwd)
workit doctor --fix-lock --force [--yes] # clear a lock whose owner cannot be verified
workit gc [--dry-run] # prune .workit/recovery to the newest 3 copies per record
workit <family> <action> [--payload <json|@file|->] [--task <id>] [--confirm] [--json]
workit action <operation> --payload <JSON> [--preview] [--confirm] [--json]
workit handoff --task <id> [--json]
Expand Down Expand Up @@ -417,7 +418,12 @@ plugins and the MCP server, 2 s in the CLI) and then returns the retryable
`busy` code, never `recovery_required`. `workit doctor` warns about a stale
lock and `workit doctor --fix-lock` clears it under the same reclaim guard
writers use; `--force` (with `--yes` or an interactive confirmation) is the
explicit escape hatch for a lock whose owner cannot be verified. New branch
explicit escape hatch for a lock whose owner cannot be verified. Each snapshot
replacement keeps a copy of the previous bytes in `.workit/recovery/`, capped at
the newest three per task or workspace record; `workit gc` prunes copies left by
older versions, removes stale temp files, and collapses duplicate stored
candidates in paused tasks (closed tasks are never rewritten). It never deletes
the live task or workspace records, and `--dry-run` writes nothing. New branch
setup shows both the existing local base SHA and remote base SHA in its
approval, rechecks them, and creates only from an approved commit. Workit does
not reject Git-valid branch names or user commit
Expand Down
4 changes: 2 additions & 2 deletions packages/workit-cli/src/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ export const TASK_ACTIONS = {
decision: ["record", "revoke"],
worker: ["assign", "report", "cancel"],
writer: ["acquire", "release"],
state: ["export", "import", "recover"],
// state.recover is not exposed: no shipped host supplies native recovery authority.
state: ["export", "import"],
} as const satisfies Record<OperationFamily, readonly string[]>;

type Stream = { write: (chunk: string) => void };
Expand Down Expand Up @@ -336,7 +337,6 @@ const CONSENT_ACTIONS = new Set([
"writer.acquire",
"writer.release",
"state.import",
"state.recover",
]);

const needsConsent = (parsed: Parsed): boolean =>
Expand Down
37 changes: 37 additions & 0 deletions packages/workit-cli/src/verbs/gc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// `workit gc`: bounded recovery state for the workspace root's .workit
// (WORKFLOW_WORKSPACE_ROOT, then --cwd/cwd). `--dry-run` is read-only.
import { TaskStore } from "@brainervirus/workit-core/src/core/task-store";
import { emit, fail, ok, type Io } from "../output";
import { workspaceRootFor } from "../task";

export async function run(argv: string[], io: Io): Promise<number> {
const result = new TaskStore(workspaceRootFor({ cwd: io.cwd })).collectGarbage({
dryRun: argv.includes("--dry-run"),
});
if (!result.ok)
return emit(
io,
fail(result.code === "busy" ? "busy" : "failed", `${result.code}: ${result.error}`, {
data: { code: result.code, details: result.details },
...(result.code === "busy" ? { unblock: "retry `workit gc`" } : {}),
}),
);
return emit(io, ok(result.data), (data) => {
const verb = data.dryRun ? "would remove" : "removed";
const megabytes = (data.recovery.removedBytes / 1_048_576).toFixed(1);
const { candidates } = data;
return [
`workit gc${data.dryRun ? " (dry run)" : ""}`,
`recovery: ${verb} ${data.recovery.removed} copies (${megabytes} MB), kept ${data.recovery.kept}`,
`temporary files: ${verb} ${data.temporary.removed}`,
`candidates: ${verb} ${candidates.removed} duplicates in ${candidates.tasks.length} tasks` +
(candidates.skippedActive.length
? `; skipped active ${candidates.skippedActive.join(", ")}`
: "") +
(candidates.skippedClosed.length
? `; skipped closed ${candidates.skippedClosed.join(", ")}`
: "") +
(candidates.failed.length ? `; failed ${candidates.failed.join(", ")}` : ""),
];
});
}
8 changes: 8 additions & 0 deletions packages/workit-cli/src/verbs/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ export const VERBS: readonly VerbEntry[] = [
"Verify the offline installation health (--fix-lock clears a stale .workit metadata lock)",
load: () => import("./doctor"),
},
{
name: "gc",
group: "setup",
usage: "workit gc [--dry-run] [--json]",
summary:
"Prune .workit/recovery copies beyond the cap and dedupe stored candidates in paused tasks",
load: () => import("./gc"),
},
{
name: "uninstall",
group: "setup",
Expand Down
1 change: 1 addition & 0 deletions packages/workit-core/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export {
POLICY_VERSION,
OPERATION_FAMILIES,
operationSchemas,
advertisedOperationSchemas,
operationJsonSchema,
boundedOperationJsonSchema,
OPERATION_SCHEMA_DEPTH,
Expand Down
15 changes: 14 additions & 1 deletion packages/workit-core/src/core/task-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1131,6 +1131,19 @@ export const operationSchemas = {
state: z.discriminatedUnion("action", Object.values(stateOperations) as any),
} as const;
export type OperationRequest = z.infer<(typeof operationSchemas)[OperationFamily]>;

/**
* Schemas advertised to hosts. `state.recover` needs host-supplied native
* recovery authority (`OperationContext.nativeRecovery`), which no shipped
* host provides, so advertising it only sends agents into a guaranteed
* permission_denied. parseOperation still accepts it for embedders that do
* supply that authority.
*/
const { recover: _unadvertisedRecover, ...advertisedStateOperations } = stateOperations;
export const advertisedOperationSchemas = {
...operationSchemas,
state: z.discriminatedUnion("action", Object.values(advertisedStateOperations) as any),
} as const;
export type TaskStartRequest = z.infer<typeof taskOperations.start>;

const compiledOperationSchemas = Object.fromEntries(
Expand Down Expand Up @@ -1234,7 +1247,7 @@ export function parseOperation(family: OperationFamily, input: unknown): Result<
}

export function operationJsonSchema(family: OperationFamily): z.core.JSONSchema.BaseSchema {
return z.toJSONSchema(operationSchemas[family], { target: "draft-2020-12" });
return z.toJSONSchema(advertisedOperationSchemas[family], { target: "draft-2020-12" });
}

/**
Expand Down
Loading
Loading