From 15f8314d88feb57c1a3ef395ab03a5a769d280cc Mon Sep 17 00:00:00 2001 From: Highbyte Date: Wed, 16 Sep 2026 14:26:44 +0200 Subject: [PATCH 1/2] Add worker run inspection and cooperative CLI controls --- docs/reference/worker.md | 26 ++++ docs/reference/workers.md | 30 +++- skills/wrighty/SKILL.md | 6 +- .../wrighty/references/board-and-workers.md | 2 + skills/wrighty/references/worker-control.md | 103 +++++++++++++ src/Highbyte.Wrighty.Cli/CliApplication.cs | 22 +-- .../CliWorkerDiscoveryCommands.cs | 70 ++++++++- .../CliWorkerLifecycleOutput.cs | 54 +++++++ .../Output/WorkerDiscoveryOutput.cs | 16 ++ .../Workers/WorkerInstances.cs | 5 + .../Workers/WorkerLaunchGuard.cs | 16 ++ .../Workers/WorkerRunHost.cs | 6 +- .../Workers/WorkerService.cs | 3 +- .../Cli/WorkerControlCommandTests.cs | 141 ++++++++++++++++++ .../Workers/LocalWorkerStateTests.cs | 10 +- .../Workers/WorkerInstanceRegistryTests.cs | 16 ++ 16 files changed, 502 insertions(+), 24 deletions(-) create mode 100644 skills/wrighty/references/worker-control.md create mode 100644 src/Highbyte.Wrighty.Cli/CliWorkerLifecycleOutput.cs create mode 100644 src/Highbyte.Wrighty.Core/Workers/WorkerLaunchGuard.cs create mode 100644 tests/Highbyte.Wrighty.UnitTests/Cli/WorkerControlCommandTests.cs diff --git a/docs/reference/worker.md b/docs/reference/worker.md index 7f6cf25..621635e 100644 --- a/docs/reference/worker.md +++ b/docs/reference/worker.md @@ -52,6 +52,32 @@ override of the saved preference, but the named CLI must still be detected: it e operator intent without silently enabling that agent for later workers. Manage the allowlist from the web console's **Agents** menu; see [User settings](user-settings.md). +## Process ownership and run receipts + +Workers run in the foreground. Keep bounded work attached to its invoking command; continuous +work needs an identified owner, such as a retained terminal or the existing web-hosted worker +supervisor. Model reasoning is not needed to keep the executable running. Terminal/session exit, +app cancellation, logout, and restart may end it; Wrighty provides no detached process manager, +service installation, startup registration, or automatic restart. + +The CLI emits a `worker-run-started` NDJSON receipt after host registration and before processing: +`schemaVersion: 1`, `runId`, `registered`, `configurationPathHash`, `configurationRevision`, +`scheduling`, `owner`, `statusCommand`, and `logs`. Scheduling contains the effective target, +agent selection, profile, workspace, filters and limits. Owner identifies the foreground PID and +lifetime. Logs are the invoking terminal's stdout/stderr, not a persisted CLI log file. Run the +status command with the same configuration/cache context. A receipt is not an item success event. + +A registration failure reports `registered: false` and a null run ID while foreground execution +continues; retain that command's output instead of launching a duplicate. A `--once` preflight with +no work exits without starting a host or emitting a receipt. A normal host return emits +`worker-run-completed` with run ID, summary, and reason (`finished`, `drained`, `OperatorStopNow`, +or `HostShutdown`). Exceptions instead use the existing error path. Missing output or registration +is not proof that work never started: inspect the item/session and the terminal before retrying. + +Use [worker discovery and control](workers.md) to inspect an exact run or request drain/interrupt. +Worker-spawned assessment and implementation processes carry `WRIGHTY_WORKER_CHILD=1`; live worker +launch from that context is refused with `WORKER_RECURSIVE_LAUNCH` to prevent recursive scheduling. + ## Requirements-readiness assessment By default, every fresh worker session starts with a separate requirements-only turn under a diff --git a/docs/reference/workers.md b/docs/reference/workers.md index 080869e..ce474fb 100644 --- a/docs/reference/workers.md +++ b/docs/reference/workers.md @@ -1,4 +1,4 @@ -# Worker discovery +# Worker discovery and control `wrighty workers` lists registered worker runs in the current local configuration scope. Plain listing reads configuration and registry files and probes only registered PIDs and their start @@ -8,6 +8,7 @@ up expired registry records. ```sh wrighty workers --json wrighty workers --item local:42 --json +wrighty workers show --json ``` `--item` resolves the canonical item ID and adds advisory pickup assessment using tracker state @@ -66,5 +67,30 @@ workers, failed registration, other users/configurations, and remote machines ca scope. An unreadable record makes coverage incomplete. Discovery does not broaden permissions or scan process command lines/environments. Cooperative stop still requires fresh verified identity. -`workers` is read-only. For launching see [worker.md](worker.md); for item action discovery see +`workers` without a control subcommand is read-only. `workers show ` selects one exact run; a missing run +returns `WORKER_NOT_RUNNING` only with complete registry coverage, otherwise +`WORKER_CONTROL_UNAVAILABLE`. A missing registration alone does not establish the item outcome. +For launching see [worker.md](worker.md); for item action discovery see [actions.md](actions.md). OS service installation and startup management are operator-managed. + +## Cooperative control + +```sh +wrighty workers drain --yes --json +wrighty workers interrupt --yes --json +``` + +Both commands require explicit `--yes` and operate in the current configuration/cache scope. +They use the same registry protocol polled by CLI and web-hosted workers. Drain closes intake and +finishes the active item; interrupt stops its agent process tree and runs bounded finalization. +The request revalidates the record's run/PID/start identity, host kind, configuration path scope, +liveness, protocol version, and supported mode. No raw process kill is used. Several hosted runs +can share a PID and remain independently controllable. Configuration changes do not prevent +stopping a verified run of that same configuration path; its startup snapshot remains visible. + +Success returns `schemaVersion: 1`, with `result.runId`, `requestedMode`, `accepted`, `code`, +`message`, and `completed: false`. This acknowledges persistence, not completed shutdown. +Interrupt escalates drain; a later drain cannot downgrade it. Check the owning terminal's final +output and the item state after exit. Refusals use the normal error envelope, including +`WORKER_NOT_VERIFIED`, `WORKER_IDENTITY_CHANGED`, and `WORKER_CONTROL_UNSUPPORTED`. +External restart/startup configuration is not changed by a cooperative stop. diff --git a/skills/wrighty/SKILL.md b/skills/wrighty/SKILL.md index 7b5c4c3..7968527 100644 --- a/skills/wrighty/SKILL.md +++ b/skills/wrighty/SKILL.md @@ -1,11 +1,11 @@ --- name: wrighty -description: Safely operate Wrighty through the `wrighty` CLI. Use only when the user explicitly asks to use Wrighty, the Wrighty CLI, or a Wrighty work-item ID, including board overview, worker discovery, pickup assessment, Queue/Send back/Resume actions, and requests to list, inspect, create, pick, claim, edit, move, finish, archive, or release tracked work. Do not trigger for generic tasks, GitHub issues, planning, backlogs, or project management that do not explicitly identify Wrighty. +description: Safely operate Wrighty through the `wrighty` CLI. Use only when the user explicitly asks to use Wrighty, the Wrighty CLI, or a Wrighty work-item ID, including board overview, worker discovery, launch/control, pickup assessment, Queue/Send back/Resume actions, and requests to list, inspect, create, pick, claim, edit, move, finish, archive, or release tracked work. Do not trigger for generic tasks, GitHub issues, planning, backlogs, or project management that do not explicitly identify Wrighty. --- # Wrighty - + Operate Wrighty state only through the `wrighty` command. Never mutate tracked state by editing local Markdown, invoking `gh`, calling GitHub APIs/MCP, writing claim comments, or changing Project @@ -15,6 +15,8 @@ For board overview, triage, individual and batch Queue/Send back/Resume actions, [references/board-and-workers.md](references/board-and-workers.md). Use `list --json` for workflow columns, `actions --json` for the shared action catalogue, and `workers [--item ] --json` for scoped worker evidence. Discovery does not authorize mutations or worker launches. +For authorized foreground/terminal worker launch, exact-run inspection, drain, or interrupt, read +[references/worker-control.md](references/worker-control.md). ## Workflow diff --git a/skills/wrighty/references/board-and-workers.md b/skills/wrighty/references/board-and-workers.md index 67dda36..d705c63 100644 --- a/skills/wrighty/references/board-and-workers.md +++ b/skills/wrighty/references/board-and-workers.md @@ -141,3 +141,5 @@ wait or when confirming an authorized operation, and stop on meaningful progress needed user decision. A conversation is not a guarantee of unattended monitoring. Worker-spawned implementation sessions must not recursively launch workers. Never run a paid provider probe merely to strengthen an assessment. + +For authorized launches and cooperative control, read [worker-control.md](worker-control.md). diff --git a/skills/wrighty/references/worker-control.md b/skills/wrighty/references/worker-control.md new file mode 100644 index 0000000..e585d77 --- /dev/null +++ b/skills/wrighty/references/worker-control.md @@ -0,0 +1,103 @@ +# Worker launch and control + +Read [board-and-workers.md](board-and-workers.md) for discovery and pickup evidence. Queueing, +resuming an item, and launching a worker are separate effects. Use the user's existing authorization +when it covers the selected items, agent, processing limits, and unattended execution; do not ask +again for an already authorized effect. + +## Decide whether to launch + +Before launching for a named item, run `get --json`, `actions --json`, and +`workers --item --json`. For queue processing, inspect `workers --json` and the requested board +selection. If a run already processes the item, report it; do not launch competing work. If a +suitable worker could pick it up, wait only when requested, with a bounded observation period. +Unknown or incomplete evidence is not permission to launch a replacement. Explain the missing +information and resolve it or get an explicit decision about launching despite that uncertainty. + +Choose a worker agent from explicit user intent, item policy, or configured default. The agent +hosting this conversation is not a default. Preserve configured workspace and profile choices +unless the user requests an override. Never probe a paid vendor merely to check readiness. + +Use the smallest processing scope that meets the request: + +```shell +wrighty worker --item --item-timeout 30m --yes --json +wrighty worker --once --item-timeout 30m --yes --json +wrighty worker --max-items 3 --idle-timeout 5m --item-timeout 30m --yes --json +``` + +`--item` selects exactly one item, including existing continuation rules; `--once` selects the next +eligible item. Do not replace one with the other. Use `--agent`, `--profile`, `--filter name=value`, +`--workspace-mode`, `--from`, and `--to` only within the requested scope. `--item-timeout` bounds each +item, not the whole worker. `--max-items` bounds item count, and `--idle-timeout` bounds an idle +period. A targeted run already has an effective item limit of one. Do not take over claims or force +`--fresh`, `--resume`, or `--handoff` merely to get past a refusal. + +## Own the process honestly + +For Codex, Claude, Copilot, and OpenCode surfaces, foreground execution is the portable path. +Keep a bounded run attached to the invoking command, consuming its output through completion. +For authorized continuous work, use a user-owned terminal or a host-provided retained terminal +whose lifetime is established. For example, in that terminal: + +```shell +wrighty worker --idle-timeout 30m --item-timeout 30m --yes --json +``` + +State the terminal/process owner and its actual cancellation and exit behavior before launching. +A retained terminal keeps the executable running without a model reasoning loop, but is not a +promise of survival across task cancellation, app exit, logout, or restart. There is no Wrighty +detach command. Do not emulate one with background shell syntax or invent a keepalive loop. +If the available tool cannot retain the process for the required duration, provide the exact +foreground command for the user's terminal and say it has not been started. + +Existing web-hosted runs can also provide continuous processing. Their launch remains in the web +console; starting `wrighty web` alone does not start a worker. Do not spoof its browser requests. +No vendor-specific detached launch path is offered by this skill. + +Worker-spawned sessions must not start workers recursively. `WRIGHTY_WORKER_CHILD=1` marks that +context and live launch returns `WORKER_RECURSIVE_LAUNCH`; do not remove the marker to bypass it. + +## Read the launch outcome + +`worker --json` streams NDJSON. Preserve the `worker-run-started` receipt: its `runId`, `registered`, +configuration scope/revision, structured `scheduling`, foreground `owner`, `statusCommand`, and +`logs` identify the actual run and limits. Registration precedes work execution; it is not proof +that an agent has started or that an item succeeded. Item events report that progress. + +Run inspection/control in the same configuration and cache context as the launch. Logs remain in +the invoking terminal's stdout/stderr; Wrighty does not create a durable CLI run-log file. Save or +retain that output using the host's supported facility when the request needs it. + +`registered: false` means execution may continue without registry control. Keep the owning +command attached and observe it; do not start a duplicate to obtain a run ID. An empty `--once` +preflight exits without a launch receipt. `worker-run-completed` reports the summary and stop +reason after the host has unwound. Read item state as well when interruption or failure needs +recovery. An error or missing receipt alone does not establish that nothing started: inspect +workers, item/session state, and terminal output before any retry. + +## Inspect, drain, or interrupt an exact run + +```shell +wrighty workers show --json +wrighty workers drain --yes --json +wrighty workers interrupt --yes --json +``` + +Inspect the exact run and explain the requested effect unless already authorized. Drain closes +intake and lets the active item and bookkeeping finish. Interrupt cancels the active agent process +tree and invokes the existing bounded finalizer; it is not a normal successful completion. + +Both CLI and web-hosted runs use the existing registry/control protocol. Never kill a PID: hosted +runs can share a web process. Control re-reads the record and verifies process/start identity, +configuration scope, protocol, and supported mode. Stale, unknown, changed, or legacy identities +produce a refusal. Do not bypass it or fall back to an OS process kill. + +An accepted response has `completed: false`: the request was persisted, not necessarily observed +by the worker yet. Observe the owning terminal and re-inspect the run within a bounded interval. +A missing registration is not proof that item bookkeeping succeeded; inspect the item after exit. +Interrupt can escalate a pending drain; a later drain never downgrades an interrupt. If a control +response is ambiguous, refresh before retrying the same run; never select a replacement run +implicitly. An external supervisor may restart a stopped process; stopping does not disable that +supervisor. OS services, startup installation, automatic restart, and boot persistence remain +outside this skill's workflow. diff --git a/src/Highbyte.Wrighty.Cli/CliApplication.cs b/src/Highbyte.Wrighty.Cli/CliApplication.cs index 03c9aab..d2ee829 100644 --- a/src/Highbyte.Wrighty.Cli/CliApplication.cs +++ b/src/Highbyte.Wrighty.Cli/CliApplication.cs @@ -1399,6 +1399,7 @@ private async Task ExecuteWorkerAsync(WorkerOptions options, CancellationTo }; ValidateWorkerInvocation( checkOnly, item, requireResume, requireFresh, requireHandoff, options.Profile); + if (!options.DryRun) WorkerLaunchGuard.EnsureAllowed(); if (checkOnly) { await workerService.CheckAsync(options.Agent ?? config.EffectiveWorker.DefaultAgent, @@ -1539,19 +1540,15 @@ private async Task RunWorkerAsync( : string.Empty); using var control = new WorkerRunControl(); var host = new WorkerRunHost(workerService!, workerInstances); + var identity = new WorkerRunIdentity(workingDirectory, configPath, revision, + WorkerInvocationSummary(options, selection.Item, selection.Intent), WorkerHostKind.CliProcess); + var runSelection = new WorkerRunSelection( + selection.Item is null ? null : tracker.ResolveId(config, selection.Item), selection.Intent, selection.ClaimToken); var summary = await host.RunAsync( config, options, - new WorkerRunIdentity( - workingDirectory, - configPath, - revision, - WorkerInvocationSummary(options, selection.Item, selection.Intent), - WorkerHostKind.CliProcess), - new WorkerRunSelection( - selection.Item is null ? null : tracker.ResolveId(config, selection.Item), - selection.Intent, - selection.ClaimToken), + identity, + runSelection, control, new WorkerRunCallbacks( ordinaryOutput, @@ -1559,8 +1556,11 @@ private async Task RunWorkerAsync( { CliDiagnostics.WorkerRuntimeWarning(diagnostics, message); return Task.CompletedTask; - }), + }, + runId => WriteWorkerLaunchAsync(runId, identity, + WorkerScheduling.From(config, options, identity, runSelection), options.Json)), cancellationToken); + await WriteWorkerCompletionAsync(control, summary, options.Json); cancellationToken.ThrowIfCancellationRequested(); return summary; } diff --git a/src/Highbyte.Wrighty.Cli/CliWorkerDiscoveryCommands.cs b/src/Highbyte.Wrighty.Cli/CliWorkerDiscoveryCommands.cs index 6801d8e..458aa45 100644 --- a/src/Highbyte.Wrighty.Cli/CliWorkerDiscoveryCommands.cs +++ b/src/Highbyte.Wrighty.Cli/CliWorkerDiscoveryCommands.cs @@ -1,5 +1,6 @@ using System.CommandLine; using Highbyte.Wrighty.Configuration; +using Highbyte.Wrighty.Errors; using Highbyte.Wrighty.Workers; namespace Highbyte.Wrighty.Cli; @@ -10,18 +11,79 @@ private Command BuildWorkersCommand() { var json = JsonOption(); var item = new Option("--item") { Description = "Assess whether each observed worker could pick up this item." }; - var command = new Command("workers", "Inspect registered local workers without starting or controlling them"); + var command = new Command("workers", "Inspect registered local workers or request cooperative control"); command.Options.Add(json); command.Options.Add(item); + command.Subcommands.Add(BuildWorkerShowCommand()); + command.Subcommands.Add(BuildWorkerStopCommand(WorkerStopMode.Drain)); + command.Subcommands.Add(BuildWorkerStopCommand(WorkerStopMode.Interrupt)); command.SetAction((parsed, cancellationToken) => ExecuteAsync(parsed.GetValue(json), - config => InspectWorkersAsync(config, parsed.GetValue(item), parsed.GetValue(json), cancellationToken), + config => InspectWorkersAsync(config, parsed.GetValue(item), null, parsed.GetValue(json), cancellationToken), cancellationToken)); return command; } - private async Task InspectWorkersAsync(TrackerConfig config, string? item, bool json, + private Command BuildWorkerShowCommand() + { + var run = new Argument("run-id"); + var json = JsonOption(); + var item = new Option("--item") { Description = "Assess whether this worker could pick up this item." }; + var command = new Command("show", "Inspect one registered worker run without starting or controlling it"); + command.Arguments.Add(run); + command.Options.Add(json); + command.Options.Add(item); + command.SetAction((parsed, token) => ExecuteAsync(parsed.GetValue(json), + config => InspectWorkersAsync(config, parsed.GetValue(item), parsed.GetValue(run), parsed.GetValue(json), token), + token)); + return command; + } + + private async Task InspectWorkersAsync(TrackerConfig config, string? item, string? run, bool json, CancellationToken cancellationToken) - => await writer.WriteWorkersAsync(await ReadWorkersAsync(config, item, cancellationToken), json); + { + var discovery = await ReadWorkersAsync(config, item, cancellationToken); + if (run is not null) + { + var matches = discovery.LocalWorkers.Where(entry => entry.Instance.RunId == run).ToArray(); + if (matches.Length == 0) + throw MissingWorker(discovery.Coverage); + discovery = discovery with { LocalWorkers = matches }; + } + await writer.WriteWorkersAsync(discovery, json); + } + + private Command BuildWorkerStopCommand(WorkerStopMode mode) + { + var run = new Argument("run-id"); + var json = JsonOption(); + var yes = new Option("--yes") { Description = "Authorize control of this exact run." }; + var command = new Command(mode.ToString().ToLowerInvariant(), mode == WorkerStopMode.Drain + ? "Close intake and finish the current item before exiting" + : "Interrupt the current agent process tree and finalize the item before exiting"); + command.Arguments.Add(run); + command.Options.Add(json); + command.Options.Add(yes); + command.SetAction((parsed, token) => ExecuteAsync(parsed.GetValue(json), async config => + { + if (!parsed.GetValue(yes)) + throw new TrackerException("WORKER_CONFIRMATION_REQUIRED", + "Inspect the run, then pass --yes to authorize the requested control.", 2); + var path = config.SourcePath ?? Path.Combine(workingDirectory, TrackerConfigLoader.FileName); + var snapshot = await workerInstances.InspectAsync(path, token); + var status = snapshot.Workers.SingleOrDefault(entry => entry.Instance.RunId == parsed.GetValue(run)); + if (status is null) throw MissingWorker(snapshot.Coverage); + var instance = status.Instance; + var result = await workerInstances.RequestStopAsync(path, + new(instance.RunId, instance.ProcessId, instance.ProcessStartIdentity, instance.HostKind), mode, token); + if (!result.Accepted) throw new TrackerException(result.Code, result.Message, 7); + await writer.WriteWorkerControlAsync(instance.RunId, mode, result, parsed.GetValue(json)); + }, token)); + return command; + } + + private static TrackerException MissingWorker(string coverage) => coverage == "complete" + ? new("WORKER_NOT_RUNNING", "The run is no longer registered in this configuration. Inspect the item and owning terminal for its outcome.", 7) + : new("WORKER_CONTROL_UNAVAILABLE", "The run could not be found in incomplete or unavailable registry evidence.", 7); private async Task ReadWorkersAsync(TrackerConfig config, string? item, CancellationToken cancellationToken) diff --git a/src/Highbyte.Wrighty.Cli/CliWorkerLifecycleOutput.cs b/src/Highbyte.Wrighty.Cli/CliWorkerLifecycleOutput.cs new file mode 100644 index 0000000..a1d3057 --- /dev/null +++ b/src/Highbyte.Wrighty.Cli/CliWorkerLifecycleOutput.cs @@ -0,0 +1,54 @@ +using System.Text.Json; +using Highbyte.Wrighty.Workers; + +namespace Highbyte.Wrighty.Cli; + +public sealed partial class CliApplication +{ + private static readonly JsonSerializerOptions WorkerReceiptJson = new(JsonSerializerDefaults.Web); + + private async Task WriteWorkerLaunchAsync(string runId, WorkerRunIdentity identity, + WorkerScheduling scheduling, bool json) + { + var registered = !string.IsNullOrEmpty(runId); + var statusCommand = registered ? new[] { "wrighty", "workers", "show", runId, "--json" } : null; + if (json) + { + await output.WriteLineAsync(JsonSerializer.Serialize(new + { + schemaVersion = 1, type = "worker-run-started", runId = registered ? runId : null, + registered, configurationPathHash = JsonWorkerInstanceRegistry.ConfigurationPathHash(identity.ConfigurationPath), + identity.ConfigurationRevision, scheduling, + owner = new { kind = "foreground-process", processId = Environment.ProcessId, + lifetime = "Attached to the invoking process/terminal; no detach or restart guarantee." }, + statusCommand, logs = "Invoking terminal stdout (events) and stderr (diagnostics)." + }, WorkerReceiptJson)); + } + else + { + await output.WriteLineAsync($"Worker run: {(registered ? runId : "unregistered — registry control unavailable")}"); + await output.WriteLineAsync($"Owner: foreground process {Environment.ProcessId}; selection: {scheduling.Mode}; target: {scheduling.TargetItemId ?? "next eligible items"}; limit: {scheduling.ItemLimit?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "unlimited"}."); + await output.WriteLineAsync("Lifetime: attached to this process/terminal. Logs: terminal stdout/stderr."); + if (statusCommand is not null) + await output.WriteLineAsync($"Status (same configuration/cache): {string.Join(' ', statusCommand)}"); + } + await output.FlushAsync(); + } + + private async Task WriteWorkerCompletionAsync(WorkerRunControl control, WorkerRunSummary summary, bool json) + { + var reason = control.IntakeClosed ? "drained" : "finished"; + if (control.IsInterrupted) reason = control.InterruptionReason.ToString(); + if (json) + await output.WriteLineAsync(JsonSerializer.Serialize(new + { + schemaVersion = 1, type = "worker-run-completed", + runId = string.IsNullOrEmpty(control.RunId) ? null : control.RunId, + reason, + summary + }, WorkerReceiptJson)); + else + await output.WriteLineAsync($"Worker run ended: {control.RunId ?? "unregistered"}; processed: {summary.Processed}; needs attention: {summary.NeedsAttention}; failed: {summary.Failed}."); + await output.FlushAsync(CancellationToken.None); + } +} diff --git a/src/Highbyte.Wrighty.Cli/Output/WorkerDiscoveryOutput.cs b/src/Highbyte.Wrighty.Cli/Output/WorkerDiscoveryOutput.cs index 952afbb..8a1a8b8 100644 --- a/src/Highbyte.Wrighty.Cli/Output/WorkerDiscoveryOutput.cs +++ b/src/Highbyte.Wrighty.Cli/Output/WorkerDiscoveryOutput.cs @@ -4,6 +4,22 @@ namespace Highbyte.Wrighty.Cli.Output; public sealed partial class OutputWriter { + public async Task WriteWorkerControlAsync(string runId, WorkerStopMode mode, + WorkerStopRequestResult result, bool json) + { + if (json) + { + await WriteJsonAsync(new { schemaVersion = 1, result = new + { + runId, requestedMode = mode.ToString().ToLowerInvariant(), + result.Accepted, result.Code, result.Message, completed = false + } }); + return; + } + await output.WriteLineAsync($"{runId}: {result.Message}"); + await output.WriteLineAsync("Request accepted; completion is not yet verified. Inspect the run and its owning terminal."); + } + public async Task WriteWorkersAsync(WorkerDiscovery discovery, bool json) { if (json) diff --git a/src/Highbyte.Wrighty.Core/Workers/WorkerInstances.cs b/src/Highbyte.Wrighty.Core/Workers/WorkerInstances.cs index d02d124..5a5d2cf 100644 --- a/src/Highbyte.Wrighty.Core/Workers/WorkerInstances.cs +++ b/src/Highbyte.Wrighty.Core/Workers/WorkerInstances.cs @@ -378,6 +378,11 @@ public async Task RequestStopAsync( WorkerStopMode mode, CancellationToken cancellationToken) { + // Run IDs are file names, never paths supplied by a control caller. + if (string.IsNullOrWhiteSpace(target.RunId) || target.RunId is "." or ".." || + target.RunId.IndexOfAny(['/', '\\', ':']) >= 0 || + target.RunId.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + return StopRejected("WORKER_IDENTITY_INVALID", "The worker run ID is invalid."); var pathHash = ConfigurationPathHash(configurationPath); var recordPath = RecordPath(pathHash, target.RunId); WorkerInstance? instance; diff --git a/src/Highbyte.Wrighty.Core/Workers/WorkerLaunchGuard.cs b/src/Highbyte.Wrighty.Core/Workers/WorkerLaunchGuard.cs new file mode 100644 index 0000000..0755ab8 --- /dev/null +++ b/src/Highbyte.Wrighty.Core/Workers/WorkerLaunchGuard.cs @@ -0,0 +1,16 @@ +using Highbyte.Wrighty.Errors; + +namespace Highbyte.Wrighty.Workers; + +/// Prevents worker-spawned sessions from recursively creating more worker hosts. +public static class WorkerLaunchGuard +{ + public const string ChildEnvironmentVariable = "WRIGHTY_WORKER_CHILD"; + + public static void EnsureAllowed() + { + if (Environment.GetEnvironmentVariable(ChildEnvironmentVariable) == "1") + throw new TrackerException("WORKER_RECURSIVE_LAUNCH", + "A worker-spawned agent must not start another worker. Let its owning worker manage execution.", 2); + } +} diff --git a/src/Highbyte.Wrighty.Core/Workers/WorkerRunHost.cs b/src/Highbyte.Wrighty.Core/Workers/WorkerRunHost.cs index b58d7f5..0126db8 100644 --- a/src/Highbyte.Wrighty.Core/Workers/WorkerRunHost.cs +++ b/src/Highbyte.Wrighty.Core/Workers/WorkerRunHost.cs @@ -25,7 +25,8 @@ public sealed record WorkerRunIdentity( public sealed record WorkerRunCallbacks( Func Emit, - Func? Warn); + Func? Warn, + Func? Registered = null); internal sealed record WorkerInstanceEventState( string? ItemId, @@ -237,6 +238,7 @@ public async Task RunAsync( WorkerRunCallbacks callbacks, CancellationToken hostCancellationToken) { + WorkerLaunchGuard.EnsureAllowed(); var registration = await RegisterAsync( identity.ConfigurationPath, identity.ConfigurationRevision, @@ -276,6 +278,8 @@ void ControlStateChanged() => _ = ReflectControlStateAsync( pollingStop.Token); try { + if (callbacks.Registered is not null) + await callbacks.Registered(registration.RunId); Func projected = value => ProjectEventAsync( value, registration, diff --git a/src/Highbyte.Wrighty.Core/Workers/WorkerService.cs b/src/Highbyte.Wrighty.Core/Workers/WorkerService.cs index 72c8d0a..11bd9b1 100644 --- a/src/Highbyte.Wrighty.Core/Workers/WorkerService.cs +++ b/src/Highbyte.Wrighty.Core/Workers/WorkerService.cs @@ -2955,10 +2955,11 @@ await RestoreInterruptedRetryAsync( private static Dictionary BuildRunEnvironment(ClaimedRun run) { if (run.Assessment is not null) - return []; + return new() { [WorkerLaunchGuard.ChildEnvironmentVariable] = "1" }; var environment = new Dictionary { + [WorkerLaunchGuard.ChildEnvironmentVariable] = "1", ["WRIGHTY_CLAIMANT_ID"] = run.ClaimantId, ["WRIGHTY_CLAIM_TOKEN"] = run.Grant.ClaimToken! }; diff --git a/tests/Highbyte.Wrighty.UnitTests/Cli/WorkerControlCommandTests.cs b/tests/Highbyte.Wrighty.UnitTests/Cli/WorkerControlCommandTests.cs new file mode 100644 index 0000000..b416863 --- /dev/null +++ b/tests/Highbyte.Wrighty.UnitTests/Cli/WorkerControlCommandTests.cs @@ -0,0 +1,141 @@ +using System.Text.Json; +using Highbyte.Wrighty.Caching; +using Highbyte.Wrighty.Workers; + +namespace Highbyte.Wrighty.UnitTests.Cli; + +public sealed partial class CliApplicationTests +{ + [Theory] + [InlineData("drain", WorkerStopMode.Drain, true)] + [InlineData("interrupt", WorkerStopMode.Interrupt, true)] + [InlineData("drain", WorkerStopMode.Drain, false)] + public async Task Control_addresses_one_hosted_run_without_stopping_its_shared_process(string verb, WorkerStopMode mode, bool json) + { + var path = Path.Combine(Path.GetTempPath(), $"wrighty-control-{Guid.NewGuid():N}"); + temporarySettingsRoots.Add(path); + var config = Config with { SourcePath = Path.Combine(path, ".wrighty.json"), SourceRevision = "revision" }; + var registry = new JsonWorkerInstanceRegistry(new CachePaths(path)); + await using var first = await registry.RegisterAsync(config.SourcePath, "revision", "web worker", + new(WorkerHostKind.WebHosted), CancellationToken.None); + await using var second = await registry.RegisterAsync(config.SourcePath, "revision", "web worker", + new(WorkerHostKind.WebHosted), CancellationToken.None); + var output = new StringWriter(); + var app = Application(new RecordingBackend(failReads: true), new StringReader(""), output, + config: config, workerInstanceRegistry: registry); + + Assert.Equal(0, await app.InvokeAsync(["workers", "show", first.RunId, "--json"])); + using (var inspection = JsonDocument.Parse(output.ToString())) + { + var entry = Assert.Single(inspection.RootElement.GetProperty("result").GetProperty("localWorkers").EnumerateArray()); + Assert.Equal(first.RunId, entry.GetProperty("instance").GetProperty("runId").GetString()); + } + output.GetStringBuilder().Clear(); + Assert.Equal(0, await app.InvokeAsync(json + ? ["workers", verb, first.RunId, "--yes", "--json"] + : ["workers", verb, first.RunId, "--yes"])); + if (json) + { + using var result = JsonDocument.Parse(output.ToString()); + Assert.True(result.RootElement.GetProperty("result").GetProperty("accepted").GetBoolean()); + Assert.False(result.RootElement.GetProperty("result").GetProperty("completed").GetBoolean()); + } + else + { + Assert.Contains(first.RunId, output.ToString()); + Assert.Contains("completion is not yet verified", output.ToString()); + } + Assert.Equal(mode, await first.ReadStopRequestAsync(CancellationToken.None)); + Assert.Null(await second.ReadStopRequestAsync(CancellationToken.None)); + } + + [Theory] + [InlineData("complete", "WORKER_NOT_RUNNING")] + [InlineData("unavailable", "WORKER_CONTROL_UNAVAILABLE")] + public async Task Missing_run_preserves_uncertainty_for_inspection_and_control(string coverage, string code) + { + IWorkerInstanceRegistry registry = coverage == "complete" ? new DiscoveryRegistry([]) : NoOpWorkerInstanceRegistry.Instance; + foreach (var args in new[] { new[] { "workers", "show", "missing", "--json" }, + ["workers", "drain", "missing", "--yes", "--json"] }) + { + var output = new StringWriter(); + Assert.Equal(7, await Application(new RecordingBackend(failReads: true), new StringReader(""), output, + workerInstanceRegistry: registry, error: output).InvokeAsync(args)); + Assert.Contains(code, output.ToString()); + } + } + + [Fact] + public async Task Control_requires_authorization_before_registry_or_tracker_access() + { + var output = new StringWriter(); + var registry = new DiscoveryRegistry([]); + Assert.Equal(2, await Application(new RecordingBackend(failReads: true), new StringReader(""), output, + workerInstanceRegistry: registry, error: output).InvokeAsync(["workers", "interrupt", "run", "--json"])); + Assert.Contains("WORKER_CONFIRMATION_REQUIRED", output.ToString()); + Assert.Null(registry.ObservedPath); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Control_rechecks_process_identity_and_rejects_legacy_protocol(bool live, bool modern) + { + var path = Path.Combine(Path.GetTempPath(), $"wrighty-control-{Guid.NewGuid():N}"); + temporarySettingsRoots.Add(path); + var config = Config with { SourcePath = Path.Combine(path, ".wrighty.json") }; + var registry = new JsonWorkerInstanceRegistry(new CachePaths(path)); + await using var run = await registry.RegisterAsync(config.SourcePath, "revision", "worker", + new(WorkerHostKind.CliProcess, ControlProtocolVersion: modern ? 1 : 0), CancellationToken.None); + var reader = live ? registry : new JsonWorkerInstanceRegistry(new CachePaths(path), + observeProcess: _ => new(true, null)); + var output = new StringWriter(); + Assert.Equal(live && modern ? 0 : 7, await Application(new RecordingBackend(failReads: true), + new StringReader(""), output, config: config, workerInstanceRegistry: reader, error: output) + .InvokeAsync(["workers", "drain", run.RunId, "--yes", "--json"])); + var expected = modern ? "WORKER_STOP_REQUESTED" : "WORKER_CONTROL_UNSUPPORTED"; + if (!live) expected = "WORKER_NOT_VERIFIED"; + Assert.Contains(expected, output.ToString()); + } + + [Theory] + [InlineData(true, false)] + [InlineData(false, false)] + [InlineData(false, true)] + public async Task Foreground_run_receipts_report_registration_limits_and_completion(bool registered, bool registrationFails) + { + var path = Path.Combine(Path.GetTempPath(), $"wrighty-launch-{Guid.NewGuid():N}"); + temporarySettingsRoots.Add(path); + var config = Config with { SourcePath = Path.Combine(path, ".wrighty.json"), SourceRevision = "revision" }; + IWorkerInstanceRegistry registry = registered ? new JsonWorkerInstanceRegistry(new CachePaths(path)) : NoOpWorkerInstanceRegistry.Instance; + if (registrationFails) registry = new FailingWorkerRegistry(); + var output = new StringWriter(); + var app = Application(new RecordingBackend(automaticExecutionAllowed: true), new StringReader(""), output, + inputRedirected: true, workerCandidate: true, candidateDisappearsAfterPreflight: true, + config: config, workerInstanceRegistry: registry); + Assert.Equal(0, await app.InvokeAsync(["worker", "--once", "--yes", "--json"])); + var events = output.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => JsonSerializer.Deserialize(line)).ToArray(); + var launch = Assert.Single(events, e => e.GetProperty("type").GetString() == "worker-run-started"); + var completed = Assert.Single(events, e => e.GetProperty("type").GetString() == "worker-run-completed"); + Assert.Equal(registered, launch.GetProperty("registered").GetBoolean()); + Assert.Equal(registered ? JsonValueKind.String : JsonValueKind.Null, launch.GetProperty("runId").ValueKind); + Assert.Equal(launch.GetProperty("runId").GetString(), completed.GetProperty("runId").GetString()); + Assert.Equal("foreground-process", launch.GetProperty("owner").GetProperty("kind").GetString()); + Assert.Equal("bounded", launch.GetProperty("scheduling").GetProperty("mode").GetString()); + Assert.Equal(1, launch.GetProperty("scheduling").GetProperty("itemLimit").GetInt32()); + Assert.Equal("finished", completed.GetProperty("reason").GetString()); + Assert.Empty((await registry.InspectAsync(config.SourcePath, CancellationToken.None)).Workers); + } + + private sealed class FailingWorkerRegistry : IWorkerInstanceRegistry + { + public Task RegisterAsync(string configurationPath, + string configurationRevision, string invocationSummary, CancellationToken cancellationToken) => + throw new IOException("Test registry unavailable."); + + public Task> ListAsync(string configurationPath, + CancellationToken cancellationToken) => Task.FromResult>([]); + } +} diff --git a/tests/Highbyte.Wrighty.UnitTests/Workers/LocalWorkerStateTests.cs b/tests/Highbyte.Wrighty.UnitTests/Workers/LocalWorkerStateTests.cs index 5e8e216..cecaf8a 100644 --- a/tests/Highbyte.Wrighty.UnitTests/Workers/LocalWorkerStateTests.cs +++ b/tests/Highbyte.Wrighty.UnitTests/Workers/LocalWorkerStateTests.cs @@ -112,6 +112,7 @@ [new ClaudeAgentAdapter()], Assert.Contains("5m elapsed", heartbeat.Message); Assert.Contains("timeout in 5m", heartbeat.Message); Assert.NotNull(runner.Environment); + Assert.Equal("1", runner.Environment[WorkerLaunchGuard.ChildEnvironmentVariable]); Assert.StartsWith("agent:worker:", runner.Environment!["WRIGHTY_CLAIMANT_ID"]); Assert.False(string.IsNullOrWhiteSpace(runner.Environment["WRIGHTY_CLAIM_TOKEN"])); Assert.Equal( @@ -2877,7 +2878,8 @@ [new ClaudeAgentAdapter()], Assert.Equal(1, summary.NeedsAttention); Assert.Equal(2, runner.Invocations.Count); - Assert.Empty(runner.Environments[0]); + Assert.Equal("1", Assert.Single(runner.Environments[0]).Value); + Assert.True(runner.Environments[0].ContainsKey(WorkerLaunchGuard.ChildEnvironmentVariable)); Assert.DoesNotContain("WRIGHTY_CLAIM_TOKEN", runner.Invocations[0].Environment.Keys); Assert.Contains("WRIGHTY_CLAIMANT_ID", runner.Invocations[0].EnvironmentVariablesToRemove!); @@ -2941,7 +2943,8 @@ [new ClaudeAgentAdapter()], Assert.Equal(1, summary.NeedsAttention); Assert.Equal(1, runner.Calls); - Assert.Empty(runner.Environment!); + Assert.Equal("1", Assert.Single(runner.Environment!).Value); + Assert.True(runner.Environment!.ContainsKey(WorkerLaunchGuard.ChildEnvironmentVariable)); Assert.Single(events, value => value.Type == "requirements-assessment-needs-clarification"); Assert.DoesNotContain(events, value => value.Type == "started"); @@ -3003,7 +3006,8 @@ [new ClaudeAgentAdapter()], Assert.Equal(1, summary.NeedsAttention); Assert.Equal(1, runner.Calls); - Assert.Empty(runner.Environment!); + Assert.Equal("1", Assert.Single(runner.Environment!).Value); + Assert.True(runner.Environment!.ContainsKey(WorkerLaunchGuard.ChildEnvironmentVariable)); Assert.Single(events, value => value.Type == expectedEvent); Assert.DoesNotContain(events, value => value.Type == "started"); } diff --git a/tests/Highbyte.Wrighty.UnitTests/Workers/WorkerInstanceRegistryTests.cs b/tests/Highbyte.Wrighty.UnitTests/Workers/WorkerInstanceRegistryTests.cs index 684fde8..0310fc6 100644 --- a/tests/Highbyte.Wrighty.UnitTests/Workers/WorkerInstanceRegistryTests.cs +++ b/tests/Highbyte.Wrighty.UnitTests/Workers/WorkerInstanceRegistryTests.cs @@ -15,6 +15,22 @@ public sealed class WorkerInstanceRegistryTests : IDisposable $"wrighty-config-{Guid.NewGuid():N}", ".wrighty.json"); + [Theory] + [InlineData("../outside")] + [InlineData("..\\outside")] + [InlineData("/outside")] + [InlineData("C:outside")] + [InlineData("")] + public async Task Control_rejects_run_ids_that_are_paths(string runId) + { + var registry = new JsonWorkerInstanceRegistry(new CachePaths(directory)); + var result = await registry.RequestStopAsync(configPath, + new(runId, Environment.ProcessId, "start", WorkerHostKind.CliProcess), + WorkerStopMode.Drain, CancellationToken.None); + Assert.False(result.Accepted); + Assert.Equal("WORKER_IDENTITY_INVALID", result.Code); + } + [Fact] public async Task Registration_is_listed_updated_and_removed_on_clean_exit() { From d524728e34c88310e185de501dc5797773faa8fc Mon Sep 17 00:00:00 2001 From: Highbyte Date: Wed, 16 Sep 2026 14:32:02 +0200 Subject: [PATCH 2/2] Use concrete environment dictionaries in worker test helpers --- .../Workers/LocalWorkerStateTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Highbyte.Wrighty.UnitTests/Workers/LocalWorkerStateTests.cs b/tests/Highbyte.Wrighty.UnitTests/Workers/LocalWorkerStateTests.cs index cecaf8a..ea5a273 100644 --- a/tests/Highbyte.Wrighty.UnitTests/Workers/LocalWorkerStateTests.cs +++ b/tests/Highbyte.Wrighty.UnitTests/Workers/LocalWorkerStateTests.cs @@ -4751,7 +4751,7 @@ public async Task RunAsync( private sealed class NeedsClarificationRunner : IAgentProcessRunner { public int Calls { get; private set; } - public IReadOnlyDictionary? Environment { get; private set; } + public Dictionary? Environment { get; private set; } public async Task RunAsync( AgentInvocation invocation, @@ -4786,7 +4786,7 @@ public async Task RunAsync( private sealed class InvalidAssessmentRunner(bool timesOut) : IAgentProcessRunner { public int Calls { get; private set; } - public IReadOnlyDictionary? Environment { get; private set; } + public Dictionary? Environment { get; private set; } public async Task RunAsync( AgentInvocation invocation,