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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ All notable changes to this project are recorded here. The format follows Keep a

### Added

- A controlled membership-observation study (#38) enumerates mixed before/after refs for families, semaphore slots and prefix descendants. Real Git transactions leave independent invariant violations in 21 of 84 synthetic cases. The fixture retains exact observations and transaction receipts, calibrates its independent oracle, and returns exit 1 when it exposes a safety failure; any harness fault, anticipated or not, exits 2. The ordinary test suite runs the oracle calibration and verifies the committed receipt hashes. No live Git race is claimed and no production fix is included; #45 tracks the unresolved correctness work.
- A runnable cooperating-worker example (#40) acquires a path set in the mutation launcher, shows holder/note contention, allows unrelated work, and demonstrates renewal, superseded cleanup, failure cleanup and non-renewing TTL expiry. JSON receipts and behavior tests cover the golden path, agreement with the retained recorded run, an existing-output edge, a stopped run leaving no worker processes, and two concurrent isolated runs. The runbook distinguishes these controlled flows from unresolved #45 coherence work and defines external adoption validation as an unrun experiment.

### Changed

- **Breaking:** re-claiming a parent's acquisition while any of its descendants remain stored, expired ones included, now exits 1 with a `parent` refusal whose detail is `descendants`. Before, a same-holder re-claim replaced the parent and left its children pointing at a superseded acquisition. Scripts that renew a parent by claiming it again must switch to `extend`, or release or sweep the descendants first (#34).

### Fixed

- The README's `with` synopses now list every option the command accepts, including `--note` and `--ttl`, which the example uses.
- A controlled membership-observation study (#38) enumerates mixed before/after refs for families, semaphore slots and prefix descendants. Real Git transactions leave independent invariant violations in 21 of 84 synthetic cases. The fixture retains exact observations and transaction receipts, calibrates its independent oracle, and returns exit 1 when it exposes a safety failure; any harness fault, anticipated or not, exits 2. The ordinary test suite runs the oracle calibration and verifies the committed receipt hashes. No live Git race is claimed and no production fix is included; #45 tracks the unresolved correctness work.
- Parent acquisition replacement (#34) now refuses while any descendants remain stored, including expired descendants. This applies to the same holder, changed holders, reparenting and batches. Renew parents with `extend`, or release/sweep descendants before replacing them. Leaf replacement followed by new child admission remains supported. Admission rejects self-parenting and indirect cycles, verifies observed ancestor records in its transaction, and reports schema-valid `parent` refusals with `cycle` or `descendants` detail. The `claim` help text and the README command table state both rules. Regression tests cover unchanged refs and the reported `detail` on refusal, renewal/recreation, both child-admission race directions with their final records, and 192 seeded operations whose outcomes and refusal reasons are checked against an independent family model.
- Semaphore capacity is validated as a bounded positive decimal and normalized before storage, arithmetic, and JSON serialization. Leading-zero values such as `01`, `08`, and `010` keep their decimal meaning, including when reading metadata written by older versions. Invalid stored capacities fail with `store-read` (#35).
- `doctor` reads a stored semaphore capacity with the same decimal rule. Before, a legacy `08` printed a bash arithmetic error, `010` was compared as octal eight (so nine live slots were a false `sem-capacity` finding), and a capacity past 2^64 wrapped around to a small number instead of being a `sem-record` finding.
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
SHELL := /usr/bin/env bash
# lib/*.sh are fragments of one script and only lint as the whole they build into (bin/git-locks).
SCRIPTS := bin/git-locks test/test.sh test/family-replacement.sh test/literal-paths.sh test/unicode-locale.sh test/unicode-locale-calibration.sh test/observation/git-shim.sh scripts/hooks/pre-commit scripts/hooks/pre-push scripts/build.sh
SCRIPTS := bin/git-locks test/test.sh test/family-replacement.sh test/literal-paths.sh test/unicode-locale.sh test/unicode-locale-calibration.sh test/observation/git-shim.sh examples/cooperating-workers/demo.sh examples/cooperating-workers/worker.sh scripts/hooks/pre-commit scripts/hooks/pre-push scripts/build.sh
PREFIX ?= $(HOME)/.local

.PHONY: build lint test test-docker study-observation test-observation-calibration install uninstall
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ In summary, a semaphore is a set of slot refs plus one ref that every writer mus

Most callers want the lock only for the duration of one command, and forgetting the release is the common failure. This section shows `with`, which does the three steps and cannot forget the third.

`git locks with --job <id> --holder <name> [--wait <s>] [--sem <name>] <path>... -- <command>...` claims the paths (and a semaphore slot if asked), runs the command, and releases on exit, on failure, and on Ctrl-C or a termination signal, then exits with the command's own status. The command owns stdout; git-locks reports its claim and release on stderr, so a pipeline reading the command's output sees only that output:
`git locks with --job <id> --holder <name> [--ttl <s>] [--wait <s>] [--sem <name>] [--note <text>] <path>... -- <command>...` claims the paths (and a semaphore slot if asked), runs the command, and releases on exit, on failure, and on Ctrl-C or a termination signal, then exits with the command's own status. The command owns stdout; git-locks reports its claim and release on stderr, so a pipeline reading the command's output sees only that output:

```text
$ git locks with --job build --holder alice dist/bundle.js -- sh -c 'echo building'
Expand All @@ -331,6 +331,10 @@ building

In summary, `with` is the shape most scripts should use: the lock's lifetime is the command's lifetime, by construction.

## A runnable cooperating-worker example

The [two-worker example](examples/cooperating-workers/README.md) reserves a path set before launching mutation, shows a competing worker who holds it and why, and lets unrelated work finish. It also demonstrates renewal, acquisition-aware cleanup, worker failure and the TTL boundary in an isolated store. The runbook defines an external adoption experiment as unrun. Its controlled flows do not resolve the observation-coherence failures tracked by [#45](https://github.com/git-stunts/locks/issues/45).

## Output: JSON Lines, always

Every example above showed one JSON object per line, and this section states the contract behind that so a consumer can rely on it. There is no plain-text mode. Stdout carries one object per result, written as each result is known; stderr carries refusals and errors as objects; `git locks help` is a `usage` object; `git locks schema` prints the schema as one line. The single exception is a command wrapped by `with`, which owns stdout while git-locks reports around it on stderr.
Expand Down Expand Up @@ -405,7 +409,7 @@ Output is JSON Lines on every command; there is no text mode.
| `claim … --parent <id>` | make the lock a child: the parent must be live and held by the same holder (verified inside the transaction), and must not be the job itself or one of its descendants; the child is released or swept with it | as `claim`, with `parent` | 0, 1 if refused |
| `batch < records` | claim several locks in one transaction, or none; records are blank-line separated `job:`, `holder:`, `ttl:`, `parent:`, then `paths:` with one path per line | one `claimed` object per record | 0, 1 if any is refused, 2 on a malformed record |
| `release --job <id> [--record <oid>] [--job <id>...]` | release the jobs and all their descendants in one transaction; `--record` releases only that acquisition | one object per job, `cascaded` lists descendants, `nothing` with `reason: superseded` when the record no longer matches | 0 |
| `with --job <id> --holder <name> [--ttl <s>] [--wait <s>] [--parent <id>] <path>... -- <cmd>...` | claim, run the command, release; `--wait` retries once a second until the paths are free or the wait runs out | the command's own stdout; git-locks' `claimed`, `released` and refusals go to **stderr** | the command's exit status; 1 if never acquired; 130/143 on INT/TERM after releasing |
| `with --job <id> --holder <name> [--ttl <s>] [--wait <s>] [--sem <name>] [--parent <id>] [--note <text>] <path>... -- <cmd>...` | claim, run the command, release; `--wait` retries once a second until the paths are free or the wait runs out | the command's own stdout; git-locks' `claimed`, `released` and refusals go to **stderr** | the command's exit status; 1 if never acquired; 130/143 on INT/TERM after releasing |
| `version` | tool name and version | one object | 0 |
| `schema` | the JSON Schema every line above conforms to | the schema document | 0 |
| `doctor` | read-only invariant check of the store; nothing is repaired | one `finding` object per broken invariant as it is found, then one `doctor` object with the basis (refs, records, clock), the checks run and the verdict | 0 healthy, 1 with findings, 2 if the store cannot be read |
Expand Down
103 changes: 103 additions & 0 deletions examples/cooperating-workers/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Two cooperating workers

This example puts reservation acquisition in the code that launches a mutation. Alice reserves two generated files before her worker starts. Bob receives a refusal naming Alice and her note, then completes unrelated work while Alice remains active. The same run demonstrates renewal, acquisition-aware cleanup, a failed worker, and the TTL boundary.

The example exercises controlled local flows. It does not resolve the mixed-observation failures in [#45](https://github.com/git-stunts/locks/issues/45). Evaluate an external runner integration after the hardening work and that correctness gate; the adoption experiment below has not been run.

## Run it

From this checkout, with Bash 4+ and Git available:

```bash
./examples/cooperating-workers/demo.sh /tmp/locks-workers-review
```

Choose a fresh output path. An existing directory is refused before any file is overwritten. With no argument, the launcher creates a fresh temporary directory and prints its location. It uses this checkout's `bin/git-locks`; no installation, service, or package download is required.

The launcher selects an explicit bare store at `<output>/store.git`, uses `<output>/work` as the workers' common artifact directory, and writes command receipts under `<output>/receipts`. It retains those files when it finishes. The demonstration does not modify this checkout's source files or project refs.

The expected transcript is:

```text
Path set acquired before mutation; overlap refused; unrelated work completed; renewed acquisition released.
Superseded cleanup preserved the replacement acquisition.
Worker exit 17 propagated; its reservation was released.
TTL expired while the command remained active; automatic renewal is not provided.
Artifacts and JSONL receipts: <absolute output directory>
```

Inspect the evidence directly:

```bash
cat /tmp/locks-workers-review/receipts/worker-b-refusal.jsonl
cat /tmp/locks-workers-review/receipts/before-renewal.jsonl
cat /tmp/locks-workers-review/receipts/after-renewal.jsonl
cat /tmp/locks-workers-review/receipts/replacement-survives.jsonl
cat /tmp/locks-workers-review/receipts/expired-while-running.jsonl
cat /tmp/locks-workers-review/receipts/final-doctor.jsonl
```

Each run records CLI `version`, selected store and checkout revision. The [recorded example](recorded-run.json) retains one observed run's CLI records and results against its named source revision. Object IDs and acquisition IDs change on later runs; compare their relationships and the actual outcomes.

## What the launcher does

The admission boundary is the `with` invocation in [demo.sh](demo.sh):

```bash
"${DEMO_BIN}" with --job build --holder alice \
--note 'regenerating API and types' --ttl 60 \
generated/api.txt generated/types.txt -- \
bash "${HERE}/worker.sh" worker-a build "${gate}" build
```

`with` obtains the whole path set before it invokes the mutation command. The worker records its admitted acquisition, writes both artifacts, and signals readiness through a gate file. Bob's launcher also uses `with`; a failed acquisition prevents Bob's mutation command from running. A prior `check` is not the admission mechanism.

The gates control order without guessing how long a worker will take. Alice remains inside her command while Bob's conflicting launch is refused and Bob's independent launch writes `independent.txt`. The independent worker records Alice's live acquisition during its own execution. It then exits and releases its separate reservation.

The launcher renews Alice's reservation with `extend`. The before/after records have different `record` object IDs and the same `acquisition` ID. When Alice's gate opens, her wrapper releases that original acquisition despite the renewal. Both generated paths become free.

A separate case starts a wrapper with job name `reused`, then creates a replacement acquisition under that name. The old wrapper's cleanup reports `nothing` with `reason: superseded`; the replacement stays live and is released explicitly by its own acquisition ID.

The failed-worker case returns status 17 after writing partial output. `with` propagates 17 and releases the reservation. Cleanup does not roll back the worker's file changes: `failed.txt` intentionally remains as partial output.

Finally, a worker with TTL 1 waits at a gate. An observation at a simulated later clock reports the reservation expired while the command is still active. The launcher then opens the gate and lets cleanup complete. The example fixes `GIT_LOCKS_NOW=1000000` and explicitly advances one observation to `1000002`; this is a deterministic TTL demonstration, not a two-second benchmark. Real integrations should use the normal clock. `with` neither renews automatically nor terminates a command when its reservation expires. Choose a TTL appropriate to the workload and put any renewal policy in the runner.

## Store and worktree meaning

This example coordinates two workers accessing the same physical artifact directory. Its explicit store isolates the exercise and makes the sharing policy visible.

In a project integration, the default separate store keeps coordination refs out of the project and is shared by linked worktrees. Reserving the same relative path across linked worktrees coordinates logical ownership; the files may be physically different. A runner must choose whether that shared logical ownership is the intended policy. Using distinct explicit stores intentionally creates independent coordination domains. Workers that should coordinate must select the same store and agree on relative path meaning.

The reservations are cooperative. Other programs can write the files without using the launcher. These controlled runs do not prove the reader coherence or arbitrary interleaving properties tracked by #45.

## Repeatable verification

```bash
python3 test/cooperating-workers.py
make lint
```

The Python test requires the same `jsonschema` dependency as the existing suite; the example itself uses Bash and Git. `make test` runs the example test as part of the normal suite.

The oracle parses the real command receipts and validates lifecycle JSON against the public schema. It checks acquisition before mutation, both reserved paths, refusal holder/note, absence of Bob's blocked mutation marker, unrelated progress during Alice's acquisition, renewal identity, release after renewal, superseded cleanup, status-17 cleanup, simulated expiry, and final store health.

The golden run uses an output path containing spaces, and its receipt set and transcript must still match [recorded-run.json](recorded-run.json), whose records must remain schema-valid. An existing-directory case checks preservation of a sentinel file. A demonstration stopped while Alice's worker is gated must leave no launcher or worker process behind. Two complete demonstrations then run concurrently with separate stores and both must satisfy the same behavioral assertions. That is bounded stress of this example and its isolation, not arbitrary-schedule fuzzing or evidence of external adoption.

## Adoption experiment, not yet run

After the hardening gates, recruit one actual runner maintainer or integration user and agree on one existing generator or coding-worker task that mutates a known path set. Run it through the launch boundary above in a shared checkout or deliberately chosen artifact directory. Keep the experiment to that task and its existing workflow.

Record the following before deciding whether to add features:

| Question | Evidence to retain |
| --- | --- |
| Can the maintainer install and wire the launcher? | Setup minutes, commands changed, platform/runtime versions, and each obstacle. |
| Is it useful beyond the first demonstration? | Number of runs on at least three workdays and whether the maintainer chose to keep using it. |
| Is contention understandable? | The refusal shown to the user and their explanation of who held the paths, why, and what they did next. |
| Does unrelated work keep moving? | A concrete blocked path set and an unrelated task that completed during it. |
| Is lifecycle handling dependable for the task? | Renewal/expiry decisions, worker failures, interrupted runs, cleanup receipts and any unexpected artifacts. |
| Does the sharing policy fit? | Shared checkout or linked-worktree layout, selected store, and any mismatch between logical paths and physical files. |

Ask the maintainer: "What did this refusal tell you?", "Where would you place acquisition in your launcher?", "What happened when the worker exceeded its TTL?", and "Would you keep this in the workflow next week, and why?" Record their words rather than substituting an inferred adoption score.

A useful result would be a maintainer who completes integration, uses it repeatedly, explains contention correctly, and wants to keep it. A passing local demo alone does not supply that evidence. No external maintainer has been recruited or contacted as part of this change, and no standalone-business conclusion follows from it.
Loading
Loading