Skip to content

Commit f990e38

Browse files
authored
Merge pull request #168 from compoundingtech/schickling-assistant/session-activity-stamp
feat: persist last PTY output timestamp
2 parents 500eab2 + 1bd7f9b commit f990e38

6 files changed

Lines changed: 276 additions & 0 deletions

File tree

docs/disk-layout.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,15 @@ Pretty-printed JSON. Source of truth: `SessionMetadata` in `src/sessions.ts`.
6666
tags?: { [k: string]: string };
6767
displayName?: string;
6868
lastAttachAt?: string; // ISO 8601 — set by the daemon on every non-readonly ATTACH
69+
lastOutputAtMs?: number; // unix ms — newest PTY output observed by the daemon
6970
}
7071
```
7172

73+
`lastOutputAtMs` is absent until the daemon observes output. While output
74+
continues, a trailing-edge debounce persists the newest stamp at most once per
75+
second; exit finalization carries the final in-memory value even when a debounce
76+
is pending.
77+
7278
`unsetEnv` and `extraEnv` form the persisted inherited-environment policy.
7379
Removals are applied first and explicit assignments second, so an assignment
7480
wins when both mention the same key. Older metadata without `unsetEnv` keeps

docs/vrs/requirements.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ implementation contract and validation map live in [spec.md](./spec.md).
1616
not authorization.
1717
- **A03 Terminal semantics:** Child output is an ordered terminal byte stream.
1818
Reconstructing it requires a terminal emulator rather than line-oriented logs.
19+
- **A04 Output observation:** The per-session daemon necessarily observes every
20+
PTY output chunk to maintain terminal state. Recording when output last
21+
occurred adds no second observer and carries no launcher or harness semantics.
1922

2023
## Acceptable tradeoffs
2124

@@ -113,3 +116,11 @@ implementation contract and validation map live in [spec.md](./spec.md).
113116
`-`, or `_` separators, including compact `C-` control notation. Invalid,
114117
incomplete, or ambiguous key specs fail before any sequence bytes are sent;
115118
their diagnostics state the accepted modifiers, notation, and key names.
119+
- **R14 Durable output-activity evidence:** Session metadata exposes an optional
120+
unix-millisecond `lastOutputAtMs` timestamp. The daemon stamps it in the
121+
existing output path and persists the newest value through the same locked,
122+
generation-aware metadata mutation, debounced to at most one write per second
123+
per busy session. Exit finalization carries the final in-memory stamp. The
124+
field reports evidence only: PTY does not classify active/idle, infer liveness,
125+
or authorize lifecycle/delivery behavior. Older records without the field
126+
remain valid.

docs/vrs/spec.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,29 @@ Metadata and events form two compatibility tiers (R10):
170170
| event JSONL | externally readable observation stream; serialized append and bounded retention |
171171
| socket packets | internal bounded protocol with documented legacy decoding fallbacks |
172172

173+
### Output-activity evidence
174+
175+
Session metadata may carry:
176+
177+
```ts
178+
lastOutputAtMs?: number
179+
```
180+
181+
The value is unix milliseconds for the newest PTY output chunk the daemon has
182+
processed. The `onData` path stamps the value in memory before feeding the same
183+
chunk to the headless terminal and clients. A trailing-edge one-second debounce
184+
persists the newest stamp through the locked metadata mutation; further chunks
185+
inside the window coalesce into that write. Child exit persists the final
186+
in-memory stamp with exit metadata, so a pending debounce cannot lose the last
187+
output observation (R14).
188+
189+
The timestamp is deliberately numeric: consumers performing freshness
190+
arithmetic need no RFC3339 parser, and other runtime/state contracts already use
191+
unix milliseconds. It is evidence rather than interpretation — PTY does not
192+
define an activity threshold or label a session active/idle. Missing
193+
`lastOutputAtMs` means no durable output observation (a new silent session or a
194+
record from an older daemon), never zero or idle.
195+
173196
Explicit lifecycle commands and `gc` own mutation. Cleanup is authorized by the
174197
observed generation; removal wins over late daemon finalization, and permanent
175198
respawn cannot overwrite a replacement (R03, R10).
@@ -363,6 +386,7 @@ invocation from being delivered.
363386
| 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) |
364387
| 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) |
365388
| R13 | [keys](../../src/keys.ts), [CLI](../../src/cli.ts) | [keys](../../tests/keys.test.ts), [send CLI](../../tests/send-paste.test.ts), [help](../../tests/help.test.ts) |
389+
| R14 | [server](../../src/server.ts), [sessions](../../src/sessions.ts) | [output activity](../../tests/output-activity.test.ts), [disk layout](../../tests/disk-layout-docs.test.ts) |
366390
367391
`node scripts/verify-docs.ts --vrs-only` validates this two-document shape,
368392
sequential requirement IDs, links, and complete requirement references.

src/server.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,14 @@ export class PtyServer {
282282
private clients = new Map<net.Socket, Client>();
283283
private exited = false;
284284
private exitCode = 0;
285+
/** Epoch ms of the last PTY output chunk this daemon processed. Stamped in
286+
* the onData path (O(1) — the chunk is already being parsed), persisted to
287+
* session metadata through the debounced `scheduleActivityPersist` so a
288+
* chatty session costs at most one metadata write per second. Consumers
289+
* (st2 observed harness state) read the persisted value to derive session
290+
* activity; this field is the in-memory source of truth between persists. */
291+
private lastOutputAtMs = 0;
292+
private activityPersistScheduled = false;
285293
private name: string;
286294
private options: ServerOptions;
287295
private attachCounter = 0;
@@ -561,6 +569,8 @@ export class PtyServer {
561569
// handlers above and must NOT be forwarded to clients — otherwise the
562570
// client's terminal responds and its response appears as garbage input.
563571
this.ptyProcess.onData((data: string) => {
572+
this.lastOutputAtMs = Date.now();
573+
this.scheduleActivityPersist();
564574
this.terminal.write(data);
565575
const cleaned = stripTerminalQueries(data);
566576
if (cleaned.length > 0) {
@@ -1308,11 +1318,38 @@ export class PtyServer {
13081318
return lines.slice(-SESSION_EXIT_LAST_LINES_LIMIT);
13091319
}
13101320

1321+
/** Trailing-edge debounce: the first output chunk after an idle period
1322+
* schedules one persist ~1s out; bursts inside the window coalesce into
1323+
* that single write carrying the newest stamp. Skipped once exited — the
1324+
* exit path persists the final stamp via `saveExitMetadata`. */
1325+
private scheduleActivityPersist(): void {
1326+
if (this.activityPersistScheduled) return;
1327+
this.activityPersistScheduled = true;
1328+
setTimeout(() => {
1329+
this.activityPersistScheduled = false;
1330+
if (this.exited || this.lastOutputAtMs === 0) return;
1331+
const stampedAtMs = this.lastOutputAtMs;
1332+
try {
1333+
mutateMetadataUnderLock(this.name, (metadata) => {
1334+
if (metadata.lastOutputAtMs === stampedAtMs) return false;
1335+
metadata.lastOutputAtMs = stampedAtMs;
1336+
return true;
1337+
});
1338+
} catch {
1339+
// Best-effort: a lost activity stamp reads as a slightly staler
1340+
// activity sample; it must never take the daemon down.
1341+
}
1342+
}, 1000);
1343+
}
1344+
13111345
private saveExitMetadata(exitCode: number): MetadataMutationResult["status"] {
13121346
const result = mutateMetadataUnderLock(this.name, (metadata) => {
13131347
metadata.exitCode = exitCode;
13141348
metadata.exitedAt = new Date().toISOString();
13151349
metadata.lastLines = this.getLastLines();
1350+
if (this.lastOutputAtMs > 0) {
1351+
metadata.lastOutputAtMs = this.lastOutputAtMs;
1352+
}
13161353
return true;
13171354
}, { expectedGeneration: this.generation });
13181355
return result.status;

src/sessions.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,14 @@ export interface SessionMetadata {
178178
* client attach — those are excluded from idle-reap (a session that
179179
* was just spawned but not yet attached to isn't "idle"). */
180180
lastAttachAt?: string;
181+
/** Unix-millisecond timestamp of the last PTY output chunk the daemon processed.
182+
* Written by the daemon, debounced to at most one persist per second while
183+
* output flows (the daemon already parses every byte, so stamping is O(1)
184+
* and adds no observation machinery). Absent on sessions that have produced
185+
* no output yet. Consumers — e.g. st2's observed harness state — derive
186+
* session activity from this; it is an activity signal, not a delivery or
187+
* liveness signal. */
188+
lastOutputAtMs?: number;
181189
}
182190

183191
export interface SessionInfo {

tests/output-activity.test.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// Tests for the daemon-stamped `lastOutputAtMs` session metadata field: the
2+
// daemon stamps every PTY output chunk in-memory and persists it debounced
3+
// (≤1 write/second) so downstream consumers (st2 observed harness state) can
4+
// derive session activity without observing the output stream themselves.
5+
//
6+
// These are integration tests against a real daemon process: the debounce
7+
// timer lives in the daemon, not in this process, so fake timers cannot drive
8+
// it — the real platform clock is the system under test.
9+
10+
import { describe, it, expect, afterEach, afterAll } from "vitest";
11+
import { terminateAndWait } from "./setup/processes.ts";
12+
import * as fs from "node:fs";
13+
import * as os from "node:os";
14+
import * as path from "node:path";
15+
import { fileURLToPath } from "node:url";
16+
import { spawn, spawnSync } from "node:child_process";
17+
18+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
19+
const nodeBin = process.execPath;
20+
const cliPath = path.join(__dirname, "..", "dist", "cli.js");
21+
const serverModule = path.join(__dirname, "..", "dist", "server.js");
22+
23+
const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-activity-"));
24+
25+
const bgPids: number[] = [];
26+
const sessionDirs: string[] = [];
27+
const runningDaemons: { sessionDir: string; name: string }[] = [];
28+
29+
function makeSessionDir(): string {
30+
const dir = fs.mkdtempSync(path.join(testRoot, "d-"));
31+
sessionDirs.push(dir);
32+
return dir;
33+
}
34+
35+
let nameCounter = 0;
36+
function uniqueName(): string {
37+
return `act${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`;
38+
}
39+
40+
function sleep(ms: number): Promise<void> {
41+
// Executor form: the repo's tsconfig lib predates Promise.withResolvers.
42+
return new Promise<void>((resolve) => setTimeout(resolve, ms));
43+
}
44+
45+
async function startDaemon(
46+
sessionDir: string,
47+
name: string,
48+
command = "cat",
49+
args: string[] = [],
50+
): Promise<void> {
51+
const config = JSON.stringify({
52+
name, command, args, displayCommand: [command, ...args].join(" "),
53+
cwd: os.tmpdir(), rows: 24, cols: 80,
54+
});
55+
const child = spawn(nodeBin, [serverModule], {
56+
detached: true,
57+
stdio: ["ignore", "ignore", "pipe"],
58+
env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir },
59+
});
60+
let stderr = "";
61+
child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); });
62+
let exitCode: number | null = null;
63+
child.on("exit", (code) => { exitCode = code; });
64+
child.unref();
65+
66+
const socketPath = path.join(sessionDir, `${name}.sock`);
67+
const start = Date.now();
68+
while (Date.now() - start < 5000) {
69+
if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`);
70+
if (fs.existsSync(socketPath)) {
71+
await sleep(100);
72+
bgPids.push(child.pid!);
73+
return;
74+
}
75+
await sleep(50);
76+
}
77+
throw new Error("Timeout waiting for daemon");
78+
}
79+
80+
function runCli(sessionDir: string, ...args: string[]) {
81+
return spawnSync(nodeBin, [cliPath, ...args], {
82+
env: { ...process.env, PTY_SESSION_DIR: sessionDir },
83+
encoding: "utf-8",
84+
timeout: 10_000,
85+
});
86+
}
87+
88+
function readMetadata(sessionDir: string, name: string): Record<string, unknown> {
89+
const raw: unknown = JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf8"));
90+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
91+
throw new Error("Session metadata must be a JSON object");
92+
}
93+
return raw as Record<string, unknown>;
94+
}
95+
96+
function readLastOutputAtMs(sessionDir: string, name: string): number | undefined {
97+
const value = readMetadata(sessionDir, name).lastOutputAtMs;
98+
return typeof value === "number" ? value : undefined;
99+
}
100+
101+
async function waitFor(
102+
poll: () => boolean,
103+
timeoutMs = 5000,
104+
stepMs = 100,
105+
): Promise<void> {
106+
const start = Date.now();
107+
while (Date.now() - start < timeoutMs) {
108+
if (poll()) return;
109+
await sleep(stepMs);
110+
}
111+
throw new Error("Condition not met within timeout");
112+
}
113+
114+
afterEach(async () => {
115+
const pids: number[] = [];
116+
while (runningDaemons.length > 0) {
117+
const { sessionDir, name } = runningDaemons.pop()!;
118+
try {
119+
pids.push(parseInt(fs.readFileSync(path.join(sessionDir, `${name}.pid`), "utf8"), 10));
120+
} catch {}
121+
}
122+
// Await before the afterAll rmtree: a daemon that is still writing would
123+
// race the directory removal with ENOTEMPTY.
124+
if (pids.length > 0) await terminateAndWait(pids);
125+
});
126+
127+
afterAll(() => {
128+
fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
129+
});
130+
131+
describe("lastOutputAtMs session activity stamp", () => {
132+
it("is absent before the session produces any output", async () => {
133+
const sessionDir = makeSessionDir();
134+
const name = uniqueName();
135+
await startDaemon(sessionDir, name);
136+
runningDaemons.push({ sessionDir, name });
137+
138+
expect(readLastOutputAtMs(sessionDir, name)).toBeUndefined();
139+
});
140+
141+
it("appears after output and carries a recent unix-millisecond timestamp", async () => {
142+
const sessionDir = makeSessionDir();
143+
const name = uniqueName();
144+
await startDaemon(sessionDir, name);
145+
runningDaemons.push({ sessionDir, name });
146+
147+
const before = Date.now();
148+
// cat echoes stdin back — one line in, one chunk of PTY output out.
149+
const sent = runCli(sessionDir, "send", name, "--seq", "activity-probe", "--seq", "key:return");
150+
expect(sent.status).toBe(0);
151+
152+
await waitFor(() => readLastOutputAtMs(sessionDir, name) !== undefined);
153+
154+
const stampedAt = readLastOutputAtMs(sessionDir, name)!;
155+
expect(stampedAt).toBeGreaterThanOrEqual(before - 1000);
156+
expect(stampedAt).toBeLessThanOrEqual(Date.now() + 1000);
157+
});
158+
159+
it("updates the stamp on subsequent output bursts", async () => {
160+
const sessionDir = makeSessionDir();
161+
const name = uniqueName();
162+
await startDaemon(sessionDir, name);
163+
runningDaemons.push({ sessionDir, name });
164+
165+
runCli(sessionDir, "send", name, "--seq", "first", "--seq", "key:return");
166+
await waitFor(() => readLastOutputAtMs(sessionDir, name) !== undefined);
167+
const first = readLastOutputAtMs(sessionDir, name)!;
168+
169+
// Wait out the 1s debounce window so the second burst cannot coalesce
170+
// into the first persist, then require the stamp to move forward.
171+
await sleep(1600);
172+
runCli(sessionDir, "send", name, "--seq", "second", "--seq", "key:return");
173+
await waitFor(() => {
174+
const stamp = readLastOutputAtMs(sessionDir, name);
175+
return stamp !== undefined && stamp > first;
176+
}, 5000);
177+
});
178+
179+
it("carries the final output stamp into exit metadata before debounce", async () => {
180+
const sessionDir = makeSessionDir();
181+
const name = uniqueName();
182+
await startDaemon(sessionDir, name, "sh", ["-c", "printf final-output"]);
183+
runningDaemons.push({ sessionDir, name });
184+
185+
// The pending activity timer skips once exited, so observing both fields
186+
// after exit proves saveExitMetadata carried the in-memory final stamp.
187+
await waitFor(() => typeof readMetadata(sessionDir, name).exitCode === "number");
188+
expect(readLastOutputAtMs(sessionDir, name)).toEqual(expect.any(Number));
189+
});
190+
});

0 commit comments

Comments
 (0)