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
44 changes: 41 additions & 3 deletions architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ prove that the earlier effect happened. These distinctions govern

Start with the properties every execution shares: predictable behavior, scoped ownership and composition across independently loaded package copies.

### Internal persistence during prerelease

XMD is in prerelease. Each kind of internally stored data has exactly one
supported format: the current format. We change that format in place. We do not
preserve earlier formats or add version branches, migrations, upgrade paths, or
compatibility adapters for earlier development builds. This prohibition includes
manually invoked paths.

Readers validate the complete current declaration and refuse incomplete,
obsolete or extra fields. This applies to session ownership as well as workflow
storage and other internal records. A retained schema label is not a promise to
accept another shape. Third-party protocol versions and public artifact
contracts keep their separately stated requirements.

### Security model

Installed plugins, providers, middleware and TypeScript components are trusted
Expand All @@ -81,7 +95,7 @@ receive. Missing fields and invalid values in a JSON response are relevant;
deliberately malicious JavaScript getters require executable code covered by
the trusted-extension assumption.

Saved history is checked for format, supported version and consistency with
Saved history is checked for its current format and consistency with
the intended run and effects. These checks protect continuation from corrupted
or mismatched records. XMD makes no authenticity guarantee against someone who
can deliberately rewrite the underlying local database and construct a
Expand Down Expand Up @@ -3533,6 +3547,13 @@ agent session right now, across processes. It is passed directly into the provid
needs it and is deliberately not contextual: a decision document middleware could
replace is not one. Acquisition never waits.

The same host also builds an ownership-administration view for the CLI alone.
Inspection reads one exact digest's record bytes without probing a lock or
contacting a provider. Explicit force compares their SHA-256 under the same
occupancy reservation and persistent sidecar lock used by ordinary acquisition.
A live owner stays busy. This capability never enters provider dependencies or
an authored component.

#### Construction routes

A construction route records how one logical agent session was first constructed —
Expand Down Expand Up @@ -3586,13 +3607,30 @@ provider-returned, which is the only compatibility inference.

Quiescence is the one-use acknowledgement an ownership body gives. It does not mean "I
finished" — it means nothing this owner started can still act on the session, which is
why a handoff that could not release its handle never gives it.
why a handoff that could not release its handle never gives it. Ordinary release
writes idle only after this acknowledgement. Pending host I/O settles before
locks, file handles or occupancy are released, including calls that finish
after cancellation.

#### Unproven release

A recovery tombstone is an ownership record left active because its owner never proved
it stopped. A crash releases the kernel lock and not this, and no pid, elapsed time,
released lock or empty transcript clears it.
released lock or empty transcript clears it. An operator can explicitly accept
that uncertainty with `xmd session recover --key-digest D --expect R --force`.
The exact byte revision must still match under the lock. Forced release records
backend quiescence unproved and interrupted outcome unknown; it proves neither
that prior work finished nor that a backend stopped.

Ownership has one current unversioned record with release state and a nullable
latest forced-release marker. Force writes the marker; later active and genuinely
quiesced writes preserve it. Obsolete shapes refuse under the
[prerelease persistence policy](#internal-persistence-during-prerelease). Publication uses a fresh
exclusive staging file, whole writes, file sync, atomic replacement and directory
sync. Cancellation before replacement removes only that attempt's staging;
a replacement already in flight settles and is synced while the lock is held.
A post-replacement failure reports publication unknown and permits no automatic
repeat. Routes, provider identities and journals are separate and unchanged.

#### Provider partitions

Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { runSessionOwnership } from "./session-ownership.ts";
import { runSidekickCommand, SIDEKICK_HELP } from "./sidekick-acp/command.ts";
import { SidekickTransport } from "./sidekick-acp/protocol.ts";
import { isAbsolute, resolve } from "node:path";
Expand Down Expand Up @@ -572,6 +573,7 @@ const xmd = program({
agent: { ...agentConfig, description: AGENT_DESCRIPTION },
upgrade: { ...upgradeConfig, description: UPGRADE_DESCRIPTION },
repl: { ...replConfig, description: REPL_DESCRIPTION },
session: { description: "Inspect or explicitly reclaim session ownership.", ...object({}) },
sidekick: { description: "Attach to or passively read one live Sidekick.", ...object({}) },
"test-agent": testAgentConfig,
workflow: workflowConfig,
Expand Down Expand Up @@ -2539,6 +2541,7 @@ const COMMAND_NAMES = [
"agent",
"repl",
"sidekick",
"session",
"test-agent",
"workflow",
];
Expand Down Expand Up @@ -3467,6 +3470,13 @@ export function* runXmd(
// restore.
replHost?: ReplHostAssembly,
): Operation<void> {
if (args[0] === "session") {
const code = yield* runSessionOwnership(args.slice(1), sessions?.ownershipAdministration);
if (code !== 0) {
yield* exit(code);
}
return;
}
if (args[0] === "sidekick") {
try {
yield* runSidekickCommand(args.slice(1));
Expand Down
25 changes: 15 additions & 10 deletions packages/cli/src/session-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@
*/
import { homedir } from "node:os";
import { join } from "node:path";
import {
createDenoAgentSessionCoordinator,
createDenoExecutableObserver,
import { createDenoAgentSessionHost, createDenoExecutableObserver } from "@executablemd/runtime";
import type {
AgentSessionCoordinator,
AgentSessionOwnershipAdministration,
ExecutableObserver,
} from "@executablemd/runtime";
import type { AgentSessionCoordinator, ExecutableObserver } from "@executablemd/runtime";
import {
ADVERTISED_CLIENT_NATIVE_ATTACHMENT,
ADVERTISED_NATIVE_LAUNCH,
Expand All @@ -42,6 +43,7 @@ export function sessionCoordinatorRoot(): string {
*/
export interface MachineSessionAssembly {
coordinator?: AgentSessionCoordinator;
ownershipAdministration?: AgentSessionOwnershipAdministration;
routeStore?: AgentSessionRouteStore;
executableObserver?: ExecutableObserver;
advertiseNativeLaunch: readonly string[];
Expand All @@ -50,7 +52,7 @@ export interface MachineSessionAssembly {

/** This host's session coordinator, or nothing when it cannot provide one. */
export function useSessionCoordinator(): AgentSessionCoordinator | undefined {
return createDenoAgentSessionCoordinator(sessionCoordinatorRoot());
return createDenoAgentSessionHost(sessionCoordinatorRoot())?.coordinator;
}

/**
Expand Down Expand Up @@ -78,12 +80,15 @@ export function useExecutableObserver(): ExecutableObserver | undefined {
* proven against the installed CLI.
*/
export function useMachineSessions(): MachineSessionAssembly {
const host = createDenoAgentSessionHost(sessionCoordinatorRoot());
const routeStore = useSessionRouteStore();
const executableObserver = useExecutableObserver();
return {
...(useSessionCoordinator() === undefined ? {} : { coordinator: useSessionCoordinator() }),
...(useSessionRouteStore() === undefined ? {} : { routeStore: useSessionRouteStore() }),
...(useExecutableObserver() === undefined
? {}
: { executableObserver: useExecutableObserver() }),
...(host
? { coordinator: host.coordinator, ownershipAdministration: host.ownershipAdministration }
: {}),
...(routeStore ? { routeStore } : {}),
...(executableObserver ? { executableObserver } : {}),
advertiseNativeLaunch: ADVERTISED_NATIVE_LAUNCH,
advertiseClientNativeAttachment: ADVERTISED_CLIENT_NATIVE_ATTACHMENT,
};
Expand Down
196 changes: 196 additions & 0 deletions packages/cli/src/session-ownership.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { Err, Ok, type Result, type Operation } from "effection";
import { agentSessionKeyDigest, AgentSessionOwnershipError } from "@executablemd/runtime";
import type {
AgentSessionOwnershipAdministration,
AgentSessionOwnershipFailureCode,
} from "@executablemd/runtime";

export const SESSION_HELP = `Inspect or explicitly reclaim one session ownership record.

xmd session ownership --key-digest D [--json]
xmd session ownership --provider P --agent-command A --session-key K [--json]
xmd session recover --key-digest D --expect R --force [--json]
xmd session --help
xmd session ownership --help
xmd session recover --help

D and R are lowercase SHA-256 digests. A is the exact resolved configured
agent command, not an alias. Inspection reads one record without probing its lock.
--force accepts backend quiescence unproved and interrupted outcome UNKNOWN.
A live lock holder stays busy. These commands send no provider requests.
`;

type Command =
| { kind: "help" }
| { kind: "ownership"; keyDigest: string; json: boolean }
| { kind: "recover"; keyDigest: string; expectedRevision: string; force: true; json: boolean };
const digest = /^[0-9a-f]{64}$/;

function parse(args: string[]): Result<Command> {
if (
(args.length === 1 && args[0] === "--help") ||
(args.length === 2 && ["ownership", "recover"].includes(args[0]!) && args[1] === "--help")
) {
return Ok({ kind: "help" });
}
const kind = args[0];
if (kind !== "ownership" && kind !== "recover") {
return Err(new Error("expected session ownership, recover or --help"));
}
const values = new Map<string, string>();
for (let i = 1; i < args.length; i++) {
const token = args[i]!;
const equal = token.indexOf("=");
const name = equal < 0 ? token : token.slice(0, equal);
if (
![
"--key-digest",
"--provider",
"--agent-command",
"--session-key",
"--expect",
"--force",
"--json",
].includes(name)
) {
return Err(new Error("unknown session option or positional argument"));
}
if (values.has(name)) {
return Err(new Error(`duplicate session option ${name}`));
}
if (name === "--force" || name === "--json") {
if (equal >= 0) {
return Err(new Error(`${name} accepts no value`));
}
values.set(name, "true");
} else {
const value = equal < 0 ? args[++i] : token.slice(equal + 1);
if (value === undefined || value.length === 0 || value.startsWith("--")) {
return Err(new Error(`${name} requires a nonempty value`));
}
values.set(name, value);
}
}
const direct = values.get("--key-digest");
const provider = values.get("--provider");
const agent = values.get("--agent-command");
const sessionKey = values.get("--session-key");
const triple = provider !== undefined || agent !== undefined || sessionKey !== undefined;
if (direct !== undefined && (!digest.test(direct) || triple)) {
return Err(
new Error(
"use one lowercase --key-digest or the complete provider/agent-command/session-key triple",
),
);
}
if (kind === "recover" && triple) {
return Err(new Error("recovery accepts only --key-digest"));
}
let keyDigest: string;
if (direct !== undefined) {
keyDigest = direct;
} else if (provider && agent && sessionKey) {
keyDigest = agentSessionKeyDigest({ provider, agent, sessionKey });
} else {
return Err(
new Error("supply --key-digest or the complete provider/agent-command/session-key triple"),
);
}
const json = values.has("--json");
if (kind === "ownership") {
if (values.has("--expect") || values.has("--force")) {
return Err(new Error("inspection accepts neither --expect nor --force"));
}
return Ok({ kind, keyDigest, json });
}
const expectedRevision = values.get("--expect");
if (!expectedRevision || !digest.test(expectedRevision) || !values.has("--force")) {
return Err(new Error("recovery requires lowercase --expect and explicit --force"));
}
return Ok({ kind, keyDigest, expectedRevision, force: true, json });
}

function failure(
code: AgentSessionOwnershipFailureCode,
message: string,
json: boolean,
keyDigest?: string,
): number {
if (json) {
console.error(
JSON.stringify({
schema: "agent-session-ownership-error",
code,
message,
...(keyDigest ? { keyDigest } : {}),
...(code === "publication-unknown" ? { applied: "unknown" } : {}),
}),
);
} else {
console.error(`xmd session: ${message}`);
}
return 1;
}

export function* runSessionOwnership(
args: string[],
administration?: AgentSessionOwnershipAdministration,
): Operation<number> {
const command = parse(args);
if (!command.ok) {
return failure("record-invalid", command.error.message, args.includes("--json"));
}
const input = command.value;
if (input.kind === "help") {
console.log(SESSION_HELP);
return 0;
}
if (!administration) {
const error = new AgentSessionOwnershipError("unsupported-capability");
return failure(error.code, error.message, input.json, input.keyDigest);
}
try {
if (input.kind === "ownership") {
const result = yield* administration.inspectOwnership(input.keyDigest);
if (!result.ok) {
const error =
result.error instanceof AgentSessionOwnershipError
? result.error
: new AgentSessionOwnershipError("io-failure");
return failure(error.code, error.message, input.json, input.keyDigest);
}
const value = result.value;
console.log(
input.json
? JSON.stringify({ schema: "agent-session-ownership-inspection", ...value })
: `Session ${value.keyDigest}\nState: ${value.record.state}\nOwner: ${value.record.ownerKind} ${value.record.operationId}\nRecord revision: ${value.recordRevision}`,
);
} else {
const result = yield* administration.forceRecoverOwnership({
keyDigest: input.keyDigest,
expectedRevision: input.expectedRevision,
force: true,
});
if (!result.ok) {
const error =
result.error instanceof AgentSessionOwnershipError
? result.error
: new AgentSessionOwnershipError("io-failure");
return failure(error.code, error.message, input.json, input.keyDigest);
}
const value = result.value;
console.log(
input.json
? JSON.stringify({ schema: "agent-session-force-recovery", ...value })
: `Session ${value.keyDigest}\nForced reclaim: ${value.recoveryId}\nBackend quiescence: unproved\nInterrupted outcome: UNKNOWN\nProvider requests: none\nRecord revision: ${value.recordRevision}`,
);
}
return 0;
} catch (cause) {
const error =
cause instanceof AgentSessionOwnershipError
? cause
: new AgentSessionOwnershipError("io-failure");
return failure(error.code, error.message, input.json, input.keyDigest);
}
}
Loading
Loading