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
6 changes: 6 additions & 0 deletions docs/disk-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,15 @@ Pretty-printed JSON. Source of truth: `SessionMetadata` in `src/sessions.ts`.
tags?: { [k: string]: string };
displayName?: string;
lastAttachAt?: string; // ISO 8601 — set by the daemon on every non-readonly ATTACH
lastOutputAtMs?: number; // unix ms — newest PTY output observed by the daemon
}
```

`lastOutputAtMs` is absent until the daemon observes output. While output
continues, a trailing-edge debounce persists the newest stamp at most once per
second; exit finalization carries the final in-memory value even when a debounce
is pending.

`unsetEnv` and `extraEnv` form the persisted inherited-environment policy.
Removals are applied first and explicit assignments second, so an assignment
wins when both mention the same key. Older metadata without `unsetEnv` keeps
Expand Down
11 changes: 11 additions & 0 deletions docs/vrs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ implementation contract and validation map live in [spec.md](./spec.md).
not authorization.
- **A03 Terminal semantics:** Child output is an ordered terminal byte stream.
Reconstructing it requires a terminal emulator rather than line-oriented logs.
- **A04 Output observation:** The per-session daemon necessarily observes every
PTY output chunk to maintain terminal state. Recording when output last
occurred adds no second observer and carries no launcher or harness semantics.

## Acceptable tradeoffs

Expand Down Expand Up @@ -113,3 +116,11 @@ implementation contract and validation map live in [spec.md](./spec.md).
`-`, or `_` separators, including compact `C-` control notation. Invalid,
incomplete, or ambiguous key specs fail before any sequence bytes are sent;
their diagnostics state the accepted modifiers, notation, and key names.
- **R14 Durable output-activity evidence:** Session metadata exposes an optional
unix-millisecond `lastOutputAtMs` timestamp. The daemon stamps it in the
existing output path and persists the newest value through the same locked,
generation-aware metadata mutation, debounced to at most one write per second
per busy session. Exit finalization carries the final in-memory stamp. The
field reports evidence only: PTY does not classify active/idle, infer liveness,
or authorize lifecycle/delivery behavior. Older records without the field
remain valid.
24 changes: 24 additions & 0 deletions docs/vrs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,29 @@ Metadata and events form two compatibility tiers (R10):
| event JSONL | externally readable observation stream; serialized append and bounded retention |
| socket packets | internal bounded protocol with documented legacy decoding fallbacks |

### Output-activity evidence

Session metadata may carry:

```ts
lastOutputAtMs?: number
```

The value is unix milliseconds for the newest PTY output chunk the daemon has
processed. The `onData` path stamps the value in memory before feeding the same
chunk to the headless terminal and clients. A trailing-edge one-second debounce
persists the newest stamp through the locked metadata mutation; further chunks
inside the window coalesce into that write. Child exit persists the final
in-memory stamp with exit metadata, so a pending debounce cannot lose the last
output observation (R14).

The timestamp is deliberately numeric: consumers performing freshness
arithmetic need no RFC3339 parser, and other runtime/state contracts already use
unix milliseconds. It is evidence rather than interpretation — PTY does not
define an activity threshold or label a session active/idle. Missing
`lastOutputAtMs` means no durable output observation (a new silent session or a
record from an older daemon), never zero or idle.

Explicit lifecycle commands and `gc` own mutation. Cleanup is authorized by the
observed generation; removal wins over late daemon finalization, and permanent
respawn cannot overwrite a replacement (R03, R10).
Expand Down Expand Up @@ -363,6 +386,7 @@ invocation from being delivered.
| 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) |
| 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) |
| 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) |
| R14 | [server](../../src/server.ts), [sessions](../../src/sessions.ts) | [output activity](../../tests/output-activity.test.ts), [disk layout](../../tests/disk-layout-docs.test.ts) |

`node scripts/verify-docs.ts --vrs-only` validates this two-document shape,
sequential requirement IDs, links, and complete requirement references.
37 changes: 37 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,14 @@ export class PtyServer {
private clients = new Map<net.Socket, Client>();
private exited = false;
private exitCode = 0;
/** Epoch ms of the last PTY output chunk this daemon processed. Stamped in
* the onData path (O(1) — the chunk is already being parsed), persisted to
* session metadata through the debounced `scheduleActivityPersist` so a
* chatty session costs at most one metadata write per second. Consumers
* (st2 observed harness state) read the persisted value to derive session
* activity; this field is the in-memory source of truth between persists. */
private lastOutputAtMs = 0;
private activityPersistScheduled = false;
private name: string;
private options: ServerOptions;
private attachCounter = 0;
Expand Down Expand Up @@ -554,6 +562,8 @@ export class PtyServer {
// handlers above and must NOT be forwarded to clients — otherwise the
// client's terminal responds and its response appears as garbage input.
this.ptyProcess.onData((data: string) => {
this.lastOutputAtMs = Date.now();
this.scheduleActivityPersist();
this.terminal.write(data);
const cleaned = stripTerminalQueries(data);
if (cleaned.length > 0) {
Expand Down Expand Up @@ -1301,11 +1311,38 @@ export class PtyServer {
return lines.slice(-SESSION_EXIT_LAST_LINES_LIMIT);
}

/** Trailing-edge debounce: the first output chunk after an idle period
* schedules one persist ~1s out; bursts inside the window coalesce into
* that single write carrying the newest stamp. Skipped once exited — the
* exit path persists the final stamp via `saveExitMetadata`. */
private scheduleActivityPersist(): void {
if (this.activityPersistScheduled) return;
this.activityPersistScheduled = true;
setTimeout(() => {
this.activityPersistScheduled = false;
if (this.exited || this.lastOutputAtMs === 0) return;
const stampedAtMs = this.lastOutputAtMs;
try {
mutateMetadataUnderLock(this.name, (metadata) => {
if (metadata.lastOutputAtMs === stampedAtMs) return false;
metadata.lastOutputAtMs = stampedAtMs;
return true;
});
} catch {
// Best-effort: a lost activity stamp reads as a slightly staler
// activity sample; it must never take the daemon down.
}
}, 1000);
}

private saveExitMetadata(exitCode: number): MetadataMutationResult["status"] {
const result = mutateMetadataUnderLock(this.name, (metadata) => {
metadata.exitCode = exitCode;
metadata.exitedAt = new Date().toISOString();
metadata.lastLines = this.getLastLines();
if (this.lastOutputAtMs > 0) {
metadata.lastOutputAtMs = this.lastOutputAtMs;
}
return true;
}, { expectedGeneration: this.generation });
return result.status;
Expand Down
8 changes: 8 additions & 0 deletions src/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,14 @@ export interface SessionMetadata {
* client attach — those are excluded from idle-reap (a session that
* was just spawned but not yet attached to isn't "idle"). */
lastAttachAt?: string;
/** Unix-millisecond timestamp of the last PTY output chunk the daemon processed.
* Written by the daemon, debounced to at most one persist per second while
* output flows (the daemon already parses every byte, so stamping is O(1)
* and adds no observation machinery). Absent on sessions that have produced
* no output yet. Consumers — e.g. st2's observed harness state — derive
* session activity from this; it is an activity signal, not a delivery or
* liveness signal. */
lastOutputAtMs?: number;
}

export interface SessionInfo {
Expand Down
190 changes: 190 additions & 0 deletions tests/output-activity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// Tests for the daemon-stamped `lastOutputAtMs` session metadata field: the
// daemon stamps every PTY output chunk in-memory and persists it debounced
// (≤1 write/second) so downstream consumers (st2 observed harness state) can
// derive session activity without observing the output stream themselves.
//
// These are integration tests against a real daemon process: the debounce
// timer lives in the daemon, not in this process, so fake timers cannot drive
// it — the real platform clock is the system under test.

import { describe, it, expect, afterEach, afterAll } from "vitest";
import { terminateAndWait } from "./setup/processes.ts";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { spawn, spawnSync } from "node:child_process";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const nodeBin = process.execPath;
const cliPath = path.join(__dirname, "..", "dist", "cli.js");
const serverModule = path.join(__dirname, "..", "dist", "server.js");

const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-activity-"));

const bgPids: number[] = [];
const sessionDirs: string[] = [];
const runningDaemons: { sessionDir: string; name: string }[] = [];

function makeSessionDir(): string {
const dir = fs.mkdtempSync(path.join(testRoot, "d-"));
sessionDirs.push(dir);
return dir;
}

let nameCounter = 0;
function uniqueName(): string {
return `act${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`;
}

function sleep(ms: number): Promise<void> {
// Executor form: the repo's tsconfig lib predates Promise.withResolvers.
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}

async function startDaemon(
sessionDir: string,
name: string,
command = "cat",
args: string[] = [],
): Promise<void> {
const config = JSON.stringify({
name, command, args, displayCommand: [command, ...args].join(" "),
cwd: os.tmpdir(), rows: 24, cols: 80,
});
const child = spawn(nodeBin, [serverModule], {
detached: true,
stdio: ["ignore", "ignore", "pipe"],
env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir },
});
let stderr = "";
child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); });
let exitCode: number | null = null;
child.on("exit", (code) => { exitCode = code; });
child.unref();

const socketPath = path.join(sessionDir, `${name}.sock`);
const start = Date.now();
while (Date.now() - start < 5000) {
if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`);
if (fs.existsSync(socketPath)) {
await sleep(100);
bgPids.push(child.pid!);
return;
}
await sleep(50);
}
throw new Error("Timeout waiting for daemon");
}

function runCli(sessionDir: string, ...args: string[]) {
return spawnSync(nodeBin, [cliPath, ...args], {
env: { ...process.env, PTY_SESSION_DIR: sessionDir },
encoding: "utf-8",
timeout: 10_000,
});
}

function readMetadata(sessionDir: string, name: string): Record<string, unknown> {
const raw: unknown = JSON.parse(fs.readFileSync(path.join(sessionDir, `${name}.json`), "utf8"));
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
throw new Error("Session metadata must be a JSON object");
}
return raw as Record<string, unknown>;
}

function readLastOutputAtMs(sessionDir: string, name: string): number | undefined {
const value = readMetadata(sessionDir, name).lastOutputAtMs;
return typeof value === "number" ? value : undefined;
}

async function waitFor(
poll: () => boolean,
timeoutMs = 5000,
stepMs = 100,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (poll()) return;
await sleep(stepMs);
}
throw new Error("Condition not met within timeout");
}

afterEach(async () => {
const pids: number[] = [];
while (runningDaemons.length > 0) {
const { sessionDir, name } = runningDaemons.pop()!;
try {
pids.push(parseInt(fs.readFileSync(path.join(sessionDir, `${name}.pid`), "utf8"), 10));
} catch {}
}
// Await before the afterAll rmtree: a daemon that is still writing would
// race the directory removal with ENOTEMPTY.
if (pids.length > 0) await terminateAndWait(pids);
});

afterAll(() => {
fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
});

describe("lastOutputAtMs session activity stamp", () => {
it("is absent before the session produces any output", async () => {
const sessionDir = makeSessionDir();
const name = uniqueName();
await startDaemon(sessionDir, name);
runningDaemons.push({ sessionDir, name });

expect(readLastOutputAtMs(sessionDir, name)).toBeUndefined();
});

it("appears after output and carries a recent unix-millisecond timestamp", async () => {
const sessionDir = makeSessionDir();
const name = uniqueName();
await startDaemon(sessionDir, name);
runningDaemons.push({ sessionDir, name });

const before = Date.now();
// cat echoes stdin back — one line in, one chunk of PTY output out.
const sent = runCli(sessionDir, "send", name, "--seq", "activity-probe", "--seq", "key:return");
expect(sent.status).toBe(0);

await waitFor(() => readLastOutputAtMs(sessionDir, name) !== undefined);

const stampedAt = readLastOutputAtMs(sessionDir, name)!;
expect(stampedAt).toBeGreaterThanOrEqual(before - 1000);
expect(stampedAt).toBeLessThanOrEqual(Date.now() + 1000);
});

it("updates the stamp on subsequent output bursts", async () => {
const sessionDir = makeSessionDir();
const name = uniqueName();
await startDaemon(sessionDir, name);
runningDaemons.push({ sessionDir, name });

runCli(sessionDir, "send", name, "--seq", "first", "--seq", "key:return");
await waitFor(() => readLastOutputAtMs(sessionDir, name) !== undefined);
const first = readLastOutputAtMs(sessionDir, name)!;

// Wait out the 1s debounce window so the second burst cannot coalesce
// into the first persist, then require the stamp to move forward.
await sleep(1600);
runCli(sessionDir, "send", name, "--seq", "second", "--seq", "key:return");
await waitFor(() => {
const stamp = readLastOutputAtMs(sessionDir, name);
return stamp !== undefined && stamp > first;
}, 5000);
});

it("carries the final output stamp into exit metadata before debounce", async () => {
const sessionDir = makeSessionDir();
const name = uniqueName();
await startDaemon(sessionDir, name, "sh", ["-c", "printf final-output"]);
runningDaemons.push({ sessionDir, name });

// The pending activity timer skips once exited, so observing both fields
// after exit proves saveExitMetadata carried the in-memory final stamp.
await waitFor(() => typeof readMetadata(sessionDir, name).exitCode === "number");
expect(readLastOutputAtMs(sessionDir, name)).toEqual(expect.any(Number));
});
});
Loading