Skip to content

Commit cb9a340

Browse files
Merge pull request #159 from schickling-assistant/schickling-assistant/2026-08-02-2026-08-02-157-exact-generation-evidence
Expose exact-generation exit evidence
2 parents c65818a + a78281c commit cb9a340

17 files changed

Lines changed: 1359 additions & 10 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,7 @@ Like `git`, `pty` supports extensions: if you run `pty foo` and there's a `pty-f
419419
```typescript
420420
import {
421421
spawnDaemon, listSessions, getSession,
422+
getSessionExitEvidence, removeSessionGeneration,
422423
SessionConnection, sendData, peekScreen, queryStats,
423424
EventFollower, readRecentEvents,
424425
extractFilterTags, matchesAllTags,

completions/pty.bash

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ _pty() {
55
COMPREPLY=()
66
cur="${COMP_WORDS[COMP_CWORD]}"
77
prev="${COMP_WORDS[COMP_CWORD-1]}"
8-
commands="run attach a exec peek send events list ls stats restart kill recover rm remove gc tag tag-multi emit rename metadata up down test remote-serve"
8+
commands="run attach a exec peek send events list ls stats restart kill recover rm remove gc tag tag-multi emit rename metadata evidence up down test remote-serve"
99

1010
if [[ ${COMP_CWORD} -eq 1 ]]; then
1111
if [[ "${cur}" == -* ]]; then
@@ -128,6 +128,33 @@ _pty() {
128128
metadata)
129129
COMPREPLY=($(compgen -W "--id" -- "${cur}"))
130130
;;
131+
evidence)
132+
if [[ ${COMP_CWORD} -eq 2 ]]; then
133+
COMPREPLY=($(compgen -W "snapshot remove" -- "${cur}"))
134+
return
135+
fi
136+
case "${COMP_WORDS[2]}" in
137+
snapshot)
138+
if [[ "${prev}" == "--id" ]]; then
139+
return
140+
fi
141+
if [[ "${cur}" == -* ]]; then
142+
COMPREPLY=($(compgen -W "--id" -- "${cur}"))
143+
fi
144+
;;
145+
remove)
146+
if [[ "${prev}" == "--id" ]]; then
147+
return
148+
fi
149+
if [[ "${prev}" == "--expected-generation" ]]; then
150+
return
151+
fi
152+
if [[ "${cur}" == -* ]]; then
153+
COMPREPLY=($(compgen -W "--id --expected-generation" -- "${cur}"))
154+
fi
155+
;;
156+
esac
157+
;;
131158
up)
132159
COMPREPLY=($(compgen -o dirnames -- "${cur}"))
133160
;;

completions/pty.fish

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ function __pty_using_command
3333
test (count $cmd) -ge 2; and test "$cmd[2]" = "$argv[1]"
3434
end
3535

36+
function __pty_needs_subcommand
37+
set -l cmd (commandline -opc)
38+
test (count $cmd) -eq 2; and test "$cmd[2]" = "$argv[1]"
39+
end
40+
41+
function __pty_using_subcommand
42+
set -l cmd (commandline -opc)
43+
test (count $cmd) -ge 3; and test "$cmd[2]" = "$argv[1]"; and test "$cmd[3]" = "$argv[2]"
44+
end
45+
3646
complete -c pty -f
3747

3848
# ── Global flags ───────────────────────────────────────────────────────
@@ -62,6 +72,7 @@ complete -c pty -n __pty_needs_command -a tag-multi -d 'Bulk tag ops across sess
6272
complete -c pty -n __pty_needs_command -a emit -d 'Publish a user.* event'
6373
complete -c pty -n __pty_needs_command -a rename -d 'Set / show / clear displayName'
6474
complete -c pty -n __pty_needs_command -a metadata -d 'Atomically patch presentation metadata by stable id'
75+
complete -c pty -n __pty_needs_command -a evidence -d 'Read or remove exact-generation retained exit evidence'
6576
complete -c pty -n __pty_needs_command -a up -d 'Start sessions from pty.toml'
6677
complete -c pty -n __pty_needs_command -a down -d 'Stop sessions from pty.toml'
6778
complete -c pty -n __pty_needs_command -a test -d 'Run the pty test suite (vitest)'
@@ -142,6 +153,11 @@ complete -c pty -n '__pty_using_command rename' -l clear -d 'Remove displayName'
142153
complete -c pty -n '__pty_using_command rename' -a '(__pty_sessions)' -d 'Session'
143154
complete -c pty -n '__pty_using_command metadata' -l id -d 'Exact stable session id'
144155
complete -c pty -n '__pty_using_command metadata' -x -a 'patch' -d 'Value'
156+
complete -c pty -n '__pty_needs_subcommand evidence' -a snapshot -d 'Read retained evidence for one exact generation'
157+
complete -c pty -n '__pty_using_subcommand evidence snapshot' -l id -x -d 'Exact stable session id'
158+
complete -c pty -n '__pty_needs_subcommand evidence' -a remove -d 'Remove only the matching terminal generation'
159+
complete -c pty -n '__pty_using_subcommand evidence remove' -l id -x -d 'Exact stable session id'
160+
complete -c pty -n '__pty_using_subcommand evidence remove' -l expected-generation -x -d 'Opaque generation returned by evidence snapshot'
145161
complete -c pty -n '__pty_using_command up' -F
146162
complete -c pty -n '__pty_using_command down' -F
147163
complete -c pty -n '__pty_using_command test' -l t -d 'Run matching tests'

completions/pty.zsh

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ _pty() {
3535
'emit:Publish a user.* event'
3636
'rename:Set / show / clear displayName'
3737
'metadata:Atomically patch presentation metadata by stable id'
38+
'evidence:Read or remove exact-generation retained exit evidence'
3839
'up:Start sessions from pty.toml'
3940
'down:Stop sessions from pty.toml'
4041
'test:Run the pty test suite (vitest)'
@@ -184,6 +185,23 @@ _pty() {
184185
'--id[Exact stable session id]' \
185186
'1:mode:(patch)'
186187
;;
188+
evidence)
189+
if (( CURRENT == 3 )); then
190+
_values 'operation' snapshot remove
191+
else
192+
case ${words[2]} in
193+
snapshot)
194+
_arguments \
195+
'--id[Exact stable session id]:id:'
196+
;;
197+
remove)
198+
_arguments \
199+
'--id[Exact stable session id]:id:' \
200+
'--expected-generation[Opaque generation returned by evidence snapshot]:generation:'
201+
;;
202+
esac
203+
fi
204+
;;
187205
up)
188206
_arguments \
189207
'1:directory:_directories'

docs/client.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,45 @@ display name resolves only when it has exactly one match; multiple matches throw
2323
an error that lists the candidate stable ids. Returns `null` when no session
2424
matches. Resolve once, then pass `session.name` to socket-oriented APIs.
2525

26+
### `getSessionExitEvidence(name: string): Promise<SessionExitEvidenceResult>`
27+
28+
Read the retained terminal evidence for one dead daemon generation. A snapshot
29+
contains the stable session id, opaque generation, `exited` or `vanished`
30+
status, nullable exit code, `stream: "combined"`, and the exact persisted
31+
bounded `lastLines`. An absent persisted tail is tagged `unavailable`; it is
32+
not reported as an empty tail.
33+
34+
The operation fails closed with a tagged `unavailable` result when the session
35+
is missing, running, locked, lacks a generation, or has invalid metadata. The
36+
evidence reader rejects malformed, oversized, symlink, non-regular, type-invalid,
37+
and over-200-line metadata as `invalid-metadata`.
38+
39+
### `removeSessionGeneration(name: string, expectedGeneration: string): Promise<RemoveSessionGenerationResult>`
40+
41+
Remove all PTY artifacts only when the retained metadata still belongs to the
42+
given opaque generation and its daemon is gone. Results distinguish `removed`,
43+
`missing`, `generation-mismatch`, `not-terminal`, `invalid-metadata`, and
44+
`busy`. A replacement generation is never removed. Cleanup errors other than
45+
absence are thrown, and metadata is removed last so failed cleanup retains the
46+
evidence for retry.
47+
48+
Rust and other non-TypeScript consumers can use the equivalent machine-only
49+
CLI boundary. Both operations address only an immutable stable id, emit exactly
50+
one tagged JSON document on stdout, and exit 0 for semantic outcomes:
51+
52+
```sh
53+
pty evidence snapshot --id a1b2c3d4
54+
pty evidence remove --id a1b2c3d4 --expected-generation 7f44b35e
55+
```
56+
57+
Invalid arguments and operational failures exit nonzero with a diagnostic on
58+
stderr. A reconciler should durably consume the snapshot before passing its
59+
opaque generation to `remove`; a mismatch must leave the replacement intact.
60+
2661
### `validateName(name: string): void`
2762

28-
Throws if the name is invalid. Names must match `[a-zA-Z0-9._-]` and be at most 255 characters.
63+
Throws if the name is invalid. Names must match `[a-zA-Z0-9._-]`, cannot be
64+
`.` or `..`, and are at most 255 characters.
2965

3066
### `patchMetadataById(id: string, patch: MetadataPatch): Promise<MetadataPatchResult>`
3167

docs/vrs/requirements.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,11 @@ implementation contract and validation map live in [spec.md](./spec.md).
100100
remote routing preserve the applicable runtime, stream, geometry, registry,
101101
and lifecycle contracts. A surface rejects unsupported capabilities instead
102102
of silently weakening them; tests use real PTYs and processes.
103+
- **R12 Exact-generation retained exit evidence:** Supported client and CLI
104+
surfaces expose one bounded, tagged snapshot of retained terminal evidence
105+
for an exact stable id and opaque generation, distinguishing exited,
106+
vanished, live, missing, busy, unavailable, and invalid state. Conditional
107+
cleanup removes artifacts only while that same terminal generation remains;
108+
it never removes a live or replacement generation. Semantic outcomes and
109+
operational failures are machine-distinguishable, and validation covers the
110+
snapshot-to-cleanup race with real processes.

docs/vrs/spec.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,119 @@ Explicit lifecycle commands and `gc` own mutation. Cleanup is authorized by the
174174
observed generation; removal wins over late daemon finalization, and permanent
175175
respawn cannot overwrite a replacement (R03, R10).
176176

177+
### Retained exit evidence
178+
179+
An evidence consumer separates observation from cleanup (R12):
180+
181+
```text
182+
exact stable id
183+
|
184+
v
185+
bounded snapshot -- opaque generation --> durable consumer action
186+
|
187+
v
188+
conditional same-generation remove
189+
|
190+
+-------------------+--------------------+
191+
| | |
192+
removed semantic refusal cleanup I/O failure
193+
| | |
194+
artifacts absent artifacts untouched metadata retained;
195+
cleanup may be partial
196+
```
197+
198+
The exported client API and machine CLI share these tagged result schemas:
199+
200+
```typescript
201+
type ExitEvidenceTail =
202+
| { _tag: "present"; lastLines: string[] }
203+
| { _tag: "unavailable" }
204+
205+
type ExitEvidenceResult =
206+
| {
207+
_tag: "snapshot"
208+
snapshot: {
209+
name: string
210+
generation: string
211+
status: "exited" | "vanished"
212+
exitCode: number | null
213+
stream: "combined"
214+
tail: ExitEvidenceTail
215+
}
216+
}
217+
| {
218+
_tag: "unavailable"
219+
reason:
220+
| "missing"
221+
| "running"
222+
| "busy"
223+
| "generation-unavailable"
224+
| "invalid-metadata"
225+
}
226+
227+
type RemoveGenerationResult =
228+
| { _tag: "removed" }
229+
| { _tag: "missing" }
230+
| { _tag: "generation-mismatch" }
231+
| { _tag: "not-terminal" }
232+
| { _tag: "invalid-metadata" }
233+
| { _tag: "busy" }
234+
```
235+
236+
Snapshot and remove accept an exact filename-safe stable id, never a mutable
237+
display-name reference. The generation is an opaque, nonempty token returned by
238+
the snapshot; callers compare or return it without interpreting its format.
239+
`exited` carries the recorded integer exit code, while `vanished` has a null
240+
exit code because no terminal exit record exists. `tail.present` is the exact
241+
persisted combined-stream line array, including an empty array;
242+
`tail.unavailable` means no retained tail was persisted.
243+
244+
The evidence metadata reader opens the metadata path read-only, nonblocking,
245+
and without following symlinks. It requires a regular file no larger than 1
246+
MiB and a JSON object with these structural constraints:
247+
248+
| Field | Constraint |
249+
| --- | --- |
250+
| `generation` | required nonempty string |
251+
| `daemonPid` | optional positive integer |
252+
| `exitedAt`, `exitCode` | both absent, or nonempty string plus integer |
253+
| `lastLines` | optional array of at most 200 strings |
254+
255+
Unknown compatible fields are ignored. Malformed JSON, oversized or nonregular
256+
artifacts, symlinks, invalid field types, mixed terminal fields, and over-bound
257+
tails produce `invalid-metadata`; they are neither truncated nor treated as
258+
missing. A legacy record without `generation` is explicitly
259+
`generation-unavailable` for snapshot and `generation-mismatch` for removal.
260+
261+
Snapshot holds the stable-id creation lock across metadata validation and the
262+
daemon-generation liveness check. Conditional removal serializes event and
263+
creation mutation, then applies this order:
264+
265+
1. read and structurally validate metadata;
266+
2. compare the retained opaque generation with the expected generation;
267+
3. refuse a live matching generation;
268+
4. re-read, revalidate, and compare the generation;
269+
5. remove socket, pid, event, and recovery-revision artifacts;
270+
6. remove metadata last, preserving evidence if an earlier cleanup step fails;
271+
7. release both locks.
272+
273+
Steps 5 and 6 are not transactional. If step 5 fails, auxiliary artifacts
274+
removed earlier in the sequence remain absent, while metadata remains as
275+
retryable exact-generation evidence. No rollback is attempted. Missing
276+
artifacts are idempotent. Contention and semantic refusal happen before cleanup
277+
mutation and return tagged results. Argument, metadata-cleanup I/O, and
278+
output-transport failures remain operational errors rather than success-shaped
279+
results. The CLI output law is:
280+
281+
| CLI outcome | stdout | stderr | exit status |
282+
| --- | --- | --- | --- |
283+
| semantic snapshot/remove result | exactly one tagged JSON document | empty | 0 |
284+
| argument or operational failure | no success JSON | diagnostic | nonzero |
285+
286+
`evidence snapshot --id` and `evidence remove --id --expected-generation`
287+
provide leaf-specific help and completion schemas; snapshot never advertises
288+
the remove-only generation option (R11, R12).
289+
177290
### Live registry recovery
178291
179292
A supporting daemon may publish an opaque recovery capability only when it can
@@ -220,6 +333,7 @@ input, resize, and multi-client geometry without mocks.
220333
| R09 | [sessions](../../src/sessions.ts), [server](../../src/server.ts), [recovery](../../src/recovery.ts), [CLI](../../src/cli.ts) | [root](../../tests/pty-root.test.ts), [display name](../../tests/display-name.test.ts), [status](../../tests/stats-cli.test.ts), [list purity](../../tests/list-purity.test.ts), [recovery](../../tests/recovery.test.ts) |
221334
| R10 | [sessions](../../src/sessions.ts), [events](../../src/events.ts), [recovery](../../src/recovery.ts), [protocol](../../src/protocol.ts) | [atomic writes](../../tests/atomic-writes.test.ts), [metadata events](../../tests/metadata-events.test.ts), [events](../../tests/events.test.ts), [recovery](../../tests/recovery.test.ts), [disk layout](../../tests/disk-layout-docs.test.ts) |
222335
| R11 | [CLI](../../src/cli.ts), [client API](../../src/client-api.ts), [remote](../../src/remote.ts), [testing API](../../src/testing/index.ts) | [help](../../tests/help.test.ts), [completions](../../tests/completions.test.ts), [remote](../../tests/remote-fabric.test.ts), [screenshots](../../tests/screenshot.test.ts), [keys](../../tests/keys.test.ts) |
336+
| R12 | [sessions](../../src/sessions.ts), [server](../../src/server.ts), [client API](../../src/client-api.ts), [CLI](../../src/cli.ts), [completions](../../src/completions.ts) | [exit evidence](../../tests/exit-reap.test.ts), [generation guard](../../tests/gc-generation-guard.test.ts), [immediate reuse](../../tests/rm-immediate-reuse.test.ts), [help](../../tests/help.test.ts), [completions](../../tests/completions.test.ts), [security](../../tests/security-fixes.test.ts) |
223337
224338
`node scripts/verify-docs.ts --vrs-only` validates this two-document shape,
225339
sequential requirement IDs, links, and complete requirement references.

scripts/verify-docs.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ function verifyVrs(): void {
2020
const requirementsPath = path.join(vrsRoot, "requirements.md");
2121
const specPath = path.join(vrsRoot, "spec.md");
2222
const errors: string[] = [];
23+
let requirementCount = 0;
2324

2425
if (actual.join("\n") !== expected.join("\n")) {
2526
errors.push(`docs/vrs must contain only ${expected.join(" and ")}`);
@@ -32,6 +33,7 @@ function verifyVrs(): void {
3233
const ids = [...requirements.matchAll(/^- \*\*(R\d{2}) [^*]+:\*\*/gm)].map(
3334
(match) => match[1],
3435
);
36+
requirementCount = ids.length;
3537

3638
if (ids.length === 0) errors.push("requirements.md defines no requirement IDs");
3739
if (!ids.every((id, index) => id === `R${String(index + 1).padStart(2, "0")}`)) {
@@ -67,7 +69,7 @@ function verifyVrs(): void {
6769
console.error(`VRS verification failed:\n${errors.map((error) => `- ${error}`).join("\n")}`);
6870
process.exit(1);
6971
}
70-
console.log("Verified 2 VRS documents and 11 requirement IDs");
72+
console.log(`Verified 2 VRS documents and ${requirementCount} requirement IDs`);
7173
}
7274

7375
verifyVrs();

0 commit comments

Comments
 (0)