Files
Michał Pierzchała e832325e87 refactor(substrate): split host mechanics into @agent-device/host-kit capability ports (#2088)
* refactor: split generic host mechanics into @agent-device/host-kit (#2082 W1)

The shared src/utils closure that blocked the platform-family moves lands
on declared owners: generic host mechanics form a new private
@agent-device/host-kit package between kernel and capture-kit, and
capture-kit keeps capture, snapshot, and recording behavior, depending on
host-kit for the mechanics it needs. tar-stream and yauzl move with the
archive code.

Every seam's exported subpaths are pinned in package-boundaries.test.ts,
the layering model ranks the new zone, R13's allow-list names it, and each
seam carries an exact eager-closure row. ADR-0019's substrate amendment
describes the layout.

Tests that mocked two of the moved modules separately became duplicate
same-seam vi.mock factories, where the second silently replaced the first;
those are merged, and the mocks that production code reaches past are
pinned at their injection points instead.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

* refactor(host-kit): one narrow capability port per export

The four technical barrels (exec/fs/values/request) grouped by category
rather than by capability, so a consumer needing one mechanic evaluated
unrelated ones. Each export is now a single capability over the host
machine: command, process, diagnostics, retry, archive, file, request,
version. A port re-exports only what a consumer of that capability uses,
and every port carries its own eager-closure row.

Most of the old values barrel was never host mechanics. Pure record
readers, config-source values, result text, memoization, async scoping,
coordinate validation, and device-scope parsing touch no process, file, or
environment, so they join kernel's other primitives instead.

Closures fall accordingly: capture-kit's png-worker-client from 20 to 10,
png-resize from 28 to 18, session-teardown from 79 to 68, and the CLI from
386 to 380.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

* chore: drop the migration inventories and trim the touched comments

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

* docs: trim the touched host-kit and mutation-lane comments

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

* docs: keep tool directives only in the touched files

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

* docs: keep tool directives only across the touched tree

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

* fix: point the Swift parity comment at the real TS twin and test

The W1 move rewrote this citation to packages/contracts/src/mobile-snapshot-semantics.ts,
which does not exist: the module went to capture-kit while isTapPointInsideViewport itself
went to packages/contracts/src/snapshot-visibility.ts. The TS test line was left pointing at
the pre-move path. Both now resolve.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

* fix: repoint comment citations at the homes this refactor moved them to

The W1 move left ~20 comment citations pointing at src/utils/*.ts and
src/request/*.ts paths that no longer exist. Each now names the capability
port that owns the symbol, which survives further file moves:

  exec -> host-kit/command          host-process, owner-identity -> host-kit/process
  diagnostics -> host-kit/diagnostics   atomic-file, process-lock -> host-kit/file
  retry -> host-kit/retry           request progress/cancel -> host-kit/request
  version -> host-kit/version       ttl-memo, source-value, parsing, device-isolation,
                                    keyed-lock, success-text -> kernel subpaths

Comment-only; no closure, budget, or behavior change. ADR citations are left
as written, being dated records of the decision rather than live references.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018VngeKZH6zBuJzNBk5YzUH

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-28 07:46:48 +02:00

300 lines
12 KiB
TypeScript

// Daemon leak rules (#1781 B1, feeds #1431): given one observation of an
// isolated state dir and the daemon pids a lane observed, decide whether daemon
// lifecycle or durable state leaked. Pure — `daemon-leak-oracle.ts` gathers the
// observation and asserts on it; `daemon-leak-model.test.ts` pins the rules.
//
// Three leak classes:
//
// surviving daemon at `after-shutdown` a daemon pid that is still alive IS
// the leak. `stopProcessForTakeover` is best-effort and
// returns silently on identity mismatch, signal failure, or
// kill timeout, so a lane that only stops the daemon never
// learns it survived.
// state-dir residue every entry must match EXPECTED_STATE_DIR_ENTRIES
// (unknown ⇒ classify the new artifact, do not widen the
// matcher): `*.tmp` write-then-publish temporaries are torn
// publishes, an empty directory is an unswept session
// scaffold, daemon.json/daemon.lock may exist only while a
// daemon legitimately lives, and a capture descriptor still
// `lifecycle: "open"` (or a legacy `app-log.pid` marker)
// after shutdown is a capture handle that outlived its
// owner — a `completed` descriptor is the finish record
// ADR 0019 keeps. `tools/` holds managed third-party
// installs (agent-browser and its Chrome), whose own
// download temporaries and scaffolding this daemon neither
// writes nor owns.
// owned process a decoded daemon/session record whose exact identity is
// still live, or no longer matches and therefore cannot be
// proved dead. The latter fails closed rather than allowing
// pid reuse to turn unknown evidence into a green checkpoint.
import { uniquePositivePids } from '@agent-device/host-kit/process';
import type { OwnedProcessRecord } from '@agent-device/contracts/platform-runtime-host';
export type DaemonLeakPhase = 'after-close' | 'after-shutdown';
/** One state-dir path, pre-read so the rules stay free of filesystem access. */
export type StateEntry = {
/** Relative to the state dir, `/`-separated; directories keep a trailing `/`. */
path: string;
kind: 'file' | 'empty-directory';
/** `lifecycle` of a durable capture descriptor, when this entry is one. */
descriptorLifecycle?: string;
ownedProcessRecordStatus?: 'decoded' | 'invalid';
};
export type OwnedProcessRecordObservation = Readonly<{
scope: 'daemon' | 'session';
sessionId?: string;
recordPath: string;
records: readonly OwnedProcessRecord[];
liveRecords: readonly OwnedProcessRecord[];
ownershipLostRecords?: readonly OwnedProcessRecord[];
status: 'decoded' | 'invalid';
}>;
export type OwnedProcessLeak = Readonly<
OwnedProcessRecord & {
scope: 'daemon' | 'session';
sessionId?: string;
recordPath: string;
ownership: 'exact' | 'lost';
}
>;
export type NonEmpty<T> = readonly [T, ...T[]];
/**
* The phase and the identity it needs, as one shape. An `after-close`
* checkpoint that cannot name the sessions that closed cannot tell their
* unfinalized capture handles from another session's legitimately live one, so
* it would accept every open handle and certify nothing. The identity is
* therefore part of the phase rather than an option a caller may omit, and the
* typechecker refuses the empty case.
*/
export type DaemonLeakPhaseSelection =
| { phase: 'after-shutdown' }
| { phase: 'after-close'; closedSessions: NonEmpty<string> };
export type DaemonLeakObservationBase = {
stateDir: string;
daemonPids: readonly number[];
livePids: readonly number[];
ownedProcessRecords: readonly OwnedProcessRecordObservation[];
stateEntries: readonly StateEntry[];
};
export type DaemonLeakObservation = DaemonLeakPhaseSelection & DaemonLeakObservationBase;
export type DaemonLeakSnapshot = {
stateDir: string;
daemonPids: number[];
phase: DaemonLeakPhase;
liveDaemonPids: number[];
ownedProcesses: OwnedProcessLeak[];
strayStateEntries: string[];
};
// `tools/` is a managed third-party install tree (agent-browser + Chrome), not
// daemon write-then-publish output, so it is exempted before the generic rules.
const MANAGED_TOOLS_ENTRY = /^tools\//;
const EXPECTED_STATE_DIR_ENTRIES: readonly RegExp[] = [
/^daemon\.json$/,
/^daemon\.lock$/,
/^daemon\.log$/,
/^daemon-shutdown\.json$/,
/^sessions\/[^/]+\/events\.ndjson$/,
/^sessions\/[^/]+\/requests\/[^/]+\.ndjson$/,
/^sessions\/[^/]+\/(?:app|runner)\.log$/,
/^sessions\/[^/]+\/repair-tombstone\.json$/,
/^sessions\/[^/]+\/artifacts\/.+$/,
/^device-claims\/.+$/,
];
const CAPTURE_DESCRIPTOR_ENTRY = /^sessions\/[^/]+\/[^/]+\.resource\.json$/;
const LEGACY_APP_LOG_MARKER_ENTRY = /^sessions\/[^/]+\/app-log\.pid$/;
const DAEMON_LIVENESS_ENTRY = /^daemon\.(?:json|lock)$/;
export function evaluateDaemonLeaks(observation: DaemonLeakObservation): DaemonLeakSnapshot {
const daemonPids = uniquePositivePids(observation.daemonPids);
const liveDaemonPids = daemonPids.filter((pid) => observation.livePids.includes(pid));
const daemonLegitimatelyAlive = observation.phase === 'after-close' && liveDaemonPids.length > 0;
return {
stateDir: observation.stateDir,
daemonPids,
phase: observation.phase,
liveDaemonPids,
ownedProcesses: observation.ownedProcessRecords.flatMap((observationEntry) => {
if (!ownedProcessIsLeak(observationEntry, observation)) return [];
return [
...observationEntry.liveRecords.map((record) =>
formatOwnedProcessLeak(observationEntry, record, 'exact'),
),
...(observationEntry.ownershipLostRecords ?? []).map((record) =>
formatOwnedProcessLeak(observationEntry, record, 'lost'),
),
];
}),
strayStateEntries: observation.stateEntries
.filter(
(entry) =>
classifyStateEntry(entry, {
phase: observation.phase,
daemonLegitimatelyAlive,
closedSessions: closedSessionsOf(observation),
}) === 'stray',
)
.map((entry) => entry.path)
.sort(),
};
}
/**
* A daemon that outlived its own shutdown is itself the leak — not a detail of
* the report — so it fails alongside owned children and state-dir residue.
*/
export function hasDaemonLeaks(snapshot: DaemonLeakSnapshot): boolean {
return (
survivingDaemonPids(snapshot).length > 0 ||
snapshot.ownedProcesses.length > 0 ||
snapshot.strayStateEntries.length > 0
);
}
function ownedProcessIsLeak(
entry: OwnedProcessRecordObservation,
observation: DaemonLeakObservation,
): boolean {
if (observation.phase === 'after-shutdown') return true;
return (
entry.scope === 'session' &&
entry.sessionId !== undefined &&
observation.closedSessions.includes(entry.sessionId)
);
}
function formatOwnedProcessLeak(
entry: OwnedProcessRecordObservation,
record: OwnedProcessRecord,
ownership: OwnedProcessLeak['ownership'],
): OwnedProcessLeak {
return {
...record,
scope: entry.scope,
...(entry.sessionId === undefined ? {} : { sessionId: entry.sessionId }),
recordPath: entry.recordPath,
ownership,
};
}
function survivingDaemonPids(snapshot: DaemonLeakSnapshot): number[] {
return snapshot.phase === 'after-shutdown' ? snapshot.liveDaemonPids : [];
}
type StateEntryContext = {
phase: DaemonLeakPhase;
daemonLegitimatelyAlive: boolean;
closedSessions: readonly string[];
};
function classifyStateEntry(entry: StateEntry, context: StateEntryContext): 'expected' | 'stray' {
const special = classifySpecialStateEntry(entry, context);
if (special !== undefined) return special;
return EXPECTED_STATE_DIR_ENTRIES.some((matcher) => matcher.test(entry.path))
? 'expected'
: 'stray';
}
function classifySpecialStateEntry(
entry: StateEntry,
context: StateEntryContext,
): 'expected' | 'stray' | undefined {
for (const classifier of SPECIAL_STATE_CLASSIFIERS) {
const result = classifier(entry, context);
if (result !== undefined) return result;
}
return undefined;
}
type StateClassification = 'expected' | 'stray' | undefined;
type StateClassifier = (entry: StateEntry, context: StateEntryContext) => StateClassification;
const SPECIAL_STATE_CLASSIFIERS: readonly StateClassifier[] = [
(entry) => (MANAGED_TOOLS_ENTRY.test(entry.path) ? 'expected' : undefined),
(entry) => (entry.kind === 'empty-directory' ? 'stray' : undefined),
(entry) => (entry.path.endsWith('.tmp') ? 'stray' : undefined),
(entry) => classifyOwnedProcessStateEntry(entry),
(entry, context) => classifyDaemonLivenessStateEntry(entry, context),
(entry, context) => classifyCaptureStateEntry(entry, context),
];
function classifyOwnedProcessStateEntry(entry: StateEntry): StateClassification {
if (!OWNED_PROCESS_RECORD_ENTRY.test(entry.path)) return undefined;
return entry.ownedProcessRecordStatus === 'invalid' ? 'stray' : 'expected';
}
function classifyDaemonLivenessStateEntry(
entry: StateEntry,
context: StateEntryContext,
): StateClassification {
if (!DAEMON_LIVENESS_ENTRY.test(entry.path)) return undefined;
return context.daemonLegitimatelyAlive ? 'expected' : 'stray';
}
function classifyCaptureStateEntry(
entry: StateEntry,
context: StateEntryContext,
): StateClassification {
if (!CAPTURE_DESCRIPTOR_ENTRY.test(entry.path) && !LEGACY_APP_LOG_MARKER_ENTRY.test(entry.path)) {
return undefined;
}
return classifyCaptureEntry(entry, context);
}
// A capture handle must be finalized once its owning session is gone: after
// shutdown that is every session, after close only the sessions that closed.
// Another session's live capture stays expected, and a legacy pid marker is
// never a finish record, so it is stray whenever its session is gone.
function classifyCaptureEntry(entry: StateEntry, context: StateEntryContext): 'expected' | 'stray' {
const owningSessionGone =
context.phase === 'after-shutdown' ||
context.closedSessions.includes(sessionDirectoryOf(entry.path) ?? '');
if (!owningSessionGone) return 'expected';
if (!CAPTURE_DESCRIPTOR_ENTRY.test(entry.path)) return 'stray';
return entry.descriptorLifecycle === 'completed' ? 'expected' : 'stray';
}
function closedSessionsOf(observation: DaemonLeakObservation): readonly string[] {
return observation.phase === 'after-close' ? observation.closedSessions : [];
}
function sessionDirectoryOf(entryPath: string): string | undefined {
return /^sessions\/([^/]+)\//.exec(entryPath)?.[1];
}
export function formatDaemonLeakReport(snapshot: DaemonLeakSnapshot): string {
const surviving = survivingDaemonPids(snapshot);
return [
`daemon leak oracle: ${hasDaemonLeaks(snapshot) ? 'LEAK' : 'clean'} (${snapshot.phase})`,
` state dir: ${snapshot.stateDir}`,
` daemon pids: ${formatPids(snapshot.daemonPids)}; live: ${formatPids(snapshot.liveDaemonPids)}`,
` daemons that outlived shutdown: ${surviving.length}${
surviving.length > 0 ? ` (${formatPids(surviving)})` : ''
}`,
` owned processes still alive: ${snapshot.ownedProcesses.length}`,
...snapshot.ownedProcesses.map(
(record) =>
` ${record.purpose}: pid ${record.pid} ${record.scope} ${record.recordPath}${
record.ownership === 'lost' ? ' (ownership lost)' : ''
}`,
),
` stray state-dir entries: ${snapshot.strayStateEntries.length}`,
...snapshot.strayStateEntries.map((entry) => ` ${entry}`),
].join('\n');
}
const OWNED_PROCESS_RECORD_ENTRY =
/^(?:owned-processes\.json|sessions\/[^/]+\/owned-processes\.json)$/;
function formatPids(pids: readonly number[]): string {
return pids.join(', ') || '(none)';
}