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
31 changes: 31 additions & 0 deletions .changeset/a-lease-says-who.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@openspec-ui/core": minor
"@openspec-ui/cli": minor
"openspec-ui-vscode": patch
---

A workspace lease says who took it, and can be asked about.

The lease records the git identity of the working directory that took it
— `user.email`, falling back to `user.name`. It is attribution, never
authentication: anybody can set that value to anything, so it is called
"git author" wherever it is shown and nothing is permitted or refused on
the strength of it. A lease taken where no identity is configured is
valid and records none, exactly like every lease written before this.

It is read once where a host starts up, never in the heartbeat — that
renews every five seconds, and the value cannot change during a run.

`openspec-ui-cli lease` answers who holds a workspace without trying to
start a run and reading the refusal, which was the only way to ask
before. It exits `0` held or free: the question was answered either way.

`openspec-ui-cli lease release` clears a lease only where it can
establish that the holder is gone — the heartbeat is already stale, or
the holder is on this machine and its process is not running, checked
with a signal that delivers nothing. There is deliberately no `--force`.
A holder that died already self-heals once its heartbeat goes stale; a
holder that is alive still has the workspace open, and taking its lease
would permit a second mutating run against files it is still holding,
which is what the lease exists to prevent. A stuck holder is stopped,
not robbed.
5 changes: 5 additions & 0 deletions openspec/changes/a-lease-says-who/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
schema: spec-driven
created: 2026-09-11
follows:
- cross-host-workspace-lease
- changes-run-side-by-side
92 changes: 92 additions & 0 deletions openspec/changes/a-lease-says-who/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Design

## Decision: attribution, and the word is load-bearing

The lease gains the git identity of the working directory that took it —
`user.email`, falling back to `user.name`. It is the same value that
signs every commit in the repository, so it is already the name people
recognise each other by here.

Anybody can set it to anything. That is stated in the type, in the
message, and in this file, because the failure mode of recording it is
not that it is wrong: it is that a later reader treats a self-declared
label as an audit trail. The message says "git author", never "user",
and nothing gates on it.

## Decision: read once, not per heartbeat

`acquireOrRenew` runs every five seconds while a mutating run is
active. Reading `git config user.email` there would spawn a process
twelve times a minute for a value that cannot change during a run.

So the identity is gathered once by whoever constructs the manager —
three places, one call each at activation — and passed in. A manager
given none records none, which is what every lease written before this
change already looks like.

## Decision: a stuck lease is mostly not a thing, and the remedy is not force

The tempting design is `lease release --force`. It is wrong, and working
out why changed this change's shape.

A holder that **died** stops renewing, and the next acquirer reclaims
the lease automatically once the heartbeat is older than the staleness
window. That case already heals; nothing is needed.

A holder that is **alive and still renewing** has the workspace open. It
may be stuck — an agent waiting forever on something — but taking its
lease would let a second mutating run start against files the first one
still has open, which is the exact scenario the lease exists to prevent.
The remedy there is to stop that process, not to steal from it.

So `release` clears a lease only where it can establish the holder is
gone:

- the heartbeat is already stale — the holder is gone by the definition
the lease has always used; or
- the holder is on **this** hostname and its pid is not running.

`process.kill(pid, 0)` answers the second without signalling anything:
`ESRCH` means no such process, `EPERM` means it exists and belongs to
somebody else. A holder on another hostname cannot be checked from here
at all, and the refusal says that rather than guessing.

## Decision: asking is its own command, not a side effect of being refused

Today the only way to learn who holds a workspace is to try to start a
run and read the refusal. That is a strange way to ask a question, and
it means the answer is only available at the moment you are being told
no.

`openspec-ui-cli lease` prints the holder — kind, hostname, pid, how
long since its heartbeat, and the git author where one was recorded —
or says the workspace is free.

## Decision: exit codes say what was found, not whether the tool worked

`lease` exits `0` whether or not the workspace is held: it answered the
question either way, and a script checking "is it free" should read the
output rather than infer from a failure code the way `validate` does.

`lease release` exits `0` when it cleared one, `1` when it refused
because the holder is alive or unverifiable, and `2` when it could not
look at all.

## Non-Goals

Authentication. Per-person permissions. Identity in the audit log, which
is a larger change through a runner that has no notion of a workspace.
Clearing a lease held by a live process.

## Risks / Trade-offs

A pid on this hostname can be reused. A holder that died and whose pid
was taken by an unrelated process reads as alive, and `release` refuses
where it could safely have cleared. That is the direction to fail in:
the staleness window still clears it twenty seconds later, so the cost
is a wait, not a stuck workspace.

Recording an email address in a file inside the repository's working
directory — `.openspec-ui/` is gitignored, so it does not leave the
machine, but it is written where a person might not expect it. Named
here so the choice is visible.
63 changes: 63 additions & 0 deletions openspec/changes/a-lease-says-who/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# A lease says who

## Why

An outside review of how this tool behaves for several people at once,
relayed on 2026-09-11, made two points about the workspace lease. Both
are correct, and one of them has a different remedy than it looks.

**It does not say who.** The lease records a host kind, a hostname and a
pid. Two people sharing a machine, or one person with two checkouts, see
"terminal run on HPP-NTB63, pid 4242" and learn nothing about whose run
it is. The audit log is worse: `AuditEntry` carries `runId`, `agent`,
`cwd`, `changeDir` and `usage`, and nothing at all about who caused it.

There is a signal already in every repository and not being used: the
git identity of the working directory, the same `user.email` that signs
every commit. It is **attribution and not authentication** — anybody can
set it to anything — and recorded as such it is exactly what a person
needs in order to know whose run is holding the workspace.

**There is no way to clear a stuck lease.** True, and the obvious
remedy is wrong. Where a holder has died, its heartbeat stops and the
next acquirer reclaims the lease automatically — that case already
heals. The case that does not heal is a holder that is alive and still
renewing while doing nothing useful, and taking the lease from it would
let a second mutating run start while the first still has the workspace
open. That is what the lease exists to prevent.

So what is missing is not a `--force`. It is the ability to ask who
holds the workspace without attempting a run, and a release that
establishes the holder is gone before clearing anything.

## Capabilities

### New

- A lease records the git identity of the working directory that took
it, so a person can tell whose run holds the workspace.
- The holder of a workspace can be asked about directly, rather than
discovered by trying to start a run and being refused.
- A lease can be cleared where the holder can be shown to be gone, and
refused where it cannot.

### Modified

- A refusal naming the holder names the git identity too, where the
holder recorded one.

## Out of scope

Authentication, access control, or per-person permissions. Anybody can
set `user.email` to anything, and a system that treated it as proof
would be worse than one that records nothing — it would look like an
audit trail while being a self-declared label. This records who says
they are running it, and says so in those words.

Identity in the audit log. Worth doing and a larger question: audit
entries are written per run by an agent runner that has no notion of a
workspace, and threading it there is its own change.

Clearing a lease whose holder is alive. That is what stopping the
process is for. A command that did it would defeat the isolation the
lease provides, at the moment somebody is most tempted to use it.
86 changes: 86 additions & 0 deletions openspec/changes/a-lease-says-who/specs/ci-cli/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
## ADDED Requirements

### Requirement: A lease records who took it, as attribution

A workspace lease SHALL record the git identity of the working directory
that took it, where one is configured.

It SHALL be reported as what it is: a self-declared label, the same one
that signs the repository's commits, which anybody can set to anything.
Nothing SHALL be permitted or refused on the strength of it.

A lease taken where no identity is configured SHALL be valid and SHALL
record none, exactly as every lease written before this existed.

#### Scenario: A run in a working directory with a git identity

- **WHEN** a run takes the workspace in a directory that has a git
identity configured
- **THEN** the lease records it, and a refusal naming the holder names
it too

#### Scenario: No identity configured

- **WHEN** no git identity is configured
- **THEN** the lease is taken and records none

### Requirement: The holder of a workspace can be asked about

It SHALL be possible to ask who holds a workspace without attempting to
start a run.

The answer SHALL name the kind of host, where it is running, its process,
how long since it last reported itself, and its git identity where one
was recorded. Where nothing holds the workspace, it SHALL say so.

Asking SHALL succeed whether or not the workspace is held: the question
was answered either way.

#### Scenario: Asking about a held workspace

- **WHEN** the holder is asked for and a live lease exists
- **THEN** it is described, and the command reports success

#### Scenario: Asking about a free workspace

- **WHEN** nothing holds the workspace
- **THEN** it says so, and the command reports success

### Requirement: A lease is cleared only where its holder is shown to be gone

Clearing a lease SHALL require establishing that its holder is gone.

A heartbeat older than the staleness window establishes it — that is
what the lease has always meant by a holder no longer being there.

A holder on this same machine whose process is no longer running
establishes it. Checking SHALL NOT signal the process.

Where the holder is on another machine, or its process is still running,
clearing SHALL be refused, saying which of the two it was. Taking a lease
from a live holder would permit a second mutating run against files the
first still holds open, which is what the lease exists to prevent — and
a holder that is stuck is stopped, not robbed.

#### Scenario: A holder whose process has gone

- **WHEN** clearing is requested and the holder is on this machine with
no such process running
- **THEN** the lease is cleared

#### Scenario: A holder that is still running

- **WHEN** clearing is requested and the holder's process is running
- **THEN** it is refused, and the message says the holder is alive and
that stopping it is the remedy

#### Scenario: A holder somewhere else

- **WHEN** the holder is on another machine
- **THEN** clearing is refused, saying that it cannot be checked from
here

#### Scenario: A lease already stale

- **WHEN** the heartbeat is older than the staleness window
- **THEN** the lease is cleared without needing to check any process
99 changes: 99 additions & 0 deletions openspec/changes/a-lease-says-who/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
The lease records a host kind, a hostname and a pid, and the audit log
records nobody at all. Two people on one machine cannot tell whose run
holds the workspace, and there is no way to ask who does without trying
to start a run and being refused.

## 1. Who took it

- [x] 1.1 `WorkspaceLeaseDocument` gains an optional git identity —
`user.email`, falling back to `user.name`. Optional because every
lease written before this has none, and a directory with no identity
configured must still be able to take one.
- [x] 1.2 Named and documented as **attribution, never authentication**.
Anybody can set `user.email` to anything. The failure mode of
recording it is not that it is wrong; it is that a later reader treats
a self-declared label as an audit trail, so the message says "git
author" and nothing is ever gated on it.
- [x] 1.3 Read once by whoever constructs the manager, not inside
`acquireOrRenew`. That runs every five seconds while a run is active,
and reading git config there would spawn a process twelve times a
minute for a value that cannot change mid-run.
- [x] 1.4 `GitWrapper` gains a read of the configured identity. Absent
config, absent git, or an error all mean none — never a guess.
- [x] 1.5 The three construction sites pass it: the CLI run, the
standalone server's recovery service, and the extension's activation.
- [x] 1.6 `describeWorkspaceLeaseConflict` names it where present and
reads the same without it.

## 2. Asking who holds it

- [x] 2.1 `openspec-ui-cli lease`: the holder's kind, host, pid,
heartbeat age and git identity, or that the workspace is free.
- [x] 2.2 Exit `0` either way. The question was answered whether or not
the workspace is held, and a script wanting "is it free" should read
the output rather than infer from a failure code.
- [x] 2.3 `--format json` for a machine, the same shape the reader
returns.

## 3. Clearing one

- [x] 3.1 `openspec-ui-cli lease release` clears only where the holder
is established to be gone: the heartbeat is already stale, or the
holder is on this hostname and its pid is not running.
- [x] 3.2 The liveness check does not signal the process.
`process.kill(pid, 0)` answers it: `ESRCH` is no such process, `EPERM`
is one that exists and belongs to somebody else.
- [x] 3.3 A holder on another hostname cannot be checked from here.
Refuse and say that, rather than guess.
- [x] 3.4 A live holder is refused, and the message says stopping that
process is the remedy. Taking its lease would let a second mutating
run start against files it still has open, which is the whole point of
the lease.
- [x] 3.5 Exit `0` cleared, `1` refused, `2` could not look.

## 4. Tests

- [x] 4.1 Core: a lease records the identity it was given, and one
written without it stays readable.
- [x] 4.2 Core: the conflict description names the identity where
present and reads correctly without it.
- [x] 4.3 Core: clearing a stale lease succeeds without consulting any
process.
- [x] 4.4 Core: clearing is refused for a live pid on this hostname —
driven with this test process's own pid, which is certainly alive.
- [x] 4.5 Core: clearing is refused for another hostname, saying it
cannot be checked.
- [x] 4.6 Core: clearing a dead pid on this hostname succeeds. A pid
that is certainly not running is needed, so the test must establish
that rather than assume a number.
- [x] 4.7 CLI: `lease` exits 0 held and free; `lease release` exits 0,
1 and 2 for the three outcomes.

## 5. Verification

- [x] 5.1 This change validates strictly. `check(validate-change)`
`openspec validate --strict --changes` — 2 passed, 0 failed, this
change among them.
- [x] 5.2 `npm run verify` unpiped, after the last edit, with everything
staged. Record the run and the per-package test counts.
2026-09-11, exit 0. Typecheck and lint clean across all five packages
(including `lint:english`, `lint:source-text`, `lint:changesets`).
Tests: cli 107, core 1075, vscode 327, server 80, webui 379 — 1968
across 155 files, 0 failed.
- [x] 5.3 A pending changeset exists. `check(changeset-present)`
`.changeset/a-lease-says-who.md`: core and cli minor, the extension
patch.
A live smoke on the way past, recorded because it exercised the
backward-compatible path for real rather than from a written file: the
workspace was held by a VS Code extension built *before* this change,
so its lease carries no identity. `lease` described it (exit 0) and
omitted the git author line entirely; `lease release` refused it
(exit 1) because its pid was running, naming stopping that process as
the remedy. What this did NOT show is a lease written by this build,
which is what 5.4 is for.
- [ ] 5.4 **Delegated to `claude-cli`**: with a real run holding a
workspace, ask who holds it and try to clear it; then after that run
ends, ask again. Evidence: the lease file, both outputs, and the exit
codes. The unit tests drive the reader with written files; only a real
run shows that the identity a chain records is the one the command
reports.
Loading
Loading