Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
a4efad5
refactor: resolve session artifact paths without a store
thymikee Oct 3, 2026
3b309f0
fix: bind replay directory cleanup to joined daemon startups
thymikee Oct 2, 2026
a0ea35d
fix: preserve repair outcomes through partial replay retirement
thymikee Oct 2, 2026
ecb5472
test: simplify paired repair cleanup assertions
thymikee Oct 2, 2026
2a359db
fix: keep daemon lifecycle outside the CLI import closure
thymikee Oct 3, 2026
3e5be9a
fix(daemon): retire only the captured failed startup
thymikee Oct 2, 2026
5ec91d7
fix: join contending daemon startups within one deadline
thymikee Oct 3, 2026
604939b
fix: observe only the current daemon registration owner
thymikee Oct 3, 2026
d2c7a19
test: bound held-claim startup deadline simulation
thymikee Oct 3, 2026
3f2199b
fix: await owned daemon retirement on request timeout
thymikee Oct 3, 2026
50eafe5
chore(gates): acknowledge unchanged health payload under probe deadlines
thymikee Oct 3, 2026
740111b
fix: share manual daemon retirement and verify lock cutover
thymikee Oct 3, 2026
12781ae
test: bound legacy contender admission observation
thymikee Oct 3, 2026
464176d
fix: preserve daemon ownership across startup and cleanup
thymikee Oct 3, 2026
e4a21ee
chore(gates): expose native PID validation and discover cleanup controls
thymikee Oct 3, 2026
4ef85e9
refactor: bind session references to stable lifetime entries
thymikee Oct 3, 2026
530c6e9
fix: bind capture adoption to session lifetimes
thymikee Oct 3, 2026
bfffb65
chore(gates): declare capture adoption field owners
thymikee Oct 3, 2026
ebecbda
fix: name the capture clear outcome inline so session bindings emit d…
thymikee Oct 3, 2026
cbf0a6e
perf: load app-log cleanup on demand in session teardown
thymikee Oct 3, 2026
cebb742
fix: finish session captures through lifetime bindings
thymikee Oct 3, 2026
d58cc91
chore(gates): retire whole-session capture projections
thymikee Oct 3, 2026
e0a818c
refactor: keep session capture bindings in one module
thymikee Oct 3, 2026
41dfe9a
fix: preserve daemon cleanup and capture lifetime proof
thymikee Oct 3, 2026
054dd00
chore(gates): colocate durable capture field owners
thymikee Oct 3, 2026
a360ff3
fix: retire only the captured expired session lifetime
thymikee Oct 3, 2026
7e96b83
refactor: carry session lifetimes through close and script finalization
thymikee Oct 3, 2026
9a5d972
refactor: bind snapshot publication to session lifetimes
thymikee Oct 3, 2026
9b22713
fix: publish settle refs through the session lifetime
thymikee Oct 3, 2026
e4ca3d1
chore(gates): declare the interaction lifetime binding facade
thymikee Oct 3, 2026
37b9ae2
fix: retain session and registration ownership through review controls
thymikee Oct 3, 2026
6c3e88b
chore(gates): declare the record-only capture publication owner
thymikee Oct 3, 2026
a679c1e
fix: prepare recording health within the captured session lifetime
thymikee Oct 3, 2026
a6d6f14
fix: bind replay observations to the captured session lifetime
thymikee Oct 3, 2026
0e24d48
fix: retain one observation lifetime across replay capture retries
thymikee Oct 3, 2026
f8b5f93
fix: publish open results into their admitted session lifetime
thymikee Oct 3, 2026
9e6de35
fix: carry the opened lifetime through foreground composition
thymikee Oct 3, 2026
08940db
chore(gates): declare the owning open lease and claim helper
thymikee Oct 3, 2026
f3a664c
fix: preserve capture records and discard retired settle observations
thymikee Oct 3, 2026
9403b97
fix: omit retired settle refs after the touch frame probe
thymikee Oct 3, 2026
03e783a
refactor: bind action recording and metadata patches to session lifet…
thymikee Oct 3, 2026
c7da1e8
fix: make retired touch observations consistent
thymikee Oct 3, 2026
5fd249b
refactor: make core fixtures declare session lifetimes
thymikee Oct 3, 2026
1f6b774
refactor: remove unrestricted session upsert and deletion
thymikee Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
32 changes: 29 additions & 3 deletions docs/adr/0030-process-lock-exclusion.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,32 @@ guard cannot constrain legacy code after its final check. The support boundary t
requires deployment control; host-kit does not claim to detect or evict every legacy user.

Daemon registration has a separate cutover boundary: keep the same `daemon.lock` path and
refuse legacy files rather than automatically reclaiming them. Its startup tests must cover
an already-running older daemon and concurrent old/new startup. This does not expand the
host-kit mixed-protocol support contract.
refuse legacy files rather than automatically reclaiming them. A legacy daemon creates an
exclusive file; a hardened daemon creates a directory at that same path. The old acquisition
cannot unlink a directory, and the new acquisition retains an existing file.

The [registration tests](../../src/__tests__/daemon-registration-owner.test.ts) exercise real
children using the c237027737 legacy acquisition and the current owner. They cover an older
daemon already running, a hardened owner waiting to publish metadata, and concurrent startup.
The client refuses an older registration before signaling or changing it. This proves the daemon
cutover; it does not expand the host-kit mixed-protocol support contract. Older clients still
require the deployment controls above.

## Registration operations

[Shared retirement](../../src/daemon-registration-owner.ts) owns verified termination, protected
metadata inspection and removal, and release. Takeover, failed startup, replay cleanup, timeout
reset and manual stop await its result. Abandoned recovery uses the same protected retirement
sequence without signaling a live process. Daemon publication and shutdown use functions bound
to their acquired claim.

```mermaid
flowchart LR
C[Client lifecycle and timeout] --> R[Shared retirement]
M[Manual stop] --> R
P[Abandoned recovery and pruning] --> R
R --> L[Acquired registration claim]
D[Daemon startup and shutdown] --> O[Functions bound to own claim]
O --> L
L --> F[Protected metadata and reports]
```
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { makeCaptureSessionBinding } from '../../durable-capture/session-binding.fixtures.ts';
import fs from 'node:fs/promises';
import path from 'node:path';
import { expect, test, vi } from 'vitest';
Expand Down Expand Up @@ -44,6 +45,10 @@ test('audio-probe disposes on a failed finish because terminating the helper is
);
const session: DurableCaptureSessionState = {};
sessionStore.set(sessionName, session);
const binding = makeCaptureSessionBinding(sessionStore, sessionName, {
read: (session) => session.audioProbe,
replace: (session, audioProbe) => ({ ...session, audioProbe }),
});
const statusPath = path.join(sessionStore.resolveSessionDir(sessionName), 'audio-probe.json');
const terminate = vi.fn(async () => {});
let resolveExit!: (result: HostCommandResult) => void;
Expand Down Expand Up @@ -85,9 +90,7 @@ test('audio-probe disposes on a failed finish because terminating the helper is
});
await adoptStartedAudioProbe({
admissionLedger: createAudioProbeAdmissionLedger(),
session,
sessionName,
sessionStore,
binding,
device,
owner: localRuntimeOwner('apple'),
fence,
Expand All @@ -101,9 +104,7 @@ test('audio-probe disposes on a failed finish because terminating the helper is
await expect(
finishLiveAudioProbe({
intent: 'capture',
session: sessionStore.get(sessionName) ?? session,
sessionName,
sessionStore,
binding,
}),
).rejects.toThrow('helper exited before completing the capture');

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { makeCaptureSessionBinding } from '../../durable-capture/session-binding.fixtures.ts';
import { vi } from 'vitest';
import type { AppLogCompletion, AppLogLiveHandle } from '@agent-device/contracts/app-log-runtime';
import type { CleanupOutcome, FinishOutcome } from '@agent-device/contracts/durable-resource';
Expand Down Expand Up @@ -30,19 +31,10 @@ export const testCaptureStore = createDurableCaptureResourceStore({
export function createTestCaptureResource(
store: DurableCaptureResourceStore<'app-log'> = testCaptureStore,
) {
return createDurableCaptureResource<
'app-log',
AppLogLiveHandle,
AppLogCompletion,
TestCaptureSession
>({
return createDurableCaptureResource<'app-log', AppLogLiveHandle, AppLogCompletion>({
resourceKind: 'app-log',
displayName: 'test capture',
store,
sessionSlot: {
read: (session) => session.appLog,
replace: (session, appLog) => ({ ...session, appLog, appLogFailure: undefined }),
},
completionMetadata: (completion) => ({
outputPath: completion.outputPath,
completedAt: completion.completedAt,
Expand Down Expand Up @@ -72,6 +64,10 @@ export function makeDurableCaptureContext(
const session: TestCaptureSession = {};
sessionStore.set(sessionName, session);
return {
binding: makeCaptureSessionBinding(sessionStore, sessionName, {
read: (session) => session.appLog,
replace: (session, appLog) => ({ ...session, appLog, appLogFailure: undefined }),
}),
admissionLedger: createDurableCaptureAdmissionLedger({ displayName: 'test capture' }),
session,
sessionName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,7 @@ test('one coordinator exposes the typed manifest and all lifecycle entrypoints',
await expect(
testCaptureResource.finishLive({
intent: 'capture',
session: active,
sessionName: context.sessionName,
sessionStore: context.sessionStore,
binding: context.binding,
}),
).resolves.toMatchObject({ outputPath: '/tmp/app.log', completedAt: 2 });
expect(context.sessionStore.get(context.sessionName)?.appLog).toBeUndefined();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { makeCaptureSessionBinding } from '../../durable-capture/session-binding.fixtures.ts';
import path from 'node:path';
import { expect, test, vi } from 'vitest';
import { createDurableResourceEnvelope } from '../../durable-resource-envelope.ts';
Expand Down Expand Up @@ -187,16 +188,11 @@ function createScreenRecordingTestResource(
return createDurableCaptureResource<
'screen-recording',
ScreenRecordingLiveHandle,
ScreenRecordingCompletion,
DurableCaptureSessionState
ScreenRecordingCompletion
>({
resourceKind: 'screen-recording',
displayName: 'screen recording',
store,
sessionSlot: {
read: (session) => session.screenRecording,
replace: (session, screenRecording) => ({ ...session, screenRecording }),
},
completionMetadata: (completion) => ({
backend: completion.backend,
outputPath: completion.outPath,
Expand All @@ -217,9 +213,14 @@ function makeContext(resource: ReturnType<typeof createScreenRecordingTestResour
const sessionName = 'recording';
const session: DurableCaptureSessionState = {};
sessionStore.set(sessionName, session);
const binding = makeCaptureSessionBinding(sessionStore, sessionName, {
read: (session) => session.screenRecording,
replace: (session, screenRecording) => ({ ...session, screenRecording }),
});
return {
admissionLedger: createDurableCaptureAdmissionLedger({ displayName: 'screen recording' }),
session,
binding,
sessionName,
sessionStore,
device,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { makeCaptureSessionBinding } from '../../durable-capture/session-binding.fixtures.ts';
import { expect, test, vi } from 'vitest';
import { PendingTransferGuard } from '@agent-device/contracts/async-lifecycle';
import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime';
Expand Down Expand Up @@ -35,6 +36,10 @@ test('screen recording persists durable truth before adopting only handle and en
const sessionName = 'recording';
const session: TestRecordingSession = { name: sessionName, device };
sessionStore.set(sessionName, session);
const binding = makeCaptureSessionBinding(sessionStore, sessionName, {
read: (session) => session.screenRecording,
replace: (session, screenRecording) => ({ ...session, screenRecording }),
});
const owner = localRuntimeOwner('android');
const fence = { token: 'recording-fence', generation: 1 } as const;
const finish = vi.fn(async () => ({
Expand Down Expand Up @@ -79,9 +84,7 @@ test('screen recording persists durable truth before adopting only handle and en

await adoptStartedScreenRecording({
admissionLedger: createScreenRecordingAdmissionLedger(),
session,
sessionName,
sessionStore,
binding,
device: session.device,
owner,
fence,
Expand All @@ -101,9 +104,10 @@ test('screen recording persists durable truth before adopting only handle and en

const active = sessionStore.get(sessionName);
if (!active) throw new Error('Expected screen-recording session');
await expect(
finishLiveScreenRecording({ intent: 'capture', session: active, sessionName, sessionStore }),
).resolves.toMatchObject({ backend: 'android', outPath: '/tmp/recording.mp4' });
await expect(finishLiveScreenRecording({ intent: 'capture', binding })).resolves.toMatchObject({
backend: 'android',
outPath: '/tmp/recording.mp4',
});
expect(finish).toHaveBeenCalledOnce();
expect(sessionStore.get(sessionName)?.screenRecording).toBeUndefined();
});
Expand All @@ -115,6 +119,10 @@ test('a failed recording finish keeps the record open and never disposes the rec
const sessionName = 'recording';
const session: TestRecordingSession = { name: sessionName, device };
sessionStore.set(sessionName, session);
const binding = makeCaptureSessionBinding(sessionStore, sessionName, {
read: (session) => session.screenRecording,
replace: (session, screenRecording) => ({ ...session, screenRecording }),
});
const owner = localRuntimeOwner('android');
const fence = { token: 'recording-fence', generation: 1 } as const;
const finishError = new Error('failed to retrieve playable Android recording');
Expand Down Expand Up @@ -150,9 +158,7 @@ test('a failed recording finish keeps the record open and never disposes the rec
});
await adoptStartedScreenRecording({
admissionLedger: createScreenRecordingAdmissionLedger(),
session,
sessionName,
sessionStore,
binding,
device: session.device,
owner,
fence,
Expand All @@ -163,9 +169,7 @@ test('a failed recording finish keeps the record open and never disposes the rec

const active = sessionStore.get(sessionName);
if (!active) throw new Error('Expected screen-recording session');
await expect(
finishLiveScreenRecording({ intent: 'capture', session: active, sessionName, sessionStore }),
).rejects.toBe(finishError);
await expect(finishLiveScreenRecording({ intent: 'capture', binding })).rejects.toBe(finishError);

expect(forceCleanup).not.toHaveBeenCalled();
expect(sessionStore.get(sessionName)?.screenRecording?.handle).toBe(handle);
Expand All @@ -182,6 +186,10 @@ test('a record stop that fails after collecting resumes through the fence withou
const sessionName = 'recording';
const session: TestRecordingSession = { name: sessionName, device };
sessionStore.set(sessionName, session);
const binding = makeCaptureSessionBinding(sessionStore, sessionName, {
read: (session) => session.screenRecording,
replace: (session, screenRecording) => ({ ...session, screenRecording }),
});
const owner = localRuntimeOwner('android');
const fence = { token: 'recording-fence', generation: 1 } as const;
const signals = vi.fn(async () => ({ observation: { recorder: 'confirmed' as const } }));
Expand Down Expand Up @@ -216,9 +224,7 @@ test('a record stop that fails after collecting resumes through the fence withou
);
await adoptStartedScreenRecording({
admissionLedger: createScreenRecordingAdmissionLedger(),
session,
sessionName,
sessionStore,
binding,
device: session.device,
owner,
fence,
Expand All @@ -239,9 +245,7 @@ test('a record stop that fails after collecting resumes through the fence withou
if (!active) throw new Error('Expected screen-recording session');
return finishLiveScreenRecording({
intent: 'capture',
session: active,
sessionName,
sessionStore,
binding,
});
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,16 @@
import path from 'node:path';
import { safeSessionName } from '@agent-device/host-kit/session-paths';
import type { DurableCaptureSessionStore } from '../../durable-capture/index.ts';
import { mkdtempForTestSync } from '../../tmp-dir.fixtures.ts';
import { makeCaptureFixtureStore } from '../../durable-capture/session-binding.fixtures.ts';

export type CaptureAdmissionSessionStore<S> = DurableCaptureSessionStore<S> &
Readonly<{
get(name: string): S | undefined;
sessionsDir: string;
}>;
export type CaptureAdmissionSessionStore<S> = ReturnType<
typeof makeCaptureAdmissionSessionStore<S>
>;

/**
* The whole of the daemon `SessionStore` these admission modules ever address — `set`,
* `resolveSessionDir`, and the read-back a test asserts on — over a fresh temp directory, so a
* test of this family needs no session record, store class, or daemon import.
*/
export function makeCaptureAdmissionSessionStore<S>(
prefix: string,
): CaptureAdmissionSessionStore<S> {
export function makeCaptureAdmissionSessionStore<S>(prefix: string) {
const sessionsDir = mkdtempForTestSync(prefix);
const sessions = new Map<string, S>();
return {
set: (name, session) => void sessions.set(name, session),
get: (name) => sessions.get(name),
resolveSessionDir: (name) => path.join(sessionsDir, safeSessionName(name)),
return Object.freeze({
...makeCaptureFixtureStore<S>((name) => path.join(sessionsDir, safeSessionName(name))),
sessionsDir,
};
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,20 @@ import type {
RuntimeOwnerRef,
} from '@agent-device/contracts/platform-runtime';
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { DurableCaptureSessionStore } from '../durable-capture/index.ts';
import type { DurableCaptureSessionBinding } from '../durable-capture/index.ts';
import { createDurableCaptureResource } from './durable-capture-resource.ts';
import type { DurableCaptureFinishIntent } from './durable-capture-resource.ts';
import type { AudioProbeAdmissionLedger } from './audio-probe-admission-ledger.ts';
import { audioProbeResourceStore } from './audio-probe-resource-store.ts';
import type { DurableCaptureSessionState } from './session-state-slice.ts';

export const audioProbeDurableResource = createDurableCaptureResource<
'audio-probe',
AudioProbeLiveHandle,
AudioProbeCompletion,
DurableCaptureSessionState
AudioProbeCompletion
>({
resourceKind: 'audio-probe',
displayName: 'audio probe',
store: audioProbeResourceStore,
sessionSlot: {
read: (session) => session.audioProbe,
replace: (session, audioProbe) => ({ ...session, audioProbe }),
},
completionMetadata: (completion) => ({
backend: completion.backend ?? 'unknown',
source: completion.source,
Expand All @@ -48,9 +42,7 @@ export const audioProbeDurableResource = createDurableCaptureResource<

export function adoptStartedAudioProbe(params: {
admissionLedger: AudioProbeAdmissionLedger;
session: DurableCaptureSessionState;
sessionName: string;
sessionStore: DurableCaptureSessionStore<DurableCaptureSessionState>;
binding: DurableCaptureSessionBinding<'audio-probe', AudioProbeLiveHandle>;
device: DeviceInfo;
owner: RuntimeOwnerRef;
fence: ResourceOwnershipFence;
Expand All @@ -62,9 +54,7 @@ export function adoptStartedAudioProbe(params: {
}

export function finishLiveAudioProbe(params: {
session: DurableCaptureSessionState;
sessionName: string;
sessionStore: DurableCaptureSessionStore<DurableCaptureSessionState>;
binding: DurableCaptureSessionBinding<'audio-probe', AudioProbeLiveHandle>;
intent: DurableCaptureFinishIntent;
}): Promise<AudioProbeCompletion> {
return audioProbeDurableResource.finishLive(params);
Expand Down
Loading
Loading