mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 01:41:21 +00:00
* fix(desktop): keep added-record checks off ids, dates, and joined timestamps A position-named record's fields keep their paths, so an identifier, a stamped date, and text past the limit are not suggested checks. A whole-text check does not climb into an ancestor whose text joins a timestamp. * fix(desktop): bound home fetches and keep unsettled edits readable Split home-folder reads by the guest answer budget, keep a capacity error from the capture output, and drop the superseded task-argument refusal. Grade kept text per line, leave unretained dconf unread, and reserve by-position notes for XML children. Map a linked database's sidecars through the selected spelling and stop a detached capture only when its start identity still owns the pid. * fix(desktop): describe unretained dconf as unread The key comparison treats a missing copy as unknown. The comment still said that copy counted as an empty key list. * test(desktop): keep a capture capacity error through endedCapture An output past the guest answer ceiling stays the resource constraint, with its heap recovery and non-retryable status, instead of a generic capture-exit message. * fix(desktop): keep nested save times and formatting runs out of checks A field check drops save-time leaves before it joins the field, so a nested timestamp is not part of the suggested text. A text check beside a save time uses the paragraph when the diff names a formatting run. * test(desktop): read paragraph pointers from checks that have them * test(desktop): expect a large task to export intact The 96 KiB refusal is gone. A prompt past that size reaches the computer and comes back unchanged, instead of failing before export. * fix(desktop): keep real edits that save-side heuristics were dropping Repeated unnamed fields stay content checks. A formula that still has text is not renumbered with its ExprID. One gained style attribute is the edit; a repeated fill-in stays upkeep. Phone numbers and plain clock times stay text, and a decimal is the element's whole text. An older verifier matches on the task it states, not its evaluator source. * fix(desktop): cover repeated records and keep timestamps out of checks An added record whose words are already in the seed still gets the list's text when that text fits. Readiness is a start reason, so a final check names seed locks without matching the message. Field text split by a timestamp stays separate, and a timestamp line is not offered as a guard. * fix(desktop): compare a cell's joined decimal within tolerance The whole-element check keeps numeric tolerance, so 437.80 and a long float still match 437.8, including when a run splits the digits. A different number or other text does not. Both evaluators read an element's text for that comparison. * fix(desktop): keep selected logs and stop treating unknown walks as complete Match an app name as a whole token, treat an omitted unchecked_paths count as incomplete, seed Chromium user_prefs.json, and keep a caller-selected log even when that path is also a state directory. A log inside the app's own folder still marks that folder so settings beside it stay seeded, while a lock in the home does not. Sibling locks under a selected folder stay out unless that lock was the selected path. * fix(desktop): grade a number from a complete XML element only A tolerance check joins an element's text only in an XML file or an XML archive entry, and leaves that check undecided when the view is cut, so a dropped descendant cannot turn the kept runs into a passing number. A JSON object with the same shape stays the value itself. Equivalent decimals and a split run on a whole cell still match. * fix(desktop): accept an older grader only when its index helper agrees The witness still matches a bundle whose evaluator is this REA's, or the older index expression alone when no path segment is a leading-zero number. A truncated verifier, a changed grader, or that older helper beside a segment such as 01 no longer counts as the same task. * fix(desktop): keep a lone lock a document and drop one beside its file A selected name such as notes.lck stays a document, so launch and the draft still treat it as the caller's file. A lock beside a recorded sibling is left out of the seed unless that lock path itself was selected. * fix(desktop): require the added record and leave save times out of list checks A record a selector names is checked itself, so its words have to be in that record. When an unnamed record only repeats words the seed already has, a list that holds a save time is checked by the text around that time. A later save of the same records still passes, and a missing or rewritten record fails. * fix(desktop): judge the older index helper by pointer segments only A leading zero in a file name or a URL does not change how the older index helper reads a pointer. The witness still rejects that helper when a pointer segment is a number such as 01. * fix(desktop): keep a document whose name ends in .lock A lock beside a recorded file still stays out of the seed unless that lock was selected. A document such as Cargo.lock in the home is the user's file, so the seed keeps it. An application lock in the home, such as poedit.lock, stays out. * fix(desktop): do not suggest list text the seed already passes A repeated record is shown by text the seed does not hold. The seed's own text is not a check. A pointer inside a check value is not a pointer segment for the older index helper. * fix(desktop): do not let a left-out lock hide settings checks A sibling lock the seed omits is not the document the draft follows. Settings changes stay suggested. The unused sibling-lock name helper is removed. * fix(desktop): keep a linked settings file in the folder the link points at A selected ~/.config alias no longer moves MuseScore settings to that alias. The seed keeps settings/MuseScore or Documents, which is the path the export opens the note from. * fix(desktop): split home fetches by answer size and keep a size refusal A batch of tiny files stops before each path's envelope exceeds the host answer budget. A capture output past that ceiling stays a resource error instead of becoming a generic exit. * fix(desktop): seed a selected link under the path the app uses The selected spelling wins, so ~/.config/MuseScore is not rewritten to the folder it points at and the note stays the one launched document. A database wal and journal follow that database. A launch argument is respelled the same way. Teardown signals a detached PID only when its start identity still matches. * fix(desktop): keep a witness's actions and an incomplete checkpoint A failed witness save retries the action log it first recorded. A deletion left unconfirmed between the seed and the end is not reported as no change. Windows packs npm through a shell, which can start npm.cmd. * fix(desktop): read verifier pointers from the parsed checks A brace inside a check value is not an object boundary. A leading-zero pointer after that value still rejects the older index helper. * fix(desktop): leave out a sibling lock under a selected folder A file under a selected folder inherits selected_path. That does not make the lock beside its map a chosen document, so a settings change stays drafted. * fix(desktop): prefer the most specific home spelling and retarget launch files A selected parent directory no longer hides a settings folder walked through a link. A launch filename under that folder is replaced with the seeded file's absolute path. * fix(desktop): pack the build runtime through npm's JavaScript entry npm pack and npm ci no longer spawn npm.cmd through a shell, so a temporary directory whose path contains spaces stays one argument. * fix(desktop): spell launch settings and keep a database log exact A database log matches only that file, so a neighboring folder is not moved. A launch option and the working directory use the same home spelling as the seed. * fix(desktop): draft a lock the export selected exactly The draft uses the same selected paths as the export. A lock chosen by itself stays in the draft. A lock under a selected folder stays out. * fix(desktop): move a state database's logs with the database The write-ahead log and journal follow the spelling the database received, including a walked state link. A neighboring folder stays put. A separate --config argument uses that spelling too. * fix(desktop): keep a selected file ahead of a database-log guess A file selected as itself keeps that spelling even when its name ends in -journal. Relative launch arguments are resolved in the captured working directory before that directory moves. * fix(desktop): keep outside-home database logs and option values A selected database outside the home keeps its write-ahead log. Launch options are rewritten only when they name a captured file or settings path. * fix(desktop): follow a database mapping and own limitation text A selected database keeps its log when a parent folder moves or the file sits outside the home. Trash and seed-lock tests compare the owning notice with the paths, and launch options that are not captured paths stay as written. * fix(desktop): retarget a settings folder that holds captured files A nested profile directory is a captured path when a seeded file is inside it. An option value that only resolves under the home stays as written. * fix(desktop): keep a program word and a directory's neighbor The program after env stays the program even when a captured file sits in a folder of that name. A selected directory is not a database, so a neighboring file keeps the parent folder's spelling. * fix(desktop): record whether a selected path is a directory Capture stores the walked path's kind. A neighboring file of a selected directory keeps the parent folder's spelling even when that directory never changes and its children are not content roots. * fix(desktop): mark containing paths without a pairwise scan A recorded path marks its known ancestors as directories in one walk up the path, instead of comparing every recorded path with every other. * fix(desktop): keep a selected folder that is named like a database log A folder the caller selected stays a selected root when its name ends in -journal or -wal. A generated log beside a database file still does not. * fix(desktop): refuse a build command that exceeds the argument vector Splitting one argument still leaves the whole command under Linux's E2BIG limit. A request past that budget is refused before the process starts. * fix(desktop): remap a captured path in an env assignment The program word stays the program. A NAME=value assignment keeps its name and follows a captured path value when that path's spelling moves. * fix(desktop): keep oversized text out of draft guards A value or line longer than the draft's text bound is not copied into a guard, so one large frame cannot fill the task by itself. * fix(desktop): keep an empty env value empty An empty assignment is not a path. Resolving it named the working directory and rewrote the value when that directory's spelling moved. * fix(desktop): check build runtime export capability * fix(desktop): provide complete runtime repack command * fix(desktop): retain runtime probe failures * test(desktop): identify capture independently of schema preflight * fix(desktop): preserve document URLs and value formats * fix(desktop): bound added checks and omit noisy text guards * fix(desktop): preserve launch URLs and JSON draft values * fix(desktop): recognize captured file URL documents * fix(desktop): render captured file URLs for runtime home * fix(desktop): distinguish phone numbers from save metadata * fix(desktop): preserve launch URLs and user text * fix(desktop): preserve local file URL authority * fix(desktop): preserve local file URL authority * fix(desktop): render captured file URLs at launch * fix(desktop): classify numeric timestamps using field context * fix(desktop): classify numeric timestamps using field context * fix(desktop): retain context for generated draft values * fix(desktop): retain context for generated draft values * test(desktop): cover generated IDs in text guards * test(desktop): cover generated IDs in text guards * fix(desktop): isolate launcher URL encoding * fix(desktop): isolate launcher URL encoding * fix(desktop): isolate launcher URL encoding * fix(desktop): retain clean text in noisy record fields * style(desktop): format the noisy-record draft files with the pinned oxfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(desktop): move launch execution to boundary suite * fix(desktop): redact build schema failure output * refactor(desktop): normalize schema failure once * fix(desktop): omit unsafe schema parser snippets * fix(desktop): preserve draft change and selected path evidence * test(desktop): group retained dconf note regressions * fix(desktop): respect archive entry format in draft checks * test(desktop): separate unowned-file draft note cases * fix(desktop): preserve explicitly selected journal documents * docs(desktop): clarify task checks before scratch export * fix(desktop): respell selected paths in file URLs * test(desktop): group captured document spelling cases * fix(desktop): bound draft preview serialization * fix(desktop): restore selected state aliases * fix(desktop): respect entry format in draft noise * test(desktop): match exported alias evidence at its JSON boundary * fix(desktop): retain app state logs in exported seeds * style: fix desktop launch test formatting * style: format native command path schema * fix(desktop): map selected external launch paths * style(desktop): format seed document regressions * test(desktop): group launch URL regressions * fix(desktop): omit redundant nested state aliases * fix(desktop): compare added free text as a whole * fix(desktop): check recompressed seeds as shipped * docs(desktop): clarify recovered seed format * test(desktop): group shipped gzip seed checks * fix(desktop): map captured launch paths once * refactor: remove unused whole-text guard state * refactor: stop passing unused text children * test: align free-text checks with whole-note policy * fix: validate recompressed desktop archive seeds * style(desktop): format archive seed regression * fix(desktop): redact runtime schema errors * fix(desktop): normalize captured file URLs * fix(desktop): report seeded files by capture path * fix(desktop): retain startup readiness failure stage * fix(desktop): retain JSON date checks * fix(desktop): preserve archive formatting checks * fix(desktop): preserve explicitly selected journal files * fix(desktop): report build command transport limits * docs(desktop): clarify unreadable dconf versions * style(desktop): format capture review fixes --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
396 lines
14 KiB
TypeScript
396 lines
14 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
|
|
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
|
import {
|
|
checkpointNameRefusal,
|
|
type DesktopCaptureEvent,
|
|
type DesktopSession,
|
|
} from "../domain/desktop/desktopCapture.js";
|
|
import { ownedCommandResources } from "../process/OwnedCommand.js";
|
|
import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js";
|
|
import {
|
|
ProviderProcessSupervisor,
|
|
spawnOwnedProviderProcess,
|
|
type SpawnedOwnedProviderProcess,
|
|
} from "../process/ProviderProcess.js";
|
|
import { terminateTokenOwnedProcesses } from "../process/TokenOwnedTermination.js";
|
|
import {
|
|
SessionObservations,
|
|
matchesWindowNames,
|
|
observedSession,
|
|
recordWindows,
|
|
sampleOpenFiles,
|
|
sampleProcesses,
|
|
sampleTokenCarriers,
|
|
trackProcess,
|
|
type WindowMatching,
|
|
} from "./DesktopSessionObservations.js";
|
|
import type { WindowSample, X11WindowObserver } from "./X11WindowObserver.js";
|
|
|
|
/** The most of the launched process's stderr a session keeps: its latest bytes, where a failing app's last words are. */
|
|
const STDERR_BYTES = 8 * 1024;
|
|
const TERMINATION_GRACE_MS = 5_000;
|
|
|
|
/** A cleanup that could not be confirmed, and the exact owners that can retry it. */
|
|
export interface UnresolvedCleanup {
|
|
readonly reason: string;
|
|
readonly leases: readonly AnalysisCleanupLease[];
|
|
}
|
|
|
|
/** The session record and, when cleanup was not verified, what remains owned. */
|
|
export interface DesktopSessionOutcome {
|
|
readonly session: DesktopSession;
|
|
readonly unreleased: UnresolvedCleanup | undefined;
|
|
/** Samples whose window list could not be read; their windows are unknown. */
|
|
readonly unreadableWindowSamples: number;
|
|
}
|
|
|
|
/** A session failed, and the processes it launched could not all be removed. */
|
|
export class DesktopSessionCleanupError extends Error {
|
|
constructor(
|
|
readonly primary: unknown,
|
|
readonly cleanup: UnresolvedCleanup,
|
|
) {
|
|
super(`desktop session cleanup is incomplete: ${cleanup.reason}`, {
|
|
cause: primary,
|
|
});
|
|
this.name = "DesktopSessionCleanupError";
|
|
}
|
|
}
|
|
|
|
/** A line the person or driver sent while the session runs. */
|
|
export type SessionRequest =
|
|
| { readonly kind: "checkpoint"; readonly name: string | undefined }
|
|
| { readonly kind: "stop" };
|
|
|
|
type CheckpointTrigger = "request" | "stop" | "exit";
|
|
|
|
/** Everything the session loop needs from its caller. */
|
|
export interface DesktopSessionPlan {
|
|
readonly argv: readonly string[];
|
|
readonly workingDirectory: string;
|
|
readonly environment: NodeJS.ProcessEnv;
|
|
readonly windows: X11WindowObserver;
|
|
readonly windowMatching: WindowMatching;
|
|
readonly roots: readonly string[];
|
|
readonly maxDurationMs: number;
|
|
readonly pollIntervalMs: number;
|
|
readonly nextRequest: () => SessionRequest | undefined;
|
|
/** Record a checkpoint, given the files the app holds open and the titles its windows have shown so far. */
|
|
readonly checkpoint: (
|
|
name: string,
|
|
trigger: CheckpointTrigger,
|
|
atMs: number,
|
|
seen: {
|
|
readonly openedFiles: ReadonlySet<string>;
|
|
readonly titles: readonly string[];
|
|
},
|
|
) => Promise<void>;
|
|
/** Progress text, with the event a driver waits on when there is one. */
|
|
readonly report: (
|
|
message: string,
|
|
event?: DesktopCaptureEvent,
|
|
) => Promise<void>;
|
|
readonly signal: AbortSignal | undefined;
|
|
}
|
|
|
|
/**
|
|
* Windows that already match the application by name before it is launched,
|
|
* or why the window list cannot be read. A capture refuses to start beside
|
|
* such windows: the launch could hand off to that instance, and its unsaved
|
|
* state would sit outside the baseline.
|
|
*/
|
|
export const runningApplicationWindows = async (
|
|
windows: X11WindowObserver,
|
|
names: ReadonlySet<string>,
|
|
signal?: AbortSignal,
|
|
): Promise<WindowSample> => {
|
|
const sample = await windows.sample(() => false, signal);
|
|
return sample.state === "listed"
|
|
? {
|
|
state: "listed",
|
|
windows: sample.windows.filter((window) =>
|
|
matchesWindowNames(window, names),
|
|
),
|
|
}
|
|
: sample;
|
|
};
|
|
|
|
/**
|
|
* Sample processes, windows and open files. Returns the application's window
|
|
* count, or undefined when the window list could not be read.
|
|
*/
|
|
const sampleSession = async (
|
|
plan: DesktopSessionPlan,
|
|
observations: SessionObservations,
|
|
signal: AbortSignal,
|
|
): Promise<number | undefined> => {
|
|
await sampleProcesses(observations, signal);
|
|
const sample = await plan.windows.sample(
|
|
(window) => observations.belongs(window),
|
|
signal,
|
|
);
|
|
let applicationWindows: number | undefined;
|
|
if (sample.state === "listed")
|
|
applicationWindows = await recordWindows(
|
|
observations,
|
|
sample.windows,
|
|
signal,
|
|
);
|
|
else observations.unreadableWindowSamples += 1;
|
|
await sampleOpenFiles(observations, plan.roots);
|
|
if (!observations.announced && observations.firstWindowMs !== null) {
|
|
observations.announced = true;
|
|
await plan.report(
|
|
"The application window is open. Send an empty line or `checkpoint <name>` to record its state, or `stop` to finish.",
|
|
{ kind: "window_open" },
|
|
);
|
|
}
|
|
return applicationWindows;
|
|
};
|
|
|
|
const checkpointNow = (
|
|
plan: DesktopSessionPlan,
|
|
observations: SessionObservations,
|
|
name: string,
|
|
trigger: CheckpointTrigger,
|
|
): Promise<void> =>
|
|
plan.checkpoint(name, trigger, observations.now(), {
|
|
openedFiles: new Set(observations.openedFiles.keys()),
|
|
titles: [...observations.windows.values()]
|
|
.filter((window) => window.application)
|
|
.flatMap((window) => window.titles),
|
|
});
|
|
|
|
/** Record a requested checkpoint, or report why its name was refused and carry on. */
|
|
const requestCheckpoint = async (
|
|
plan: DesktopSessionPlan,
|
|
observations: SessionObservations,
|
|
name: string | undefined,
|
|
taken: Set<string>,
|
|
): Promise<void> => {
|
|
const refusal = checkpointNameRefusal(name, taken);
|
|
if (refusal !== undefined) {
|
|
await plan.report(`${refusal} No checkpoint was recorded.`, {
|
|
kind: "checkpoint_refused",
|
|
name: name ?? "",
|
|
reason: refusal,
|
|
});
|
|
return;
|
|
}
|
|
const chosen = name ?? `checkpoint-${String(observations.now())}`;
|
|
taken.add(chosen);
|
|
await checkpointNow(plan, observations, chosen, "request");
|
|
};
|
|
|
|
/**
|
|
* Whether no process the capture started is left. Run-token carriers no
|
|
* parent link reached are looked for first, since a launcher can exit before
|
|
* its worker is sampled; an unreadable process table leaves this unknown.
|
|
*/
|
|
const noStartedProcessLeft = async (
|
|
observations: SessionObservations,
|
|
signal: AbortSignal,
|
|
): Promise<boolean> =>
|
|
observations.liveProcessIds(true).length === 0 &&
|
|
(await sampleTokenCarriers(observations, signal)) &&
|
|
observations.liveProcessIds(true).length === 0;
|
|
|
|
/**
|
|
* Watch until the application exits, a stop is requested or time runs out.
|
|
* The application has exited after two consecutive samples in which the
|
|
* launched process is gone, no started process is left and the window list
|
|
* was read and holds none of its windows; an unread list counts as unknown.
|
|
*/
|
|
const watch = async (
|
|
plan: DesktopSessionPlan,
|
|
observations: SessionObservations,
|
|
supervisor: ProviderProcessSupervisor,
|
|
signal: AbortSignal,
|
|
): Promise<DesktopSession["end_reason"]> => {
|
|
let quietSamples = 0;
|
|
const taken = new Set<string>();
|
|
for (;;) {
|
|
signal.throwIfAborted();
|
|
if (observations.now() >= plan.maxDurationMs) return "time_limit";
|
|
// One request per sample, so queued lines cannot starve observation.
|
|
const request = plan.nextRequest();
|
|
if (request?.kind === "stop") return "stop_requested";
|
|
if (request !== undefined)
|
|
await requestCheckpoint(plan, observations, request.name, taken);
|
|
const applicationWindows = await sampleSession(plan, observations, signal);
|
|
const quiet =
|
|
supervisor.observedExit() !== undefined &&
|
|
applicationWindows === 0 &&
|
|
(await noStartedProcessLeft(observations, signal));
|
|
quietSamples = quiet ? quietSamples + 1 : 0;
|
|
if (quietSamples >= 2) return "application_exited";
|
|
await delay(plan.pollIntervalMs, undefined, { signal });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Ask the launched process, its process group and every process carrying the
|
|
* run token to exit, wait up to the grace period, then remove whatever still
|
|
* carries the token. Processes that only own a matching window, such as an
|
|
* instance started outside the capture, are never signalled.
|
|
*/
|
|
const release = async (
|
|
spawned: SpawnedOwnedProviderProcess,
|
|
supervisor: ProviderProcessSupervisor,
|
|
): Promise<DesktopSession["cleanup"]> => {
|
|
// One SIGTERM each, since some applications treat a second one as a forced exit.
|
|
const { launchGroupSignalled } = await terminateTokenOwnedProcesses(
|
|
spawned.ownership,
|
|
TERMINATION_GRACE_MS,
|
|
);
|
|
if (!launchGroupSignalled && supervisor.observedExit() === undefined) {
|
|
spawned.process.kill("SIGTERM");
|
|
await supervisor.waitForExit(TERMINATION_GRACE_MS);
|
|
}
|
|
let stopped = await supervisor.stop();
|
|
// An incomplete stop leaves the supervisor owning the processes; try once more.
|
|
if (stopped.status === "incomplete") stopped = await supervisor.stop();
|
|
return stopped.status === "incomplete"
|
|
? { verified: false, reason: stopped.reason }
|
|
: { verified: true, reason: null };
|
|
};
|
|
|
|
/**
|
|
* Keep the supervisor owning processes whose release was not verified; its
|
|
* lease stops them again and is labelled with the run token, pid and group.
|
|
*/
|
|
const unreleased = (
|
|
spawned: SpawnedOwnedProviderProcess,
|
|
supervisor: ProviderProcessSupervisor,
|
|
cleanup: DesktopSession["cleanup"],
|
|
): UnresolvedCleanup | undefined =>
|
|
cleanup.verified
|
|
? undefined
|
|
: {
|
|
reason: cleanup.reason ?? "cleanup was not verified",
|
|
leases: [
|
|
new AnalysisCleanupLease(async () => {
|
|
const stopped = await supervisor.stop();
|
|
if (stopped.status === "incomplete")
|
|
throw new Error(stopped.reason);
|
|
}, ownedCommandResources(spawned.ownership)),
|
|
],
|
|
};
|
|
|
|
/**
|
|
* The caller's environment without the agent environment's own variables,
|
|
* such as its control token: agentenv launches the exported app without
|
|
* them, and the app must not hold a credential to the computer it runs on.
|
|
* glibc writes a fatal error, such as an abort's reason, to stderr rather
|
|
* than the terminal, so a failed launch reports it.
|
|
*/
|
|
const applicationEnvironment = (
|
|
environment: NodeJS.ProcessEnv,
|
|
): NodeJS.ProcessEnv => ({
|
|
...Object.fromEntries(
|
|
Object.entries(environment).filter(
|
|
([name]) => !name.startsWith("AGENTENV_"),
|
|
),
|
|
),
|
|
LIBC_FATAL_STDERR_: "1",
|
|
});
|
|
|
|
const launch = async (
|
|
plan: DesktopSessionPlan,
|
|
runId: string,
|
|
signal: AbortSignal,
|
|
) => {
|
|
const [command, ...arguments_] = plan.argv;
|
|
const spawned = await spawnOwnedProviderProcess({
|
|
command: command ?? "",
|
|
arguments: arguments_,
|
|
runId,
|
|
// Desktop launchers often exec through env, a shell or a wrapper script.
|
|
expectedCommand: null,
|
|
cwd: plan.workingDirectory,
|
|
hostEnvironment: applicationEnvironment(plan.environment),
|
|
sweepTokenOwnedProcesses: true,
|
|
signal,
|
|
});
|
|
const supervisor = new ProviderProcessSupervisor(
|
|
{
|
|
...spawned,
|
|
ownsProcessLifetime: true,
|
|
cleanup:
|
|
spawned.cleanup ?? (() => cleanupOwnedProcessGroup(spawned.ownership)),
|
|
},
|
|
{
|
|
maxDiagnosticBytes: { stdout: 0, stderr: STDERR_BYTES },
|
|
diagnosticRetention: "tail",
|
|
},
|
|
);
|
|
return { spawned, supervisor };
|
|
};
|
|
|
|
/**
|
|
* Launch the application with a run token in its own process group and watch
|
|
* it. Checkpoints run between samples. Cancellation ends the session without
|
|
* further checkpoints and still releases the application; any other failure
|
|
* releases it the same way before it is rethrown.
|
|
*/
|
|
export const runDesktopSession = async (
|
|
plan: DesktopSessionPlan,
|
|
): Promise<DesktopSessionOutcome> => {
|
|
const signal = plan.signal ?? new AbortController().signal;
|
|
const runId = randomUUID();
|
|
const startedAt = new Date();
|
|
const startedMs = performance.now();
|
|
const { spawned, supervisor } = await launch(plan, runId, signal);
|
|
const observations = new SessionObservations(
|
|
startedMs,
|
|
{ runId, launchPid: spawned.ownership.leaderPid },
|
|
plan.windowMatching,
|
|
);
|
|
let endReason: DesktopSession["end_reason"];
|
|
let cleanup: DesktopSession["cleanup"];
|
|
// Released at most once: a second release would signal the processes again.
|
|
let released: Promise<DesktopSession["cleanup"]> | undefined;
|
|
const releaseOnce = () => (released ??= release(spawned, supervisor));
|
|
try {
|
|
await trackProcess(observations, spawned.ownership.leaderPid, signal);
|
|
endReason = await watch(plan, observations, supervisor, signal);
|
|
if (endReason !== "application_exited")
|
|
await checkpointNow(
|
|
plan,
|
|
observations,
|
|
endReason === "stop_requested" ? "stop" : "time-limit",
|
|
"stop",
|
|
);
|
|
cleanup = await releaseOnce();
|
|
await checkpointNow(plan, observations, "exit", "exit");
|
|
} catch (cause: unknown) {
|
|
cleanup = await releaseOnce();
|
|
if (!signal.aborted) {
|
|
const left = unreleased(spawned, supervisor, cleanup);
|
|
if (left === undefined) throw cause;
|
|
throw new DesktopSessionCleanupError(cause, left);
|
|
}
|
|
endReason = "cancelled";
|
|
}
|
|
const snapshot = supervisor.snapshot();
|
|
const exit = supervisor.observedExit();
|
|
const session: DesktopSession = {
|
|
started_at: startedAt.toISOString(),
|
|
duration_ms: observations.now(),
|
|
end_reason: endReason,
|
|
launch_pid: spawned.ownership.leaderPid,
|
|
exit: exit === undefined ? null : { code: exit.code, signal: exit.signal },
|
|
...observedSession(observations),
|
|
stderr: snapshot.stderr.text,
|
|
stderr_truncated: snapshot.stderr.bytes < snapshot.stderr.observedBytes,
|
|
cleanup,
|
|
};
|
|
return {
|
|
session,
|
|
unreleased: unreleased(spawned, supervisor, cleanup),
|
|
unreadableWindowSamples: observations.unreadableWindowSamples,
|
|
};
|
|
};
|